run/engine/
nim.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 NimEngine {
13    executable: Option<PathBuf>,
14}
15
16impl NimEngine {
17    pub fn new() -> Self {
18        Self {
19            executable: resolve_nim_binary(),
20        }
21    }
22
23    fn ensure_executable(&self) -> Result<&Path> {
24        self.executable.as_deref().ok_or_else(|| {
25            anyhow::anyhow!(
26                "Nim support requires the `nim` executable. Install it from https://nim-lang.org/install.html and ensure it is on your PATH."
27            )
28        })
29    }
30
31    fn write_temp_source(&self, code: &str) -> Result<(TempDir, PathBuf)> {
32        let dir = Builder::new()
33            .prefix("run-nim")
34            .tempdir()
35            .context("failed to create temporary directory for Nim source")?;
36        let path = dir.path().join("snippet.nim");
37        let mut contents = code.to_string();
38        if !contents.ends_with('\n') {
39            contents.push('\n');
40        }
41        std::fs::write(&path, contents).with_context(|| {
42            format!("failed to write temporary Nim source to {}", path.display())
43        })?;
44        Ok((dir, path))
45    }
46
47    fn run_source(&self, source: &Path) -> Result<std::process::Output> {
48        let executable = self.ensure_executable()?;
49        let mut cmd = Command::new(executable);
50        cmd.arg("r")
51            .arg(source)
52            .arg("--colors:off")
53            .arg("--hints:off")
54            .arg("--verbosity:0")
55            .stdout(Stdio::piped())
56            .stderr(Stdio::piped());
57        cmd.stdin(Stdio::inherit());
58        if let Some(dir) = source.parent() {
59            cmd.current_dir(dir);
60        }
61        cmd.output().with_context(|| {
62            format!(
63                "failed to execute {} with source {}",
64                executable.display(),
65                source.display()
66            )
67        })
68    }
69}
70
71impl LanguageEngine for NimEngine {
72    fn id(&self) -> &'static str {
73        "nim"
74    }
75
76    fn display_name(&self) -> &'static str {
77        "Nim"
78    }
79
80    fn aliases(&self) -> &[&'static str] {
81        &["nimlang"]
82    }
83
84    fn supports_sessions(&self) -> bool {
85        self.executable.is_some()
86    }
87
88    fn validate(&self) -> Result<()> {
89        let executable = self.ensure_executable()?;
90        let mut cmd = Command::new(executable);
91        cmd.arg("--version")
92            .stdout(Stdio::null())
93            .stderr(Stdio::null());
94        cmd.status()
95            .with_context(|| format!("failed to invoke {}", executable.display()))?
96            .success()
97            .then_some(())
98            .ok_or_else(|| anyhow::anyhow!("{} is not executable", executable.display()))
99    }
100
101    fn execute(&self, payload: &ExecutionPayload) -> Result<ExecutionOutcome> {
102        let start = Instant::now();
103        let (temp_dir, source_path) = match payload {
104            ExecutionPayload::Inline { code } | ExecutionPayload::Stdin { code } => {
105                let (dir, path) = self.write_temp_source(code)?;
106                (Some(dir), path)
107            }
108            ExecutionPayload::File { path } => {
109                if path.extension().and_then(|e| e.to_str()) != Some("nim") {
110                    let code = std::fs::read_to_string(path)?;
111                    let (dir, new_path) = self.write_temp_source(&code)?;
112                    (Some(dir), new_path)
113                } else {
114                    (None, path.clone())
115                }
116            }
117        };
118
119        let output = self.run_source(&source_path)?;
120        drop(temp_dir);
121
122        Ok(ExecutionOutcome {
123            language: self.id().to_string(),
124            exit_code: output.status.code(),
125            stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
126            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
127            duration: start.elapsed(),
128        })
129    }
130
131    fn start_session(&self) -> Result<Box<dyn LanguageSession>> {
132        let executable = self.ensure_executable()?.to_path_buf();
133        Ok(Box::new(NimSession::new(executable)?))
134    }
135}
136
137fn resolve_nim_binary() -> Option<PathBuf> {
138    which::which("nim").ok()
139}
140
141struct NimSession {
142    executable: PathBuf,
143    workspace: TempDir,
144    snippets: Vec<String>,
145    last_stdout: String,
146    last_stderr: String,
147}
148
149impl NimSession {
150    fn new(executable: PathBuf) -> Result<Self> {
151        let workspace = TempDir::new().context("failed to create Nim session workspace")?;
152        let session = Self {
153            executable,
154            workspace,
155            snippets: Vec::new(),
156            last_stdout: String::new(),
157            last_stderr: String::new(),
158        };
159        session.persist_source()?;
160        Ok(session)
161    }
162
163    fn source_path(&self) -> PathBuf {
164        self.workspace.path().join("session.nim")
165    }
166
167    fn persist_source(&self) -> Result<()> {
168        let source = self.render_source();
169        fs::write(self.source_path(), source)
170            .with_context(|| "failed to write Nim session source".to_string())
171    }
172
173    fn render_source(&self) -> String {
174        if self.snippets.is_empty() {
175            return String::from("# session body\n");
176        }
177
178        let mut source = String::new();
179        for snippet in &self.snippets {
180            source.push_str(snippet);
181            if !snippet.ends_with('\n') {
182                source.push('\n');
183            }
184            source.push('\n');
185        }
186        source
187    }
188
189    fn run_program(&self) -> Result<std::process::Output> {
190        let mut cmd = Command::new(&self.executable);
191        cmd.arg("r")
192            .arg("session.nim")
193            .arg("--colors:off")
194            .arg("--hints:off")
195            .arg("--verbosity:0")
196            .stdout(Stdio::piped())
197            .stderr(Stdio::piped())
198            .current_dir(self.workspace.path());
199        cmd.output().with_context(|| {
200            format!(
201                "failed to execute {} for Nim session",
202                self.executable.display()
203            )
204        })
205    }
206
207    fn run_current(&mut self, start: Instant) -> Result<(ExecutionOutcome, bool)> {
208        self.persist_source()?;
209        let output = self.run_program()?;
210        let stdout_full = Self::normalize_output(&output.stdout);
211        let stderr_raw = Self::normalize_output(&output.stderr);
212        let stderr_filtered = filter_nim_stderr(&stderr_raw);
213
214        let success = output.status.success();
215        let (stdout, stderr) = if success {
216            let stdout_delta = Self::diff_outputs(&self.last_stdout, &stdout_full);
217            let stderr_delta = Self::diff_outputs(&self.last_stderr, &stderr_filtered);
218            self.last_stdout = stdout_full;
219            self.last_stderr = stderr_filtered;
220            (stdout_delta, stderr_delta)
221        } else {
222            (stdout_full, stderr_raw)
223        };
224
225        let outcome = ExecutionOutcome {
226            language: "nim".to_string(),
227            exit_code: output.status.code(),
228            stdout,
229            stderr,
230            duration: start.elapsed(),
231        };
232
233        Ok((outcome, success))
234    }
235
236    fn apply_snippet(&mut self, snippet: String) -> Result<(ExecutionOutcome, bool)> {
237        self.snippets.push(snippet);
238        let start = Instant::now();
239        let (outcome, success) = self.run_current(start)?;
240        if !success {
241            let _ = self.snippets.pop();
242            self.persist_source()?;
243        }
244        Ok((outcome, success))
245    }
246
247    fn reset(&mut self) -> Result<()> {
248        self.snippets.clear();
249        self.last_stdout.clear();
250        self.last_stderr.clear();
251        self.persist_source()
252    }
253
254    fn normalize_output(bytes: &[u8]) -> String {
255        String::from_utf8_lossy(bytes)
256            .replace("\r\n", "\n")
257            .replace('\r', "")
258    }
259
260    fn diff_outputs(previous: &str, current: &str) -> String {
261        current
262            .strip_prefix(previous)
263            .map(|s| s.to_string())
264            .unwrap_or_else(|| current.to_string())
265    }
266}
267
268impl LanguageSession for NimSession {
269    fn language_id(&self) -> &str {
270        "nim"
271    }
272
273    fn eval(&mut self, code: &str) -> Result<ExecutionOutcome> {
274        let trimmed = code.trim();
275        if trimmed.is_empty() {
276            return Ok(ExecutionOutcome {
277                language: "nim".to_string(),
278                exit_code: None,
279                stdout: String::new(),
280                stderr: String::new(),
281                duration: Duration::default(),
282            });
283        }
284
285        if trimmed.eq_ignore_ascii_case(":reset") {
286            self.reset()?;
287            return Ok(ExecutionOutcome {
288                language: "nim".to_string(),
289                exit_code: None,
290                stdout: String::new(),
291                stderr: String::new(),
292                duration: Duration::default(),
293            });
294        }
295
296        if trimmed.eq_ignore_ascii_case(":help") {
297            return Ok(ExecutionOutcome {
298                language: "nim".to_string(),
299                exit_code: None,
300                stdout:
301                    "Nim commands:\n  :reset — clear session state\n  :help  — show this message\n"
302                        .to_string(),
303                stderr: String::new(),
304                duration: Duration::default(),
305            });
306        }
307
308        let snippet = match classify_nim_snippet(trimmed) {
309            NimSnippetKind::Statement => prepare_statement(code),
310            NimSnippetKind::Expression => wrap_expression(trimmed),
311        };
312
313        let (outcome, _) = self.apply_snippet(snippet)?;
314        Ok(outcome)
315    }
316
317    fn shutdown(&mut self) -> Result<()> {
318        Ok(())
319    }
320}
321
322enum NimSnippetKind {
323    Statement,
324    Expression,
325}
326
327fn classify_nim_snippet(code: &str) -> NimSnippetKind {
328    if looks_like_nim_statement(code) {
329        NimSnippetKind::Statement
330    } else {
331        NimSnippetKind::Expression
332    }
333}
334
335fn looks_like_nim_statement(code: &str) -> bool {
336    let trimmed = code.trim_start();
337    trimmed.contains('\n')
338        || trimmed.ends_with(';')
339        || trimmed.ends_with(':')
340        || trimmed.starts_with("#")
341        || trimmed.starts_with("import ")
342        || trimmed.starts_with("from ")
343        || trimmed.starts_with("include ")
344        || trimmed.starts_with("let ")
345        || trimmed.starts_with("var ")
346        || trimmed.starts_with("const ")
347        || trimmed.starts_with("type ")
348        || trimmed.starts_with("proc ")
349        || trimmed.starts_with("iterator ")
350        || trimmed.starts_with("macro ")
351        || trimmed.starts_with("template ")
352        || trimmed.starts_with("when ")
353        || trimmed.starts_with("block ")
354        || trimmed.starts_with("if ")
355        || trimmed.starts_with("for ")
356        || trimmed.starts_with("while ")
357        || trimmed.starts_with("case ")
358}
359
360fn ensure_trailing_newline(code: &str) -> String {
361    let mut snippet = code.to_string();
362    if !snippet.ends_with('\n') {
363        snippet.push('\n');
364    }
365    snippet
366}
367
368fn prepare_statement(code: &str) -> String {
369    let mut snippet = ensure_trailing_newline(code);
370    let identifiers = collect_declared_identifiers(code);
371    if identifiers.is_empty() {
372        return snippet;
373    }
374
375    for name in identifiers {
376        snippet.push_str("discard ");
377        snippet.push_str(&name);
378        snippet.push('\n');
379    }
380
381    snippet
382}
383
384fn wrap_expression(code: &str) -> String {
385    format!("echo ({})\n", code)
386}
387
388fn collect_declared_identifiers(code: &str) -> Vec<String> {
389    let mut identifiers = BTreeSet::new();
390
391    for line in code.lines() {
392        let trimmed = line.trim_start();
393        let rest = if let Some(stripped) = trimmed.strip_prefix("let ") {
394            stripped
395        } else if let Some(stripped) = trimmed.strip_prefix("var ") {
396            stripped
397        } else if let Some(stripped) = trimmed.strip_prefix("const ") {
398            stripped
399        } else {
400            continue;
401        };
402
403        let before_comment = rest.split('#').next().unwrap_or(rest);
404        let declaration_part = before_comment.split('=').next().unwrap_or(before_comment);
405
406        for segment in declaration_part.split(',') {
407            let mut candidate = segment.trim();
408            if candidate.is_empty() {
409                continue;
410            }
411
412            candidate = candidate.trim_matches('`');
413            candidate = candidate.trim_end_matches('*');
414            candidate = candidate.trim();
415
416            if candidate.is_empty() {
417                continue;
418            }
419
420            let mut name = String::new();
421            for ch in candidate.chars() {
422                if is_nim_identifier_part(ch) {
423                    name.push(ch);
424                } else {
425                    break;
426                }
427            }
428
429            if name.is_empty() {
430                continue;
431            }
432
433            if !is_nim_identifier_start(name.chars().next().unwrap()) {
434                continue;
435            }
436
437            identifiers.insert(name);
438        }
439    }
440
441    identifiers.into_iter().collect()
442}
443
444fn is_nim_identifier_start(ch: char) -> bool {
445    ch == '_' || ch.is_ascii_alphabetic()
446}
447
448fn is_nim_identifier_part(ch: char) -> bool {
449    is_nim_identifier_start(ch) || ch.is_ascii_digit()
450}
451
452fn filter_nim_stderr(stderr: &str) -> String {
453    stderr
454        .lines()
455        .filter(|line| {
456            let trimmed = line.trim();
457            if trimmed.is_empty() {
458                return false;
459            }
460            if trimmed.chars().all(|c| c == '.') {
461                return false;
462            }
463            if trimmed.starts_with("Hint: used config file") {
464                return false;
465            }
466            if trimmed.starts_with("Hint:  [Link]") {
467                return false;
468            }
469            if trimmed.starts_with("Hint: mm: ") {
470                return false;
471            }
472            if (trimmed.starts_with("Hint: ")
473                || trimmed.chars().next().map_or(false, |c| c.is_ascii_digit()))
474                && (trimmed.contains(" lines;")
475                    || trimmed.contains(" proj:")
476                    || trimmed.contains(" out:")
477                    || trimmed.contains("Success")
478                    || trimmed.contains("[Success"))
479            {
480                return false;
481            }
482            if trimmed.starts_with("Hint: /") && trimmed.contains("--colors:off") {
483                return false;
484            }
485            if trimmed.starts_with("CC: ") {
486                return false;
487            }
488
489            true
490        })
491        .map(|line| line.to_string())
492        .collect::<Vec<_>>()
493        .join("\n")
494}