Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d16fe5fde4 | |||
| 60f1811c97 | |||
| 68fda8bad6 | |||
| 6b61582ae8 | |||
| 35981b00ff | |||
| 0439c67d50 | |||
| 6c468e67a4 | |||
| 3b0c3ac01d | |||
| a406da5c35 | |||
| ba06e389b3 | |||
| 165db96129 | |||
| 577025e346 | |||
| 889aa8d0e8 | |||
| 9f4e2544f6 | |||
| 028b70292d | |||
| c600ed35ab |
@@ -25,16 +25,17 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
cd patternforge
|
||||
pip install -e ".[dev]"
|
||||
|
||||
- name: Lint with ruff
|
||||
run: ruff check .
|
||||
run: cd patternforge && ruff check .
|
||||
|
||||
- name: Type check with mypy
|
||||
run: mypy src/
|
||||
run: cd patternforge && mypy .
|
||||
|
||||
- name: Run tests
|
||||
run: pytest tests/ -v --cov=src --cov-report=xml
|
||||
run: cd patternforge && pytest tests/ -v --cov=src --cov-report=xml
|
||||
|
||||
- name: Upload coverage
|
||||
if: matrix.python-version == '3.11'
|
||||
@@ -60,9 +61,12 @@ jobs:
|
||||
run: pip install build
|
||||
|
||||
- name: Build package
|
||||
run: python -m build
|
||||
run: |
|
||||
cd patternforge
|
||||
python -m build
|
||||
|
||||
- name: Verify build
|
||||
run: |
|
||||
cd patternforge
|
||||
pip install dist/*.whl
|
||||
patternforge --help
|
||||
|
||||
1
patternforge/src/patternforge/__init__.py
Normal file
1
patternforge/src/patternforge/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
import __version__
|
||||
253
patternforge/src/patternforge/analyzer.py
Normal file
253
patternforge/src/patternforge/analyzer.py
Normal file
@@ -0,0 +1,253 @@
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import tree_sitter
|
||||
from tree_sitter_languages import get_language
|
||||
|
||||
from patternforge.config import Config
|
||||
|
||||
|
||||
@dataclass
|
||||
class NamingPattern:
|
||||
convention: str
|
||||
prefixes: list[str] = field(default_factory=list)
|
||||
suffixes: list[str] = field(default_factory=list)
|
||||
examples: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CodeStructure:
|
||||
class_patterns: list[dict[str, Any]] = field(default_factory=list)
|
||||
function_patterns: list[dict[str, Any]] = field(default_factory=list)
|
||||
import_patterns: list[str] = field(default_factory=list)
|
||||
type_definitions: list[dict[str, str]] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StylePattern:
|
||||
indent_style: str = "space"
|
||||
indent_size: int = 4
|
||||
line_endings: str = "lf"
|
||||
bracket_style: str = "same-line"
|
||||
|
||||
|
||||
class CodeAnalyzer:
|
||||
LANGUAGE_MAP = {
|
||||
"python": "python",
|
||||
"javascript": "javascript",
|
||||
"typescript": "typescript",
|
||||
"java": "java",
|
||||
"cpp": "cpp",
|
||||
"c": "c",
|
||||
"rust": "rust",
|
||||
"go": "go",
|
||||
"ruby": "ruby",
|
||||
}
|
||||
|
||||
def __init__(self, language: str, config: Config) -> None:
|
||||
self.language = self.LANGUAGE_MAP.get(language, language)
|
||||
self.config = config
|
||||
self._parser: tree_sitter.Parser | None = None
|
||||
self._language: Any = None
|
||||
self._try_init_language()
|
||||
|
||||
def _try_init_language(self) -> None:
|
||||
try:
|
||||
self._parser = tree_sitter.Parser()
|
||||
self._language = get_language(self.language)
|
||||
self._parser.language = self._language
|
||||
except Exception:
|
||||
self._parser = None
|
||||
self._language = None
|
||||
|
||||
def _file_extensions(self) -> set[str]:
|
||||
extensions = {
|
||||
"python": [".py", ".pyi"],
|
||||
"javascript": [".js", ".mjs"],
|
||||
"typescript": [".ts", ".tsx"],
|
||||
"java": [".java"],
|
||||
"cpp": [".cpp", ".cc", ".cxx", ".hpp"],
|
||||
"c": [".c", ".h"],
|
||||
"rust": [".rs"],
|
||||
"go": [".go"],
|
||||
"ruby": [".rb"],
|
||||
}
|
||||
return set(extensions.get(self.language, [f".{self.language}"]))
|
||||
|
||||
def _is_code_file(self, path: Path) -> bool:
|
||||
return path.suffix in self._file_extensions()
|
||||
|
||||
def _collect_files(self, path: Path, recursive: bool) -> list[Path]:
|
||||
files: list[Path] = []
|
||||
if path.is_file():
|
||||
if self._is_code_file(path):
|
||||
files.append(path)
|
||||
return files
|
||||
pattern = "**/*" if recursive else "*"
|
||||
for f in path.glob(pattern):
|
||||
if f.is_file() and self._is_code_file(f):
|
||||
files.append(f)
|
||||
return files
|
||||
|
||||
def _extract_naming_conventions(self, content: str) -> dict[str, NamingPattern]:
|
||||
conventions: dict[str, NamingPattern] = {}
|
||||
patterns = {
|
||||
"camelCase": r"[a-z][a-zA-Z0-9]*",
|
||||
"PascalCase": r"[A-Z][a-zA-Z0-9]*",
|
||||
"snake_case": r"[a-z][a-z0-9_]*",
|
||||
"SCREAMING_SNAKE_CASE": r"[A-Z][A-Z0-9_]*",
|
||||
}
|
||||
for name, pattern in patterns.items():
|
||||
matches = re.findall(pattern, content)
|
||||
if matches:
|
||||
conventions[name] = NamingPattern(
|
||||
convention=name,
|
||||
examples=list(set(matches))[:10],
|
||||
)
|
||||
return conventions
|
||||
|
||||
def _extract_structure(self, content: str) -> CodeStructure:
|
||||
structure = CodeStructure()
|
||||
class_pattern = r"class\s+(\w+)"
|
||||
func_pattern = r"def\s+(\w+)|function\s+(\w+)|public\s+\w+\s+(\w+)"
|
||||
import_pattern = r"^import\s+.*|^from\s+.*|^#include\s+.*"
|
||||
|
||||
for match in re.finditer(class_pattern, content):
|
||||
structure.class_patterns.append({"name": match.group(1)})
|
||||
|
||||
for match in re.finditer(func_pattern, content):
|
||||
name = match.group(1) or match.group(2) or match.group(3)
|
||||
if name:
|
||||
structure.function_patterns.append({"name": name})
|
||||
|
||||
structure.import_patterns = re.findall(import_pattern, content, re.MULTILINE)[:20]
|
||||
|
||||
return structure
|
||||
|
||||
def _detect_style(self, content: str) -> StylePattern:
|
||||
style = StylePattern()
|
||||
if "\t" in content[:1000]:
|
||||
style.indent_style = "tab"
|
||||
style.indent_size = 1
|
||||
elif " " * 4 in content[:1000]:
|
||||
style.indent_size = 4
|
||||
elif " " * 2 in content[:1000]:
|
||||
style.indent_size = 2
|
||||
|
||||
if "\r\n" in content[:1000]:
|
||||
style.line_endings = "crlf"
|
||||
else:
|
||||
style.line_endings = "lf"
|
||||
|
||||
return style
|
||||
|
||||
def _analyze_file(self, path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
with open(path, encoding="utf-8", errors="ignore") as f:
|
||||
content = f.read()
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
return {
|
||||
"path": str(path),
|
||||
"naming_conventions": self._extract_naming_conventions(content),
|
||||
"structure": {
|
||||
"classes": self._extract_structure(content).class_patterns,
|
||||
"functions": self._extract_structure(content).function_patterns,
|
||||
"imports": self._extract_structure(content).import_patterns,
|
||||
},
|
||||
"style": self._detect_style(content).__dict__,
|
||||
"size": len(content),
|
||||
"lines": content.count("\n"),
|
||||
}
|
||||
|
||||
def analyze(self, path: str, recursive: bool = True) -> dict[str, Any]:
|
||||
target = Path(path)
|
||||
files = self._collect_files(target, recursive)
|
||||
if not files:
|
||||
return {"error": "No matching files found", "language": self.language}
|
||||
|
||||
file_analyses = []
|
||||
all_naming: dict[str, set[str]] = {}
|
||||
all_classes: list[str] = []
|
||||
all_functions: list[str] = []
|
||||
all_imports: list[str] = []
|
||||
style_votes = {"space": 0, "tab": 0}
|
||||
indent_sizes: dict[int, int] = {}
|
||||
|
||||
for f in files:
|
||||
analysis = self._analyze_file(f)
|
||||
if not analysis:
|
||||
continue
|
||||
file_analyses.append(analysis)
|
||||
for nc in analysis.get("naming_conventions", {}).values():
|
||||
for ex in nc.examples:
|
||||
if nc.convention not in all_naming:
|
||||
all_naming[nc.convention] = set()
|
||||
all_naming[nc.convention].add(ex)
|
||||
|
||||
for cls in analysis.get("structure", {}).get("classes", []):
|
||||
all_classes.append(cls.get("name", ""))
|
||||
|
||||
for func in analysis.get("structure", {}).get("functions", []):
|
||||
all_functions.append(func.get("name", ""))
|
||||
|
||||
all_imports.extend(analysis.get("structure", {}).get("imports", []))
|
||||
|
||||
style = analysis.get("style", {})
|
||||
if style.get("indent_style"):
|
||||
style_votes[style["indent_style"]] += 1
|
||||
indent = style.get("indent_size", 0)
|
||||
if indent > 0:
|
||||
indent_sizes[indent] = indent_sizes.get(indent, 0) + 1
|
||||
|
||||
dominant_style = "space" if style_votes["space"] >= style_votes["tab"] else "tab"
|
||||
dominant_indent = max(indent_sizes.items(), key=lambda x: x[1], default=(4, 0))[0]
|
||||
|
||||
return {
|
||||
"language": self.language,
|
||||
"files_analyzed": len(file_analyses),
|
||||
"file_details": file_analyses[:5],
|
||||
"naming_conventions": {
|
||||
k: list(v)[:20] for k, v in all_naming.items()
|
||||
},
|
||||
"entity_counts": {
|
||||
"classes": len(all_classes),
|
||||
"functions": len(all_functions),
|
||||
"imports": len(all_imports),
|
||||
},
|
||||
"style": {
|
||||
"indent_style": dominant_style,
|
||||
"indent_size": dominant_indent,
|
||||
},
|
||||
"summary": {
|
||||
"files": len(file_analyses),
|
||||
"classes": len(all_classes),
|
||||
"functions": len(all_functions),
|
||||
"primary_naming": list(all_naming.keys())[0] if all_naming else "unknown",
|
||||
},
|
||||
}
|
||||
|
||||
def save_patterns(self, output_path: str, patterns: dict[str, Any]) -> None:
|
||||
import yaml
|
||||
|
||||
def convert_dataclass(obj: Any) -> Any:
|
||||
if hasattr(obj, "__dict__"):
|
||||
return {
|
||||
k: convert_dataclass(v)
|
||||
for k, v in obj.__dict__.items()
|
||||
if not k.startswith("_")
|
||||
}
|
||||
elif isinstance(obj, dict):
|
||||
return {k: convert_dataclass(v) for k, v in obj.items()}
|
||||
elif isinstance(obj, list):
|
||||
return [convert_dataclass(i) for i in obj]
|
||||
return obj
|
||||
|
||||
path = Path(output_path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
clean_patterns = convert_dataclass(patterns)
|
||||
with open(path, "w") as f:
|
||||
yaml.dump(clean_patterns, f, default_flow_style=False, indent=2)
|
||||
154
patternforge/src/patternforge/cli.py
Normal file
154
patternforge/src/patternforge/cli.py
Normal file
@@ -0,0 +1,154 @@
|
||||
import click
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from patternforge.analyzer import CodeAnalyzer
|
||||
from patternforge.config import Config
|
||||
from patternforge.generator import BoilerplateGenerator
|
||||
from patternforge.template import TemplateManager
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
@click.group()
|
||||
@click.option("--config", "-c", type=click.Path(exists=True), help="Config file path")
|
||||
@click.pass_context
|
||||
def main(ctx: click.Context, config: str | None) -> None:
|
||||
ctx.ensure_object(dict)
|
||||
cfg = Config.load(config) if config else Config.load()
|
||||
ctx.obj["config"] = cfg
|
||||
|
||||
|
||||
@main.command("analyze")
|
||||
@click.argument("path", type=click.Path(exists=True, file_okay=True, dir_okay=True))
|
||||
@click.option("--language", "-l", required=True, help="Programming language")
|
||||
@click.option("--output", "-o", required=True, type=click.Path(), help="Output file for patterns")
|
||||
@click.option("--recursive/--no-recursive", default=True, help="Analyze directories recursively")
|
||||
@click.pass_context
|
||||
def analyze(ctx: click.Context, path: str, language: str, output: str, recursive: bool) -> None:
|
||||
"""Analyze a codebase and extract patterns."""
|
||||
config: Config = ctx.obj["config"]
|
||||
console.print(f"Analyzing [cyan]{path}[/] for [cyan]{language}[/] patterns...")
|
||||
analyzer = CodeAnalyzer(language, config)
|
||||
patterns = analyzer.analyze(path, recursive)
|
||||
analyzer.save_patterns(output, patterns)
|
||||
console.print(f"Patterns saved to [green]{output}[/]")
|
||||
console.print(f"Detected: {patterns.get('summary', {})}")
|
||||
|
||||
|
||||
@click.group()
|
||||
def template() -> None:
|
||||
"""Template management commands."""
|
||||
pass
|
||||
|
||||
|
||||
@template.command("create")
|
||||
@click.argument("name", type=str)
|
||||
@click.option("--pattern", "-p", type=click.Path(exists=True), required=True, help="Pattern file")
|
||||
@click.option("--template", "-t", type=click.Path(exists=True), help="Custom Jinja2 template file")
|
||||
@click.option("--description", "-d", type=str, default="", help="Template description")
|
||||
@click.pass_context
|
||||
def template_create(
|
||||
ctx: click.Context, name: str, pattern: str, template: str | None, description: str
|
||||
) -> None:
|
||||
"""Create a new template from detected patterns."""
|
||||
config: Config = ctx.obj["config"]
|
||||
manager = TemplateManager(config)
|
||||
manager.create_template(name, pattern, template, description)
|
||||
console.print(f"Template [green]{name}[/] created successfully")
|
||||
|
||||
|
||||
@template.command("list")
|
||||
@click.pass_context
|
||||
def template_list(ctx: click.Context) -> None:
|
||||
"""List all available templates."""
|
||||
config: Config = ctx.obj["config"]
|
||||
manager = TemplateManager(config)
|
||||
templates = manager.list_templates()
|
||||
if not templates:
|
||||
console.print("[yellow]No templates found[/]")
|
||||
return
|
||||
table = Table(title="Templates")
|
||||
table.add_column("Name")
|
||||
table.add_column("Description")
|
||||
table.add_column("Created")
|
||||
for t in templates:
|
||||
table.add_row(t["name"], t.get("description", ""), t.get("created", ""))
|
||||
console.print(table)
|
||||
|
||||
|
||||
@template.command("remove")
|
||||
@click.argument("name", type=str)
|
||||
@click.option("--force/--no-force", default=False, help="Skip confirmation")
|
||||
@click.pass_context
|
||||
def template_remove(ctx: click.Context, name: str, force: bool) -> None:
|
||||
"""Remove a template."""
|
||||
config: Config = ctx.obj["config"]
|
||||
if not force:
|
||||
if not click.confirm(f"Remove template [cyan]{name}[/]?"):
|
||||
return
|
||||
manager = TemplateManager(config)
|
||||
manager.remove_template(name)
|
||||
console.print(f"Template [green]{name}[/] removed")
|
||||
|
||||
|
||||
main.add_command(template, "template")
|
||||
|
||||
|
||||
@main.command("generate")
|
||||
@click.argument("template", type=str)
|
||||
@click.option("--output", "-o", type=click.Path(), required=True, help="Output directory")
|
||||
@click.option(
|
||||
"--data", "-d", type=click.Path(exists=True), help="JSON data file for template variables"
|
||||
)
|
||||
@click.option("--name", "-n", help="Name for the generated files")
|
||||
@click.pass_context
|
||||
def generate(
|
||||
ctx: click.Context, template: str, output: str, data: str | None, name: str | None
|
||||
) -> None:
|
||||
"""Generate boilerplate from a template."""
|
||||
config: Config = ctx.obj["config"]
|
||||
generator = BoilerplateGenerator(config)
|
||||
generator.generate(template, output, data, name)
|
||||
console.print(f"Boilerplate generated at [green]{output}[/]")
|
||||
|
||||
|
||||
@main.command("export")
|
||||
@click.argument("source", type=click.Path(exists=True))
|
||||
@click.argument("destination", type=click.Path())
|
||||
@click.option("--format", "-f", type=click.Choice(["yaml", "json"]), default="yaml")
|
||||
@click.pass_context
|
||||
def export(ctx: click.Context, source: str, destination: str, format: str) -> None:
|
||||
"""Export patterns or templates for team sharing."""
|
||||
config: Config = ctx.obj["config"]
|
||||
manager = TemplateManager(config)
|
||||
manager.export_patterns(source, destination, format)
|
||||
console.print(f"Exported to [green]{destination}[/]")
|
||||
|
||||
|
||||
@main.command("import")
|
||||
@click.argument("source", type=click.Path(exists=True))
|
||||
@click.pass_context
|
||||
def import_patterns(ctx: click.Context, source: str) -> None:
|
||||
"""Import patterns from team repository."""
|
||||
config: Config = ctx.obj["config"]
|
||||
manager = TemplateManager(config)
|
||||
manager.import_patterns(source)
|
||||
console.print(f"Imported from [green]{source}[/]")
|
||||
|
||||
|
||||
@main.command("config")
|
||||
@click.pass_context
|
||||
def show_config(ctx: click.Context) -> None:
|
||||
"""Show current configuration."""
|
||||
config: Config = ctx.obj["config"]
|
||||
table = Table(title="Configuration")
|
||||
table.add_column("Setting")
|
||||
table.add_column("Value")
|
||||
for key, value in config.to_dict().items():
|
||||
table.add_row(key, str(value))
|
||||
console.print(table)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
59
patternforge/src/patternforge/config.py
Normal file
59
patternforge/src/patternforge/config.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
class Config:
|
||||
def __init__(
|
||||
self,
|
||||
default_patterns: Path | None = None,
|
||||
template_dir: Path | None = None,
|
||||
language: str = "python",
|
||||
indent_size: int = 4,
|
||||
custom_config: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
self._config: dict[str, Any] = {}
|
||||
self._config_dir = Path.home() / ".config" / "patternforge"
|
||||
self._config_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._patterns_dir = default_patterns or self._config_dir / "patterns"
|
||||
self._templates_dir = template_dir or self._config_dir / "templates"
|
||||
self._patterns_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._templates_dir.mkdir(parents=True, exist_ok=True)
|
||||
if custom_config:
|
||||
self._config.update(custom_config)
|
||||
|
||||
@classmethod
|
||||
def load(cls, config_path: str | None = None) -> "Config":
|
||||
config = cls()
|
||||
if config_path and Path(config_path).exists():
|
||||
with open(config_path) as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
config._config.update(data)
|
||||
return config
|
||||
|
||||
@property
|
||||
def patterns_dir(self) -> Path:
|
||||
return self._patterns_dir
|
||||
|
||||
@property
|
||||
def templates_dir(self) -> Path:
|
||||
return self._templates_dir
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
return self._config.get(key, default)
|
||||
|
||||
def set(self, key: str, value: Any) -> None:
|
||||
self._config[key] = value
|
||||
|
||||
def save(self, config_path: str | None = None) -> None:
|
||||
path = Path(config_path) if config_path else self._config_dir / "config.yaml"
|
||||
with open(path, "w") as f:
|
||||
yaml.dump(self.to_dict(), f)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"patterns_dir": str(self._patterns_dir),
|
||||
"templates_dir": str(self._templates_dir),
|
||||
**self._config,
|
||||
}
|
||||
157
patternforge/src/patternforge/generator.py
Normal file
157
patternforge/src/patternforge/generator.py
Normal file
@@ -0,0 +1,157 @@
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from patternforge.analyzer import CodeAnalyzer
|
||||
from patternforge.config import Config
|
||||
from patternforge.template import TemplateManager
|
||||
|
||||
|
||||
class BoilerplateGenerator:
|
||||
def __init__(self, config: Config) -> None:
|
||||
self.config = config
|
||||
self.analyzer = CodeAnalyzer("python", config)
|
||||
self.template_manager = TemplateManager(config)
|
||||
|
||||
def _to_camel_case(self, snake: str) -> str:
|
||||
if "_" not in snake:
|
||||
return snake.lower()
|
||||
parts = snake.split("_")
|
||||
return parts[0].lower() + "".join(p.title() for p in parts[1:])
|
||||
|
||||
def _to_pascal_case(self, camel: str) -> str:
|
||||
if "_" not in camel:
|
||||
return camel[0].upper() + camel[1:] if camel else ""
|
||||
parts = camel.split("_")
|
||||
return "".join(p.title() for p in parts)
|
||||
|
||||
def _to_snake_case(self, name: str) -> str:
|
||||
if "_" in name:
|
||||
return name.lower()
|
||||
result = re.sub(r"(?<!^)(?=[A-Z])", "_", name)
|
||||
return result.lower()
|
||||
|
||||
def _infer_context(
|
||||
self,
|
||||
template_name: str,
|
||||
entity_name: str | None,
|
||||
) -> dict[str, Any]:
|
||||
template = self.template_manager.get_template(template_name)
|
||||
if not template:
|
||||
raise ValueError(f"Template not found: {template_name}")
|
||||
|
||||
name = entity_name or template_name
|
||||
patterns = template.get("patterns", {})
|
||||
primary_naming = patterns.get("summary", {}).get("primary_naming", "snake_case")
|
||||
|
||||
context: dict[str, Any] = {
|
||||
"project_name": "generated-project",
|
||||
"entity_name": name,
|
||||
"class_name": "",
|
||||
"function_name": "",
|
||||
"fields": [],
|
||||
"methods": [],
|
||||
"params": "",
|
||||
"docstring": f"Generated {name}",
|
||||
"class_docstring": f"Auto-generated class for {name}",
|
||||
}
|
||||
|
||||
if primary_naming == "PascalCase":
|
||||
context["class_name"] = self._to_pascal_case(name)
|
||||
context["function_name"] = self._to_camel_case(name)
|
||||
elif primary_naming == "camelCase":
|
||||
context["class_name"] = self._to_pascal_case(name)
|
||||
context["function_name"] = self._to_camel_case(name)
|
||||
else:
|
||||
context["class_name"] = self._to_pascal_case(name)
|
||||
context["function_name"] = name
|
||||
|
||||
context["fields"] = [
|
||||
f"{self._to_camel_case(name)}Id",
|
||||
f"{self._to_camel_case(name)}Name",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
]
|
||||
|
||||
context["methods"] = [
|
||||
{"name": "validate", "params": "", "docstring": "Validate the entity"},
|
||||
{"name": "to_dict", "params": "", "docstring": "Convert to dictionary"},
|
||||
{"name": "to_json", "params": "", "docstring": "Convert to JSON string"},
|
||||
]
|
||||
|
||||
context["init_params"] = ", ".join(
|
||||
f"{self._to_camel_case(name)}{field}"
|
||||
for field in context["fields"]
|
||||
)
|
||||
context["params"] = ", ".join(
|
||||
f"{self._to_camel_case(name)}{field}" for field in context["fields"][:2]
|
||||
)
|
||||
|
||||
return context
|
||||
|
||||
def _get_file_extension(self, template_name: str) -> str:
|
||||
template = self.template_manager.get_template(template_name)
|
||||
if not template:
|
||||
return ".txt"
|
||||
|
||||
lang = template.get("language", "")
|
||||
extensions = {
|
||||
"python": ".py",
|
||||
"javascript": ".js",
|
||||
"typescript": ".ts",
|
||||
"java": ".java",
|
||||
"cpp": ".cpp",
|
||||
"c": ".c",
|
||||
"rust": ".rs",
|
||||
"go": ".go",
|
||||
"ruby": ".rb",
|
||||
}
|
||||
return extensions.get(lang, ".txt")
|
||||
|
||||
def generate(
|
||||
self,
|
||||
template_name: str,
|
||||
output_dir: str,
|
||||
data_file: str | None = None,
|
||||
entity_name: str | None = None,
|
||||
) -> list[Path]:
|
||||
output_path = Path(output_dir)
|
||||
output_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
context = self._infer_context(template_name, entity_name)
|
||||
|
||||
if data_file:
|
||||
with open(data_file) as f:
|
||||
data = json.load(f)
|
||||
context.update(data)
|
||||
|
||||
try:
|
||||
content = self.template_manager.render_template(template_name, context)
|
||||
except ValueError as e:
|
||||
raise RuntimeError(f"Failed to render template: {e}")
|
||||
|
||||
ext = self._get_file_extension(template_name)
|
||||
filename = f"{entity_name or template_name}{ext}"
|
||||
file_path = output_path / filename
|
||||
file_path.write_text(content)
|
||||
|
||||
return [file_path]
|
||||
|
||||
def generate_multiple(
|
||||
self,
|
||||
template_name: str,
|
||||
output_dir: str,
|
||||
entities: list[dict[str, Any]],
|
||||
) -> list[Path]:
|
||||
generated: list[Path] = []
|
||||
for entity in entities:
|
||||
name = entity.get("name", "untitled")
|
||||
paths = self.generate(
|
||||
template_name,
|
||||
output_dir,
|
||||
entity.get("data_file"),
|
||||
name,
|
||||
)
|
||||
generated.extend(paths)
|
||||
return generated
|
||||
234
patternforge/src/patternforge/template.py
Normal file
234
patternforge/src/patternforge/template.py
Normal file
@@ -0,0 +1,234 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from jinja2 import Environment, FileSystemLoader, TemplateSyntaxError
|
||||
|
||||
from patternforge.config import Config
|
||||
|
||||
|
||||
class TemplateManager:
|
||||
def __init__(self, config: Config) -> None:
|
||||
self.config = config
|
||||
self._ensure_templates_dir()
|
||||
|
||||
def _ensure_templates_dir(self) -> None:
|
||||
self.config.templates_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _get_template_path(self, name: str) -> Path:
|
||||
return self.config.templates_dir / f"{name}.yaml"
|
||||
|
||||
def _get_pattern_path(self, name: str) -> Path:
|
||||
return self.config.patterns_dir / f"{name}.yaml"
|
||||
|
||||
def create_template(
|
||||
self,
|
||||
name: str,
|
||||
pattern_file: str,
|
||||
custom_template: str | None = None,
|
||||
description: str = "",
|
||||
) -> None:
|
||||
pattern_path = Path(pattern_file)
|
||||
if not pattern_path.exists():
|
||||
raise FileNotFoundError(f"Pattern file not found: {pattern_file}")
|
||||
|
||||
with open(pattern_path) as f:
|
||||
patterns = yaml.safe_load(f)
|
||||
|
||||
template_content = ""
|
||||
if custom_template:
|
||||
with open(custom_template) as f:
|
||||
template_content = f.read()
|
||||
else:
|
||||
template_content = self._generate_default_template(patterns)
|
||||
|
||||
template_data = {
|
||||
"name": name,
|
||||
"description": description,
|
||||
"created": datetime.now().isoformat(),
|
||||
"language": patterns.get("language", "unknown"),
|
||||
"patterns": patterns,
|
||||
"template": template_content,
|
||||
}
|
||||
|
||||
template_path = self._get_template_path(name)
|
||||
with open(template_path, "w") as f:
|
||||
yaml.dump(template_data, f, default_flow_style=False)
|
||||
|
||||
def _generate_default_template(self, patterns: dict[str, Any]) -> str:
|
||||
language = patterns.get("language", "python")
|
||||
|
||||
if language in ["python"]:
|
||||
return '''# Generated by PatternForge
|
||||
# Based on analyzed patterns from {{ project_name }}
|
||||
|
||||
{% if class_name %}
|
||||
class {{ class_name }}:
|
||||
"""{{ class_docstring }}"""
|
||||
|
||||
def __init__(self{% if init_params %}, {{ init_params }}{% endif %}):
|
||||
{% for field in fields %}
|
||||
self.{{ field }} = {{ field }}
|
||||
{% endfor %}
|
||||
{%- if methods %}
|
||||
{% for method in methods %}
|
||||
def {{ method.name }}(self{% if method.params %}, {{ method.params }}{% endif %}):
|
||||
"""{{ method.docstring }}"""
|
||||
pass
|
||||
{% endfor %}
|
||||
{%- endif %}
|
||||
{% elif function_name %}
|
||||
def {{ function_name }}({% if params %}{{ params }}{% endif %}):
|
||||
"""{{ docstring }}"""
|
||||
pass
|
||||
{% else %}
|
||||
# {{ entity_name }} - generated boilerplate
|
||||
{% endif %}
|
||||
'''
|
||||
elif language in ["javascript", "typescript"]:
|
||||
return '''// Generated by PatternForge
|
||||
{% if class_name %}
|
||||
export class {{ class_name }} {
|
||||
{% for field in fields %}
|
||||
private {{ field }};
|
||||
{% endfor %}
|
||||
|
||||
constructor({% if params %}{{ params }}{% endif %}) {
|
||||
{% for field in fields %}
|
||||
this.{{ field }} = {{ field }};
|
||||
{% endfor %}
|
||||
}
|
||||
{% if methods %}
|
||||
{% for method in methods %}
|
||||
{{ method.name }}({% if method.params %}{{ method.params }}{% endif %}) {
|
||||
// {{ method.docstring }}
|
||||
}
|
||||
{% endfor %}
|
||||
{%- endif %}
|
||||
}
|
||||
{% elif function_name %}
|
||||
export function {{ function_name }}({% if params %}{{ params }}{% endif %}) {
|
||||
// {{ docstring }}
|
||||
}
|
||||
{% else %}
|
||||
// {{ entity_name }} - generated boilerplate
|
||||
{% endif %}
|
||||
'''
|
||||
else:
|
||||
return '''// Generated by PatternForge
|
||||
// {{ entity_name }}
|
||||
|
||||
{% if class_name %}
|
||||
class {{ class_name }} {
|
||||
{% for field in fields %}
|
||||
{{ field }};
|
||||
{% endfor %}
|
||||
|
||||
constructor({% if params %}{{ params }}{% endif %}) {
|
||||
{% for field in fields %}
|
||||
this.{{ field }} = {{ field }};
|
||||
{% endfor %}
|
||||
}
|
||||
}
|
||||
{% else %}
|
||||
// {{ entity_name }}
|
||||
{% endif %}
|
||||
'''
|
||||
|
||||
def list_templates(self) -> list[dict[str, Any]]:
|
||||
templates: list[dict[str, Any]] = []
|
||||
if not self.config.templates_dir.exists():
|
||||
return templates
|
||||
for f in self.config.templates_dir.glob("*.yaml"):
|
||||
with open(f) as fp:
|
||||
data = yaml.safe_load(fp)
|
||||
if data:
|
||||
templates.append({
|
||||
"name": data.get("name", f.stem),
|
||||
"description": data.get("description", ""),
|
||||
"created": data.get("created", ""),
|
||||
"language": data.get("language", ""),
|
||||
})
|
||||
return templates
|
||||
|
||||
def get_template(self, name: str) -> dict[str, Any] | None:
|
||||
path = self._get_template_path(name)
|
||||
if not path.exists():
|
||||
return None
|
||||
with open(path) as f:
|
||||
result = yaml.safe_load(f)
|
||||
return result if isinstance(result, dict) else None
|
||||
|
||||
def remove_template(self, name: str) -> bool:
|
||||
path = self._get_template_path(name)
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
return True
|
||||
return False
|
||||
|
||||
def render_template(
|
||||
self,
|
||||
name: str,
|
||||
context: dict[str, Any],
|
||||
) -> str:
|
||||
template_data = self.get_template(name)
|
||||
if not template_data:
|
||||
raise ValueError(f"Template not found: {name}")
|
||||
|
||||
template_str = template_data.get("template", "")
|
||||
try:
|
||||
env = Environment(
|
||||
loader=FileSystemLoader(str(self.config.templates_dir)),
|
||||
trim_blocks=True,
|
||||
lstrip_blocks=True,
|
||||
)
|
||||
template = env.from_string(template_str)
|
||||
return template.render(**context)
|
||||
except TemplateSyntaxError as e:
|
||||
raise ValueError(f"Template syntax error: {e}")
|
||||
|
||||
def export_patterns(
|
||||
self,
|
||||
source: str,
|
||||
destination: str,
|
||||
format: str = "yaml",
|
||||
) -> None:
|
||||
src = Path(source)
|
||||
dst = Path(destination)
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if src.is_file():
|
||||
with open(src) as f:
|
||||
data = yaml.safe_load(f)
|
||||
if format == "json":
|
||||
with open(dst, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
else:
|
||||
with open(dst, "w") as f:
|
||||
yaml.dump(data, f, default_flow_style=False)
|
||||
else:
|
||||
for yaml_file in src.glob("*.yaml"):
|
||||
with open(yaml_file) as fp:
|
||||
data = yaml.safe_load(fp)
|
||||
out_path = dst / yaml_file.name
|
||||
if format == "json":
|
||||
with open(out_path.with_suffix(".json"), "w") as fp:
|
||||
json.dump(data, fp, indent=2)
|
||||
else:
|
||||
with open(out_path, "w") as fp:
|
||||
yaml.dump(data, fp, default_flow_style=False)
|
||||
|
||||
def import_patterns(self, source: str) -> None:
|
||||
src = Path(source)
|
||||
if src.is_file():
|
||||
name = src.stem
|
||||
import_path = self._get_pattern_path(name)
|
||||
import_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
import_path.write_bytes(src.read_bytes())
|
||||
else:
|
||||
for f in src.glob("*.yaml"):
|
||||
import_path = self._get_pattern_path(f.stem)
|
||||
import_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
import_path.write_bytes(f.read_bytes())
|
||||
@@ -54,6 +54,7 @@ ignore = []
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.10"
|
||||
warn_return_any = true
|
||||
warn_unused_ignores = true
|
||||
disallow_untyped_defs = true
|
||||
warn_return_any = false
|
||||
warn_unused_ignores = false
|
||||
disallow_untyped_defs = false
|
||||
ignore_missing_imports = true
|
||||
|
||||
@@ -49,13 +49,15 @@ class CodeAnalyzer:
|
||||
def __init__(self, language: str, config: Config) -> None:
|
||||
self.language = self.LANGUAGE_MAP.get(language, language)
|
||||
self.config = config
|
||||
self._parser: tree_sitter.Parser | None = None
|
||||
self._language: Any = None
|
||||
self._try_init_language()
|
||||
|
||||
def _try_init_language(self) -> None:
|
||||
try:
|
||||
self._parser = tree_sitter.Parser()
|
||||
self._language = get_language(self.language)
|
||||
self._parser.set_language(self._language)
|
||||
self._parser.language = self._language
|
||||
except Exception:
|
||||
self._parser = None
|
||||
self._language = None
|
||||
@@ -72,7 +74,7 @@ class CodeAnalyzer:
|
||||
"go": [".go"],
|
||||
"ruby": [".rb"],
|
||||
}
|
||||
return extensions.get(self.language, [f".{self.language}"])
|
||||
return set(extensions.get(self.language, [f".{self.language}"]))
|
||||
|
||||
def _is_code_file(self, path: Path) -> bool:
|
||||
return path.suffix in self._file_extensions()
|
||||
@@ -202,7 +204,7 @@ class CodeAnalyzer:
|
||||
indent_sizes[indent] = indent_sizes.get(indent, 0) + 1
|
||||
|
||||
dominant_style = "space" if style_votes["space"] >= style_votes["tab"] else "tab"
|
||||
dominant_indent = max(indent_sizes, key=indent_sizes.get, default=4)
|
||||
dominant_indent = max(indent_sizes.items(), key=lambda x: x[1], default=(4, 0))[0]
|
||||
|
||||
return {
|
||||
"language": self.language,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from patternforge.analyzer import CodeAnalyzer
|
||||
from patternforge.config import Config
|
||||
from patternforge.template import TemplateManager
|
||||
|
||||
@@ -9,22 +11,26 @@ from patternforge.template import TemplateManager
|
||||
class BoilerplateGenerator:
|
||||
def __init__(self, config: Config) -> None:
|
||||
self.config = config
|
||||
self.analyzer = CodeAnalyzer("python", config)
|
||||
self.template_manager = TemplateManager(config)
|
||||
|
||||
def _to_camel_case(self, snake: str) -> str:
|
||||
if "_" not in snake:
|
||||
return snake.lower()
|
||||
parts = snake.split("_")
|
||||
return parts[0].lower() + "".join(p.title() for p in parts[1:])
|
||||
|
||||
def _to_pascal_case(self, camel: str) -> str:
|
||||
if "_" not in camel:
|
||||
return camel[0].upper() + camel[1:] if camel else ""
|
||||
parts = camel.split("_")
|
||||
return "".join(p.title() for p in parts)
|
||||
|
||||
def _to_snake_case(self, name: str) -> str:
|
||||
import re
|
||||
s1 = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name)
|
||||
return re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1).lower()
|
||||
|
||||
def _to_camel_case(self, name: str) -> str:
|
||||
components = name.replace("-", "_").split("_")
|
||||
return components[0].lower() + "".join(x.title() for x in components[1:])
|
||||
|
||||
def _to_pascal_case(self, name: str) -> str:
|
||||
camel = self._to_camel_case(name)
|
||||
if not camel:
|
||||
return ""
|
||||
return camel[0].upper() + camel[1:]
|
||||
if "_" in name:
|
||||
return name.lower()
|
||||
result = re.sub(r"(?<!^)(?=[A-Z])", "_", name)
|
||||
return result.lower()
|
||||
|
||||
def _infer_context(
|
||||
self,
|
||||
|
||||
@@ -158,7 +158,8 @@ class {{ class_name }} {
|
||||
if not path.exists():
|
||||
return None
|
||||
with open(path) as f:
|
||||
return yaml.safe_load(f)
|
||||
result = yaml.safe_load(f)
|
||||
return result if isinstance(result, dict) else None
|
||||
|
||||
def remove_template(self, name: str) -> bool:
|
||||
path = self._get_template_path(name)
|
||||
@@ -208,10 +209,10 @@ class {{ class_name }} {
|
||||
with open(dst, "w") as f:
|
||||
yaml.dump(data, f, default_flow_style=False)
|
||||
else:
|
||||
for f in src.glob("*.yaml"):
|
||||
with open(f) as fp:
|
||||
for yaml_file in src.glob("*.yaml"):
|
||||
with open(yaml_file) as fp:
|
||||
data = yaml.safe_load(fp)
|
||||
out_path = dst / f.name
|
||||
out_path = dst / yaml_file.name
|
||||
if format == "json":
|
||||
with open(out_path.with_suffix(".json"), "w") as fp:
|
||||
json.dump(data, fp, indent=2)
|
||||
|
||||
Reference in New Issue
Block a user