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() {
354 let trimmed = line.trim();
355 if trimmed.is_empty() {
356 continue;
357 }
358 let statement = if trimmed.ends_with(';') {
359 trimmed.to_string()
360 } else {
361 format!("{};", trimmed)
362 };
363 self.imports.remove(&statement);
364 }
365 self.persist_source()?;
366 }
367 Ok((outcome, success))
368 }
369
370 fn apply_declaration(&mut self, code: &str) -> Result<(ExecutionOutcome, bool)> {
371 let snippet = ensure_trailing_newline(code);
372 self.declarations.push(snippet);
373 let start = Instant::now();
374 let (outcome, success) = self.run_current(start)?;
375 if !success {
376 let _ = self.declarations.pop();
377 self.persist_source()?;
378 }
379 Ok((outcome, success))
380 }
381
382 fn apply_statement(&mut self, code: &str) -> Result<(ExecutionOutcome, bool)> {
383 self.statements.push(ensure_trailing_semicolon(code));
384 let start = Instant::now();
385 let (outcome, success) = self.run_current(start)?;
386 if !success {
387 let _ = self.statements.pop();
388 self.persist_source()?;
389 }
390 Ok((outcome, success))
391 }
392
393 fn apply_expression(&mut self, code: &str) -> Result<(ExecutionOutcome, bool)> {
394 self.statements.push(wrap_expression(code));
395 let start = Instant::now();
396 let (outcome, success) = self.run_current(start)?;
397 if !success {
398 let _ = self.statements.pop();
399 self.persist_source()?;
400 }
401 Ok((outcome, success))
402 }
403
404 fn reset(&mut self) -> Result<()> {
405 self.imports.clear();
406 self.declarations.clear();
407 self.statements.clear();
408 self.previous_stdout.clear();
409 self.previous_stderr.clear();
410 self.persist_source()
411 }
412}
413
414impl LanguageSession for DartSession {
415 fn language_id(&self) -> &str {
416 "dart"
417 }
418
419 fn eval(&mut self, code: &str) -> Result<ExecutionOutcome> {
420 let trimmed = code.trim();
421 if trimmed.is_empty() {
422 return Ok(ExecutionOutcome {
423 language: "dart".to_string(),
424 exit_code: None,
425 stdout: String::new(),
426 stderr: String::new(),
427 duration: Duration::default(),
428 });
429 }
430
431 if trimmed.eq_ignore_ascii_case(":reset") {
432 self.reset()?;
433 return Ok(ExecutionOutcome {
434 language: "dart".to_string(),
435 exit_code: None,
436 stdout: String::new(),
437 stderr: String::new(),
438 duration: Duration::default(),
439 });
440 }
441
442 if trimmed.eq_ignore_ascii_case(":help") {
443 return Ok(ExecutionOutcome {
444 language: "dart".to_string(),
445 exit_code: None,
446 stdout:
447 "Dart commands:\n :reset — clear session state\n :help — show this message\n"
448 .to_string(),
449 stderr: String::new(),
450 duration: Duration::default(),
451 });
452 }
453
454 if contains_main(code) {
455 return self.run_standalone_program(code);
456 }
457
458 match classify_snippet(trimmed) {
459 DartSnippet::Import => {
460 let (outcome, success) = self.apply_import(code)?;
461 if !success {
462 return Ok(outcome);
463 }
464 Ok(outcome)
465 }
466 DartSnippet::Declaration => {
467 let (outcome, _) = self.apply_declaration(code)?;
468 Ok(outcome)
469 }
470 DartSnippet::Expression => {
471 let (outcome, _) = self.apply_expression(trimmed)?;
472 Ok(outcome)
473 }
474 DartSnippet::Statement => {
475 let (outcome, _) = self.apply_statement(code)?;
476 Ok(outcome)
477 }
478 }
479 }
480
481 fn shutdown(&mut self) -> Result<()> {
482 Ok(())
483 }
484}
485
486enum DartSnippet {
487 Import,
488 Declaration,
489 Statement,
490 Expression,
491}
492
493fn classify_snippet(code: &str) -> DartSnippet {
494 if is_import(code) {
495 return DartSnippet::Import;
496 }
497
498 if is_declaration(code) {
499 return DartSnippet::Declaration;
500 }
501
502 if should_wrap_expression(code) {
503 return DartSnippet::Expression;
504 }
505
506 DartSnippet::Statement
507}
508
509fn is_import(code: &str) -> bool {
510 code.lines().all(|line| {
511 let trimmed = line.trim_start();
512 trimmed.starts_with("import ")
513 || trimmed.starts_with("export ")
514 || trimmed.starts_with("part ")
515 || trimmed.starts_with("part of ")
516 })
517}
518
519fn is_declaration(code: &str) -> bool {
520 let lowered = code.trim_start().to_ascii_lowercase();
521 const PREFIXES: [&str; 9] = [
522 "class ",
523 "enum ",
524 "typedef ",
525 "extension ",
526 "mixin ",
527 "void ",
528 "Future<",
529 "Future<void> ",
530 "@",
531 ];
532 PREFIXES.iter().any(|prefix| lowered.starts_with(prefix)) && !contains_main(code)
533}
534
535fn should_wrap_expression(code: &str) -> bool {
536 if code.contains('\n') {
537 return false;
538 }
539
540 let trimmed = code.trim();
541 if trimmed.is_empty() {
542 return false;
543 }
544
545 if trimmed.ends_with(';') {
546 return false;
547 }
548
549 let lowered = trimmed.to_ascii_lowercase();
550 const STATEMENT_PREFIXES: [&str; 12] = [
551 "var ", "final ", "const ", "if ", "for ", "while ", "do ", "switch ", "return ", "throw ",
552 "await ", "yield ",
553 ];
554 if STATEMENT_PREFIXES
555 .iter()
556 .any(|prefix| lowered.starts_with(prefix))
557 {
558 return false;
559 }
560
561 true
562}
563
564fn ensure_trailing_newline(code: &str) -> String {
565 let mut owned = code.to_string();
566 if !owned.ends_with('\n') {
567 owned.push('\n');
568 }
569 owned
570}
571
572fn ensure_trailing_semicolon(code: &str) -> String {
573 let lines: Vec<&str> = code.lines().collect();
574 if lines.is_empty() {
575 return ensure_trailing_newline(code);
576 }
577
578 let mut result = String::new();
579 for (idx, line) in lines.iter().enumerate() {
580 let trimmed_end = line.trim_end();
581 if trimmed_end.is_empty() {
582 result.push_str(line);
583 } else if trimmed_end.ends_with(';')
584 || trimmed_end.ends_with('}')
585 || trimmed_end.ends_with('{')
586 || trimmed_end.trim_start().starts_with("//")
587 {
588 result.push_str(trimmed_end);
589 } else {
590 result.push_str(trimmed_end);
591 result.push(';');
592 }
593
594 if idx + 1 < lines.len() {
595 result.push('\n');
596 }
597 }
598
599 ensure_trailing_newline(&result)
600}
601
602fn wrap_expression(code: &str) -> String {
603 format!("print(({}));\n", code)
604}
605
606fn diff_output(previous: &str, current: &str) -> String {
607 if let Some(stripped) = current.strip_prefix(previous) {
608 stripped.to_string()
609 } else {
610 current.to_string()
611 }
612}
613
614fn normalize_output(bytes: &[u8]) -> String {
615 String::from_utf8_lossy(bytes)
616 .replace("\r\n", "\n")
617 .replace('\r', "")
618}