use crate::finding::{Finding, Severity};
use crate::instance::Outcome;
use crate::scope::{DocumentCheck, DocumentView};
use crate::shape::Shape;
use crate::surface::glob_matches;
use headwater_doc::body::{BlockKind, Ownership};
pub const RULE: &str = "surface.command.undeclared";
pub(crate) const SHELLS: [&str; 4] = ["sh", "shell", "bash", "console"];
pub struct Undeclared {
adopter_documents: Vec<String>,
commands: Vec<String>,
}
impl Undeclared {
pub fn over(shape: &Shape) -> Self {
Undeclared {
adopter_documents: shape.surface.adopter_documents.clone(),
commands: shape.surface.commands.clone(),
}
}
fn declared(&self) -> bool {
!self.adopter_documents.is_empty() && !self.commands.is_empty()
}
fn for_an_adopter(&self, path: &str) -> bool {
self.adopter_documents
.iter()
.any(|glob| glob_matches(glob, path))
}
}
impl DocumentCheck for Undeclared {
const RULE: &'static str = self::RULE;
const VERSION: u32 = 2;
const NEEDS_BODY: bool = true;
fn instantiates(&self, _kind: &str) -> bool {
self.declared()
}
fn selects(&self, path: &str) -> bool {
self.for_an_adopter(path)
}
fn evaluate(&self, view: &DocumentView<'_>) -> Outcome {
if !self.declared() || !self.for_an_adopter(view.path()) {
return Outcome::Passed;
}
let mut findings = Vec::new();
for block in view
.body()
.map(|body| body.blocks.as_slice())
.unwrap_or(&[])
{
let Some(info) = block.info.as_deref() else {
continue;
};
if block.kind != BlockKind::Code || !SHELLS.contains(&info) {
continue;
}
let Some(first) = block.runs.first() else {
continue;
};
let text: String = block
.runs
.iter()
.filter(|run| run.ownership == Ownership::Code)
.map(|run| run.text.as_str())
.collect();
for (line, read) in programs(&text, info == "console") {
let (message, remediation) = match read {
Read::Unreadable => (
"a line of this shell block ends inside a quote, a pair of backticks, a `$(` or a `${`, or closes one with the wrong `)` or `}`, so the rule cannot read the rest of the block (HW-DR-0077)".to_string(),
format!(
"close the quote on the line that opens it, or mark the step as a deliberate exception with `<!-- headwater allow={} scope=block reason=accepted_deviation ... -->`",
self::RULE
),
),
Read::Program(program) => {
let name = program.rsplit('/').next().unwrap_or(program.as_str());
if self.commands.iter().any(|declared| declared == name) {
continue;
}
(
format!(
"a shell block runs `{name}`, and `commands` of the consumer surface does not declare it (HW-DR-0077)"
),
format!(
"add `{name}` to `surface.commands` if an adopter must have it installed, name a declared command that does this, or mark the step as a deliberate exception with `<!-- headwater allow={} scope=block reason=accepted_deviation ... -->`",
self::RULE
),
)
}
};
findings.push(Finding {
rule: self::RULE,
severity: Severity::Error,
obligation: None,
path: view.path().to_string(),
line: first.span.start.line + line,
column: 1,
message,
remediation,
patch: None,
});
}
}
findings.sort_by_key(|finding| (finding.line, finding.column));
Outcome::failed(findings)
}
}
#[derive(Debug, PartialEq, Eq)]
enum Read {
Program(String),
Unreadable,
}
fn programs(text: &str, console: bool) -> Vec<(usize, Read)> {
let mut found = Vec::new();
let mut command = String::new();
let mut starts: Vec<(usize, usize)> = Vec::new();
let mut heredoc: Option<String> = None;
for (number, raw) in text.lines().enumerate() {
if let Some(end) = &heredoc {
if raw.trim() == end {
heredoc = None;
}
continue;
}
let continuing = !starts.is_empty();
let line = match (continuing, console) {
(true, true) => raw.trim_start().strip_prefix("> ").unwrap_or(raw),
(true, false) => raw,
(false, _) => {
let trimmed = raw.trim_start();
match (trimmed.strip_prefix("$ "), console) {
(Some(rest), _) => rest,
(None, true) => continue,
(None, false) => raw,
}
}
};
let trimmed = line.trim();
if !continuing && (trimmed.is_empty() || trimmed.starts_with('#')) {
continue;
}
starts.push((command.len(), number));
command.push_str(trimmed);
let scanned = scan(&command);
match scanned.open {
Some(open) if open != '#' => {
read_command(&command, &starts, number, &mut found);
found.push((number, Read::Unreadable));
return found;
}
None if scanned.continued => {
command.pop();
command.push(' ');
continue;
}
_ => {}
}
read_command(&command, &starts, number, &mut found);
heredoc = heredoc_end(&command);
command.clear();
starts.clear();
}
if let Some(&(_, last)) = starts.last() {
read_command(&command, &starts, last, &mut found);
}
found
}
fn read_command(
command: &str,
starts: &[(usize, usize)],
number: usize,
found: &mut Vec<(usize, Read)>,
) {
for (offset, segment) in segments(command) {
if let Some(program) = program_of(segment) {
let line = starts
.iter()
.rev()
.find(|(start, _)| *start <= offset)
.map_or(number, |(_, line)| *line);
found.push((line, Read::Program(program)));
}
}
}
fn segments(command: &str) -> Vec<(usize, &str)> {
scan(command).segments
}
struct Scan<'a> {
segments: Vec<(usize, &'a str)>,
open: Option<char>,
heredoc: Option<usize>,
continued: bool,
}
fn scan(command: &str) -> Scan<'_> {
let mut out = Vec::new();
let mut start = 0;
let mut stack: Vec<char> = Vec::new();
let mut heredoc = None;
let mut escaped = false;
let bytes = command.as_bytes();
let mut at = 0;
while at < bytes.len() {
let c = bytes[at] as char;
if escaped {
escaped = false;
at += 1;
continue;
}
let top = stack.last().copied();
let quoted = stack.iter().any(|span| matches!(span, '\'' | '"' | 'a'));
let next = bytes.get(at + 1).copied();
match (top, c) {
(Some('\''), '\'') | (Some('a'), '\'') | (Some('`'), '`') | (Some('"'), '"') => {
stack.pop();
}
(Some('\''), _) => {}
(Some('a' | '`' | '"') | None | Some('(' | '{'), '\\') => escaped = true,
(Some('a'), _) => {}
(Some('`' | '"') | None | Some('(' | '{'), '$') if next == Some(b'(') => {
stack.push('(');
at += 1;
}
(Some('`' | '"') | None | Some('(' | '{'), '$') if next == Some(b'{') => {
stack.push('{');
at += 1;
}
(Some('"') | None | Some('(' | '{'), '`') => stack.push('`'),
(Some('"'), _) => {}
(None | Some('(' | '{'), '$') if next == Some(b'\'') => {
stack.push('a');
at += 1;
}
(None | Some('(' | '{'), '\'' | '"') => stack.push(c),
(Some('('), ')') | (Some('{'), '}') => {
stack.pop();
}
(Some('(' | '{'), ')' | '}') => {
out.push((start, &command[start..]));
return Scan {
segments: out,
open: Some('!'),
heredoc,
continued: false,
};
}
(None, '#')
if at == 0 || matches!(bytes[at - 1], b' ' | b'\t' | b';' | b'&' | b'|') =>
{
out.push((start, &command[start..at]));
return Scan {
segments: out,
open: Some('#'),
heredoc,
continued: false,
};
}
(None | Some('(' | '{'), '<') if !quoted && heredoc.is_none() && next == Some(b'<') => {
heredoc = Some(at);
at += 1;
}
(None | Some('(' | '{' | '`'), '|' | '&' | ';') if !quoted => {
let redirect =
(c == '&') && ((at > 0 && bytes[at - 1] == b'>') || next == Some(b'>'));
if !redirect {
out.push((start, &command[start..at]));
while at + 1 < bytes.len() && matches!(bytes[at + 1], b'|' | b'&' | b';') {
at += 1;
}
start = at + 1;
}
}
_ => {}
}
at += 1;
}
out.push((start, &command[start..]));
Scan {
segments: out,
open: stack.last().copied(),
heredoc,
continued: escaped && stack.is_empty(),
}
}
fn program_of(segment: &str) -> Option<String> {
for word in segment.split_whitespace() {
let word = word.trim_start_matches(['(', '{']);
match word {
"" | "!" | "if" | "then" | "else" | "elif" | "while" | "until" | "do" | "time" => {
continue;
}
"for" | "case" | "select" | "fi" | "done" | "esac" | "}" | ")" | "in" => {
return None;
}
_ => {}
}
if is_assignment(word) {
continue;
}
let word = word.trim_matches(['"', '\'', ')', '}']);
if word.is_empty() || word.starts_with(['$', '<', '>', '#']) {
return None;
}
return Some(word.to_string());
}
None
}
fn is_assignment(word: &str) -> bool {
let Some((name, _)) = word.split_once('=') else {
return false;
};
let mut chars = name.chars();
chars
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
fn heredoc_end(command: &str) -> Option<String> {
let at = scan(command).heredoc?;
let rest = &command[at + 2..];
if rest.starts_with('<') {
return None;
}
let rest = rest.strip_prefix('-').unwrap_or(rest).trim_start();
let word: String = rest
.chars()
.take_while(|c| !c.is_whitespace() && !matches!(c, ';' | '|' | '&' | ')'))
.filter(|c| !matches!(c, '\'' | '"'))
.collect();
(!word.is_empty()).then_some(word)
}
#[cfg(test)]
mod tests {
use super::{programs, Read};
fn names(text: &str, console: bool) -> Vec<(usize, String)> {
programs(text, console)
.into_iter()
.map(|(line, read)| match read {
Read::Program(name) => (line, name),
Read::Unreadable => (line, UNREADABLE.to_string()),
})
.collect()
}
const UNREADABLE: &str = "<unreadable>";
fn pairs(list: &[(usize, &str)]) -> Vec<(usize, String)> {
list.iter().map(|(l, n)| (*l, n.to_string())).collect()
}
#[test]
fn a_prompt_a_comment_and_a_blank_line_are_skipped() {
assert_eq!(
names("# set up\n\n$ headwater check\nFOO=1 git init\n", false),
pairs(&[(2, "headwater"), (3, "git")])
);
}
#[test]
fn every_program_of_a_pipeline_is_read_and_a_quoted_bar_is_not_an_operator() {
let block = "grep -rlZ -E 'a|b' docs \\\n | xargs -0 sed -i \\\n -e 's#a#b#g'\n";
assert_eq!(names(block, false), pairs(&[(0, "grep"), (1, "xargs")]));
assert_eq!(
names("cd x && make; ls | wc -l 2>&1\n", false),
pairs(&[(0, "cd"), (0, "make"), (0, "ls"), (0, "wc")])
);
}
#[test]
fn a_console_block_reads_only_prompted_lines() {
assert_eq!(
names("$ headwater check\nwrote x\n0 findings\n", true),
pairs(&[(0, "headwater")])
);
}
#[test]
fn grammar_and_a_heredoc_body_are_not_programs() {
let block = "if headwater check; then\n echo ok\nfi\ncat > x <<'EOF'\nrm -rf /\nEOF\nfor f in a b; do tar x; done\n";
assert_eq!(
names(block, false),
pairs(&[(0, "headwater"), (1, "echo"), (3, "cat"), (6, "tar")])
);
}
#[test]
fn a_quote_open_at_the_end_of_a_line_leaves_the_rest_of_the_block_unreadable() {
let block = "printf 'a\nheadwater check' && echo RAN-2 && printf 'b'\n";
assert_eq!(
names(block, false),
pairs(&[(0, "printf"), (0, UNREADABLE)])
);
assert_eq!(
names("echo ok\nprintf \"a\nnpm install\n", false),
pairs(&[(0, "echo"), (1, "printf"), (1, UNREADABLE)])
);
}
#[test]
fn every_open_span_leaves_the_rest_of_the_block_unreadable() {
assert_eq!(
names("printf $'a\nheadwater check' && echo RAN\n", false),
pairs(&[(0, "printf"), (0, UNREADABLE)])
);
assert_eq!(
names("echo `date\nnpm install\n", false),
pairs(&[(0, "echo"), (0, UNREADABLE)])
);
assert_eq!(
names("echo $(date\nnpm install\n", false),
pairs(&[(0, "echo"), (0, UNREADABLE)])
);
}
#[test]
fn a_quote_the_block_never_closes_is_flagged() {
assert_eq!(
names("echo ok && npm install '\n", false),
pairs(&[(0, "echo"), (0, "npm"), (0, UNREADABLE)])
);
}
#[test]
fn a_trailing_backslash_joins_and_one_in_a_comment_does_not() {
assert_eq!(
names("headwater check \\\n && npm install\n", false),
pairs(&[(0, "headwater"), (1, "npm")])
);
assert_eq!(
names("headwater check # note \\\nnpm install\n", false),
pairs(&[(0, "headwater"), (1, "npm")])
);
assert_eq!(
names("echo a\\\\\nnpm ci\n", false),
pairs(&[(0, "echo"), (1, "npm")])
);
}
#[test]
fn a_backslash_escapes_a_quote_inside_an_ansi_c_quote() {
assert_eq!(
names("printf $'\\'' && echo RAN-3\n", false),
pairs(&[(0, "printf"), (0, "echo")])
);
}
#[test]
fn a_quote_inside_backticks_opens_nothing() {
assert_eq!(
names("echo `echo it's` && npm install\nnpm ci\n", false),
pairs(&[(0, "echo"), (0, "npm"), (1, "npm")])
);
}
#[test]
fn a_hash_after_a_blank_or_an_operator_opens_a_comment() {
assert_eq!(
names("headwater check # it's here\nnpm install\n", false),
pairs(&[(0, "headwater"), (1, "npm")])
);
assert_eq!(
names("echo a &&# it's\nnpm install\n", false),
pairs(&[(0, "echo"), (1, "npm")])
);
}
#[test]
fn a_hash_after_a_parenthesis_or_a_redirect_is_part_of_a_word() {
assert_eq!(
names("echo $(date)#x && npm install\n", false),
pairs(&[(0, "echo"), (0, "npm")])
);
assert_eq!(
names("(echo a)#'\nnpm install\n", false),
pairs(&[(0, "echo"), (0, UNREADABLE)])
);
assert_eq!(
names("echo hi >#it's\nnpm install\n", false),
pairs(&[(0, "echo"), (0, UNREADABLE)])
);
}
#[test]
fn a_heredoc_opens_only_outside_quotes_and_comments() {
assert_eq!(
names("printf '<<EOF' && echo hi\nnpm install\n", false),
pairs(&[(0, "printf"), (0, "echo"), (1, "npm")])
);
assert_eq!(
names("echo hi # see <<EOF\nnpm install\n", false),
pairs(&[(0, "echo"), (1, "npm")])
);
assert_eq!(
names("cat <<'EOF'\ndon't\nEOF\nnpm install && echo RAN\n", false),
pairs(&[(0, "cat"), (3, "npm"), (3, "echo")])
);
}
#[test]
fn a_double_quote_inside_a_quoted_substitution_is_a_quote_of_its_own() {
assert_eq!(
names("echo \"$(echo \"it's\")\" && npm install\nnpm ci\n", false),
pairs(&[(0, "echo"), (0, "npm"), (1, "npm")])
);
}
#[test]
fn a_hash_inside_a_parameter_expansion_opens_no_comment() {
assert_eq!(
names("echo ${x:-a #b} && npm ci\n", false),
pairs(&[(0, "echo"), (0, "npm")])
);
assert_eq!(
names("git log ${x:-a #b} && npm ci\n", false),
pairs(&[(0, "git"), (0, "npm")])
);
assert_eq!(
names("echo ${x// #/-} && npm ci\n", false),
pairs(&[(0, "echo"), (0, "npm")])
);
}
#[test]
fn an_open_expansion_or_a_comment_inside_an_open_substitution_is_flagged() {
assert_eq!(
names("echo ${x:-a\nnpm ci\n", false),
pairs(&[(0, "echo"), (0, UNREADABLE)])
);
assert_eq!(
names("x=$(ls # c\nnpm ci\n", false),
pairs(&[(0, UNREADABLE)])
);
}
#[test]
fn a_closer_that_does_not_match_the_innermost_span_is_flagged() {
assert_eq!(
names("echo ${x:-$(echo }) #c} && npm ci\nnpm i\n", false),
pairs(&[(0, "echo"), (0, UNREADABLE)])
);
assert_eq!(
names("echo ${x:-a)} && npm ci\nnpm i\n", false),
pairs(&[(0, "echo"), (0, UNREADABLE)])
);
}
#[test]
fn a_span_closed_by_its_own_kind_reads_on() {
assert_eq!(
names("echo ${x:-$(echo a) #c} && npm ci\n", false),
pairs(&[(0, "echo"), (0, "npm")])
);
assert_eq!(
names("echo \"a)\" && npm ci\n", false),
pairs(&[(0, "echo"), (0, "npm")])
);
assert_eq!(
names("{ echo a; } #c && npm ci\nnpm i\n", false),
pairs(&[(0, "echo"), (1, "npm")])
);
}
#[test]
fn an_operator_inside_backticks_starts_a_program() {
assert_eq!(
names("echo `a && npm ci`\n", false),
pairs(&[(0, "echo"), (0, "npm")])
);
}
}