Skip to main content

normalize_manifest/
zig.rs

1//! Parser for `build.zig.zon` files (Zig).
2//!
3//! ZON (Zig Object Notation) is a subset of Zig syntax used for package
4//! manifests. We use heuristic line-pattern matching to extract `.name`,
5//! `.version`, and `.dependencies` without a full Zig parser.
6
7use crate::{DeclaredDep, DepKind, ManifestError, ManifestParser, ParsedManifest};
8
9/// Parser for `build.zig.zon` files.
10pub struct ZigZonParser;
11
12impl ManifestParser for ZigZonParser {
13    fn filename(&self) -> &'static str {
14        "build.zig.zon"
15    }
16
17    fn parse(&self, content: &str) -> Result<ParsedManifest, ManifestError> {
18        let mut name: Option<String> = None;
19        let mut version: Option<String> = None;
20        let mut deps: Vec<DeclaredDep> = Vec::new();
21
22        // Track parser state.
23        // We care about three depth levels:
24        //   top-level `.{ ... }` → depth 1
25        //   `.dependencies = .{ ... }` → depth 2
26        //   individual dep `.depname = .{ ... }` → depth 3
27        #[derive(PartialEq)]
28        enum State {
29            TopLevel,
30            InDeps,
31            InDepEntry,
32        }
33
34        let mut state = State::TopLevel;
35        let mut depth = 0usize;
36        let mut deps_depth = 0usize;
37        let mut dep_entry_depth = 0usize;
38        let mut current_dep_name: Option<String> = None;
39
40        for line in content.lines() {
41            let trimmed = line.trim();
42
43            // Skip comments
44            if trimmed.starts_with("//") || trimmed.is_empty() {
45                continue;
46            }
47
48            // Count brace changes on this line.
49            let opens = trimmed.chars().filter(|&c| c == '{').count();
50            let closes = trimmed.chars().filter(|&c| c == '}').count();
51
52            // Extract .name and .version at top level (depth 1).
53            if state == State::TopLevel && depth <= 1 {
54                if let Some(v) = extract_field_string(trimmed, ".name")
55                    && name.is_none()
56                {
57                    name = Some(v);
58                }
59                if let Some(v) = extract_field_string(trimmed, ".version")
60                    && version.is_none()
61                {
62                    version = Some(v);
63                }
64            }
65
66            // Detect entry into .dependencies = .{
67            if state == State::TopLevel
68                && trimmed.contains(".dependencies")
69                && trimmed.contains('=')
70                && opens > 0
71            {
72                state = State::InDeps;
73                deps_depth = depth + opens - closes;
74                depth = depth + opens - closes;
75                continue;
76            }
77
78            // Inside dependencies block, detect individual dep entries: .name = .{
79            if state == State::InDeps {
80                // Check for leaving the deps block.
81                let new_depth = depth + opens - closes;
82                if new_depth < deps_depth {
83                    state = State::TopLevel;
84                    depth = new_depth;
85                    continue;
86                }
87
88                // Detect a dep entry: line like `.depname = .{`
89                if opens > 0
90                    && trimmed.starts_with('.')
91                    && trimmed.contains('=')
92                    && let Some(dep_name) = extract_zon_key(trimmed)
93                {
94                    state = State::InDepEntry;
95                    current_dep_name = Some(dep_name);
96                    dep_entry_depth = new_depth;
97                    depth = new_depth;
98                    continue;
99                }
100
101                depth = new_depth;
102                continue;
103            }
104
105            // Inside a single dep entry.
106            if state == State::InDepEntry {
107                let new_depth = depth + opens - closes;
108                if new_depth < dep_entry_depth {
109                    // Leaving this dep entry; emit it.
110                    if let Some(dep_name) = current_dep_name.take() {
111                        deps.push(DeclaredDep {
112                            name: dep_name,
113                            version_req: None,
114                            kind: DepKind::Normal,
115                        });
116                    }
117                    // Are we back in deps or fully out?
118                    if new_depth < deps_depth {
119                        state = State::TopLevel;
120                    } else {
121                        state = State::InDeps;
122                    }
123                    depth = new_depth;
124                    continue;
125                }
126                depth = new_depth;
127                continue;
128            }
129
130            depth = (depth + opens).saturating_sub(closes);
131        }
132
133        // Handle unclosed final dep entry (file ends without closing brace).
134        if state == State::InDepEntry
135            && let Some(dep_name) = current_dep_name.take()
136        {
137            deps.push(DeclaredDep {
138                name: dep_name,
139                version_req: None,
140                kind: DepKind::Normal,
141            });
142        }
143
144        Ok(ParsedManifest {
145            ecosystem: "zig",
146            name,
147            version,
148            dependencies: deps,
149        })
150    }
151}
152
153/// Extract the value from `.field = "value"` or `.field = "value",`.
154fn extract_field_string(line: &str, field: &str) -> Option<String> {
155    let rest = line.strip_prefix(field)?.trim();
156    let rest = rest.strip_prefix('=')?.trim();
157    let rest = rest.strip_prefix('"')?;
158    let end = rest.find('"')?;
159    Some(rest[..end].to_string())
160}
161
162/// Extract the key name from `.keyname = .{` or `.keyname = .{`.
163fn extract_zon_key(line: &str) -> Option<String> {
164    let rest = line.strip_prefix('.')?;
165    // key name ends at whitespace or `=`
166    let end = rest.find(|c: char| c.is_whitespace() || c == '=')?;
167    let key = rest[..end].trim();
168    if key.is_empty() {
169        None
170    } else {
171        Some(key.to_string())
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use crate::ManifestParser;
179
180    const SAMPLE: &str = r#".{
181    .name = "my-project",
182    .version = "0.12.0",
183    .minimum_zig_version = "0.12.0",
184    .dependencies = .{
185        .zap = .{
186            .url = "https://github.com/zigzap/zap/archive/refs/tags/v0.2.0.tar.gz",
187            .hash = "122059d35a68afb4f5e59b52fdc63be4c09ee07f72bf7c7abaab46c5ebe8c39e8f",
188        },
189        .known_folders = .{
190            .url = "https://github.com/ziglibs/known-folders/archive/fa75e1bc672952efa0cf06160bbd942b47f6d59b.tar.gz",
191            .hash = "122048992d",
192        },
193    },
194}
195"#;
196
197    #[test]
198    fn test_parse_zig_zon() {
199        let m = ZigZonParser.parse(SAMPLE).unwrap();
200        assert_eq!(m.ecosystem, "zig");
201        assert_eq!(m.name.as_deref(), Some("my-project"));
202        assert_eq!(m.version.as_deref(), Some("0.12.0"));
203
204        let names: Vec<&str> = m.dependencies.iter().map(|d| d.name.as_str()).collect();
205        assert!(names.contains(&"zap"), "{names:?}");
206        assert!(names.contains(&"known_folders"), "{names:?}");
207        assert_eq!(m.dependencies.len(), 2);
208        assert!(m.dependencies.iter().all(|d| d.version_req.is_none()));
209        assert!(m.dependencies.iter().all(|d| d.kind == DepKind::Normal));
210    }
211
212    #[test]
213    fn test_no_deps() {
214        let content = r#".{
215    .name = "simple",
216    .version = "0.1.0",
217}
218"#;
219        let m = ZigZonParser.parse(content).unwrap();
220        assert_eq!(m.name.as_deref(), Some("simple"));
221        assert_eq!(m.version.as_deref(), Some("0.1.0"));
222        assert!(m.dependencies.is_empty());
223    }
224}