Skip to main content

run/engine/
dart.rs

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}