use std::path::{Path, PathBuf};
const SKIPPED: &[&str] = &[
".git",
".wsb",
".scrap",
"node_modules",
"target",
"build",
"dist",
".venv",
"venv",
"__pycache__",
];
const HOLE: char = '\u{0}';
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LocalRender {
pub file: String,
pub line: usize,
pub text: String,
pub escapes: Vec<String>,
}
impl LocalRender {
pub fn message(&self) -> String {
format!("{}:{} {}", self.file, self.line, self.text)
}
pub fn travels(&self) -> bool {
self.escapes.is_empty()
}
}
pub fn refusal(found: &[LocalRender]) -> String {
const SHOWN: usize = 5;
let mut lines: Vec<String> = found
.iter()
.take(SHOWN)
.map(|one| format!(" {}", one.message()))
.collect();
if found.len() > SHOWN {
lines.push(format!(" … and {} more", found.len() - SHOWN));
}
format!(
"this tree is rendered against this machine — `wsb local on` has not been taken back. \
{} generated manifest(s) point at working copies here:\n{}\n Nothing built from them \
can be reproduced from a commit, and no registry refuses a binary. Take the render back \
first:\n wsb local off",
found.len(),
lines.join("\n")
)
}
pub fn escaping(found: &[LocalRender]) -> Vec<&LocalRender> {
found.iter().filter(|one| !one.travels()).collect()
}
pub fn staging_refusal(escaping: &[&LocalRender]) -> String {
let mut lines: Vec<String> = Vec::new();
for one in escaping {
lines.push(format!(" {}", one.message()));
for reference in &one.escapes {
lines.push(format!(" names {reference}"));
}
}
format!(
"{} reference(s) in this tree's generated manifests name a place \
OUTSIDE the workspace, and copying the workspace does not bring \
them:\n{}\n A local render that stays inside the workspace travels \
with it and is no obstacle here. Give these one relative to the \
manifest that names them — or, if that is not expressible, take the \
render back:\n wsb local off",
escaping.len(),
lines.join("\n")
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Persistence {
Committed,
NotRestored,
Untracked,
}
impl Persistence {
pub fn describe(self) -> &'static str {
match self {
Self::Committed => "committed local render — the history carries it",
Self::NotRestored => "not restored — the take-back did not reach this repository",
Self::Untracked => "untracked — git holds no committed form of it",
}
}
}
pub fn persistence(root: &Path, one: &LocalRender, variable: &str) -> Persistence {
let rendered = root.join(&one.file);
let template = PathBuf::from(format!("{}.wstemplate", rendered.display()));
let Ok(template_text) = std::fs::read_to_string(&template) else {
return Persistence::Untracked;
};
let Ok(repo) = gix::discover(rendered.parent().unwrap_or(root)) else {
return Persistence::Untracked;
};
let Some(workdir) = repo.workdir().map(Path::to_path_buf) else {
return Persistence::Untracked;
};
let Ok(relative) = rendered.strip_prefix(&workdir) else {
return Persistence::Untracked;
};
let Ok(commit) = repo.head_commit() else {
return Persistence::Untracked;
};
let Ok(mut tree) = commit.tree() else {
return Persistence::Untracked;
};
let Ok(Some(entry)) = tree.peel_to_entry_by_path(relative) else {
return Persistence::Untracked;
};
let Ok(object) = entry.object() else {
return Persistence::Untracked;
};
let Ok(committed) = std::str::from_utf8(&object.data) else {
return Persistence::Untracked;
};
if carried_by(&template_text, committed, variable).is_empty() {
Persistence::NotRestored
} else {
Persistence::Committed
}
}
pub fn scan_workspace(start: &Path, variable: &str) -> Result<Vec<LocalRender>, String> {
let root = crate::workspace::find_super_root(start).unwrap_or_else(|| start.to_path_buf());
scan(&root, variable)
}
pub fn scan(dir: &Path, variable: &str) -> Result<Vec<LocalRender>, String> {
let mut found = Vec::new();
for template_path in templates_under(dir) {
let template = match std::fs::read_to_string(&template_path) {
Ok(text) => text,
Err(e) => {
return Err(format!(
"reading {}: {e}",
template_path.display()
))
}
};
if !template.contains(variable) {
continue;
}
let rendered_path = rendered_form(&template_path);
let Ok(rendered) = std::fs::read_to_string(&rendered_path) else {
continue;
};
let relative =
crate::platform::slash_path(rendered_path.strip_prefix(dir).unwrap_or(&rendered_path));
if let Some(carried) = carried_by(&template, &rendered, variable).into_iter().next() {
let escapes = escaping_references(dir, &rendered_path, &carried.block);
found.push(LocalRender {
file: relative,
line: carried.line,
text: carried.text,
escapes,
});
}
}
found.sort_by(|a, b| a.file.cmp(&b.file));
Ok(found)
}
pub fn local_render_reads_build_root(dir: &Path, variable: &str) -> Result<bool, String> {
for template_path in templates_under(dir) {
let template = std::fs::read_to_string(&template_path)
.map_err(|e| format!("reading {}: {e}", template_path.display()))?;
if !template.contains(variable) {
continue;
}
let references = crate::state::template_refs::TemplateReferences::parse(&template)
.map_err(|e| format!("{}: {e:#}", template_path.display()))?;
if !references.build_fields().is_empty() {
return Ok(true);
}
}
Ok(false)
}
fn escaping_references(root: &Path, manifest: &Path, block: &[String]) -> Vec<String> {
let mut escaping: Vec<String> = Vec::new();
for line in block {
for reference in references(line) {
if escapes(root, manifest, &reference) && !escaping.contains(&reference) {
escaping.push(reference);
}
}
}
escaping
}
fn references(line: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for piece in line.split(|c: char| c.is_whitespace() || c == '"' || c == '\'') {
let token = piece.trim_matches(|c: char| matches!(c, ',' | ';' | ')' | '=' | '>' | '<'));
if !token.chars().any(char::is_alphanumeric) {
continue;
}
let path = token
.strip_prefix("file://")
.or_else(|| token.strip_prefix("file:"))
.unwrap_or(token);
if path.contains("://") {
continue;
}
let path_like = is_absolute_anywhere(path)
|| path.contains('/')
|| path.contains('\\')
|| path.starts_with('.');
if path_like && !out.iter().any(|seen| seen == path) {
out.push(path.to_string());
}
}
out
}
fn escapes(root: &Path, manifest: &Path, reference: &str) -> bool {
if is_absolute_anywhere(reference) {
return true;
}
let Some(dir) = manifest.parent() else {
return true;
};
let reference = reference.replace('\\', "/");
!normalised(&dir.join(reference)).starts_with(normalised(root))
}
fn is_absolute_anywhere(reference: &str) -> bool {
let bytes = reference.as_bytes();
reference.starts_with('/')
|| reference.starts_with('\\')
|| reference.starts_with('~')
|| (bytes.len() >= 3
&& bytes[0].is_ascii_alphabetic()
&& bytes[1] == b':'
&& (bytes[2] == b'/' || bytes[2] == b'\\'))
}
fn normalised(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for part in path.components() {
match part {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
out.pop();
}
other => out.push(other.as_os_str()),
}
}
out
}
fn rendered_form(template: &Path) -> PathBuf {
let name = template.to_string_lossy();
PathBuf::from(name.trim_end_matches(".wstemplate").to_string())
}
fn templates_under(dir: &Path) -> Vec<PathBuf> {
walkdir::WalkDir::new(dir)
.follow_links(false)
.into_iter()
.filter_entry(|entry| {
entry.depth() == 0
|| !entry
.file_name()
.to_str()
.is_some_and(|name| SKIPPED.contains(&name))
})
.filter_map(Result::ok)
.filter(|entry| entry.file_type().is_file())
.map(|entry| entry.into_path())
.filter(|path| {
path.to_str()
.is_some_and(|name| name.ends_with(".wstemplate"))
})
.collect()
}
pub fn carried_by(template: &str, rendered: &str, variable: &str) -> Vec<Carried> {
let (local_form, blocks) = resolve(template, true, variable);
if blocks.is_empty() {
return Vec::new();
}
let (other_form, _) = resolve(template, false, variable);
let other: Vec<String> = lines_of(&other_form).into_iter().map(|(_, p)| p).collect();
let actual: Vec<(usize, String)> = rendered
.lines()
.enumerate()
.map(|(index, line)| (index + 1, line.trim().to_string()))
.filter(|(_, text)| !text.is_empty())
.collect();
let local_lines = lines_of(&local_form);
let mut hits = Vec::new();
for block in &blocks {
let wanted: Vec<&String> = local_lines
.iter()
.filter(|(line, _)| block.contains(line))
.map(|(_, pattern)| pattern)
.collect();
if wanted.is_empty() {
continue;
}
if wanted.iter().all(|pattern| other.contains(pattern)) {
continue;
}
let Some(at) = position_of(&wanted, &actual) else {
continue;
};
let offset = wanted
.iter()
.position(|pattern| !other.contains(*pattern))
.unwrap_or(0);
hits.push(Carried {
line: actual[at + offset].0,
text: actual[at + offset].1.clone(),
block: actual[at..at + wanted.len()]
.iter()
.map(|(_, text)| text.clone())
.collect(),
});
}
hits.sort_by_key(|hit| hit.line);
hits
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Carried {
pub line: usize,
pub text: String,
pub block: Vec<String>,
}
fn position_of(wanted: &[&String], actual: &[(usize, String)]) -> Option<usize> {
if wanted.len() > actual.len() {
return None;
}
(0..=actual.len() - wanted.len()).find(|&start| {
wanted
.iter()
.enumerate()
.all(|(offset, pattern)| matches(pattern, &actual[start + offset].1))
})
}
fn lines_of(resolved: &str) -> Vec<(usize, String)> {
resolved
.lines()
.enumerate()
.filter_map(|(index, line)| {
let trimmed = line.trim();
if trimmed.is_empty() {
None
} else {
Some((index + 1, blank_expressions(trimmed)))
}
})
.collect()
}
fn resolve(
template: &str,
take_local: bool,
variable: &str,
) -> (String, Vec<std::ops::RangeInclusive<usize>>) {
let mut out = String::with_capacity(template.len());
let mut blocks = Vec::new();
let mut rest = template;
loop {
let Some((before, opening, after)) = next_tag(rest, &format!("if env.{variable}")) else {
out.push_str(rest);
return (out, blocks);
};
out.push_str(before);
if opening.starts_with("{%-") {
while out.ends_with(' ') || out.ends_with('\t') {
out.pop();
}
if out.ends_with('\n') {
out.pop();
}
}
let Some((branch, tail)) = branch_and_tail(after) else {
out.push_str(after);
return (out, blocks);
};
let taken = if take_local { branch.local } else { branch.other };
let first = line_at(&out, out.len());
out.push_str(taken);
let last = line_at(&out, out.len());
if !taken.trim().is_empty() {
blocks.push(first..=last);
}
rest = tail;
}
}
fn line_at(text: &str, offset: usize) -> usize {
text[..offset].matches('\n').count() + 1
}
struct Branches<'a> {
local: &'a str,
other: &'a str,
}
fn branch_and_tail(after: &str) -> Option<(Branches<'_>, &str)> {
let (before_end, end_tag, tail) = next_tag(after, "endif")?;
let mut body = before_end;
if end_tag.starts_with("{%-") {
body = body.trim_end_matches([' ', '\t']);
body = body.strip_suffix('\n').unwrap_or(body);
}
match next_tag(body, "else") {
Some((local, else_tag, other)) => {
let mut local = local;
if else_tag.starts_with("{%-") {
local = local.trim_end_matches([' ', '\t']);
local = local.strip_suffix('\n').unwrap_or(local);
}
Some((Branches { local, other }, tail))
}
None => Some((Branches { local: body, other: "" }, tail)),
}
}
fn next_tag<'a>(text: &'a str, keyword: &str) -> Option<(&'a str, &'a str, &'a str)> {
let mut search = 0usize;
while let Some(offset) = text[search..].find("{%") {
let start = search + offset;
let length = text[start..].find("%}")?;
let end = start + length + 2;
let tag = &text[start..end];
let body = tag
.trim_start_matches("{%-")
.trim_start_matches("{%")
.trim_end_matches("-%}")
.trim_end_matches("%}")
.trim();
if body.starts_with(keyword) {
return Some((&text[..start], tag, &text[end..]));
}
search = end;
}
None
}
fn blank_expressions(line: &str) -> String {
let mut out = String::with_capacity(line.len());
let mut rest = line;
while let Some(start) = rest.find("{{") {
out.push_str(&rest[..start]);
let Some(length) = rest[start..].find("}}") else {
out.push_str(&rest[start..]);
return out;
};
out.push(HOLE);
rest = &rest[start + length + 2..];
}
out.push_str(rest);
out
}
fn matches(pattern: &str, line: &str) -> bool {
let mut parts = pattern.split(HOLE);
let Some(first) = parts.next() else {
return false;
};
let Some(mut rest) = line.strip_prefix(first) else {
return false;
};
let tail: Vec<&str> = parts.collect();
if tail.is_empty() {
return rest.is_empty();
}
for (index, part) in tail.iter().enumerate() {
let last = index + 1 == tail.len();
if last {
if part.is_empty() {
return !rest.is_empty();
}
let Some(at) = rest.rfind(part) else {
return false;
};
return at > 0 && at + part.len() == rest.len();
}
let Some(at) = rest.find(part) else {
return false;
};
if at == 0 {
return false;
}
rest = &rest[at + part.len()..];
}
false
}
#[cfg(test)]
#[path = "local_render_tests.rs"]
mod tests;