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            // remove the imports we just inserted
353            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}