From f8785c8c0fadb8e99e66b637e843819af9bf8c6d Mon Sep 17 00:00:00 2001 From: Mike Landau Date: Mon, 28 Oct 2024 14:48:15 -0700 Subject: [PATCH 1/2] [auto] Add php detector --- pkg/autodetect/autodetect.go | 12 +- pkg/autodetect/detector/go.go | 6 +- pkg/autodetect/detector/php.go | 92 ++++++++++++++++ pkg/autodetect/detector/php_test.go | 164 ++++++++++++++++++++++++++++ 4 files changed, 268 insertions(+), 6 deletions(-) create mode 100644 pkg/autodetect/detector/php.go create mode 100644 pkg/autodetect/detector/php_test.go 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..54eb92854e9 --- /dev/null +++ b/pkg/autodetect/detector/php.go @@ -0,0 +1,92 @@ +package detector + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" +) + +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 1, nil + } + return 0, nil +} + +func (d *PHPDetector) Packages(ctx context.Context) ([]string, error) { + version, err := d.phpVersion() + if err != nil { + return nil, err + } + return []string{fmt.Sprintf("php@%s", version)}, nil +} + +func (d *PHPDetector) phpVersion() (string, error) { + require := d.composerJSON.Require + + if require["php"] == "" { + return "latest", nil + } + // 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) + if match == "" { + return "latest", nil + } + + version = match + + return version, nil +} + +func (d *PHPDetector) phpExtensions() ([]string, error) { + extensions := []string{} + for key := range d.composerJSON.Require { + if strings.HasPrefix(key, "ext-") { + extensions = append(extensions, "phpExtensions."+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..3eacb3d6794 --- /dev/null +++ b/pkg/autodetect/detector/php_test.go @@ -0,0 +1,164 @@ +package detector + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPHPDetector_Relevance(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T) string + expected float64 + }{ + { + name: "no composer.json", + setup: func(t *testing.T) string { + dir := t.TempDir() + return dir + }, + expected: 1, + }, + { + name: "with composer.json", + setup: func(t *testing.T) string { + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(`{ + "require": { + "php": "^8.1" + } + }`), 0644) + require.NoError(t, err) + return dir + }, + expected: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := tt.setup(t) + d := &PHPDetector{Root: dir} + err := d.Init() + require.NoError(t, err) + + score, err := d.Relevance(dir) + require.NoError(t, err) + assert.Equal(t, tt.expected, score) + }) + } +} + +func TestPHPDetector_Packages(t *testing.T) { + tests := []struct { + name string + composerJSON string + expectedPHP string + expectedError bool + }{ + { + name: "no php version specified", + composerJSON: `{ + "require": {} + }`, + expectedPHP: "php@latest", + }, + { + name: "specific php version", + composerJSON: `{ + "require": { + "php": "^8.1" + } + }`, + expectedPHP: "php@8.1", + }, + { + name: "php version with patch", + composerJSON: `{ + "require": { + "php": "^8.1.2" + } + }`, + expectedPHP: "php@8.1.2", + }, + { + name: "invalid composer.json", + composerJSON: `invalid json`, + expectedError: true, + }, + } + + for _, curTest := range tests { + t.Run(curTest.name, func(t *testing.T) { + dir := t.TempDir() + if curTest.composerJSON != "" { + err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(curTest.composerJSON), 0644) + 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 + composerJSON string + expectedExtensions []string + }{ + { + name: "no extensions", + composerJSON: `{ + "require": { + "php": "^8.1" + } + }`, + expectedExtensions: []string{}, + }, + { + name: "multiple extensions", + composerJSON: `{ + "require": { + "ext-mbstring": "*", + "ext-imagick": "*" + } + }`, + expectedExtensions: []string{ + "phpExtensions.mbstring", + "phpExtensions.imagick", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(tt.composerJSON), 0644) + require.NoError(t, err) + + d := &PHPDetector{Root: dir} + err = d.Init() + require.NoError(t, err) + + extensions, err := d.phpExtensions() + require.NoError(t, err) + assert.ElementsMatch(t, tt.expectedExtensions, extensions) + }) + } +} From b6ad8f4c088c5abe93bb9e37b0233eec5466d2e2 Mon Sep 17 00:00:00 2001 From: Mike Landau Date: Mon, 28 Oct 2024 17:23:58 -0700 Subject: [PATCH 2/2] Fixes --- pkg/autodetect/detector/php.go | 47 ++++++--- pkg/autodetect/detector/php_test.go | 149 +++++++++++++++++----------- 2 files changed, 122 insertions(+), 74 deletions(-) diff --git a/pkg/autodetect/detector/php.go b/pkg/autodetect/detector/php.go index 54eb92854e9..ed01079f50e 100644 --- a/pkg/autodetect/detector/php.go +++ b/pkg/autodetect/detector/php.go @@ -8,6 +8,8 @@ import ( "path/filepath" "regexp" "strings" + + "go.jetpack.io/devbox/internal/searcher" ) type composerJSON struct { @@ -32,24 +34,26 @@ func (d *PHPDetector) Init() error { func (d *PHPDetector) Relevance(path string) (float64, error) { if d.composerJSON == nil { - return 1, nil + return 0, nil } - return 0, nil + return 1, nil } func (d *PHPDetector) Packages(ctx context.Context) ([]string, error) { - version, err := d.phpVersion() + packages := []string{fmt.Sprintf("php@%s", d.phpVersion(ctx))} + extensions, err := d.phpExtensions(ctx) if err != nil { return nil, err } - return []string{fmt.Sprintf("php@%s", version)}, nil + packages = append(packages, extensions...) + return packages, nil } -func (d *PHPDetector) phpVersion() (string, error) { +func (d *PHPDetector) phpVersion(ctx context.Context) string { require := d.composerJSON.Require if require["php"] == "" { - return "latest", nil + return "latest" } // Remove the caret (^) if present version := strings.TrimPrefix(require["php"], "^") @@ -57,20 +61,35 @@ func (d *PHPDetector) phpVersion() (string, error) { // Extract version in the format x, x.y, or x.y.z re := regexp.MustCompile(`^(\d+(\.\d+){0,2})`) match := re.FindString(version) - if match == "" { - return "latest", nil - } - - version = match - return version, nil + return determineBestVersion(ctx, "php", match) } -func (d *PHPDetector) phpExtensions() ([]string, error) { +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-") { - extensions = append(extensions, "phpExtensions."+strings.TrimPrefix(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-")), + ) } } diff --git a/pkg/autodetect/detector/php_test.go b/pkg/autodetect/detector/php_test.go index 3eacb3d6794..1adafd3893d 100644 --- a/pkg/autodetect/detector/php_test.go +++ b/pkg/autodetect/detector/php_test.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "testing" + "testing/fstest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -13,43 +14,44 @@ import ( func TestPHPDetector_Relevance(t *testing.T) { tests := []struct { name string - setup func(t *testing.T) string + fs fstest.MapFS expected float64 }{ { - name: "no composer.json", - setup: func(t *testing.T) string { - dir := t.TempDir() - return dir - }, - expected: 1, + name: "no composer.json", + fs: fstest.MapFS{}, + expected: 0, }, { name: "with composer.json", - setup: func(t *testing.T) string { - dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(`{ - "require": { - "php": "^8.1" - } - }`), 0644) - require.NoError(t, err) - return dir + fs: fstest.MapFS{ + "composer.json": &fstest.MapFile{ + Data: []byte(`{ + "require": { + "php": "^8.1" + } + }`), + }, }, - expected: 0, + expected: 1, }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - dir := tt.setup(t) + 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, tt.expected, score) + assert.Equal(t, curTest.expected, score) }) } } @@ -57,38 +59,54 @@ func TestPHPDetector_Relevance(t *testing.T) { func TestPHPDetector_Packages(t *testing.T) { tests := []struct { name string - composerJSON string + fs fstest.MapFS expectedPHP string expectedError bool }{ { name: "no php version specified", - composerJSON: `{ - "require": {} - }`, + fs: fstest.MapFS{ + "composer.json": &fstest.MapFile{ + Data: []byte(`{ + "require": {} + }`), + }, + }, expectedPHP: "php@latest", }, { name: "specific php version", - composerJSON: `{ - "require": { - "php": "^8.1" - } - }`, + fs: fstest.MapFS{ + "composer.json": &fstest.MapFile{ + Data: []byte(`{ + "require": { + "php": "^8.1" + } + }`), + }, + }, expectedPHP: "php@8.1", }, { name: "php version with patch", - composerJSON: `{ - "require": { - "php": "^8.1.2" - } - }`, + fs: fstest.MapFS{ + "composer.json": &fstest.MapFile{ + Data: []byte(`{ + "require": { + "php": "^8.1.2" + } + }`), + }, + }, expectedPHP: "php@8.1.2", }, { - name: "invalid composer.json", - composerJSON: `invalid json`, + name: "invalid composer.json", + fs: fstest.MapFS{ + "composer.json": &fstest.MapFile{ + Data: []byte(`invalid json`), + }, + }, expectedError: true, }, } @@ -96,8 +114,8 @@ func TestPHPDetector_Packages(t *testing.T) { for _, curTest := range tests { t.Run(curTest.name, func(t *testing.T) { dir := t.TempDir() - if curTest.composerJSON != "" { - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(curTest.composerJSON), 0644) + for name, file := range curTest.fs { + err := os.WriteFile(filepath.Join(dir, name), file.Data, 0o644) require.NoError(t, err) } @@ -119,46 +137,57 @@ func TestPHPDetector_Packages(t *testing.T) { func TestPHPDetector_PHPExtensions(t *testing.T) { tests := []struct { name string - composerJSON string + fs fstest.MapFS expectedExtensions []string }{ { name: "no extensions", - composerJSON: `{ - "require": { - "php": "^8.1" - } - }`, + fs: fstest.MapFS{ + "composer.json": &fstest.MapFile{ + Data: []byte(`{ + "require": { + "php": "^8.1" + } + }`), + }, + }, expectedExtensions: []string{}, }, { name: "multiple extensions", - composerJSON: `{ - "require": { - "ext-mbstring": "*", - "ext-imagick": "*" - } - }`, + fs: fstest.MapFS{ + "composer.json": &fstest.MapFile{ + Data: []byte(`{ + "require": { + "php": "^8.1", + "ext-mbstring": "*", + "ext-imagick": "*" + } + }`), + }, + }, expectedExtensions: []string{ - "phpExtensions.mbstring", - "phpExtensions.imagick", + "php81Extensions.mbstring@latest", + "php81Extensions.imagick@latest", }, }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { + for _, curTest := range tests { + t.Run(curTest.name, func(t *testing.T) { dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(tt.composerJSON), 0644) - require.NoError(t, err) + 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() + err := d.Init() require.NoError(t, err) - extensions, err := d.phpExtensions() + extensions, err := d.phpExtensions(context.Background()) require.NoError(t, err) - assert.ElementsMatch(t, tt.expectedExtensions, extensions) + assert.ElementsMatch(t, curTest.expectedExtensions, extensions) }) } }