use std::collections::HashSet;
use std::fmt::Write as _;
use anyhow::{Result, anyhow};
use crate::resolver::{ResolutionOverrides, ResolveError};
use crate::types::{DetectionWarning, ProjectContext, Task, TaskSource};
pub(super) fn parse_qualified_task(input: &str) -> (Option<TaskSource>, &str) {
if let Some(colon) = input.find(':') {
let prefix = &input[..colon];
if let Some(source) = TaskSource::from_label(prefix) {
return (Some(source), &input[colon + 1..]);
}
}
(None, input)
}
pub(super) fn parse_fqn_task(input: &str) -> Option<(TaskSource, &str)> {
let (prefix, name) = input.split_once('#')?;
let kind = prefix.strip_prefix("root:").unwrap_or(prefix);
let source = TaskSource::from_label(kind)?;
Some((source, name))
}
pub(crate) struct TokenLookup<'a> {
pub qualifier: Option<TaskSource>,
pub task_name: &'a str,
}
pub(crate) fn lookup_token<'a>(
ctx: &'a ProjectContext,
token: &'a str,
) -> (TokenLookup<'a>, Vec<&'a Task>) {
let (qualifier, task_name, fqn) = if let Some((source, name)) = parse_fqn_task(token) {
(Some(source), name, true)
} else {
let (q, n) = parse_qualified_task(token);
(q, n, false)
};
let found: Vec<_> = ctx.tasks.iter().filter(|t| t.name == task_name).collect();
if !fqn
&& let Some(source) = qualifier
&& !found.iter().any(|t| t.source == source)
{
let exact: Vec<_> = ctx.tasks.iter().filter(|t| t.name == token).collect();
if !exact.is_empty() {
return (
TokenLookup {
qualifier: None,
task_name: token,
},
exact,
);
}
}
(
TokenLookup {
qualifier,
task_name,
},
found,
)
}
pub(super) fn qualified_miss_error(
ctx: &ProjectContext,
source: TaskSource,
task_name: &str,
) -> anyhow::Error {
let mut msg = format!(
"task {task_name:?} not found in {}. Run `runner list` to see available tasks.",
source.label(),
);
append_unreadable_note(ctx, &mut msg);
anyhow!(msg)
}
pub(super) fn reversed_qualifier_error(
ctx: &ProjectContext,
task: &str,
source: TaskSource,
task_part: &str,
) -> anyhow::Error {
let src_label = source.label();
let mut msg = format!(
"unknown qualifier in {task:?}: source {src_label:?} must come first.\nhint: did you mean \
\"{src_label}:{task_part}\"?",
);
append_unreadable_note(ctx, &mut msg);
anyhow!(msg)
}
fn append_unreadable_note(ctx: &ProjectContext, msg: &mut String) {
for warning in &ctx.warnings {
if let DetectionWarning::TaskListUnreadable { source, .. } = warning {
let _ = write!(
msg,
"\nnote: {source} failed to read, so its tasks are invisible to this lookup",
);
}
}
}
pub(super) fn detect_reversed_qualifier(input: &str) -> Option<(TaskSource, &str)> {
let colon = input.rfind(':')?;
let suffix = &input[colon + 1..];
let source = TaskSource::from_label(suffix)?;
Some((source, &input[..colon]))
}
pub(crate) fn precheck_task(
ctx: &ProjectContext,
overrides: &ResolutionOverrides,
task: &str,
) -> Result<()> {
if super::local_file::has_local_prefix(task) {
return Ok(());
}
let (lookup, found) = lookup_token(ctx, task);
let TokenLookup {
qualifier,
task_name,
} = lookup;
let restricted: Vec<_> = if qualifier.is_some() {
found.clone()
} else if let Some(allowed) = allowed_runner_sources(overrides) {
found
.iter()
.copied()
.filter(|t| allowed.contains(&t.source))
.collect()
} else {
found.clone()
};
if !restricted.is_empty() {
if let Some(source) = qualifier
&& !restricted.iter().any(|t| t.source == source)
{
return Err(qualified_miss_error(ctx, source, task_name));
}
return Ok(());
}
if let Some(source) = qualifier {
return Err(qualified_miss_error(ctx, source, task_name));
}
if let Some((src, task_part)) = detect_reversed_qualifier(task) {
return Err(reversed_qualifier_error(ctx, task, src, task_part));
}
if let Some(reason) = runner_constraint_error(overrides, &found) {
return Err(reason.into());
}
Ok(())
}
pub(crate) fn allowed_runner_sources(
overrides: &ResolutionOverrides,
) -> Option<HashSet<TaskSource>> {
if let Some(ovr) = overrides.runner.as_ref() {
return Some(ovr.runner.task_source().into_iter().collect());
}
if !overrides.prefer_runners.is_empty() {
let set: HashSet<_> = overrides
.prefer_runners
.iter()
.filter_map(|r| r.task_source())
.collect();
return Some(set);
}
None
}
pub(crate) fn runner_constraint_error(
overrides: &ResolutionOverrides,
found: &[&Task],
) -> Option<ResolveError> {
if let Some(ovr) = overrides.runner.as_ref() {
let label = ovr.runner.label();
if ovr.runner.task_source().is_none() {
return Some(ResolveError::InvalidOverride {
value: label.to_string(),
reason: "no task source is registered for this runner; cannot restrict candidates",
});
}
let reason = if found.is_empty() {
"no task with that name exists in the project"
} else {
"no candidate task is registered under this runner's source"
};
return Some(ResolveError::InvalidOverride {
value: label.to_string(),
reason,
});
}
if !overrides.prefer_runners.is_empty() {
let names = overrides
.prefer_runners
.iter()
.map(|r| r.label())
.collect::<Vec<_>>()
.join(", ");
return Some(ResolveError::InvalidOverride {
value: format!("[{names}]"),
reason: "[task_runner].prefer matched no candidate task source",
});
}
None
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::{lookup_token, parse_fqn_task, precheck_task};
use crate::resolver::ResolutionOverrides;
use crate::types::{DetectionWarning, ProjectContext, Task, TaskRunner, TaskSource};
fn context() -> ProjectContext {
ProjectContext {
root: PathBuf::from("."),
package_managers: Vec::new(),
task_runners: Vec::new(),
tasks: Vec::new(),
node_version: None,
current_node: None,
is_monorepo: false,
warnings: Vec::new(),
}
}
fn task(name: &str, source: TaskSource) -> Task {
Task {
name: name.to_string(),
source,
run_target: None,
description: None,
alias_of: None,
passthrough_to: None,
}
}
#[test]
fn parse_fqn_task_accepts_doctor_fqn_forms() {
assert_eq!(
parse_fqn_task("root:package.json#deno:importsmap"),
Some((TaskSource::PackageJson, "deno:importsmap")),
);
assert_eq!(
parse_fqn_task("package.json#deno:importsmap"),
Some((TaskSource::PackageJson, "deno:importsmap")),
);
assert_eq!(
parse_fqn_task("just#fmt"),
Some((TaskSource::Justfile, "fmt"))
);
}
#[test]
fn parse_fqn_task_leaves_package_specs_alone() {
assert_eq!(parse_fqn_task("user/repo#ref"), None);
assert_eq!(parse_fqn_task("root:unknown#x"), None);
assert_eq!(parse_fqn_task("no-hash"), None);
}
#[test]
fn lookup_token_exact_name_wins_on_qualified_miss() {
let mut ctx = context();
ctx.tasks
.push(task("deno:importsmap", TaskSource::Justfile));
let (lookup, found) = lookup_token(&ctx, "deno:importsmap");
assert_eq!(lookup.qualifier, None);
assert_eq!(lookup.task_name, "deno:importsmap");
assert_eq!(found.len(), 1);
}
#[test]
fn lookup_token_qualified_hit_outranks_exact_name() {
let mut ctx = context();
ctx.tasks.push(task("importsmap", TaskSource::DenoJson));
ctx.tasks
.push(task("deno:importsmap", TaskSource::PackageJson));
let (lookup, found) = lookup_token(&ctx, "deno:importsmap");
assert_eq!(lookup.qualifier, Some(TaskSource::DenoJson));
assert_eq!(lookup.task_name, "importsmap");
assert!(found.iter().any(|t| t.source == TaskSource::DenoJson));
}
#[test]
fn precheck_passes_shadowed_colon_named_task() {
let mut ctx = context();
ctx.tasks
.push(task("deno:importsmap", TaskSource::Justfile));
precheck_task(&ctx, &ResolutionOverrides::default(), "deno:importsmap")
.expect("colon-named task must pass precheck");
}
#[test]
fn precheck_fqn_miss_errors_instead_of_falling_through() {
let err = precheck_task(
&context(),
&ResolutionOverrides::default(),
"root:just#nope",
)
.expect_err("FQN miss must fail precheck");
assert!(format!("{err:#}").contains("not found in just"));
}
#[test]
fn qualified_miss_error_notes_unreadable_source() {
let mut ctx = context();
ctx.warnings.push(DetectionWarning::TaskListUnreadable {
source: "package.json",
error: "invalid JSON".to_string(),
});
let err = precheck_task(&ctx, &ResolutionOverrides::default(), "deno:lint")
.expect_err("qualified miss");
let msg = format!("{err:#}");
assert!(msg.contains("not found in deno"));
assert!(msg.contains("package.json failed to read"));
let err = precheck_task(&ctx, &ResolutionOverrides::default(), "lint:deno")
.expect_err("reversed qualifier");
let msg = format!("{err:#}");
assert!(msg.contains("deno:lint"));
assert!(msg.contains("package.json failed to read"));
}
#[test]
fn precheck_passes_explicit_local_path_under_runner_constraint() {
let overrides = ResolutionOverrides {
prefer_runners: vec![TaskRunner::Just],
..ResolutionOverrides::default()
};
for token in ["./gen.sh", "../gen.sh", "/abs/gen.sh", "~/gen.sh"] {
precheck_task(&context(), &overrides, token).unwrap_or_else(|e| {
panic!("explicit local path {token} should precheck Ok: {e:#}")
});
}
}
#[test]
fn precheck_rejects_prefixless_miss_under_runner_constraint() {
let overrides = ResolutionOverrides {
prefer_runners: vec![TaskRunner::Just],
..ResolutionOverrides::default()
};
let err = precheck_task(&context(), &overrides, "gen")
.expect_err("a prefix-less miss under a constraint should fail precheck");
assert!(
format!("{err:#}").contains("[task_runner].prefer matched no candidate"),
"expected a runner-constraint error, got: {err:#}",
);
}
#[test]
fn precheck_does_not_restrict_under_tasks_prefer() {
let overrides = ResolutionOverrides {
prefer_sources: vec![TaskSource::TurboJson],
..ResolutionOverrides::default()
};
precheck_task(&context(), &overrides, "gen")
.expect("[tasks].prefer must not restrict candidates");
}
}