mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-15 22:28:10 +08:00
Replace the dev server's wasm-pack, cargo-watch, and concurrently tools with custom cargo-run tooling (#4254)
Replace wasm-pack, cargo-watch and concurrently with custom build tooling Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
@@ -9,3 +9,5 @@ default-run = "cargo-run"
|
||||
|
||||
[dependencies]
|
||||
thiserror = { workspace = true }
|
||||
semver = "1"
|
||||
duct = "1"
|
||||
|
||||
11
tools/cargo-run/internal/watch/Cargo.toml
Normal file
11
tools/cargo-run/internal/watch/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "cargo-run-internal-watch"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
cargo-run = { path = "../.." }
|
||||
notify = "8.2.0"
|
||||
notify-debouncer-full = "0.7.0"
|
||||
93
tools/cargo-run/internal/watch/src/main.rs
Normal file
93
tools/cargo-run/internal/watch/src/main.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
use cargo_run::Error;
|
||||
use cargo_run::cmd::prelude::*;
|
||||
use notify::RecursiveMode;
|
||||
use notify::event::{EventKind, ModifyKind};
|
||||
use notify_debouncer_full::{Debouncer, RecommendedCache, new_debouncer, notify::RecommendedWatcher};
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::process::ExitCode;
|
||||
use std::time::Duration;
|
||||
|
||||
const EXCLUDED_DIRECTORIES: &[&str] = &["target", ".git", "frontend/node_modules", "frontend/dist", "frontend/wrapper/pkg"];
|
||||
const INCLUDED_EXTENSIONS: &[&str] = &["rs"];
|
||||
|
||||
const DEBOUNCE: Duration = Duration::from_millis(500);
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let release = std::env::args().nth(1).as_deref() == Some("release");
|
||||
|
||||
let _guard = match watch(cargo_run::frontend::build_wasm_steps(release, false)) {
|
||||
Ok(guard) => guard,
|
||||
Err(e) => {
|
||||
eprintln!("Error setting up file watcher: {e}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
println!("Watching for changes...");
|
||||
loop {
|
||||
std::thread::park();
|
||||
}
|
||||
}
|
||||
|
||||
pub struct WatchGuard {
|
||||
_debouncer: Debouncer<RecommendedWatcher, RecommendedCache>,
|
||||
}
|
||||
|
||||
pub fn watch(steps: impl IntoIterator<Item = Expression>) -> Result<WatchGuard, Error> {
|
||||
let steps: Vec<Expression> = steps.into_iter().collect();
|
||||
let root = std::env::current_dir()
|
||||
.and_then(|p| p.canonicalize())
|
||||
.map_err(|e| Error::Io(e, "Failed to resolve root for file watcher".into()))?;
|
||||
let root_clone = root.clone();
|
||||
|
||||
let mut current: Option<Sequence> = None;
|
||||
|
||||
let mut debouncer = new_debouncer(DEBOUNCE, None, move |result: notify_debouncer_full::DebounceEventResult| match result {
|
||||
Ok(events) => {
|
||||
let mut seen = HashSet::new();
|
||||
let mut triggered = false;
|
||||
for ev in events {
|
||||
if !matches!(
|
||||
&ev.event.kind,
|
||||
EventKind::Create(_) | EventKind::Remove(_) | EventKind::Modify(ModifyKind::Data(_) | ModifyKind::Name(_) | ModifyKind::Any)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
for path in &ev.event.paths {
|
||||
if is_excluded(path, &root) {
|
||||
continue;
|
||||
}
|
||||
if seen.insert(path.clone()) {
|
||||
triggered = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if triggered {
|
||||
if let Some(c) = current.take() {
|
||||
c.kill();
|
||||
}
|
||||
current = Some(sequence(steps.clone()));
|
||||
}
|
||||
}
|
||||
Err(errors) => {
|
||||
for e in errors {
|
||||
eprintln!("watch: {e}");
|
||||
}
|
||||
}
|
||||
})
|
||||
.map_err(|e| Error::Io(std::io::Error::other(e.to_string()), "file watcher".into()))?;
|
||||
|
||||
debouncer
|
||||
.watch(&root_clone, RecursiveMode::Recursive)
|
||||
.map_err(|e| Error::Io(std::io::Error::other(e.to_string()), "file watcher".into()))?;
|
||||
|
||||
Ok(WatchGuard { _debouncer: debouncer })
|
||||
}
|
||||
|
||||
fn is_excluded(path: &Path, root: &Path) -> bool {
|
||||
let rel = path.strip_prefix(root).unwrap_or(path);
|
||||
if EXCLUDED_DIRECTORIES.iter().any(|d| rel.starts_with(d)) {
|
||||
return true;
|
||||
}
|
||||
!path.extension().and_then(|e| e.to_str()).is_some_and(|e| INCLUDED_EXTENSIONS.contains(&e))
|
||||
}
|
||||
334
tools/cargo-run/src/cmd.rs
Normal file
334
tools/cargo-run/src/cmd.rs
Normal file
@@ -0,0 +1,334 @@
|
||||
use crate::Error;
|
||||
use duct::{Handle, ReaderHandle};
|
||||
use std::ffi::OsString;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
pub use duct::{Expression, cmd};
|
||||
|
||||
pub mod prelude {
|
||||
pub use super::{Expression, ExpressionExt, Sequence, TerminalColor, cmd, sequence, supervise, utils};
|
||||
}
|
||||
|
||||
pub trait ExpressionExt {
|
||||
fn arg(self, arg: impl Into<OsString>) -> Self;
|
||||
fn args<I, S>(self, args: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: Into<OsString>;
|
||||
fn arg_if(self, cond: bool, arg: impl Into<OsString>) -> Self;
|
||||
fn args_if<I, S>(self, cond: bool, args: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: Into<OsString>;
|
||||
fn run(self) -> Result<(), Error>;
|
||||
fn read(self) -> Result<String, Error>;
|
||||
fn output_unchecked(self) -> Result<std::process::Output, Error>;
|
||||
}
|
||||
|
||||
impl ExpressionExt for Expression {
|
||||
fn arg(self, arg: impl Into<OsString>) -> Self {
|
||||
let arg = arg.into();
|
||||
self.before_spawn(move |c| {
|
||||
c.arg(&arg);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn args<I, S>(self, args: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: Into<OsString>,
|
||||
{
|
||||
let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
|
||||
self.before_spawn(move |c| {
|
||||
c.args(&args);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn arg_if(self, cond: bool, arg: impl Into<OsString>) -> Self {
|
||||
if cond { self.arg(arg) } else { self }
|
||||
}
|
||||
|
||||
fn args_if<I, S>(self, cond: bool, args: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: Into<OsString>,
|
||||
{
|
||||
if cond { self.args(args) } else { self }
|
||||
}
|
||||
|
||||
fn run(self) -> Result<(), Error> {
|
||||
Expression::run(&self).map_err(Error::Command)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read(self) -> Result<String, Error> {
|
||||
Expression::read(&self).map_err(Error::Command)
|
||||
}
|
||||
|
||||
fn output_unchecked(self) -> Result<std::process::Output, Error> {
|
||||
let e = self.unchecked().stdout_capture().stderr_capture();
|
||||
Expression::run(&e).map_err(Error::Command)
|
||||
}
|
||||
}
|
||||
|
||||
pub enum TerminalColor {
|
||||
Black,
|
||||
Red,
|
||||
Green,
|
||||
Yellow,
|
||||
Blue,
|
||||
Magenta,
|
||||
Cyan,
|
||||
White,
|
||||
Reset,
|
||||
}
|
||||
impl TerminalColor {
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Black => "\x1b[30m",
|
||||
Self::Red => "\x1b[31m",
|
||||
Self::Green => "\x1b[32m",
|
||||
Self::Yellow => "\x1b[33m",
|
||||
Self::Blue => "\x1b[34m",
|
||||
Self::Magenta => "\x1b[35m",
|
||||
Self::Cyan => "\x1b[36m",
|
||||
Self::White => "\x1b[37m",
|
||||
Self::Reset => "\x1b[0m",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sequence<I: IntoIterator<Item = Expression>>(expressions: I) -> Sequence {
|
||||
let expressions: Vec<Expression> = expressions.into_iter().collect();
|
||||
let current: Arc<Mutex<Option<Arc<Handle>>>> = Arc::new(Mutex::new(None));
|
||||
let killed = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let worker_current = Arc::clone(¤t);
|
||||
let worker_killed = Arc::clone(&killed);
|
||||
let worker = std::thread::spawn(move || {
|
||||
for expr in expressions {
|
||||
if worker_killed.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
let handle = match expr.start() {
|
||||
Ok(h) => Arc::new(h),
|
||||
Err(e) => {
|
||||
eprintln!("sequence: failed to start step: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
{
|
||||
let mut slot = worker_current.lock().unwrap();
|
||||
if worker_killed.load(Ordering::SeqCst) {
|
||||
let _ = handle.kill();
|
||||
return;
|
||||
}
|
||||
*slot = Some(Arc::clone(&handle));
|
||||
}
|
||||
let result = handle.wait().map(|_| ());
|
||||
worker_current.lock().unwrap().take();
|
||||
if worker_killed.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = result {
|
||||
eprintln!("sequence: step failed: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Sequence {
|
||||
current,
|
||||
killed,
|
||||
worker: Some(worker),
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Sequence {
|
||||
current: Arc<Mutex<Option<Arc<Handle>>>>,
|
||||
killed: Arc<AtomicBool>,
|
||||
worker: Option<JoinHandle<()>>,
|
||||
}
|
||||
impl Sequence {
|
||||
pub fn kill(&self) {
|
||||
let slot = self.current.lock().unwrap();
|
||||
self.killed.store(true, Ordering::SeqCst);
|
||||
if let Some(handle) = slot.as_ref() {
|
||||
let _ = handle.kill();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn wait(&mut self) {
|
||||
if let Some(w) = self.worker.take() {
|
||||
let _ = w.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Drop for Sequence {
|
||||
fn drop(&mut self) {
|
||||
self.kill();
|
||||
self.wait();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn supervise<I, S>(children: I) -> Result<(), Error>
|
||||
where
|
||||
I: IntoIterator<Item = (S, TerminalColor, Expression)>,
|
||||
S: Into<String>,
|
||||
{
|
||||
use std::io::{BufRead, BufReader, IsTerminal, Write};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
windows_ctrl_c::install();
|
||||
|
||||
let mut handles: Vec<(String, TerminalColor, Arc<ReaderHandle>)> = Vec::new();
|
||||
for (label, color, expr) in children {
|
||||
#[cfg(target_os = "windows")]
|
||||
let expr = expr.before_spawn(|cmd| {
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
|
||||
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP);
|
||||
Ok(())
|
||||
});
|
||||
|
||||
let handle = expr.stderr_to_stdout().reader().map_err(Error::Command)?;
|
||||
handles.push((label.into(), color, Arc::new(handle)));
|
||||
}
|
||||
|
||||
let mut io_threads = Vec::new();
|
||||
for (label, color, handle) in handles.iter() {
|
||||
let prefix = if std::io::stdout().is_terminal() {
|
||||
format!("{color}[{label}]{reset} ", color = color.as_str(), reset = TerminalColor::Reset.as_str())
|
||||
} else {
|
||||
format!("[{label}] ")
|
||||
};
|
||||
let handle = handle.clone();
|
||||
io_threads.push(thread::spawn(move || {
|
||||
let reader = BufReader::new(&*handle);
|
||||
for line in reader.lines().map_while(Result::ok) {
|
||||
let out = std::io::stdout();
|
||||
let mut out = out.lock();
|
||||
let _ = writeln!(out, "{prefix}{line}");
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
let mut reason: Option<(String, std::process::ExitStatus)> = None;
|
||||
|
||||
loop {
|
||||
#[cfg(target_os = "windows")]
|
||||
if windows_ctrl_c::interrupted() {
|
||||
break;
|
||||
}
|
||||
|
||||
for (label, _color, handle) in handles.iter() {
|
||||
if let Ok(Some(output)) = handle.try_wait() {
|
||||
reason = Some((label.clone(), output.status));
|
||||
break;
|
||||
}
|
||||
}
|
||||
if reason.is_some() {
|
||||
break;
|
||||
}
|
||||
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
|
||||
for (_, _color, handle) in handles.iter() {
|
||||
if matches!(handle.try_wait(), Ok(Some(_))) {
|
||||
continue;
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
for pid in handle.pids() {
|
||||
let _ = std::process::Command::new("taskkill")
|
||||
.args(["/T", "/F", "/PID", &pid.to_string()])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status();
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = handle.kill();
|
||||
}
|
||||
}
|
||||
|
||||
for t in io_threads {
|
||||
let _ = t.join();
|
||||
}
|
||||
|
||||
match reason {
|
||||
Some((label, status)) if !status.success() => Err(Error::Command(std::io::Error::other(format!("supervised child '{label}' exited with status {status}")))),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
pub mod utils {
|
||||
use super::*;
|
||||
|
||||
pub fn internal(name: &str) -> Expression {
|
||||
let package = format!("cargo-run-internal-{name}");
|
||||
cmd!("cargo", "run", "-p", package, "--")
|
||||
}
|
||||
|
||||
pub fn npm<I, S>(args: I) -> Expression
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: Into<OsString>,
|
||||
{
|
||||
let prog = if cfg!(target_os = "windows") { "npm.cmd" } else { "npm" };
|
||||
cmd(prog, args)
|
||||
}
|
||||
|
||||
pub fn node_bin(rel: &str) -> Expression {
|
||||
cmd!("node", format!("node_modules/{rel}"))
|
||||
}
|
||||
|
||||
pub fn open_url(url: &str) -> Result<(), Error> {
|
||||
#[cfg(target_os = "windows")]
|
||||
let expr = cmd!("cmd", "/c", "start", url);
|
||||
#[cfg(target_os = "macos")]
|
||||
let expr = cmd!("open", url);
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
let expr = cmd!("xdg-open", url);
|
||||
expr.run()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows_ctrl_c {
|
||||
use std::sync::Once;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
static INTERRUPTED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
#[link(name = "kernel32")]
|
||||
unsafe extern "system" {
|
||||
fn SetConsoleCtrlHandler(handler: Option<unsafe extern "system" fn(u32) -> i32>, add: i32) -> i32;
|
||||
}
|
||||
|
||||
unsafe extern "system" fn handler(_ctrl_type: u32) -> i32 {
|
||||
INTERRUPTED.store(true, Ordering::SeqCst);
|
||||
1 // Report the event as handled
|
||||
}
|
||||
|
||||
pub fn install() {
|
||||
INTERRUPTED.store(false, Ordering::SeqCst);
|
||||
static REGISTER: Once = Once::new();
|
||||
REGISTER.call_once(|| unsafe {
|
||||
SetConsoleCtrlHandler(Some(handler), 1);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn interrupted() -> bool {
|
||||
INTERRUPTED.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
62
tools/cargo-run/src/frontend.rs
Normal file
62
tools/cargo-run/src/frontend.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
use crate::cmd::prelude::*;
|
||||
use crate::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
const WRAPPER_CRATE: &str = "graphite-wasm-wrapper";
|
||||
const WASM_TARGET: &str = "wasm32-unknown-unknown";
|
||||
const OUT_NAME: &str = "graphite_wasm_wrapper";
|
||||
|
||||
pub fn frontend_dir() -> PathBuf {
|
||||
workspace_dir().join("frontend")
|
||||
}
|
||||
|
||||
pub fn setup() -> Result<(), Error> {
|
||||
utils::npm(["run", "setup"]).dir(frontend_dir()).run()
|
||||
}
|
||||
|
||||
pub fn build_wasm(release: bool, native: bool) -> Result<(), Error> {
|
||||
sequence(build_wasm_steps(release, native)).wait();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn build_wasm_steps(release: bool, native: bool) -> Vec<Expression> {
|
||||
let wasm_artifact = target_dir().join(WASM_TARGET).join(if release { "release" } else { "debug" }).join(format!("{OUT_NAME}.wasm"));
|
||||
let pkg_dir = frontend_dir().join("wrapper").join("pkg");
|
||||
|
||||
let mut steps = vec![
|
||||
cmd!("cargo", "build", "--lib", "--package", WRAPPER_CRATE, "--target", WASM_TARGET)
|
||||
.arg_if(release, "--release")
|
||||
.args_if(native, ["--no-default-features", "--features", "native"])
|
||||
.dir(workspace_dir()),
|
||||
cmd!("wasm-bindgen", "--target", "web", "--out-name", OUT_NAME, "--out-dir", &pkg_dir, &wasm_artifact)
|
||||
.arg_if(release, "--no-demangle")
|
||||
.arg_if(!release, "--debug"),
|
||||
];
|
||||
|
||||
if release {
|
||||
let wasm_file = pkg_dir.join(format!("{OUT_NAME}_bg.wasm"));
|
||||
steps.push(cmd!("wasm-opt", "-Os", "-g", &wasm_file, "-o", &wasm_file));
|
||||
}
|
||||
|
||||
steps
|
||||
}
|
||||
|
||||
pub fn vite() -> Expression {
|
||||
utils::node_bin("vite/bin/vite.js").dir(frontend_dir()).env("CARGO_TARGET_DIR", target_dir())
|
||||
}
|
||||
|
||||
pub fn watch(release: bool) -> Result<(), Error> {
|
||||
use crate::cmd::prelude::*;
|
||||
|
||||
setup()?;
|
||||
build_wasm(release, false)?;
|
||||
|
||||
let vite = vite().env("FORCE_COLOR", "1").env("CARGO_TERM_COLOR", "always");
|
||||
let rust = utils::internal("watch")
|
||||
.arg_if(release, "release")
|
||||
.dir(workspace_dir())
|
||||
.env("CARGO_TARGET_DIR", target_dir())
|
||||
.env("CARGO_TERM_COLOR", "always");
|
||||
|
||||
supervise([("VITE", TerminalColor::Magenta, vite), ("RUST", TerminalColor::Blue, rust)])
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::path::PathBuf;
|
||||
use std::process;
|
||||
|
||||
pub mod cmd;
|
||||
pub mod frontend;
|
||||
pub mod requirements;
|
||||
|
||||
pub enum Action {
|
||||
@@ -65,70 +66,25 @@ impl Task {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(command: &str) -> Result<(), Error> {
|
||||
run_from(command, None)
|
||||
pub fn workspace_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_WORKSPACE_DIR"))
|
||||
}
|
||||
|
||||
pub fn npm_run_in_frontend_dir(args: &str) -> Result<(), Error> {
|
||||
let workspace_dir = std::path::PathBuf::from(env!("CARGO_WORKSPACE_DIR"));
|
||||
let frontend_dir = workspace_dir.join("frontend");
|
||||
let npm = if cfg!(target_os = "windows") { "npm.cmd" } else { "npm" };
|
||||
run_from(&format!("{npm} run {args}"), Some(&frontend_dir))
|
||||
}
|
||||
|
||||
pub fn open_url(url: &str) -> Result<(), Error> {
|
||||
#[cfg(target_os = "windows")]
|
||||
let mut cmd = process::Command::new("cmd");
|
||||
#[cfg(target_os = "windows")]
|
||||
cmd.args(["/c", "start", url]);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
let mut cmd = process::Command::new("open");
|
||||
#[cfg(target_os = "macos")]
|
||||
cmd.arg(url);
|
||||
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
let mut cmd = process::Command::new("xdg-open");
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
cmd.arg(url);
|
||||
|
||||
let command_str = format!("{:?}", cmd);
|
||||
let exit_code = cmd
|
||||
.spawn()
|
||||
.map_err(|e| Error::Io(e, format!("Failed to spawn command '{command_str}'")))?
|
||||
.wait()
|
||||
.map_err(|e| Error::Io(e, format!("Failed to wait for command '{command_str}'")))?;
|
||||
if !exit_code.success() {
|
||||
return Err(Error::Command(command_str, exit_code));
|
||||
pub fn target_dir() -> PathBuf {
|
||||
match std::env::var_os("CARGO_TARGET_DIR") {
|
||||
Some(custom_dir) => workspace_dir().join(custom_dir),
|
||||
None => workspace_dir().join("target"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_from(command: &str, dir: Option<&PathBuf>) -> Result<(), Error> {
|
||||
let command = command.split_whitespace().collect::<Vec<_>>();
|
||||
let mut cmd = process::Command::new(command[0]);
|
||||
if command.len() > 1 {
|
||||
cmd.args(&command[1..]);
|
||||
}
|
||||
if let Some(dir) = dir {
|
||||
cmd.current_dir(dir);
|
||||
}
|
||||
let exit_code = cmd
|
||||
.spawn()
|
||||
.map_err(|e| Error::Io(e, format!("Failed to spawn command '{}'", command.join(" "))))?
|
||||
.wait()
|
||||
.map_err(|e| Error::Io(e, format!("Failed to wait for command '{}'", command.join(" "))))?;
|
||||
if !exit_code.success() {
|
||||
return Err(Error::Command(command.join(" "), exit_code));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("One or more requirements were not met")]
|
||||
RequirementsNotMet,
|
||||
|
||||
#[error("{1}: {0}")]
|
||||
Io(#[source] std::io::Error, String),
|
||||
|
||||
#[error("Command '{0}' exited with code {1}")]
|
||||
Command(String, process::ExitStatus),
|
||||
/// Used by the duct-based `cmd` module; folds in `Command` once call sites are migrated.
|
||||
#[error("{0}")]
|
||||
Command(#[source] std::io::Error),
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::process::ExitCode;
|
||||
|
||||
use cargo_run::cmd::prelude::*;
|
||||
use cargo_run::*;
|
||||
|
||||
fn usage() {
|
||||
@@ -33,6 +34,8 @@ fn usage() {
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
prepend_path();
|
||||
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let args: Vec<&str> = args.iter().skip(1).map(String::as_str).collect();
|
||||
|
||||
@@ -67,9 +70,9 @@ fn explore_usage() {
|
||||
fn run_task(task: &Task) -> Result<(), Error> {
|
||||
if let Action::Explore(tool) = &task.action {
|
||||
match tool.as_deref() {
|
||||
Some("bisect") => return open_url("https://graphite.art/volunteer/guide/codebase-overview/debugging-tips/#build-bisect-tool"),
|
||||
Some("deps") => return open_url("https://graphite.art/volunteer/guide/codebase-overview/#crate-dependency-graph"),
|
||||
Some("editor") => return open_url("https://graphite.art/volunteer/guide/codebase-overview/editor-structure/#editor-outline"),
|
||||
Some("bisect") => return utils::open_url("https://graphite.art/volunteer/guide/codebase-overview/debugging-tips/#build-bisect-tool"),
|
||||
Some("deps") => return utils::open_url("https://graphite.art/volunteer/guide/codebase-overview/#crate-dependency-graph"),
|
||||
Some("editor") => return utils::open_url("https://graphite.art/volunteer/guide/codebase-overview/editor-structure/#editor-outline"),
|
||||
None | Some("--help") => {
|
||||
explore_usage();
|
||||
return Ok(());
|
||||
@@ -85,11 +88,19 @@ fn run_task(task: &Task) -> Result<(), Error> {
|
||||
requirements::check(task)?;
|
||||
|
||||
match (&task.action, &task.target, &task.profile) {
|
||||
(Action::Run, Target::Web, Profile::Debug | Profile::Default) => npm_run_in_frontend_dir("start")?,
|
||||
(Action::Run, Target::Web, Profile::Release) => npm_run_in_frontend_dir("production")?,
|
||||
(Action::Run, Target::Web, Profile::Debug | Profile::Default) => frontend::watch(false)?,
|
||||
(Action::Run, Target::Web, Profile::Release) => frontend::watch(true)?,
|
||||
|
||||
(Action::Build, Target::Web, Profile::Debug) => npm_run_in_frontend_dir("build-dev")?,
|
||||
(Action::Build, Target::Web, Profile::Release | Profile::Default) => npm_run_in_frontend_dir("build")?,
|
||||
(Action::Build, Target::Web, Profile::Debug) => {
|
||||
frontend::setup()?;
|
||||
frontend::build_wasm(false, false)?;
|
||||
frontend::vite().args(["build", "--mode", "dev"]).run()?;
|
||||
}
|
||||
(Action::Build, Target::Web, Profile::Release | Profile::Default) => {
|
||||
frontend::setup()?;
|
||||
frontend::build_wasm(true, false)?;
|
||||
frontend::vite().args(["build"]).run()?;
|
||||
}
|
||||
|
||||
(action, Target::Desktop, mut profile) => {
|
||||
if matches!(profile, Profile::Default) {
|
||||
@@ -100,34 +111,41 @@ fn run_task(task: &Task) -> Result<(), Error> {
|
||||
}
|
||||
}
|
||||
|
||||
if matches!(profile, Profile::Release) {
|
||||
npm_run_in_frontend_dir("build-native")?;
|
||||
} else {
|
||||
npm_run_in_frontend_dir("build-native-dev")?;
|
||||
};
|
||||
// Build the editor's Wasm module with the `native` feature, then bundle the frontend with Vite
|
||||
frontend::setup()?;
|
||||
frontend::build_wasm(matches!(profile, Profile::Release), true)?;
|
||||
frontend::vite().args(["build", "--mode", "native"]).run()?;
|
||||
|
||||
run("cargo run -p third-party-licenses --features desktop")?;
|
||||
cmd!("cargo", "run", "-p", "third-party-licenses", "--features", "desktop").run()?;
|
||||
|
||||
let cargo_profile = match profile {
|
||||
Profile::Debug => "dev",
|
||||
Profile::Release => "release",
|
||||
Profile::Default => unreachable!(),
|
||||
};
|
||||
let args = if matches!(action, Action::Run) {
|
||||
format!(" -- open {}", task.args.join(" "))
|
||||
} else {
|
||||
"".to_string()
|
||||
};
|
||||
run(&format!("cargo run --profile {cargo_profile} -p graphite-desktop-bundle{args}"))?;
|
||||
cmd!("cargo", "run", "--profile", cargo_profile, "-p", "graphite-desktop-bundle")
|
||||
.args_if(matches!(action, Action::Run), ["--", "open"].into_iter().chain(task.args.iter().map(String::as_str)))
|
||||
.run()?;
|
||||
}
|
||||
|
||||
(Action::Run, Target::Cli, Profile::Debug | Profile::Default) => run(&format!("cargo run -p graphene-cli -- {}", task.args.join(" ")))?,
|
||||
(Action::Run, Target::Cli, Profile::Release) => run(&format!("cargo run -r -p graphene-cli -- {}", task.args.join(" ")))?,
|
||||
(Action::Run, Target::Cli, Profile::Debug | Profile::Default) => cmd!("cargo", "run", "-p", "graphene-cli", "--").args(&task.args).run()?,
|
||||
(Action::Run, Target::Cli, Profile::Release) => cmd!("cargo", "run", "-r", "-p", "graphene-cli", "--").args(&task.args).run()?,
|
||||
|
||||
(Action::Build, Target::Cli, Profile::Debug) => run("cargo build -p graphene-cli")?,
|
||||
(Action::Build, Target::Cli, Profile::Release | Profile::Default) => run("cargo build -r -p graphene-cli")?,
|
||||
(Action::Build, Target::Cli, Profile::Debug) => cmd!("cargo", "build", "-p", "graphene-cli").run()?,
|
||||
(Action::Build, Target::Cli, Profile::Release | Profile::Default) => cmd!("cargo", "build", "-r", "-p", "graphene-cli").run()?,
|
||||
|
||||
(Action::Explore(_), _, _) => unreachable!(),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prepend_path() {
|
||||
let mut paths = vec![target_dir().join("cargo-run").join("bin")];
|
||||
if let Some(path) = std::env::var_os("PATH") {
|
||||
paths.extend(std::env::split_paths(&path));
|
||||
}
|
||||
if let Ok(joined) = std::env::join_paths(paths) {
|
||||
// Safety: this runs before any other threads are spawned
|
||||
unsafe { std::env::set_var("PATH", joined) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
use semver::{Version, VersionReq};
|
||||
use std::io::IsTerminal;
|
||||
use std::process::Command;
|
||||
|
||||
use crate::cmd::prelude::*;
|
||||
use crate::*;
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
struct Requirement {
|
||||
pub struct Requirement {
|
||||
command: &'static str,
|
||||
args: &'static [&'static str],
|
||||
name: &'static str,
|
||||
check: Check,
|
||||
version: Option<&'static str>,
|
||||
install: Option<&'static str>,
|
||||
install: InstallAction,
|
||||
skip: Option<&'static dyn Fn(&Task) -> bool>,
|
||||
}
|
||||
|
||||
@@ -22,43 +24,43 @@ fn requirements(task: &Task) -> Vec<Requirement> {
|
||||
..Default::default()
|
||||
},
|
||||
Requirement {
|
||||
command: "cargo-about",
|
||||
args: &["--version"],
|
||||
name: "cargo-about",
|
||||
install: Some("cargo install cargo-about"),
|
||||
command: "rustc",
|
||||
args: &["--print", "target-libdir", "--target", "wasm32-unknown-unknown"],
|
||||
check: Check::Matches(&|out| std::path::Path::new(out.trim()).is_dir()),
|
||||
name: "Rust Wasm Toolchain",
|
||||
install: "rustup target add wasm32-unknown-unknown".into(),
|
||||
skip: Some(&|task| matches!(task.target, Target::Cli)),
|
||||
..Default::default()
|
||||
},
|
||||
Requirement {
|
||||
command: "cargo-watch",
|
||||
command: "cargo-about",
|
||||
args: &["--version"],
|
||||
name: "cargo-watch",
|
||||
install: Some("cargo install cargo-watch"),
|
||||
name: "Cargo About",
|
||||
install: "cargo install cargo-about".into(),
|
||||
skip: Some(&|task| matches!(task.target, Target::Cli)),
|
||||
..Default::default()
|
||||
},
|
||||
Requirement {
|
||||
command: "wasm-opt",
|
||||
args: &["--version"],
|
||||
name: "Wasm Opt",
|
||||
version: Some(">=130"),
|
||||
skip: Some(&|task| {
|
||||
!matches!(
|
||||
task,
|
||||
Task {
|
||||
target: Target::Web,
|
||||
action: Action::Run,
|
||||
..
|
||||
matches!(task.target, Target::Cli)
|
||||
|| match task.profile {
|
||||
Profile::Debug => true,
|
||||
Profile::Release => false,
|
||||
Profile::Default => matches!(task.action, Action::Run),
|
||||
}
|
||||
)
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
Requirement {
|
||||
command: "wasm-bindgen",
|
||||
args: &["--version"],
|
||||
name: "wasm-bindgen-cli",
|
||||
version: Some("0.2.121"),
|
||||
install: Some("cargo install -f wasm-bindgen-cli@0.2.121"),
|
||||
skip: Some(&|task| matches!(task.target, Target::Cli)),
|
||||
},
|
||||
Requirement {
|
||||
command: "wasm-pack",
|
||||
args: &["--version"],
|
||||
name: "wasm-pack",
|
||||
install: Some("cargo install wasm-pack"),
|
||||
name: "Wasm Bindgen",
|
||||
version: Some("=0.2.121"),
|
||||
install: "cargo install -f wasm-bindgen-cli@0.2.121".into(),
|
||||
skip: Some(&|task| matches!(task.target, Target::Cli)),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -98,24 +100,44 @@ pub fn check(task: &Task) -> Result<(), Error> {
|
||||
let mut failures: Vec<String> = Vec::new();
|
||||
|
||||
for dep in requirements(task) {
|
||||
match Command::new(dep.command).args(dep.args).output() {
|
||||
match cmd(dep.command, dep.args.iter().copied()).output_unchecked() {
|
||||
Ok(output) if output.status.success() => {
|
||||
let version = String::from_utf8_lossy(&output.stdout);
|
||||
let version = version.lines().next().unwrap_or_default().trim();
|
||||
|
||||
if let Some(expected) = dep.version {
|
||||
if version.contains(expected) {
|
||||
eprintln!(" ✓ {} ({})", dep.name, version);
|
||||
} else {
|
||||
eprintln!(" ✗ {} (found {}, expected {})", dep.name, version, expected);
|
||||
if dep.install.is_some() {
|
||||
installable.push(dep);
|
||||
} else {
|
||||
failures.push(format!("{}: version mismatch (found {version}, expected {expected})", dep.name));
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
match dep.check {
|
||||
Check::PrintsVersion => {
|
||||
let line = stdout.lines().next().unwrap_or_default().trim();
|
||||
match dep.version {
|
||||
None => eprintln!(" ✓ {} ({line})", dep.name),
|
||||
Some(req_str) => {
|
||||
let req = VersionReq::parse(req_str).expect("invalid semver requirement");
|
||||
match extract_version(line) {
|
||||
Some(version) if req.matches(&version) => eprintln!(" ✓ {} ({version})", dep.name),
|
||||
Some(version) => {
|
||||
eprintln!(" ✗ {} (found {version}, requires {req_str})", dep.name);
|
||||
if dep.install.is_some() {
|
||||
installable.push(dep);
|
||||
} else {
|
||||
failures.push(format!("{}: version mismatch (found {version}, requires {req_str})", dep.name));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
eprintln!(" ✗ {} (could not parse version from '{line}')", dep.name);
|
||||
failures.push(format!("{}: could not parse version from '{line}'", dep.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Check::Matches(check) => {
|
||||
if !check(stdout.to_string()) {
|
||||
eprintln!(" ✗ {} - check failed", dep.name);
|
||||
if dep.install.is_some() {
|
||||
installable.push(dep);
|
||||
}
|
||||
} else {
|
||||
eprintln!(" ✓ {}", dep.name);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
eprintln!(" ✓ {} ({})", dep.name, version);
|
||||
}
|
||||
}
|
||||
Ok(output) => {
|
||||
@@ -147,7 +169,7 @@ pub fn check(task: &Task) -> Result<(), Error> {
|
||||
let total = installable.len() + failures.len();
|
||||
eprintln!("{total} requirement{} not met:", if total > 1 { "s" } else { "" });
|
||||
for dep in &installable {
|
||||
eprintln!(" - {}: {}", dep.name, dep.install.unwrap());
|
||||
eprintln!(" - {}", dep.name);
|
||||
}
|
||||
for msg in &failures {
|
||||
eprintln!(" - {msg}");
|
||||
@@ -158,43 +180,149 @@ pub fn check(task: &Task) -> Result<(), Error> {
|
||||
eprintln!("See: https://graphite.art/volunteer/guide/project-setup/");
|
||||
}
|
||||
|
||||
let is_interactive = std::io::stdout().is_terminal() && std::io::stderr().is_terminal() && std::io::stdin().is_terminal();
|
||||
|
||||
// Don't prompt for automatic installation if we're not interactive session
|
||||
if !std::io::stdout().is_terminal() || !std::io::stderr().is_terminal() || !std::io::stdin().is_terminal() {
|
||||
if !is_interactive {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if installable.is_empty() {
|
||||
if installable.is_empty() && failures.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
eprintln!();
|
||||
eprintln!("The following can be installed automatically:");
|
||||
for dep in &installable {
|
||||
eprintln!(" {}", dep.install.unwrap());
|
||||
}
|
||||
eprintln!();
|
||||
if installable.len() == 1 {
|
||||
eprint!("Install it now? [Y/n] ");
|
||||
} else {
|
||||
eprint!("Install them now? [Y/n] ");
|
||||
}
|
||||
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input).map_err(|e| Error::Io(e, "Failed to read from stdin".into()))?;
|
||||
let input = input.trim();
|
||||
|
||||
if input.is_empty() || input.eq_ignore_ascii_case("y") || input.eq_ignore_ascii_case("yes") {
|
||||
if !installable.is_empty() {
|
||||
eprintln!();
|
||||
eprintln!("The following can be installed automatically:");
|
||||
for dep in &installable {
|
||||
let parts: Vec<&str> = dep.install.unwrap().split_whitespace().collect();
|
||||
eprintln!("Running: {}...", dep.install.unwrap());
|
||||
let status = Command::new(parts[0])
|
||||
.args(&parts[1..])
|
||||
.status()
|
||||
.map_err(|e| Error::Io(e, format!("Failed to run '{}'", dep.install.unwrap())))?;
|
||||
if !status.success() {
|
||||
eprintln!("Failed to install {}", dep.name);
|
||||
eprintln!(" {}: {}", dep.name, dep.install.description());
|
||||
}
|
||||
eprintln!();
|
||||
if installable.len() == 1 {
|
||||
eprint!("Install it now? [Y/n] ");
|
||||
} else {
|
||||
eprint!("Install them now? [Y/n] ");
|
||||
}
|
||||
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input).map_err(|e| Error::Io(e, "Failed to read from stdin".into()))?;
|
||||
let input = input.trim();
|
||||
|
||||
if input.eq_ignore_ascii_case("y") || input.eq_ignore_ascii_case("yes") {
|
||||
let mut successfully_installed = Vec::new();
|
||||
|
||||
for (i, dep) in installable.iter().enumerate() {
|
||||
eprintln!("Installing {}...", dep.name);
|
||||
match &dep.install {
|
||||
InstallAction::Command(install_cmd) => {
|
||||
let parts: Vec<&str> = install_cmd.split_whitespace().collect();
|
||||
let expr = cmd(parts[0], parts[1..].iter().copied()).unchecked();
|
||||
match Expression::run(&expr) {
|
||||
Ok(output) if output.status.success() => successfully_installed.push(i),
|
||||
Ok(_) => eprintln!("Failed to install {}", dep.name),
|
||||
Err(e) => return Err(Error::Command(e)),
|
||||
}
|
||||
}
|
||||
InstallAction::Function { function, .. } => {
|
||||
if let Err(e) = function() {
|
||||
eprintln!("{e}");
|
||||
eprintln!("Failed to install {}", dep.name);
|
||||
} else {
|
||||
successfully_installed.push(i);
|
||||
}
|
||||
}
|
||||
InstallAction::None => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
for i in successfully_installed.into_iter().rev() {
|
||||
installable.remove(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !failures.is_empty() {
|
||||
eprintln!();
|
||||
eprintln!("The following requirements must be resolved manually:");
|
||||
for msg in &failures {
|
||||
eprintln!(" - {msg}");
|
||||
}
|
||||
}
|
||||
|
||||
if (!installable.is_empty() || !failures.is_empty()) && is_interactive {
|
||||
eprintln!();
|
||||
eprintln!("Continue without resolving these requirements? [y/N]");
|
||||
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input).map_err(|e| Error::Io(e, "Failed to read from stdin".into()))?;
|
||||
let input = input.trim();
|
||||
|
||||
if input.eq_ignore_ascii_case("n") || input.eq_ignore_ascii_case("no") {
|
||||
return Err(Error::RequirementsNotMet);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_version(line: &str) -> Option<Version> {
|
||||
line.split_whitespace().find_map(|token| {
|
||||
let token = token.trim_start_matches('v').trim_end_matches(|c: char| !c.is_ascii_alphanumeric());
|
||||
if token.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if let Ok(version) = Version::parse(token) {
|
||||
return Some(version);
|
||||
}
|
||||
let (core, suffix) = match token.find(['-', '+']) {
|
||||
Some(i) => token.split_at(i),
|
||||
None => (token, ""),
|
||||
};
|
||||
let parts: Vec<&str> = core.split('.').collect();
|
||||
if parts.iter().any(|p| p.is_empty() || !p.chars().all(|c| c.is_ascii_digit())) {
|
||||
return None;
|
||||
}
|
||||
let major = parts[0];
|
||||
let minor = parts.get(1).copied().unwrap_or("0");
|
||||
let patch = parts.get(2).copied().unwrap_or("0");
|
||||
Version::parse(&format!("{major}.{minor}.{patch}{suffix}")).ok()
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
enum Check {
|
||||
#[default]
|
||||
PrintsVersion,
|
||||
Matches(&'static dyn Fn(String) -> bool),
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
enum InstallAction {
|
||||
#[default]
|
||||
None,
|
||||
Command(&'static str),
|
||||
#[expect(dead_code)] // TODO: Remove after followup pr landed
|
||||
Function {
|
||||
description: &'static str,
|
||||
function: &'static dyn Fn() -> Result<(), Error>,
|
||||
},
|
||||
}
|
||||
|
||||
impl InstallAction {
|
||||
fn is_some(&self) -> bool {
|
||||
!matches!(self, InstallAction::None)
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
match self {
|
||||
InstallAction::None => "",
|
||||
InstallAction::Command(cmd) => cmd,
|
||||
InstallAction::Function { description, .. } => description,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&'static str> for InstallAction {
|
||||
fn from(value: &'static str) -> Self {
|
||||
InstallAction::Command(value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ fn parse(parsed: Output) -> Vec<LicenseEntry> {
|
||||
|
||||
fn run() -> Result<Output, Error> {
|
||||
let output = Command::new("cargo")
|
||||
.args(["about", "generate", "--format", "json", "--frozen"])
|
||||
.args(["about", "generate", "--format", "json", "--locked"])
|
||||
.current_dir(env!("CARGO_WORKSPACE_DIR"))
|
||||
.output()
|
||||
.map_err(|e| Error::Io(e, "Failed to run cargo about generate".into()))?;
|
||||
|
||||
@@ -98,10 +98,9 @@ fn run() -> Result<(), Error> {
|
||||
let current_hash = format!("{:016x}", hasher.finish());
|
||||
|
||||
if current_hash == fs::read_to_string(¤t_hash_path).unwrap_or_default() {
|
||||
eprintln!("No changes in licenses detected, skipping generation.");
|
||||
return Ok(());
|
||||
}
|
||||
eprintln!("Changes in licenses detected, generating new license file.");
|
||||
eprintln!("Changes in licenses detected, generating new license file...");
|
||||
|
||||
let licenses = merge_filter_dedup_and_sort(vec![
|
||||
cargo_source.licenses()?,
|
||||
|
||||
Reference in New Issue
Block a user