Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions internal/cli/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ package cli
import (
"context"
"fmt"
"io"
"time"

"github.com/php-debugger/installer/internal/php"
"github.com/php-debugger/installer/internal/platform"
"github.com/php-debugger/installer/internal/release"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -90,5 +92,33 @@ func runResolve(cmd *cobra.Command, opts *resolveOptions) error {
if asset.Size > 0 {
fmt.Fprintf(out, "size: %.1f MiB\n", float64(asset.Size)/(1024*1024))
}

printExistingPHP(ctx, out)
return nil
}

// printExistingPHP prints facts about the php interpreter currently on PATH, for
// diagnostics. It never fails the command.
func printExistingPHP(ctx context.Context, out io.Writer) {
path, err := php.Detect()
if err != nil {
fmt.Fprintf(out, "existing: none on PATH\n")
return
}
info, err := php.Query(ctx, path)
if err != nil {
fmt.Fprintf(out, "existing: %s (could not query: %v)\n", path, err)
return
}
loaded := info.Ini.LoadedFile
if loaded == "" {
loaded = "(none)"
}
fmt.Fprintf(out, "existing: %s\n", info.Path)
fmt.Fprintf(out, " version: %s (series %s, zts=%t)\n", info.Version, info.Series, info.ZTS)
fmt.Fprintf(out, " loaded ini: %s\n", loaded)
fmt.Fprintf(out, " extension_dir: %s\n", info.ExtensionDir)
if len(info.Ini.AdditionalFiles) > 0 {
fmt.Fprintf(out, " extra ini: %d file(s)\n", len(info.Ini.AdditionalFiles))
}
}
113 changes: 113 additions & 0 deletions internal/php/parse.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package php

import "strings"

// IniPaths captures the ini file locations reported by `php --ini`.
type IniPaths struct {
// LoadedFile is the main php.ini actually loaded ("" if none).
LoadedFile string
// ScanDir is the directory scanned for additional .ini files ("" if none).
ScanDir string
// AdditionalFiles are the extra .ini files parsed, in order.
AdditionalFiles []string
}

// parseKeyVals parses simple "key=value" lines (as emitted by our -r info
// script) into a map. Lines without '=' are ignored.
func parseKeyVals(out string) map[string]string {
m := map[string]string{}
for _, ln := range strings.Split(out, "\n") {
eq := strings.IndexByte(ln, '=')
if eq < 0 {
continue
}
key := strings.TrimSpace(ln[:eq])
if key == "" {
continue
}
m[key] = strings.TrimSpace(ln[eq+1:])
}
return m
}

// parseIniOutput parses the output of `php --ini`.
func parseIniOutput(out string) IniPaths {
var p IniPaths
lines := strings.Split(out, "\n")
for i, ln := range lines {
switch {
case strings.HasPrefix(ln, "Loaded Configuration File:"):
p.LoadedFile = cleanIniValue(afterColon(ln))
case strings.HasPrefix(ln, "Scan for additional .ini files in:"):
p.ScanDir = cleanIniValue(afterColon(ln))
case strings.HasPrefix(ln, "Additional .ini files parsed:"):
// The value can wrap across subsequent indented lines; this is the
// last labelled section, so absorb everything that follows.
rest := afterColon(ln)
for _, cont := range lines[i+1:] {
rest += " " + cont
}
p.AdditionalFiles = parseFileList(rest)
}
}
return p
}

// parseModules parses the output of `php -m` into a list of module names,
// skipping section headers ("[PHP Modules]", "[Zend Modules]") and blanks.
func parseModules(out string) []string {
var mods []string
for _, ln := range strings.Split(out, "\n") {
ln = strings.TrimSpace(ln)
if ln == "" || strings.HasPrefix(ln, "[") {
continue
}
mods = append(mods, ln)
}
return mods
}

// parseVersionLine extracts the version and thread-safety from the first line of
// `php -v`, e.g. "PHP 8.3.10 (cli) (built: ...) (NTS)". It is a fallback used to
// verify a freshly downloaded binary before trusting it to run -r scripts.
func parseVersionLine(out string) (version string, zts bool, ok bool) {
line := out
if idx := strings.IndexByte(out, '\n'); idx >= 0 {
line = out[:idx]
}
fields := strings.Fields(line)
if len(fields) < 2 || fields[0] != "PHP" {
return "", false, false
}
return fields[1], strings.Contains(line, "ZTS"), true
}

func afterColon(s string) string {
if i := strings.IndexByte(s, ':'); i >= 0 {
return s[i+1:]
}
return s
}

// cleanIniValue trims a value and normalizes PHP's "(none)" placeholder to "".
func cleanIniValue(s string) string {
s = strings.TrimSpace(s)
if s == "(none)" {
return ""
}
return s
}

// parseFileList splits a comma/whitespace separated list of file paths, dropping
// empties and "(none)".
func parseFileList(s string) []string {
var out []string
for _, part := range strings.Split(s, ",") {
part = strings.TrimSpace(part)
if part == "" || part == "(none)" {
continue
}
out = append(out, part)
}
return out
}
147 changes: 147 additions & 0 deletions internal/php/parse_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package php

import (
"reflect"
"testing"
)

func TestParseKeyVals(t *testing.T) {
out := "version=8.3.10\nseries=8.3\nzts=0\nextension_dir=/usr/lib/php/20230831\n"
kv := parseKeyVals(out)
want := map[string]string{
"version": "8.3.10",
"series": "8.3",
"zts": "0",
"extension_dir": "/usr/lib/php/20230831",
}
if !reflect.DeepEqual(kv, want) {
t.Errorf("parseKeyVals = %v, want %v", kv, want)
}
}

func TestParseKeyValsEmptyExtensionDir(t *testing.T) {
kv := parseKeyVals("version=8.2.1\nseries=8.2\nzts=1\nextension_dir=\n")
if kv["extension_dir"] != "" {
t.Errorf("empty extension_dir = %q, want empty", kv["extension_dir"])
}
if kv["zts"] != "1" {
t.Errorf("zts = %q, want 1", kv["zts"])
}
}

func TestParseIniOutputTypical(t *testing.T) {
out := `Configuration File (php.ini) Path: /etc/php/8.3/cli
Loaded Configuration File: /etc/php/8.3/cli/php.ini
Scan for additional .ini files in: /etc/php/8.3/cli/conf.d
Additional .ini files parsed: /etc/php/8.3/cli/conf.d/10-opcache.ini,
/etc/php/8.3/cli/conf.d/20-xdebug.ini,
/etc/php/8.3/cli/conf.d/30-mysqli.ini
`
got := parseIniOutput(out)
want := IniPaths{
LoadedFile: "/etc/php/8.3/cli/php.ini",
ScanDir: "/etc/php/8.3/cli/conf.d",
AdditionalFiles: []string{
"/etc/php/8.3/cli/conf.d/10-opcache.ini",
"/etc/php/8.3/cli/conf.d/20-xdebug.ini",
"/etc/php/8.3/cli/conf.d/30-mysqli.ini",
},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("parseIniOutput =\n%+v\nwant\n%+v", got, want)
}
}

func TestParseIniOutputNone(t *testing.T) {
out := `Configuration File (php.ini) Path: /usr/local/etc/php
Loaded Configuration File: (none)
Scan for additional .ini files in: (none)
Additional .ini files parsed: (none)
`
got := parseIniOutput(out)
if got.LoadedFile != "" {
t.Errorf("LoadedFile = %q, want empty", got.LoadedFile)
}
if got.ScanDir != "" {
t.Errorf("ScanDir = %q, want empty", got.ScanDir)
}
if len(got.AdditionalFiles) != 0 {
t.Errorf("AdditionalFiles = %v, want empty", got.AdditionalFiles)
}
}

func TestParseIniOutputSingleAdditional(t *testing.T) {
out := `Loaded Configuration File: /etc/php.ini
Scan for additional .ini files in: /etc/php.d
Additional .ini files parsed: /etc/php.d/20-xdebug.ini
`
got := parseIniOutput(out)
if len(got.AdditionalFiles) != 1 || got.AdditionalFiles[0] != "/etc/php.d/20-xdebug.ini" {
t.Errorf("AdditionalFiles = %v", got.AdditionalFiles)
}
}

func TestParseModules(t *testing.T) {
out := `[PHP Modules]
Core
date
json
mysqli
xdebug

[Zend Modules]
Xdebug
Zend OPcache
`
got := parseModules(out)
want := []string{"Core", "date", "json", "mysqli", "xdebug", "Xdebug", "Zend OPcache"}
if !reflect.DeepEqual(got, want) {
t.Errorf("parseModules = %v, want %v", got, want)
}
}

func TestParseVersionLine(t *testing.T) {
tests := []struct {
name string
in string
wantVersion string
wantZTS bool
wantOK bool
}{
{
name: "nts cli",
in: "PHP 8.3.10 (cli) (built: Jul 1 2024 10:00:00) (NTS)\nCopyright (c) The PHP Group",
wantVersion: "8.3.10",
wantZTS: false,
wantOK: true,
},
{
name: "zts",
in: "PHP 8.2.20 (cli) (built: Jun 1 2024) (ZTS)",
wantVersion: "8.2.20",
wantZTS: true,
wantOK: true,
},
{
name: "garbage",
in: "not php output",
wantOK: false,
},
{
name: "empty",
in: "",
wantOK: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
v, zts, ok := parseVersionLine(tt.in)
if ok != tt.wantOK {
t.Fatalf("ok = %v, want %v", ok, tt.wantOK)
}
if ok && (v != tt.wantVersion || zts != tt.wantZTS) {
t.Errorf("got (%q, %v), want (%q, %v)", v, zts, tt.wantVersion, tt.wantZTS)
}
})
}
}
Loading
Loading