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 DartEngine {
13 executable: Option<PathBuf>,
14}
15
16impl Default for DartEngine {
17 fn default() -> Self {
18 Self::new()
19 }
20}
21
22impl DartEngine {
23 pub fn new() -> Self {
24 Self {
25 executable: resolve_dart_binary(),
26 }
27 }
28
29 fn ensure_executable(&self) -> Result<&Path> {
30 self.executable.as_deref().ok_or_else(|| {
31 anyhow::anyhow!(
32 "Dart support requires the `dart` executable. Install the Dart SDK from https://dart.dev/get-dart and ensure `dart` is on your PATH."
33 )
34 })
35 }
36
37 fn prepare_inline_source(code: &str) -> String {
38 if contains_main(code) {
39 let mut snippet = code.to_string();
40 if !snippet.ends_with('\n') {
41 snippet.push('\n');
42 }
43 return snippet;
44 }
45
46 let mut wrapped = String::from("Future<void> main() async {\n");
47 for line in code.lines() {
48 if line.trim().is_empty() {
49 wrapped.push_str(" \n");
50 } else {
51 wrapped.push_str(" ");
52 wrapped.push_str(line);
53 if !line.trim_end().ends_with(';') && !line.trim_end().ends_with('}') {
54 wrapped.push(';');
55 }
56 wrapped.push('\n');
57 }
58 }
59 wrapped.push_str("}\n");
60 wrapped
61 }
62
63 fn write_temp_source(&self, code: &str) -> Result<(TempDir, PathBuf)> {
64 let dir = Builder::new()
65 .prefix("run-dart")
66 .tempdir()
67 .context("failed to create temporary directory for Dart source")?;
68 let path = dir.path().join("main.dart");
69 fs::write(&path, Self::prepare_inline_source(code)).with_context(|| {
70 format!(
71 "failed to write temporary Dart source to {}",
72 path.display()
73 )
74 })?;
75 Ok((dir, path))
76 }
77
78 fn execute_path(&self, path: &Path) -> Result<std::process::Output> {
79 let executable = self.ensure_executable()?;
80 let mut cmd = Command::new(executable);
81 cmd.arg("run")
82 .arg("--enable-asserts")
83 .stdout(Stdio::piped())
84 .stderr(Stdio::piped());
85 cmd.stdin(Stdio::inherit());
86
87 if let Some(parent) = path.parent() {
88 cmd.current_dir(parent);
89 if let Some(file_name) = path.file_name() {
90 cmd.arg(file_name);
91 } else {
92 cmd.arg(path);
93 }
94 } else {
95 cmd.arg(path);
96 }
97
98 cmd.output().with_context(|| {
99 format!(
100 "failed to invoke {} to run {}",
101 executable.display(),
102 path.display()
103 )
104 })
105 }
106}
107
108impl LanguageEngine for DartEngine {
109 fn id(&self) -> &'static str {
110 "dart"
111 }
112
113 fn display_name(&self) -> &'static str {
114 "Dart"
115 }
116
117 fn aliases(&self) -> &[&'static str] {
118 &["dartlang", "flutter"]
119 }
120
121 fn supports_sessions(&self) -> bool {
122 self.executable.is_some()
123 }
124
125 fn validate(&self) -> Result<()> {
126 let executable = self.ensure_executable()?;
127 let mut cmd = Command::new(executable);
128 cmd.arg("--version")
129 .stdout(Stdio::null())
130 .stderr(Stdio::null());
131 cmd.status()
132 .with_context(|| format!("failed to invoke {}", executable.display()))?
133 .success()
134 .then_some(())
135 .ok_or_else(|| anyhow::anyhow!("{} is not executable", executable.display()))
136 }
137
138 fn execute(&self, payload: &ExecutionPayload) -> Result<ExecutionOutcome> {
139 let start = Instant::now();
140 let (temp_dir, path) = match payload {
141 ExecutionPayload::Inline { code } => {
142 let (dir, path) = self.write_temp_source(code)?;
143 (Some(dir), path)
144 }
145 ExecutionPayload::Stdin { code } => {
146 let (dir, path) = self.write_temp_source(code)?;
147 (Some(dir), path)
148 }
149 ExecutionPayload::File { path } => (None, path.clone()),
150 };
151
152 let output = self.execute_path(&path)?;
153 drop(temp_dir);
154
155 Ok(ExecutionOutcome {
156 language: self.id().to_string(),
157 exit_code: output.status.code(),
158 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
159 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
160 duration: start.elapsed(),
161 })
162 }
163
164 fn start_session(&self) -> Result<Box<dyn LanguageSession>> {
165 let executable = self.ensure_executable()?.to_path_buf();
166 Ok(Box::new(DartSession::new(executable)?))
167 }
168}
169
170fn resolve_dart_binary() -> Option<PathBuf> {
171 which::which("dart").ok()
172}
173
174fn contains_main(code: &str) -> bool {
175 code.lines()
176 .any(|line| line.contains("void main") || line.contains("Future<void> main"))
177}
178
179struct DartSession {
180 executable: PathBuf,
181 workspace: TempDir,
182 imports: BTreeSet<String>,
183 declarations: Vec<String>,
184 statements: Vec<String>,
185 previous_stdout: String,
186 previous_stderr: String,
187}
188
189impl DartSession {
190 fn new(executable: PathBuf) -> Result<Self> {
191 let workspace = Builder::new()
192 .prefix("run-dart-repl")
193 .tempdir()
194 .context("failed to create temporary directory for Dart repl")?;
195 let session = Self {
196 executable,
197 workspace,
198 imports: BTreeSet::new(),
199 declarations: Vec::new(),
200 statements: Vec::new(),
201 previous_stdout: String::new(),
202 previous_stderr: String::new(),
203 };
204 session.persist_source()?;
205 Ok(session)
206 }
207
208 fn source_path(&self) -> PathBuf {
209 self.workspace.path().join("session.dart")
210 }
211
212 fn persist_source(&self) -> Result<()> {
213 let source = self.render_source();
214 fs::write(self.source_path(), source)
215 .with_context(|| "failed to write Dart session source".to_string())
216 }
217
218 fn render_source(&self) -> String {
219 let mut source = String::from("import 'dart:async';\n");
220 for import in &self.imports {
221 source.push_str(import);
222 if !import.trim_end().ends_with(';') {
223 source.push(';');
224 }
225 source.push('\n');
226 }
227 source.push('\n');
228 for decl in &self.declarations {
229 source.push_str(decl);
230 if !decl.ends_with('\n') {
231 source.push('\n');
232 }
233 source.push('\n');
234 }
235 source.push_str("Future<void> main() async {\n");
236 if self.statements.is_empty() {
237 source.push_str(" // session body\n");
238 } else {
239 for stmt in &self.statements {
240 for line in stmt.lines() {
241 source.push_str(" ");
242 source.push_str(line);
243 source.push('\n');
244 }
245 }
246 }
247 source.push_str("}\n");
248 source
249 }
250
251 fn run_program(&self) -> Result<std::process::Output> {
252 let mut cmd = Command::new(&self.executable);
253 cmd.arg("run")
254 .arg("--enable-asserts")
255 .arg("session.dart")
256 .stdout(Stdio::piped())
257 .stderr(Stdio::piped())
258 .current_dir(self.workspace.path());
259 cmd.output().with_context(|| {
260 format!(
261 "failed to execute {} for Dart session",
262 self.executable.display()
263 )
264 })
265 }
266
267 fn run_standalone_program(&self, code: &str) -> Result<ExecutionOutcome> {
268 let start = Instant::now();
269 let path = self.workspace.path().join("standalone.dart");
270 fs::write(&path, ensure_trailing_newline(code))
271 .with_context(|| "failed to write Dart standalone source".to_string())?;
272
273 let mut cmd = Command::new(&self.executable);
274 cmd.arg("run")
275 .arg("--enable-asserts")
276 .arg("standalone.dart")
277 .stdout(Stdio::piped())
278 .stderr(Stdio::piped())
279 .current_dir(self.workspace.path());
280 let output = cmd.output().with_context(|| {
281 format!(
282 "failed to execute {} for Dart standalone program",
283 self.executable.display()
284 )
285 })?;
286
287 let outcome = ExecutionOutcome {
288 language: self.language_id().to_string(),
289 exit_code: output.status.code(),
290 stdout: normalize_output(&output.stdout),
291 stderr: normalize_output(&output.stderr),
292 duration: start.elapsed(),
293 };
294
295 let _ = fs::remove_file(&path);
296
297 Ok(outcome)
298 }
299
300 fn run_current(&mut self, start: Instant) -> Result<(ExecutionOutcome, bool)> {
301 self.persist_source()?;
302 let output = self.run_program()?;
303 let stdout_full = normalize_output(&output.stdout);
304 let stderr_full = normalize_output(&output.stderr);
305
306 let stdout_delta = diff_output(&self.previous_stdout, &stdout_full);
307 let stderr_delta = diff_output(&self.previous_stderr, &stderr_full);
308
309 let success = output.status.success();
310 if success {
311 self.previous_stdout = stdout_full;
312 self.previous_stderr = stderr_full;
313 }
314
315 let outcome = ExecutionOutcome {
316 language: "dart".to_string(),
317 exit_code: output.status.code(),
318 stdout: stdout_delta,
319 stderr: stderr_delta,
320 duration: start.elapsed(),
321 };
322
323 Ok((outcome, success))
324 }
325
326 fn apply_import(&mut self, code: &str) -> Result<(ExecutionOutcome, bool)> {
327 let mut updated = false;
328 for line in code.lines() {
329 let trimmed = line.trim();
330 if trimmed.is_empty() {
331 continue;
332 }
333 let statement = if trimmed.ends_with(';') {
334 trimmed.to_string()
335 } else {
336 format!("{};", trimmed)
337 };
338 if self.imports.insert(statement) {
339 updated = true;
340 }
341 }
342 if !updated {
343 return Ok((
344 ExecutionOutcome {
345 language: "dart".to_string(),
346 exit_code: None,
347 stdout: String::new(),
348 stderr: String::new(),
349 duration: Duration::default(),
350 },
351 true,
352 ));
353 }
354
355 let start = Instant::now();
356 let (outcome, success) = self.run_current(start)?;
357 if !success {
358 for line in code.lines() {
359 let trimmed = line.trim();
360 if trimmed.is_empty() {
361 continue;
362 }
363 let statement = if trimmed.ends_with(';') {
364 trimmed.to_string()
365 } else {
366 format!("{};", trimmed)
367 };
368 self.imports.remove(&statement);
369 }
370 self.persist_source()?;
371 }
372 Ok((outcome, success))
373 }
374
375 fn apply_declaration(&mut self, code: &str) -> Result<(ExecutionOutcome, bool)> {
376 let snippet = ensure_trailing_newline(code);
377 self.declarations.push(snippet);
378 let start = Instant::now();
379 let (outcome, success) = self.run_current(start)?;
380 if !success {
381 let _ = self.declarations.pop();
382 self.persist_source()?;
383 }
384 Ok((outcome, success))
385 }
386
387 fn apply_statement(&mut self, code: &str) -> Result<(ExecutionOutcome, bool)> {
388 self.statements.push(ensure_trailing_semicolon(code));
389 let start = Instant::now();
390 let (outcome, success) = self.run_current(start)?;
391 if !success {
392 let _ = self.statements.pop();
393 self.persist_source()?;
394 }
395 Ok((outcome, success))
396 }
397
398 fn apply_expression(&mut self, code: &str) -> Result<(ExecutionOutcome, bool)> {
399 self.statements.push(wrap_expression(code));
400 let start = Instant::now();
401 let (outcome, success) = self.run_current(start)?;
402 if !success {
403 let _ = self.statements.pop();
404 self.persist_source()?;
405 }
406 Ok((outcome, success))
407 }
408
409 fn reset(&mut self) -> Result<()> {
410 self.imports.clear();
411 self.declarations.clear();
412 self.statements.clear();
413 self.previous_stdout.clear();
414 self.previous_stderr.clear();
415 self.persist_source()
416 }
417}
418
419impl LanguageSession for DartSession {
420 fn language_id(&self) -> &str {
421 "dart"
422 }
423
424 fn eval(&mut self, code: &str) -> Result<ExecutionOutcome> {
425 let trimmed = code.trim();
426 if trimmed.is_empty() {
427 return Ok(ExecutionOutcome {
428 language: "dart".to_string(),
429 exit_code: None,
430 stdout: String::new(),
431 stderr: String::new(),
432 duration: Duration::default(),
433 });
434 }
435
436 if trimmed.eq_ignore_ascii_case(":reset") {
437 self.reset()?;
438 return Ok(ExecutionOutcome {
439 language: "dart".to_string(),
440 exit_code: None,
441 stdout: String::new(),
442 stderr: String::new(),
443 duration: Duration::default(),
444 });
445 }
446
447 if trimmed.eq_ignore_ascii_case(":help") {
448 return Ok(ExecutionOutcome {
449 language: "dart".to_string(),
450 exit_code: None,
451 stdout:
452 "Dart commands:\n :reset - clear session state\n :help - show this message\n"
453 .to_string(),
454 stderr: String::new(),
455 duration: Duration::default(),
456 });
457 }
458
459 if contains_main(code) {
460 return self.run_standalone_program(code);
461 }
462
463 match classify_snippet(trimmed) {
464 DartSnippet::Import => {
465 let (outcome, success) = self.apply_import(code)?;
466 if !success {
467 return Ok(outcome);
468 }
469 Ok(outcome)
470 }
471 DartSnippet::Declaration => {
472 let (outcome, _) = self.apply_declaration(code)?;
473 Ok(outcome)
474 }
475 DartSnippet::Expression => {
476 let (outcome, _) = self.apply_expression(trimmed)?;
477 Ok(outcome)
478 }
479 DartSnippet::Statement => {
480 let (outcome, _) = self.apply_statement(code)?;
481 Ok(outcome)
482 }
483 }
484 }
485
486 fn shutdown(&mut self) -> Result<()> {
487 Ok(())
488 }
489}
490
491enum DartSnippet {
492 Import,
493 Declaration,
494 Statement,
495 Expression,
496}
497
498fn classify_snippet(code: &str) -> DartSnippet {
499 if is_import(code) {
500 return DartSnippet::Import;
501 }
502
503 if is_declaration(code) {
504 return DartSnippet::Declaration;
505 }
506
507 if should_wrap_expression(code) {
508 return DartSnippet::Expression;
509 }
510
511 DartSnippet::Statement
512}
513
514fn is_import(code: &str) -> bool {
515 code.lines().all(|line| {
516 let trimmed = line.trim_start();
517 trimmed.starts_with("import ")
518 || trimmed.starts_with("export ")
519 || trimmed.starts_with("part ")
520 || trimmed.starts_with("part of ")
521 })
522}
523
524fn is_declaration(code: &str) -> bool {
525 let lowered = code.trim_start().to_ascii_lowercase();
526 const PREFIXES: [&str; 9] = [
527 "class ",
528 "enum ",
529 "typedef ",
530 "extension ",
531 "mixin ",
532 "void ",
533 "Future<",
534 "Future<void> ",
535 "@",
536 ];
537 PREFIXES.iter().any(|prefix| lowered.starts_with(prefix)) && !contains_main(code)
538}
539
540fn should_wrap_expression(code: &str) -> bool {
541 if code.contains('\n') {
542 return false;
543 }
544
545 let trimmed = code.trim();
546 if trimmed.is_empty() {
547 return false;
548 }
549
550 if trimmed.ends_with(';') {
551 return false;
552 }
553
554 let lowered = trimmed.to_ascii_lowercase();
555 const STATEMENT_PREFIXES: [&str; 12] = [
556 "var ", "final ", "const ", "if ", "for ", "while ", "do ", "switch ", "return ", "throw ",
557 "await ", "yield ",
558 ];
559 if STATEMENT_PREFIXES
560 .iter()
561 .any(|prefix| lowered.starts_with(prefix))
562 {
563 return false;
564 }
565
566 true
567}
568
569fn ensure_trailing_newline(code: &str) -> String {
570 let mut owned = code.to_string();
571 if !owned.ends_with('\n') {
572 owned.push('\n');
573 }
574 owned
575}
576
577fn ensure_trailing_semicolon(code: &str) -> String {
578 let lines: Vec<&str> = code.lines().collect();
579 if lines.is_empty() {
580 return ensure_trailing_newline(code);
581 }
582
583 let mut result = String::new();
584 for (idx, line) in lines.iter().enumerate() {
585 let trimmed_end = line.trim_end();
586 if trimmed_end.is_empty() {
587 result.push_str(line);
588 } else if trimmed_end.ends_with(';')
589 || trimmed_end.ends_with('}')
590 || trimmed_end.ends_with('{')
591 || trimmed_end.trim_start().starts_with("//")
592 {
593 result.push_str(trimmed_end);
594 } else {
595 result.push_str(trimmed_end);
596 result.push(';');
597 }
598
599 if idx + 1 < lines.len() {
600 result.push('\n');
601 }
602 }
603
604 ensure_trailing_newline(&result)
605}
606
607fn wrap_expression(code: &str) -> String {
608 format!("print(({}));\n", code)
609}
610
611fn diff_output(previous: &str, current: &str) -> String {
612 if let Some(stripped) = current.strip_prefix(previous) {
613 stripped.to_string()
614 } else {
615 current.to_string()
616 }
617}
618
619fn normalize_output(bytes: &[u8]) -> String {
620 String::from_utf8_lossy(bytes)
621 .replace("\r\n", "\n")
622 .replace('\r', "")
623}