Skip to content

Commit 336ec93

Browse files
committed
feat(core): add preview directives and helper tasks
1 parent 176e43b commit 336ec93

18 files changed

Lines changed: 431 additions & 27 deletions

File tree

Onlyfile

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
!echo true
2+
!preview false
3+
4+
% Internal helper for release builds
5+
_release_build():
6+
cargo build --release
27

38
% Run cargo check
49
check():
@@ -18,8 +23,7 @@ ci() & check & test:
1823
echo "CI complete!"
1924

2025
% Install the local only binary
21-
install() ? @os("windows") shell?=pwsh:
22-
cargo build --release
26+
install() ? @os("windows") & _release_build shell?=pwsh:
2327
Write-Output "Windows: cannot replace running binary. Run:`n Copy-Item target/release/only.exe -Destination `$env:USERPROFILE\.cargo\bin\ -Force"
2428

2529
install():

README.md

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,22 @@ Only is a cross-platform task runner built around a real task language.
1010
Write tasks once, keep one execution model, and get predictable results on **macOS, Linux, and Windows**.
1111

1212
- **Cross-platform by default** — no Git Bash, no `if os()` hacks, no `platforms:` boilerplate
13-
- **A better task language** — readable task syntax with parameters, guards, serial and parallel dependencies, namespaces, and interpolation
13+
- **A better task language** — readable task syntax with parameters, guards, serial and parallel dependencies, helper tasks, directives, namespaces, and interpolation
1414
- **Built for tooling** — the same core model can power execution, diagnostics, editor features, and future visual workflows
1515

1616
```Onlyfile
17+
!preview true
18+
19+
_prepare():
20+
cargo fmt --all --check
21+
1722
check():
1823
cargo check
1924
2025
test():
2126
cargo test
2227
23-
ci() & check & test:
28+
ci() & _prepare & check & test:
2429
echo "CI complete"
2530
2631
release() & build & (package, publish):
@@ -53,6 +58,11 @@ Create an `Onlyfile` in your project root:
5358

5459
```Onlyfile
5560
!echo true
61+
!preview false
62+
63+
% Internal helper for release builds.
64+
_release_build():
65+
cargo build --release
5666
5767
% Run cargo check.
5868
check():
@@ -111,6 +121,7 @@ build():
111121

112122
```Onlyfile
113123
!echo true
124+
!preview true
114125
115126
% Run checks only if cargo is available.
116127
check() ? @has("cargo"):
@@ -128,9 +139,12 @@ test() ? @has("cargo-nextest"):
128139
test():
129140
cargo test
130141
131-
% Install the local binary.
132-
install() ? @os("windows") shell?=pwsh:
142+
% Internal helper reused by install on Windows.
143+
_release_build():
133144
cargo build --release
145+
146+
% Install the local binary.
147+
install() ? @os("windows") & _release_build shell?=pwsh:
134148
Write-Output "Windows: cannot replace running binary. Run:`n Copy-Item target/release/only.exe -Destination `$env:USERPROFILE\.cargo\bin\ -Force"
135149
136150
install():
@@ -162,9 +176,10 @@ build():
162176
## Why Only ✨
163177

164178
- **Actually cross-platform by default**`deno_task_shell` keeps behavior aligned across macOS, Linux, and Windows
165-
- **A better task language** — function-style signatures, parameters, defaults, guards, namespaces, and interpolation stay readable
179+
- **A better task language** — function-style signatures, parameters, defaults, guards, helper tasks, directives, namespaces, and interpolation stay readable
166180
- **Clear execution flow** — dependencies, planning, and runtime behavior are explicit instead of being buried in shell glue
167181
- **Better diagnostics and help** — dynamic task listing and structured validation make the terminal experience less guessy
182+
- **Safer internal workflow composition** — helper tasks stay usable as dependencies without cluttering normal CLI help
168183
- **Built for tooling, not just execution** — the same pipeline can power CLI, editor features, language servers, and future visual workflows
169184

170185
| Tool | Best fit | Core model | Portability | Tooling headroom |

crates/cli/src/command.rs

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
use crate::args::{CliInput, parse_global_options, parse_with_onlyfile};
2-
use crate::compile::{compile_for_cli_input_in_dir, ensure_no_error_diagnostics};
2+
use crate::compile::{
3+
CliCompileResult, compile_for_cli_input_in_dir, ensure_no_error_diagnostics, resolve_target,
4+
};
35
use crate::discover::discover_onlyfile;
46
use crate::error::{OnlyError, Result};
57
use crate::render::{
68
render_available_tasks, render_error_message, render_global_help, render_help_hint,
79
render_namespace_help,
810
};
9-
use only_engine::ExecutionPlan;
10-
use only_semantic::DocumentAst;
11+
use only_engine::{ExecutionPlan, render_command, select_root_task_variant};
12+
use only_semantic::{DocumentAst, GuardAst, TaskAst};
1113
use std::path::{Path, PathBuf};
1214
use std::process::ExitCode;
1315

@@ -75,6 +77,7 @@ pub fn run_with(cli: CliInput) -> Result<ExitCode> {
7577
}
7678

7779
let compiled = compile_for_cli_input_in_dir(&discovered.contents, &cli, discovered.base_dir)?;
80+
render_preview_if_enabled(&compiled, &cli)?;
7881
only_engine::run_plan(&compiled.plan).map_err(|error| OnlyError::runtime(error.to_string()))
7982
}
8083

@@ -150,6 +153,58 @@ pub fn run_plan(plan: &ExecutionPlan) -> Result<ExitCode> {
150153
only_engine::run_plan(plan).map_err(|error| OnlyError::runtime(error.to_string()))
151154
}
152155

156+
fn render_preview_if_enabled(compiled: &CliCompileResult, cli: &CliInput) -> Result<()> {
157+
if !compiled.plan.preview {
158+
return Ok(());
159+
}
160+
161+
let (target, _) = resolve_target(&compiled.compiled, cli)?;
162+
let variant = select_root_task_variant(&compiled.compiled.document, &target)
163+
.map_err(|error| OnlyError::runtime(error.to_string()))?;
164+
eprintln!("{}", render_plan_preview(&compiled.plan, variant)?);
165+
Ok(())
166+
}
167+
168+
fn render_plan_preview(plan: &ExecutionPlan, variant: &TaskAst) -> Result<String> {
169+
let mut output = String::from("Preview:\n");
170+
output.push_str(" variant: ");
171+
output.push_str(&render_task_variant(variant));
172+
output.push('\n');
173+
output.push_str(" commands:\n");
174+
175+
for node in &plan.nodes {
176+
for command in &node.commands {
177+
let rendered = render_command(command, &node.params)
178+
.map_err(|error| OnlyError::runtime(error.to_string()))?;
179+
output.push_str(" [");
180+
output.push_str(&node.name);
181+
output.push_str("] ");
182+
output.push_str(&rendered);
183+
output.push('\n');
184+
}
185+
}
186+
187+
Ok(output.trim_end().to_string())
188+
}
189+
190+
fn render_task_variant(task: &TaskAst) -> String {
191+
let mut variant = match &task.namespace {
192+
Some(namespace) => format!("{namespace}.{}", task.signature()),
193+
None => task.signature().to_string(),
194+
};
195+
196+
if let Some(guard) = &task.guard {
197+
variant.push_str(" ? ");
198+
variant.push_str(&render_guard(guard));
199+
}
200+
201+
variant
202+
}
203+
204+
fn render_guard(guard: &GuardAst) -> String {
205+
format!("@{}(\"{}\")", guard.kind, guard.argument)
206+
}
207+
153208
fn run_inner() -> Result<ExitCode> {
154209
let partial = parse_global_options()?;
155210

@@ -192,5 +247,6 @@ fn run_inner() -> Result<ExitCode> {
192247
}
193248

194249
let compiled = compile_for_cli_input_in_dir(&discovered.contents, &cli, discovered.base_dir)?;
250+
render_preview_if_enabled(&compiled, &cli)?;
195251
only_engine::run_plan(&compiled.plan).map_err(|error| OnlyError::runtime(error.to_string()))
196252
}

crates/cli/src/compile.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ pub fn compile_for_cli(source: &str) -> CliCompileResult {
2626
let task_name = compiled
2727
.document
2828
.tasks
29-
.first()
29+
.iter()
30+
.find(|task| !task.is_helper())
3031
.map(|task| task.qualified_name().to_string())
3132
.unwrap_or_default();
3233
let plan = build_execution_plan(
@@ -101,7 +102,10 @@ pub fn compile_for_cli_input_in_dir(
101102
})
102103
}
103104

104-
fn resolve_target(compiled: &SemanticSnapshot, cli: &CliInput) -> Result<(String, Vec<String>)> {
105+
pub(crate) fn resolve_target(
106+
compiled: &SemanticSnapshot,
107+
cli: &CliInput,
108+
) -> Result<(String, Vec<String>)> {
105109
let namespaces = compiled
106110
.document
107111
.namespaces

crates/cli/src/render.rs

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ pub fn render_help(document: &DocumentAst) -> StyledStr {
8686
/// Returns:
8787
/// User-facing task list with global tasks and namespaces.
8888
pub fn render_available_tasks(document: &DocumentAst) -> String {
89-
let entries = unique_tasks(global_tasks(document))
89+
let entries = unique_tasks(global_tasks(document).filter(|task| !task.is_helper()))
9090
.into_iter()
9191
.map(|task| {
9292
(
@@ -257,7 +257,8 @@ fn build_task_command(task: &TaskAst) -> Command {
257257
.unwrap_or_default();
258258
let mut cmd = Command::new(task.name.to_string())
259259
.styles(cli_styles())
260-
.about(about);
260+
.about(about)
261+
.hide(task.is_helper());
261262

262263
for (index, param) in task.params.iter().enumerate() {
263264
let arg = if let Some(default) = &param.default_value {
@@ -520,6 +521,27 @@ smoke():
520521
assert!(help.starts_with("Developer workflow."));
521522
}
522523

524+
#[test]
525+
fn hides_helper_tasks_from_rendered_outputs() {
526+
let document = parse_onlyfile(
527+
"% Run tests.\n_test_helper():\n cargo test\ntest():\n cargo test\n\n[dev]\n_workflow():\n echo hidden\nworkflow():\n echo ok\n",
528+
)
529+
.expect("document should parse");
530+
531+
let listing = render_available_tasks(&document);
532+
assert!(listing.contains("test"));
533+
assert!(!listing.contains("_test_helper"));
534+
assert!(!listing.contains("_workflow"));
535+
536+
let root_help = render_help(&document).to_string();
537+
assert!(root_help.contains("test"));
538+
assert!(!root_help.contains("_test_helper"));
539+
540+
let namespace_help = render_namespace_help(&document, &document.namespaces[0]).to_string();
541+
assert!(namespace_help.contains("workflow"));
542+
assert!(!namespace_help.contains("_workflow"));
543+
}
544+
523545
#[test]
524546
fn allows_guarded_task_variants_without_duplicate_subcommand_panic() {
525547
let document = parse_onlyfile(

crates/cli/tests/cli.rs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1032,6 +1032,70 @@ fn run_with_rejects_error_diagnostics_before_execution() {
10321032
fs::remove_dir_all(root).expect("temp tree should be removed");
10331033
}
10341034

1035+
#[test]
1036+
fn rejects_direct_helper_task_execution_via_run_with() {
1037+
let _cwd_lock = cwd_lock();
1038+
let root = temp_case_dir("only-runtime-helper-task");
1039+
let onlyfile_path = root.join("Onlyfile");
1040+
fs::write(&onlyfile_path, "_prepare():\n echo helper\n")
1041+
.expect("Onlyfile should be written");
1042+
1043+
let input = CliInput {
1044+
onlyfile_path: Some(onlyfile_path),
1045+
print_discovered_path: false,
1046+
top_level_help_requested: false,
1047+
top_level_version_requested: false,
1048+
task_path: vec!["_prepare".into()],
1049+
parameter_overrides: vec![],
1050+
};
1051+
1052+
let error = run_with(input).expect_err("helper task should not execute directly");
1053+
assert_eq!(
1054+
error.to_string(),
1055+
"helper task '_prepare' cannot be invoked directly"
1056+
);
1057+
1058+
fs::remove_dir_all(root).expect("temp tree should be removed");
1059+
}
1060+
1061+
#[cfg(unix)]
1062+
#[test]
1063+
fn preview_prints_selected_variant_and_commands_before_execution() {
1064+
let _cwd_lock = cwd_lock();
1065+
let temp_dir = TempDir::new("preview-cli-unix");
1066+
let onlyfile_path = temp_dir.path().join("Onlyfile");
1067+
let current_os = std::env::consts::OS;
1068+
let other_os = if current_os == "windows" {
1069+
"linux"
1070+
} else {
1071+
"windows"
1072+
};
1073+
fs::write(
1074+
&onlyfile_path,
1075+
format!(
1076+
"!preview true\nprobe() ? @os(\"{current_os}\"):\n printf 'guarded\\n'\nprobe() ? @os(\"{other_os}\"):\n printf 'skipped\\n'\nprobe():\n printf 'fallback\\n'\n"
1077+
),
1078+
)
1079+
.expect("Onlyfile should be written");
1080+
1081+
let output = Command::new(cli_binary_path())
1082+
.arg("probe")
1083+
.current_dir(temp_dir.path())
1084+
.output()
1085+
.expect("CLI process should run");
1086+
1087+
let stdout = String::from_utf8(output.stdout).expect("stdout should be valid utf-8");
1088+
let stderr = String::from_utf8(output.stderr).expect("stderr should be valid utf-8");
1089+
let plain_stdout = strip_ansi(&stdout);
1090+
let plain_stderr = strip_ansi(&stderr);
1091+
1092+
assert_eq!(output.status.code(), Some(0), "stderr was: {stderr}");
1093+
assert!(plain_stderr.contains("Preview:"));
1094+
assert!(plain_stderr.contains(&format!("variant: probe() ? @os(\"{current_os}\")")));
1095+
assert!(plain_stderr.contains("[probe] printf 'guarded"));
1096+
assert!(plain_stdout.contains("[probe] guarded"));
1097+
}
1098+
10351099
#[test]
10361100
fn exposes_workspace_cli_version() {
10371101
assert_eq!(version_string(), env!("CARGO_PKG_VERSION"));

crates/cli/tests/compile.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ fn compiles_in_memory_source_without_fs() {
77
assert_eq!(compiled.plan.nodes[0].name, "check");
88
}
99

10+
#[test]
11+
fn skips_helper_task_when_picking_default_in_memory_target() {
12+
let compiled = compile_for_cli("_prepare():\n echo helper\ncheck():\n echo ok\n");
13+
assert!(compiled.diagnostics.is_empty());
14+
assert_eq!(compiled.plan.nodes[0].name, "check");
15+
}
16+
1017
#[test]
1118
fn compiles_namespaced_first_task_into_plan() {
1219
let compiled = compile_for_cli("[dev]\nserve():\n echo ok\n");
@@ -56,6 +63,25 @@ fn compiles_selected_global_task_with_named_override() {
5663
);
5764
}
5865

66+
#[test]
67+
fn rejects_direct_invocation_of_helper_task_for_semantic_cli_compile() {
68+
let cli = CliInput {
69+
onlyfile_path: None,
70+
print_discovered_path: false,
71+
top_level_help_requested: false,
72+
top_level_version_requested: false,
73+
task_path: vec!["_prepare".into()],
74+
parameter_overrides: vec![],
75+
};
76+
let error = compile_for_cli_input("_prepare():\n echo helper\n", &cli)
77+
.expect_err("helper target should fail semantic CLI compile");
78+
79+
assert_eq!(
80+
error.to_string(),
81+
"helper task '_prepare' cannot be invoked directly"
82+
);
83+
}
84+
5985
#[test]
6086
fn rejects_namespace_without_task_target_for_semantic_cli_compile() {
6187
let cli = CliInput {

crates/engine/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,6 @@ pub use interpolate::interpolate as render_command;
1414
pub use planner::try_build_execution_plan;
1515
pub use planner::{
1616
ExecutionNode, ExecutionPlan, Invocation, PlanError, PlanParam, build_execution_plan,
17-
try_build_execution_plan_in_dir,
17+
select_root_task_variant, try_build_execution_plan_in_dir,
1818
};
1919
pub use runtime::run_plan;

0 commit comments

Comments
 (0)