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