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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions Werkfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
config mdbook-flags = "--open"

# Serve the documentation using mdbook and open it in the default browser.
task mdbook {
let book-dir = "book"
spawn "mdbook serve {mdbook-flags*} <book-dir:workspace> -d <book-dir:out-dir>"
}

# Install werk-cli using a current installation of Werk.
task install {
let cli = "werk-cli"
run "cargo install --locked --path <cli>"
}
1 change: 1 addition & 0 deletions book/src/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
- [Paths](./paths.md)
- [.werk-cache](./werk_cache.md)
- [Task recipes](./task_recipes.md)
- [Long-running tasks](./long_running_tasks.md)
- [Build recipes](./build_recipes.md)
- [When is a target outdated?](./outdatedness.md)
- [Depfile support](./depfile_support.md)
Expand Down
10 changes: 6 additions & 4 deletions book/src/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,13 @@

- **Concurrency:** Build recipes and tasks run in parallel when possible.

- (TODO) **Autoclean:** Werk is aware of which files it has generated, and can
automatically clean them up from the output directory.
- **Autowatch:** Werk can be run in `--watch` mode, which waits for file changes
and automatically rebuilds when any change is detected.

- (TODO) **Autowatch:** Werk can be run in `--watch` mode, which waits for file
changes and automatically rebuilds when any change is detected.
- **Long-running tasks:** Werk natively supports long-running processes, such as
a development webserver running locally, using the `spawn` statement. This
also works in combination with the `--watch` feature, such that any spawned
processes are automatically restarted when a rebuild is triggered.

# Limitations

Expand Down
23 changes: 23 additions & 0 deletions book/src/language/recipe_commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,29 @@ arbitrary [expressions](./expressions.md), only strings. However, string
interpolation can be used within `run` statements to build command-line
invocations from other bits.

## The `spawn` statement

In task recipes, the `spawn` statement is used to start a long-running process in
the background. Other statements do not wait for the process to finish before executing.

`spawn` statements may also occur within `run` blocks, meaning that the task can
rely on normal `run` commands having finished before the process is spawned.

Examples:

```werk
task my-task {
# Execute a long-running process in the background
spawn "my-server"

run {
# Execute a normal command before spawning the server
run "hello"
spawn "my-server"
}
}
```

## String interpolation in `run` statements

Commands executed in recipes, or as part of the `shell` expression, have
Expand Down
21 changes: 21 additions & 0 deletions book/src/long_running_tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Long-running tasks

Task recipes can spawn long-running processes controlled by `werk` using [the
`spawn` statement](./language/recipe_commands.md#the-spawn-statement). This is
useful for running a development server or other long-running processes that
need to be restarted when the source files change.

When a `spawn` statement has executed, `werk` will wait for the process to exit
before exiting itself. When `werk` receives a Ctrl-C signal, it will kill the
child process as well.

## Autowatch integration

When `--watch` is enabled, `werk` will automatically kill and restart any
spawned processes when a rebuild is triggered.

<div class="warning">
<strong>Note:</strong> Some programs, such as local webservers, implement their
own watching mechanism. Using these in conjunction with `--watch` may not be desirable,
because `werk` will unconditionally restart the process on any change.
</div>
4 changes: 4 additions & 0 deletions tests/mock_io.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1100,6 +1100,10 @@ impl werk_runner::Child for MockChild {
> {
self.status.take().unwrap()
}

fn kill(&mut self) -> std::io::Result<()> {
Ok(())
}
}

impl werk_runner::Io for MockIo {
Expand Down
4 changes: 4 additions & 0 deletions werk-cli/dry_run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ impl Child for DryRunChild {
{
Box::pin(std::future::ready(Ok(std::process::ExitStatus::default())))
}

fn kill(&mut self) -> std::io::Result<()> {
Ok(())
}
}

impl werk_runner::Io for DryRun {
Expand Down
143 changes: 88 additions & 55 deletions werk-cli/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ use std::{borrow::Cow, path::Path, sync::Arc};
use ahash::HashSet;
use clap::{CommandFactory, Parser};
use clap_complete::ArgValueCandidates;
use futures::future::Either;
use futures::future::{self, Either};
use notify_debouncer_full::notify;
use owo_colors::OwoColorize as _;
use render::{AutoStream, ColorOutputKind};
use werk_fs::{Absolute, Normalize as _, PathError};
use werk_runner::{Runner, Warning, Workspace, WorkspaceSettings};
use werk_runner::{BuildStatus, Runner, Warning, Workspace, WorkspaceSettings};
use werk_util::{Annotated, AsDiagnostic, DiagnosticFileId, DiagnosticSource, DiagnosticSourceMap};

shadow_rs::shadow!(build);
Expand Down Expand Up @@ -263,30 +263,6 @@ async fn try_main(args: Args) -> Result<(), Error> {
return Ok(());
}

let target = args
.target
.clone()
.or_else(|| workspace.default_target.clone());
let Some(target) = target else {
return Err(Error::NoTarget);
};

let runner = Runner::new(&workspace);
let result = runner.build_or_run(&target).await;

let write_cache = match result {
Ok(_) => true,
Err(ref err) => err.error.should_still_write_werk_cache(),
};

if write_cache {
if let Err(err) = workspace.finalize().await {
eprintln!("Error writing `.werk-cache`: {err}")
}
}

std::mem::drop(runner);

if args.watch {
autowatch_loop(
std::time::Duration::from_millis(args.watch_delay),
Expand All @@ -299,14 +275,45 @@ async fn try_main(args: Args) -> Result<(), Error> {
.await?;
Ok(())
} else {
result.map(|_| ()).map_err(print_error)
let target = args
.target
.clone()
.or_else(|| workspace.default_target.clone());
let Some(target) = target else {
workspace
.render
.runner_message("No configured default target");
return Err(Error::NoTarget);
};

let (ctrlc_sender, ctrlc_receiver) = smol::channel::bounded(1);
_ = ctrlc::set_handler(move || {
_ = ctrlc_sender.try_send(());
});
let ctrlc_recv = ctrlc_receiver.recv();
smol::pin!(ctrlc_recv);

let (runner, result) = run(&workspace, &target).await;
result?;
let wait = runner.wait_for_long_running_tasks();
smol::pin!(wait);

match future::select(wait, ctrlc_recv).await {
Either::Left(_) => Ok(()),
Either::Right(_) => {
// Stop children, giving child processes a chance to finish
// cleanly (and giving us a chance to read their stdout/stderr).
runner.stop(std::time::Duration::from_millis(100)).await;
Ok(())
}
}
}
}

async fn autowatch_loop(
timeout: std::time::Duration,
// The initial workspace built by main(). Must be finalize()d.
workspace: Workspace,
mut workspace: Workspace,
werkfile: Absolute<std::path::PathBuf>,
// Target to keep building
target_from_args: Option<String>,
Expand All @@ -332,11 +339,30 @@ async fn autowatch_loop(
}
}));
let workspace_dir = workspace.project_root().to_path_buf();
std::mem::drop(workspace);

let mut settings = settings.clone();

let mut target = target_from_args
.clone()
.or_else(|| workspace.default_target.clone());

loop {
if target.is_none() {
render.runner_message("No configured default target");
watch_set = watch_manifest.clone();
}

// Start the notifier.
let notifier = make_notifier_for_files(&watch_set, notification_sender.clone(), timeout)?;
let notification_recv = notification_receiver.recv();

// Build or rebuild the target!
let runner_and_result = if let Some(target) = target.as_ref() {
Some(run(&workspace, target).await)
} else {
None
};

if watch_set == watch_manifest {
render.runner_message("Watching manifest for changes, press Ctrl-C to stop");
} else {
Expand All @@ -346,14 +372,11 @@ async fn autowatch_loop(
));
}

// Start the notifier.
let notifier = make_notifier_for_files(&watch_set, notification_sender.clone(), timeout)?;
let notification_recv = notification_receiver.recv();
let ctrlc_recv = ctrlc_receiver.recv();
smol::pin!(notification_recv);
smol::pin!(ctrlc_recv);

match futures::future::select(notification_recv, ctrlc_recv).await {
match future::select(notification_recv, ctrlc_recv).await {
Either::Left((result, _)) => result.expect("notifier channel error"),
Either::Right((result, _)) => {
if result.is_ok() {
Expand All @@ -363,6 +386,15 @@ async fn autowatch_loop(
}
}

// If there are any long-running tasks, give them a chance to finish.
// Note that `run()` automatically does this if the target could not be
// built.
if let Some((ref runner, Ok(_))) = runner_and_result {
runner.stop(std::time::Duration::from_millis(100)).await;
}

std::mem::drop(runner_and_result);

// Stop the notifier again immediately. TODO: Consider if it makes sense to reuse it.
notifier.stop();

Expand Down Expand Up @@ -430,7 +462,7 @@ async fn autowatch_loop(
settings.output_directory = out_dir;
}

let mut workspace =
workspace =
match Workspace::new(io.clone(), render.clone(), workspace_dir.clone(), &settings) {
Ok(workspace) => workspace,
Err(err) => {
Expand All @@ -453,14 +485,9 @@ async fn autowatch_loop(
}
}

let target = target_from_args
target = target_from_args
.clone()
.or_else(|| workspace.default_target.clone());
let Some(target) = target else {
render.runner_message("No configured default target");
watch_set = watch_manifest.clone();
continue;
};

// Update the watchset.
watch_set.clear();
Expand All @@ -472,25 +499,31 @@ async fn autowatch_loop(
None
}
}));
}
}

// Finally, rebuild the target!
let runner = Runner::new(&workspace);
let write_cache = match runner.build_or_run(&target).await {
Ok(_) => true,
Err(err) => {
let write_cache = err.error.should_still_write_werk_cache();
print_error(err);
write_cache
}
};
async fn run<'w>(
workspace: &'w Workspace,
target: &str,
) -> (Runner<'w>, Result<BuildStatus, Error>) {
let runner = Runner::new(workspace);
let result = runner.build_or_run(target).await;

if write_cache {
if let Err(err) = workspace.finalize().await {
eprintln!("Error writing `.werk-cache`: {err}");
return Err(err.into());
}
let write_cache = match result {
Ok(_) => true,
Err(ref err) => {
runner.stop(std::time::Duration::from_secs(1)).await;
err.error.should_still_write_werk_cache()
}
};

if write_cache {
if let Err(err) = workspace.finalize().await {
eprintln!("Error writing `.werk-cache`: {err}")
}
}

(runner, result.map_err(print_error))
}

fn make_notifier_for_files(
Expand Down
7 changes: 7 additions & 0 deletions werk-parser/ast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,7 @@ pub enum TaskRecipeStmt {
Let(LetStmt),
Build(BuildStmt),
Run(RunStmt),
Spawn(SpawnExpr),
Info(InfoExpr),
Warn(WarnExpr),
SetCapture(KwExpr<keyword::SetCapture, ConfigBool>),
Expand All @@ -432,6 +433,7 @@ impl SemanticHash for TaskRecipeStmt {
TaskRecipeStmt::Let(stmt) => stmt.semantic_hash(state),
TaskRecipeStmt::Build(stmt) => stmt.semantic_hash(state),
TaskRecipeStmt::Run(stmt) => stmt.semantic_hash(state),
TaskRecipeStmt::Spawn(stmt) => stmt.semantic_hash(state),
TaskRecipeStmt::Env(stmt) => stmt.semantic_hash(state),
TaskRecipeStmt::EnvRemove(stmt) => stmt.semantic_hash(state),
// Information statements do not contribute to outdatedness.
Expand Down Expand Up @@ -524,6 +526,7 @@ pub type FromStmt = KwExpr<keyword::From, ExprChain>;
pub type BuildStmt = KwExpr<keyword::Build, ExprChain>;
pub type DepfileStmt = KwExpr<keyword::Depfile, ExprChain>;
pub type RunStmt = KwExpr<keyword::Run, RunExpr>;
pub type SpawnExpr = KwExpr<keyword::Spawn, StringExpr>;
pub type ErrorStmt = KwExpr<keyword::Error, StringExpr>;
pub type DeleteExpr = KwExpr<keyword::Delete, Expr>;
pub type TouchExpr = KwExpr<keyword::Touch, Expr>;
Expand All @@ -535,6 +538,8 @@ pub type EnvRemoveStmt = KwExpr<keyword::RemoveEnv, StringExpr>;
pub enum RunExpr {
/// Run shell command.
Shell(ShellExpr),
/// Spawn a shell command without waiting for it to finish.
Spawn(SpawnExpr),
/// Write the result of the expression to the path. The string is an OS path.
Write(WriteExpr),
/// Copy one file to another.
Expand All @@ -561,6 +566,7 @@ impl Spanned for RunExpr {
fn span(&self) -> Span {
match self {
RunExpr::Shell(expr) => expr.span,
RunExpr::Spawn(expr) => expr.span,
RunExpr::Write(expr) => expr.span,
RunExpr::Copy(expr) => expr.span,
RunExpr::Delete(expr) => expr.span,
Expand All @@ -580,6 +586,7 @@ impl SemanticHash for RunExpr {
std::mem::discriminant(self).hash(state);
match self {
RunExpr::Shell(expr) => expr.semantic_hash(state),
RunExpr::Spawn(expr) => expr.semantic_hash(state),
RunExpr::Write(expr) => expr.semantic_hash(state),
RunExpr::Copy(expr) => expr.semantic_hash(state),
RunExpr::Delete(expr) => expr.semantic_hash(state),
Expand Down
Loading