1use std::fs;
2use std::path::{Path, PathBuf};
3use std::process::{Command, Stdio};
4use std::time::{Duration, Instant};
5
6use anyhow::{Context, Result};
7use tempfile::{Builder, TempDir};
8
9use super::{
10 ExecutionOutcome, ExecutionPayload, LanguageEngine, LanguageSession,
11 cache_store, hash_source, try_cached_execution,
12};
13
14pub struct ZigEngine {
15 executable: Option<PathBuf>,
16}
17
18impl ZigEngine {
19 pub fn new() -> Self {
20 Self {
21 executable: resolve_zig_binary(),
22 }
23 }
24
25 fn ensure_executable(&self) -> Result<&Path> {
26 self.executable.as_deref().ok_or_else(|| {
27 anyhow::anyhow!(
28 "Zig support requires the `zig` executable. Install it from https://ziglang.org/download/ and ensure it is on your PATH."
29 )
30 })
31 }
32
33 fn write_temp_source(&self, code: &str) -> Result<(TempDir, PathBuf)> {
34 let dir = Builder::new()
35 .prefix("run-zig")
36 .tempdir()
37 .context("failed to create temporary directory for Zig source")?;
38 let path = dir.path().join("snippet.zig");
39 let mut contents = code.to_string();
40 if !contents.ends_with('\n') {
41 contents.push('\n');
42 }
43 std::fs::write(&path, contents).with_context(|| {
44 format!("failed to write temporary Zig source to {}", path.display())
45 })?;
46 Ok((dir, path))
47 }
48
49 fn run_source(&self, source: &Path) -> Result<std::process::Output> {
50 let executable = self.ensure_executable()?;
51 let mut cmd = Command::new(executable);
52 cmd.arg("run")
53 .arg(source)
54 .stdout(Stdio::piped())
55 .stderr(Stdio::piped());
56 cmd.stdin(Stdio::inherit());
57 if let Some(dir) = source.parent() {
58 cmd.current_dir(dir);
59 }
60 cmd.output().with_context(|| {
61 format!(
62 "failed to execute {} with source {}",
63 executable.display(),
64 source.display()
65 )
66 })
67 }
68}
69
70impl LanguageEngine for ZigEngine {
71 fn id(&self) -> &'static str {
72 "zig"
73 }
74
75 fn display_name(&self) -> &'static str {
76 "Zig"
77 }
78
79 fn aliases(&self) -> &[&'static str] {
80 &["ziglang"]
81 }
82
83 fn supports_sessions(&self) -> bool {
84 self.executable.is_some()
85 }
86
87 fn validate(&self) -> Result<()> {
88 let executable = self.ensure_executable()?;
89 let mut cmd = Command::new(executable);
90 cmd.arg("version")
91 .stdout(Stdio::null())
92 .stderr(Stdio::null());
93 cmd.status()
94 .with_context(|| format!("failed to invoke {}", executable.display()))?
95 .success()
96 .then_some(())
97 .ok_or_else(|| anyhow::anyhow!("{} is not executable", executable.display()))
98 }
99
100 fn execute(&self, payload: &ExecutionPayload) -> Result<ExecutionOutcome> {
101 if let Some(code) = match payload {
103 ExecutionPayload::Inline { code } | ExecutionPayload::Stdin { code } => Some(code.as_str()),
104 _ => None,
105 } {
106 let snippet = wrap_inline_snippet(code);
107 let src_hash = hash_source(&snippet);
108 if let Some(output) = try_cached_execution(src_hash) {
109 let start = Instant::now();
110 return Ok(ExecutionOutcome {
111 language: self.id().to_string(),
112 exit_code: output.status.code(),
113 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
114 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
115 duration: start.elapsed(),
116 });
117 }
118 }
119
120 let start = Instant::now();
121 let (temp_dir, source_path, cache_key) = match payload {
122 ExecutionPayload::Inline { code } | ExecutionPayload::Stdin { code } => {
123 let snippet = wrap_inline_snippet(code);
124 let h = hash_source(&snippet);
125 let (dir, path) = self.write_temp_source(&snippet)?;
126 (Some(dir), path, Some(h))
127 }
128 ExecutionPayload::File { path } => {
129 if path.extension().and_then(|e| e.to_str()) != Some("zig") {
130 let code = std::fs::read_to_string(path)?;
131 let (dir, new_path) = self.write_temp_source(&code)?;
132 (Some(dir), new_path, None)
133 } else {
134 (None, path.clone(), None)
135 }
136 }
137 };
138
139 if let Some(h) = cache_key {
141 let executable = self.ensure_executable()?;
142 let dir = source_path.parent().unwrap_or(std::path::Path::new("."));
143 let bin_path = dir.join("snippet");
144 let mut build_cmd = Command::new(executable);
145 build_cmd
146 .arg("build-exe")
147 .arg(&source_path)
148 .arg("-femit-bin=snippet")
149 .stdout(Stdio::piped())
150 .stderr(Stdio::piped())
151 .current_dir(dir);
152
153 if let Ok(build_output) = build_cmd.output() {
154 if build_output.status.success() && bin_path.exists() {
155 cache_store(h, &bin_path);
156 let mut run_cmd = Command::new(&bin_path);
157 run_cmd
158 .stdout(Stdio::piped())
159 .stderr(Stdio::piped())
160 .stdin(Stdio::inherit());
161 if let Ok(output) = run_cmd.output() {
162 drop(temp_dir);
163 return Ok(ExecutionOutcome {
164 language: self.id().to_string(),
165 exit_code: output.status.code(),
166 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
167 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
168 duration: start.elapsed(),
169 });
170 }
171 }
172 }
173 }
174
175 let output = self.run_source(&source_path)?;
177 drop(temp_dir);
178
179 let mut combined_stdout = String::from_utf8_lossy(&output.stdout).into_owned();
180 let stderr_str = String::from_utf8_lossy(&output.stderr).into_owned();
181
182 if output.status.success() && !stderr_str.contains("error:") {
183 if !combined_stdout.is_empty() && !stderr_str.is_empty() {
184 combined_stdout.push_str(&stderr_str);
185 } else if combined_stdout.is_empty() {
186 combined_stdout = stderr_str.clone();
187 }
188 }
189
190 Ok(ExecutionOutcome {
191 language: self.id().to_string(),
192 exit_code: output.status.code(),
193 stdout: combined_stdout,
194 stderr: if output.status.success() && !stderr_str.contains("error:") {
195 String::new()
196 } else {
197 stderr_str
198 },
199 duration: start.elapsed(),
200 })
201 }
202
203 fn start_session(&self) -> Result<Box<dyn LanguageSession>> {
204 let executable = self.ensure_executable()?.to_path_buf();
205 Ok(Box::new(ZigSession::new(executable)?))
206 }
207}
208
209fn resolve_zig_binary() -> Option<PathBuf> {
210 which::which("zig").ok()
211}
212
213const ZIG_NUMERIC_SUFFIXES: [&str; 17] = [
214 "usize", "isize", "u128", "i128", "f128", "f80", "u64", "i64", "f64", "u32", "i32", "f32",
215 "u16", "i16", "f16", "u8", "i8",
216];
217
218fn wrap_inline_snippet(code: &str) -> String {
219 let trimmed = code.trim();
220 if trimmed.is_empty() || trimmed.contains("pub fn main") {
221 let mut owned = code.to_string();
222 if !owned.ends_with('\n') {
223 owned.push('\n');
224 }
225 return owned;
226 }
227
228 let mut body = String::new();
229 for line in code.lines() {
230 body.push_str(" ");
231 body.push_str(line);
232 if !line.ends_with('\n') {
233 body.push('\n');
234 }
235 }
236 if body.is_empty() {
237 body.push_str(" const stdout = std.io.getStdOut().writer(); _ = stdout.print(\"\\n\", .{}) catch {};\n");
238 }
239
240 format!("const std = @import(\"std\");\n\npub fn main() !void {{\n{body}}}\n")
241}
242
243struct ZigSession {
244 executable: PathBuf,
245 workspace: TempDir,
246 items: Vec<String>,
247 statements: Vec<String>,
248 last_stdout: String,
249 last_stderr: String,
250}
251
252enum ZigSnippetKind {
253 Declaration,
254 Statement,
255 Expression,
256}
257
258impl ZigSession {
259 fn new(executable: PathBuf) -> Result<Self> {
260 let workspace = TempDir::new().context("failed to create Zig session workspace")?;
261 let session = Self {
262 executable,
263 workspace,
264 items: Vec::new(),
265 statements: Vec::new(),
266 last_stdout: String::new(),
267 last_stderr: String::new(),
268 };
269 session.persist_source()?;
270 Ok(session)
271 }
272
273 fn source_path(&self) -> PathBuf {
274 self.workspace.path().join("session.zig")
275 }
276
277 fn persist_source(&self) -> Result<()> {
278 let source = self.render_source();
279 fs::write(self.source_path(), source)
280 .with_context(|| "failed to write Zig session source".to_string())
281 }
282
283 fn render_source(&self) -> String {
284 let mut source = String::from("const std = @import(\"std\");\n\n");
285
286 for item in &self.items {
287 source.push_str(item);
288 if !item.ends_with('\n') {
289 source.push('\n');
290 }
291 source.push('\n');
292 }
293
294 source.push_str("pub fn main() !void {\n");
295 if self.statements.is_empty() {
296 source.push_str(" return;\n");
297 } else {
298 for snippet in &self.statements {
299 for line in snippet.lines() {
300 source.push_str(" ");
301 source.push_str(line);
302 source.push('\n');
303 }
304 }
305 }
306 source.push_str("}\n");
307
308 source
309 }
310
311 fn run_program(&self) -> Result<std::process::Output> {
312 let mut cmd = Command::new(&self.executable);
313 cmd.arg("run")
314 .arg("session.zig")
315 .stdout(Stdio::piped())
316 .stderr(Stdio::piped())
317 .current_dir(self.workspace.path());
318 cmd.output().with_context(|| {
319 format!(
320 "failed to execute {} for Zig session",
321 self.executable.display()
322 )
323 })
324 }
325
326 fn run_standalone_program(&self, code: &str) -> Result<ExecutionOutcome> {
327 let start = Instant::now();
328 let path = self.workspace.path().join("standalone.zig");
329 let mut contents = code.to_string();
330 if !contents.ends_with('\n') {
331 contents.push('\n');
332 }
333 fs::write(&path, contents)
334 .with_context(|| "failed to write Zig standalone source".to_string())?;
335
336 let mut cmd = Command::new(&self.executable);
337 cmd.arg("run")
338 .arg("standalone.zig")
339 .stdout(Stdio::piped())
340 .stderr(Stdio::piped())
341 .current_dir(self.workspace.path());
342 let output = cmd.output().with_context(|| {
343 format!(
344 "failed to execute {} for Zig standalone snippet",
345 self.executable.display()
346 )
347 })?;
348
349 let mut stdout = Self::normalize_output(&output.stdout);
350 let stderr = Self::normalize_output(&output.stderr);
351
352 if output.status.success() && !stderr.contains("error:") {
353 if stdout.is_empty() {
354 stdout = stderr.clone();
355 } else {
356 stdout.push_str(&stderr);
357 }
358 }
359
360 Ok(ExecutionOutcome {
361 language: self.language_id().to_string(),
362 exit_code: output.status.code(),
363 stdout,
364 stderr: if output.status.success() && !stderr.contains("error:") {
365 String::new()
366 } else {
367 stderr
368 },
369 duration: start.elapsed(),
370 })
371 }
372
373 fn run_current(&mut self, start: Instant) -> Result<(ExecutionOutcome, bool)> {
374 self.persist_source()?;
375 let output = self.run_program()?;
376 let mut stdout_full = Self::normalize_output(&output.stdout);
377 let stderr_full = Self::normalize_output(&output.stderr);
378
379 let success = output.status.success();
380
381 if success && !stderr_full.is_empty() && !stderr_full.contains("error:") {
382 if stdout_full.is_empty() {
383 stdout_full = stderr_full.clone();
384 } else {
385 stdout_full.push_str(&stderr_full);
386 }
387 }
388
389 let (stdout, stderr) = if success {
390 let stdout_delta = Self::diff_outputs(&self.last_stdout, &stdout_full);
391 let stderr_clean = if !stderr_full.contains("error:") {
392 String::new()
393 } else {
394 stderr_full.clone()
395 };
396 let stderr_delta = Self::diff_outputs(&self.last_stderr, &stderr_clean);
397 self.last_stdout = stdout_full;
398 self.last_stderr = stderr_clean;
399 (stdout_delta, stderr_delta)
400 } else {
401 (stdout_full, stderr_full)
402 };
403
404 let outcome = ExecutionOutcome {
405 language: self.language_id().to_string(),
406 exit_code: output.status.code(),
407 stdout,
408 stderr,
409 duration: start.elapsed(),
410 };
411
412 Ok((outcome, success))
413 }
414
415 fn apply_declaration(&mut self, code: &str) -> Result<(ExecutionOutcome, bool)> {
416 let normalized = normalize_snippet(code);
417 let mut snippet = normalized;
418 if !snippet.ends_with('\n') {
419 snippet.push('\n');
420 }
421 self.items.push(snippet);
422 let start = Instant::now();
423 let (outcome, success) = self.run_current(start)?;
424 if !success {
425 let _ = self.items.pop();
426 self.persist_source()?;
427 }
428 Ok((outcome, success))
429 }
430
431 fn apply_statement(&mut self, code: &str) -> Result<(ExecutionOutcome, bool)> {
432 let normalized = normalize_snippet(code);
433 let snippet = ensure_trailing_newline(&normalized);
434 self.statements.push(snippet);
435 let start = Instant::now();
436 let (outcome, success) = self.run_current(start)?;
437 if !success {
438 let _ = self.statements.pop();
439 self.persist_source()?;
440 }
441 Ok((outcome, success))
442 }
443
444 fn apply_expression(&mut self, code: &str) -> Result<(ExecutionOutcome, bool)> {
445 let normalized = normalize_snippet(code);
446 let wrapped = wrap_expression(&normalized);
447 self.statements.push(wrapped);
448 let start = Instant::now();
449 let (outcome, success) = self.run_current(start)?;
450 if !success {
451 let _ = self.statements.pop();
452 self.persist_source()?;
453 }
454 Ok((outcome, success))
455 }
456
457 fn reset(&mut self) -> Result<()> {
458 self.items.clear();
459 self.statements.clear();
460 self.last_stdout.clear();
461 self.last_stderr.clear();
462 self.persist_source()
463 }
464
465 fn normalize_output(bytes: &[u8]) -> String {
466 String::from_utf8_lossy(bytes)
467 .replace("\r\n", "\n")
468 .replace('\r', "")
469 }
470
471 fn diff_outputs(previous: &str, current: &str) -> String {
472 current
473 .strip_prefix(previous)
474 .map(|s| s.to_string())
475 .unwrap_or_else(|| current.to_string())
476 }
477}
478
479impl LanguageSession for ZigSession {
480 fn language_id(&self) -> &str {
481 "zig"
482 }
483
484 fn eval(&mut self, code: &str) -> Result<ExecutionOutcome> {
485 let trimmed = code.trim();
486 if trimmed.is_empty() {
487 return Ok(ExecutionOutcome {
488 language: self.language_id().to_string(),
489 exit_code: None,
490 stdout: String::new(),
491 stderr: String::new(),
492 duration: Duration::default(),
493 });
494 }
495
496 if trimmed.eq_ignore_ascii_case(":reset") {
497 self.reset()?;
498 return Ok(ExecutionOutcome {
499 language: self.language_id().to_string(),
500 exit_code: None,
501 stdout: String::new(),
502 stderr: String::new(),
503 duration: Duration::default(),
504 });
505 }
506
507 if trimmed.eq_ignore_ascii_case(":help") {
508 return Ok(ExecutionOutcome {
509 language: self.language_id().to_string(),
510 exit_code: None,
511 stdout:
512 "Zig commands:\n :reset - clear session state\n :help - show this message\n"
513 .to_string(),
514 stderr: String::new(),
515 duration: Duration::default(),
516 });
517 }
518
519 if trimmed.contains("pub fn main") {
520 return self.run_standalone_program(code);
521 }
522
523 match classify_snippet(trimmed) {
524 ZigSnippetKind::Declaration => {
525 let (outcome, _) = self.apply_declaration(code)?;
526 Ok(outcome)
527 }
528 ZigSnippetKind::Statement => {
529 let (outcome, _) = self.apply_statement(code)?;
530 Ok(outcome)
531 }
532 ZigSnippetKind::Expression => {
533 let (outcome, _) = self.apply_expression(trimmed)?;
534 Ok(outcome)
535 }
536 }
537 }
538
539 fn shutdown(&mut self) -> Result<()> {
540 Ok(())
541 }
542}
543
544fn classify_snippet(code: &str) -> ZigSnippetKind {
545 if looks_like_declaration(code) {
546 ZigSnippetKind::Declaration
547 } else if looks_like_statement(code) {
548 ZigSnippetKind::Statement
549 } else {
550 ZigSnippetKind::Expression
551 }
552}
553
554fn looks_like_declaration(code: &str) -> bool {
555 let trimmed = code.trim_start();
556 matches!(
557 trimmed,
558 t if t.starts_with("const ")
559 || t.starts_with("var ")
560 || t.starts_with("pub ")
561 || t.starts_with("fn ")
562 || t.starts_with("usingnamespace ")
563 || t.starts_with("extern ")
564 || t.starts_with("comptime ")
565 || t.starts_with("test ")
566 )
567}
568
569fn looks_like_statement(code: &str) -> bool {
570 let trimmed = code.trim_end();
571 trimmed.contains('\n')
572 || trimmed.ends_with(';')
573 || trimmed.ends_with('}')
574 || trimmed.ends_with(':')
575 || trimmed.starts_with("//")
576 || trimmed.starts_with("/*")
577}
578
579fn ensure_trailing_newline(code: &str) -> String {
580 let mut snippet = code.to_string();
581 if !snippet.ends_with('\n') {
582 snippet.push('\n');
583 }
584 snippet
585}
586
587fn wrap_expression(code: &str) -> String {
588 format!("std.debug.print(\"{{any}}\\n\", .{{ {} }});", code)
589}
590
591fn normalize_snippet(code: &str) -> String {
592 rewrite_numeric_suffixes(code)
593}
594
595fn rewrite_numeric_suffixes(code: &str) -> String {
596 let bytes = code.as_bytes();
597 let mut result = String::with_capacity(code.len());
598 let mut i = 0;
599 while i < bytes.len() {
600 let ch = bytes[i] as char;
601
602 if ch == '"' {
603 let (segment, advance) = extract_string_literal(&code[i..]);
604 result.push_str(segment);
605 i += advance;
606 continue;
607 }
608
609 if ch == '\'' {
610 let (segment, advance) = extract_char_literal(&code[i..]);
611 result.push_str(segment);
612 i += advance;
613 continue;
614 }
615
616 if ch == '/' && i + 1 < bytes.len() {
617 let next = bytes[i + 1] as char;
618 if next == '/' {
619 result.push_str(&code[i..]);
620 break;
621 }
622 if next == '*' {
623 let (segment, advance) = extract_block_comment(&code[i..]);
624 result.push_str(segment);
625 i += advance;
626 continue;
627 }
628 }
629
630 if ch.is_ascii_digit() {
631 if i > 0 {
632 let prev = bytes[i - 1] as char;
633 if prev.is_ascii_alphanumeric() || prev == '_' {
634 result.push(ch);
635 i += 1;
636 continue;
637 }
638 }
639
640 let literal_end = scan_numeric_literal(bytes, i);
641 if literal_end > i {
642 if let Some((suffix, suffix_len)) = match_suffix(&code[literal_end..]) {
643 if !is_identifier_char(bytes, literal_end + suffix_len) {
644 let literal = &code[i..literal_end];
645 result.push_str("@as(");
646 result.push_str(suffix);
647 result.push_str(", ");
648 result.push_str(literal);
649 result.push_str(")");
650 i = literal_end + suffix_len;
651 continue;
652 }
653 }
654
655 result.push_str(&code[i..literal_end]);
656 i = literal_end;
657 continue;
658 }
659 }
660
661 result.push(ch);
662 i += 1;
663 }
664
665 if result.len() == code.len() {
666 code.to_string()
667 } else {
668 result
669 }
670}
671
672fn extract_string_literal(source: &str) -> (&str, usize) {
673 let bytes = source.as_bytes();
674 let mut i = 1; while i < bytes.len() {
676 match bytes[i] {
677 b'\\' => {
678 i += 2;
679 }
680 b'"' => {
681 i += 1;
682 break;
683 }
684 _ => i += 1,
685 }
686 }
687 (&source[..i], i)
688}
689
690fn extract_char_literal(source: &str) -> (&str, usize) {
691 let bytes = source.as_bytes();
692 let mut i = 1; while i < bytes.len() {
694 match bytes[i] {
695 b'\\' => {
696 i += 2;
697 }
698 b'\'' => {
699 i += 1;
700 break;
701 }
702 _ => i += 1,
703 }
704 }
705 (&source[..i], i)
706}
707
708fn extract_block_comment(source: &str) -> (&str, usize) {
709 if let Some(idx) = source[2..].find("*/") {
710 let end = 2 + idx + 2;
711 (&source[..end], end)
712 } else {
713 (source, source.len())
714 }
715}
716
717fn scan_numeric_literal(bytes: &[u8], start: usize) -> usize {
718 let len = bytes.len();
719 if start >= len {
720 return start;
721 }
722
723 let mut i = start;
724
725 if bytes[i] == b'0' && i + 1 < len {
726 match bytes[i + 1] {
727 b'x' | b'X' => {
728 i += 2;
729 while i < len {
730 match bytes[i] {
731 b'0'..=b'9' | b'a'..=b'f' | b'A'..=b'F' | b'_' => i += 1,
732 _ => break,
733 }
734 }
735 return i;
736 }
737 b'o' | b'O' => {
738 i += 2;
739 while i < len {
740 match bytes[i] {
741 b'0'..=b'7' | b'_' => i += 1,
742 _ => break,
743 }
744 }
745 return i;
746 }
747 b'b' | b'B' => {
748 i += 2;
749 while i < len {
750 match bytes[i] {
751 b'0' | b'1' | b'_' => i += 1,
752 _ => break,
753 }
754 }
755 return i;
756 }
757 _ => {}
758 }
759 }
760
761 i = start;
762 let mut seen_dot = false;
763 while i < len {
764 match bytes[i] {
765 b'0'..=b'9' | b'_' => i += 1,
766 b'.' if !seen_dot => {
767 if i + 1 < len && bytes[i + 1].is_ascii_digit() {
768 seen_dot = true;
769 i += 1;
770 } else {
771 break;
772 }
773 }
774 b'e' | b'E' | b'p' | b'P' => {
775 let mut j = i + 1;
776 if j < len && (bytes[j] == b'+' || bytes[j] == b'-') {
777 j += 1;
778 }
779 let mut exp_digits = 0;
780 while j < len {
781 match bytes[j] {
782 b'0'..=b'9' | b'_' => {
783 exp_digits += 1;
784 j += 1;
785 }
786 _ => break,
787 }
788 }
789 if exp_digits == 0 {
790 break;
791 }
792 i = j;
793 }
794 _ => break,
795 }
796 }
797
798 i
799}
800
801fn match_suffix(rest: &str) -> Option<(&'static str, usize)> {
802 for &suffix in &ZIG_NUMERIC_SUFFIXES {
803 if rest.starts_with(suffix) {
804 return Some((suffix, suffix.len()));
805 }
806 }
807 None
808}
809
810fn is_identifier_char(bytes: &[u8], index: usize) -> bool {
811 if index >= bytes.len() {
812 return false;
813 }
814 let ch = bytes[index] as char;
815 ch.is_ascii_alphanumeric() || ch == '_'
816}