pico8_to_lua/
lib.rs

1#![doc(html_root_url = "https://docs.rs/pico8-to-lua/0.1.1")]
2#![doc = include_str!("../README.md")]
3/// Copyright (c) 2015 Jez Kabanov <thesleepless@gmail.com>
4/// Modified (c) 2019 Ben Wiley <therealbenwiley@gmail.com>
5/// Modified (c) 2025 Shane Celis <shane.celis@gmail.com>
6///
7/// Original 2015 code from
8/// [here](https://github.com/picolove/picolove/blob/d5a65fd6dd322532d90ea612893a00a28096804a/main.lua#L820).
9///
10/// Modified 2019 code from
11/// [here](https://github.com/benwiley4000/pico8-to-lua/blob/master/pico8-to-lua.lua).
12///
13/// Licensed under the Zlib license.
14use regex::{Regex, Replacer};
15use std::{borrow::Cow, error::Error};
16use find_matching_bracket::find_matching_paren;
17use lazy_regex::regex;
18
19// https://stackoverflow.com/a/79268946/6454690
20fn replace_all_in_place<R: Replacer>(regex: &Regex, s: &mut Cow<'_, str>, replacer: R) {
21    let new = regex.replace_all(s, replacer);
22    if let Cow::Owned(o) = new {
23        *s = Cow::Owned(o);
24    } // Otherwise, no change was made.
25}
26
27/// Resolve the Pico-8 "#include path.p8" statements with possible errors.
28///
29/// If there are substitution errors, the first error will be returned.
30pub fn try_patch_includes<'h, E: Error>(
31    lua: impl Into<Cow<'h, str>>,
32    mut resolve: impl FnMut(&str) -> Result<String, E>,
33) -> Result<Cow<'h, str>, E> {
34    let mut lua = lua.into();
35    let mut error = None;
36
37    replace_all_in_place(
38        regex!(r"(?m)^\s*#include\s+(\S+)"),
39        &mut lua,
40        |caps: &regex::Captures| {
41            match resolve(&caps[1]) {
42                Ok(s) => s,
43                Err(e) => {
44                    // This is kind of pointless since the user will never get
45                    // access to the string. I'm leaving here incase the results
46                    // change to make it relevant later.
47                    let result = format!("error(\"failed to include {:?}: {}\")", &caps[1], &e);
48                    if error.is_none() {
49                        error = Some(Err(e))
50                    }
51                    result
52                }
53            }
54        },
55    );
56    error.unwrap_or(Ok(lua))
57}
58
59/// Returns true if the patch_output was patched by testing whether it is
60/// `Cow::Owned`; a `Cow::Borrowed` implies it was not patched.
61#[allow(clippy::ptr_arg)]
62pub fn was_patched(patch_output: &Cow<'_, str>) -> bool {
63    match patch_output {
64        Cow::Owned(_) => true,
65        Cow::Borrowed(_) => false,
66    }
67}
68
69/// Resolve the Pico-8 "#include path.p8" statements without possible error.
70pub fn patch_includes<'h>(
71    lua: impl Into<Cow<'h, str>>,
72    mut resolve: impl FnMut(&str) -> String,
73) -> Cow<'h, str> {
74    let mut lua = lua.into();
75    replace_all_in_place(
76        regex!(r"(?m)^\s*#include\s+(\S+)"),
77        &mut lua,
78        |caps: &regex::Captures| resolve(&caps[1]),
79    );
80    lua
81}
82
83/// Return each path from the the Pico-8 "#include path.p8" statements.
84///
85/// This function is not strictly necessary if one can read the includes
86/// synchronously using [patch_includes] or [try_patch_includes]. However, in an
87/// asynchronous IO context, it is often necessary to read in the contents
88/// before patching the includes.
89pub fn find_includes(
90    lua: &str,
91) -> impl Iterator<Item = String> {
92    regex!(r"(?m)^\s*#include\s+(\S+)").captures_iter(lua)
93        .map(|caps: regex::Captures| caps[1].to_string())
94}
95
96/// Given a string with the Pico-8 dialect of Lua, it will convert that code to
97/// plain Lua.
98///
99/// This function will not handle "#include path.p8" statements. It is
100/// recommended to use [patch_includes] before this function since if those
101/// inclusions may use the Pico-8 dialect.
102///
103/// NOTE: This is not a full language parser, but a series of regular
104/// expressions, so it is not guaranteed to work with every valid Pico-8
105/// expression. But if it does not work, please file an issue with the failing
106/// expression.
107pub fn patch_lua<'h>(lua: impl Into<Cow<'h, str>>) -> Cow<'h, str> {
108    let mut lua = lua.into();
109    // Replace != with ~=.
110    replace_all_in_place(regex!(r"!="), &mut lua, "~=");
111
112    // Replace // with --.
113    replace_all_in_place(regex!(r"//"), &mut lua, "--");
114
115    // Replace unicode symbols for buttons.
116    replace_all_in_place(
117        regex!(r"(btnp?)\(\s*(\S+)\s*\)"),
118        &mut lua,
119        |caps: &regex::Captures| {
120            let func = &caps[1];
121            let symbol = caps[2].trim_end_matches("\u{fe0f}");
122            let sub = match symbol {
123                "⬅" => "0",
124                "➡" => "1",
125                "⬆" => "2",
126                "⬇" => "3",
127                "🅾" => "4",
128                "❎" => "5",
129                x => x,
130            };
131            format!("{func}({sub})")
132        },
133    );
134
135    // Rewrite shorthand if statements.
136    //
137    // This is why using regex is not a great tool for parsing but because we
138    // only need to match one line, we find the matching parenthesis and move on.
139    replace_all_in_place(
140        regex!(r"(?m)^(\s*)if\s*(\([^\n]*)$"),
141        &mut lua,
142        |caps: &regex::Captures| {
143            let prefix = &caps[1];
144            let line = &caps[2];
145
146            if regex!(r"\bthen\b").is_match(line) {
147                return caps[0].to_string();
148            }
149            if let Some(index) = find_matching_paren(line, 0) {
150                let cond = &line[1..index];
151                let body = &line[index + 1..].trim_start();
152                let comment_start = body.find("--");
153                if let Some(cs) = comment_start {
154                    let (code, comment) = body.split_at(cs);
155                    format!(
156                        "{}if {} then {} end {}",
157                        prefix,
158                        cond,
159                        code.trim_end(),
160                        comment
161                    )
162                } else {
163                    format!("{}if {} then {} end", prefix, cond, body)
164                }
165            } else {
166                caps[0].to_string()
167            }
168        },
169    );
170
171    // Rewrite assignment operators (+=, -=, etc.).
172    replace_all_in_place(regex!(r"(?m)([^-\s]\S*)\s*([+\-*/%])=\s*([^\n\r]+?)(\s*(\breturn|\bend|\belse|;|--|$))"), &mut lua, "$1 = $1 $2 ($3)$4");
173
174    // Replace "?expr" with "print(expr)".
175    replace_all_in_place(regex!(r"(?m)^(\s*)\?([^\n\r]+)"), &mut lua, "${1}print($2)");
176
177    // Convert binary literals to hex literals.
178    replace_all_in_place(
179        regex!(r"([^[:alnum:]_])0[bB]([01.]+)"),
180        &mut lua,
181        |caps: &regex::Captures| {
182            let prefix = &caps[1];
183            let bin = &caps[2];
184            let mut parts = bin.split('.');
185
186            let p1 = parts.next().unwrap_or("");
187            let p2 = parts.next().unwrap_or("");
188
189            let int_val = u64::from_str_radix(p1, 2).ok();
190            let frac_val = if !p2.is_empty() {
191                let padded = format!("{:0<4}", p2);
192                u64::from_str_radix(&padded, 2).ok()
193            } else {
194                None
195            };
196
197            match (int_val, frac_val) {
198                (Some(i), Some(f)) => format!("{}0x{:x}.{:x}", prefix, i, f),
199                (Some(i), None) => format!("{}0x{:x}", prefix, i),
200                _ => caps[0].to_string(),
201            }
202        },
203    );
204    lua
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn test_not_equal_replacement() {
213        let lua = "if a != b then print(a) end";
214        let patched = patch_lua(lua);
215        assert!(patched.contains("a ~= b"));
216    }
217
218    #[test]
219    fn test_comment_replacement() {
220        let lua = "// this is a comment\nprint('hello')";
221        let patched = patch_lua(lua);
222        assert!(patched.contains("-- this is a comment"));
223    }
224
225    #[test]
226    fn test_shorthand_if_rewrite() {
227        let lua = "if (not b) i = 1\n";
228        let expected = "if not b then i = 1 end\n";
229        let patched = patch_lua(lua);
230        assert_eq!(patched, expected);
231    }
232
233    #[test]
234    fn test_shorthand_if_rewrite_comment() {
235        let lua = "if (not b) i = 1 // hi\n";
236        let expected = "if not b then i = 1 end -- hi\n";
237        let patched = patch_lua(lua);
238        assert_eq!(patched, expected);
239    }
240
241    #[test]
242    fn test_shorthand_if_rewrite_and() {
243        let lua = "if (not b and not c) i = 1\n";
244        let expected = "if not b and not c then i = 1 end\n";
245        let patched = patch_lua(lua);
246        assert_eq!(patched, expected);
247    }
248
249    #[test]
250    fn test_assignment_operator_rewrite() {
251        let lua = "x += 1";
252        let patched = patch_lua(lua);
253        assert_eq!(patched.trim(), "x = x + (1)");
254    }
255
256    #[test]
257    fn test_question_print_conversion0() {
258        let lua = "?x";
259        let patched = patch_lua(lua);
260        assert_eq!(patched.trim(), "print(x)");
261    }
262
263    #[test]
264    fn test_question_print_conversion() {
265        let lua = "?x + y";
266        let patched = patch_lua(lua);
267        assert_eq!(patched.trim(), "print(x + y)");
268    }
269
270    #[test]
271    fn test_binary_literal_conversion_integer() {
272        let lua = "a = 0b1010";
273        let patched = patch_lua(lua);
274        assert_eq!(patched.trim(), "a = 0xa");
275    }
276
277    #[test]
278    fn test_binary_literal_conversion_fractional() {
279        let lua = "a = 0b1010.1";
280        let patched = patch_lua(lua);
281        assert_eq!(patched.trim(), "a = 0xa.8");
282    }
283
284    #[test]
285    fn test_mixed_transforms() {
286        let lua = r#"
287        // comment
288        if (a != b) x += 1
289        ?x
290        "#;
291        let patched = patch_lua(lua);
292        assert!(patched.contains("-- comment"), "{}", patched);
293        assert!(
294            patched.contains("if a ~= b then x = x + (1) end"),
295            "{}",
296            patched
297        );
298        assert!(patched.contains("print(x)"), "{}", patched);
299    }
300
301    #[test]
302    fn test_no_change_no_allocation() {
303        let lua = "x = 1";
304        let patched = patch_lua(lua);
305        // assert!(patched.is_borrowed());
306        assert!(match patched {
307            Cow::Owned(_) => false,
308            Cow::Borrowed(_) => true,
309        });
310    }
311
312    #[test]
313    fn test_change_requires_allocation() {
314        let lua = "x += 1";
315        let patched = patch_lua(lua);
316        // assert!(patched.is_owned());
317        assert!(match patched {
318            Cow::Owned(_) => true,
319            Cow::Borrowed(_) => false,
320        });
321    }
322
323    #[test]
324    fn test_includes() {
325        let lua = r#"
326        #include blah.p8
327        "#;
328        let patched = patch_includes(lua, |path| format!("-- INCLUDE {}", path));
329        assert!(patched.contains("-- INCLUDE blah.p8"), "{}", &patched);
330    }
331
332    #[test]
333    fn test_bad_comment() {
334        let lua = "--==configurations==--";
335        let patched = patch_lua(lua);
336        assert_eq!(patched.trim(), "--==configurations==--");
337    }
338
339    #[test]
340    fn test_bad_if() {
341        let lua =
342            "if (ord(tb.str[tb.i],tb.char)!=32) sfx(tb.voice) -- play the voice sound effect.";
343        let patched = patch_lua(lua);
344        assert_eq!(
345            patched.trim(),
346            "if ord(tb.str[tb.i],tb.char)~=32 then sfx(tb.voice) end -- play the voice sound effect."
347        );
348    }
349
350    #[test]
351    fn test_bad_incr() {
352        let lua = "tb.i+=1 -- increase the index, to display the next message on tb.str";
353        let patched = patch_lua(lua);
354        assert_eq!(
355            patched.trim(),
356            "tb.i = tb.i + (1) -- increase the index, to display the next message on tb.str"
357        );
358    }
359
360    #[test]
361    fn test_button() {
362        let lua = "if btnp(➡️) or btn(❎) then";
363        let patched = patch_lua(lua);
364        assert_eq!(patched.trim(), "if btnp(1) or btn(5) then");
365    }
366
367    #[test]
368    fn test_button2() {
369        let lua = "if btnp(❎) then";
370        let patched = patch_lua(lua);
371        assert_eq!(patched.trim(), "if btnp(5) then");
372    }
373
374    #[test]
375    fn test_button3() {
376        let lua = "if btnp(🅾) then";
377        let patched = patch_lua(lua);
378        assert_eq!(patched.trim(), "if btnp(4) then");
379    }
380
381    fn assert_patch(unpatched: &str, expected_patched: &str) {
382        let patched = patch_lua(unpatched);
383        assert_eq!(patched, expected_patched);
384    }
385
386    #[test]
387    fn test_cardboard_toad0() {
388        assert_patch(
389            "if (o.color) setmetatable(o.color, { __index = (message_instance or message).color })",
390            "if o.color then setmetatable(o.color, { __index = (message_instance or message).color }) end",
391        );
392    }
393
394    #[test]
395    fn test_cardboard_toad1() {
396        assert_patch(
397            r#"
398if ((abs(x) < (a.w+a2.w)) and
399    (abs(y) < (a.h+a2.h)))
400    then "hi" end
401"#,
402            r#"
403if ((abs(x) < (a.w+a2.w)) and
404    (abs(y) < (a.h+a2.h)))
405    then "hi" end
406"#,
407        );
408    }
409
410    #[test]
411    fn test_cardboard_toad2() {
412        assert_patch(
413            r#"
414 if (self.sprites ~= nil) then
415  self.sprite = self.sprites[self.sprites_index]
416 end
417"#,
418            r#"
419 if (self.sprites ~= nil) then
420  self.sprite = self.sprites[self.sprites_index]
421 end
422"#,
423        );
424    }
425
426    #[test]
427    fn test_cardboard_toad3() {
428        // This is a bug.
429        // assert_patch(
430        //     "accum += f.delay or self.delay",
431        //     "accum = accum + f.delay or self.delay",
432        // );
433
434        // It should actually do this, but the corner cases are too many.
435        assert_patch("accum += f.delay or self.delay",
436                     "accum = accum + (f.delay or self.delay)");
437
438        assert_patch("if true then accum += f.delay or self.delay end",
439                     "if true then accum = accum + (f.delay or self.delay) end");
440    }
441
442    #[test]
443    fn test_celeste0() {
444        assert_patch("if freeze>0 then freeze-=1 return end",
445                     "if freeze>0 then freeze = freeze - (1) return end");
446    }
447
448
449    #[test]
450    fn test_pooh_big_adventure0() {
451        assert_patch("if btnp(3) then self.choice += 1; result = true end",
452                     "if btnp(3) then self.choice = self.choice + (1); result = true end");
453
454        assert_patch("       i += 1",
455                     "       i = i + (1)");
456    }
457
458    #[test]
459    fn test_plist0() {
460        let lua = r#"
461i += 1
462local key = keys[i]
463"#;
464        let patched = patch_lua(lua);
465        assert!(patched.contains("i = i + (1)"));
466    }
467
468    #[test]
469    fn test_find_includes() {
470
471        let lua = r#"
472#include a.p8
473#include b.lua
474"#;
475        assert_eq!(find_includes(lua).collect::<Vec<_>>(), vec!["a.p8", "b.lua"]);
476    }
477
478
479    #[test]
480    #[ignore = "need a real parser to fix this; see 'antlr' branch"]
481    fn test_not_so_well0() {
482        assert_eq!(patch_lua("pos += (delta - thresh):map(function(v) return mid(0, v, 4) end)"),
483                "pos = pos + ((delta - thresh):map(function(v) return mid(0, v, 4) end))");
484    }
485}