Skip to main content

normalize_manifest/
rockspec.rs

1//! Parser for `*.rockspec` files (Lua/LuaRocks).
2//!
3//! Rockspec files are Lua source. We heuristically extract the `dependencies`
4//! table (a list of strings) without executing Lua.
5//!
6//! Dependency string format: `"pkg >= 1.0"` or `"pkg"`.
7
8use crate::{DeclaredDep, DepKind, ManifestError, ManifestParser, ParsedManifest};
9
10/// Parser for `*.rockspec` files.
11///
12/// Since rockspec files use non-standard filenames that include the version
13/// (e.g. `mypkg-1.0-1.rockspec`), this parser is not registered in
14/// `parse_manifest()` by filename. Use `parse_manifest_by_extension("rockspec", content)`
15/// or call `RockspecParser` directly.
16pub struct RockspecParser;
17
18impl ManifestParser for RockspecParser {
19    fn filename(&self) -> &'static str {
20        "*.rockspec"
21    }
22
23    fn parse(&self, content: &str) -> Result<ParsedManifest, ManifestError> {
24        let mut name = None;
25        let mut version = None;
26        let mut deps = Vec::new();
27        let mut in_deps = false;
28
29        for line in content.lines() {
30            let trimmed = line.trim();
31            if trimmed.is_empty() || trimmed.starts_with("--") {
32                continue;
33            }
34
35            // package = "mypkg"
36            if trimmed.starts_with("package")
37                && trimmed.contains('=')
38                && name.is_none()
39                && let Some(v) = extract_lua_string(trimmed)
40            {
41                name = Some(v);
42                continue;
43            }
44
45            // version = "1.0-1"
46            if trimmed.starts_with("version")
47                && trimmed.contains('=')
48                && version.is_none()
49                && let Some(v) = extract_lua_string(trimmed)
50            {
51                version = Some(v);
52                continue;
53            }
54
55            // dependencies = { "lua >= 5.1", "pkg ~> 1.0" }
56            if trimmed.starts_with("dependencies") && trimmed.contains('{') {
57                in_deps = true;
58                extract_dep_strings(trimmed, &mut deps);
59                if trimmed.contains('}') {
60                    in_deps = false;
61                }
62                continue;
63            }
64
65            if in_deps {
66                extract_dep_strings(trimmed, &mut deps);
67                if trimmed.contains('}') {
68                    in_deps = false;
69                }
70            }
71        }
72
73        Ok(ParsedManifest {
74            ecosystem: "luarocks",
75            name,
76            version,
77            dependencies: deps,
78        })
79    }
80}
81
82fn extract_lua_string(line: &str) -> Option<String> {
83    let start = line.find('"')? + 1;
84    let end = line[start..].find('"')?;
85    Some(line[start..start + end].to_string())
86}
87
88fn extract_dep_strings(line: &str, out: &mut Vec<DeclaredDep>) {
89    let mut s = line;
90    while let Some(q_start) = s.find('"') {
91        s = &s[q_start + 1..];
92        if let Some(q_end) = s.find('"') {
93            let spec = s[..q_end].trim();
94            if let Some(dep) = parse_rockspec_spec(spec) {
95                out.push(dep);
96            }
97            s = &s[q_end + 1..];
98        } else {
99            break;
100        }
101    }
102}
103
104fn parse_rockspec_spec(spec: &str) -> Option<DeclaredDep> {
105    let spec = spec.trim();
106    if spec.is_empty() {
107        return None;
108    }
109
110    const OPS: &[&str] = &[">=", "<=", "!=", ">", "<", "==", "~>"];
111    for op in OPS {
112        if let Some(idx) = spec.find(op) {
113            let name = spec[..idx].trim().to_string();
114            if name.is_empty() || name == "lua" {
115                return None; // Skip the Lua runtime itself
116            }
117            let version_req = spec[idx..].trim().to_string();
118            return Some(DeclaredDep {
119                name,
120                version_req: Some(version_req),
121                kind: DepKind::Normal,
122            });
123        }
124    }
125
126    if spec == "lua" {
127        return None;
128    }
129    Some(DeclaredDep {
130        name: spec.to_string(),
131        version_req: None,
132        kind: DepKind::Normal,
133    })
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use crate::ManifestParser;
140
141    #[test]
142    fn test_parse_rockspec() {
143        let content = r#"package = "mypkg"
144version = "1.0-1"
145source = { url = "https://example.com/mypkg-1.0.tar.gz" }
146description = { summary = "My package" }
147
148dependencies = {
149  "lua >= 5.1",
150  "luasocket >= 3.0",
151  "dkjson ~> 2.5",
152  "argparse"
153}
154
155build = { type = "builtin" }
156"#;
157        let m = RockspecParser.parse(content).unwrap();
158        assert_eq!(m.ecosystem, "luarocks");
159        assert_eq!(m.name.as_deref(), Some("mypkg"));
160        assert_eq!(m.version.as_deref(), Some("1.0-1"));
161
162        // lua is filtered
163        assert!(!m.dependencies.iter().any(|d| d.name == "lua"));
164
165        let socket = m
166            .dependencies
167            .iter()
168            .find(|d| d.name == "luasocket")
169            .unwrap();
170        assert_eq!(socket.version_req.as_deref(), Some(">= 3.0"));
171
172        let argparse = m
173            .dependencies
174            .iter()
175            .find(|d| d.name == "argparse")
176            .unwrap();
177        assert!(argparse.version_req.is_none());
178    }
179}