Skip to main content

mdt_core/
engine.rs

1use std::collections::HashMap;
2use std::collections::HashSet;
3use std::path::PathBuf;
4
5use crate::Argument;
6use crate::MdtError;
7use crate::MdtResult;
8use crate::Transformer;
9use crate::TransformerType;
10use crate::config::PaddingConfig;
11use crate::project::ConsumerEntry;
12use crate::project::ProjectContext;
13use crate::project::ProviderEntry;
14
15/// A warning about undefined template variables in a provider block.
16#[derive(Debug, Clone)]
17#[non_exhaustive]
18pub struct TemplateWarning {
19	/// Path to the file containing the provider block that uses the undefined
20	/// variables.
21	pub provider_file: PathBuf,
22	/// Name of the provider block.
23	pub block_name: String,
24	/// The undefined variable references found in the template (e.g.,
25	/// `["pkgg.version", "typo"]`).
26	pub undefined_variables: Vec<String>,
27}
28
29/// Result of checking a project for stale consumers.
30#[derive(Debug)]
31#[non_exhaustive]
32pub struct CheckResult {
33	/// Consumer entries that are out of date.
34	pub stale: Vec<StaleEntry>,
35	/// Errors encountered while rendering templates. These are collected
36	/// instead of aborting so that the check reports all problems at once.
37	pub render_errors: Vec<RenderError>,
38	/// Warnings about undefined template variables in provider blocks.
39	pub warnings: Vec<TemplateWarning>,
40}
41
42impl CheckResult {
43	/// Returns true if all consumers are up to date and no errors occurred.
44	pub fn is_ok(&self) -> bool {
45		self.stale.is_empty() && self.render_errors.is_empty()
46	}
47
48	/// Returns true if there are template render errors.
49	pub fn has_errors(&self) -> bool {
50		!self.render_errors.is_empty()
51	}
52
53	/// Returns true if there are warnings about undefined template variables.
54	pub fn has_warnings(&self) -> bool {
55		!self.warnings.is_empty()
56	}
57}
58
59/// A template render error associated with a specific consumer block.
60#[derive(Debug)]
61#[non_exhaustive]
62pub struct RenderError {
63	/// Path to the file containing the consumer block.
64	pub file: PathBuf,
65	/// Name of the block whose template failed to render.
66	pub block_name: String,
67	/// The error message from the template engine.
68	pub message: String,
69	/// 1-indexed line number of the consumer's opening tag.
70	pub line: usize,
71	/// 1-indexed column number of the consumer's opening tag.
72	pub column: usize,
73}
74
75/// A consumer entry that is out of date.
76#[derive(Debug)]
77#[non_exhaustive]
78pub struct StaleEntry {
79	/// Path to the file containing the stale consumer.
80	pub file: PathBuf,
81	/// Name of the block that is out of date.
82	pub block_name: String,
83	/// The current content between the consumer's tags.
84	pub current_content: String,
85	/// The expected content after applying provider content and transformers.
86	pub expected_content: String,
87	/// 1-indexed line number of the consumer's opening tag.
88	pub line: usize,
89	/// 1-indexed column number of the consumer's opening tag.
90	pub column: usize,
91}
92
93/// Result of updating a project.
94#[derive(Debug)]
95#[non_exhaustive]
96pub struct UpdateResult {
97	/// Files that were modified and their new content.
98	pub updated_files: HashMap<PathBuf, String>,
99	/// Number of consumer blocks that were updated.
100	pub updated_count: usize,
101	/// Warnings about undefined template variables in provider blocks.
102	pub warnings: Vec<TemplateWarning>,
103}
104
105/// Render provider content through minijinja using the given data context.
106/// If data is empty or the content has no template syntax, returns the
107/// content unchanged.
108#[allow(clippy::implicit_hasher)]
109pub fn render_template(
110	content: &str,
111	data: &HashMap<String, serde_json::Value>,
112) -> MdtResult<String> {
113	if data.is_empty() || !has_template_syntax(content) {
114		return Ok(content.to_string());
115	}
116
117	let mut env = minijinja::Environment::new();
118	env.set_keep_trailing_newline(true);
119	env.set_undefined_behavior(minijinja::UndefinedBehavior::Chainable);
120	env.add_template("__inline__", content)
121		.map_err(|e| MdtError::TemplateRender(e.to_string()))?;
122
123	let template = env
124		.get_template("__inline__")
125		.map_err(|e| MdtError::TemplateRender(e.to_string()))?;
126
127	let ctx = minijinja::Value::from_serialize(data);
128	template
129		.render(ctx)
130		.map_err(|e| MdtError::TemplateRender(e.to_string()))
131}
132
133/// Find template variables referenced in `content` that are not defined in
134/// `data`. Returns the list of undefined variable names (with nested
135/// attribute access like `"pkgg.version"`). This uses minijinja's static
136/// analysis to detect undeclared variables, so it does not depend on
137/// runtime control flow.
138///
139/// Returns an empty `Vec` when `data` is empty (no data configured means
140/// template rendering is a no-op) or when the content has no template
141/// syntax.
142#[allow(clippy::implicit_hasher)]
143pub fn find_undefined_variables(
144	content: &str,
145	data: &HashMap<String, serde_json::Value>,
146) -> Vec<String> {
147	if data.is_empty() || !has_template_syntax(content) {
148		return Vec::new();
149	}
150
151	let mut env = minijinja::Environment::new();
152	env.set_keep_trailing_newline(true);
153	// We only need the template for static analysis, undefined behavior
154	// doesn't affect undeclared_variables.
155	let Ok(()) = env.add_template("__inline__", content) else {
156		return Vec::new();
157	};
158	let Ok(template) = env.get_template("__inline__") else {
159		return Vec::new();
160	};
161
162	// Get all undeclared variables with nested access (e.g., "pkg.version").
163	let undeclared: HashSet<String> = template.undeclared_variables(true);
164
165	// Also get top-level names so we can check both "pkg.version" (nested)
166	// and "pkg" (top-level).
167	let top_level_names: HashSet<String> = data.keys().cloned().collect();
168
169	let mut undefined: Vec<String> = undeclared
170		.into_iter()
171		.filter(|var| {
172			// Extract the top-level namespace from the variable reference.
173			let top_level = var.split('.').next().unwrap_or(var);
174			// A variable is truly undefined if its top-level namespace is
175			// not present in the data context. Variables like "loop" or
176			// "range" are minijinja builtins that we should not warn about.
177			!top_level_names.contains(top_level) && !is_builtin_variable(top_level)
178		})
179		.collect();
180
181	undefined.sort();
182	undefined
183}
184
185/// Check whether a variable name is a minijinja builtin that should not
186/// trigger an "undefined variable" warning.
187fn is_builtin_variable(name: &str) -> bool {
188	matches!(
189		name,
190		"loop" | "self" | "super" | "true" | "false" | "none" | "namespace" | "range" | "dict"
191	)
192}
193
194/// Check whether content contains minijinja template syntax.
195fn has_template_syntax(content: &str) -> bool {
196	content.contains("{{") || content.contains("{%") || content.contains("{#")
197}
198
199/// Build a data context that merges base project data with block-specific
200/// positional arguments. Consumer argument values are bound to the provider's
201/// declared parameter names, with block args taking precedence over data
202/// variables.
203/// Build a data context that merges base project data with block-specific
204/// positional arguments. Returns `None` if the argument count doesn't match.
205pub fn build_render_context(
206	base_data: &HashMap<String, serde_json::Value>,
207	provider: &ProviderEntry,
208	consumer: &ConsumerEntry,
209) -> Option<HashMap<String, serde_json::Value>> {
210	let param_count = provider.block.arguments.len();
211	let arg_count = consumer.block.arguments.len();
212
213	if param_count != arg_count && (param_count > 0 || arg_count > 0) {
214		return None;
215	}
216
217	if provider.block.arguments.is_empty() {
218		return Some(base_data.clone());
219	}
220
221	let mut data = base_data.clone();
222	for (name, value) in provider
223		.block
224		.arguments
225		.iter()
226		.zip(consumer.block.arguments.iter())
227	{
228		data.insert(name.clone(), serde_json::Value::String(value.clone()));
229	}
230	Some(data)
231}
232
233/// Check whether all consumer blocks in the project are up to date.
234/// Consumer blocks that reference non-existent providers are silently skipped.
235/// Template render errors are collected rather than aborting, so the check
236/// reports all problems in a single pass.
237pub fn check_project(ctx: &ProjectContext) -> MdtResult<CheckResult> {
238	let mut stale = Vec::new();
239	let mut render_errors = Vec::new();
240	let warnings = collect_template_warnings(ctx);
241
242	for consumer in &ctx.project.consumers {
243		let Some(provider) = ctx.project.providers.get(&consumer.block.name) else {
244			continue;
245		};
246
247		let Some(render_data) = build_render_context(&ctx.data, provider, consumer) else {
248			render_errors.push(RenderError {
249				file: consumer.file.clone(),
250				block_name: consumer.block.name.clone(),
251				message: format!(
252					"argument count mismatch: provider `{}` declares {} parameter(s), but \
253					 consumer passes {}",
254					consumer.block.name,
255					provider.block.arguments.len(),
256					consumer.block.arguments.len(),
257				),
258				line: consumer.block.opening.start.line,
259				column: consumer.block.opening.start.column,
260			});
261			continue;
262		};
263		let rendered = match render_template(&provider.content, &render_data) {
264			Ok(r) => r,
265			Err(e) => {
266				render_errors.push(RenderError {
267					file: consumer.file.clone(),
268					block_name: consumer.block.name.clone(),
269					message: e.to_string(),
270					line: consumer.block.opening.start.line,
271					column: consumer.block.opening.start.column,
272				});
273				continue;
274			}
275		};
276		let mut expected = apply_transformers(&rendered, &consumer.block.transformers);
277		if let Some(padding) = &ctx.padding {
278			expected = pad_content_with_config(&expected, &consumer.content, padding);
279		}
280
281		if consumer.content != expected {
282			stale.push(StaleEntry {
283				file: consumer.file.clone(),
284				block_name: consumer.block.name.clone(),
285				current_content: consumer.content.clone(),
286				expected_content: expected,
287				line: consumer.block.opening.start.line,
288				column: consumer.block.opening.start.column,
289			});
290		}
291	}
292
293	Ok(CheckResult {
294		stale,
295		render_errors,
296		warnings,
297	})
298}
299
300/// Compute the updated file contents for all consumer blocks.
301pub fn compute_updates(ctx: &ProjectContext) -> MdtResult<UpdateResult> {
302	let mut file_contents: HashMap<PathBuf, String> = HashMap::new();
303	let mut updated_count = 0;
304	let warnings = collect_template_warnings(ctx);
305
306	// Group consumers by file
307	let mut consumers_by_file: HashMap<PathBuf, Vec<&ConsumerEntry>> = HashMap::new();
308	for consumer in &ctx.project.consumers {
309		consumers_by_file
310			.entry(consumer.file.clone())
311			.or_default()
312			.push(consumer);
313	}
314
315	for (file, consumers) in &consumers_by_file {
316		let original = if let Some(content) = file_contents.get(file) {
317			content.clone()
318		} else {
319			std::fs::read_to_string(file)?
320		};
321
322		let mut result = original.clone();
323		let mut had_update = false;
324		// Process consumers in reverse offset order so earlier replacements
325		// don't shift the positions of later ones.
326		let mut sorted_consumers: Vec<&&ConsumerEntry> = consumers.iter().collect();
327		sorted_consumers
328			.sort_by(|a, b| b.block.opening.end.offset.cmp(&a.block.opening.end.offset));
329
330		for consumer in sorted_consumers {
331			let Some(provider) = ctx.project.providers.get(&consumer.block.name) else {
332				continue;
333			};
334
335			let Some(render_data) = build_render_context(&ctx.data, provider, consumer) else {
336				continue; // Argument count mismatch — skip this consumer.
337			};
338			let rendered = render_template(&provider.content, &render_data)?;
339			let mut new_content = apply_transformers(&rendered, &consumer.block.transformers);
340			if let Some(padding) = &ctx.padding {
341				new_content = pad_content_with_config(&new_content, &consumer.content, padding);
342			}
343
344			if consumer.content != new_content {
345				let start = consumer.block.opening.end.offset;
346				let end = consumer.block.closing.start.offset;
347
348				if start <= end && end <= result.len() {
349					let mut buf =
350						String::with_capacity(result.len() - (end - start) + new_content.len());
351					buf.push_str(&result[..start]);
352					buf.push_str(&new_content);
353					buf.push_str(&result[end..]);
354					result = buf;
355					had_update = true;
356					updated_count += 1;
357				}
358			}
359		}
360
361		if had_update {
362			file_contents.insert(file.clone(), result);
363		}
364	}
365
366	Ok(UpdateResult {
367		updated_files: file_contents,
368		updated_count,
369		warnings,
370	})
371}
372
373/// Collect warnings about undefined template variables across all provider
374/// blocks that have at least one consumer. Each provider is checked at most
375/// once even if it has multiple consumers.
376fn collect_template_warnings(ctx: &ProjectContext) -> Vec<TemplateWarning> {
377	let mut warnings = Vec::new();
378	let mut checked_providers: HashSet<String> = HashSet::new();
379
380	// Only check providers that are actually referenced by consumers.
381	for consumer in &ctx.project.consumers {
382		let name = &consumer.block.name;
383		if checked_providers.contains(name) {
384			continue;
385		}
386		checked_providers.insert(name.clone());
387
388		let Some(provider) = ctx.project.providers.get(name) else {
389			continue;
390		};
391
392		// Provider params are known variables — add them to the data context
393		// so they don't trigger false undefined-variable warnings.
394		let data_with_params = if provider.block.arguments.is_empty() {
395			std::borrow::Cow::Borrowed(&ctx.data)
396		} else {
397			let mut data = ctx.data.clone();
398			for param in &provider.block.arguments {
399				data.entry(param.clone())
400					.or_insert(serde_json::Value::String(String::new()));
401			}
402			std::borrow::Cow::Owned(data)
403		};
404
405		let undefined = find_undefined_variables(&provider.content, &data_with_params);
406		if !undefined.is_empty() {
407			warnings.push(TemplateWarning {
408				provider_file: provider.file.clone(),
409				block_name: name.clone(),
410				undefined_variables: undefined,
411			});
412		}
413	}
414
415	warnings
416}
417
418/// Write the updated contents back to disk.
419pub fn write_updates(updates: &UpdateResult) -> MdtResult<()> {
420	for (path, content) in &updates.updated_files {
421		std::fs::write(path, content)?;
422	}
423	Ok(())
424}
425
426/// Apply a sequence of transformers to content.
427pub fn apply_transformers(content: &str, transformers: &[Transformer]) -> String {
428	let mut result = content.to_string();
429
430	for transformer in transformers {
431		result = apply_transformer(&result, transformer);
432	}
433
434	result
435}
436
437fn apply_transformer(content: &str, transformer: &Transformer) -> String {
438	match transformer.r#type {
439		TransformerType::Trim => content.trim().to_string(),
440		TransformerType::TrimStart => content.trim_start().to_string(),
441		TransformerType::TrimEnd => content.trim_end().to_string(),
442		TransformerType::Indent => {
443			let indent_str = get_string_arg(&transformer.args, 0).unwrap_or_default();
444			let include_empty = get_bool_arg(&transformer.args, 1).unwrap_or(false);
445			content
446				.lines()
447				.map(|line| {
448					if line.is_empty() && !include_empty {
449						String::new()
450					} else {
451						format!("{indent_str}{line}")
452					}
453				})
454				.collect::<Vec<_>>()
455				.join("\n")
456		}
457		TransformerType::Prefix => {
458			let prefix = get_string_arg(&transformer.args, 0).unwrap_or_default();
459			format!("{prefix}{content}")
460		}
461		TransformerType::Wrap => {
462			let wrapper = get_string_arg(&transformer.args, 0).unwrap_or_default();
463			format!("{wrapper}{content}{wrapper}")
464		}
465		TransformerType::CodeBlock => {
466			let lang = get_string_arg(&transformer.args, 0).unwrap_or_default();
467			format!("```{lang}\n{content}\n```")
468		}
469		TransformerType::Code => {
470			format!("`{content}`")
471		}
472		TransformerType::Replace => {
473			let search = get_string_arg(&transformer.args, 0).unwrap_or_default();
474			let replacement = get_string_arg(&transformer.args, 1).unwrap_or_default();
475			content.replace(&search, &replacement)
476		}
477		TransformerType::Suffix => {
478			let suffix = get_string_arg(&transformer.args, 0).unwrap_or_default();
479			format!("{content}{suffix}")
480		}
481		TransformerType::LinePrefix => {
482			let prefix = get_string_arg(&transformer.args, 0).unwrap_or_default();
483			let include_empty = get_bool_arg(&transformer.args, 1).unwrap_or(false);
484			content
485				.lines()
486				.map(|line| {
487					if line.is_empty() && !include_empty {
488						String::new()
489					} else if line.is_empty() {
490						prefix.trim_end().to_string()
491					} else {
492						format!("{prefix}{line}")
493					}
494				})
495				.collect::<Vec<_>>()
496				.join("\n")
497		}
498		TransformerType::LineSuffix => {
499			let suffix = get_string_arg(&transformer.args, 0).unwrap_or_default();
500			let include_empty = get_bool_arg(&transformer.args, 1).unwrap_or(false);
501			content
502				.lines()
503				.map(|line| {
504					if line.is_empty() && !include_empty {
505						String::new()
506					} else if line.is_empty() {
507						suffix.trim_start().to_string()
508					} else {
509						format!("{line}{suffix}")
510					}
511				})
512				.collect::<Vec<_>>()
513				.join("\n")
514		}
515	}
516}
517
518/// Validate that all transformer arguments are well-formed. Returns an error
519/// for the first invalid transformer found.
520pub fn validate_transformers(transformers: &[Transformer]) -> MdtResult<()> {
521	for t in transformers {
522		let (min, max) = match t.r#type {
523			TransformerType::Trim
524			| TransformerType::TrimStart
525			| TransformerType::TrimEnd
526			| TransformerType::Code => (0, 0),
527			TransformerType::Prefix
528			| TransformerType::Suffix
529			| TransformerType::Wrap
530			| TransformerType::CodeBlock => (0, 1),
531			TransformerType::Indent | TransformerType::LinePrefix | TransformerType::LineSuffix => {
532				(0, 2)
533			}
534			TransformerType::Replace => (2, 2),
535		};
536
537		if t.args.len() < min || t.args.len() > max {
538			let expected = if min == max {
539				format!("{min}")
540			} else {
541				format!("{min}-{max}")
542			};
543			return Err(MdtError::InvalidTransformerArgs {
544				name: t.r#type.to_string(),
545				expected,
546				got: t.args.len(),
547			});
548		}
549	}
550	Ok(())
551}
552
553/// Pad content according to the padding configuration while preserving the
554/// trailing line prefix from the original consumer content. When the closing
555/// tag is preceded by a comment prefix (e.g., `//! ` or `/// `) that prefix
556/// is part of the content range and must be preserved after replacement.
557///
558/// The `before` value controls blank lines between the opening tag and
559/// content, and `after` controls blank lines between content and the closing
560/// tag. Each value can be:
561///
562/// - `false` — No padding; content appears inline with the tag.
563/// - `0` — Content on the very next line (one newline, no blank lines).
564/// - `1` — One blank line between the tag and content.
565/// - `2` — Two blank lines, and so on.
566fn pad_content_with_config(
567	new_content: &str,
568	original_content: &str,
569	padding: &PaddingConfig,
570) -> String {
571	// Extract the trailing prefix from the original content — everything after
572	// the last newline. For example, in "\n//! old\n//! " the trailing prefix
573	// is "//! ".
574	let trailing_prefix = original_content
575		.rfind('\n')
576		.map_or("", |idx| &original_content[idx + 1..]);
577	// Trimmed prefix for blank padding lines — avoids trailing whitespace
578	// on empty lines (e.g., "//! " becomes "//!").
579	let blank_line_prefix = trailing_prefix.trim_end();
580
581	let mut result = String::with_capacity(new_content.len() + trailing_prefix.len() * 4 + 8);
582
583	// Before padding: lines between opening tag and content
584	match padding.before.line_count() {
585		None => {
586			// false — content inline with opening tag
587		}
588		Some(0) => {
589			// Content on the very next line
590			if !new_content.starts_with('\n') {
591				result.push('\n');
592			}
593		}
594		Some(n) => {
595			// N blank lines between opening tag and content
596			if !new_content.starts_with('\n') {
597				result.push('\n');
598			}
599			for _ in 0..n {
600				result.push_str(blank_line_prefix);
601				result.push('\n');
602			}
603		}
604	}
605
606	result.push_str(new_content);
607
608	// After padding: lines between content and closing tag
609	match padding.after.line_count() {
610		None => {
611			// false — closing tag inline with content
612		}
613		Some(0) => {
614			// Closing tag on the very next line
615			if !new_content.ends_with('\n') {
616				result.push('\n');
617			}
618			result.push_str(trailing_prefix);
619		}
620		Some(n) => {
621			if !new_content.ends_with('\n') {
622				result.push('\n');
623			}
624			for _ in 0..n {
625				result.push_str(blank_line_prefix);
626				result.push('\n');
627			}
628			result.push_str(trailing_prefix);
629		}
630	}
631
632	result
633}
634
635fn get_string_arg(args: &[Argument], index: usize) -> Option<String> {
636	args.get(index).map(|arg| {
637		match arg {
638			Argument::String(s) => s.clone(),
639			Argument::Number(n) => n.to_string(),
640			Argument::Boolean(b) => b.to_string(),
641		}
642	})
643}
644
645fn get_bool_arg(args: &[Argument], index: usize) -> Option<bool> {
646	args.get(index).map(|arg| {
647		match arg {
648			Argument::Boolean(b) => *b,
649			Argument::String(s) => s == "true",
650			Argument::Number(n) => n.0 != 0.0,
651		}
652	})
653}