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}