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