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}