pub mod extensions;
use crate::brew::Brew;
use crate::daemon::{self, ServiceSpec};
use crate::paths;
use crate::state::PhpVersion;
use anyhow::{bail, Context, Result};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::thread;
use std::time::{Duration, Instant};
pub fn formula(version: &str) -> String {
format!("php@{version}")
}
fn compact(version: &str) -> String {
version.chars().filter(|c| c.is_ascii_digit()).collect()
}
pub fn fpm_socket(version: &str) -> Result<PathBuf> {
Ok(paths::run_dir()?.join(format!("php{}.sock", compact(version))))
}
pub fn service_id(version: &str) -> String {
format!("php-{}", compact(version))
}
pub fn launchd_log(version: &str) -> Result<PathBuf> {
Ok(paths::logs_dir()?.join(format!("php{}-launchd.log", compact(version))))
}
fn fpm_binary(brew: &Brew, version: &str) -> PathBuf {
brew.opt(&formula(version)).join("sbin/php-fpm")
}
fn ini_dir(brew: &Brew, version: &str) -> PathBuf {
brew.etc("php").join(version)
}
fn current_user() -> String {
std::env::var("USER")
.ok()
.filter(|u| !u.is_empty())
.unwrap_or_else(|| {
Command::new("id")
.arg("-un")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "_www".to_string())
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PhpSettingKind {
Pool,
Value,
Flag,
}
pub struct PhpSettingDef {
pub key: &'static str,
pub label: &'static str,
pub default: &'static str,
pub help: &'static str,
pub kind: PhpSettingKind,
}
pub fn php_settings_defs() -> &'static [PhpSettingDef] {
use PhpSettingKind::*;
&[
PhpSettingDef {
key: "memory_limit",
label: "Memory limit",
default: "256M",
help: "e.g. 256M, 1G",
kind: Value,
},
PhpSettingDef {
key: "upload_max_filesize",
label: "Max upload size",
default: "64M",
help: "e.g. 64M",
kind: Value,
},
PhpSettingDef {
key: "post_max_size",
label: "Max POST size",
default: "64M",
help: "≥ upload size",
kind: Value,
},
PhpSettingDef {
key: "max_execution_time",
label: "Max exec time (s)",
default: "60",
help: "0 = unlimited",
kind: Value,
},
PhpSettingDef {
key: "max_input_vars",
label: "Max input vars",
default: "2000",
help: "form/array inputs",
kind: Value,
},
PhpSettingDef {
key: "date.timezone",
label: "Timezone",
default: "UTC",
help: "e.g. America/New_York",
kind: Value,
},
PhpSettingDef {
key: "display_errors",
label: "Display errors",
default: "on",
help: "on | off",
kind: Flag,
},
PhpSettingDef {
key: "opcache.enable",
label: "OPcache",
default: "1",
help: "1 = on, 0 = off",
kind: Value,
},
PhpSettingDef {
key: "opcache.memory_consumption",
label: "OPcache MB",
default: "128",
help: "shared memory (MB)",
kind: Value,
},
PhpSettingDef {
key: "opcache.revalidate_freq",
label: "OPcache revalidate",
default: "2",
help: "seconds; 0 = always",
kind: Value,
},
PhpSettingDef {
key: "pm",
label: "Process manager",
default: "dynamic",
help: "dynamic | static | ondemand",
kind: Pool,
},
PhpSettingDef {
key: "pm.max_children",
label: "Max children",
default: "10",
help: "worker ceiling",
kind: Pool,
},
PhpSettingDef {
key: "pm.start_servers",
label: "Start servers",
default: "2",
help: "initial workers",
kind: Pool,
},
PhpSettingDef {
key: "pm.min_spare_servers",
label: "Min spare",
default: "1",
help: "idle floor",
kind: Pool,
},
PhpSettingDef {
key: "pm.max_spare_servers",
label: "Max spare",
default: "4",
help: "idle ceiling",
kind: Pool,
},
]
}
pub fn render_fpm_conf(php: &PhpVersion) -> Result<PathBuf> {
paths::ensure_dirs()?;
let c = compact(&php.version);
let conf = build_fpm_conf(php)?;
let path = paths::generated_dir()?
.join("fpm")
.join(format!("php{c}.conf"));
std::fs::write(&path, conf)
.with_context(|| format!("Failed to write FPM config {}", path.display()))?;
Ok(path)
}
fn build_fpm_conf(php: &PhpVersion) -> Result<String> {
let version = &php.version;
let c = compact(version);
let socket = fpm_socket(version)?;
let user = current_user();
let run = paths::run_dir()?;
let logs = paths::logs_dir()?;
let mut conf = format!(
"; Generated by reeve — do not edit by hand.\n\
; PHP {version} FPM master.\n\
[global]\n\
pid = {run}/php{c}-fpm.pid\n\
error_log = {logs}/php{c}-fpm.log\n\
daemonize = no\n\
\n\
[www]\n\
user = {user}\n\
group = staff\n\
listen = {socket}\n\
listen.owner = {user}\n\
listen.group = staff\n\
listen.mode = 0660\n",
version = version,
c = c,
run = run.display(),
logs = logs.display(),
user = user,
socket = socket.display(),
);
for def in php_settings_defs() {
if def.key == "opcache.enable" {
continue;
}
let val = php.setting(def.key, def.default);
match def.kind {
PhpSettingKind::Pool => conf.push_str(&format!("{} = {}\n", def.key, val)),
PhpSettingKind::Value => {
conf.push_str(&format!("php_admin_value[{}] = {}\n", def.key, val))
}
PhpSettingKind::Flag => {
conf.push_str(&format!("php_admin_flag[{}] = {}\n", def.key, val))
}
}
}
conf.push_str(&format!(
"catch_workers_output = yes\n\
clear_env = no\n\
php_admin_value[error_log] = {logs}/php{c}-php.log\n\
php_admin_flag[log_errors] = on\n",
logs = logs.display(),
c = c,
));
Ok(conf)
}
fn fpm_define_args(php: &PhpVersion) -> Vec<String> {
let mut d = Vec::new();
d.push("-d".into());
d.push(format!("xdebug.mode={}", php.xdebug.as_str()));
if !php.xdebug.is_off() {
d.push("-d".into());
d.push(format!("xdebug.client_port={}", php.xdebug_port));
d.push("-d".into());
d.push("xdebug.start_with_request=yes".into());
}
d.push("-d".into());
d.push(format!(
"opcache.enable={}",
php.setting("opcache.enable", "1")
));
d
}
pub fn ensure_fpm_running(brew: &Brew, php: &PhpVersion) -> Result<()> {
let version = &php.version;
let conf = render_fpm_conf(php)?;
let bin = fpm_binary(brew, version);
if !bin.exists() {
bail!(
"php-fpm binary not found at {} — is {} installed?",
bin.display(),
formula(version)
);
}
let mut args = vec![
"--nodaemonize".into(),
"--fpm-config".into(),
conf.display().to_string(),
"-c".into(),
ini_dir(brew, version).display().to_string(),
];
args.extend(fpm_define_args(php));
let spec = ServiceSpec {
service: service_id(version),
program: bin,
args,
log: launchd_log(version)?,
keep_alive: true,
run_at_load: true,
};
daemon::install(&spec)?;
let socket = fpm_socket(version)?;
let _ = std::fs::remove_file(&socket);
daemon::restart(&service_id(version))?;
let service = service_id(version);
let deadline = Instant::now() + Duration::from_secs(8);
while Instant::now() < deadline {
if daemon::pid(&service).is_some() && daemon::socket_alive(&socket) {
return Ok(());
}
thread::sleep(Duration::from_millis(150));
}
let diag = fpm_failure_diagnostics(brew, version, &conf);
let fpm_log = paths::logs_dir()?.join(format!("php{}-fpm.log", compact(version)));
let where_to_look = format!(
"Logs (if any): {} and {}.",
fpm_log.display(),
launchd_log(version)?.display()
);
if diag.trim().is_empty() {
bail!(
"FPM master for PHP {version} started but its socket never appeared at {}.\n\
No diagnostics captured. {where_to_look}",
socket.display(),
)
}
bail!(
"FPM master for PHP {version} started but its socket never appeared at {}.\n\n{diag}\n\n\
{where_to_look}",
socket.display(),
)
}
fn fpm_failure_diagnostics(brew: &Brew, version: &str, conf: &Path) -> String {
let mut parts: Vec<String> = Vec::new();
let bin = fpm_binary(brew, version);
match Command::new(&bin)
.arg("-t")
.arg("--fpm-config")
.arg(conf)
.arg("-c")
.arg(ini_dir(brew, version))
.output()
{
Ok(o) => {
let mut text = String::from_utf8_lossy(&o.stderr).into_owned();
text.push_str(&String::from_utf8_lossy(&o.stdout));
let text = text.trim();
if !o.status.success() && !text.is_empty() {
parts.push(format!("php-fpm config test failed:\n{text}"));
}
}
Err(e) => parts.push(format!("could not run `{} -t`: {e}", bin.display())),
}
if let Ok(log) = paths::logs_dir().map(|d| d.join(format!("php{}-fpm.log", compact(version)))) {
if let Ok(contents) = std::fs::read_to_string(&log) {
let lines: Vec<&str> = contents.lines().collect();
let tail = lines[lines.len().saturating_sub(8)..].join("\n");
if !tail.trim().is_empty() {
parts.push(format!("Last lines of {}:\n{tail}", log.display()));
}
}
}
parts.join("\n\n")
}
pub fn is_installed(brew: &Brew, version: &str) -> bool {
brew.is_installed(&formula(version))
}
const SHIM_BINARIES: &[&str] = &["php", "php-config", "phpize", "pecl", "phar"];
pub fn set_cli_php(brew: &Brew, version: &str) -> Result<()> {
if !is_installed(brew, version) {
bail!("PHP {version} is not installed — run `reeve php install {version}` first.");
}
let shim = paths::shim_dir()?;
std::fs::create_dir_all(&shim)
.with_context(|| format!("Failed to create shim dir {}", shim.display()))?;
let bindir = brew.opt(&formula(version)).join("bin");
for name in SHIM_BINARIES {
let link = shim.join(name);
let _ = std::fs::remove_file(&link);
let target = bindir.join(name);
if !target.exists() {
continue;
}
std::os::unix::fs::symlink(&target, &link).with_context(|| {
format!("Failed to link {} -> {}", link.display(), target.display())
})?;
}
Ok(())
}
pub fn current_cli_php() -> Option<String> {
let link = paths::shim_dir().ok()?.join("php");
let target = std::fs::read_link(&link).ok()?;
target
.components()
.find_map(|c| c.as_os_str().to_str()?.strip_prefix("php@"))
.map(str::to_string)
}
pub fn shim_on_path(brew: &Brew) -> bool {
let Ok(shim) = paths::shim_dir() else {
return false;
};
let Ok(path) = std::env::var("PATH") else {
return false;
};
let brew_bin = brew.prefix.join("bin");
for entry in std::env::split_paths(&path) {
if entry == shim {
return true;
}
if entry == brew_bin {
return false;
}
}
false
}
pub fn install(brew: &Brew, version: &str) -> Result<PhpVersion> {
brew.ensure_tap("shivammathur/php")?;
brew.trust_tap("shivammathur/php");
if is_installed(brew, version) {
println!("• {} already installed — adopting it", formula(version));
} else {
println!(
"Installing {} (this can take a few minutes)…",
formula(version)
);
brew.install(&formula(version))?;
}
let record = PhpVersion {
version: version.to_string(),
fpm_socket: fpm_socket(version)?.display().to_string(),
..Default::default()
};
ensure_fpm_running(brew, &record)?;
Ok(record)
}
pub fn discover(brew: &Brew) -> Vec<String> {
let opt = brew.prefix.join("opt");
let mut found = Vec::new();
if let Ok(entries) = std::fs::read_dir(&opt) {
for e in entries.flatten() {
if let Some(name) = e.file_name().to_str() {
if let Some(ver) = name.strip_prefix("php@") {
found.push(ver.to_string());
}
}
}
}
found.sort();
found
}
#[cfg(test)]
mod tests {
use super::*;
use crate::state::XdebugMode;
#[test]
fn naming_conventions() {
assert_eq!(formula("8.3"), "php@8.3");
assert_eq!(compact("8.3"), "83");
assert_eq!(service_id("8.4"), "php-84");
assert!(fpm_socket("8.3").unwrap().ends_with("php83.sock"));
}
#[test]
fn fpm_conf_uses_defaults_then_overrides() {
let mut php = PhpVersion {
version: "8.3".into(),
..Default::default()
};
let conf = build_fpm_conf(&php).unwrap();
assert!(conf.contains("pm.max_children = 10"));
assert!(conf.contains("php_admin_value[memory_limit] = 256M"));
assert!(conf.contains("php_admin_flag[display_errors] = on"));
assert!(!conf.contains("xdebug.mode"));
assert!(!conf.contains("php_admin_value[opcache.enable]"));
assert_eq!(
fpm_define_args(&php),
vec!["-d", "xdebug.mode=off", "-d", "opcache.enable=1"]
);
php.settings.insert("pm.max_children".into(), "32".into());
php.settings.insert("memory_limit".into(), "1G".into());
let conf = build_fpm_conf(&php).unwrap();
assert!(conf.contains("pm.max_children = 32"));
assert!(conf.contains("php_admin_value[memory_limit] = 1G"));
php.xdebug = XdebugMode::Debug;
php.xdebug_port = 9009;
assert_eq!(
fpm_define_args(&php),
vec![
"-d",
"xdebug.mode=debug",
"-d",
"xdebug.client_port=9009",
"-d",
"xdebug.start_with_request=yes",
"-d",
"opcache.enable=1",
]
);
}
}