use std::cmp::Reverse;
use std::collections::HashMap;
use std::collections::HashSet;
use std::hash::BuildHasher;
use std::io::Write;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
use std::process::Stdio;
use tracing::debug;
use tracing::instrument;
use tracing::trace;
use tracing::warn;
use crate::Argument;
use crate::BlockType;
use crate::MdtError;
use crate::MdtResult;
use crate::Transformer;
use crate::TransformerType;
use crate::config::PaddingConfig;
use crate::parser::parse_with_diagnostics;
use crate::project::ConsumerEntry;
use crate::project::ProjectContext;
use crate::project::ProviderEntry;
use crate::project::extract_content_between_tags;
use crate::project::is_markdown_path;
use crate::project::normalize_line_endings;
use crate::source_scanner::parse_source_with_diagnostics;
#[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 stale_files: Vec<StaleFileEntry>,
pub render_errors: Vec<RenderError>,
pub warnings: Vec<TemplateWarning>,
}
impl CheckResult {
pub fn is_ok(&self) -> bool {
self.stale.is_empty() && self.stale_files.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 StaleFileEntry {
pub file: PathBuf,
pub current_content: String,
pub expected_content: String,
}
#[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)]
#[instrument(skip(content, data), fields(content_len = content.len(), data_keys = data.len()))]
pub fn render_template(
content: &str,
data: &HashMap<String, serde_json::Value>,
) -> MdtResult<String> {
trace!("rendering template");
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"
)
}
pub fn normalize_whitespace(content: &str) -> String {
let mut result = String::with_capacity(content.len());
let mut prev_blank = false;
for line in content.split('\n') {
let trimmed = line.trim_end();
let is_blank = trimmed.is_empty();
if is_blank {
if !prev_blank && !result.is_empty() {
result.push('\n');
}
prev_blank = true;
} else {
if !result.is_empty() {
result.push('\n');
}
result.push_str(trimmed);
prev_blank = false;
}
}
result
}
fn content_matches(
actual: &str,
expected: &str,
comparison: &crate::config::ComparisonMode,
) -> bool {
match comparison {
crate::config::ComparisonMode::Strict => actual == expected,
crate::config::ComparisonMode::Lenient => {
normalize_whitespace(actual) == normalize_whitespace(expected)
}
}
}
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)
}
#[instrument(skip(ctx), fields(
root = %ctx.root.display(),
providers = ctx.project.providers.len(),
consumers = ctx.project.consumers.len(),
has_formatters = !ctx.formatters.is_empty(),
))]
pub fn check_project(ctx: &ProjectContext) -> MdtResult<CheckResult> {
debug!("checking project");
if ctx.formatters.is_empty() {
return check_project_without_formatters(ctx);
}
let mut stale = Vec::new();
let mut stale_files = Vec::new();
let mut render_errors = Vec::new();
let warnings = collect_template_warnings(ctx);
debug!(warnings = warnings.len(), "collected template warnings");
let consumers_by_file = group_consumers_by_file(&ctx.project.consumers);
for (file, consumers) in consumers_by_file {
trace!(file = %file.display(), consumers = consumers.len(), "checking file");
let original = std::fs::read_to_string(&file)?;
let ordered_consumers = sort_consumers_in_file(consumers);
let mut candidate = original.clone();
let mut eligible = vec![false; ordered_consumers.len()];
let mut raw_expected: Vec<Option<String>> = vec![None; ordered_consumers.len()];
for (index, consumer) in ordered_consumers.iter().enumerate().rev() {
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(rendered) => rendered,
Err(error) => {
warn!(
file = %consumer.file.display(),
block = %consumer.block.name,
error = %error,
"template render failed",
);
render_errors.push(RenderError {
file: consumer.file.clone(),
block_name: consumer.block.name.clone(),
message: error.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);
}
eligible[index] = true;
raw_expected[index] = Some(expected.clone());
if consumer.content != expected {
replace_consumer_content(&mut candidate, consumer, &expected);
}
}
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(rendered) => rendered,
Err(error) => {
render_errors.push(RenderError {
file: consumer.file.clone(),
block_name: consumer.block.name.clone(),
message: error.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),
);
eligible[index] = true;
raw_expected[index] = Some(expected.clone());
if consumer.content != expected {
replace_consumer_content(&mut candidate, consumer, &expected);
}
}
BlockType::Provider => {}
}
}
let (candidate, formatter_commands) = apply_formatter_pipeline(ctx, &file, &candidate)?;
if formatter_commands.is_empty() {
for (index, consumer) in ordered_consumers.iter().enumerate() {
let Some(expected) = raw_expected[index].clone() else {
continue;
};
if !content_matches(&consumer.content, &expected, &ctx.comparison) {
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,
});
}
}
continue;
}
if candidate == original {
continue;
}
let final_contents = parse_candidate_consumer_contents(
ctx,
&file,
&candidate,
ordered_consumers.len(),
&formatter_commands,
)?;
let mut file_stale_count = 0;
for (index, consumer) in ordered_consumers.iter().enumerate() {
if !eligible[index] {
continue;
}
let expected = final_contents[index].clone();
if !content_matches(&consumer.content, &expected, &ctx.comparison) {
file_stale_count += 1;
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,
});
}
}
if file_stale_count == 0 {
stale_files.push(StaleFileEntry {
file: file.clone(),
current_content: original,
expected_content: candidate,
});
}
}
debug!(
stale = stale.len(),
stale_files = stale_files.len(),
render_errors = render_errors.len(),
"check complete",
);
Ok(CheckResult {
stale,
stale_files,
render_errors,
warnings,
})
}
#[instrument(skip(ctx), fields(
root = %ctx.root.display(),
providers = ctx.project.providers.len(),
consumers = ctx.project.consumers.len(),
has_formatters = !ctx.formatters.is_empty(),
))]
pub fn compute_updates(ctx: &ProjectContext) -> MdtResult<UpdateResult> {
debug!("computing updates");
if ctx.formatters.is_empty() {
return compute_updates_without_formatters(ctx);
}
let mut file_contents: HashMap<PathBuf, String> = HashMap::new();
let mut updated_count = 0;
let warnings = collect_template_warnings(ctx);
let consumers_by_file = group_consumers_by_file(&ctx.project.consumers);
for (file, consumers) in consumers_by_file {
trace!(file = %file.display(), "processing file for updates");
let original = std::fs::read_to_string(&file)?;
let ordered_consumers = sort_consumers_in_file(consumers);
let mut candidate = original.clone();
let mut eligible = vec![false; ordered_consumers.len()];
let mut raw_expected: Vec<Option<String>> = vec![None; ordered_consumers.len()];
for (index, consumer) in ordered_consumers.iter().enumerate().rev() {
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,
};
eligible[index] = true;
raw_expected[index] = Some(new_content.clone());
if consumer.content != new_content {
replace_consumer_content(&mut candidate, consumer, &new_content);
}
}
let (candidate, formatter_commands) = apply_formatter_pipeline(ctx, &file, &candidate)?;
if candidate == original {
continue;
}
if formatter_commands.is_empty() {
updated_count += ordered_consumers
.iter()
.enumerate()
.filter(|(index, consumer)| {
raw_expected[*index]
.as_ref()
.is_some_and(|expected| consumer.content != *expected)
})
.count();
} else {
let final_contents = parse_candidate_consumer_contents(
ctx,
&file,
&candidate,
ordered_consumers.len(),
&formatter_commands,
)?;
updated_count += ordered_consumers
.iter()
.enumerate()
.filter(|(index, consumer)| {
eligible[*index] && consumer.content != final_contents[*index]
})
.count();
}
file_contents.insert(file.clone(), candidate);
}
debug!(
updated_files = file_contents.len(),
updated_count, "updates computed"
);
Ok(UpdateResult {
updated_files: file_contents,
updated_count,
warnings,
})
}
#[allow(clippy::unnecessary_wraps)]
fn check_project_without_formatters(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(rendered) => rendered,
Err(error) => {
render_errors.push(RenderError {
file: consumer.file.clone(),
block_name: consumer.block.name.clone(),
message: error.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 !content_matches(&consumer.content, &expected, &ctx.comparison) {
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(rendered) => rendered,
Err(error) => {
render_errors.push(RenderError {
file: consumer.file.clone(),
block_name: consumer.block.name.clone(),
message: error.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 !content_matches(&consumer.content, &expected, &ctx.comparison) {
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,
stale_files: Vec::new(),
render_errors,
warnings,
})
}
fn compute_updates_without_formatters(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 consumers_by_file = group_consumers_by_file(&ctx.project.consumers);
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_key(|b| Reverse(b.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 group_consumers_by_file(consumers: &[ConsumerEntry]) -> HashMap<PathBuf, Vec<&ConsumerEntry>> {
let mut grouped: HashMap<PathBuf, Vec<&ConsumerEntry>> = HashMap::new();
for consumer in consumers {
grouped
.entry(consumer.file.clone())
.or_default()
.push(consumer);
}
grouped
}
fn sort_consumers_in_file(mut consumers: Vec<&ConsumerEntry>) -> Vec<&ConsumerEntry> {
consumers.sort_by(|a, b| {
a.block
.opening
.start
.offset
.cmp(&b.block.opening.start.offset)
});
consumers
}
fn replace_consumer_content(result: &mut String, consumer: &ConsumerEntry, new_content: &str) {
let start = consumer.block.opening.end.offset;
let end = consumer.block.closing.start.offset;
if start > end || end > result.len() {
return;
}
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;
}
fn apply_formatter_pipeline(
ctx: &ProjectContext,
file: &Path,
content: &str,
) -> MdtResult<(String, Vec<String>)> {
let matching_commands: Vec<String> = ctx
.formatters
.iter()
.filter(|formatter| formatter.matches_file(&ctx.root, file))
.map(|formatter| formatter.command.clone())
.collect();
let mut current = content.to_string();
for command in &matching_commands {
current = run_formatter_command(ctx, file, command, ¤t)?;
}
Ok((current, matching_commands))
}
fn run_formatter_command(
ctx: &ProjectContext,
file: &Path,
command: &str,
input: &str,
) -> MdtResult<String> {
let relative_file = file.strip_prefix(&ctx.root).unwrap_or(file);
let interpolated = interpolate_formatter_command(command, file, relative_file, &ctx.root)
.map_err(|reason| {
MdtError::Formatter {
file: relative_file.display().to_string(),
command: command.to_string(),
reason,
}
})?;
let mut command_builder = if cfg!(windows) {
let mut command_builder = Command::new("cmd");
command_builder.arg("/C").arg(&interpolated);
command_builder
} else {
let mut command_builder = Command::new("sh");
command_builder.arg("-c").arg(&interpolated);
command_builder
};
let mut child = command_builder
.current_dir(&ctx.root)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(input.as_bytes())?;
}
let output = child.wait_with_output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let reason = if stderr.is_empty() {
format!(
"command exited with status {}",
output
.status
.code()
.map_or_else(|| "unknown".to_string(), |code| code.to_string())
)
} else {
stderr
};
return Err(MdtError::Formatter {
file: relative_file.display().to_string(),
command: interpolated,
reason,
});
}
Ok(normalize_line_endings(&String::from_utf8_lossy(
&output.stdout,
)))
}
fn interpolate_formatter_command(
command: &str,
file: &Path,
relative_file: &Path,
root: &Path,
) -> Result<String, String> {
if !has_template_syntax(command) {
return Ok(command.to_string());
}
let mut env = minijinja::Environment::new();
env.set_keep_trailing_newline(true);
env.add_template("__formatter_command__", command)
.map_err(|error| format!("invalid formatter command template: {error}"))?;
let template = env
.get_template("__formatter_command__")
.map_err(|error| format!("invalid formatter command template: {error}"))?;
template
.render(minijinja::context! {
filePath => file.display().to_string(),
relativeFilePath => relative_file.display().to_string(),
rootDirectory => root.display().to_string(),
})
.map_err(|error| format!("invalid formatter command template: {error}"))
}
fn parse_candidate_consumer_contents(
ctx: &ProjectContext,
file: &Path,
content: &str,
expected_consumer_count: usize,
formatter_commands: &[String],
) -> MdtResult<Vec<String>> {
let normalized = normalize_line_endings(content);
let (blocks, _) = if is_markdown_path(file) {
parse_with_diagnostics(&normalized).map_err(|error| {
MdtError::Formatter {
file: file
.strip_prefix(&ctx.root)
.unwrap_or(file)
.display()
.to_string(),
command: formatter_commands.join(" && "),
reason: format!("formatter pipeline produced unparsable markdown: {error}"),
}
})?
} else {
parse_source_with_diagnostics(&normalized, &ctx.markdown_codeblocks).map_err(|error| {
MdtError::Formatter {
file: file
.strip_prefix(&ctx.root)
.unwrap_or(file)
.display()
.to_string(),
command: formatter_commands.join(" && "),
reason: format!("formatter pipeline produced unparsable source comments: {error}"),
}
})?
};
let consumer_contents: Vec<String> = blocks
.into_iter()
.filter(|block| matches!(block.r#type, BlockType::Consumer | BlockType::Inline))
.map(|block| extract_content_between_tags(&normalized, &block))
.collect();
if consumer_contents.len() != expected_consumer_count {
return Err(MdtError::Formatter {
file: file
.strip_prefix(&ctx.root)
.unwrap_or(file)
.display()
.to_string(),
command: formatter_commands.join(" && "),
reason: format!(
"formatter pipeline changed the number of consumer blocks from {} to {}",
expected_consumer_count,
consumer_contents.len()
),
});
}
Ok(consumer_contents)
}
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
}
#[instrument(skip(updates), fields(file_count = updates.updated_files.len()))]
pub fn write_updates(updates: &UpdateResult) -> MdtResult<()> {
for (path, content) in &updates.updated_files {
trace!(path = %path.display(), "writing updated file");
std::fs::write(path, content)?;
}
Ok(())
}
#[instrument(skip(content), fields(content_len = content.len(), transformer_count = transformers.len()))]
pub fn apply_transformers(content: &str, transformers: &[Transformer]) -> String {
apply_transformers_with_data(content, transformers, None)
}
#[allow(clippy::implicit_hasher)]
#[instrument(skip(content, data), fields(content_len = content.len(), transformer_count = transformers.len(), has_data = data.is_some()))]
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 {
trace!(transformer = ?transformer.r#type, "applying transformer");
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,
}
})
}