Skip to main content

run/engine/
zig.rs

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        // Try cache for inline/stdin payloads
102        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        // For cacheable code, try zig build-exe + cache
140        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        // Fallback to zig run
176        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; // skip opening quote
675    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; // skip opening quote
693    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}