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