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}