diff --git a/pkg/autodetect/autodetect.go b/pkg/autodetect/autodetect.go index f5ad8988e2f..038fd86528c 100644 --- a/pkg/autodetect/autodetect.go +++ b/pkg/autodetect/autodetect.go @@ -41,9 +41,10 @@ func populateConfig(ctx context.Context, path string, config *devconfig.Config) func detectors(path string) []detector.Detector { return []detector.Detector{ - &detector.PythonDetector{Root: path}, - &detector.PoetryDetector{Root: path}, &detector.GoDetector{Root: path}, + &detector.PHPDetector{Root: path}, + &detector.PoetryDetector{Root: path}, + &detector.PythonDetector{Root: path}, } } @@ -62,6 +63,13 @@ func relevantDetector(path string) (detector.Detector, error) { relevantScore := 0.0 var mostRelevantDetector detector.Detector for _, detector := range detectors(path) { + if d, ok := detector.(interface { + Init() error + }); ok { + if err := d.Init(); err != nil { + return nil, err + } + } score, err := detector.Relevance(path) if err != nil { return nil, err diff --git a/pkg/autodetect/detector/go.go b/pkg/autodetect/detector/go.go index 0fa938db4da..d7c44f007ca 100644 --- a/pkg/autodetect/detector/go.go +++ b/pkg/autodetect/detector/go.go @@ -34,10 +34,8 @@ func (d *GoDetector) Packages(ctx context.Context) ([]string, error) { // Parse the Go version from go.mod goVersion := parseGoVersion(string(goModContent)) - if goVersion != "" { - return []string{"go@" + goVersion}, nil - } - return []string{"go@latest"}, nil + goVersion = determineBestVersion(ctx, "go", goVersion) + return []string{"go@" + goVersion}, nil } func parseGoVersion(goModContent string) string { diff --git a/pkg/autodetect/detector/php.go b/pkg/autodetect/detector/php.go new file mode 100644 index 00000000000..ed01079f50e --- /dev/null +++ b/pkg/autodetect/detector/php.go @@ -0,0 +1,111 @@ +package detector + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "go.jetpack.io/devbox/internal/searcher" +) + +type composerJSON struct { + Require map[string]string `json:"require"` +} + +type PHPDetector struct { + Root string + composerJSON *composerJSON +} + +var _ Detector = &PHPDetector{} + +func (d *PHPDetector) Init() error { + composer, err := loadComposerJSON(d.Root) + if err != nil && !os.IsNotExist(err) { + return err + } + d.composerJSON = composer + return nil +} + +func (d *PHPDetector) Relevance(path string) (float64, error) { + if d.composerJSON == nil { + return 0, nil + } + return 1, nil +} + +func (d *PHPDetector) Packages(ctx context.Context) ([]string, error) { + packages := []string{fmt.Sprintf("php@%s", d.phpVersion(ctx))} + extensions, err := d.phpExtensions(ctx) + if err != nil { + return nil, err + } + packages = append(packages, extensions...) + return packages, nil +} + +func (d *PHPDetector) phpVersion(ctx context.Context) string { + require := d.composerJSON.Require + + if require["php"] == "" { + return "latest" + } + // Remove the caret (^) if present + version := strings.TrimPrefix(require["php"], "^") + + // Extract version in the format x, x.y, or x.y.z + re := regexp.MustCompile(`^(\d+(\.\d+){0,2})`) + match := re.FindString(version) + + return determineBestVersion(ctx, "php", match) +} + +func (d *PHPDetector) phpExtensions(ctx context.Context) ([]string, error) { + resolved, err := searcher.Client().ResolveV2(ctx, "php", d.phpVersion(ctx)) + if err != nil { + return nil, err + } + + // extract major-minor from resolved.Version + re := regexp.MustCompile(`^(\d+)\.(\d+)`) + matches := re.FindStringSubmatch(resolved.Version) + if len(matches) < 3 { + return nil, fmt.Errorf("could not parse PHP version: %s", resolved.Version) + } + majorMinor := matches[1] + matches[2] + + extensions := []string{} + for key := range d.composerJSON.Require { + if strings.HasPrefix(key, "ext-") { + // The way nix versions php extensions is inconsistent. Sometimes the version is the PHP + // version, sometimes it's the extension version. We just use @latest everywhere which in + // practice will just use the version of the extension that exists in the same nixpkgs as + // the php version. + extensions = append( + extensions, + fmt.Sprintf("php%sExtensions.%s@latest", majorMinor, strings.TrimPrefix(key, "ext-")), + ) + } + } + + return extensions, nil +} + +func loadComposerJSON(root string) (*composerJSON, error) { + composerPath := filepath.Join(root, "composer.json") + composerData, err := os.ReadFile(composerPath) + if err != nil { + return nil, err + } + var composer composerJSON + err = json.Unmarshal(composerData, &composer) + if err != nil { + return nil, err + } + return &composer, nil +} diff --git a/pkg/autodetect/detector/php_test.go b/pkg/autodetect/detector/php_test.go new file mode 100644 index 00000000000..1adafd3893d --- /dev/null +++ b/pkg/autodetect/detector/php_test.go @@ -0,0 +1,193 @@ +package detector + +import ( + "context" + "os" + "path/filepath" + "testing" + "testing/fstest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPHPDetector_Relevance(t *testing.T) { + tests := []struct { + name string + fs fstest.MapFS + expected float64 + }{ + { + name: "no composer.json", + fs: fstest.MapFS{}, + expected: 0, + }, + { + name: "with composer.json", + fs: fstest.MapFS{ + "composer.json": &fstest.MapFile{ + Data: []byte(`{ + "require": { + "php": "^8.1" + } + }`), + }, + }, + expected: 1, + }, + } + + for _, curTest := range tests { + t.Run(curTest.name, func(t *testing.T) { + dir := t.TempDir() + for name, file := range curTest.fs { + err := os.WriteFile(filepath.Join(dir, name), file.Data, 0o644) + require.NoError(t, err) + } + + d := &PHPDetector{Root: dir} + err := d.Init() + require.NoError(t, err) + + score, err := d.Relevance(dir) + require.NoError(t, err) + assert.Equal(t, curTest.expected, score) + }) + } +} + +func TestPHPDetector_Packages(t *testing.T) { + tests := []struct { + name string + fs fstest.MapFS + expectedPHP string + expectedError bool + }{ + { + name: "no php version specified", + fs: fstest.MapFS{ + "composer.json": &fstest.MapFile{ + Data: []byte(`{ + "require": {} + }`), + }, + }, + expectedPHP: "php@latest", + }, + { + name: "specific php version", + fs: fstest.MapFS{ + "composer.json": &fstest.MapFile{ + Data: []byte(`{ + "require": { + "php": "^8.1" + } + }`), + }, + }, + expectedPHP: "php@8.1", + }, + { + name: "php version with patch", + fs: fstest.MapFS{ + "composer.json": &fstest.MapFile{ + Data: []byte(`{ + "require": { + "php": "^8.1.2" + } + }`), + }, + }, + expectedPHP: "php@8.1.2", + }, + { + name: "invalid composer.json", + fs: fstest.MapFS{ + "composer.json": &fstest.MapFile{ + Data: []byte(`invalid json`), + }, + }, + expectedError: true, + }, + } + + for _, curTest := range tests { + t.Run(curTest.name, func(t *testing.T) { + dir := t.TempDir() + for name, file := range curTest.fs { + err := os.WriteFile(filepath.Join(dir, name), file.Data, 0o644) + require.NoError(t, err) + } + + d := &PHPDetector{Root: dir} + err := d.Init() + if curTest.expectedError { + require.Error(t, err) + return + } + require.NoError(t, err) + + packages, err := d.Packages(context.Background()) + require.NoError(t, err) + assert.Equal(t, []string{curTest.expectedPHP}, packages) + }) + } +} + +func TestPHPDetector_PHPExtensions(t *testing.T) { + tests := []struct { + name string + fs fstest.MapFS + expectedExtensions []string + }{ + { + name: "no extensions", + fs: fstest.MapFS{ + "composer.json": &fstest.MapFile{ + Data: []byte(`{ + "require": { + "php": "^8.1" + } + }`), + }, + }, + expectedExtensions: []string{}, + }, + { + name: "multiple extensions", + fs: fstest.MapFS{ + "composer.json": &fstest.MapFile{ + Data: []byte(`{ + "require": { + "php": "^8.1", + "ext-mbstring": "*", + "ext-imagick": "*" + } + }`), + }, + }, + expectedExtensions: []string{ + "php81Extensions.mbstring@latest", + "php81Extensions.imagick@latest", + }, + }, + } + + for _, curTest := range tests { + t.Run(curTest.name, func(t *testing.T) { + dir := t.TempDir() + for name, file := range curTest.fs { + err := os.WriteFile(filepath.Join(dir, name), file.Data, 0o644) + require.NoError(t, err) + } + + d := &PHPDetector{Root: dir} + err := d.Init() + require.NoError(t, err) + + extensions, err := d.phpExtensions(context.Background()) + require.NoError(t, err) + assert.ElementsMatch(t, curTest.expectedExtensions, extensions) + }) + } +}