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::{
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}