Skip to main content

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