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}