diff --git a/project_scaffold_cli/template_engine.py b/project_scaffold_cli/template_engine.py new file mode 100644 index 0000000..3ad0417 --- /dev/null +++ b/project_scaffold_cli/template_engine.py @@ -0,0 +1,200 @@ +"""Template engine using Jinja2 for project scaffolding.""" + +import os +from pathlib import Path +from typing import Any, Dict, Optional + +from jinja2 import ( + BaseLoader, + Environment, + FileSystemLoader, + TemplateSyntaxError, +) + +from .config import Config + + +class TemplateEngine: + """Jinja2 template rendering engine for project scaffolding.""" + + SUPPORTED_LANGUAGES = ["python", "nodejs", "go", "rust"] + + def __init__(self, template_path: Optional[str] = None, config: Optional[Config] = None): + self.template_path = template_path + self.config = config or Config() + self._env: Optional[Environment] = None + + def _get_environment(self) -> Environment: + """Get or create the Jinja2 environment.""" + if self._env is not None: + return self._env + + if self.template_path and not os.path.isabs(self.template_path): + search_dirs = self.config.get_template_dirs() + search_dirs.insert(0, str(Path.cwd())) + self._env = Environment( + loader=FileSystemLoader(search_dirs), + trim_blocks=True, + lstrip_blocks=True, + ) + elif self.template_path: + template_dir = Path(self.template_path).parent + self._env = Environment( + loader=FileSystemLoader([str(template_dir)]), + trim_blocks=True, + lstrip_blocks=True, + ) + else: + project_root = self._get_project_root() + self._env = Environment( + loader=FileSystemLoader(str(project_root)), + trim_blocks=True, + lstrip_blocks=True, + ) + + return self._env + + def _get_project_root(self) -> Path: + """Get the project root directory for built-in templates.""" + return Path(__file__).parent / "templates" + + def _validate_template(self, template_name: str) -> bool: + """Validate that a template exists.""" + env = self._get_environment() + try: + env.get_template(template_name) + return True + except Exception: + return False + + def _render_template( + self, template_name: str, context: Dict[str, Any] + ) -> str: + """Render a single template with context.""" + env = self._get_environment() + template = env.get_template(template_name) + return template.render(**context) + + def render_language_template( + self, language: str, context: Dict[str, Any], output_dir: Path + ) -> None: + """Render all templates for a language.""" + if language not in self.SUPPORTED_LANGUAGES: + raise ValueError( + f"Unsupported language: {language}. " + f"Supported: {', '.join(self.SUPPORTED_LANGUAGES)}" + ) + + env = self._get_environment() + language_dir = language + + try: + project_root = self._get_project_root() / language_dir + for root, dirs, files in os.walk(project_root): + rel_path = Path(root).relative_to(project_root) + + rel_path_str = rel_path.as_posix() + rel_path_str = rel_path_str.replace("{{project_slug}}", context.get("project_slug", "")) + rel_path_str = rel_path_str.replace("{{ project_name }}", context.get("project_name", "")) + + dest_dir = output_dir / rel_path_str + dest_dir.mkdir(parents=True, exist_ok=True) + + for file in files: + if file.endswith((".j2", ".jinja2")): + template_path = ( + Path(root) / file + ).relative_to(project_root).as_posix() + dest_file = Path(dest_dir) / file[:-3] + + try: + content = self._render_template( + language_dir + "/" + template_path, context + ) + dest_file.write_text(content) + except TemplateSyntaxError as e: + raise ValueError( + f"Template syntax error in {template_path}: {e}" + ) + else: + src_file = Path(root) / file + dest_file = Path(dest_dir) / file + dest_file.write_bytes(src_file.read_bytes()) + + except Exception as e: + raise RuntimeError(f"Error rendering language template: {e}") + + def render_project( + self, + context: Dict[str, Any], + output_dir: Path, + template: str, + ) -> None: + """Render a custom project template.""" + if not self._validate_template(template): + raise ValueError(f"Template not found: {template}") + + try: + for root, dirs, files in os.walk( + self._get_environment().loader.searchpath[0] + ): + for pattern in self._get_environment().loader.get_files( + template + ): + pass + + env = self._get_environment() + base_template = env.get_template(template) + + for item in base_template.list_templates(): + pass + + except Exception as e: + raise ValueError(f"Error rendering project template: {e}") + + def render_ci_template( + self, + ci_provider: str, + context: Dict[str, Any], + output_dir: Path, + ) -> None: + """Render CI/CD template for a provider.""" + ci_templates = { + "github": "ci/.github/workflows/ci.yml.j2", + "gitlab": "ci/.gitlab-ci.yml.j2", + } + + if ci_provider not in ci_templates: + raise ValueError( + f"Unsupported CI provider: {ci_provider}. " + f"Supported: {', '.join(ci_templates.keys())}" + ) + + env = self._get_environment() + template_name = ci_templates[ci_provider] + + try: + content = self._render_template(template_name, context) + + if ci_provider == "github": + workflow_dir = output_dir / ".github" / "workflows" + workflow_dir.mkdir(parents=True, exist_ok=True) + output_file = workflow_dir / "ci.yml" + else: + output_file = output_dir / ".gitlab-ci.yml" + + output_file.write_text(content) + + except Exception as e: + raise RuntimeError(f"Error rendering CI template: {e}") + + def get_template_list(self) -> list[str]: + """Get list of available templates.""" + env = self._get_environment() + return env.list_templates() + + def validate_context(self, context: Dict[str, Any]) -> list[str]: + """Validate that all required context variables are provided.""" + required = ["project_name", "author"] + missing = [var for var in required if var not in context or not context[var]] + return missing