Coverage for src/prosemark/templates/adapters/prosemark_template_validator.py: 89%
196 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-09-30 23:09 +0000
« prev ^ index » next coverage.py v7.8.0, created at 2025-09-30 23:09 +0000
1"""Prosemark-specific template validator adapter."""
3import re
4from typing import Any
6import yaml
8from prosemark.templates.domain.entities.placeholder import Placeholder
9from prosemark.templates.domain.entities.template import Template
10from prosemark.templates.domain.entities.template_directory import TemplateDirectory
11from prosemark.templates.domain.exceptions.template_exceptions import InvalidPlaceholderValueError
12from prosemark.templates.ports.template_validator_port import TemplateValidatorPort
15class ProsemarkTemplateValidator(TemplateValidatorPort):
16 """Prosemark-specific implementation of template validator."""
18 def validate_template(self, template: Template) -> list[str]:
19 """Validate a single template against prosemark standards.
21 Args:
22 template: Template to validate
24 Returns:
25 List of validation error messages (empty if valid)
27 """
28 errors: list[str] = []
30 # Validate basic template structure
31 errors.extend(self._validate_template_structure(template))
33 # Validate prosemark-specific requirements
34 errors.extend(self._validate_prosemark_format(template))
36 # Validate placeholder consistency
37 errors.extend(self._validate_placeholder_consistency(template))
39 return errors
41 def validate_template_directory(self, template_directory: TemplateDirectory) -> list[str]:
42 """Validate a template directory against prosemark standards.
44 Args:
45 template_directory: Template directory to validate
47 Returns:
48 List of validation error messages (empty if valid)
50 """
51 errors: list[str] = []
53 # Validate each template in the directory
54 for template in template_directory.templates:
55 template_errors = self.validate_template(template)
56 errors.extend(f"Template '{template.name}': {error}" for error in template_errors)
58 # Validate directory-specific requirements
59 errors.extend(self._validate_directory_consistency(template_directory))
61 return errors
63 @staticmethod
64 def validate_placeholder_values(template: Template, values: dict[str, str]) -> list[str]:
65 """Validate placeholder values for a template.
67 Args:
68 template: Template to validate values against
69 values: Dictionary of placeholder values to validate
71 Returns:
72 List of validation error messages (empty if valid)
74 """
75 errors: list[str] = []
77 # Check that all required placeholders have values
78 for placeholder in template.required_placeholders:
79 if placeholder.name not in values:
80 errors.append(f'Missing value for required placeholder: {placeholder.name}')
81 else:
82 # Validate the value
83 try:
84 placeholder.validate_value(values[placeholder.name])
85 except (InvalidPlaceholderValueError, ValueError) as e:
86 errors.append(str(e))
88 # Check for unexpected placeholder values
89 template_placeholder_names = {p.name for p in template.placeholders}
90 errors.extend(f'Unknown placeholder: {name}' for name in values if name not in template_placeholder_names)
92 return errors
94 def _validate_template_structure(self, template: Template) -> list[str]:
95 """Validate basic template structure.
97 Args:
98 template: Template to validate
100 Returns:
101 List of validation errors
103 """
104 errors: list[str] = []
106 # Must have frontmatter
107 if not template.frontmatter: 107 ↛ 108line 107 didn't jump to line 108 because the condition on line 107 was never true
108 errors.append('Template must have YAML frontmatter')
110 # Must have body content
111 if not template.body.strip(): 111 ↛ 112line 111 didn't jump to line 112 because the condition on line 111 was never true
112 errors.append('Template must have body content')
114 # Content must be valid markdown structure
115 errors.extend(self._validate_content_structure(template))
117 return errors
119 def _validate_prosemark_format(self, template: Template) -> list[str]:
120 """Validate prosemark-specific format requirements.
122 Args:
123 template: Template to validate
125 Returns:
126 List of validation errors
128 """
129 errors: list[str] = []
131 # Validate YAML frontmatter structure
132 if template.frontmatter: 132 ↛ 136line 132 didn't jump to line 136 because the condition on line 132 was always true
133 errors.extend(self._validate_yaml_frontmatter(template.frontmatter))
135 # Validate that body starts with a heading (prosemark convention)
136 if template.body.strip(): 136 ↛ 143line 136 didn't jump to line 143 because the condition on line 136 was always true
137 lines = [line.strip() for line in template.body.strip().split('\n') if line.strip()]
138 if lines and not lines[0].startswith('#'):
139 # This is a warning for prosemark, not a hard error
140 # Could be made configurable based on strictness level
141 pass
143 return errors
145 def _validate_placeholder_consistency(self, template: Template) -> list[str]:
146 """Validate placeholder usage consistency.
148 Args:
149 template: Template to validate
151 Returns:
152 List of validation errors
154 """
155 # Validate that all placeholders in frontmatter have corresponding patterns in content
156 frontmatter_str = yaml.safe_dump(template.frontmatter)
157 all_content = frontmatter_str + template.body
159 errors = [
160 f"Placeholder '{placeholder.name}' defined but not used in template"
161 for placeholder in template.placeholders
162 if not placeholder.pattern_obj.matches_text(all_content)
163 ]
165 # Validate placeholder naming conventions
166 errors.extend(
167 f"Placeholder name '{placeholder.name}' violates naming conventions"
168 for placeholder in template.placeholders
169 if not self._is_valid_placeholder_name(placeholder.name)
170 )
172 return errors
174 @staticmethod
175 def _validate_directory_consistency(template_directory: TemplateDirectory) -> list[str]:
176 """Validate directory-specific consistency requirements.
178 Args:
179 template_directory: Template directory to validate
181 Returns:
182 List of validation errors
184 """
185 errors: list[str] = []
187 # Check for shared placeholder consistency
188 shared_placeholders = template_directory.shared_placeholders
190 for shared_placeholder in shared_placeholders:
191 # Find all instances of this placeholder across templates
192 placeholder_instances = []
193 for template in template_directory.templates:
194 placeholder = template.get_placeholder_by_name(shared_placeholder.name)
195 if placeholder: 195 ↛ 193line 195 didn't jump to line 193 because the condition on line 195 was always true
196 placeholder_instances.append((template.name, placeholder))
198 # Validate consistency across instances
199 if len(placeholder_instances) > 1: 199 ↛ 190line 199 didn't jump to line 190 because the condition on line 199 was always true
200 first_template_name, first_placeholder = placeholder_instances[0]
202 for template_name, placeholder in placeholder_instances[1:]:
203 # Check required/optional consistency
204 if placeholder.required != first_placeholder.required: 204 ↛ 205line 204 didn't jump to line 205 because the condition on line 204 was never true
205 errors.append(
206 f"Placeholder '{placeholder.name}' has inconsistent required status "
207 f"between templates '{first_template_name}' and '{template_name}'"
208 )
210 # Check default value consistency
211 if placeholder.default_value != first_placeholder.default_value: 211 ↛ 212line 211 didn't jump to line 212 because the condition on line 211 was never true
212 errors.append(
213 f"Placeholder '{placeholder.name}' has inconsistent default values "
214 f"between templates '{first_template_name}' and '{template_name}'"
215 )
217 return errors
219 @staticmethod
220 def _validate_content_structure(template: Template) -> list[str]:
221 """Validate markdown content structure.
223 Args:
224 template: Template to validate
226 Returns:
227 List of validation errors
229 """
230 errors: list[str] = []
232 # Check for malformed markdown structures
233 content = template.body
235 # Basic markdown validation could be added here
236 # For now, we'll keep it simple and just check for basic issues
238 # Check for unclosed code blocks
239 if content.count('```') % 2 != 0:
240 errors.append('Template contains unclosed code blocks')
242 # Check for malformed placeholder patterns
243 import re
245 malformed_patterns = re.findall(r'\{[^{}]*\}(?!\})', content)
246 malformed_patterns.extend(re.findall(r'(?<!\{)\{[^{}]*\}', content))
248 # Filter out valid patterns
249 valid_pattern = re.compile(r'\{\{[a-zA-Z_][a-zA-Z0-9_]*\}\}')
250 truly_malformed = [pattern for pattern in malformed_patterns if not valid_pattern.match(pattern)]
252 if truly_malformed: 252 ↛ 253line 252 didn't jump to line 253 because the condition on line 252 was never true
253 errors.append(f'Template contains malformed placeholder patterns: {truly_malformed}')
255 return errors
257 def _validate_yaml_frontmatter(self, frontmatter: dict[str, Any]) -> list[str]:
258 """Validate YAML frontmatter structure.
260 Args:
261 frontmatter: Parsed frontmatter dictionary
263 Returns:
264 List of validation errors
266 """
267 errors: list[str] = []
269 # Validate that frontmatter is a dictionary
270 if not isinstance(frontmatter, dict):
271 errors.append('YAML frontmatter must be a dictionary')
272 return errors
274 # Check for reserved keys that might conflict with prosemark
275 reserved_keys = {'id', 'created', 'modified', 'type'}
276 errors.extend(f'Frontmatter contains reserved key: {key}' for key in frontmatter if key in reserved_keys)
278 # Validate placeholder-related keys
279 for key, value in frontmatter.items():
280 if key.endswith('_default'):
281 placeholder_name = key[:-8] # Remove '_default'
282 if not self._is_valid_placeholder_name(placeholder_name):
283 errors.append(f'Invalid placeholder name in default key: {placeholder_name}')
284 if not isinstance(value, str):
285 errors.append(f'Default value for {placeholder_name} must be a string')
287 elif key.endswith('_description'):
288 placeholder_name = key[:-12] # Remove '_description'
289 if not self._is_valid_placeholder_name(placeholder_name):
290 errors.append(f'Invalid placeholder name in description key: {placeholder_name}')
291 if not isinstance(value, str):
292 errors.append(f'Description for {placeholder_name} must be a string')
294 return errors
296 @staticmethod
297 def _is_valid_placeholder_name(name: str) -> bool:
298 """Check if a placeholder name follows naming conventions.
300 Args:
301 name: Placeholder name to validate
303 Returns:
304 True if name is valid, False otherwise
306 """
307 import re
309 # Must start with letter or underscore, followed by letters, digits, or underscores
310 pattern = re.compile(r'^[a-zA-Z_][a-zA-Z0-9_]*$')
311 return bool(pattern.match(name))
313 @classmethod
314 def validate_template_structure(cls, content: str) -> bool:
315 """Validate that template content has valid structure.
317 Args:
318 content: Raw template content
320 Returns:
321 True if structure is valid
323 Raises:
324 TemplateParseError: If YAML frontmatter is invalid
325 TemplateValidationError: If content violates prosemark format
327 """
328 from prosemark.templates.domain.exceptions.template_exceptions import (
329 TemplateParseError,
330 TemplateValidationError,
331 )
333 # Basic structure checks
334 if not content.startswith('---'):
335 raise TemplateValidationError('Template must have YAML frontmatter', template_path='<string>')
337 try:
338 # Try to split and parse frontmatter
339 parts = content.split('---', 2)
340 min_frontmatter_parts = 3
341 if len(parts) < min_frontmatter_parts: 341 ↛ 342line 341 didn't jump to line 342 because the condition on line 341 was never true
342 raise TemplateValidationError('Template must have YAML frontmatter', template_path='<string>')
344 frontmatter_text = parts[1].strip()
345 body_text = parts[2].lstrip('\n')
347 # Parse YAML frontmatter
348 if frontmatter_text: 348 ↛ 360line 348 didn't jump to line 360 because the condition on line 348 was always true
349 try:
350 parsed_frontmatter = yaml.safe_load(frontmatter_text)
351 if parsed_frontmatter is None: 351 ↛ 352line 351 didn't jump to line 352 because the condition on line 351 was never true
352 parsed_frontmatter = {}
353 if not isinstance(parsed_frontmatter, dict):
354 raise TemplateParseError('YAML frontmatter must be a dictionary', template_path='<string>')
355 except yaml.YAMLError as e:
356 msg = f'Invalid YAML frontmatter: {e}'
357 raise TemplateParseError(msg, template_path='<string>') from e
359 # Must have body content
360 if not body_text.strip():
361 raise TemplateValidationError('Template must have body content', template_path='<string>')
363 return len(parts) >= min_frontmatter_parts
365 except (ValueError, AttributeError) as e:
366 msg = f'Template structure validation failed: {e}'
367 raise TemplateValidationError(msg, template_path='<string>') from e
369 @classmethod
370 def validate_prosemark_format(cls, content: str) -> bool:
371 """Validate that template follows prosemark node format.
373 Args:
374 content: Raw template content
376 Returns:
377 True if format is valid
379 Raises:
380 TemplateValidationError: If content violates prosemark format requirements
382 """
383 from prosemark.templates.domain.exceptions.template_exceptions import (
384 TemplateValidationError,
385 )
387 try:
388 # Basic format validation - should have frontmatter and body
389 # Additional prosemark-specific checks could go here
390 return cls.validate_template_structure(content)
391 except (ValueError, yaml.YAMLError, AttributeError) as e:
392 msg = f'Prosemark format validation failed: {e}'
393 raise TemplateValidationError(msg, template_path='<string>') from e
395 @classmethod
396 def extract_placeholders(cls, content: str) -> list[Placeholder]:
397 """Extract all placeholders from template content.
399 Args:
400 content: Template content containing placeholders
402 Returns:
403 List of Placeholder instances found in content
405 Raises:
406 InvalidPlaceholderError: If placeholder syntax is malformed
408 """
409 from prosemark.templates.domain.exceptions.template_exceptions import InvalidPlaceholderError
411 try:
412 from prosemark.templates.domain.services.placeholder_service import PlaceholderService
414 service = PlaceholderService()
416 # Check for severely malformed patterns that indicate completely broken content
417 severely_malformed_patterns = [
418 r'\{\{[^}]*\n', # Unclosed placeholders like {{unclosed
419 ]
421 for pattern in severely_malformed_patterns:
422 if re.search(pattern, content):
423 raise InvalidPlaceholderError('Malformed placeholder pattern detected in content')
425 return service.extract_placeholders_from_text(content)
426 except (ValueError, AttributeError) as e:
427 msg = f'Failed to extract placeholders: {e}'
428 raise InvalidPlaceholderError(msg) from e
430 @classmethod
431 def validate_placeholder_syntax(cls, placeholder_text: str) -> bool:
432 """Validate that a placeholder has correct syntax.
434 Args:
435 placeholder_text: Placeholder pattern (e.g., "{{variable_name}}")
437 Returns:
438 True if syntax is valid
440 Raises:
441 InvalidPlaceholderError: If syntax is malformed
443 """
444 from prosemark.templates.domain.exceptions.template_exceptions import InvalidPlaceholderError
446 try:
447 from prosemark.templates.domain.services.placeholder_service import PlaceholderService
449 service = PlaceholderService()
450 result = service.validate_placeholder_pattern(placeholder_text)
451 if result:
452 return True
453 msg = f'Invalid placeholder syntax: {placeholder_text}'
454 raise InvalidPlaceholderError(msg)
455 except (ValueError, yaml.YAMLError, AttributeError) as e:
456 msg = f'Placeholder validation failed: {e}'
457 raise InvalidPlaceholderError(msg) from e
459 @classmethod
460 def validate_template_dependencies(cls, template: Template) -> bool:
461 """Validate that template dependencies are resolvable.
463 Args:
464 template: Template to validate dependencies for
466 Returns:
467 True if all dependencies are valid
469 Raises:
470 TemplateValidationError: If dependencies cannot be resolved
472 """
473 from prosemark.templates.domain.exceptions.template_exceptions import TemplateValidationError
475 # Basic dependency validation - check that placeholders are well-formed
476 try:
477 placeholders = template.placeholders
479 def validate_placeholders() -> bool:
480 placeholder_list = list(placeholders)
481 for placeholder in placeholder_list:
482 cls.validate_placeholder_syntax(placeholder.pattern)
483 return True
485 if not placeholders:
486 return True
487 return validate_placeholders()
488 except (ValueError, yaml.YAMLError, AttributeError) as e:
489 msg = f'Template dependency validation failed: {e}'
490 raise TemplateValidationError(msg, template_path=str(template.path)) from e