Skip to main content

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