use std::collections::HashMap;
use std::collections::HashSet;
use std::hash::BuildHasher;
use std::path::PathBuf;
use crate::Argument;
use crate::BlockType;
use crate::MdtError;
use crate::MdtResult;
use crate::Transformer;
use crate::TransformerType;
use crate::config::PaddingConfig;
use crate::project::ConsumerEntry;
use crate::project::ProjectContext;
use crate::project::ProviderEntry;
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct TemplateWarning {
pub provider_file: PathBuf,
pub block_name: String,
pub undefined_variables: Vec<String>,
}
#[derive(Debug)]
#[non_exhaustive]
pub struct CheckResult {
pub stale: Vec<StaleEntry>,
pub render_errors: Vec<RenderError>,
pub warnings: Vec<TemplateWarning>,
}
impl CheckResult {
pub fn is_ok(&self) -> bool {
self.stale.is_empty() && self.render_errors.is_empty()
}
pub fn has_errors(&self) -> bool {
!self.render_errors.is_empty()
}
pub fn has_warnings(&self) -> bool {
!self.warnings.is_empty()
}
}
#[derive(Debug)]
#[non_exhaustive]
pub struct RenderError {
pub file: PathBuf,
pub block_name: String,
pub message: String,
pub line: usize,
pub column: usize,
}
#[derive(Debug)]
#[non_exhaustive]
pub struct StaleEntry {
pub file: PathBuf,
pub block_name: String,
pub current_content: String,
pub expected_content: String,
pub line: usize,
pub column: usize,
}
#[derive(Debug)]
#[non_exhaustive]
pub struct UpdateResult {
pub updated_files: HashMap<PathBuf, String>,
pub updated_count: usize,
pub warnings: Vec<TemplateWarning>,
}
#[allow(clippy::implicit_hasher)]
pub fn render_template(
content: &str,
data: &HashMap<String, serde_json::Value>,
) -> MdtResult<String> {
if data.is_empty() || !has_template_syntax(content) {
return Ok(content.to_string());
}
let mut env = minijinja::Environment::new();
env.set_keep_trailing_newline(true);
env.set_undefined_behavior(minijinja::UndefinedBehavior::Chainable);
env.add_template("__inline__", content)
.map_err(|e| MdtError::TemplateRender(e.to_string()))?;
let template = env
.get_template("__inline__")
.map_err(|e| MdtError::TemplateRender(e.to_string()))?;
let ctx = minijinja::Value::from_serialize(data);
template
.render(ctx)
.map_err(|e| MdtError::TemplateRender(e.to_string()))
}
#[allow(clippy::implicit_hasher)]
pub fn find_undefined_variables(
content: &str,
data: &HashMap<String, serde_json::Value>,
) -> Vec<String> {
if data.is_empty() || !has_template_syntax(content) {
return Vec::new();
}
let mut env = minijinja::Environment::new();
env.set_keep_trailing_newline(true);
let Ok(()) = env.add_template("__inline__", content) else {
return Vec::new();
};
let Ok(template) = env.get_template("__inline__") else {
return Vec::new();
};
let undeclared: HashSet<String> = template.undeclared_variables(true);
let top_level_names: HashSet<String> = data.keys().cloned().collect();
let mut undefined: Vec<String> = undeclared
.into_iter()
.filter(|var| {
let top_level = var.split('.').next().unwrap_or(var);
!top_level_names.contains(top_level) && !is_builtin_variable(top_level)
})
.collect();
undefined.sort();
undefined
}
fn is_builtin_variable(name: &str) -> bool {
matches!(
name,
"loop" | "self" | "super" | "true" | "false" | "none" | "namespace" | "range" | "dict"
)
}
fn has_template_syntax(content: &str) -> bool {
content.contains("{{") || content.contains("{%") || content.contains("{#")
}
pub fn build_render_context<S: BuildHasher + Clone>(
base_data: &HashMap<String, serde_json::Value, S>,
provider: &ProviderEntry,
consumer: &ConsumerEntry,
) -> Option<HashMap<String, serde_json::Value, S>> {
let param_count = provider.block.arguments.len();
let arg_count = consumer.block.arguments.len();
if param_count != arg_count && (param_count > 0 || arg_count > 0) {
return None;
}
if provider.block.arguments.is_empty() {
return Some(base_data.clone());
}
let mut data = base_data.clone();
for (name, value) in provider
.block
.arguments
.iter()
.zip(consumer.block.arguments.iter())
{
data.insert(name.clone(), serde_json::Value::String(value.clone()));
}
Some(data)
}
pub fn check_project(ctx: &ProjectContext) -> MdtResult<CheckResult> {
let mut stale = Vec::new();
let mut render_errors = Vec::new();
let warnings = collect_template_warnings(ctx);
for consumer in &ctx.project.consumers {
match consumer.block.r#type {
BlockType::Consumer => {
let Some(provider) = ctx.project.providers.get(&consumer.block.name) else {
continue;
};
let Some(render_data) = build_render_context(&ctx.data, provider, consumer) else {
render_errors.push(RenderError {
file: consumer.file.clone(),
block_name: consumer.block.name.clone(),
message: format!(
"argument count mismatch: provider `{}` declares {} parameter(s), but \
consumer passes {}",
consumer.block.name,
provider.block.arguments.len(),
consumer.block.arguments.len(),
),
line: consumer.block.opening.start.line,
column: consumer.block.opening.start.column,
});
continue;
};
let rendered = match render_template(&provider.content, &render_data) {
Ok(r) => r,
Err(e) => {
render_errors.push(RenderError {
file: consumer.file.clone(),
block_name: consumer.block.name.clone(),
message: e.to_string(),
line: consumer.block.opening.start.line,
column: consumer.block.opening.start.column,
});
continue;
}
};
let mut expected = apply_transformers_with_data(
&rendered,
&consumer.block.transformers,
Some(&render_data),
);
if let Some(padding) = &ctx.padding {
expected = pad_content_with_config(&expected, &consumer.content, padding);
}
if consumer.content != expected {
stale.push(StaleEntry {
file: consumer.file.clone(),
block_name: consumer.block.name.clone(),
current_content: consumer.content.clone(),
expected_content: expected,
line: consumer.block.opening.start.line,
column: consumer.block.opening.start.column,
});
}
}
BlockType::Inline => {
let Some(template) = consumer.block.arguments.first() else {
render_errors.push(RenderError {
file: consumer.file.clone(),
block_name: consumer.block.name.clone(),
message: "inline block requires one template argument, e.g. <!-- \
{~name:\"{{ pkg.version }}\"} -->"
.to_string(),
line: consumer.block.opening.start.line,
column: consumer.block.opening.start.column,
});
continue;
};
let rendered = match render_template(template, &ctx.data) {
Ok(r) => r,
Err(e) => {
render_errors.push(RenderError {
file: consumer.file.clone(),
block_name: consumer.block.name.clone(),
message: e.to_string(),
line: consumer.block.opening.start.line,
column: consumer.block.opening.start.column,
});
continue;
}
};
let expected = apply_transformers_with_data(
&rendered,
&consumer.block.transformers,
Some(&ctx.data),
);
if consumer.content != expected {
stale.push(StaleEntry {
file: consumer.file.clone(),
block_name: consumer.block.name.clone(),
current_content: consumer.content.clone(),
expected_content: expected,
line: consumer.block.opening.start.line,
column: consumer.block.opening.start.column,
});
}
}
BlockType::Provider => {}
}
}
Ok(CheckResult {
stale,
render_errors,
warnings,
})
}
pub fn compute_updates(ctx: &ProjectContext) -> MdtResult<UpdateResult> {
let mut file_contents: HashMap<PathBuf, String> = HashMap::new();
let mut updated_count = 0;
let warnings = collect_template_warnings(ctx);
let mut consumers_by_file: HashMap<PathBuf, Vec<&ConsumerEntry>> = HashMap::new();
for consumer in &ctx.project.consumers {
consumers_by_file
.entry(consumer.file.clone())
.or_default()
.push(consumer);
}
for (file, consumers) in &consumers_by_file {
let original = if let Some(content) = file_contents.get(file) {
content.clone()
} else {
std::fs::read_to_string(file)?
};
let mut result = original.clone();
let mut had_update = false;
let mut sorted_consumers: Vec<&&ConsumerEntry> = consumers.iter().collect();
sorted_consumers
.sort_by(|a, b| b.block.opening.end.offset.cmp(&a.block.opening.end.offset));
for consumer in sorted_consumers {
let new_content = match consumer.block.r#type {
BlockType::Consumer => {
let Some(provider) = ctx.project.providers.get(&consumer.block.name) else {
continue;
};
let Some(render_data) = build_render_context(&ctx.data, provider, consumer)
else {
continue; };
let rendered = render_template(&provider.content, &render_data)?;
let mut new_content = apply_transformers_with_data(
&rendered,
&consumer.block.transformers,
Some(&render_data),
);
if let Some(padding) = &ctx.padding {
new_content =
pad_content_with_config(&new_content, &consumer.content, padding);
}
new_content
}
BlockType::Inline => {
let Some(template) = consumer.block.arguments.first() else {
continue;
};
let rendered = render_template(template, &ctx.data)?;
apply_transformers_with_data(
&rendered,
&consumer.block.transformers,
Some(&ctx.data),
)
}
BlockType::Provider => continue,
};
if consumer.content != new_content {
let start = consumer.block.opening.end.offset;
let end = consumer.block.closing.start.offset;
if start <= end && end <= result.len() {
let mut buf =
String::with_capacity(result.len() - (end - start) + new_content.len());
buf.push_str(&result[..start]);
buf.push_str(&new_content);
buf.push_str(&result[end..]);
result = buf;
had_update = true;
updated_count += 1;
}
}
}
if had_update {
file_contents.insert(file.clone(), result);
}
}
Ok(UpdateResult {
updated_files: file_contents,
updated_count,
warnings,
})
}
fn collect_template_warnings(ctx: &ProjectContext) -> Vec<TemplateWarning> {
let mut warnings = Vec::new();
let mut checked_providers: HashSet<String> = HashSet::new();
for consumer in &ctx.project.consumers {
if consumer.block.r#type != BlockType::Consumer {
continue;
}
let name = &consumer.block.name;
if checked_providers.contains(name) {
continue;
}
checked_providers.insert(name.clone());
let Some(provider) = ctx.project.providers.get(name) else {
continue;
};
let data_with_params = if provider.block.arguments.is_empty() {
std::borrow::Cow::Borrowed(&ctx.data)
} else {
let mut data = ctx.data.clone();
for param in &provider.block.arguments {
data.entry(param.clone())
.or_insert(serde_json::Value::String(String::new()));
}
std::borrow::Cow::Owned(data)
};
let undefined = find_undefined_variables(&provider.content, &data_with_params);
if !undefined.is_empty() {
warnings.push(TemplateWarning {
provider_file: provider.file.clone(),
block_name: name.clone(),
undefined_variables: undefined,
});
}
}
warnings
}
pub fn write_updates(updates: &UpdateResult) -> MdtResult<()> {
for (path, content) in &updates.updated_files {
std::fs::write(path, content)?;
}
Ok(())
}
pub fn apply_transformers(content: &str, transformers: &[Transformer]) -> String {
apply_transformers_with_data(content, transformers, None)
}
#[allow(clippy::implicit_hasher)]
pub fn apply_transformers_with_data(
content: &str,
transformers: &[Transformer],
data: Option<&HashMap<String, serde_json::Value>>,
) -> String {
let mut result = content.to_string();
for transformer in transformers {
result = apply_transformer(&result, transformer, data);
}
result
}
fn apply_transformer(
content: &str,
transformer: &Transformer,
data: Option<&HashMap<String, serde_json::Value>>,
) -> String {
match transformer.r#type {
TransformerType::Trim => content.trim().to_string(),
TransformerType::TrimStart => content.trim_start().to_string(),
TransformerType::TrimEnd => content.trim_end().to_string(),
TransformerType::Indent => {
let indent_str = get_string_arg(&transformer.args, 0).unwrap_or_default();
let include_empty = get_bool_arg(&transformer.args, 1).unwrap_or(false);
content
.lines()
.map(|line| {
if line.is_empty() && !include_empty {
String::new()
} else {
format!("{indent_str}{line}")
}
})
.collect::<Vec<_>>()
.join("\n")
}
TransformerType::Prefix => {
let prefix = get_string_arg(&transformer.args, 0).unwrap_or_default();
format!("{prefix}{content}")
}
TransformerType::Wrap => {
let wrapper = get_string_arg(&transformer.args, 0).unwrap_or_default();
format!("{wrapper}{content}{wrapper}")
}
TransformerType::CodeBlock => {
let lang = get_string_arg(&transformer.args, 0).unwrap_or_default();
format!("```{lang}\n{content}\n```")
}
TransformerType::Code => {
format!("`{content}`")
}
TransformerType::Replace => {
let search = get_string_arg(&transformer.args, 0).unwrap_or_default();
let replacement = get_string_arg(&transformer.args, 1).unwrap_or_default();
content.replace(&search, &replacement)
}
TransformerType::Suffix => {
let suffix = get_string_arg(&transformer.args, 0).unwrap_or_default();
format!("{content}{suffix}")
}
TransformerType::LinePrefix => {
let prefix = get_string_arg(&transformer.args, 0).unwrap_or_default();
let include_empty = get_bool_arg(&transformer.args, 1).unwrap_or(false);
content
.lines()
.map(|line| {
if line.is_empty() && !include_empty {
String::new()
} else if line.is_empty() {
prefix.trim_end().to_string()
} else {
format!("{prefix}{line}")
}
})
.collect::<Vec<_>>()
.join("\n")
}
TransformerType::LineSuffix => {
let suffix = get_string_arg(&transformer.args, 0).unwrap_or_default();
let include_empty = get_bool_arg(&transformer.args, 1).unwrap_or(false);
content
.lines()
.map(|line| {
if line.is_empty() && !include_empty {
String::new()
} else if line.is_empty() {
suffix.trim_start().to_string()
} else {
format!("{line}{suffix}")
}
})
.collect::<Vec<_>>()
.join("\n")
}
TransformerType::If => {
let path = get_string_arg(&transformer.args, 0).unwrap_or_default();
if is_data_path_truthy(data, &path) {
content.to_string()
} else {
String::new()
}
}
}
}
fn is_data_path_truthy(data: Option<&HashMap<String, serde_json::Value>>, path: &str) -> bool {
let Some(data) = data else {
return false;
};
let mut parts = path.split('.');
let Some(root) = parts.next() else {
return false;
};
let Some(mut current) = data.get(root) else {
return false;
};
for part in parts {
match current {
serde_json::Value::Object(map) => {
let Some(next) = map.get(part) else {
return false;
};
current = next;
}
_ => return false,
}
}
is_json_value_truthy(current)
}
fn is_json_value_truthy(value: &serde_json::Value) -> bool {
match value {
serde_json::Value::Null => false,
serde_json::Value::Bool(b) => *b,
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
i != 0
} else if let Some(u) = n.as_u64() {
u != 0
} else if let Some(f) = n.as_f64() {
f != 0.0
} else {
true
}
}
serde_json::Value::String(s) => !s.is_empty(),
serde_json::Value::Array(_) | serde_json::Value::Object(_) => true,
}
}
pub fn validate_transformers(transformers: &[Transformer]) -> MdtResult<()> {
for t in transformers {
let (min, max) = match t.r#type {
TransformerType::Trim
| TransformerType::TrimStart
| TransformerType::TrimEnd
| TransformerType::Code => (0, 0),
TransformerType::Prefix
| TransformerType::Suffix
| TransformerType::Wrap
| TransformerType::CodeBlock => (0, 1),
TransformerType::Indent | TransformerType::LinePrefix | TransformerType::LineSuffix => {
(0, 2)
}
TransformerType::Replace => (2, 2),
TransformerType::If => (1, 1),
};
if t.args.len() < min || t.args.len() > max {
let expected = if min == max {
format!("{min}")
} else {
format!("{min}-{max}")
};
return Err(MdtError::InvalidTransformerArgs {
name: t.r#type.to_string(),
expected,
got: t.args.len(),
});
}
}
Ok(())
}
fn pad_content_with_config(
new_content: &str,
original_content: &str,
padding: &PaddingConfig,
) -> String {
let trailing_prefix = original_content
.rfind('\n')
.map_or("", |idx| &original_content[idx + 1..]);
let blank_line_prefix = trailing_prefix.trim_end();
let mut result = String::with_capacity(new_content.len() + trailing_prefix.len() * 4 + 8);
match padding.before.line_count() {
None => {
}
Some(0) => {
if !new_content.starts_with('\n') {
result.push('\n');
}
}
Some(n) => {
if !new_content.starts_with('\n') {
result.push('\n');
}
for _ in 0..n {
result.push_str(blank_line_prefix);
result.push('\n');
}
}
}
result.push_str(new_content);
match padding.after.line_count() {
None => {
}
Some(0) => {
if !new_content.ends_with('\n') {
result.push('\n');
}
result.push_str(trailing_prefix);
}
Some(n) => {
if !new_content.ends_with('\n') {
result.push('\n');
}
for _ in 0..n {
result.push_str(blank_line_prefix);
result.push('\n');
}
result.push_str(trailing_prefix);
}
}
result
}
fn get_string_arg(args: &[Argument], index: usize) -> Option<String> {
args.get(index).map(|arg| {
match arg {
Argument::String(s) => s.clone(),
Argument::Number(n) => n.to_string(),
Argument::Boolean(b) => b.to_string(),
}
})
}
fn get_bool_arg(args: &[Argument], index: usize) -> Option<bool> {
args.get(index).map(|arg| {
match arg {
Argument::Boolean(b) => *b,
Argument::String(s) => s == "true",
Argument::Number(n) => n.0 != 0.0,
}
})
}