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