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 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}