Skip to content

Commit 1f4db4a

Browse files
authored
[auto] Add php detector (#2390)
## Summary Add php detector ## How was it tested? - [x] unit tests - [ ] devbox init --auto
1 parent 8384357 commit 1f4db4a

File tree

4 files changed

+316
-6
lines changed

4 files changed

+316
-6
lines changed

pkg/autodetect/autodetect.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,10 @@ func populateConfig(ctx context.Context, path string, config *devconfig.Config)
4141

4242
func detectors(path string) []detector.Detector {
4343
return []detector.Detector{
44-
&detector.PythonDetector{Root: path},
45-
&detector.PoetryDetector{Root: path},
4644
&detector.GoDetector{Root: path},
45+
&detector.PHPDetector{Root: path},
46+
&detector.PoetryDetector{Root: path},
47+
&detector.PythonDetector{Root: path},
4748
}
4849
}
4950

@@ -62,6 +63,13 @@ func relevantDetector(path string) (detector.Detector, error) {
6263
relevantScore := 0.0
6364
var mostRelevantDetector detector.Detector
6465
for _, detector := range detectors(path) {
66+
if d, ok := detector.(interface {
67+
Init() error
68+
}); ok {
69+
if err := d.Init(); err != nil {
70+
return nil, err
71+
}
72+
}
6573
score, err := detector.Relevance(path)
6674
if err != nil {
6775
return nil, err

pkg/autodetect/detector/go.go

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,8 @@ func (d *GoDetector) Packages(ctx context.Context) ([]string, error) {
3434

3535
// Parse the Go version from go.mod
3636
goVersion := parseGoVersion(string(goModContent))
37-
if goVersion != "" {
38-
return []string{"go@" + goVersion}, nil
39-
}
40-
return []string{"go@latest"}, nil
37+
goVersion = determineBestVersion(ctx, "go", goVersion)
38+
return []string{"go@" + goVersion}, nil
4139
}
4240

4341
func parseGoVersion(goModContent string) string {

pkg/autodetect/detector/php.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
package detector
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"os"
8+
"path/filepath"
9+
"regexp"
10+
"strings"
11+
12+
"go.jetpack.io/devbox/internal/searcher"
13+
)
14+
15+
type composerJSON struct {
16+
Require map[string]string `json:"require"`
17+
}
18+
19+
type PHPDetector struct {
20+
Root string
21+
composerJSON *composerJSON
22+
}
23+
24+
var _ Detector = &PHPDetector{}
25+
26+
func (d *PHPDetector) Init() error {
27+
composer, err := loadComposerJSON(d.Root)
28+
if err != nil && !os.IsNotExist(err) {
29+
return err
30+
}
31+
d.composerJSON = composer
32+
return nil
33+
}
34+
35+
func (d *PHPDetector) Relevance(path string) (float64, error) {
36+
if d.composerJSON == nil {
37+
return 0, nil
38+
}
39+
return 1, nil
40+
}
41+
42+
func (d *PHPDetector) Packages(ctx context.Context) ([]string, error) {
43+
packages := []string{fmt.Sprintf("php@%s", d.phpVersion(ctx))}
44+
extensions, err := d.phpExtensions(ctx)
45+
if err != nil {
46+
return nil, err
47+
}
48+
packages = append(packages, extensions...)
49+
return packages, nil
50+
}
51+
52+
func (d *PHPDetector) phpVersion(ctx context.Context) string {
53+
require := d.composerJSON.Require
54+
55+
if require["php"] == "" {
56+
return "latest"
57+
}
58+
// Remove the caret (^) if present
59+
version := strings.TrimPrefix(require["php"], "^")
60+
61+
// Extract version in the format x, x.y, or x.y.z
62+
re := regexp.MustCompile(`^(\d+(\.\d+){0,2})`)
63+
match := re.FindString(version)
64+
65+
return determineBestVersion(ctx, "php", match)
66+
}
67+
68+
func (d *PHPDetector) phpExtensions(ctx context.Context) ([]string, error) {
69+
resolved, err := searcher.Client().ResolveV2(ctx, "php", d.phpVersion(ctx))
70+
if err != nil {
71+
return nil, err
72+
}
73+
74+
// extract major-minor from resolved.Version
75+
re := regexp.MustCompile(`^(\d+)\.(\d+)`)
76+
matches := re.FindStringSubmatch(resolved.Version)
77+
if len(matches) < 3 {
78+
return nil, fmt.Errorf("could not parse PHP version: %s", resolved.Version)
79+
}
80+
majorMinor := matches[1] + matches[2]
81+
82+
extensions := []string{}
83+
for key := range d.composerJSON.Require {
84+
if strings.HasPrefix(key, "ext-") {
85+
// The way nix versions php extensions is inconsistent. Sometimes the version is the PHP
86+
// version, sometimes it's the extension version. We just use @latest everywhere which in
87+
// practice will just use the version of the extension that exists in the same nixpkgs as
88+
// the php version.
89+
extensions = append(
90+
extensions,
91+
fmt.Sprintf("php%sExtensions.%s@latest", majorMinor, strings.TrimPrefix(key, "ext-")),
92+
)
93+
}
94+
}
95+
96+
return extensions, nil
97+
}
98+
99+
func loadComposerJSON(root string) (*composerJSON, error) {
100+
composerPath := filepath.Join(root, "composer.json")
101+
composerData, err := os.ReadFile(composerPath)
102+
if err != nil {
103+
return nil, err
104+
}
105+
var composer composerJSON
106+
err = json.Unmarshal(composerData, &composer)
107+
if err != nil {
108+
return nil, err
109+
}
110+
return &composer, nil
111+
}
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
package detector
2+
3+
import (
4+
"context"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
"testing/fstest"
9+
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
func TestPHPDetector_Relevance(t *testing.T) {
15+
tests := []struct {
16+
name string
17+
fs fstest.MapFS
18+
expected float64
19+
}{
20+
{
21+
name: "no composer.json",
22+
fs: fstest.MapFS{},
23+
expected: 0,
24+
},
25+
{
26+
name: "with composer.json",
27+
fs: fstest.MapFS{
28+
"composer.json": &fstest.MapFile{
29+
Data: []byte(`{
30+
"require": {
31+
"php": "^8.1"
32+
}
33+
}`),
34+
},
35+
},
36+
expected: 1,
37+
},
38+
}
39+
40+
for _, curTest := range tests {
41+
t.Run(curTest.name, func(t *testing.T) {
42+
dir := t.TempDir()
43+
for name, file := range curTest.fs {
44+
err := os.WriteFile(filepath.Join(dir, name), file.Data, 0o644)
45+
require.NoError(t, err)
46+
}
47+
48+
d := &PHPDetector{Root: dir}
49+
err := d.Init()
50+
require.NoError(t, err)
51+
52+
score, err := d.Relevance(dir)
53+
require.NoError(t, err)
54+
assert.Equal(t, curTest.expected, score)
55+
})
56+
}
57+
}
58+
59+
func TestPHPDetector_Packages(t *testing.T) {
60+
tests := []struct {
61+
name string
62+
fs fstest.MapFS
63+
expectedPHP string
64+
expectedError bool
65+
}{
66+
{
67+
name: "no php version specified",
68+
fs: fstest.MapFS{
69+
"composer.json": &fstest.MapFile{
70+
Data: []byte(`{
71+
"require": {}
72+
}`),
73+
},
74+
},
75+
expectedPHP: "php@latest",
76+
},
77+
{
78+
name: "specific php version",
79+
fs: fstest.MapFS{
80+
"composer.json": &fstest.MapFile{
81+
Data: []byte(`{
82+
"require": {
83+
"php": "^8.1"
84+
}
85+
}`),
86+
},
87+
},
88+
expectedPHP: "[email protected]",
89+
},
90+
{
91+
name: "php version with patch",
92+
fs: fstest.MapFS{
93+
"composer.json": &fstest.MapFile{
94+
Data: []byte(`{
95+
"require": {
96+
"php": "^8.1.2"
97+
}
98+
}`),
99+
},
100+
},
101+
expectedPHP: "[email protected]",
102+
},
103+
{
104+
name: "invalid composer.json",
105+
fs: fstest.MapFS{
106+
"composer.json": &fstest.MapFile{
107+
Data: []byte(`invalid json`),
108+
},
109+
},
110+
expectedError: true,
111+
},
112+
}
113+
114+
for _, curTest := range tests {
115+
t.Run(curTest.name, func(t *testing.T) {
116+
dir := t.TempDir()
117+
for name, file := range curTest.fs {
118+
err := os.WriteFile(filepath.Join(dir, name), file.Data, 0o644)
119+
require.NoError(t, err)
120+
}
121+
122+
d := &PHPDetector{Root: dir}
123+
err := d.Init()
124+
if curTest.expectedError {
125+
require.Error(t, err)
126+
return
127+
}
128+
require.NoError(t, err)
129+
130+
packages, err := d.Packages(context.Background())
131+
require.NoError(t, err)
132+
assert.Equal(t, []string{curTest.expectedPHP}, packages)
133+
})
134+
}
135+
}
136+
137+
func TestPHPDetector_PHPExtensions(t *testing.T) {
138+
tests := []struct {
139+
name string
140+
fs fstest.MapFS
141+
expectedExtensions []string
142+
}{
143+
{
144+
name: "no extensions",
145+
fs: fstest.MapFS{
146+
"composer.json": &fstest.MapFile{
147+
Data: []byte(`{
148+
"require": {
149+
"php": "^8.1"
150+
}
151+
}`),
152+
},
153+
},
154+
expectedExtensions: []string{},
155+
},
156+
{
157+
name: "multiple extensions",
158+
fs: fstest.MapFS{
159+
"composer.json": &fstest.MapFile{
160+
Data: []byte(`{
161+
"require": {
162+
"php": "^8.1",
163+
"ext-mbstring": "*",
164+
"ext-imagick": "*"
165+
}
166+
}`),
167+
},
168+
},
169+
expectedExtensions: []string{
170+
"php81Extensions.mbstring@latest",
171+
"php81Extensions.imagick@latest",
172+
},
173+
},
174+
}
175+
176+
for _, curTest := range tests {
177+
t.Run(curTest.name, func(t *testing.T) {
178+
dir := t.TempDir()
179+
for name, file := range curTest.fs {
180+
err := os.WriteFile(filepath.Join(dir, name), file.Data, 0o644)
181+
require.NoError(t, err)
182+
}
183+
184+
d := &PHPDetector{Root: dir}
185+
err := d.Init()
186+
require.NoError(t, err)
187+
188+
extensions, err := d.phpExtensions(context.Background())
189+
require.NoError(t, err)
190+
assert.ElementsMatch(t, curTest.expectedExtensions, extensions)
191+
})
192+
}
193+
}

0 commit comments

Comments
 (0)