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