diff --git a/cmd/core/cmd/php.go b/cmd/core/cmd/php.go index 522a8c12..28482531 100644 --- a/cmd/core/cmd/php.go +++ b/cmd/core/cmd/php.go @@ -63,6 +63,9 @@ func AddPHPCommands(parent *clir.Cli) { addPHPBuildCommand(phpCmd) addPHPServeCommand(phpCmd) addPHPShellCommand(phpCmd) + addPHPTestCommand(phpCmd) + addPHPFmtCommand(phpCmd) + addPHPAnalyseCommand(phpCmd) } func addPHPDevCommand(parent *clir.Command) { @@ -814,3 +817,188 @@ func addPHPShellCommand(parent *clir.Command) { return nil }) } + +func addPHPTestCommand(parent *clir.Command) { + var ( + parallel bool + coverage bool + filter string + group string + ) + + testCmd := parent.NewSubCommand("test", "Run PHP tests (PHPUnit/Pest)") + testCmd.LongDescription("Run PHP tests using PHPUnit or Pest.\n\n" + + "Auto-detects Pest if tests/Pest.php exists, otherwise uses PHPUnit.\n\n" + + "Examples:\n" + + " core php test # Run all tests\n" + + " core php test --parallel # Run tests in parallel\n" + + " core php test --coverage # Run with coverage\n" + + " core php test --filter UserTest # Filter by test name") + + testCmd.BoolFlag("parallel", "Run tests in parallel", ¶llel) + testCmd.BoolFlag("coverage", "Generate code coverage", &coverage) + testCmd.StringFlag("filter", "Filter tests by name pattern", &filter) + testCmd.StringFlag("group", "Run only tests in specified group", &group) + + testCmd.Action(func() error { + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get working directory: %w", err) + } + + if !php.IsPHPProject(cwd) { + return fmt.Errorf("not a PHP project (missing composer.json)") + } + + // Detect test runner + runner := php.DetectTestRunner(cwd) + fmt.Printf("%s Running tests with %s\n\n", dimStyle.Render("PHP:"), runner) + + ctx := context.Background() + + opts := php.TestOptions{ + Dir: cwd, + Filter: filter, + Parallel: parallel, + Coverage: coverage, + Output: os.Stdout, + } + + if group != "" { + opts.Groups = []string{group} + } + + if err := php.RunTests(ctx, opts); err != nil { + return fmt.Errorf("tests failed: %w", err) + } + + return nil + }) +} + +func addPHPFmtCommand(parent *clir.Command) { + var ( + fix bool + diff bool + ) + + fmtCmd := parent.NewSubCommand("fmt", "Format PHP code with Laravel Pint") + fmtCmd.LongDescription("Format PHP code using Laravel Pint.\n\n" + + "Examples:\n" + + " core php fmt # Check formatting (dry-run)\n" + + " core php fmt --fix # Auto-fix formatting issues\n" + + " core php fmt --diff # Show diff of changes") + + fmtCmd.BoolFlag("fix", "Auto-fix formatting issues", &fix) + fmtCmd.BoolFlag("diff", "Show diff of changes", &diff) + + fmtCmd.Action(func() error { + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get working directory: %w", err) + } + + if !php.IsPHPProject(cwd) { + return fmt.Errorf("not a PHP project (missing composer.json)") + } + + // Detect formatter + formatter, found := php.DetectFormatter(cwd) + if !found { + return fmt.Errorf("no formatter found (install Laravel Pint: composer require laravel/pint --dev)") + } + + action := "Checking" + if fix { + action = "Formatting" + } + fmt.Printf("%s %s code with %s\n\n", dimStyle.Render("PHP:"), action, formatter) + + ctx := context.Background() + + opts := php.FormatOptions{ + Dir: cwd, + Fix: fix, + Diff: diff, + Output: os.Stdout, + } + + // Get any additional paths from args + if args := fmtCmd.OtherArgs(); len(args) > 0 { + opts.Paths = args + } + + if err := php.Format(ctx, opts); err != nil { + if fix { + return fmt.Errorf("formatting failed: %w", err) + } + return fmt.Errorf("formatting issues found: %w", err) + } + + if fix { + fmt.Printf("\n%s Code formatted successfully\n", successStyle.Render("Done:")) + } else { + fmt.Printf("\n%s No formatting issues found\n", successStyle.Render("Done:")) + } + + return nil + }) +} + +func addPHPAnalyseCommand(parent *clir.Command) { + var ( + level int + memory string + ) + + analyseCmd := parent.NewSubCommand("analyse", "Run PHPStan static analysis") + analyseCmd.LongDescription("Run PHPStan or Larastan static analysis.\n\n" + + "Auto-detects Larastan if installed, otherwise uses PHPStan.\n\n" + + "Examples:\n" + + " core php analyse # Run analysis\n" + + " core php analyse --level 9 # Run at max strictness\n" + + " core php analyse --memory 2G # Increase memory limit") + + analyseCmd.IntFlag("level", "PHPStan analysis level (0-9)", &level) + analyseCmd.StringFlag("memory", "Memory limit (e.g., 2G)", &memory) + + analyseCmd.Action(func() error { + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get working directory: %w", err) + } + + if !php.IsPHPProject(cwd) { + return fmt.Errorf("not a PHP project (missing composer.json)") + } + + // Detect analyser + analyser, found := php.DetectAnalyser(cwd) + if !found { + return fmt.Errorf("no static analyser found (install PHPStan: composer require phpstan/phpstan --dev)") + } + + fmt.Printf("%s Running static analysis with %s\n\n", dimStyle.Render("PHP:"), analyser) + + ctx := context.Background() + + opts := php.AnalyseOptions{ + Dir: cwd, + Level: level, + Memory: memory, + Output: os.Stdout, + } + + // Get any additional paths from args + if args := analyseCmd.OtherArgs(); len(args) > 0 { + opts.Paths = args + } + + if err := php.Analyse(ctx, opts); err != nil { + return fmt.Errorf("analysis found issues: %w", err) + } + + fmt.Printf("\n%s No issues found\n", successStyle.Render("Done:")) + return nil + }) +} diff --git a/pkg/php/quality.go b/pkg/php/quality.go new file mode 100644 index 00000000..e53716c3 --- /dev/null +++ b/pkg/php/quality.go @@ -0,0 +1,238 @@ +package php + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" +) + +// FormatOptions configures PHP code formatting. +type FormatOptions struct { + // Dir is the project directory (defaults to current working directory). + Dir string + + // Fix automatically fixes formatting issues. + Fix bool + + // Diff shows a diff of changes instead of modifying files. + Diff bool + + // Paths limits formatting to specific paths. + Paths []string + + // Output is the writer for output (defaults to os.Stdout). + Output io.Writer +} + +// AnalyseOptions configures PHP static analysis. +type AnalyseOptions struct { + // Dir is the project directory (defaults to current working directory). + Dir string + + // Level is the PHPStan analysis level (0-9). + Level int + + // Paths limits analysis to specific paths. + Paths []string + + // Memory is the memory limit for analysis (e.g., "2G"). + Memory string + + // Output is the writer for output (defaults to os.Stdout). + Output io.Writer +} + +// FormatterType represents the detected formatter. +type FormatterType string + +const ( + FormatterPint FormatterType = "pint" +) + +// AnalyserType represents the detected static analyser. +type AnalyserType string + +const ( + AnalyserPHPStan AnalyserType = "phpstan" + AnalyserLarastan AnalyserType = "larastan" +) + +// DetectFormatter detects which formatter is available in the project. +func DetectFormatter(dir string) (FormatterType, bool) { + // Check for Pint config + pintConfig := filepath.Join(dir, "pint.json") + if _, err := os.Stat(pintConfig); err == nil { + return FormatterPint, true + } + + // Check for vendor binary + pintBin := filepath.Join(dir, "vendor", "bin", "pint") + if _, err := os.Stat(pintBin); err == nil { + return FormatterPint, true + } + + return "", false +} + +// DetectAnalyser detects which static analyser is available in the project. +func DetectAnalyser(dir string) (AnalyserType, bool) { + // Check for PHPStan config + phpstanConfig := filepath.Join(dir, "phpstan.neon") + phpstanDistConfig := filepath.Join(dir, "phpstan.neon.dist") + + hasConfig := false + if _, err := os.Stat(phpstanConfig); err == nil { + hasConfig = true + } + if _, err := os.Stat(phpstanDistConfig); err == nil { + hasConfig = true + } + + // Check for vendor binary + phpstanBin := filepath.Join(dir, "vendor", "bin", "phpstan") + hasBin := false + if _, err := os.Stat(phpstanBin); err == nil { + hasBin = true + } + + if hasConfig || hasBin { + // Check if it's Larastan (Laravel-specific PHPStan) + larastanPath := filepath.Join(dir, "vendor", "larastan", "larastan") + if _, err := os.Stat(larastanPath); err == nil { + return AnalyserLarastan, true + } + // Also check nunomaduro/larastan + larastanPath2 := filepath.Join(dir, "vendor", "nunomaduro", "larastan") + if _, err := os.Stat(larastanPath2); err == nil { + return AnalyserLarastan, true + } + return AnalyserPHPStan, true + } + + return "", false +} + +// Format runs Laravel Pint to format PHP code. +func Format(ctx context.Context, opts FormatOptions) error { + if opts.Dir == "" { + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get working directory: %w", err) + } + opts.Dir = cwd + } + + if opts.Output == nil { + opts.Output = os.Stdout + } + + // Check if formatter is available + formatter, found := DetectFormatter(opts.Dir) + if !found { + return fmt.Errorf("no formatter found (install Laravel Pint: composer require laravel/pint --dev)") + } + + var cmdName string + var args []string + + switch formatter { + case FormatterPint: + cmdName, args = buildPintCommand(opts) + } + + cmd := exec.CommandContext(ctx, cmdName, args...) + cmd.Dir = opts.Dir + cmd.Stdout = opts.Output + cmd.Stderr = opts.Output + + return cmd.Run() +} + +// Analyse runs PHPStan or Larastan for static analysis. +func Analyse(ctx context.Context, opts AnalyseOptions) error { + if opts.Dir == "" { + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get working directory: %w", err) + } + opts.Dir = cwd + } + + if opts.Output == nil { + opts.Output = os.Stdout + } + + // Check if analyser is available + analyser, found := DetectAnalyser(opts.Dir) + if !found { + return fmt.Errorf("no static analyser found (install PHPStan: composer require phpstan/phpstan --dev)") + } + + var cmdName string + var args []string + + switch analyser { + case AnalyserPHPStan, AnalyserLarastan: + cmdName, args = buildPHPStanCommand(opts) + } + + cmd := exec.CommandContext(ctx, cmdName, args...) + cmd.Dir = opts.Dir + cmd.Stdout = opts.Output + cmd.Stderr = opts.Output + + return cmd.Run() +} + +// buildPintCommand builds the command for running Laravel Pint. +func buildPintCommand(opts FormatOptions) (string, []string) { + // Check for vendor binary first + vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "pint") + cmdName := "pint" + if _, err := os.Stat(vendorBin); err == nil { + cmdName = vendorBin + } + + var args []string + + if !opts.Fix { + args = append(args, "--test") + } + + if opts.Diff { + args = append(args, "--diff") + } + + // Add specific paths if provided + args = append(args, opts.Paths...) + + return cmdName, args +} + +// buildPHPStanCommand builds the command for running PHPStan. +func buildPHPStanCommand(opts AnalyseOptions) (string, []string) { + // Check for vendor binary first + vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "phpstan") + cmdName := "phpstan" + if _, err := os.Stat(vendorBin); err == nil { + cmdName = vendorBin + } + + args := []string{"analyse"} + + if opts.Level > 0 { + args = append(args, "--level", fmt.Sprintf("%d", opts.Level)) + } + + if opts.Memory != "" { + args = append(args, "--memory-limit", opts.Memory) + } + + // Add specific paths if provided + args = append(args, opts.Paths...) + + return cmdName, args +} diff --git a/pkg/php/testing.go b/pkg/php/testing.go new file mode 100644 index 00000000..a6257eba --- /dev/null +++ b/pkg/php/testing.go @@ -0,0 +1,175 @@ +package php + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" +) + +// TestOptions configures PHP test execution. +type TestOptions struct { + // Dir is the project directory (defaults to current working directory). + Dir string + + // Filter filters tests by name pattern. + Filter string + + // Parallel runs tests in parallel. + Parallel bool + + // Coverage generates code coverage. + Coverage bool + + // CoverageFormat is the coverage output format (text, html, clover). + CoverageFormat string + + // Groups runs only tests in the specified groups. + Groups []string + + // Output is the writer for test output (defaults to os.Stdout). + Output io.Writer +} + +// TestRunner represents the detected test runner. +type TestRunner string + +const ( + TestRunnerPest TestRunner = "pest" + TestRunnerPHPUnit TestRunner = "phpunit" +) + +// DetectTestRunner detects which test runner is available in the project. +// Returns Pest if tests/Pest.php exists, otherwise PHPUnit. +func DetectTestRunner(dir string) TestRunner { + // Check for Pest + pestFile := filepath.Join(dir, "tests", "Pest.php") + if _, err := os.Stat(pestFile); err == nil { + return TestRunnerPest + } + + return TestRunnerPHPUnit +} + +// RunTests runs PHPUnit or Pest tests. +func RunTests(ctx context.Context, opts TestOptions) error { + if opts.Dir == "" { + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get working directory: %w", err) + } + opts.Dir = cwd + } + + if opts.Output == nil { + opts.Output = os.Stdout + } + + // Detect test runner + runner := DetectTestRunner(opts.Dir) + + // Build command based on runner + var cmdName string + var args []string + + switch runner { + case TestRunnerPest: + cmdName, args = buildPestCommand(opts) + default: + cmdName, args = buildPHPUnitCommand(opts) + } + + cmd := exec.CommandContext(ctx, cmdName, args...) + cmd.Dir = opts.Dir + cmd.Stdout = opts.Output + cmd.Stderr = opts.Output + cmd.Stdin = os.Stdin + + return cmd.Run() +} + +// RunParallel runs tests in parallel using the appropriate runner. +func RunParallel(ctx context.Context, opts TestOptions) error { + opts.Parallel = true + return RunTests(ctx, opts) +} + +// buildPestCommand builds the command for running Pest tests. +func buildPestCommand(opts TestOptions) (string, []string) { + // Check for vendor binary first + vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "pest") + cmdName := "pest" + if _, err := os.Stat(vendorBin); err == nil { + cmdName = vendorBin + } + + var args []string + + if opts.Filter != "" { + args = append(args, "--filter", opts.Filter) + } + + if opts.Parallel { + args = append(args, "--parallel") + } + + if opts.Coverage { + switch opts.CoverageFormat { + case "html": + args = append(args, "--coverage-html", "coverage") + case "clover": + args = append(args, "--coverage-clover", "coverage.xml") + default: + args = append(args, "--coverage") + } + } + + for _, group := range opts.Groups { + args = append(args, "--group", group) + } + + return cmdName, args +} + +// buildPHPUnitCommand builds the command for running PHPUnit tests. +func buildPHPUnitCommand(opts TestOptions) (string, []string) { + // Check for vendor binary first + vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "phpunit") + cmdName := "phpunit" + if _, err := os.Stat(vendorBin); err == nil { + cmdName = vendorBin + } + + var args []string + + if opts.Filter != "" { + args = append(args, "--filter", opts.Filter) + } + + if opts.Parallel { + // PHPUnit uses paratest for parallel execution + paratestBin := filepath.Join(opts.Dir, "vendor", "bin", "paratest") + if _, err := os.Stat(paratestBin); err == nil { + cmdName = paratestBin + } + } + + if opts.Coverage { + switch opts.CoverageFormat { + case "html": + args = append(args, "--coverage-html", "coverage") + case "clover": + args = append(args, "--coverage-clover", "coverage.xml") + default: + args = append(args, "--coverage-text") + } + } + + for _, group := range opts.Groups { + args = append(args, "--group", group) + } + + return cmdName, args +}