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