Skip to content

Commit

Permalink
[auto] Add php detector (#2390)
Browse files Browse the repository at this point in the history
## Summary

Add php detector

## How was it tested?

- [x] unit tests
- [ ] devbox init --auto
  • Loading branch information
mikeland73 authored Oct 29, 2024
1 parent 8384357 commit 1f4db4a
Show file tree
Hide file tree
Showing 4 changed files with 316 additions and 6 deletions.
12 changes: 10 additions & 2 deletions pkg/autodetect/autodetect.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
}
}

Expand All @@ -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
Expand Down
6 changes: 2 additions & 4 deletions pkg/autodetect/detector/go.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
111 changes: 111 additions & 0 deletions pkg/autodetect/detector/php.go
Original file line number Diff line number Diff line change
@@ -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
}
193 changes: 193 additions & 0 deletions pkg/autodetect/detector/php_test.go
Original file line number Diff line number Diff line change
@@ -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: "[email protected]",
},
{
name: "php version with patch",
fs: fstest.MapFS{
"composer.json": &fstest.MapFile{
Data: []byte(`{
"require": {
"php": "^8.1.2"
}
}`),
},
},
expectedPHP: "[email protected]",
},
{
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)
})
}
}

0 comments on commit 1f4db4a

Please sign in to comment.