use crate::command::SHELLS;
use crate::finding::{Finding, Severity};
use crate::instance::Outcome;
use crate::scope::{DocumentCheck, DocumentView};
use crate::shape::Shape;
use headwater_doc::body::{BlockKind, Ownership};
use headwater_yaml::{Spanned, Value};
pub const RULE: &str = "surface.local_path.instructed";
pub struct LocalPath {
adopter_documents: Vec<String>,
local_roots: Vec<String>,
programs: Vec<String>,
}
impl LocalPath {
pub fn over(shape: &Shape) -> Self {
LocalPath {
adopter_documents: shape.surface.adopter_documents.clone(),
local_roots: shape.surface.local_roots.clone(),
programs: shape.surface.programs.clone(),
}
}
fn declared(&self) -> bool {
!self.adopter_documents.is_empty() && !self.local_roots.is_empty()
}
fn for_an_adopter(&self, path: &str) -> bool {
self.adopter_documents
.iter()
.any(|glob| glob_matches(glob, path))
}
fn root_of(&self, token: &str, bare_counts: bool) -> Option<&str> {
let token = strip_variable(token);
let token = token.strip_prefix("./").unwrap_or(token);
self.local_roots
.iter()
.find(|root| {
token.starts_with(root.as_str()) || (bare_counts && self.bare(token, root))
})
.map(String::as_str)
}
fn bare(&self, token: &str, root: &str) -> bool {
token == root.trim_end_matches('/') && !self.programs.iter().any(|name| name == token)
}
}
impl DocumentCheck for LocalPath {
const RULE: &'static str = self::RULE;
const VERSION: u32 = 5;
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();
let mut report = |line: usize, column: usize, token: &str, root: &str, place: &str| {
findings.push(Finding {
rule: self::RULE,
severity: Severity::Error,
obligation: None,
path: view.path().to_string(),
line,
column,
message: format!(
"{place} names `{token}`, and `{root}` holds files only this repository has (HW-DR-0077, population 4)"
),
remediation: format!(
"name the `headwater` verb or the declared prerequisite that does this, or mark the passage as how this repository does it with `<!-- headwater allow={} scope=block reason=accepted_deviation ... -->`",
self::RULE
),
patch: None,
});
};
for (key, node) in view
.facets()
.iter()
.map(|entry| (&entry.key.value, &entry.value))
{
for item in paths_in(node) {
let Some(text) = item.value.as_scalar().map(|scalar| scalar.text.as_str()) else {
continue;
};
if text.contains(char::is_whitespace) {
continue;
}
if let Some(root) = self.root_of(text, false) {
report(
item.span.start.line,
item.span.start.col,
text,
root,
&format!("the `{key}` front-matter value"),
);
}
}
}
for block in view
.body()
.map(|body| body.blocks.as_slice())
.unwrap_or(&[])
{
if block.kind == BlockKind::Html {
continue;
}
let place = match block.kind {
BlockKind::Code => "a code block",
_ => "a code span",
};
let bare_counts = match block.kind {
BlockKind::Code => block
.info
.as_deref()
.is_some_and(|info| SHELLS.contains(&info)),
_ => true,
};
for run in &block.runs {
if run.ownership != Ownership::Code || run.text.trim_start().starts_with('<') {
continue;
}
for (offset, line) in run.text.split('\n').enumerate() {
for token in line.split(|c: char| {
c.is_whitespace()
|| matches!(c, '`' | '"' | '\'' | '(' | ')' | '=' | ',' | ';')
}) {
if let Some(root) = self.root_of(token, bare_counts) {
let line_number = run.span.start.line + offset;
let column = match offset {
0 => run.span.start.col,
_ => 1,
};
report(line_number, column, token, root, place);
}
}
}
}
}
findings.sort_by_key(|finding| (finding.line, finding.column));
Outcome::failed(findings)
}
}
fn strip_variable(token: &str) -> &str {
let Some(rest) = token.strip_prefix('$') else {
return token;
};
let rest = match rest.strip_prefix('{') {
Some(braced) => match braced.split_once('}') {
Some((name, tail)) if !name.is_empty() && name != "HOME" => tail,
_ => return token,
},
None => {
let end = rest
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
.unwrap_or(rest.len());
match &rest[..end] {
"" | "HOME" => return token,
_ => &rest[end..],
}
}
};
rest.strip_prefix('/').unwrap_or(token)
}
fn paths_in(node: &Spanned<Value>) -> Vec<&Spanned<Value>> {
match (&node.value.as_seq(), &node.value.as_map()) {
(Some(items), _) => items.iter().flat_map(paths_in).collect(),
(None, Some(map)) => map
.iter()
.flat_map(|entry| paths_in(&entry.value))
.collect(),
(None, None) => vec![node],
}
}
pub fn glob_matches(glob: &str, path: &str) -> bool {
let glob: Vec<&str> = glob.split('/').collect();
let path: Vec<&str> = path.split('/').collect();
segments(&glob, &path)
}
fn segments(glob: &[&str], path: &[&str]) -> bool {
match glob.split_first() {
None => path.is_empty(),
Some((&"**", rest)) => (0..=path.len()).any(|skip| segments(rest, &path[skip..])),
Some((first, rest)) => match path.split_first() {
Some((segment, tail)) => segment_matches(first, segment) && segments(rest, tail),
None => false,
},
}
}
fn segment_matches(glob: &str, text: &str) -> bool {
match glob.split_once('*') {
None => glob == text,
Some((head, tail)) => {
text.len() >= head.len()
&& text.starts_with(head)
&& (0..=text.len() - head.len())
.filter(|at| text.is_char_boundary(head.len() + at))
.any(|at| segment_matches(tail, &text[head.len() + at..]))
}
}
}
#[cfg(test)]
mod tests {
use super::{glob_matches, strip_variable};
#[test]
fn a_shell_variable_before_a_path_is_removed() {
assert_eq!(strip_variable("$ROOT/tools/x.sh"), "tools/x.sh");
assert_eq!(strip_variable("${ROOT}/tools/x.sh"), "tools/x.sh");
assert_eq!(strip_variable("$ROOT"), "$ROOT");
assert_eq!(strip_variable("$/tools"), "$/tools");
assert_eq!(strip_variable("tools/x.sh"), "tools/x.sh");
assert_eq!(strip_variable("$HOME/.claude/x"), "$HOME/.claude/x");
assert_eq!(strip_variable("${HOME}/.claude/x"), "${HOME}/.claude/x");
}
#[test]
fn a_glob_matches_inside_a_segment_and_across_segments() {
assert!(glob_matches(
"docs/interfaces/*.md",
"docs/interfaces/headwater-check.md"
));
assert!(!glob_matches(
"docs/interfaces/*.md",
"docs/interfaces/sub/x.md"
));
assert!(glob_matches("docs/tutorials/**", "docs/tutorials/a/b.md"));
assert!(glob_matches("README.md", "README.md"));
assert!(!glob_matches(
"docs/how-to/relocate-*.md",
"docs/how-to/wire-x.md"
));
}
}