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