agpm_cli/templating/renderer.rs
1//! Template rendering engine with Tera.
2//!
3//! This module provides the TemplateRenderer struct that wraps Tera with
4//! AGPM-specific configuration, custom filters, and literal block handling.
5
6use anyhow::Result;
7use regex::Regex;
8use std::collections::HashMap;
9use std::path::PathBuf;
10use strsim::levenshtein;
11use tera::{Context as TeraContext, Tera};
12
13use super::content::NON_TEMPLATED_LITERAL_GUARD_END;
14use super::content::NON_TEMPLATED_LITERAL_GUARD_START;
15use super::error::{ErrorLocation, TemplateError};
16use super::filters;
17use crate::core::ResourceType;
18
19/// Context information about the current rendering operation
20#[derive(Debug, Clone)]
21pub struct RenderingMetadata {
22 /// The resource currently being rendered
23 pub resource_name: String,
24 /// The type of resource (agent, command, snippet, etc.)
25 pub resource_type: ResourceType,
26 /// Full dependency chain from root to current resource
27 pub dependency_chain: Vec<DependencyChainEntry>,
28 /// Source file path if available
29 pub source_path: Option<PathBuf>,
30 /// Current rendering depth (for content filter recursion)
31 pub depth: usize,
32}
33
34#[derive(Debug, Clone)]
35pub struct DependencyChainEntry {
36 pub resource_type: ResourceType,
37 pub name: String,
38 pub path: Option<String>,
39}
40
41/// Template renderer with Tera engine and custom functions.
42///
43/// This struct wraps a Tera instance with AGPM-specific configuration,
44/// custom functions, and filters. It provides a safe, sandboxed environment
45/// for rendering Markdown templates.
46///
47/// # Security
48///
49/// The renderer is configured with security restrictions:
50/// - No file system access via includes/extends (except content filter)
51/// - No network access
52/// - Sandboxed template execution
53/// - Custom functions are carefully vetted
54/// - Project file access restricted to project directory with validation
55pub struct TemplateRenderer {
56 /// The underlying Tera template engine
57 tera: Tera,
58 /// Whether templating is enabled globally
59 enabled: bool,
60}
61
62impl TemplateRenderer {
63 /// Create a new template renderer with AGPM-specific configuration.
64 ///
65 /// # Arguments
66 ///
67 /// * `enabled` - Whether templating is enabled globally
68 /// * `project_dir` - Project root directory for content filter validation
69 /// * `max_content_file_size` - Maximum file size in bytes for content filter (None for no limit)
70 ///
71 /// # Returns
72 ///
73 /// Returns a configured `TemplateRenderer` instance with custom filters registered.
74 ///
75 /// # Filters
76 ///
77 /// The following custom filters are registered:
78 /// - `content`: Read project-specific files with path validation and size limits
79 pub fn new(
80 enabled: bool,
81 project_dir: PathBuf,
82 max_content_file_size: Option<u64>,
83 ) -> Result<Self> {
84 let mut tera = Tera::default();
85
86 // Register custom filters
87 tera.register_filter(
88 "content",
89 filters::create_content_filter(project_dir.clone(), max_content_file_size),
90 );
91
92 Ok(Self {
93 tera,
94 enabled,
95 })
96 }
97
98 /// Protect literal blocks from template rendering by replacing them with placeholders.
99 ///
100 /// This method scans for ```literal fenced code blocks and replaces them with
101 /// unique placeholders that won't be affected by template rendering. The original
102 /// content is stored in a HashMap that can be used to restore the blocks later.
103 ///
104 /// # Arguments
105 ///
106 /// * `content` - The content to process
107 ///
108 /// # Returns
109 ///
110 /// Returns a tuple of:
111 /// - Modified content with placeholders instead of literal blocks
112 /// - HashMap mapping placeholder IDs to original content
113 ///
114 /// # Examples
115 ///
116 /// ````markdown
117 /// # Documentation Example
118 ///
119 /// Use this syntax in templates:
120 ///
121 /// ```literal
122 /// {{ agpm.deps.snippets.example.content }}
123 /// ```
124 /// ````
125 ///
126 /// The content inside the literal block will be protected from rendering.
127 pub(crate) fn protect_literal_blocks(
128 &self,
129 content: &str,
130 ) -> (String, HashMap<String, String>) {
131 let mut placeholders = HashMap::new();
132 let mut counter = 0;
133 let mut result = String::with_capacity(content.len());
134
135 // Split content by lines to find both ```literal fences and RAW guards
136 let mut in_literal_fence = false;
137 let mut in_raw_guard = false;
138 let mut current_block = String::new();
139 let lines = content.lines();
140
141 for line in lines {
142 let trimmed = line.trim();
143
144 if trimmed == NON_TEMPLATED_LITERAL_GUARD_START {
145 // Start of RAW guard block
146 in_raw_guard = true;
147 current_block.clear();
148 tracing::debug!("Found start of RAW guard block");
149 // Skip the guard line
150 } else if in_raw_guard && trimmed == NON_TEMPLATED_LITERAL_GUARD_END {
151 // End of RAW guard block
152 in_raw_guard = false;
153
154 // Generate unique placeholder
155 let placeholder_id = format!("__AGPM_LITERAL_BLOCK_{}__", counter);
156 counter += 1;
157
158 // Store original content (keep the guards for later processing)
159 let guarded_content = format!(
160 "{}\n{}\n{}",
161 NON_TEMPLATED_LITERAL_GUARD_START,
162 current_block,
163 NON_TEMPLATED_LITERAL_GUARD_END
164 );
165 placeholders.insert(placeholder_id.clone(), guarded_content);
166
167 // Insert placeholder
168 result.push_str(&placeholder_id);
169 result.push('\n');
170
171 tracing::debug!(
172 "Protected RAW guard block with placeholder {} ({} bytes)",
173 placeholder_id,
174 current_block.len()
175 );
176
177 current_block.clear();
178 // Skip the guard line
179 } else if in_raw_guard {
180 // Inside RAW guard - accumulate content
181 if !current_block.is_empty() {
182 current_block.push('\n');
183 }
184 current_block.push_str(line);
185 } else if trimmed.starts_with("```literal") {
186 // Start of ```literal fence
187 in_literal_fence = true;
188 current_block.clear();
189 tracing::debug!("Found start of literal fence");
190 // Skip the fence line
191 } else if in_literal_fence && trimmed.starts_with("```") {
192 // End of ```literal fence
193 in_literal_fence = false;
194
195 // Generate unique placeholder
196 let placeholder_id = format!("__AGPM_LITERAL_BLOCK_{}__", counter);
197 counter += 1;
198
199 // Store original content
200 placeholders.insert(placeholder_id.clone(), current_block.clone());
201
202 // Insert placeholder
203 result.push_str(&placeholder_id);
204 result.push('\n');
205
206 tracing::debug!(
207 "Protected literal fence with placeholder {} ({} bytes)",
208 placeholder_id,
209 current_block.len()
210 );
211
212 current_block.clear();
213 // Skip the fence line
214 } else if in_literal_fence {
215 // Inside ```literal fence - accumulate content
216 if !current_block.is_empty() {
217 current_block.push('\n');
218 }
219 current_block.push_str(line);
220 } else {
221 // Regular content - pass through
222 result.push_str(line);
223 result.push('\n');
224 }
225 }
226
227 // Handle unclosed blocks (add back as-is)
228 if in_literal_fence {
229 tracing::warn!("Unclosed literal fence found - treating as regular content");
230 result.push_str("```literal\n");
231 result.push_str(¤t_block);
232 }
233 if in_raw_guard {
234 tracing::warn!("Unclosed RAW guard found - treating as regular content");
235 result.push_str(NON_TEMPLATED_LITERAL_GUARD_START);
236 result.push('\n');
237 result.push_str(¤t_block);
238 }
239
240 // Remove trailing newline if original didn't have one
241 if !content.ends_with('\n') && result.ends_with('\n') {
242 result.pop();
243 }
244
245 tracing::debug!("Protected {} literal block(s)", placeholders.len());
246 (result, placeholders)
247 }
248
249 /// Restore literal blocks by replacing placeholders with original content.
250 ///
251 /// This method takes rendered content and restores any literal blocks that were
252 /// protected during the rendering process.
253 ///
254 /// # Arguments
255 ///
256 /// * `content` - The rendered content containing placeholders
257 /// * `placeholders` - HashMap mapping placeholder IDs to original content
258 ///
259 /// # Returns
260 ///
261 /// Returns the content with placeholders replaced by original literal blocks,
262 /// wrapped in markdown code fences for proper display.
263 pub(crate) fn restore_literal_blocks(
264 &self,
265 content: &str,
266 placeholders: HashMap<String, String>,
267 ) -> String {
268 let mut result = content.to_string();
269
270 for (placeholder_id, original_content) in placeholders {
271 if original_content.starts_with(NON_TEMPLATED_LITERAL_GUARD_START) {
272 result = result.replace(&placeholder_id, &original_content);
273 } else {
274 // Wrap in markdown code fence for display
275 let replacement = format!("```\n{}\n```", original_content);
276 result = result.replace(&placeholder_id, &replacement);
277 }
278
279 tracing::debug!(
280 "Restored literal block {} ({} bytes)",
281 placeholder_id,
282 original_content.len()
283 );
284 }
285
286 result
287 }
288
289 /// Collapse literal fences that were injected to protect non-templated dependency content.
290 ///
291 /// Any block that starts with ```literal, contains the sentinel marker on its first line,
292 /// and ends with ``` will be replaced by the inner content without the sentinel or fences.
293 fn collapse_non_templated_literal_guards(content: String) -> String {
294 let mut result = String::with_capacity(content.len());
295 let mut in_guard = false;
296
297 for chunk in content.split_inclusive('\n') {
298 let trimmed = chunk.trim_end_matches(['\r', '\n']);
299
300 if !in_guard {
301 if trimmed == NON_TEMPLATED_LITERAL_GUARD_START {
302 in_guard = true;
303 } else {
304 result.push_str(chunk);
305 }
306 } else if trimmed == NON_TEMPLATED_LITERAL_GUARD_END {
307 in_guard = false;
308 } else {
309 result.push_str(chunk);
310 }
311 }
312
313 // If guard never closed, re-append the start marker and captured content to avoid dropping data.
314 if in_guard {
315 result.push_str(NON_TEMPLATED_LITERAL_GUARD_START);
316 }
317
318 result
319 }
320
321 /// Render a Markdown template with the given context.
322 ///
323 /// This method supports recursive template rendering where project files
324 /// can reference other project files using the `content` filter.
325 /// Rendering continues up to [`filters::MAX_RENDER_DEPTH`] levels deep.
326 ///
327 /// # Arguments
328 ///
329 /// * `template_content` - The raw Markdown template content
330 /// * `context` - The template context containing variables
331 ///
332 /// # Returns
333 ///
334 /// Returns the rendered Markdown content.
335 ///
336 /// # Errors
337 ///
338 /// Returns an error if:
339 /// - Template syntax is invalid
340 /// - Context variables are missing
341 /// - Custom functions/filters fail
342 /// - Recursive rendering exceeds maximum depth (10 levels)
343 ///
344 /// # Literal Blocks
345 ///
346 /// Content wrapped in ```literal fences will be protected from
347 /// template rendering and displayed literally:
348 ///
349 /// ````markdown
350 /// ```literal
351 /// {{ agpm.deps.snippets.example.content }}
352 /// ```
353 /// ````
354 ///
355 /// This is useful for documentation that shows template syntax examples.
356 ///
357 /// # Recursive Rendering
358 ///
359 /// When a template contains `content` filter references, those files
360 /// may themselves contain template syntax. The renderer automatically
361 /// detects this and performs multiple rendering passes until either:
362 /// - No template syntax remains in the output
363 /// - Maximum depth is reached (error)
364 ///
365 /// Example recursive template chain:
366 /// ```markdown
367 /// # Main Agent
368 /// {{ 'docs/guide.md' | content }}
369 /// ```
370 ///
371 /// Where `docs/guide.md` contains:
372 /// ```markdown
373 /// # Guide
374 /// {{ 'docs/common.md' | content }}
375 /// ```
376 ///
377 /// This will render up to 10 levels deep.
378 pub fn render_template(
379 &mut self,
380 template_content: &str,
381 context: &TeraContext,
382 metadata: Option<&RenderingMetadata>,
383 ) -> Result<String, TemplateError> {
384 tracing::debug!("render_template called, enabled={}", self.enabled);
385
386 if !self.enabled {
387 // If templating is disabled, return content as-is
388 tracing::debug!("Templating disabled, returning content as-is");
389 return Ok(template_content.to_string());
390 }
391
392 // Step 1: Protect literal blocks before any rendering
393 let (protected_content, placeholders) = self.protect_literal_blocks(template_content);
394
395 // Check if content contains template syntax (after protecting literals)
396 if !self.contains_template_syntax(&protected_content) {
397 // No template syntax found, restore literals and return
398 tracing::debug!(
399 "No template syntax found after protecting literals, returning content"
400 );
401 return Ok(self.restore_literal_blocks(&protected_content, placeholders));
402 }
403
404 // Log the template context for debugging
405 tracing::debug!("Rendering template with context");
406 Self::log_context_as_kv(context);
407
408 // Step 2: Multi-pass rendering for recursive templates
409 // This allows project files to reference other project files
410 let mut current_content = protected_content;
411 let mut depth = 0;
412 let max_depth = filters::MAX_RENDER_DEPTH;
413
414 let rendered = loop {
415 depth += 1;
416
417 // Check depth limit
418 if depth > max_depth {
419 return Err(TemplateError::SyntaxError {
420 message: format!(
421 "Template rendering exceeded maximum recursion depth of {}. \
422 This usually indicates circular dependencies between project files. \
423 Please check your content filter references for cycles.",
424 max_depth
425 ),
426 location: Box::new(Self::build_error_location(metadata, None)),
427 });
428 }
429
430 tracing::debug!("Rendering pass {} of max {}", depth, max_depth);
431
432 // Render the current content
433 let rendered = self.tera.render_str(¤t_content, context).map_err(|e| {
434 // Parse into structured error
435 Self::parse_tera_error(&e, context, metadata)
436 })?;
437
438 // Check if the rendered output still contains template syntax OUTSIDE code fences
439 // This prevents re-rendering template syntax that was embedded as code examples
440 if !self.contains_template_syntax_outside_fences(&rendered) {
441 // No more template syntax outside fences - we're done with rendering
442 tracing::debug!("Template rendering complete after {} pass(es)", depth);
443 break rendered;
444 }
445
446 // More template syntax found outside fences - prepare for next iteration
447 tracing::debug!("Template syntax detected in output, continuing to pass {}", depth + 1);
448 current_content = rendered;
449 };
450
451 // Step 3: Restore literal blocks after all rendering is complete
452 let restored = self.restore_literal_blocks(&rendered, placeholders);
453
454 // Step 4: Collapse any literal guards that were added for non-templated dependencies
455 Ok(Self::collapse_non_templated_literal_guards(restored))
456 }
457
458 /// Parse a Tera error into a structured TemplateError
459 fn parse_tera_error(
460 error: &tera::Error,
461 context: &TeraContext,
462 metadata: Option<&RenderingMetadata>,
463 ) -> TemplateError {
464 // Extract error type (error_msg is no longer needed as we use format_tera_error)
465
466 // Try to extract more specific error information based on the error kind
467 match &error.kind {
468 tera::ErrorKind::Msg(msg) => {
469 // Check if this is an undefined variable error in disguise
470 if msg.contains("Variable") && msg.contains("not found") {
471 // Try to extract variable name
472 if let Some(name) = Self::extract_variable_name(msg) {
473 let available_variables = Self::extract_available_variables(context);
474 let suggestions = Self::find_similar_variables(&name, &available_variables);
475 return TemplateError::VariableNotFound {
476 variable: name.clone(),
477 available_variables: Box::new(available_variables),
478 suggestions: Box::new(suggestions),
479 location: Box::new(Self::build_error_location(metadata, None)),
480 };
481 }
482 }
483
484 // For other message types, use the format_tera_error function to clean them up
485 TemplateError::SyntaxError {
486 message: Self::format_tera_error(error),
487 location: Box::new(Self::build_error_location(metadata, None)),
488 }
489 }
490 _ => {
491 // Fallback to syntax error with detailed error formatting
492 TemplateError::SyntaxError {
493 message: Self::format_tera_error(error),
494 location: Box::new(Self::build_error_location(metadata, None)),
495 }
496 }
497 }
498 }
499
500 /// Extract variable name from "Variable `foo` not found" message
501 fn extract_variable_name(error_msg: &str) -> Option<String> {
502 // Pattern: "Variable `<name>` not found"
503 let re = Regex::new(r"Variable `([^`]+)` not found").ok()?;
504 if let Some(caps) = re.captures(error_msg) {
505 if let Some(m) = caps.get(1) {
506 return Some(m.as_str().to_string());
507 }
508 }
509
510 // Try other patterns if needed
511 // Pattern: "Unknown variable `foo`"
512 let re2 = Regex::new(r"Unknown variable `([^`]+)`").ok()?;
513 if let Some(caps) = re2.captures(error_msg) {
514 if let Some(m) = caps.get(1) {
515 return Some(m.as_str().to_string());
516 }
517 }
518
519 None
520 }
521
522 /// Extract available variables from Tera context
523 fn extract_available_variables(context: &TeraContext) -> Vec<String> {
524 // Tera context doesn't implement Serialize directly
525 // We need to access its internal data structure
526 let mut vars = Vec::new();
527
528 // Get the context as a Value by using Tera's internal data access
529 // TeraContext stores data as a tera::Value internally
530 if let Some(_data) = context.get("agpm") {
531 // This is a simplified version - we should walk the actual structure
532 vars.push("agpm.resource.name".to_string());
533 vars.push("agpm.resource.path".to_string());
534 vars.push("agpm.resource.install_path".to_string());
535 }
536
537 // Add common project variables if they exist
538 if context.contains_key("project") {
539 vars.push("project.language".to_string());
540 vars.push("project.framework".to_string());
541 }
542
543 // Add dependency variables (simplified check)
544 if context.contains_key("deps") {
545 vars.push("agpm.deps.*".to_string());
546 }
547
548 vars
549 }
550
551 /// Find similar variable names using Levenshtein distance
552 fn find_similar_variables(target: &str, available: &[String]) -> Vec<String> {
553 let mut scored: Vec<_> = available
554 .iter()
555 .map(|var| {
556 let distance = levenshtein(target, var);
557 (var.clone(), distance)
558 })
559 .collect();
560
561 // Sort by distance (closest first)
562 scored.sort_by_key(|(_, dist)| *dist);
563
564 // Return top 3 suggestions within reasonable distance
565 scored
566 .into_iter()
567 .filter(|(_, dist)| *dist <= target.len() / 2) // 50% similarity threshold
568 .take(3)
569 .map(|(var, _)| var)
570 .collect()
571 }
572
573 /// Build ErrorLocation from metadata
574 fn build_error_location(
575 metadata: Option<&RenderingMetadata>,
576 line_number: Option<usize>,
577 ) -> ErrorLocation {
578 let meta = metadata.cloned().unwrap_or_else(|| RenderingMetadata {
579 resource_name: "unknown".to_string(),
580 resource_type: ResourceType::Snippet, // Default to snippet
581 dependency_chain: vec![],
582 source_path: None,
583 depth: 0,
584 });
585
586 ErrorLocation {
587 resource_name: meta.resource_name,
588 resource_type: meta.resource_type,
589 dependency_chain: meta.dependency_chain,
590 file_path: meta.source_path,
591 line_number,
592 }
593 }
594
595 /// Format a Tera error with detailed information about what went wrong.
596 ///
597 /// Tera errors can contain various types of issues:
598 /// - Missing variables (e.g., "Variable `foo` not found")
599 /// - Syntax errors (e.g., "Unexpected end of template")
600 /// - Filter/function errors (e.g., "Filter `unknown` not found")
601 ///
602 /// This function extracts the root cause and formats it in a user-friendly way,
603 /// filtering out unhelpful internal template names like '__tera_one_off'.
604 ///
605 /// # Arguments
606 ///
607 /// * `error` - The Tera error to format
608 pub fn format_tera_error(error: &tera::Error) -> String {
609 use std::error::Error;
610
611 let mut messages = Vec::new();
612
613 // Walk the entire error chain and collect all messages
614 let mut all_messages = vec![error.to_string()];
615 let mut current_error: Option<&dyn Error> = error.source();
616 while let Some(err) = current_error {
617 all_messages.push(err.to_string());
618 current_error = err.source();
619 }
620
621 // Process messages to extract useful information
622 for msg in all_messages {
623 // Clean up the message by removing internal template names
624 let cleaned = msg
625 .replace("while rendering '__tera_one_off'", "")
626 .replace("Failed to render '__tera_one_off'", "Template rendering failed")
627 .replace("Failed to parse '__tera_one_off'", "Template syntax error")
628 .replace("'__tera_one_off'", "template")
629 .trim()
630 .to_string();
631
632 // Only keep non-empty, useful messages
633 if !cleaned.is_empty()
634 && cleaned != "Template rendering failed"
635 && cleaned != "Template syntax error"
636 {
637 messages.push(cleaned);
638 }
639 }
640
641 // If we got useful messages, return them
642 if !messages.is_empty() {
643 messages.join("\n → ")
644 } else {
645 // Fallback: extract just the error kind
646 "Template syntax error (see details above)".to_string()
647 }
648 }
649
650 /// Format the template context as a string for error messages.
651 ///
652 /// # Arguments
653 ///
654 /// * `context` - The Tera context to format
655 fn format_context_as_string(context: &TeraContext) -> String {
656 let context_clone = context.clone();
657 let json_value = context_clone.into_json();
658 let mut output = String::new();
659
660 // Recursively format the JSON structure with indentation
661 fn format_value(key: &str, value: &serde_json::Value, indent: usize) -> Vec<String> {
662 let prefix = " ".repeat(indent);
663 let mut lines = Vec::new();
664
665 match value {
666 serde_json::Value::Object(map) => {
667 lines.push(format!("{}{}:", prefix, key));
668 for (k, v) in map {
669 lines.extend(format_value(k, v, indent + 1));
670 }
671 }
672 serde_json::Value::Array(arr) => {
673 lines.push(format!("{}{}: [{} items]", prefix, key, arr.len()));
674 // Only show first few items to avoid spam
675 for (i, item) in arr.iter().take(3).enumerate() {
676 lines.extend(format_value(&format!("[{}]", i), item, indent + 1));
677 }
678 if arr.len() > 3 {
679 lines.push(format!("{} ... {} more items", prefix, arr.len() - 3));
680 }
681 }
682 serde_json::Value::String(s) => {
683 // Truncate long strings
684 if s.len() > 100 {
685 lines.push(format!(
686 "{}{}: \"{}...\" ({} chars)",
687 prefix,
688 key,
689 &s[..97],
690 s.len()
691 ));
692 } else {
693 lines.push(format!("{}{}: \"{}\"", prefix, key, s));
694 }
695 }
696 serde_json::Value::Number(n) => {
697 lines.push(format!("{}{}: {}", prefix, key, n));
698 }
699 serde_json::Value::Bool(b) => {
700 lines.push(format!("{}{}: {}", prefix, key, b));
701 }
702 serde_json::Value::Null => {
703 lines.push(format!("{}{}: null", prefix, key));
704 }
705 }
706 lines
707 }
708
709 if let serde_json::Value::Object(map) = &json_value {
710 for (key, value) in map {
711 output.push_str(&format_value(key, value, 1).join("\n"));
712 output.push('\n');
713 }
714 }
715
716 output
717 }
718
719 /// Log the template context as key-value pairs at debug level.
720 ///
721 /// # Arguments
722 ///
723 /// * `context` - The Tera context to log
724 fn log_context_as_kv(context: &TeraContext) {
725 let formatted = Self::format_context_as_string(context);
726 for line in formatted.lines() {
727 tracing::debug!("{}", line);
728 }
729 }
730
731 /// Check if content contains Tera template syntax.
732 ///
733 /// # Arguments
734 ///
735 /// * `content` - The content to check
736 ///
737 /// # Returns
738 ///
739 /// Returns `true` if the content contains template delimiters.
740 pub(crate) fn contains_template_syntax(&self, content: &str) -> bool {
741 let has_vars = content.contains("{{");
742 let has_tags = content.contains("{%");
743 let has_comments = content.contains("{#");
744 let result = has_vars || has_tags || has_comments;
745 tracing::debug!(
746 "Template syntax check: vars={}, tags={}, comments={}, result={}",
747 has_vars,
748 has_tags,
749 has_comments,
750 result
751 );
752 result
753 }
754
755 /// Check if content contains template syntax outside of code fences.
756 ///
757 /// This is used after rendering to determine if another pass is needed.
758 /// It ignores template syntax inside code fences to prevent re-rendering
759 /// content that has already been processed (like embedded dependency content).
760 pub(crate) fn contains_template_syntax_outside_fences(&self, content: &str) -> bool {
761 let mut in_code_fence = false;
762 let mut in_guard = 0usize;
763
764 for line in content.lines() {
765 let trimmed = line.trim();
766
767 if trimmed == NON_TEMPLATED_LITERAL_GUARD_START {
768 in_guard = in_guard.saturating_add(1);
769 continue;
770 } else if trimmed == NON_TEMPLATED_LITERAL_GUARD_END {
771 in_guard = in_guard.saturating_sub(1);
772 continue;
773 }
774
775 if in_guard > 0 {
776 continue;
777 }
778
779 // Track code fence boundaries
780 if trimmed.starts_with("```") {
781 in_code_fence = !in_code_fence;
782 continue;
783 }
784
785 // Skip lines inside code fences
786 if in_code_fence {
787 continue;
788 }
789
790 // Check for template syntax in non-fenced content
791 if line.contains("{{") || line.contains("{%") || line.contains("{#") {
792 tracing::debug!(
793 "Template syntax found outside code fences: {:?}",
794 &line[..line.len().min(80)]
795 );
796 return true;
797 }
798 }
799
800 tracing::debug!("No template syntax found outside code fences");
801 false
802 }
803}