diff --git a/context.yaml b/context.yaml index d8a6fcae..71e09b62 100644 --- a/context.yaml +++ b/context.yaml @@ -24,6 +24,15 @@ documents: - '*Interface.php' showTreeView: true + - description: Project Templates + outputPath: core/templates.md + sources: + - type: file + sourcePaths: + - src/Template + - vendor/spiral/files/src/FilesInterface.php + showTreeView: true + - description: "Changes in the Project" outputPath: "changes.md" sources: diff --git a/docs/template-system.md b/docs/template-system.md new file mode 100644 index 00000000..f8d3c026 --- /dev/null +++ b/docs/template-system.md @@ -0,0 +1,80 @@ +# Template System Usage + +The CTX template system provides intelligent project analysis and template-based configuration generation. + +## Commands + +### List Templates +```bash +# Show all available templates +ctx template:list + +# Show detailed template information +ctx template:list --detailed + +# Filter by tags +ctx template:list --tag php --tag laravel + +# Combine options +ctx template:list --detailed --tag php +``` + +### Initialize with Template +```bash +# Auto-detect project type and use appropriate template +ctx init + +# Use specific template +ctx init laravel +ctx init generic-php + +# Specify custom config filename +ctx init laravel --config-file=custom-context.yaml +``` + +## Available Templates + +### Laravel Template (`laravel`) +- **Priority**: 100 (high) +- **Tags**: php, laravel, web, framework +- **Detection**: Looks for `composer.json`, `artisan` file, and Laravel directories +- **Generated Documents**: + - `docs/laravel-overview.md` - Application overview + - `docs/laravel-structure.md` - Directory structure with tree view + +### Generic PHP Template (`generic-php`) +- **Priority**: 10 (low) +- **Tags**: php, generic +- **Detection**: Looks for `composer.json` and `src` directory +- **Generated Documents**: + - `docs/php-overview.md` - Project overview + - `docs/php-structure.md` - Directory structure with tree view + +## Analysis Process + +When you run `ctx init` without specifying a template: + +1. **Project Analysis**: Scans the current directory for project indicators +2. **Template Matching**: Matches detected patterns to available templates +3. **Confidence Scoring**: Calculates confidence levels for matches +4. **Auto-Selection**: Uses high-confidence matches automatically +5. **Manual Selection**: Shows options for lower-confidence matches + +### Analysis Output Example +``` +$ ctx init +Analyzing project... +✅ Detected: laravel (confidence: 95%) +Using template: Laravel PHP Framework project template +✅ Config context.yaml created +``` + +## Extending the System + +The template system is designed to be extensible: + +- **Custom Analyzers**: Implement `ProjectAnalyzerInterface` +- **Custom Templates**: Implement `TemplateProviderInterface` +- **Custom Template Sources**: File-based, remote, or database templates + +Templates automatically integrate with the existing CTX configuration system, supporting all source types, modifiers, and features. diff --git a/src/Application/Bootloader/CoreBootloader.php b/src/Application/Bootloader/CoreBootloader.php index 27fab0f0..d3ced50e 100644 --- a/src/Application/Bootloader/CoreBootloader.php +++ b/src/Application/Bootloader/CoreBootloader.php @@ -5,7 +5,6 @@ namespace Butschster\ContextGenerator\Application\Bootloader; use Butschster\ContextGenerator\Console\GenerateCommand; -use Butschster\ContextGenerator\Console\InitCommand; use Butschster\ContextGenerator\Console\SchemaCommand; use Butschster\ContextGenerator\Console\SelfUpdateCommand; use Butschster\ContextGenerator\Console\VersionCommand; @@ -58,7 +57,6 @@ public function boot(ConsoleBootloader $console): void { $console->addCommand( VersionCommand::class, - InitCommand::class, SchemaCommand::class, SelfUpdateCommand::class, GenerateCommand::class, diff --git a/src/Application/Kernel.php b/src/Application/Kernel.php index 77980ad6..96b28294 100644 --- a/src/Application/Kernel.php +++ b/src/Application/Kernel.php @@ -21,6 +21,7 @@ use Butschster\ContextGenerator\Application\Bootloader\SourceFetcherBootloader; use Butschster\ContextGenerator\Application\Bootloader\VariableBootloader; use Butschster\ContextGenerator\McpServer\McpServerBootloader; +use Butschster\ContextGenerator\Template\TemplateSystemBootloader; use Butschster\ContextGenerator\Modifier\PhpContentFilter\PhpContentFilterBootloader; use Butschster\ContextGenerator\Modifier\PhpDocs\PhpDocsModifierBootloader; use Butschster\ContextGenerator\Modifier\PhpSignature\PhpSignatureModifierBootloader; @@ -70,6 +71,9 @@ protected function defineBootloaders(): array SourceRegistryBootloader::class, SchemaMapperBootloader::class, + // Template System + TemplateSystemBootloader::class, + // Sources TextSourceBootloader::class, FileSourceBootloader::class, diff --git a/src/Console/InitCommand.php b/src/Console/InitCommand.php deleted file mode 100644 index 73942651..00000000 --- a/src/Console/InitCommand.php +++ /dev/null @@ -1,105 +0,0 @@ -configFilename; - $ext = \pathinfo($filename, PATHINFO_EXTENSION); - - try { - $type = ConfigType::fromExtension($ext); - } catch (\ValueError) { - $this->output->error(\sprintf('Unsupported config type: %s', $ext)); - - return Command::FAILURE; - } - - $filename = \pathinfo(\strtolower($filename), PATHINFO_FILENAME) . '.' . $type->value; - $filePath = (string) $dirs->getRootPath()->join($filename); - - if ($files->exists($filePath)) { - $this->output->error(\sprintf('Config %s already exists', $filePath)); - - return Command::FAILURE; - } - - $config = new ConfigRegistry( - schema: JsonSchema::SCHEMA_URL, - ); - - $config->register(new DocumentRegistry([ - new Document( - description: 'Project structure overview', - outputPath: 'project-structure.md', - firstSource: new TreeSource( - sourcePaths: ['src'], - treeView: new TreeViewConfig( - showCharCount: true, - ), - ), - ), - ])); - - try { - $content = match ($type) { - ConfigType::Json => \json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), - ConfigType::Yaml => Yaml::dump( - \json_decode(\json_encode($config), true), - 10, - 2, - Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK, - ), - default => throw new \InvalidArgumentException( - \sprintf('Unsupported config type: %s', $type->value), - ), - }; - } catch (\Throwable $e) { - $this->output->error(\sprintf('Failed to create config: %s', $e->getMessage())); - - return Command::FAILURE; - } - - if ($files->exists($filePath)) { - $this->output->error(\sprintf('Config %s already exists', $filePath)); - - return Command::FAILURE; - } - - $files->ensureDirectory(\dirname($filePath)); - $files->write($filePath, $content); - - $this->output->success(\sprintf('Config %s created', $filePath)); - - return Command::SUCCESS; - } -} diff --git a/src/Console/context.yaml b/src/Console/context.yaml index 841a4444..932538de 100644 --- a/src/Console/context.yaml +++ b/src/Console/context.yaml @@ -15,3 +15,13 @@ documents: filePattern: '*.php' showTreeView: true + - description: Init command + outputPath: console/init.md + sources: + - type: file + sourcePaths: + - ./InitCommand.php + - ../DirectoriesInterface.php + - ../Application/FSPath.php + - ../Application/Bootloader/ConsoleBootloader.php + diff --git a/src/Template/Analysis/AnalysisResult.php b/src/Template/Analysis/AnalysisResult.php new file mode 100644 index 00000000..c1924923 --- /dev/null +++ b/src/Template/Analysis/AnalysisResult.php @@ -0,0 +1,42 @@ + $suggestedTemplates List of template names that match this analysis + * @param array $metadata Additional metadata discovered during analysis + */ + public function __construct( + public string $analyzerName, + public string $detectedType, + public float $confidence, + public array $suggestedTemplates = [], + public array $metadata = [], + ) {} + + /** + * Check if this result has high confidence (>= 0.8) + */ + public function hasHighConfidence(): bool + { + return $this->confidence >= 0.8; + } + + /** + * Get the primary suggested template (first in the list) + */ + public function getPrimaryTemplate(): ?string + { + return $this->suggestedTemplates[0] ?? null; + } +} diff --git a/src/Template/Analysis/Analyzer/AbstractFrameworkAnalyzer.php b/src/Template/Analysis/Analyzer/AbstractFrameworkAnalyzer.php new file mode 100644 index 00000000..564bf1b4 --- /dev/null +++ b/src/Template/Analysis/Analyzer/AbstractFrameworkAnalyzer.php @@ -0,0 +1,249 @@ +nextAnalyzer = $analyzer; + } + + /** + * Get the next analyzer in the chain + */ + public function getNext(): ?ProjectAnalyzerInterface + { + return $this->nextAnalyzer; + } + + public function analyze(FSPath $projectRoot): ?AnalysisResult + { + if (!$this->canAnalyze($projectRoot)) { + return null; + } + + $composer = $this->composerReader->readComposerFile($projectRoot); + + if ($composer === null || !$this->hasFrameworkPackages($composer)) { + return null; + } + + $confidence = $this->calculateConfidence($projectRoot, $composer); + $metadata = $this->buildMetadata($projectRoot, $composer); + + return new AnalysisResult( + analyzerName: $this->getName(), + detectedType: $this->getFrameworkType(), + confidence: \min($confidence, 1.0), + suggestedTemplates: [$this->getFrameworkType()], + metadata: $metadata, + ); + } + + public function canAnalyze(FSPath $projectRoot): bool + { + // Must have composer.json to be a PHP framework + if (!$projectRoot->join('composer.json')->exists()) { + return false; + } + + $composer = $this->composerReader->readComposerFile($projectRoot); + return $composer !== null && $this->hasFrameworkPackages($composer); + } + + /** + * Get framework-specific packages to look for + * + * @return array + */ + abstract protected function getFrameworkPackages(): array; + + /** + * Get framework-specific directories that indicate this framework + * + * @return array + */ + abstract protected function getFrameworkDirectories(): array; + + /** + * Get framework-specific files that indicate this framework + * + * @return array + */ + abstract protected function getFrameworkFiles(): array; + + /** + * Get the base confidence score for having framework packages + */ + protected function getBaseConfidence(): float + { + return 0.6; + } + + /** + * Get the weight for directory structure matching + */ + protected function getDirectoryWeight(): float + { + return 0.2; + } + + /** + * Get the weight for file matching + */ + protected function getFileWeight(): float + { + return 0.2; + } + + /** + * Get the framework type identifier (usually same as getName()) + */ + protected function getFrameworkType(): string + { + return $this->getName(); + } + + /** + * Check if composer.json contains framework-specific packages + */ + protected function hasFrameworkPackages(array $composer): bool + { + foreach ($this->getFrameworkPackages() as $package) { + if ($this->composerReader->hasPackage($composer, $package)) { + return true; + } + } + + return false; + } + + /** + * Calculate confidence score based on framework indicators + */ + protected function calculateConfidence(FSPath $projectRoot, array $composer): float + { + $confidence = $this->getBaseConfidence(); + + // Check for framework-specific files + $fileScore = $this->checkFrameworkFiles($projectRoot); + $confidence += $fileScore * $this->getFileWeight(); + + // Check for framework-specific directories + $existingDirs = $this->structureDetector->detectExistingDirectories($projectRoot); + $directoryScore = $this->structureDetector->getPatternMatchConfidence( + $existingDirs, + $this->getFrameworkDirectories(), + ); + $confidence += $directoryScore * $this->getDirectoryWeight(); + + // Allow subclasses to add custom confidence calculations + $confidence += $this->getAdditionalConfidence($projectRoot, $composer, $existingDirs); + + return $confidence; + } + + /** + * Check for framework-specific files and return confidence score + */ + protected function checkFrameworkFiles(FSPath $projectRoot): float + { + $frameworkFiles = $this->getFrameworkFiles(); + + if (empty($frameworkFiles)) { + return 0.0; + } + + $found = 0; + foreach ($frameworkFiles as $file) { + if ($projectRoot->join($file)->exists()) { + $found++; + } + } + + return $found / \count($frameworkFiles); + } + + /** + * Allow subclasses to add framework-specific confidence calculations + */ + protected function getAdditionalConfidence( + FSPath $projectRoot, + array $composer, + array $existingDirectories, + ): float { + return 0.0; + } + + /** + * Build metadata for the analysis result + */ + protected function buildMetadata(FSPath $projectRoot, array $composer): array + { + $existingDirs = $this->structureDetector->detectExistingDirectories($projectRoot); + + return [ + 'composer' => $composer, + 'frameworkPackages' => $this->getDetectedPackages($composer), + 'existingDirectories' => $existingDirs, + 'frameworkDirectoriesFound' => \array_intersect($existingDirs, $this->getFrameworkDirectories()), + 'frameworkFilesFound' => $this->getDetectedFiles($projectRoot), + 'directoryScore' => $this->structureDetector->getPatternMatchConfidence( + $existingDirs, + $this->getFrameworkDirectories(), + ), + 'fileScore' => $this->checkFrameworkFiles($projectRoot), + ]; + } + + /** + * Get detected framework packages from composer.json + */ + protected function getDetectedPackages(array $composer): array + { + $detected = []; + foreach ($this->getFrameworkPackages() as $package) { + if ($this->composerReader->hasPackage($composer, $package)) { + $detected[$package] = $this->composerReader->getPackageVersion($composer, $package); + } + } + return $detected; + } + + /** + * Get detected framework files + */ + protected function getDetectedFiles(FSPath $projectRoot): array + { + $detected = []; + foreach ($this->getFrameworkFiles() as $file) { + if ($projectRoot->join($file)->exists()) { + $detected[] = $file; + } + } + return $detected; + } +} diff --git a/src/Template/Analysis/Analyzer/ComposerAnalyzer.php b/src/Template/Analysis/Analyzer/ComposerAnalyzer.php new file mode 100644 index 00000000..b7eb3390 --- /dev/null +++ b/src/Template/Analysis/Analyzer/ComposerAnalyzer.php @@ -0,0 +1,78 @@ +canAnalyze($projectRoot)) { + return null; + } + + $composer = $this->composerReader->readComposerFile($projectRoot); + + if ($composer === null) { + return null; + } + + $existingDirs = $this->structureDetector->detectExistingDirectories($projectRoot); + $structureConfidence = $this->structureDetector->calculateStructureConfidence($existingDirs); + + // Base confidence for having composer.json + $confidence = 0.4; + + // Boost confidence based on directory structure + $confidence += $structureConfidence * 0.4; + + // Boost confidence if it has a proper package name + if (isset($composer['name']) && \is_string($composer['name']) && \str_contains($composer['name'], '/')) { + $confidence += 0.2; + } + + return new AnalysisResult( + analyzerName: $this->getName(), + detectedType: 'generic-php', + confidence: \min($confidence, 1.0), + suggestedTemplates: ['generic-php'], + metadata: [ + 'composer' => $composer, + 'existingDirectories' => $existingDirs, + 'packageName' => $composer['name'] ?? null, + 'packages' => $this->composerReader->getAllPackages($composer), + ], + ); + } + + public function canAnalyze(FSPath $projectRoot): bool + { + return $projectRoot->join('composer.json')->exists(); + } + + public function getPriority(): int + { + return 50; // Medium priority - let specific framework analyzers go first + } + + public function getName(): string + { + return 'composer'; + } +} diff --git a/src/Template/Analysis/Analyzer/FallbackAnalyzer.php b/src/Template/Analysis/Analyzer/FallbackAnalyzer.php new file mode 100644 index 00000000..951f6ff6 --- /dev/null +++ b/src/Template/Analysis/Analyzer/FallbackAnalyzer.php @@ -0,0 +1,78 @@ +structureDetector->detectExistingDirectories($projectRoot); + $confidence = $this->structureDetector->calculateStructureConfidence($existingDirs); + + // Determine the best generic template based on what we found + $suggestedTemplate = $this->determineBestTemplate($existingDirs); + + return new AnalysisResult( + analyzerName: $this->getName(), + detectedType: 'generic', + confidence: $confidence, + suggestedTemplates: [$suggestedTemplate], + metadata: [ + 'existingDirectories' => $existingDirs, + 'isFallback' => true, + 'directoryCount' => \count($existingDirs), + ], + ); + } + + public function canAnalyze(FSPath $projectRoot): bool + { + // This analyzer can always analyze any project as a fallback + return true; + } + + public function getPriority(): int + { + return 1; // Lowest priority - only used when no other analyzers match + } + + public function getName(): string + { + return 'fallback'; + } + + /** + * Determine the best generic template based on existing directories + */ + private function determineBestTemplate(array $existingDirs): string + { + // If we have src or app directories, assume it's a PHP project + if (\in_array('src', $existingDirs, true) || \in_array('app', $existingDirs, true)) { + return 'generic-php'; + } + + // If we have lib directory, might be a library project + if (\in_array('lib', $existingDirs, true)) { + return 'generic-php'; + } + + // Default fallback + return 'generic-php'; + } +} diff --git a/src/Template/Analysis/Analyzer/GoAnalyzer.php b/src/Template/Analysis/Analyzer/GoAnalyzer.php new file mode 100644 index 00000000..02459737 --- /dev/null +++ b/src/Template/Analysis/Analyzer/GoAnalyzer.php @@ -0,0 +1,394 @@ + ['github.com/gin-gonic/gin'], + 'echo' => ['github.com/labstack/echo'], + 'fiber' => ['github.com/gofiber/fiber'], + 'chi' => ['github.com/go-chi/chi'], + 'mux' => ['github.com/gorilla/mux'], + 'beego' => ['github.com/beego/beego'], + 'iris' => ['github.com/kataras/iris'], + 'buffalo' => ['github.com/gobuffalo/buffalo'], + 'revel' => ['github.com/revel/revel'], + 'fasthttp' => ['github.com/valyala/fasthttp'], + 'grpc' => ['google.golang.org/grpc'], + 'cobra' => ['github.com/spf13/cobra'], // CLI framework + ]; + + /** + * Go project indicator files + */ + private const array GO_FILES = [ + 'go.mod', + 'go.sum', + 'go.work', + 'main.go', + 'cmd', + 'Makefile', + 'Dockerfile', + '.gitignore', + ]; + + /** + * Go project directories + */ + private const array GO_DIRECTORIES = [ + 'cmd', + 'pkg', + 'internal', + 'api', + 'web', + 'configs', + 'scripts', + 'build', + 'deployments', + 'test', + 'tests', + 'docs', + 'tools', + 'vendor', + 'bin', + 'assets', + 'static', + 'templates', + ]; + + public function __construct( + private FilesInterface $files, + private ProjectStructureDetector $structureDetector, + ) {} + + public function analyze(FSPath $projectRoot): ?AnalysisResult + { + if (!$this->canAnalyze($projectRoot)) { + return null; + } + + $goModData = $this->parseGoMod($projectRoot); + $dependencies = $goModData['dependencies'] ?? []; + $detectedFramework = $this->detectFramework($dependencies); + $existingDirs = $this->structureDetector->detectExistingDirectories($projectRoot); + $projectType = $this->determineProjectType($projectRoot, $dependencies); + + $detectedType = $detectedFramework ?? $projectType; + $confidence = $this->calculateConfidence($projectRoot, $goModData, $detectedFramework, $existingDirs); + + $suggestedTemplates = $detectedFramework ? [$detectedFramework] : ['go']; + + return new AnalysisResult( + analyzerName: $this->getName(), + detectedType: $detectedType, + confidence: $confidence, + suggestedTemplates: $suggestedTemplates, + metadata: [ + 'goMod' => $goModData, + 'dependencies' => $dependencies, + 'detectedFramework' => $detectedFramework, + 'projectType' => $projectType, + 'existingDirectories' => $existingDirs, + 'goFiles' => $this->getDetectedFiles($projectRoot), + 'goVersion' => $goModData['goVersion'] ?? null, + 'moduleName' => $goModData['module'] ?? null, + ], + ); + } + + public function canAnalyze(FSPath $projectRoot): bool + { + // Primary indicator: go.mod file + if ($projectRoot->join('go.mod')->exists()) { + return true; + } + + // Secondary indicators: Go files in expected locations + if ($projectRoot->join('main.go')->exists()) { + return true; + } + + // Check cmd directory for Go files + $cmdDir = $projectRoot->join('cmd'); + if ($cmdDir->exists() && $this->hasGoFiles($cmdDir)) { + return true; + } + + return false; + } + + public function getPriority(): int + { + return 80; // High priority for Go framework detection + } + + public function getName(): string + { + return 'go'; + } + + /** + * Parse go.mod file for module info and dependencies + */ + private function parseGoMod(FSPath $projectRoot): array + { + $goModFile = $projectRoot->join('go.mod'); + + if (!$goModFile->exists()) { + return []; + } + + $content = $this->files->read($goModFile->toString()); + if ($content === '') { + return []; + } + + $data = [ + 'module' => null, + 'goVersion' => null, + 'dependencies' => [], + ]; + + $lines = \explode("\n", $content); + $inRequireBlock = false; + + foreach ($lines as $line) { + $line = \trim($line); + + // Skip empty lines and comments + if ($line === '' || \str_starts_with($line, '//')) { + continue; + } + + // Parse module name + if (\preg_match('/^module\s+(.+)$/', $line, $matches)) { + $data['module'] = \trim($matches[1]); + continue; + } + + // Parse go version + if (\preg_match('/^go\s+(.+)$/', $line, $matches)) { + $data['goVersion'] = \trim($matches[1]); + continue; + } + + // Handle require block + if (\str_starts_with($line, 'require (')) { + $inRequireBlock = true; + continue; + } + + if ($inRequireBlock && $line === ')') { + $inRequireBlock = false; + continue; + } + + // Parse single require line + if (\str_starts_with($line, 'require ') && !\str_contains($line, '(')) { + if (\preg_match('/^require\s+([^\s]+)/', $line, $matches)) { + $data['dependencies'][] = $matches[1]; + } + continue; + } + + // Parse require block content + if ($inRequireBlock) { + if (\preg_match('/^\s*([^\s]+)/', $line, $matches)) { + $data['dependencies'][] = $matches[1]; + } + } + } + + return $data; + } + + /** + * Detect Go framework from dependencies + */ + private function detectFramework(array $dependencies): ?string + { + foreach (self::FRAMEWORK_PATTERNS as $framework => $patterns) { + foreach ($patterns as $pattern) { + foreach ($dependencies as $dependency) { + if (\str_starts_with((string) $dependency, $pattern)) { + return $framework; + } + } + } + } + + return null; + } + + /** + * Determine project type based on structure and dependencies + */ + private function determineProjectType(FSPath $projectRoot, array $dependencies): string + { + // Check for CLI patterns + if ($projectRoot->join('cmd')->exists() || $this->hasCliDependencies($dependencies)) { + return 'go-cli'; + } + + // Check for web service patterns + if ($this->hasWebDependencies($dependencies)) { + return 'go-web'; + } + + // Check for gRPC patterns + if ($this->hasGrpcDependencies($dependencies)) { + return 'go-grpc'; + } + + // Default Go project + return 'go'; + } + + /** + * Calculate confidence score for Go project detection + */ + private function calculateConfidence( + FSPath $projectRoot, + array $goModData, + ?string $detectedFramework, + array $existingDirs, + ): float { + $confidence = 0.6; // Base confidence for having Go indicators + + // High confidence boost for go.mod file + if ($projectRoot->join('go.mod')->exists()) { + $confidence += 0.2; + } + + // Boost confidence if we detected a framework + if ($detectedFramework !== null) { + $confidence += 0.15; + } + + // Boost confidence for having dependencies + if (!empty($goModData['dependencies'] ?? [])) { + $confidence += 0.1; + } + + // Boost confidence based on directory structure + $goDirScore = $this->structureDetector->getPatternMatchConfidence( + $existingDirs, + self::GO_DIRECTORIES, + ); + $confidence += $goDirScore * 0.05; + + return \min($confidence, 1.0); + } + + /** + * Get detected Go files + */ + private function getDetectedFiles(FSPath $projectRoot): array + { + $detected = []; + foreach (self::GO_FILES as $file) { + if ($projectRoot->join($file)->exists()) { + $detected[] = $file; + } + } + return $detected; + } + + /** + * Check if directory contains Go files + */ + private function hasGoFiles(FSPath $directory): bool + { + if (!$this->files->isDirectory($directory->toString())) { + return false; + } + + $files = $this->files->getFiles($directory->toString(), '*.go'); + return !empty($files); + } + + /** + * Check for CLI framework dependencies + */ + private function hasCliDependencies(array $dependencies): bool + { + $cliPatterns = [ + 'github.com/spf13/cobra', + 'github.com/urfave/cli', + 'github.com/alecthomas/kingpin', + 'github.com/jessevdk/go-flags', + ]; + + foreach ($dependencies as $dependency) { + foreach ($cliPatterns as $pattern) { + if (\str_starts_with((string) $dependency, $pattern)) { + return true; + } + } + } + + return false; + } + + /** + * Check for web framework dependencies + */ + private function hasWebDependencies(array $dependencies): bool + { + $webPatterns = [ + 'github.com/gin-gonic/gin', + 'github.com/labstack/echo', + 'github.com/gofiber/fiber', + 'github.com/go-chi/chi', + 'github.com/gorilla/mux', + 'net/http', // Standard library + ]; + + foreach ($dependencies as $dependency) { + foreach ($webPatterns as $pattern) { + if (\str_starts_with((string) $dependency, $pattern)) { + return true; + } + } + } + + return false; + } + + /** + * Check for gRPC dependencies + */ + private function hasGrpcDependencies(array $dependencies): bool + { + $grpcPatterns = [ + 'google.golang.org/grpc', + 'google.golang.org/protobuf', + 'github.com/grpc-ecosystem', + ]; + + foreach ($dependencies as $dependency) { + foreach ($grpcPatterns as $pattern) { + if (\str_starts_with((string) $dependency, $pattern)) { + return true; + } + } + } + + return false; + } +} diff --git a/src/Template/Analysis/Analyzer/PackageJsonAnalyzer.php b/src/Template/Analysis/Analyzer/PackageJsonAnalyzer.php new file mode 100644 index 00000000..4e32fd5f --- /dev/null +++ b/src/Template/Analysis/Analyzer/PackageJsonAnalyzer.php @@ -0,0 +1,222 @@ + ['react', 'react-dom'], + 'vue' => ['vue'], + 'next' => ['next'], + 'nuxt' => ['nuxt', '@nuxt/kit'], + 'express' => ['express'], + 'angular' => ['@angular/core'], + 'svelte' => ['svelte'], + 'gatsby' => ['gatsby'], + ]; + + public function __construct( + private FilesInterface $files, + private ProjectStructureDetector $structureDetector, + ) {} + + public function analyze(FSPath $projectRoot): ?AnalysisResult + { + if (!$this->canAnalyze($projectRoot)) { + return null; + } + + $packageJson = $this->readPackageJson($projectRoot); + + if ($packageJson === null) { + return null; + } + + $detectedFramework = $this->detectFramework($packageJson); + $existingDirs = $this->structureDetector->detectExistingDirectories($projectRoot); + + if ($detectedFramework === null) { + // Generic Node.js project + return new AnalysisResult( + analyzerName: $this->getName(), + detectedType: 'node', + confidence: 0.6, + suggestedTemplates: ['node'], + metadata: [ + 'packageJson' => $packageJson, + 'existingDirectories' => $existingDirs, + 'packageName' => $packageJson['name'] ?? null, + 'scripts' => $packageJson['scripts'] ?? [], + ], + ); + } + + $confidence = $this->calculateFrameworkConfidence($packageJson, $detectedFramework); + + return new AnalysisResult( + analyzerName: $this->getName(), + detectedType: $detectedFramework, + confidence: $confidence, + suggestedTemplates: [$detectedFramework], + metadata: [ + 'packageJson' => $packageJson, + 'detectedFramework' => $detectedFramework, + 'existingDirectories' => $existingDirs, + 'packageName' => $packageJson['name'] ?? null, + 'scripts' => $packageJson['scripts'] ?? [], + 'dependencies' => $this->getAllDependencies($packageJson), + ], + ); + } + + public function canAnalyze(FSPath $projectRoot): bool + { + return $projectRoot->join('package.json')->exists(); + } + + public function getPriority(): int + { + return 80; // High priority for JavaScript framework detection + } + + public function getName(): string + { + return 'package-json'; + } + + /** + * Read and parse package.json file + */ + private function readPackageJson(FSPath $projectRoot): ?array + { + $packagePath = $projectRoot->join('package.json'); + + if (!$packagePath->exists()) { + return null; + } + + $content = $this->files->read($packagePath->toString()); + + if ($content === '') { + return null; + } + + $decoded = \json_decode($content, true); + + if (!\is_array($decoded)) { + return null; + } + + return $decoded; + } + + /** + * Detect the framework based on package.json dependencies + */ + private function detectFramework(array $packageJson): ?string + { + $allDependencies = $this->getAllDependencies($packageJson); + + // Check for framework-specific packages + foreach (self::FRAMEWORK_PATTERNS as $framework => $patterns) { + foreach ($patterns as $pattern) { + if (\array_key_exists($pattern, $allDependencies)) { + return $framework; + } + } + } + + return null; + } + + /** + * Calculate confidence score for detected framework + */ + private function calculateFrameworkConfidence(array $packageJson, string $framework): float + { + $confidence = 0.7; // Base confidence for framework detection + + // Boost confidence if multiple framework packages are present + $frameworkPatterns = self::FRAMEWORK_PATTERNS[$framework] ?? []; + $allDependencies = $this->getAllDependencies($packageJson); + + $matchCount = 0; + foreach ($frameworkPatterns as $pattern) { + if (\array_key_exists($pattern, $allDependencies)) { + $matchCount++; + } + } + + if ($matchCount > 1) { + $confidence += 0.2; + } + + // Boost confidence if there are relevant scripts + $scripts = $packageJson['scripts'] ?? []; + if ($this->hasRelevantScripts($scripts, $framework)) { + $confidence += 0.1; + } + + return \min($confidence, 1.0); + } + + /** + * Check if scripts are relevant to the detected framework + */ + private function hasRelevantScripts(array $scripts, string $framework): bool + { + $relevantScripts = match ($framework) { + 'react' => ['start', 'build', 'test'], + 'vue' => ['serve', 'build', 'test'], + 'next' => ['dev', 'build', 'start'], + 'nuxt' => ['dev', 'build', 'generate'], + 'express' => ['start', 'dev'], + 'angular' => ['ng', 'start', 'build'], + default => ['start', 'build'], + }; + + foreach ($relevantScripts as $script) { + if (isset($scripts[$script])) { + return true; + } + } + + return false; + } + + /** + * Get all dependencies from package.json + */ + private function getAllDependencies(array $packageJson): array + { + $dependencies = []; + + if (isset($packageJson['dependencies'])) { + $dependencies = \array_merge($dependencies, $packageJson['dependencies']); + } + + if (isset($packageJson['devDependencies'])) { + $dependencies = \array_merge($dependencies, $packageJson['devDependencies']); + } + + if (isset($packageJson['peerDependencies'])) { + $dependencies = \array_merge($dependencies, $packageJson['peerDependencies']); + } + + return $dependencies; + } +} diff --git a/src/Template/Analysis/Analyzer/PythonAnalyzer.php b/src/Template/Analysis/Analyzer/PythonAnalyzer.php new file mode 100644 index 00000000..7f9edbbc --- /dev/null +++ b/src/Template/Analysis/Analyzer/PythonAnalyzer.php @@ -0,0 +1,386 @@ + ['Django', 'django'], + 'flask' => ['Flask', 'flask'], + 'fastapi' => ['fastapi', 'FastAPI'], + 'pyramid' => ['pyramid'], + 'tornado' => ['tornado'], + 'bottle' => ['bottle'], + 'cherrypy' => ['CherryPy', 'cherrypy'], + 'falcon' => ['falcon'], + 'sanic' => ['sanic'], + 'quart' => ['quart'], + 'starlette' => ['starlette'], + ]; + + /** + * Python project indicator files + */ + private const array PYTHON_FILES = [ + 'requirements.txt', + 'pyproject.toml', + 'setup.py', + 'setup.cfg', + 'Pipfile', + 'poetry.lock', + 'conda.yml', + 'environment.yml', + 'manage.py', // Django + 'app.py', // Common Flask pattern + 'main.py', // Common FastAPI pattern + 'wsgi.py', + 'asgi.py', + ]; + + /** + * Python project directories + */ + private const array PYTHON_DIRECTORIES = [ + 'src', + 'lib', + 'app', + 'apps', + 'project', + 'tests', + 'test', + 'static', + 'templates', + 'migrations', + 'venv', + 'env', + '.venv', + '__pycache__', + ]; + + public function __construct( + private FilesInterface $files, + private ProjectStructureDetector $structureDetector, + ) {} + + public function analyze(FSPath $projectRoot): ?AnalysisResult + { + if (!$this->canAnalyze($projectRoot)) { + return null; + } + + $dependencies = $this->extractDependencies($projectRoot); + $detectedFramework = $this->detectFramework($dependencies); + $existingDirs = $this->structureDetector->detectExistingDirectories($projectRoot); + + $detectedType = $detectedFramework ?? 'python'; + $confidence = $this->calculateConfidence($projectRoot, $dependencies, $detectedFramework, $existingDirs); + + $suggestedTemplates = $detectedFramework ? [$detectedFramework] : ['python']; + + return new AnalysisResult( + analyzerName: $this->getName(), + detectedType: $detectedType, + confidence: $confidence, + suggestedTemplates: $suggestedTemplates, + metadata: [ + 'dependencies' => $dependencies, + 'detectedFramework' => $detectedFramework, + 'existingDirectories' => $existingDirs, + 'pythonFiles' => $this->getDetectedFiles($projectRoot), + 'packageManagers' => $this->detectPackageManagers($projectRoot), + ], + ); + } + + public function canAnalyze(FSPath $projectRoot): bool + { + // Check for any Python indicator files + foreach (self::PYTHON_FILES as $file) { + if ($projectRoot->join($file)->exists()) { + return true; + } + } + + // Check for .py files in common directories + foreach (['src', 'app', '.'] as $dir) { + $dirPath = $projectRoot->join($dir); + if ($dirPath->exists() && $this->hasPythonFiles($dirPath)) { + return true; + } + } + + return false; + } + + public function getPriority(): int + { + return 75; // High priority for Python framework detection + } + + public function getName(): string + { + return 'python'; + } + + /** + * Extract dependencies from various Python dependency files + */ + private function extractDependencies(FSPath $projectRoot): array + { + $dependencies = []; + + // Parse requirements.txt + $requirementsFile = $projectRoot->join('requirements.txt'); + if ($requirementsFile->exists()) { + $dependencies = \array_merge($dependencies, $this->parseRequirementsTxt($requirementsFile)); + } + + // Parse pyproject.toml + $pyprojectFile = $projectRoot->join('pyproject.toml'); + if ($pyprojectFile->exists()) { + $dependencies = \array_merge($dependencies, $this->parsePyprojectToml($pyprojectFile)); + } + + // Parse setup.py (basic extraction) + $setupFile = $projectRoot->join('setup.py'); + if ($setupFile->exists()) { + $dependencies = \array_merge($dependencies, $this->parseSetupPy($setupFile)); + } + + // Parse Pipfile + $pipfile = $projectRoot->join('Pipfile'); + if ($pipfile->exists()) { + $dependencies = \array_merge($dependencies, $this->parsePipfile($pipfile)); + } + + return \array_unique($dependencies); + } + + /** + * Parse requirements.txt file + */ + private function parseRequirementsTxt(FSPath $requirementsFile): array + { + $content = $this->files->read($requirementsFile->toString()); + if ($content === '') { + return []; + } + + $dependencies = []; + $lines = \explode("\n", $content); + + foreach ($lines as $line) { + $line = \trim($line); + + // Skip comments and empty lines + if ($line === '' || \str_starts_with($line, '#')) { + continue; + } + + // Extract package name (everything before version specifiers) + if (\preg_match('/^([a-zA-Z0-9_-]+)/', $line, $matches)) { + $dependencies[] = $matches[1]; + } + } + + return $dependencies; + } + + /** + * Parse pyproject.toml file for dependencies + */ + private function parsePyprojectToml(FSPath $pyprojectFile): array + { + $content = $this->files->read($pyprojectFile->toString()); + if ($content === '') { + return []; + } + + $dependencies = []; + + // Basic TOML parsing for dependencies section + if (\preg_match('/\[tool\.poetry\.dependencies\](.*?)(?=\[|$)/s', $content, $matches)) { + $dependenciesSection = $matches[1]; + if (\preg_match_all('/^([a-zA-Z0-9_-]+)\s*=/m', $dependenciesSection, $matches)) { + $dependencies = \array_merge($dependencies, $matches[1]); + } + } + + // Also check for PEP 621 format + if (\preg_match('/\[project\](.*?)(?=\[|$)/s', $content, $matches)) { + $projectSection = $matches[1]; + if (\preg_match('/dependencies\s*=\s*\[(.*?)\]/s', $projectSection, $depMatches)) { + $depList = $depMatches[1]; + if (\preg_match_all('/"([a-zA-Z0-9_-]+)/', $depList, $matches)) { + $dependencies = \array_merge($dependencies, $matches[1]); + } + } + } + + return $dependencies; + } + + /** + * Basic parsing of setup.py for install_requires + */ + private function parseSetupPy(FSPath $setupFile): array + { + $content = $this->files->read($setupFile->toString()); + if ($content === '') { + return []; + } + + $dependencies = []; + + // Look for install_requires list + if (\preg_match('/install_requires\s*=\s*\[(.*?)\]/s', $content, $matches)) { + $requiresList = $matches[1]; + if (\preg_match_all('/"([a-zA-Z0-9_-]+)/', $requiresList, $matches)) { + $dependencies = $matches[1]; + } elseif (\preg_match_all("/'([a-zA-Z0-9_-]+)/", $requiresList, $matches)) { + $dependencies = $matches[1]; + } + } + + return $dependencies; + } + + /** + * Basic parsing of Pipfile + */ + private function parsePipfile(FSPath $pipfile): array + { + $content = $this->files->read($pipfile->toString()); + if ($content === '') { + return []; + } + + $dependencies = []; + + // Parse [packages] section + if (\preg_match('/\[packages\](.*?)(?=\[|$)/s', $content, $matches)) { + $packagesSection = $matches[1]; + if (\preg_match_all('/^([a-zA-Z0-9_-]+)\s*=/m', $packagesSection, $matches)) { + $dependencies = \array_merge($dependencies, $matches[1]); + } + } + + return $dependencies; + } + + /** + * Detect Python framework from dependencies + */ + private function detectFramework(array $dependencies): ?string + { + foreach (self::FRAMEWORK_PATTERNS as $framework => $patterns) { + foreach ($patterns as $pattern) { + if (\in_array($pattern, $dependencies, true)) { + return $framework; + } + } + } + + return null; + } + + /** + * Calculate confidence score for Python project detection + */ + private function calculateConfidence( + FSPath $projectRoot, + array $dependencies, + ?string $detectedFramework, + array $existingDirs, + ): float { + $confidence = 0.4; // Base confidence for having Python indicators + + // Boost confidence if we detected a framework + if ($detectedFramework !== null) { + $confidence += 0.3; + } + + // Boost confidence for having dependencies + if (!empty($dependencies)) { + $confidence += 0.2; + } + + // Boost confidence based on directory structure + $pythonDirScore = $this->structureDetector->getPatternMatchConfidence( + $existingDirs, + self::PYTHON_DIRECTORIES, + ); + $confidence += $pythonDirScore * 0.1; + + // Boost confidence if we have manage.py (Django indicator) + if ($projectRoot->join('manage.py')->exists()) { + $confidence += 0.1; + } + + return \min($confidence, 1.0); + } + + /** + * Get detected Python files + */ + private function getDetectedFiles(FSPath $projectRoot): array + { + $detected = []; + foreach (self::PYTHON_FILES as $file) { + if ($projectRoot->join($file)->exists()) { + $detected[] = $file; + } + } + return $detected; + } + + /** + * Detect which package managers are in use + */ + private function detectPackageManagers(FSPath $projectRoot): array + { + $managers = []; + + if ($projectRoot->join('requirements.txt')->exists()) { + $managers[] = 'pip'; + } + if ($projectRoot->join('pyproject.toml')->exists()) { + $managers[] = 'poetry'; + } + if ($projectRoot->join('Pipfile')->exists()) { + $managers[] = 'pipenv'; + } + if ($projectRoot->join('conda.yml')->exists() || $projectRoot->join('environment.yml')->exists()) { + $managers[] = 'conda'; + } + + return $managers; + } + + /** + * Check if directory contains Python files + */ + private function hasPythonFiles(FSPath $directory): bool + { + if (!$this->files->isDirectory($directory->toString())) { + return false; + } + + $files = $this->files->getFiles($directory->toString(), '*.py'); + return !empty($files); + } +} diff --git a/src/Template/Analysis/AnalyzerChain.php b/src/Template/Analysis/AnalyzerChain.php new file mode 100644 index 00000000..0ad08509 --- /dev/null +++ b/src/Template/Analysis/AnalyzerChain.php @@ -0,0 +1,148 @@ + */ + private array $analyzers = []; + + /** + * @param array $analyzers + */ + public function __construct(array $analyzers = []) + { + foreach ($analyzers as $analyzer) { + $this->addAnalyzer($analyzer); + } + } + + /** + * Add an analyzer to the chain, maintaining priority order + */ + public function addAnalyzer(ProjectAnalyzerInterface $analyzer): void + { + $this->analyzers[] = $analyzer; + $this->sortByPriority(); + } + + /** + * Remove an analyzer from the chain + */ + public function removeAnalyzer(string $analyzerName): void + { + $this->analyzers = \array_filter( + $this->analyzers, + static fn($analyzer) => $analyzer->getName() !== $analyzerName, + ); + + $this->analyzers = \array_values($this->analyzers); // Re-index + } + + /** + * Execute the chain and collect all results + * + * @return array + */ + public function analyze(FSPath $projectRoot): array + { + $results = []; + + foreach ($this->analyzers as $analyzer) { + if ($analyzer->canAnalyze($projectRoot)) { + $result = $analyzer->analyze($projectRoot); + if ($result !== null) { + $results[] = $result; + } + } + } + + // Sort results by confidence (highest first) + \usort($results, static fn($a, $b) => $b->confidence <=> $a->confidence); + + return $results; + } + + /** + * Get the first analyzer that can handle the project + */ + public function getFirstApplicableAnalyzer(FSPath $projectRoot): ?ProjectAnalyzerInterface + { + foreach ($this->analyzers as $analyzer) { + if ($analyzer->canAnalyze($projectRoot)) { + return $analyzer; + } + } + + return null; + } + + /** + * Get all analyzers that can handle the project + * + * @return array + */ + public function getApplicableAnalyzers(FSPath $projectRoot): array + { + return \array_filter( + $this->analyzers, + static fn($analyzer) => $analyzer->canAnalyze($projectRoot), + ); + } + + /** + * Get analyzer by name + */ + public function getAnalyzer(string $name): ?ProjectAnalyzerInterface + { + foreach ($this->analyzers as $analyzer) { + if ($analyzer->getName() === $name) { + return $analyzer; + } + } + + return null; + } + + /** + * Get all registered analyzers + * + * @return array + */ + public function getAllAnalyzers(): array + { + return $this->analyzers; + } + + /** + * Check if chain has any analyzers + */ + public function isEmpty(): bool + { + return empty($this->analyzers); + } + + /** + * Get count of analyzers in chain + */ + public function count(): int + { + return \count($this->analyzers); + } + + /** + * Sort analyzers by priority (highest first) + */ + private function sortByPriority(): void + { + \usort($this->analyzers, static fn($a, $b) => $b->getPriority() <=> $a->getPriority()); + } +} diff --git a/src/Template/Analysis/ProjectAnalysisService.php b/src/Template/Analysis/ProjectAnalysisService.php new file mode 100644 index 00000000..3ae19819 --- /dev/null +++ b/src/Template/Analysis/ProjectAnalysisService.php @@ -0,0 +1,115 @@ + $analyzers + */ + public function __construct(array $analyzers = []) + { + $this->analyzerChain = new AnalyzerChain($analyzers); + } + + /** + * Add an analyzer to the service + */ + public function addAnalyzer(ProjectAnalyzerInterface $analyzer): void + { + $this->analyzerChain->addAnalyzer($analyzer); + } + + /** + * Remove an analyzer from the service + */ + public function removeAnalyzer(string $analyzerName): void + { + $this->analyzerChain->removeAnalyzer($analyzerName); + } + + /** + * Analyze a project and return analysis results + * + * This method guarantees to always return at least one result. + * If no specific analyzers match, the fallback analyzer will provide a default result. + * + * @return array + */ + public function analyzeProject(FSPath $projectRoot): array + { + $results = $this->analyzerChain->analyze($projectRoot); + + // This should never happen if FallbackAnalyzer is registered, + // but add safety check just in case + if (empty($results)) { + throw new \RuntimeException( + 'No analysis results returned. Ensure FallbackAnalyzer is registered.', + ); + } + + return $results; + } + + /** + * Get the best analysis result (highest confidence) + */ + public function getBestAnalysis(FSPath $projectRoot): ?AnalysisResult + { + $results = $this->analyzeProject($projectRoot); + return $results[0] ?? null; + } + + /** + * Get the first analyzer that can handle the project + */ + public function getFirstApplicableAnalyzer(FSPath $projectRoot): ?ProjectAnalyzerInterface + { + return $this->analyzerChain->getFirstApplicableAnalyzer($projectRoot); + } + + /** + * Get all analyzers that can handle the project + * + * @return array + */ + public function getApplicableAnalyzers(FSPath $projectRoot): array + { + return $this->analyzerChain->getApplicableAnalyzers($projectRoot); + } + + /** + * Get analyzer by name + */ + public function getAnalyzer(string $name): ?ProjectAnalyzerInterface + { + return $this->analyzerChain->getAnalyzer($name); + } + + /** + * Get all registered analyzers + * + * @return array + */ + public function getAllAnalyzers(): array + { + return $this->analyzerChain->getAllAnalyzers(); + } + + /** + * Get the analyzer chain for direct access + */ + public function getAnalyzerChain(): AnalyzerChain + { + return $this->analyzerChain; + } +} diff --git a/src/Template/Analysis/ProjectAnalyzerInterface.php b/src/Template/Analysis/ProjectAnalyzerInterface.php new file mode 100644 index 00000000..7426320c --- /dev/null +++ b/src/Template/Analysis/ProjectAnalyzerInterface.php @@ -0,0 +1,33 @@ +join('composer.json'); + + if (!$composerPath->exists()) { + return null; + } + + $content = $this->files->read($composerPath->toString()); + + if ($content === '') { + return null; + } + + $decoded = \json_decode($content, true); + + if (!\is_array($decoded)) { + return null; + } + + return $decoded; + } + + /** + * Check if a package is present in composer dependencies + */ + public function hasPackage(array $composer, string $packageName): bool + { + // Check in require section + if (isset($composer['require']) && \array_key_exists($packageName, $composer['require'])) { + return true; + } + + // Check in require-dev section + if (isset($composer['require-dev']) && \array_key_exists($packageName, $composer['require-dev'])) { + return true; + } + + return false; + } + + /** + * Get package version from composer dependencies + */ + public function getPackageVersion(array $composer, string $packageName): ?string + { + return $composer['require'][$packageName] ?? $composer['require-dev'][$packageName] ?? null; + } + + /** + * Get all packages from composer file + */ + public function getAllPackages(array $composer): array + { + $packages = []; + + if (isset($composer['require'])) { + $packages = \array_merge($packages, $composer['require']); + } + + if (isset($composer['require-dev'])) { + $packages = \array_merge($packages, $composer['require-dev']); + } + + return $packages; + } +} diff --git a/src/Template/Analysis/Util/ProjectStructureDetector.php b/src/Template/Analysis/Util/ProjectStructureDetector.php new file mode 100644 index 00000000..703d18a5 --- /dev/null +++ b/src/Template/Analysis/Util/ProjectStructureDetector.php @@ -0,0 +1,98 @@ + List of existing directories + */ + public function detectExistingDirectories(FSPath $projectRoot): array + { + $existing = []; + + foreach (self::COMMON_DIRECTORIES as $directory) { + if ($projectRoot->join($directory)->exists()) { + $existing[] = $directory; + } + } + + return $existing; + } + + /** + * Calculate confidence score based on directory structure + * More directories = higher confidence that this is a real project + */ + public function calculateStructureConfidence(array $existingDirectories): float + { + $count = \count($existingDirectories); + + if ($count === 0) { + return 0.1; // Very low confidence + } + + if ($count === 1) { + return 0.3; // Low confidence + } + + if ($count === 2) { + return 0.5; // Medium confidence + } + + if ($count >= 3) { + return 0.7; // High confidence + } + + return 0.4; // Fallback + } + + /** + * Check if directory structure matches a specific pattern + */ + public function matchesPattern(array $existingDirectories, array $requiredDirectories): bool + { + foreach ($requiredDirectories as $required) { + if (!\in_array($required, $existingDirectories, true)) { + return false; + } + } + + return true; + } + + /** + * Get confidence score for matching a specific pattern + */ + public function getPatternMatchConfidence(array $existingDirectories, array $requiredDirectories): float + { + if (empty($requiredDirectories)) { + return 0.0; + } + + $matches = \count(\array_intersect($existingDirectories, $requiredDirectories)); + $total = \count($requiredDirectories); + + return $matches / $total; + } +} diff --git a/src/Template/Builder/TemplateConfigurationBuilder.php b/src/Template/Builder/TemplateConfigurationBuilder.php new file mode 100644 index 00000000..a9312cbb --- /dev/null +++ b/src/Template/Builder/TemplateConfigurationBuilder.php @@ -0,0 +1,196 @@ +templateName) . '-structure.md'; + $description ??= \ucfirst($this->templateName) . ' Project Structure'; + $treeViewConfig ??= new TreeViewConfig( + showCharCount: true, + includeFiles: true, + maxDepth: 3, + ); + + $this->documents[] = new Document( + description: $description, + outputPath: $outputPath, + overwrite: true, + modifiers: [], + tags: [\strtolower($this->templateName), 'structure'], + treeSource: new TreeSource( + sourcePaths: $sourcePaths, + description: \ucfirst($this->templateName) . ' Directory Structure', + treeView: $treeViewConfig, + ), + ); + + return $this; + } + + /** + * Add a source code document showing files with optional modifiers + */ + public function addSourceDocument( + string $description, + string $outputPath, + array $sourcePaths, + array $filePatterns = ['*.php'], + array $modifiers = [], + array $tags = [], + ): self { + $this->documents[] = new Document( + description: $description, + outputPath: $outputPath, + overwrite: true, + modifiers: $modifiers, + tags: \array_merge([\strtolower($this->templateName)], $tags), + fileSource: new FileSource( + sourcePaths: $sourcePaths, + description: $description, + filePattern: $filePatterns, + modifiers: $modifiers, + ), + ); + + return $this; + } + + /** + * Add custom document with full control + */ + public function addDocument(Document $document): self + { + $this->documents[] = $document; + return $this; + } + + /** + * Set required files for template detection + */ + public function requireFiles(array $files): self + { + $this->detectionCriteria['files'] = \array_merge( + $this->detectionCriteria['files'] ?? [], + $files, + ); + return $this; + } + + /** + * Set required directories for template detection + */ + public function requireDirectories(array $directories): self + { + $this->detectionCriteria['directories'] = \array_merge( + $this->detectionCriteria['directories'] ?? [], + $directories, + ); + return $this; + } + + /** + * Set required packages for template detection + */ + public function requirePackages(array $packages): self + { + $this->detectionCriteria['patterns'] = \array_merge( + $this->detectionCriteria['patterns'] ?? [], + $packages, + ); + return $this; + } + + /** + * Set complete detection criteria + */ + public function setDetectionCriteria(array $criteria): self + { + $this->detectionCriteria = $criteria; + return $this; + } + + /** + * Build the final configuration registry + */ + public function build(): ConfigRegistry + { + $config = new ConfigRegistry(); + $documents = new DocumentRegistry($this->documents); + $config->register($documents); + return $config; + } + + /** + * Get the detection criteria that was built + */ + public function getDetectionCriteria(): array + { + return $this->detectionCriteria; + } + + /** + * Get all documents that were added + * + * @return array + */ + public function getDocuments(): array + { + return $this->documents; + } + + /** + * Clear all documents (useful for rebuilding) + */ + public function clearDocuments(): self + { + $this->documents = []; + return $this; + } + + /** + * Clear detection criteria + */ + public function clearDetectionCriteria(): self + { + $this->detectionCriteria = []; + return $this; + } + + /** + * Reset builder to initial state + */ + public function reset(): self + { + $this->documents = []; + $this->detectionCriteria = []; + return $this; + } +} diff --git a/src/Template/Console/InitCommand.php b/src/Template/Console/InitCommand.php new file mode 100644 index 00000000..3d31a230 --- /dev/null +++ b/src/Template/Console/InitCommand.php @@ -0,0 +1,341 @@ +configFilename; + $ext = \pathinfo($filename, \PATHINFO_EXTENSION); + + try { + $type = ConfigType::fromExtension($ext); + } catch (\ValueError) { + $this->output->error(\sprintf('Unsupported config type: %s', $ext)); + return Command::FAILURE; + } + + $filename = \pathinfo(\strtolower($filename), PATHINFO_FILENAME) . '.' . $type->value; + $filePath = (string) $dirs->getRootPath()->join($filename); + + if ($files->exists($filePath)) { + $this->output->error(\sprintf('Config %s already exists', $filePath)); + return Command::FAILURE; + } + + if ($this->template !== null) { + return $this->initWithSpecificTemplate($files, $templateRegistry, $this->template, $type, $filePath); + } + + return $this->initWithSmartDetection($dirs, $files, $detectionService, $type, $filePath); + } + + private function initWithSpecificTemplate( + FilesInterface $files, + TemplateRegistry $templateRegistry, + string $templateName, + ConfigType $type, + string $filePath, + ): int { + $template = $templateRegistry->getTemplate($templateName); + + if ($template === null) { + $this->output->error(\sprintf('Template "%s" not found', $templateName)); + $this->showAvailableTemplates($templateRegistry); + return Command::FAILURE; + } + + $this->output->success(\sprintf('Using template: %s', $template->description)); + return $this->writeConfig($files, $template->config, $type, $filePath); + } + + private function initWithSmartDetection( + DirectoriesInterface $dirs, + FilesInterface $files, + TemplateDetectionService $detectionService, + ConfigType $type, + string $filePath, + ): int { + if ($this->output->isVerbose()) { + $this->output->writeln('Analyzing project structure...'); + $this->showDetectionStrategies($detectionService); + } + + if ($this->showAll) { + return $this->showAllPossibleTemplates($dirs, $files, $detectionService, $type, $filePath); + } + + $detection = $detectionService->detectBestTemplate($dirs->getRootPath()); + + if (!$detection->hasTemplate()) { + $this->output->warning('No specific project type detected.'); + $this->showDetectionFallbackOptions($detectionService); + return Command::FAILURE; + } + + $this->displayDetectionResult($detection, $detectionService); + return $this->writeConfig($files, $detection->template->config, $type, $filePath); + } + + private function showDetectionStrategies(TemplateDetectionService $detectionService): void + { + $strategies = $detectionService->getStrategies(); + + $this->output->writeln('Available detection strategies:'); + foreach ($strategies as $strategy) { + $this->output->writeln(\sprintf( + ' - %s (priority: %d, threshold: %.0f%%)', + $strategy->getName(), + $strategy->getPriority(), + $strategy->getConfidenceThreshold() * 100.0, + )); + } + $this->output->newLine(); + } + + private function displayDetectionResult( + $detection, + TemplateDetectionService $detectionService, + ): void { + $confidencePercent = $detection->confidence * 100.0; + + if ($detection->isHighConfidenceTemplateDetection()) { + $this->output->success(\sprintf( + 'High-confidence template match: %s (%.0f%% confidence)', + $detection->template->description, + $confidencePercent, + )); + } else { + $this->output->writeln(\sprintf( + 'Detected via analysis: %s (%.0f%% confidence, method: %s)', + $detection->template->description, + $confidencePercent, + $detection->getDetectionMethodDescription(), + )); + } + + // Show additional context in verbose mode + if ($this->output->isVerbose()) { + if (isset($detection->metadata['reason'])) { + $this->output->writeln(\sprintf(' Reason: %s', $detection->metadata['reason'])); + } + + // Show strategy used + if (isset($detection->metadata['selectedStrategy'])) { + $this->output->writeln(\sprintf(' Strategy: %s', $detection->metadata['selectedStrategy'])); + } + + // Show template match details if available + if ($detection->detectionMethod === 'template_criteria' && isset($detection->metadata['matchingCriteria'])) { + $criteria = $detection->metadata['matchingCriteria']; + if (!empty($criteria)) { + $this->output->writeln(' Matched criteria:'); + foreach ($criteria as $type => $matches) { + if (!empty($matches)) { + $this->output->writeln(\sprintf(' - %s: %s', $type, \implode(', ', $matches))); + } + } + } + } + } + } + + private function showAllPossibleTemplates( + DirectoriesInterface $dirs, + FilesInterface $files, + TemplateDetectionService $detectionService, + ConfigType $type, + string $filePath, + ): int { + $this->output->writeln('Analyzing all possible templates...'); + + $bestDetection = $detectionService->detectBestTemplate($dirs->getRootPath()); + $allDetections = $detectionService->getAllPossibleTemplates($dirs->getRootPath()); + + if (empty($allDetections)) { + $this->output->warning('No templates detected for this project.'); + return Command::FAILURE; + } + + $this->output->title('All Possible Templates'); + + $tableData = []; + foreach ($allDetections as $detection) { + $confidencePercent = $detection->confidence * 100.0; + + $isSelected = $bestDetection->hasTemplate() && + $detection->template !== null && + $detection->template->name === $bestDetection->template->name; + + $status = $this->getTemplateStatus($detection, $isSelected, $detectionService); + + $strategyInfo = $detection->getDetectionMethodDescription(); + if (isset($detection->metadata['strategy'])) { + $strategyInfo = $detection->metadata['strategy']; + } + + $tableData[] = [ + $detection->template->name ?? 'Unknown', + $detection->template->description ?? 'Unknown', + \sprintf('%.0f%%', $confidencePercent), + $strategyInfo, + $status, + ]; + } + + $this->output->table(['Template', 'Description', 'Confidence', 'Strategy', 'Status'], $tableData); + + $this->output->note(\sprintf( + 'Template detection uses %.0f%% confidence threshold. Strategies are tried in priority order.', + $detectionService->getHighConfidenceThreshold() * 100.0, + )); + + if ($bestDetection->hasTemplate()) { + $this->displayDetectionResult($bestDetection, $detectionService); + return $this->writeConfig($files, $bestDetection->template->config, $type, $filePath); + } + + $this->output->error('No suitable template found'); + return Command::FAILURE; + } + + private function getTemplateStatus($detection, bool $isSelected, TemplateDetectionService $detectionService): string + { + if ($isSelected) { + return match ($detection->detectionMethod) { + 'template_criteria' => 'Selected (Template)', + 'analyzer' => 'Selected (Analyzer)', + default => 'Selected', + }; + } + + if ($detection->detectionMethod === 'template_criteria') { + $meetsThreshold = $detection->confidence > $detectionService->getHighConfidenceThreshold(); + return $meetsThreshold ? 'High confidence but not best' : 'Low confidence'; + } + + return 'Available'; + } + + private function showAvailableTemplates(TemplateRegistry $templateRegistry): void + { + $this->output->note('Available templates:'); + foreach ($templateRegistry->getAllTemplates() as $template) { + $this->output->writeln(\sprintf(' - %s: %s', $template->name, $template->description)); + } + $this->output->newLine(); + $this->output->writeln('Use ctx template:list to see detailed template information.'); + } + + private function showDetectionFallbackOptions(TemplateDetectionService $detectionService): void + { + $this->output->writeln('Options:'); + $this->output->writeln(' - Use ctx init to specify a template manually'); + $this->output->writeln(' - Use ctx template:list to see available templates'); + $this->output->writeln(' - Use ctx init --show-all to see all detection results'); + + if ($this->output->isVerbose()) { + $this->output->newLine(); + $this->output->writeln('Detection strategies in use:'); + foreach ($detectionService->getStrategies() as $strategy) { + $this->output->writeln(\sprintf( + ' - %s (threshold: %.0f%%)', + \ucfirst(\str_replace('-', ' ', $strategy->getName())), + $strategy->getConfidenceThreshold() * 100.0, + )); + } + } + } + + private function writeConfig( + FilesInterface $files, + ConfigRegistry $config, + ConfigType $type, + string $filePath, + ): int { + try { + // Create a new config registry with schema for output + $outputConfig = new ConfigRegistry(JsonSchema::SCHEMA_URL); + + // Copy all registries from the original config + foreach ($config->all() as $registry) { + $outputConfig->register($registry); + } + + $content = match ($type) { + ConfigType::Json => \json_encode($outputConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), + ConfigType::Yaml => Yaml::dump( + \json_decode(\json_encode($outputConfig), true), + 10, + 2, + Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK, + ), + default => throw new \InvalidArgumentException( + \sprintf('Unsupported config type: %s', $type->value), + ), + }; + } catch (\Throwable $e) { + $this->output->error(\sprintf('Failed to create config: %s', $e->getMessage())); + return Command::FAILURE; + } + + $files->ensureDirectory(\dirname($filePath)); + $files->write($filePath, $content); + + $this->output->success(\sprintf('Configuration created: %s', $filePath)); + + if ($this->output->isVerbose()) { + $this->output->writeln('Next steps:'); + $this->output->writeln(' - Review and customize the generated configuration'); + $this->output->writeln(' - Run ctx generate to create context documents'); + $this->output->writeln(' - Use ctx server to start MCP server for Claude integration'); + } + + return Command::SUCCESS; + } +} diff --git a/src/Template/Console/ListCommand.php b/src/Template/Console/ListCommand.php new file mode 100644 index 00000000..2439ec2d --- /dev/null +++ b/src/Template/Console/ListCommand.php @@ -0,0 +1,160 @@ +getAllTemplates(); + + if (empty($templates)) { + $this->output->warning('No templates available'); + return Command::SUCCESS; + } + + // Filter by tags if specified + if (!empty($this->tags)) { + $templates = \array_filter($templates, fn($template) => !empty(\array_intersect($this->tags, $template->tags))); + } + + if (empty($templates)) { + $this->output->warning(\sprintf( + 'No templates found with tag(s): %s', + \implode(', ', $this->tags), + )); + return Command::SUCCESS; + } + + $this->output->title('Available Templates'); + + if ($this->detailed) { + return $this->showDetailedList($templates); + } + + return $this->showBasicList($templates); + } + + /** + * Show basic template list in table format + */ + private function showBasicList(array $templates): int + { + $tableData = []; + + foreach ($templates as $template) { + $tableData[] = [ + $template->name, + $template->description, + \implode(', ', $template->tags), + $template->priority, + ]; + } + + $this->output->table(['Name', 'Description', 'Tags', 'Priority'], $tableData); + + $this->output->note('Use "ctx init " to initialize with a specific template.'); + + return Command::SUCCESS; + } + + /** + * Show detailed template information + */ + private function showDetailedList(array $templates): int + { + foreach ($templates as $index => $template) { + if ($index > 0) { + $this->output->newLine(); + } + + $this->output->section(\sprintf('%s (%s)', $template->name, $template->description)); + + // Show basic info + $this->output->definitionList( + ['Priority' => (string) $template->priority], + ['Tags' => empty($template->tags) ? 'None' : \implode(', ', $template->tags)], + ); + + // Show detection criteria if available + if (!empty($template->detectionCriteria)) { + $this->output->writeln('Detection Criteria:'); + + // Show files + if (isset($template->detectionCriteria['files']) && !empty($template->detectionCriteria['files'])) { + $this->output->writeln(\sprintf( + ' • Required Files: %s', + \implode(', ', $template->detectionCriteria['files']), + )); + } + + // Show directories + if (isset($template->detectionCriteria['directories']) && !empty($template->detectionCriteria['directories'])) { + $this->output->writeln(\sprintf( + ' • Expected Directories: %s', + \implode(', ', $template->detectionCriteria['directories']), + )); + } + + // Show patterns (packages) + if (isset($template->detectionCriteria['patterns']) && !empty($template->detectionCriteria['patterns'])) { + $this->output->writeln(\sprintf( + ' • Required Packages: %s', + \implode(', ', $template->detectionCriteria['patterns']), + )); + } + } + + // Show generated documents + $documents = $template->config?->has('documents') + ? $template->config->get('documents', DocumentRegistry::class)->getItems() + : []; + + if (!empty($documents)) { + $this->output->writeln('Generated Documents:'); + foreach ($documents as $document) { + $this->output->writeln(\sprintf( + ' • %s → %s', + $document->description, + $document->outputPath, + )); + } + } + } + + $this->output->note([ + 'Use "ctx init " to initialize with a specific template.', + 'Use "ctx init" to let the system detect the best template automatically.', + 'Use "ctx init --show-all" to see all possible templates with confidence scores.', + ]); + + return Command::SUCCESS; + } +} diff --git a/src/Template/Definition/AbstractTemplateDefinition.php b/src/Template/Definition/AbstractTemplateDefinition.php new file mode 100644 index 00000000..a9bd45fa --- /dev/null +++ b/src/Template/Definition/AbstractTemplateDefinition.php @@ -0,0 +1,170 @@ +createDocuments($projectMetadata)); + $config->register($documents); + + return new Template( + name: $this->getName(), + description: $this->getDescription(), + tags: $this->getTags(), + priority: $this->getPriority(), + detectionCriteria: $this->buildDetectionCriteria(), + config: $config, + ); + } + + /** + * Get detection criteria for automatic selection + */ + final public function getDetectionCriteria(): array + { + return $this->buildDetectionCriteria(); + } + + /** + * Get the main source directories for this template type + * + * @return array + */ + abstract protected function getSourceDirectories(): array; + + /** + * Get framework-specific detection criteria + * + * @return array + */ + abstract protected function getFrameworkSpecificCriteria(): array; + + /** + * Get the output filename for the structure document + */ + protected function getStructureDocumentPath(): string + { + return 'docs/' . $this->getName() . '-structure.md'; + } + + /** + * Get the description for the structure document + */ + protected function getStructureDocumentDescription(): string + { + return $this->getDescription() . ' - Project Structure'; + } + + /** + * Get source paths filtered by existing directories in project metadata + * + * @return array + */ + protected function getDetectedSourcePaths(array $projectMetadata): array + { + $existingDirs = $projectMetadata['existingDirectories'] ?? $projectMetadata['directories'] ?? []; + $sourceDirs = $this->getSourceDirectories(); + + // Filter to only include directories that exist + $detectedPaths = \array_intersect($existingDirs, $sourceDirs); + + // If no standard directories found, return all expected directories + if (empty($detectedPaths)) { + return $sourceDirs; + } + + return \array_values($detectedPaths); + } + + /** + * Create additional documents beyond the standard structure document + * Override in subclasses to add framework-specific documents + * + * @return array + */ + protected function createAdditionalDocuments(array $projectMetadata): array + { + return []; + } + + /** + * Create the tree view configuration for structure documents + */ + protected function createTreeViewConfig(): TreeViewConfig + { + return new TreeViewConfig( + showCharCount: true, + includeFiles: true, + maxDepth: 3, + ); + } + + /** + * Customize tree view configuration in subclasses if needed + */ + protected function customizeTreeViewConfig(TreeViewConfig $config): TreeViewConfig + { + return $config; + } + + /** + * Create all documents for this template + * + * @return array + */ + protected function createDocuments(array $projectMetadata): array + { + return [ + $this->createStructureDocument($projectMetadata), + ...$this->createAdditionalDocuments($projectMetadata), + ]; + } + + /** + * Create the main structure document + */ + protected function createStructureDocument(array $projectMetadata): Document + { + $sourcePaths = $this->getDetectedSourcePaths($projectMetadata); + $treeViewConfig = $this->customizeTreeViewConfig($this->createTreeViewConfig()); + + return new Document( + description: $this->getStructureDocumentDescription(), + outputPath: $this->getStructureDocumentPath(), + overwrite: true, + modifiers: [], + tags: [], + treeSource: new TreeSource( + sourcePaths: $sourcePaths, + description: $this->getName() . ' Directory Structure', + treeView: $treeViewConfig, + ), + ); + } + + /** + * Build complete detection criteria by merging common and framework-specific criteria + */ + private function buildDetectionCriteria(): array + { + return \array_merge([ + 'directories' => $this->getSourceDirectories(), + ], $this->getFrameworkSpecificCriteria()); + } +} diff --git a/src/Template/Definition/DjangoTemplateDefinition.php b/src/Template/Definition/DjangoTemplateDefinition.php new file mode 100644 index 00000000..52143f61 --- /dev/null +++ b/src/Template/Definition/DjangoTemplateDefinition.php @@ -0,0 +1,99 @@ + ['manage.py', 'requirements.txt'], + 'patterns' => ['Django', 'django'], + ]; + } + + /** + * Add Django-specific documents + */ + #[\Override] + protected function createAdditionalDocuments(array $projectMetadata): array + { + $documents = []; + $existingDirs = $projectMetadata['existingDirectories'] ?? $projectMetadata['directories'] ?? []; + + // Add Models and Views document if apps or project directory exists + $sourcePaths = []; + if (\in_array('app', $existingDirs, true)) { + $sourcePaths[] = 'app'; + } + if (\in_array('apps', $existingDirs, true)) { + $sourcePaths[] = 'apps'; + } + if (\in_array('project', $existingDirs, true)) { + $sourcePaths[] = 'project'; + } + + if (!empty($sourcePaths)) { + $documents[] = new Document( + description: 'Django Models and Views', + outputPath: 'docs/django-models-views.md', + tags: ['django', 'models', 'views'], + fileSource: new FileSource( + sourcePaths: $sourcePaths, + description: 'Django Models and Views', + filePattern: ['models.py', 'views.py', '*.py'], + path: ['models', 'views'], + ), + ); + } + + // Add Settings and URLs document + if (!empty($sourcePaths)) { + $documents[] = new Document( + description: 'Django Configuration', + outputPath: 'docs/django-config.md', + tags: ['django', 'configuration'], + fileSource: new FileSource( + sourcePaths: $sourcePaths, + description: 'Django Configuration', + filePattern: ['settings.py', 'urls.py', 'wsgi.py', 'asgi.py'], + ), + ); + } + + return $documents; + } +} diff --git a/src/Template/Definition/ExpressTemplateDefinition.php b/src/Template/Definition/ExpressTemplateDefinition.php new file mode 100644 index 00000000..0b562908 --- /dev/null +++ b/src/Template/Definition/ExpressTemplateDefinition.php @@ -0,0 +1,110 @@ +createStructureDocument($projectMetadata), + ]); + + $config->register($documents); + + return new Template( + name: $this->getName(), + description: $this->getDescription(), + tags: $this->getTags(), + priority: $this->getPriority(), + detectionCriteria: $this->getDetectionCriteria(), + config: $config, + ); + } + + public function getName(): string + { + return 'express'; + } + + public function getDescription(): string + { + return 'Express.js Node.js framework project template'; + } + + public function getTags(): array + { + return ['javascript', 'nodejs', 'express', 'backend', 'api']; + } + + public function getPriority(): int + { + return 80; // Medium-high priority for backend framework + } + + public function getDetectionCriteria(): array + { + return [ + 'files' => ['package.json'], + 'patterns' => self::EXPRESS_PACKAGES, + 'directories' => self::EXPRESS_DIRECTORIES, + ]; + } + + /** + * Create the Express project structure document + */ + private function createStructureDocument(array $projectMetadata): Document + { + return new Document( + description: 'Express Project Structure', + outputPath: 'docs/express-structure.md', + overwrite: true, + modifiers: [], + tags: [], + treeSource: new TreeSource( + sourcePaths: ['routes', 'controllers', 'middleware', 'models'], + description: 'Express Directory Structure', + treeView: new TreeViewConfig( + showCharCount: true, + includeFiles: true, + maxDepth: 3, + ), + ), + ); + } +} diff --git a/src/Template/Definition/FastApiTemplateDefinition.php b/src/Template/Definition/FastApiTemplateDefinition.php new file mode 100644 index 00000000..64f8de63 --- /dev/null +++ b/src/Template/Definition/FastApiTemplateDefinition.php @@ -0,0 +1,111 @@ + ['main.py', 'requirements.txt'], + 'patterns' => ['fastapi', 'FastAPI'], + ]; + } + + /** + * Add FastAPI-specific documents + */ + #[\Override] + protected function createAdditionalDocuments(array $projectMetadata): array + { + $documents = []; + $existingDirs = $projectMetadata['existingDirectories'] ?? $projectMetadata['directories'] ?? []; + + // Add API Routes and Models document + $sourcePaths = []; + if (\in_array('app', $existingDirs, true)) { + $sourcePaths[] = 'app'; + } + if (\in_array('src', $existingDirs, true)) { + $sourcePaths[] = 'src'; + } + if (\in_array('api', $existingDirs, true)) { + $sourcePaths[] = 'api'; + } + + // If no structured directories, check for main.py in root + if (empty($sourcePaths)) { + $sourcePaths = ['.']; + } + + $documents[] = new Document( + description: 'FastAPI Routes and Endpoints', + outputPath: 'docs/fastapi-routes.md', + tags: ['fastapi', 'routes', 'api'], + fileSource: new FileSource( + sourcePaths: $sourcePaths, + description: 'FastAPI Routes and Endpoints', + filePattern: ['*.py'], + contains: ['@app.', 'APIRouter', 'FastAPI'], + ), + ); + + // Add Models and Schemas document if those directories exist + if (\in_array('models', $existingDirs, true) || \in_array('schemas', $existingDirs, true)) { + $modelPaths = []; + if (\in_array('models', $existingDirs, true)) { + $modelPaths[] = 'models'; + } + if (\in_array('schemas', $existingDirs, true)) { + $modelPaths[] = 'schemas'; + } + + $documents[] = new Document( + description: 'FastAPI Models and Schemas', + outputPath: 'docs/fastapi-models-schemas.md', + tags: ['fastapi', 'models', 'schemas'], + fileSource: new FileSource( + sourcePaths: $modelPaths, + description: 'FastAPI Models and Schemas', + filePattern: ['*.py'], + contains: ['BaseModel', 'SQLModel', 'pydantic'], + ), + ); + } + + return $documents; + } +} diff --git a/src/Template/Definition/FlaskTemplateDefinition.php b/src/Template/Definition/FlaskTemplateDefinition.php new file mode 100644 index 00000000..22717c39 --- /dev/null +++ b/src/Template/Definition/FlaskTemplateDefinition.php @@ -0,0 +1,85 @@ + ['app.py', 'requirements.txt'], + 'patterns' => ['Flask', 'flask'], + ]; + } + + /** + * Add Flask-specific documents + */ + #[\Override] + protected function createAdditionalDocuments(array $projectMetadata): array + { + $documents = []; + $existingDirs = $projectMetadata['existingDirectories'] ?? $projectMetadata['directories'] ?? []; + + // Add Flask App and Routes document + $sourcePaths = []; + if (\in_array('app', $existingDirs, true)) { + $sourcePaths[] = 'app'; + } + if (\in_array('src', $existingDirs, true)) { + $sourcePaths[] = 'src'; + } + + // If no app/src directory, check for app.py in root + if (empty($sourcePaths)) { + $sourcePaths = ['.']; + } + + $documents[] = new Document( + description: 'Flask Application and Routes', + outputPath: 'docs/flask-app-routes.md', + tags: ['flask', 'routes', 'application'], + fileSource: new FileSource( + sourcePaths: $sourcePaths, + description: 'Flask Application and Routes', + filePattern: ['*.py'], + contains: ['@app.route', 'Flask', 'blueprint'], + ), + ); + + return $documents; + } +} diff --git a/src/Template/Definition/GenericPhpTemplateDefinition.php b/src/Template/Definition/GenericPhpTemplateDefinition.php new file mode 100644 index 00000000..95badb91 --- /dev/null +++ b/src/Template/Definition/GenericPhpTemplateDefinition.php @@ -0,0 +1,120 @@ +createStructureDocument($projectMetadata), + ]); + + $config->register($documents); + + return new Template( + name: $this->getName(), + description: $this->getDescription(), + tags: $this->getTags(), + priority: $this->getPriority(), + detectionCriteria: $this->getDetectionCriteria(), + config: $config, + ); + } + + public function getName(): string + { + return 'generic-php'; + } + + public function getDescription(): string + { + return 'Generic PHP project template'; + } + + public function getTags(): array + { + return ['php', 'generic']; + } + + public function getPriority(): int + { + return 10; + } + + public function getDetectionCriteria(): array + { + return [ + 'files' => ['composer.json'], + 'directories' => self::PHP_SOURCE_DIRECTORIES, + ]; + } + + /** + * Create the PHP project structure document + */ + private function createStructureDocument(array $projectMetadata): Document + { + $sourcePaths = $this->getDetectedSourcePaths($projectMetadata); + + return new Document( + description: 'PHP Project Structure', + outputPath: 'docs/php-structure.md', + overwrite: true, + modifiers: [], + tags: [], + treeSource: new TreeSource( + sourcePaths: $sourcePaths, + description: 'PHP Directory Structure', + treeView: new TreeViewConfig( + showCharCount: true, + includeFiles: true, + maxDepth: 3, + ), + ), + ); + } + + /** + * Get detected source paths from project metadata + */ + private function getDetectedSourcePaths(array $projectMetadata): array + { + $existingDirs = $projectMetadata['existingDirectories'] ?? []; + + // Filter to only include common PHP source directories that exist + $sourcePaths = \array_intersect($existingDirs, self::PHP_SOURCE_DIRECTORIES); + + // If no standard source directories found, fall back to 'src' + if (empty($sourcePaths)) { + return ['src']; + } + + return \array_values($sourcePaths); + } +} diff --git a/src/Template/Definition/GinTemplateDefinition.php b/src/Template/Definition/GinTemplateDefinition.php new file mode 100644 index 00000000..d03bdada --- /dev/null +++ b/src/Template/Definition/GinTemplateDefinition.php @@ -0,0 +1,99 @@ + ['go.mod'], + 'patterns' => ['github.com/gin-gonic/gin'], + ]; + } + + /** + * Add Gin-specific documents + */ + #[\Override] + protected function createAdditionalDocuments(array $projectMetadata): array + { + $documents = []; + $existingDirs = $projectMetadata['existingDirectories'] ?? $projectMetadata['directories'] ?? []; + + // Add handlers and routes document + $handlerPaths = []; + if (\in_array('handlers', $existingDirs, true)) { + $handlerPaths[] = 'handlers'; + } + if (\in_array('api', $existingDirs, true)) { + $handlerPaths[] = 'api'; + } + if (\in_array('internal', $existingDirs, true)) { + $handlerPaths[] = 'internal'; + } + + if (!empty($handlerPaths)) { + $documents[] = new Document( + description: 'Gin Handlers and Routes', + outputPath: 'docs/gin-handlers-routes.md', + tags: ['gin', 'handlers', 'routes'], + fileSource: new FileSource( + sourcePaths: $handlerPaths, + description: 'Gin Handlers and Routes', + filePattern: ['*.go'], + contains: ['gin.', 'c.JSON', 'router.', 'engine.'], + ), + ); + } + + // Add middleware document if middleware directory exists + if (\in_array('middleware', $existingDirs, true)) { + $documents[] = new Document( + description: 'Gin Middleware', + outputPath: 'docs/gin-middleware.md', + tags: ['gin', 'middleware'], + fileSource: new FileSource( + sourcePaths: ['middleware'], + description: 'Gin Middleware', + filePattern: ['*.go'], + ), + ); + } + + return $documents; + } +} diff --git a/src/Template/Definition/GoTemplateDefinition.php b/src/Template/Definition/GoTemplateDefinition.php new file mode 100644 index 00000000..42c0f617 --- /dev/null +++ b/src/Template/Definition/GoTemplateDefinition.php @@ -0,0 +1,106 @@ + ['go.mod'], + ]; + } + + /** + * Add Go-specific documents + */ + #[\Override] + protected function createAdditionalDocuments(array $projectMetadata): array + { + $documents = []; + $existingDirs = $projectMetadata['existingDirectories'] ?? $projectMetadata['directories'] ?? []; + + // Add Go packages document if source directories exist + $sourcePaths = \array_intersect($existingDirs, $this->getSourceDirectories()); + + if (!empty($sourcePaths)) { + $documents[] = new Document( + description: 'Go Packages and Modules', + outputPath: 'docs/go-packages.md', + tags: ['go', 'packages'], + fileSource: new FileSource( + sourcePaths: \array_values($sourcePaths), + description: 'Go Packages and Modules', + filePattern: ['*.go'], + notPath: ['vendor', 'bin'], + ), + ); + } + + // Add configuration files document + $configFiles = []; + $potentialConfigFiles = [ + 'go.mod', + 'go.sum', + 'go.work', + 'Makefile', + 'Dockerfile', + '.golangci.yml', + '.golangci.yaml', + ]; + + foreach ($potentialConfigFiles as $configFile) { + if (\in_array($configFile, $projectMetadata['files'] ?? [], true)) { + $configFiles[] = $configFile; + } + } + + if (!empty($configFiles)) { + $documents[] = new Document( + description: 'Go Configuration Files', + outputPath: 'docs/go-config.md', + tags: ['go', 'configuration'], + fileSource: new FileSource( + sourcePaths: ['.'], + description: 'Go Configuration Files', + filePattern: $configFiles, + ), + ); + } + + return $documents; + } +} diff --git a/src/Template/Definition/LaravelTemplateDefinition.php b/src/Template/Definition/LaravelTemplateDefinition.php new file mode 100644 index 00000000..dbd8b605 --- /dev/null +++ b/src/Template/Definition/LaravelTemplateDefinition.php @@ -0,0 +1,108 @@ + ['composer.json', 'artisan'], + 'patterns' => ['laravel/framework'], + ]; + } + + /** + * Override to customize Laravel tree view with framework-specific directories + */ + #[\Override] + protected function customizeTreeViewConfig( + \Butschster\ContextGenerator\Lib\TreeBuilder\TreeViewConfig $config, + ): \Butschster\ContextGenerator\Lib\TreeBuilder\TreeViewConfig { + return new \Butschster\ContextGenerator\Lib\TreeBuilder\TreeViewConfig( + showCharCount: true, + includeFiles: true, + maxDepth: 3, + dirContext: [ + 'app' => 'Application core - models, controllers, services', + 'database' => 'Database migrations, seeders, and factories', + 'routes' => 'Application route definitions', + 'config' => 'Application configuration files', + ], + ); + } + + /** + * Add Laravel-specific documents beyond the basic structure + */ + #[\Override] + protected function createAdditionalDocuments(array $projectMetadata): array + { + $documents = []; + + // Add Controllers and Models document if they exist + $existingDirs = $projectMetadata['existingDirectories'] ?? $projectMetadata['directories'] ?? []; + + if (\in_array('app', $existingDirs, true)) { + $documents[] = new Document( + description: 'Laravel Controllers and Models', + outputPath: 'docs/laravel-controllers-models.md', + tags: ['laravel', 'controllers', 'models'], + fileSource: new FileSource( + sourcePaths: ['app/Http/Controllers', 'app/Models'], + description: 'Laravel Controllers and Models', + filePattern: '*.php', + ), + ); + } + + // Add Routes document if routes directory exists + if (\in_array('routes', $existingDirs, true)) { + $documents[] = new Document( + description: 'Laravel Routes Configuration', + outputPath: 'docs/laravel-routes.md', + tags: ['laravel', 'routes'], + fileSource: new FileSource( + sourcePaths: ['routes'], + description: 'Laravel Routes', + filePattern: '*.php', + ), + ); + } + + return $documents; + } +} diff --git a/src/Template/Definition/NextJsTemplateDefinition.php b/src/Template/Definition/NextJsTemplateDefinition.php new file mode 100644 index 00000000..1485f50c --- /dev/null +++ b/src/Template/Definition/NextJsTemplateDefinition.php @@ -0,0 +1,110 @@ +createStructureDocument($projectMetadata), + ]); + + $config->register($documents); + + return new Template( + name: $this->getName(), + description: $this->getDescription(), + tags: $this->getTags(), + priority: $this->getPriority(), + detectionCriteria: $this->getDetectionCriteria(), + config: $config, + ); + } + + public function getName(): string + { + return 'nextjs'; + } + + public function getDescription(): string + { + return 'Next.js React framework project template'; + } + + public function getTags(): array + { + return ['javascript', 'react', 'nextjs', 'fullstack', 'ssr']; + } + + public function getPriority(): int + { + return 88; // Higher priority than React, as Next.js is more specific + } + + public function getDetectionCriteria(): array + { + return [ + 'files' => ['package.json'], + 'patterns' => self::NEXTJS_PACKAGES, + 'directories' => self::NEXTJS_DIRECTORIES, + ]; + } + + /** + * Create the Next.js project structure document + */ + private function createStructureDocument(array $projectMetadata): Document + { + return new Document( + description: 'Next.js Project Structure', + outputPath: 'docs/nextjs-structure.md', + overwrite: true, + modifiers: [], + tags: [], + treeSource: new TreeSource( + sourcePaths: ['pages', 'app', 'public', 'components'], + description: 'Next.js Directory Structure', + treeView: new TreeViewConfig( + showCharCount: true, + includeFiles: true, + maxDepth: 3, + ), + ), + ); + } +} diff --git a/src/Template/Definition/NuxtTemplateDefinition.php b/src/Template/Definition/NuxtTemplateDefinition.php new file mode 100644 index 00000000..b71b63e1 --- /dev/null +++ b/src/Template/Definition/NuxtTemplateDefinition.php @@ -0,0 +1,114 @@ +createStructureDocument($projectMetadata), + ]); + + $config->register($documents); + + return new Template( + name: $this->getName(), + description: $this->getDescription(), + tags: $this->getTags(), + priority: $this->getPriority(), + detectionCriteria: $this->getDetectionCriteria(), + config: $config, + ); + } + + public function getName(): string + { + return 'nuxt'; + } + + public function getDescription(): string + { + return 'Nuxt.js Vue framework project template'; + } + + public function getTags(): array + { + return ['javascript', 'vue', 'nuxt', 'fullstack', 'ssr']; + } + + public function getPriority(): int + { + return 88; // Higher priority than Vue, as Nuxt is more specific + } + + public function getDetectionCriteria(): array + { + return [ + 'files' => ['package.json'], + 'patterns' => self::NUXT_PACKAGES, + 'directories' => self::NUXT_DIRECTORIES, + ]; + } + + /** + * Create the Nuxt project structure document + */ + private function createStructureDocument(array $projectMetadata): Document + { + return new Document( + description: 'Nuxt Project Structure', + outputPath: 'docs/nuxt-structure.md', + overwrite: true, + modifiers: [], + tags: [], + treeSource: new TreeSource( + sourcePaths: ['pages', 'components', 'layouts', 'plugins'], + description: 'Nuxt Directory Structure', + treeView: new TreeViewConfig( + showCharCount: true, + includeFiles: true, + maxDepth: 3, + ), + ), + ); + } +} diff --git a/src/Template/Definition/PythonTemplateDefinition.php b/src/Template/Definition/PythonTemplateDefinition.php new file mode 100644 index 00000000..5174a2a2 --- /dev/null +++ b/src/Template/Definition/PythonTemplateDefinition.php @@ -0,0 +1,107 @@ + ['requirements.txt', 'pyproject.toml', 'setup.py'], + ]; + } + + /** + * Add Python-specific documents + */ + #[\Override] + protected function createAdditionalDocuments(array $projectMetadata): array + { + $documents = []; + $existingDirs = $projectMetadata['existingDirectories'] ?? $projectMetadata['directories'] ?? []; + + // Add Python modules document if source directories exist + $sourcePaths = \array_intersect($existingDirs, $this->getSourceDirectories()); + + if (!empty($sourcePaths)) { + $documents[] = new Document( + description: 'Python Modules and Packages', + outputPath: 'docs/python-modules.md', + tags: ['python', 'modules'], + fileSource: new FileSource( + sourcePaths: \array_values($sourcePaths), + description: 'Python Modules and Packages', + filePattern: ['*.py'], + notPath: ['__pycache__', '*.pyc', 'venv', 'env', '.venv'], + ), + ); + } + + // Add configuration files document + $configFiles = []; + $potentialConfigFiles = [ + 'setup.py', + 'setup.cfg', + 'pyproject.toml', + 'requirements.txt', + 'Pipfile', + 'tox.ini', + 'pytest.ini', + '.coveragerc', + ]; + + foreach ($potentialConfigFiles as $configFile) { + if (\in_array($configFile, $projectMetadata['files'] ?? [], true)) { + $configFiles[] = $configFile; + } + } + + if (!empty($configFiles)) { + $documents[] = new Document( + description: 'Python Configuration Files', + outputPath: 'docs/python-config.md', + tags: ['python', 'configuration'], + fileSource: new FileSource( + sourcePaths: ['.'], + description: 'Python Configuration Files', + filePattern: $configFiles, + ), + ); + } + + return $documents; + } +} diff --git a/src/Template/Definition/ReactTemplateDefinition.php b/src/Template/Definition/ReactTemplateDefinition.php new file mode 100644 index 00000000..261348ff --- /dev/null +++ b/src/Template/Definition/ReactTemplateDefinition.php @@ -0,0 +1,110 @@ +createStructureDocument($projectMetadata), + ]); + + $config->register($documents); + + return new Template( + name: $this->getName(), + description: $this->getDescription(), + tags: $this->getTags(), + priority: $this->getPriority(), + detectionCriteria: $this->getDetectionCriteria(), + config: $config, + ); + } + + public function getName(): string + { + return 'react'; + } + + public function getDescription(): string + { + return 'React.js application project template'; + } + + public function getTags(): array + { + return ['javascript', 'react', 'frontend', 'spa']; + } + + public function getPriority(): int + { + return 85; // High priority for specific framework detection + } + + public function getDetectionCriteria(): array + { + return [ + 'files' => ['package.json'], + 'patterns' => self::REACT_PACKAGES, + 'directories' => self::REACT_DIRECTORIES, + ]; + } + + /** + * Create the React project structure document + */ + private function createStructureDocument(array $projectMetadata): Document + { + return new Document( + description: 'React Project Structure', + outputPath: 'docs/react-structure.md', + overwrite: true, + modifiers: [], + tags: [], + treeSource: new TreeSource( + sourcePaths: ['src', 'public'], + description: 'React Directory Structure', + treeView: new TreeViewConfig( + showCharCount: true, + includeFiles: true, + maxDepth: 3, + ), + ), + ); + } +} diff --git a/src/Template/Definition/SpiralTemplateDefinition.php b/src/Template/Definition/SpiralTemplateDefinition.php new file mode 100644 index 00000000..07fafd0e --- /dev/null +++ b/src/Template/Definition/SpiralTemplateDefinition.php @@ -0,0 +1,108 @@ +createStructureDocument($projectMetadata), + ]); + + $config->register($documents); + + return new Template( + name: $this->getName(), + description: $this->getDescription(), + tags: $this->getTags(), + priority: $this->getPriority(), + detectionCriteria: $this->getDetectionCriteria(), + config: $config, + ); + } + + public function getName(): string + { + return 'spiral'; + } + + public function getDescription(): string + { + return 'Spiral PHP Framework project template'; + } + + public function getTags(): array + { + return ['php', 'spiral', 'framework', 'roadrunner']; + } + + public function getPriority(): int + { + return 95; // High priority for specific framework detection + } + + public function getDetectionCriteria(): array + { + return [ + 'files' => ['composer.json'], + 'patterns' => self::SPIRAL_PACKAGES, + 'directories' => self::SPIRAL_DIRECTORIES, + ]; + } + + /** + * Create the Spiral project structure document + */ + private function createStructureDocument(array $projectMetadata): Document + { + return new Document( + description: 'Spiral Project Structure', + outputPath: 'docs/spiral-structure.md', + overwrite: true, + modifiers: [], + tags: [], + treeSource: new TreeSource( + sourcePaths: ['app', 'resources', 'public', 'config'], + description: 'Spiral Directory Structure', + treeView: new TreeViewConfig( + showCharCount: true, + includeFiles: true, + maxDepth: 3, + ), + ), + ); + } +} diff --git a/src/Template/Definition/SymfonyTemplateDefinition.php b/src/Template/Definition/SymfonyTemplateDefinition.php new file mode 100644 index 00000000..6904e1f2 --- /dev/null +++ b/src/Template/Definition/SymfonyTemplateDefinition.php @@ -0,0 +1,118 @@ +createStructureDocument($projectMetadata), + ]); + + $config->register($documents); + + return new Template( + name: $this->getName(), + description: $this->getDescription(), + tags: $this->getTags(), + priority: $this->getPriority(), + detectionCriteria: $this->getDetectionCriteria(), + config: $config, + ); + } + + public function getName(): string + { + return 'symfony'; + } + + public function getDescription(): string + { + return 'Symfony PHP Framework project template'; + } + + public function getTags(): array + { + return ['php', 'symfony', 'framework', 'web']; + } + + public function getPriority(): int + { + return 95; // High priority for specific framework detection + } + + public function getDetectionCriteria(): array + { + return [ + 'files' => \array_merge(['composer.json'], self::SYMFONY_FILES), + 'patterns' => self::SYMFONY_PACKAGES, + 'directories' => self::SYMFONY_DIRECTORIES, + ]; + } + + /** + * Create the Symfony project structure document + */ + private function createStructureDocument(array $projectMetadata): Document + { + return new Document( + description: 'Symfony Project Structure', + outputPath: 'docs/symfony-structure.md', + overwrite: true, + modifiers: [], + tags: [], + treeSource: new TreeSource( + sourcePaths: ['src', 'config', 'templates', 'public'], + description: 'Symfony Directory Structure', + treeView: new TreeViewConfig( + showCharCount: true, + includeFiles: true, + maxDepth: 3, + ), + ), + ); + } +} diff --git a/src/Template/Definition/TemplateDefinitionInterface.php b/src/Template/Definition/TemplateDefinitionInterface.php new file mode 100644 index 00000000..2eb29880 --- /dev/null +++ b/src/Template/Definition/TemplateDefinitionInterface.php @@ -0,0 +1,49 @@ + $projectMetadata Optional project metadata for context-aware template creation + */ + public function createTemplate(array $projectMetadata = []): Template; + + /** + * Get the template name/identifier + */ + public function getName(): string; + + /** + * Get the template description + */ + public function getDescription(): string; + + /** + * Get template tags for categorization + * + * @return array + */ + public function getTags(): array; + + /** + * Get the template priority (higher = more preferred) + */ + public function getPriority(): int; + + /** + * Get detection criteria for automatic selection + * + * @return array + */ + public function getDetectionCriteria(): array; +} diff --git a/src/Template/Definition/TemplateDefinitionRegistry.php b/src/Template/Definition/TemplateDefinitionRegistry.php new file mode 100644 index 00000000..702d3f05 --- /dev/null +++ b/src/Template/Definition/TemplateDefinitionRegistry.php @@ -0,0 +1,80 @@ + */ + private array $definitions = []; + + /** + * @param array $definitions + */ + public function __construct(array $definitions = []) + { + foreach ($definitions as $definition) { + $this->registerDefinition($definition); + } + } + + /** + * Register a template definition + */ + public function registerDefinition(TemplateDefinitionInterface $definition): void + { + $this->definitions[] = $definition; + + // Sort by priority (highest first) + \usort($this->definitions, static fn($a, $b) => $b->getPriority() <=> $a->getPriority()); + } + + /** + * Get a specific template definition by name + */ + public function getDefinition(string $name): ?TemplateDefinitionInterface + { + foreach ($this->definitions as $definition) { + if ($definition->getName() === $name) { + return $definition; + } + } + + return null; + } + + /** + * Create all templates from registered definitions + * + * @param array $projectMetadata + * @return array