Skip to main content

zoi_lua/
parser.rs

1//! Parser for Zoi package definitions (`.pkg.lua`).
2//!
3//! This module provides functions to parse Lua package definitions from files
4//! or Zoi archives, extracting metadata, dependencies, and other configuration.
5
6use std::fs;
7use std::path::Path;
8
9use anyhow::{Result, anyhow};
10use mlua::{self, Lua, LuaSerdeExt, Table, Value};
11use tar::Archive;
12use walkdir::WalkDir;
13use zoi_core::{types, utils};
14use zstd::stream::read::Decoder as ZstdDecoder;
15
16use crate::functions;
17
18/// Parses a Lua package definition from a Zoi archive (`.zpa` or `.zsa`).
19///
20/// This function detects the current platform and uses it for resolution.
21///
22/// # Errors
23/// Returns an error if the archive cannot be read, unpacked, or if no
24/// `.pkg.lua` is found.
25pub fn parse_lua_package_from_archive(
26    archive_path: &Path,
27    version_override: Option<&str>,
28    scope: Option<types::Scope>,
29    quiet: bool
30) -> Result<types::Package> {
31    let platform = utils::get_platform()?;
32    parse_lua_package_from_archive_for_platform(
33        archive_path,
34        &platform,
35        version_override,
36        scope,
37        quiet
38    )
39}
40
41/// Parses a Lua package definition from a Zoi archive for a specific platform.
42///
43/// # Errors
44/// Returns an error if the archive cannot be read, unpacked, or if no
45/// `.pkg.lua` is found.
46pub fn parse_lua_package_from_archive_for_platform(
47    archive_path: &Path,
48    platform: &str,
49    version_override: Option<&str>,
50    scope: Option<types::Scope>,
51    quiet: bool
52) -> Result<types::Package> {
53    let file = fs::File::open(archive_path)?;
54    let decoder = ZstdDecoder::new(file)?;
55    let mut archive = Archive::new(decoder);
56    let temp_dir = tempfile::Builder::new()
57        .prefix("zoi-arch-parse-")
58        .tempdir()?;
59    archive.unpack(temp_dir.path())?;
60
61    let mut pkg_lua = None;
62    for entry in WalkDir::new(temp_dir.path())
63        .into_iter()
64        .filter_map(Result::ok)
65    {
66        if entry.file_name().to_string_lossy().ends_with(".pkg.lua") {
67            pkg_lua = Some(entry.path().to_path_buf());
68            break;
69        }
70    }
71
72    let pkg_lua_path =
73        pkg_lua.ok_or_else(|| anyhow!("No .pkg.lua in archive"))?;
74    parse_lua_package_from_file_for_platform(
75        pkg_lua_path
76            .to_str()
77            .ok_or_else(|| anyhow!("Invalid path"))?,
78        platform,
79        version_override,
80        scope,
81        quiet
82    )
83}
84
85/// Parses a Lua package definition from either a file or an archive for a
86/// specific platform.
87///
88/// # Errors
89/// Returns an error if the file or archive cannot be read or parsed.
90pub fn parse_lua_package_for_platform(
91    file_path: &str,
92    platform: &str,
93    version_override: Option<&str>,
94    scope: Option<types::Scope>,
95    quiet: bool
96) -> Result<types::Package> {
97    let path = Path::new(file_path);
98    if path.extension().is_some_and(|ext| {
99        ext.eq_ignore_ascii_case("zpa") || ext.eq_ignore_ascii_case("zsa")
100    }) {
101        return parse_lua_package_from_archive_for_platform(
102            path,
103            platform,
104            version_override,
105            scope,
106            quiet
107        );
108    }
109    parse_lua_package_from_file_for_platform(
110        file_path,
111        platform,
112        version_override,
113        scope,
114        quiet
115    )
116}
117
118/// Internal function to parse a Lua package definition from a file for a
119/// specific platform.
120///
121/// This function sets up the Lua environment, executes the script, and extracts
122/// the resulting package metadata.
123fn parse_lua_package_from_file_for_platform(
124    file_path: &str,
125    platform: &str,
126    version_override: Option<&str>,
127    scope: Option<types::Scope>,
128    quiet: bool
129) -> Result<types::Package> {
130    let lua_code = fs::read_to_string(file_path)?;
131    let lua = Lua::new();
132
133    functions::setup_lua_environment(
134        &lua,
135        platform,
136        version_override,
137        Some(file_path),
138        None,
139        None,
140        None,
141        None,
142        scope,
143        None,
144        quiet
145    )
146    .map_err(|e| {
147        anyhow!("Failed to setup Lua environment for '{file_path}': {e}")
148    })?;
149
150    lua.load(&lua_code).exec().map_err(|e| {
151        use colored::Colorize;
152        let error_msg = format!("{e}");
153        let enriched_msg = if let Some(line) = extract_line_number(&error_msg) {
154            let lines: Vec<&str> = lua_code.lines().collect();
155            if let Some(code_line) =
156                lines.get((line as usize).saturating_sub(1))
157            {
158                format!(
159                    "{}\n\n{} | {}\n   | ^",
160                    error_msg.red().bold(),
161                    format!("{line:4}").dimmed(),
162                    code_line.cyan()
163                )
164            } else {
165                error_msg
166            }
167        } else {
168            error_msg
169        };
170        anyhow!(
171            "Failed to execute Lua package file '{file_path}':\n{enriched_msg}"
172        )
173    })?;
174
175    let final_pkg_meta: Table = lua
176        .globals()
177        .get("__ZoiPackageMeta")
178        .map_err(|e| anyhow!(e.to_string()))?;
179    let final_pkg_deps: Table = lua
180        .globals()
181        .get("__ZoiPackageDeps")
182        .map_err(|e| anyhow!(e.to_string()))?;
183    let final_pkg_updates: Table = lua
184        .globals()
185        .get("__ZoiPackageUpdates")
186        .map_err(|e| anyhow!(e.to_string()))?;
187    let final_pkg_hooks: Table = lua
188        .globals()
189        .get("__ZoiPackageHooks")
190        .map_err(|e| anyhow!(e.to_string()))?;
191    let final_pkg_service: Table = lua
192        .globals()
193        .get("__ZoiPackageService")
194        .map_err(|e| anyhow!(e.to_string()))?;
195
196    let mut package: types::Package = lua
197        .from_value(Value::Table(final_pkg_meta.clone()))
198        .map_err(|e| {
199            anyhow!(
200                "Failed to parse 'metadata' block in package file \
201                 '{file_path}':\n{e}"
202            )
203        })?;
204
205    // Manually extract zoios field to handle boolean/nil correctly if needed,
206    // though from_value should handle it.
207    package.zoios = final_pkg_meta.get("zoios").ok();
208
209    package.dependencies = if final_pkg_deps.is_empty() {
210        None
211    } else {
212        Some(lua.from_value(Value::Table(final_pkg_deps)).map_err(|e| {
213            anyhow!(
214                "Failed to parse 'dependencies' block in package file \
215                 '{file_path}':\n{e}"
216            )
217        })?)
218    };
219
220    package.updates = if final_pkg_updates.is_empty() {
221        None
222    } else {
223        Some(
224            lua.from_value(Value::Table(final_pkg_updates))
225                .map_err(|e| {
226                    anyhow!(
227                        "Failed to parse 'updates' block in package file \
228                         '{file_path}':
229{e}"
230                    )
231                })?
232        )
233    };
234
235    package.hooks = if final_pkg_hooks.is_empty() {
236        None
237    } else {
238        Some(lua.from_value(Value::Table(final_pkg_hooks)).map_err(|e| {
239            anyhow!(
240                "Failed to parse 'hooks' block in package file '{file_path}':
241{e}"
242            )
243        })?)
244    };
245
246    package.service = if final_pkg_service.is_empty() {
247        None
248    } else {
249        Some(
250            lua.from_value(Value::Table(final_pkg_service))
251                .map_err(|e| {
252                    anyhow!(
253                        "Failed to parse 'service' block in package file \
254                         '{file_path}':
255{e}"
256                    )
257                })?
258        )
259    };
260
261    Ok(package)
262}
263
264/// Parses a Lua package definition from either a file or an archive.
265///
266/// This function detects the current platform and uses it for resolution.
267///
268/// # Errors
269/// Returns an error if the file or archive cannot be read or parsed.
270pub fn parse_lua_package(
271    file_path: &str,
272    version_override: Option<&str>,
273    scope: Option<types::Scope>,
274    quiet: bool
275) -> Result<types::Package> {
276    let platform = utils::get_platform()?;
277    parse_lua_package_for_platform(
278        file_path,
279        &platform,
280        version_override,
281        scope,
282        quiet
283    )
284}
285
286/// Extracts the line number from a Lua error message.
287fn extract_line_number(error: &str) -> Option<u32> {
288    // Lua error format: [string "code"]:10: error message
289    if let Some(idx) = error.find("]:") {
290        let after_bracket = &error[idx + 2..];
291        if let Some(colon_idx) = after_bracket.find(':') {
292            let line_str = &after_bracket[..colon_idx];
293            return line_str.parse::<u32>().ok();
294        }
295    }
296    None
297}