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}