run/engine/
perl.rs

1use std::collections::BTreeSet;
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::process::{Command, Stdio};
5use std::time::{Duration, Instant};
6
7use anyhow::{Context, Result};
8use tempfile::{Builder, TempDir};
9
10use super::{ExecutionOutcome, ExecutionPayload, LanguageEngine, LanguageSession};
11
12pub struct PerlEngine {
13    executable: Option<PathBuf>,
14}
15
16impl PerlEngine {
17    pub fn new() -> Self {
18        Self {
19            executable: resolve_perl_binary(),
20        }
21    }
22
23    fn ensure_executable(&self) -> Result<&Path> {
24        self.executable.as_deref().ok_or_else(|| {
25            anyhow::anyhow!(
26                "Perl support requires the `perl` executable. Install Perl from https://www.perl.org/get.html and ensure `perl` is on your PATH."
27            )
28        })
29    }
30
31    fn write_temp_script(&self, code: &str) -> Result<(TempDir, PathBuf)> {
32        let dir = Builder::new()
33            .prefix("run-perl")
34            .tempdir()
35            .context("failed to create temporary directory for Perl source")?;
36        let path = dir.path().join("snippet.pl");
37        let mut contents = code.to_string();
38        if !contents.ends_with('\n') {
39            contents.push('\n');
40        }
41        fs::write(&path, contents).with_context(|| {
42            format!(
43                "failed to write temporary Perl source to {}",
44                path.display()
45            )
46        })?;
47        Ok((dir, path))
48    }
49
50    fn execute_path(&self, path: &Path) -> Result<std::process::Output> {
51        let executable = self.ensure_executable()?;
52        let mut cmd = Command::new(executable);
53        cmd.arg(path).stdout(Stdio::piped()).stderr(Stdio::piped());
54        cmd.stdin(Stdio::inherit());
55        if let Some(parent) = path.parent() {
56            cmd.current_dir(parent);
57        }
58        cmd.output().with_context(|| {
59            format!(
60                "failed to execute {} with script {}",
61                executable.display(),
62                path.display()
63            )
64        })
65    }
66}
67
68impl LanguageEngine for PerlEngine {
69    fn id(&self) -> &'static str {
70        "perl"
71    }
72
73    fn display_name(&self) -> &'static str {
74        "Perl"
75    }
76
77    fn aliases(&self) -> &[&'static str] {
78        &["pl"]
79    }
80
81    fn supports_sessions(&self) -> bool {
82        self.executable.is_some()
83    }
84
85    fn validate(&self) -> Result<()> {
86        let executable = self.ensure_executable()?;
87        let mut cmd = Command::new(executable);
88        cmd.arg("-v").stdout(Stdio::null()).stderr(Stdio::null());
89        cmd.status()
90            .with_context(|| format!("failed to invoke {}", executable.display()))?
91            .success()
92            .then_some(())
93            .ok_or_else(|| anyhow::anyhow!("{} is not executable", executable.display()))
94    }
95
96    fn execute(&self, payload: &ExecutionPayload) -> Result<ExecutionOutcome> {
97        let start = Instant::now();
98        let (temp_dir, path) = match payload {
99            ExecutionPayload::Inline { code } | ExecutionPayload::Stdin { code } => {
100                let (dir, path) = self.write_temp_script(code)?;
101                (Some(dir), path)
102            }
103            ExecutionPayload::File { path } => (None, path.clone()),
104        };
105
106        let output = self.execute_path(&path)?;
107        drop(temp_dir);
108
109        Ok(ExecutionOutcome {
110            language: self.id().to_string(),
111            exit_code: output.status.code(),
112            stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
113            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
114            duration: start.elapsed(),
115        })
116    }
117
118    fn start_session(&self) -> Result<Box<dyn LanguageSession>> {
119        let executable = self.ensure_executable()?.to_path_buf();
120        Ok(Box::new(PerlSession::new(executable)?))
121    }
122}
123
124fn resolve_perl_binary() -> Option<PathBuf> {
125    which::which("perl").ok()
126}
127
128#[derive(Default)]
129struct PerlSessionState {
130    pragmas: BTreeSet<String>,
131    declarations: Vec<String>,
132    statements: Vec<String>,
133}
134
135struct PerlSession {
136    executable: PathBuf,
137    workspace: TempDir,
138    state: PerlSessionState,
139    previous_stdout: String,
140    previous_stderr: String,
141}
142
143impl PerlSession {
144    fn new(executable: PathBuf) -> Result<Self> {
145        let workspace = Builder::new()
146            .prefix("run-perl-repl")
147            .tempdir()
148            .context("failed to create temporary directory for Perl repl")?;
149        let mut state = PerlSessionState::default();
150        state.pragmas.insert("use strict;".to_string());
151        state.pragmas.insert("use warnings;".to_string());
152        state.pragmas.insert("use feature 'say';".to_string());
153        let session = Self {
154            executable,
155            workspace,
156            state,
157            previous_stdout: String::new(),
158            previous_stderr: String::new(),
159        };
160        session.persist_source()?;
161        Ok(session)
162    }
163
164    fn source_path(&self) -> PathBuf {
165        self.workspace.path().join("session.pl")
166    }
167
168    fn persist_source(&self) -> Result<()> {
169        let source = self.render_source();
170        fs::write(self.source_path(), source)
171            .with_context(|| "failed to write Perl session source".to_string())
172    }
173
174    fn render_source(&self) -> String {
175        let mut source = String::new();
176        for pragma in &self.state.pragmas {
177            source.push_str(pragma);
178            if !pragma.ends_with('\n') {
179                source.push('\n');
180            }
181        }
182        source.push('\n');
183
184        for decl in &self.state.declarations {
185            source.push_str(decl);
186            if !decl.ends_with('\n') {
187                source.push('\n');
188            }
189            source.push('\n');
190        }
191
192        if self.state.statements.is_empty() {
193            source.push_str("# session body\n");
194        } else {
195            for stmt in &self.state.statements {
196                source.push_str(stmt);
197                if !stmt.ends_with('\n') {
198                    source.push('\n');
199                }
200            }
201        }
202
203        source
204    }
205
206    fn run_program(&self) -> Result<std::process::Output> {
207        let mut cmd = Command::new(&self.executable);
208        cmd.arg("session.pl")
209            .stdout(Stdio::piped())
210            .stderr(Stdio::piped())
211            .current_dir(self.workspace.path());
212        cmd.output().with_context(|| {
213            format!(
214                "failed to execute {} for Perl session",
215                self.executable.display()
216            )
217        })
218    }
219
220    fn run_current(&mut self, start: Instant) -> Result<(ExecutionOutcome, bool)> {
221        self.persist_source()?;
222        let output = self.run_program()?;
223        let stdout_full = normalize_output(&output.stdout);
224        let stderr_full = normalize_output(&output.stderr);
225
226        let stdout_delta = diff_output(&self.previous_stdout, &stdout_full);
227        let stderr_delta = diff_output(&self.previous_stderr, &stderr_full);
228
229        let success = output.status.success();
230        if success {
231            self.previous_stdout = stdout_full;
232            self.previous_stderr = stderr_full;
233        }
234
235        let outcome = ExecutionOutcome {
236            language: "perl".to_string(),
237            exit_code: output.status.code(),
238            stdout: stdout_delta,
239            stderr: stderr_delta,
240            duration: start.elapsed(),
241        };
242
243        Ok((outcome, success))
244    }
245
246    fn apply_pragma(&mut self, code: &str) -> Result<(ExecutionOutcome, bool)> {
247        let mut inserted = Vec::new();
248        for line in code.lines() {
249            let trimmed = line.trim();
250            if trimmed.is_empty() {
251                continue;
252            }
253            let normalized = if trimmed.ends_with(';') {
254                trimmed.trim_end_matches(';').to_string() + ";"
255            } else {
256                format!("{};", trimmed)
257            };
258            if self.state.pragmas.insert(normalized.clone()) {
259                inserted.push(normalized);
260            }
261        }
262
263        if inserted.is_empty() {
264            return Ok((
265                ExecutionOutcome {
266                    language: "perl".to_string(),
267                    exit_code: None,
268                    stdout: String::new(),
269                    stderr: String::new(),
270                    duration: Duration::default(),
271                },
272                true,
273            ));
274        }
275
276        let start = Instant::now();
277        let (outcome, success) = self.run_current(start)?;
278        if !success {
279            for pragma in inserted {
280                self.state.pragmas.remove(&pragma);
281            }
282            self.persist_source()?;
283        }
284        Ok((outcome, success))
285    }
286
287    fn apply_declaration(&mut self, code: &str) -> Result<(ExecutionOutcome, bool)> {
288        let snippet = ensure_trailing_newline(code);
289        self.state.declarations.push(snippet);
290        let start = Instant::now();
291        let (outcome, success) = self.run_current(start)?;
292        if !success {
293            let _ = self.state.declarations.pop();
294            self.persist_source()?;
295        }
296        Ok((outcome, success))
297    }
298
299    fn apply_statement(&mut self, code: &str) -> Result<(ExecutionOutcome, bool)> {
300        self.state.statements.push(ensure_statement(code));
301        let start = Instant::now();
302        let (outcome, success) = self.run_current(start)?;
303        if !success {
304            let _ = self.state.statements.pop();
305            self.persist_source()?;
306        }
307        Ok((outcome, success))
308    }
309
310    fn apply_expression(&mut self, code: &str) -> Result<(ExecutionOutcome, bool)> {
311        self.state.statements.push(wrap_expression(code));
312        let start = Instant::now();
313        let (outcome, success) = self.run_current(start)?;
314        if !success {
315            let _ = self.state.statements.pop();
316            self.persist_source()?;
317        }
318        Ok((outcome, success))
319    }
320
321    fn reset(&mut self) -> Result<()> {
322        self.state.pragmas.clear();
323        self.state.declarations.clear();
324        self.state.statements.clear();
325        self.previous_stdout.clear();
326        self.previous_stderr.clear();
327        self.state.pragmas.insert("use strict;".to_string());
328        self.state.pragmas.insert("use warnings;".to_string());
329        self.state.pragmas.insert("use feature 'say';".to_string());
330        self.persist_source()
331    }
332}
333
334impl LanguageSession for PerlSession {
335    fn language_id(&self) -> &str {
336        "perl"
337    }
338
339    fn eval(&mut self, code: &str) -> Result<ExecutionOutcome> {
340        let trimmed = code.trim();
341        if trimmed.is_empty() {
342            return Ok(ExecutionOutcome {
343                language: "perl".to_string(),
344                exit_code: None,
345                stdout: String::new(),
346                stderr: String::new(),
347                duration: Duration::default(),
348            });
349        }
350
351        if trimmed.eq_ignore_ascii_case(":reset") {
352            self.reset()?;
353            return Ok(ExecutionOutcome {
354                language: "perl".to_string(),
355                exit_code: None,
356                stdout: String::new(),
357                stderr: String::new(),
358                duration: Duration::default(),
359            });
360        }
361
362        if trimmed.eq_ignore_ascii_case(":help") {
363            return Ok(ExecutionOutcome {
364                language: "perl".to_string(),
365                exit_code: None,
366                stdout:
367                    "Perl commands:\n  :reset — clear session state\n  :help  — show this message\n"
368                        .to_string(),
369                stderr: String::new(),
370                duration: Duration::default(),
371            });
372        }
373
374        match classify_snippet(trimmed) {
375            PerlSnippet::Pragma => {
376                let (outcome, _) = self.apply_pragma(code)?;
377                Ok(outcome)
378            }
379            PerlSnippet::Declaration => {
380                let (outcome, _) = self.apply_declaration(code)?;
381                Ok(outcome)
382            }
383            PerlSnippet::Expression => {
384                let (outcome, _) = self.apply_expression(trimmed)?;
385                Ok(outcome)
386            }
387            PerlSnippet::Statement => {
388                let (outcome, _) = self.apply_statement(code)?;
389                Ok(outcome)
390            }
391        }
392    }
393
394    fn shutdown(&mut self) -> Result<()> {
395        Ok(())
396    }
397}
398
399enum PerlSnippet {
400    Pragma,
401    Declaration,
402    Statement,
403    Expression,
404}
405
406fn classify_snippet(code: &str) -> PerlSnippet {
407    if is_pragma(code) {
408        return PerlSnippet::Pragma;
409    }
410
411    if is_declaration(code) {
412        return PerlSnippet::Declaration;
413    }
414
415    if should_wrap_expression(code) {
416        return PerlSnippet::Expression;
417    }
418
419    PerlSnippet::Statement
420}
421
422fn is_pragma(code: &str) -> bool {
423    code.lines().all(|line| {
424        let trimmed = line.trim_start();
425        trimmed.starts_with("use ") || trimmed.starts_with("no ")
426    })
427}
428
429fn is_declaration(code: &str) -> bool {
430    let trimmed = code.trim_start();
431    trimmed.starts_with("sub ")
432}
433
434fn should_wrap_expression(code: &str) -> bool {
435    if code.contains('\n') {
436        return false;
437    }
438
439    let trimmed = code.trim();
440    if trimmed.is_empty() {
441        return false;
442    }
443
444    if trimmed.ends_with(';') {
445        return false;
446    }
447
448    let lowered = trimmed.to_ascii_lowercase();
449    const STATEMENT_PREFIXES: [&str; 9] = [
450        "my ", "our ", "state ", "if ", "for ", "while ", "foreach ", "given ", "when ",
451    ];
452
453    if STATEMENT_PREFIXES
454        .iter()
455        .any(|prefix| lowered.starts_with(prefix))
456    {
457        return false;
458    }
459
460    if trimmed.contains('=') {
461        return false;
462    }
463
464    true
465}
466
467fn ensure_trailing_newline(code: &str) -> String {
468    let mut owned = code.to_string();
469    if !owned.ends_with('\n') {
470        owned.push('\n');
471    }
472    owned
473}
474
475fn ensure_statement(code: &str) -> String {
476    if code.trim().is_empty() {
477        return String::new();
478    }
479
480    let mut owned = code.to_string();
481    if !code.contains('\n') {
482        let trimmed = owned.trim_end();
483        if !trimmed.ends_with(';') && !trimmed.ends_with('}') {
484            owned.push(';');
485        }
486    }
487    if !owned.ends_with('\n') {
488        owned.push('\n');
489    }
490    owned
491}
492
493fn wrap_expression(code: &str) -> String {
494    format!("say({});\n", code.trim())
495}
496
497fn diff_output(previous: &str, current: &str) -> String {
498    if let Some(stripped) = current.strip_prefix(previous) {
499        stripped.to_string()
500    } else {
501        current.to_string()
502    }
503}
504
505fn normalize_output(bytes: &[u8]) -> String {
506    String::from_utf8_lossy(bytes)
507        .replace("\r\n", "\n")
508        .replace('\r', "")
509}