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