Skip to main content

run/engine/
php.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3use std::process::{Command, Stdio};
4use std::time::{Duration, Instant};
5
6use anyhow::{Context, Result};
7use tempfile::{Builder, TempDir};
8
9use super::{ExecutionOutcome, ExecutionPayload, LanguageEngine, LanguageSession};
10
11pub struct PhpEngine {
12    interpreter: Option<PathBuf>,
13}
14
15impl Default for PhpEngine {
16    fn default() -> Self {
17        Self::new()
18    }
19}
20
21impl PhpEngine {
22    pub fn new() -> Self {
23        Self {
24            interpreter: resolve_php_binary(),
25        }
26    }
27
28    fn ensure_interpreter(&self) -> Result<&Path> {
29        self.interpreter.as_deref().ok_or_else(|| {
30            anyhow::anyhow!(
31                "PHP support requires the `php` CLI executable. Install PHP and ensure it is on your PATH."
32            )
33        })
34    }
35
36    fn write_temp_script(&self, code: &str) -> Result<(tempfile::TempDir, PathBuf)> {
37        let dir = Builder::new()
38            .prefix("run-php")
39            .tempdir()
40            .context("failed to create temporary directory for php source")?;
41        let path = dir.path().join("snippet.php");
42        let mut contents = code.to_string();
43        if !contents.starts_with("<?php") {
44            contents = format!("<?php\n{}", contents);
45        }
46        if !contents.ends_with('\n') {
47            contents.push('\n');
48        }
49        std::fs::write(&path, contents).with_context(|| {
50            format!("failed to write temporary PHP source to {}", path.display())
51        })?;
52        Ok((dir, path))
53    }
54
55    fn run_script(&self, script: &Path) -> Result<std::process::Output> {
56        let interpreter = self.ensure_interpreter()?;
57        let mut cmd = Command::new(interpreter);
58        cmd.arg(script)
59            .stdout(Stdio::piped())
60            .stderr(Stdio::piped());
61        cmd.stdin(Stdio::inherit());
62        if let Some(dir) = script.parent() {
63            cmd.current_dir(dir);
64        }
65        cmd.output().with_context(|| {
66            format!(
67                "failed to execute {} with script {}",
68                interpreter.display(),
69                script.display()
70            )
71        })
72    }
73}
74
75impl LanguageEngine for PhpEngine {
76    fn id(&self) -> &'static str {
77        "php"
78    }
79
80    fn display_name(&self) -> &'static str {
81        "PHP"
82    }
83
84    fn aliases(&self) -> &[&'static str] {
85        &[]
86    }
87
88    fn supports_sessions(&self) -> bool {
89        self.interpreter.is_some()
90    }
91
92    fn validate(&self) -> Result<()> {
93        let interpreter = self.ensure_interpreter()?;
94        let mut cmd = Command::new(interpreter);
95        cmd.arg("-v").stdout(Stdio::null()).stderr(Stdio::null());
96        cmd.status()
97            .with_context(|| format!("failed to invoke {}", interpreter.display()))?
98            .success()
99            .then_some(())
100            .ok_or_else(|| anyhow::anyhow!("{} is not executable", interpreter.display()))
101    }
102
103    fn execute(&self, payload: &ExecutionPayload) -> Result<ExecutionOutcome> {
104        let start = Instant::now();
105        let (temp_dir, script_path) = match payload {
106            ExecutionPayload::Inline { code } | ExecutionPayload::Stdin { code } => {
107                let (dir, path) = self.write_temp_script(code)?;
108                (Some(dir), path)
109            }
110            ExecutionPayload::File { path } => (None, path.clone()),
111        };
112
113        let output = self.run_script(&script_path)?;
114        drop(temp_dir);
115
116        Ok(ExecutionOutcome {
117            language: self.id().to_string(),
118            exit_code: output.status.code(),
119            stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
120            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
121            duration: start.elapsed(),
122        })
123    }
124
125    fn start_session(&self) -> Result<Box<dyn LanguageSession>> {
126        let interpreter = self.ensure_interpreter()?.to_path_buf();
127        let session = PhpSession::new(interpreter)?;
128        Ok(Box::new(session))
129    }
130}
131
132fn resolve_php_binary() -> Option<PathBuf> {
133    which::which("php").ok()
134}
135
136const SESSION_MAIN_FILE: &str = "session.php";
137const PHP_PROMPT_PREFIXES: &[&str] = &["php>>> ", "php>>>", "... ", "..."];
138
139struct PhpSession {
140    interpreter: PathBuf,
141    workspace: TempDir,
142    statements: Vec<String>,
143    last_stdout: String,
144    last_stderr: String,
145}
146
147impl PhpSession {
148    fn new(interpreter: PathBuf) -> Result<Self> {
149        let workspace = TempDir::new().context("failed to create PHP session workspace")?;
150        let session = Self {
151            interpreter,
152            workspace,
153            statements: Vec::new(),
154            last_stdout: String::new(),
155            last_stderr: String::new(),
156        };
157        session.persist_source()?;
158        Ok(session)
159    }
160
161    fn language_id(&self) -> &str {
162        "php"
163    }
164
165    fn source_path(&self) -> PathBuf {
166        self.workspace.path().join(SESSION_MAIN_FILE)
167    }
168
169    fn persist_source(&self) -> Result<()> {
170        let path = self.source_path();
171        let source = self.render_source();
172        fs::write(&path, source)
173            .with_context(|| format!("failed to write PHP session source at {}", path.display()))
174    }
175
176    fn render_source(&self) -> String {
177        let mut source = String::from("<?php\n");
178        if self.statements.is_empty() {
179            source.push_str("// session body\n");
180        } else {
181            for stmt in &self.statements {
182                source.push_str(stmt);
183                if !stmt.ends_with('\n') {
184                    source.push('\n');
185                }
186            }
187        }
188        source
189    }
190
191    fn run_program(&self) -> Result<std::process::Output> {
192        let mut cmd = Command::new(&self.interpreter);
193        cmd.arg(SESSION_MAIN_FILE)
194            .stdout(Stdio::piped())
195            .stderr(Stdio::piped())
196            .current_dir(self.workspace.path());
197        cmd.output().with_context(|| {
198            format!(
199                "failed to execute {} for PHP session",
200                self.interpreter.display()
201            )
202        })
203    }
204
205    fn normalize_output(bytes: &[u8]) -> String {
206        String::from_utf8_lossy(bytes)
207            .replace("\r\n", "\n")
208            .replace('\r', "")
209    }
210
211    fn diff_outputs(previous: &str, current: &str) -> String {
212        if let Some(suffix) = current.strip_prefix(previous) {
213            suffix.to_string()
214        } else {
215            current.to_string()
216        }
217    }
218}
219
220impl LanguageSession for PhpSession {
221    fn language_id(&self) -> &str {
222        self.language_id()
223    }
224
225    fn eval(&mut self, code: &str) -> Result<ExecutionOutcome> {
226        let trimmed = code.trim();
227
228        if trimmed.eq_ignore_ascii_case(":reset") {
229            self.statements.clear();
230            self.last_stdout.clear();
231            self.last_stderr.clear();
232            self.persist_source()?;
233            return Ok(ExecutionOutcome {
234                language: self.language_id().to_string(),
235                exit_code: None,
236                stdout: String::new(),
237                stderr: String::new(),
238                duration: Duration::default(),
239            });
240        }
241
242        if trimmed.is_empty() {
243            return Ok(ExecutionOutcome {
244                language: self.language_id().to_string(),
245                exit_code: None,
246                stdout: String::new(),
247                stderr: String::new(),
248                duration: Duration::default(),
249            });
250        }
251
252        let mut statement = normalize_php_snippet(code);
253        if statement.trim().is_empty() {
254            return Ok(ExecutionOutcome {
255                language: self.language_id().to_string(),
256                exit_code: None,
257                stdout: String::new(),
258                stderr: String::new(),
259                duration: Duration::default(),
260            });
261        }
262
263        if !statement.ends_with('\n') {
264            statement.push('\n');
265        }
266
267        self.statements.push(statement);
268        self.persist_source()?;
269
270        let start = Instant::now();
271        let output = self.run_program()?;
272        let stdout_full = PhpSession::normalize_output(&output.stdout);
273        let stderr_full = PhpSession::normalize_output(&output.stderr);
274        let stdout = PhpSession::diff_outputs(&self.last_stdout, &stdout_full);
275        let stderr = PhpSession::diff_outputs(&self.last_stderr, &stderr_full);
276        let duration = start.elapsed();
277
278        if output.status.success() {
279            self.last_stdout = stdout_full;
280            self.last_stderr = stderr_full;
281            Ok(ExecutionOutcome {
282                language: self.language_id().to_string(),
283                exit_code: output.status.code(),
284                stdout,
285                stderr,
286                duration,
287            })
288        } else {
289            self.statements.pop();
290            self.persist_source()?;
291            Ok(ExecutionOutcome {
292                language: self.language_id().to_string(),
293                exit_code: output.status.code(),
294                stdout,
295                stderr,
296                duration,
297            })
298        }
299    }
300
301    fn shutdown(&mut self) -> Result<()> {
302        Ok(())
303    }
304}
305
306fn strip_leading_php_prompt(line: &str) -> String {
307    let without_bom = line.trim_start_matches('\u{feff}');
308    let mut leading_len = 0;
309    for (idx, ch) in without_bom.char_indices() {
310        if ch == ' ' || ch == '\t' {
311            leading_len = idx + ch.len_utf8();
312        } else {
313            break;
314        }
315    }
316    let (leading_ws, rest) = without_bom.split_at(leading_len);
317    for prefix in PHP_PROMPT_PREFIXES {
318        if let Some(stripped) = rest.strip_prefix(prefix) {
319            return format!("{}{}", leading_ws, stripped);
320        }
321    }
322    without_bom.to_string()
323}
324
325fn normalize_php_snippet(code: &str) -> String {
326    let mut lines: Vec<String> = code.lines().map(strip_leading_php_prompt).collect();
327
328    while let Some(first) = lines.first() {
329        let trimmed = first.trim();
330        if trimmed.is_empty() {
331            lines.remove(0);
332            continue;
333        }
334        if trimmed.starts_with("<?php") {
335            lines.remove(0);
336            break;
337        }
338        if trimmed == "<?" {
339            lines.remove(0);
340            break;
341        }
342        break;
343    }
344
345    while let Some(last) = lines.last() {
346        let trimmed = last.trim();
347        if trimmed.is_empty() {
348            lines.pop();
349            continue;
350        }
351        if trimmed == "?>" {
352            lines.pop();
353            continue;
354        }
355        break;
356    }
357
358    if lines.is_empty() {
359        String::new()
360    } else {
361        let mut result = lines.join("\n");
362        if code.ends_with('\n') {
363            result.push('\n');
364        }
365        result
366    }
367}
368
369#[cfg(test)]
370mod tests {
371    use super::{PhpSession, normalize_php_snippet};
372
373    #[test]
374    fn strips_prompt_prefixes() {
375        let input = "php>>> echo 'hello';\n... echo 'world';\n";
376        let normalized = normalize_php_snippet(input);
377        assert_eq!(normalized, "echo 'hello';\necho 'world';\n");
378    }
379
380    #[test]
381    fn preserves_indentation_after_prompt_removal() {
382        let input = "    php>>> if (true) {\n    ...     echo 'ok';\n    ... }\n";
383        let normalized = normalize_php_snippet(input);
384        assert_eq!(normalized, "    if (true) {\n        echo 'ok';\n    }\n");
385    }
386
387    #[test]
388    fn diff_outputs_appends_only_suffix() {
389        let previous = "a\nb\n";
390        let current = "a\nb\nc\n";
391        assert_eq!(PhpSession::diff_outputs(previous, current), "c\n");
392
393        let previous = "a\n";
394        let current = "x\na\n";
395        assert_eq!(PhpSession::diff_outputs(previous, current), "x\na\n");
396    }
397}