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}