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#[derive(Debug, Clone)]
17#[non_exhaustive]
18pub struct TemplateWarning {
19 pub provider_file: PathBuf,
22 pub block_name: String,
24 pub undefined_variables: Vec<String>,
27}
28
29#[derive(Debug)]
31#[non_exhaustive]
32pub struct CheckResult {
33 pub stale: Vec<StaleEntry>,
35 pub render_errors: Vec<RenderError>,
38 pub warnings: Vec<TemplateWarning>,
40}
41
42impl CheckResult {
43 pub fn is_ok(&self) -> bool {
45 self.stale.is_empty() && self.render_errors.is_empty()
46 }
47
48 pub fn has_errors(&self) -> bool {
50 !self.render_errors.is_empty()
51 }
52
53 pub fn has_warnings(&self) -> bool {
55 !self.warnings.is_empty()
56 }
57}
58
59#[derive(Debug)]
61#[non_exhaustive]
62pub struct RenderError {
63 pub file: PathBuf,
65 pub block_name: String,
67 pub message: String,
69 pub line: usize,
71 pub column: usize,
73}
74
75#[derive(Debug)]
77#[non_exhaustive]
78pub struct StaleEntry {
79 pub file: PathBuf,
81 pub block_name: String,
83 pub current_content: String,
85 pub expected_content: String,
87 pub line: usize,
89 pub column: usize,
91}
92
93#[derive(Debug)]
95#[non_exhaustive]
96pub struct UpdateResult {
97 pub updated_files: HashMap<PathBuf, String>,
99 pub updated_count: usize,
101 pub warnings: Vec<TemplateWarning>,
103}
104
105#[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#[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 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 let undeclared: HashSet<String> = template.undeclared_variables(true);
164
165 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 let top_level = var.split('.').next().unwrap_or(var);
174 !top_level_names.contains(top_level) && !is_builtin_variable(top_level)
178 })
179 .collect();
180
181 undefined.sort();
182 undefined
183}
184
185fn is_builtin_variable(name: &str) -> bool {
188 matches!(
189 name,
190 "loop" | "self" | "super" | "true" | "false" | "none" | "namespace" | "range" | "dict"
191 )
192}
193
194fn has_template_syntax(content: &str) -> bool {
196 content.contains("{{") || content.contains("{%") || content.contains("{#")
197}
198
199pub 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
233pub 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
300pub 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 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 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; };
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
373fn collect_template_warnings(ctx: &ProjectContext) -> Vec<TemplateWarning> {
377 let mut warnings = Vec::new();
378 let mut checked_providers: HashSet<String> = HashSet::new();
379
380 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 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
418pub 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
426pub 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
518pub 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
553fn pad_content_with_config(
567 new_content: &str,
568 original_content: &str,
569 padding: &PaddingConfig,
570) -> String {
571 let trailing_prefix = original_content
575 .rfind('\n')
576 .map_or("", |idx| &original_content[idx + 1..]);
577 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 match padding.before.line_count() {
585 None => {
586 }
588 Some(0) => {
589 if !new_content.starts_with('\n') {
591 result.push('\n');
592 }
593 }
594 Some(n) => {
595 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 match padding.after.line_count() {
610 None => {
611 }
613 Some(0) => {
614 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}