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}