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