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}