Compare commits

..

9 Commits

Author SHA1 Message Date
cmounce d699340ec8 Update dependencies, fix up Cargo.toml 2025-07-21 01:05:27 -07:00
cmounce e8fa45fc3b Add documentation 2025-07-20 14:25:53 -07:00
cmounce 84f35c8b44 Overhaul label sanitization logic
Besides code cleanup, this commit changes the sanitization logic to
avoid numeric digits altogether. Previously, they had been allowed at
the end of a label name, but this is susceptible to a ZZT parser bug:
`#foo` matches `:foo2` because ZZT allows a prefix match if the next
character is a digit.
2025-07-20 01:39:24 -07:00
cmounce 85ba7b63c8 Add license, description 2025-07-13 02:30:40 -07:00
cmounce f6df3222ab Rename macro crate, start prepping for publish 2025-07-13 02:15:22 -07:00
cmounce 8817cd8e2e Upgrade nom from 7.1 to 8.0 2025-07-11 03:28:26 -07:00
cmounce 3bddc3c85e Implement new algorithm for local label processing
This change allows qualified references to local labels from other
sections: you can do `#send section.local` and it will take you to the
appropriate `:.local` label. When `section.local` is ambiguous due to
sections/locals sharing the same names, the reference resolves to the
local in the first such section.
2025-07-11 02:18:17 -07:00
cmounce d751aade99 Document purposes of functions in encoding.rs 2025-07-10 22:15:20 -07:00
cmounce 051e08fba9 Fix serialization for stats with non-ASCII code 2025-07-07 11:53:49 -07:00
22 changed files with 445 additions and 298 deletions
Generated
+19 -26
View File
@@ -10,9 +10,9 @@ checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487"
[[package]]
name = "castaway"
version = "0.2.3"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0abae9be0aaf9ea96a3b1b8b1b55c602ca751eba1b1500220cea4ecbafe7c0d5"
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
dependencies = [
"rustversion",
]
@@ -138,8 +138,8 @@ dependencies = [
"compact_str",
"insta",
"lexopt",
"mzp-peg-macro",
"nom",
"peg_macro",
"rustc-hash",
]
@@ -150,29 +150,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "once_cell"
version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "peg_macro"
name = "mzp-peg-macro"
version = "0.1.0"
dependencies = [
"itertools",
@@ -181,6 +159,21 @@ dependencies = [
"syn",
]
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]]
name = "once_cell"
version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "proc-macro2"
version = "1.0.95"
+8 -8
View File
@@ -1,16 +1,20 @@
[package]
name = "marzipan"
description = "A macro processor for ZZT worlds"
keywords = ["zzt", "gamedev"]
categories = ["compilers", "command-line-utilities"]
version = "0.1.0"
edition = "2024"
license = "MIT"
repository = "https://github.com/cmounce/marzipan"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
anyhow = "1.0.86"
codepage-437 = "0.1.0"
peg_macro = { path = "./peg_macro"}
insta = "1.39.0"
nom = "7.1.3"
mzp-peg-macro = { path = "./mzp_peg_macro", version = "0.1.0" }
nom = "8.0.0"
compact_str = "0.9.0"
rustc-hash = "2.1.1"
lexopt = "0.3.1"
@@ -22,9 +26,5 @@ insta = "1.39.0"
insta.opt-level = 3
similar.opt-level = 3
[[bin]]
name = "marzipan"
path = "src/main.rs"
[workspace]
members = [".", "peg_macro"]
members = [".", "mzp_peg_macro"]
+18
View File
@@ -0,0 +1,18 @@
MIT License
Copyright (c) 2025 Chris Mounce
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.
+57
View File
@@ -0,0 +1,57 @@
# Marzipan
Marzipan is a macro processor for [ZZT](https://en.wikipedia.org/wiki/ZZT).
It extends the ZZT-OOP scripting language while still maintaining compatibility with ZZT 3.2: all language features compile down to vanilla ZZT-OOP.
## Features
- **Anonymous labels:** Like assembly code, ZZT-OOP control flow relies heavily on gotos.
Anonymous labels reduce the cognitive overhead of writing such code.
`:@` defines a label with a unique name, and `@f`/`@b` reference the nearest anonymous label forward/backward of the current line.
- **Local labels:** Label names with a dot are scoped to a single section of an object's program.
This allows you to reuse a name like `.loop` multiple times in a single object's code.
Marzipan will replace this with a distinct name per section: `loop_` in one section, `loopa` in the next section, etc.
- **Macro language (WIP):** Lines starting with `%` invoke a Marzipan macro.
Macros work by text substitution; for example, `%include "foo.txt"` will insert the contents of a text file at the current line.
Here's an example of what anonymous labels look like in practice:
```
@Treasure chest
#end
:touch 'Event handler, user touched the chest.
#if key @f 'Jump to anonymous label. Marzipan compiles `@f` to `_`...
The chest is locked.
#end
:@ '...and compiles this to `:_`.
You unlock the chest.
There's a bunch of gems inside!
#give gems 20
#die
:shot 'Event handler, user shot at the chest.
#if shotgun @f 'This time, Marzipan compiles `@f` to `a`...
Nothing happens.
#end
:@ '...and compiles this to `:a`.
The chest shatters!
Gems fly everywhere.
#put w green gem
#put e green gem
#become green gem
```
## Usage
Marzipan reads and writes ZZT world files, a binary file format.
You will need either ZZT itself or an external ZZT editor (such as [KevEdit](https://github.com/cknave/kevedit)) to work with them.
```
marzipan WORLD.ZZT -o ./dest_folder/WORLD.ZZT
```
A disclaimer: **Marzipan is experimental.**
It hasn't eaten my code yet, but I cannot guarantee it will treat your code with kindness.
If you use it, make sure to keep backups of your work. (You were already keeping backups, right?)
## Planned features
- Extending the macro system to a full language with variables, custom macro definitions, etc.
- Code minification, for generated code that bumps up against ZZT 3.2's size limits.
- Lints for ZZT-OOP, such as dead code analysis.
@@ -1,7 +1,10 @@
[package]
name = "peg_macro"
name = "mzp-peg-macro"
description = "Internal crate for generating Marzipan's PEG parser code"
version = "0.1.0"
edition = "2024"
license = "MIT"
repository = "https://github.com/cmounce/marzipan"
[lib]
proc-macro = true
+5
View File
@@ -0,0 +1,5 @@
A PEG parser generator built for [Marzipan](https://github.com/cmounce/marzipan).
Currently tightly coupled to Marzipan's specific needs and not designed for general use.
It might evolve into a standalone library in the future if there's broader interest.
But until then, you probably want something like [pest](https://pest.rs/) if you're looking for a PEG parser.
+8 -3
View File
@@ -1,7 +1,8 @@
use anyhow::{Result, anyhow};
use codepage_437::CP437_WINGDINGS;
// Serialize multi-line content with CR-terminated lines
/// Serialize a multi-line code string for an object or a scroll.
/// Uses ZZT's convention of CR-terminated lines.
pub fn encode_multiline(input: &str) -> Result<Vec<u8>> {
input
.chars()
@@ -17,6 +18,7 @@ pub fn encode_multiline(input: &str) -> Result<Vec<u8>> {
.collect()
}
/// Deserialize a multi-line code string.
pub fn decode_multiline(input: &[u8]) -> String {
input
.iter()
@@ -30,6 +32,8 @@ pub fn decode_multiline(input: &[u8]) -> String {
.collect()
}
/// Serialize a single-line string for a board title.
/// Newlines cannot be encoded because ZZT interprets them as dingbats (♪).
pub fn encode_oneline(input: &str) -> Result<Vec<u8>> {
input
.chars()
@@ -41,6 +45,7 @@ pub fn encode_oneline(input: &str) -> Result<Vec<u8>> {
.collect()
}
/// Deserialize a board title.
pub fn decode_oneline(input: &[u8]) -> String {
input.iter().map(|&x| CP437_WINGDINGS.decode(x)).collect()
}
@@ -86,12 +91,12 @@ mod tests {
}
#[test]
fn byte_13_to_wingding() {
fn byte_13_to_dingbat() {
// Code is allowed to have newlines
let bytes = serialize_code("ABC\nDEF");
assert!(bytes.contains(&13));
// But in a board title, that same byte is a wingding
// But in a board title, that same byte is a dingbat
assert_debug_snapshot!(decode_oneline(&bytes), @r#""ABC♪DEF""#);
}
+8 -1
View File
@@ -75,6 +75,13 @@ impl<'a> Context<'a> {
});
}
pub fn any_errors(&self) -> bool {
match self {
Context::Base(refcell) => refcell.borrow().iter().any(|x| x.level == Level::Error),
Context::With(parent, _info) => parent.any_errors(),
}
}
pub fn into_messages(self) -> Vec<CompileMessage> {
match self {
Context::Base(refcell) => {
@@ -102,7 +109,7 @@ pub struct CompileMessage {
pub location: Location,
}
#[derive(Debug)]
#[derive(Debug, Eq, PartialEq)]
pub enum Level {
Error,
Warning,
+1 -1
View File
@@ -121,7 +121,7 @@ pub fn parse_stat_labels(stat: &Stat, ctx: &Context) -> ParsedStat {
}
mod grammar {
use peg_macro::grammar;
use mzp_peg_macro::grammar;
grammar! {
program = (line ("\n" line)*)? EOI;
+87 -45
View File
@@ -8,7 +8,7 @@ use super::{
sanitize::Registry,
};
pub fn process_labels(board: &Board, ctx: &Context) -> Board {
pub fn process_labels(board: &Board, ctx: &Context) -> Option<Board> {
let mut board = board.clone();
// Parse stats into chunks
@@ -20,10 +20,17 @@ pub fn process_labels(board: &Board, ctx: &Context) -> Board {
.collect();
let mut registry = Registry::new();
// Expand ".local" labels to full "section.local" form.
resolve_local_labels(&mut stats, ctx);
assign_named_labels(&mut stats, &mut registry);
// Assign names to anonymous labels
// Sanitize all non-anonymous labels.
// This condenses name strings like "namespace~name$1.local" down to
// something short and valid for ZZT-OOP, e.g., "local_".
sanitize_named_labels(&mut stats, &mut registry);
// Replace anonymous labels with short names.
// This happens after sanitization so we know which short names are available to use.
// Two passes are needed because anonymous references can point either forward or backward.
anonymous_forward_pass(&mut stats, &mut registry, ctx);
anonymous_backward_pass(&mut stats, ctx);
@@ -43,7 +50,7 @@ pub fn process_labels(board: &Board, ctx: &Context) -> Board {
old_stat.code = new_code;
}
board
(!ctx.any_errors()).then_some(board)
}
/// Resolve ".local" labels to "name.local" form.
@@ -51,15 +58,8 @@ fn resolve_local_labels(stats: &mut [ParsedStat], ctx: &Context) {
for (i, stat) in stats.iter_mut().enumerate() {
let ctx = ctx.with_stat(i);
// Helper: Generate unique section strings like "touch$0"
let mut i = 0;
let mut make_section_id = |label_name: &str| -> CompactString {
let result = format!("{}${}", &label_name, i);
i += 1;
result.into()
};
let mut namespace_to_section: FxHashMap<Option<CompactString>, CompactString> =
// Keep a separate resolver for each namespace
let mut resolvers: FxHashMap<Option<CompactString>, LocalLabelResolver> =
FxHashMap::default();
for chunk in stat.iter_mut() {
@@ -69,33 +69,22 @@ fn resolve_local_labels(stats: &mut [ParsedStat], ctx: &Context) {
is_anon: false,
name: label,
} => {
// Handle cases where either one of (section name, label) is missing.
// If both are present ("name.local") then the label is already fully resolved
// and there is nothing to do.
let resolver = resolvers.entry(label.namespace.clone()).or_default();
let is_definition = !*is_ref;
if let Some(local) = &label.local {
if label.name.is_empty() {
// Expand :.local to :name.local
assert!(label.local.is_some());
label.name = if let Some(section) =
namespace_to_section.get(&label.namespace)
{
section.clone()
} else {
let section = make_section_id("");
namespace_to_section.insert(label.namespace.clone(), section.clone());
section
}
} else if label.local.is_none() {
// Interpret label :name as start of new section.
// Only label definitions do this; label references have no effect.
if !*is_ref {
namespace_to_section
.insert(label.namespace.clone(), make_section_id(&label.name));
}
} else {
// User specified both a section name and a local name ("name.local").
// This isn't allowed because of issues around resolving the section ID.
// Local that needs to be resolved, such as ":.foo" or "#send .foo"
label.name = resolver.get_section_prefix(&local)
} else if is_definition {
// Illegal local label definition, such as ":touch.foo"
// _References_ to local labels may specify a section name: "#send touch.foo".
// But when a local is _defined_, the section name must always be inferred.
ctx.with_span(label.span.clone())
.error("local labels with section names not supported");
.error("local label definitions cannot specify a section name");
}
} else if is_definition {
// Top-level label definition, such as ":touch"
resolver.start_new_section(&label.name);
}
}
_ => {}
@@ -104,8 +93,59 @@ fn resolve_local_labels(stats: &mut [ParsedStat], ctx: &Context) {
}
}
#[derive(Default)]
struct LocalLabelResolver {
current_section: CompactString,
current_section_index: usize,
pair_info: FxHashMap<(CompactString, CompactString), LocalLabelInfo>,
}
struct LocalLabelInfo {
last_section_index: usize,
num_sections: usize,
}
impl<'a> LocalLabelResolver {
/// Record the start of a new section, e.g., `touch`.
///
/// This is called for each occurence of a top-level label; if the same
/// label name appears multiple times, that creates multiple sections.
fn start_new_section(&mut self, section: &str) {
self.current_section = section.into();
self.current_section_index += 1;
}
/// Generate a distinct section string (e.g., `touch$1`) for a local label.
///
/// The output depends not just on the section's and local's names, but also
/// the number of previous sections that have had that section-local combo:
///
/// - The first time `.foo` appears in a `touch` section, all instances of
/// `.foo` within that section resolve to `touch.foo`.
/// - But if there's a _second_ `touch` label, any instances of `.foo` within
/// _that_ section must resolve to a distinct name: `touch$1.foo`.
fn get_section_prefix(&mut self, local: &str) -> CompactString {
let key = (self.current_section.clone(), local.into());
let info = self.pair_info.entry(key).or_insert_with(|| LocalLabelInfo {
last_section_index: self.current_section_index,
num_sections: 1,
});
if info.last_section_index != self.current_section_index {
info.last_section_index = self.current_section_index;
info.num_sections += 1;
}
let mut result = self.current_section.clone();
if info.num_sections > 1 {
let disambiguator = info.num_sections - 1;
result.push('$');
result.push_str(&disambiguator.to_string());
}
result
}
}
/// Assign sanitized names to all of the named labels.
fn assign_named_labels(stats: &mut [ParsedStat], registry: &mut Registry) {
fn sanitize_named_labels(stats: &mut [ParsedStat], registry: &mut Registry) {
for stat in stats.iter_mut() {
for chunk in stat.iter_mut() {
match chunk {
@@ -140,6 +180,8 @@ fn anonymous_forward_pass(stats: &mut [ParsedStat], registry: &mut Registry, ctx
let mut label_names = vec![];
for (stat_index, stat) in stats.iter_mut().enumerate() {
let ctx = ctx.with_stat(stat_index);
// Helper: Get the next label name that hasn't been used in this object yet
let mut i = 0;
let mut get_next_name = || -> CompactString {
@@ -155,7 +197,6 @@ fn anonymous_forward_pass(stats: &mut [ParsedStat], registry: &mut Registry, ctx
let mut namespace_to_latest: FxHashMap<Option<CompactString>, CompactString> =
FxHashMap::default();
let ctx = ctx.with_stat(stat_index);
for chunk in stat.iter_mut() {
match chunk {
Chunk::Label {
@@ -190,8 +231,9 @@ fn anonymous_forward_pass(stats: &mut [ParsedStat], registry: &mut Registry, ctx
/// Resolve anonymous forward references to their label names.
fn anonymous_backward_pass(stats: &mut [ParsedStat], ctx: &Context) {
for (stat_index, stat) in stats.iter_mut().enumerate() {
let mut namespace_to_latest = FxHashMap::default();
let ctx = ctx.with_stat(stat_index);
let mut namespace_to_latest = FxHashMap::default();
for chunk in stat.iter_mut().rev() {
match chunk {
Chunk::Label {
@@ -276,28 +318,28 @@ mod test {
#[test]
fn test_label_sanitization() {
let board = board_from_text("tests/labels/sanitize.txt");
let board = process_labels(&board, &Context::new());
let board = process_labels(&board, &Context::new()).unwrap();
assert_snapshot!(board_to_text(board));
}
#[test]
fn test_anonymous_labels() {
let board = board_from_text("tests/labels/anonymous.txt");
let board = process_labels(&board, &Context::new());
let board = process_labels(&board, &Context::new()).unwrap();
assert_snapshot!(board_to_text(board));
}
#[test]
fn test_local_labels() {
let board = board_from_text("tests/labels/local.txt");
let board = process_labels(&board, &Context::new());
let board = process_labels(&board, &Context::new()).unwrap();
assert_snapshot!(board_to_text(board));
}
#[test]
fn test_namespaces() {
let board = board_from_text("tests/labels/namespaces.txt");
let board = process_labels(&board, &Context::new());
let board = process_labels(&board, &Context::new()).unwrap();
assert_snapshot!(board_to_text(board));
}
+107 -146
View File
@@ -1,187 +1,151 @@
use std::collections::{HashMap, HashSet};
use compact_str::CompactString;
use rustc_hash::{FxHashMap, FxHashSet};
#[derive(PartialEq, Eq, Hash)]
struct LabelId(CompactString);
pub struct Registry {
key_to_suffix: FxHashMap<Lowercase, Suffix>,
names: FxHashSet<Lowercase>,
anonymous_counter: CompactString,
}
impl LabelId {
fn new(label: &str) -> Self {
let mut result = CompactString::new(label);
#[derive(Clone, PartialEq, Eq, Hash)]
struct Lowercase(CompactString);
impl Lowercase {
fn new(key: &str) -> Self {
let mut result = CompactString::new(key);
result.make_ascii_lowercase();
Self(result)
}
}
pub struct Registry {
label_transforms: HashMap<LabelId, Transform>,
existing: HashSet<CompactString>,
anonymous_counter: CompactString,
}
#[derive(Default)]
struct Suffix(CompactString);
enum Transform {
Preferred,
FilteredWithSuffix(CompactString),
}
impl Transform {
fn apply(&self, label: &str) -> CompactString {
let preferred = preferred_label_name(label);
match self {
Transform::Preferred => preferred,
Transform::FilteredWithSuffix(suffix) => {
let mut result = preferred;
result = Registry::filter_label_chars(&result);
result.push_str(&suffix);
impl Suffix {
fn apply(&self, key: &str) -> CompactString {
let mut result = preferred_label_name(key);
result.push_str(&self.0);
result
}
}
}
}
impl Registry {
pub fn new() -> Self {
let builtin_labels = ["bombed", "energize", "shot", "thud", "touch"];
let mut label_transforms = HashMap::new();
let mut existing = HashSet::new();
let mut key_to_suffix = FxHashMap::default();
let mut taken = FxHashSet::default();
for name in builtin_labels {
let cs = CompactString::new(name);
label_transforms.insert(LabelId(cs.clone()), Transform::Preferred);
existing.insert(cs);
let name = Lowercase(name.into());
key_to_suffix.insert(name.clone(), Suffix::default());
taken.insert(name);
}
Self {
label_transforms,
existing,
anonymous_counter: "_".into(),
key_to_suffix,
names: taken,
anonymous_counter: CompactString::const_new(""),
}
}
pub fn sanitize(&mut self, label: &str) -> CompactString {
pub fn sanitize(&mut self, key: &str) -> CompactString {
// Use existing sanitization if one exists
let id = LabelId::new(label);
if let Some(transform) = self.label_transforms.get(&id) {
return transform.apply(label);
let key_lower = Lowercase::new(key);
if let Some(transform) = self.key_to_suffix.get(&key_lower) {
return transform.apply(key);
}
// Try to use label's preferred name as-is
let preferred = Transform::Preferred.apply(label);
if Registry::is_valid_label(&preferred) {
let key = preferred.to_ascii_lowercase();
if !self.existing.contains(&key) {
self.label_transforms.insert(id, Transform::Preferred);
self.existing.insert(key);
return preferred;
}
}
// Generate a new name by appending suffixes
let base = Registry::filter_label_chars(&preferred);
let base_len = base.len();
let mut candidate = base;
let mut suffix = CompactString::with_capacity(0);
// Append suffixes until we find a name that's not taken yet
let mut candidate = Lowercase::new(&preferred_label_name(key));
let mut suffix = CompactString::const_new("");
let original_len = candidate.0.len();
loop {
candidate.push_str(&suffix);
let key = candidate.to_ascii_lowercase();
if !self.existing.contains(&key) {
self.label_transforms
.insert(id, Transform::FilteredWithSuffix(suffix));
self.existing.insert(key);
candidate.0.push_str(&suffix);
if !self.names.contains(&candidate) {
self.key_to_suffix.insert(key_lower, Suffix(suffix));
self.names.insert(candidate.clone());
break;
} else {
candidate.truncate(base_len);
candidate.0.truncate(original_len);
increment(&mut suffix);
}
}
candidate
candidate.0
}
pub fn gen_anonymous(&mut self) -> CompactString {
while self.existing.contains(&self.anonymous_counter) {
increment(&mut self.anonymous_counter);
while self
.names
.contains(&Lowercase(self.anonymous_counter.clone()))
{
increment(&mut self.anonymous_counter);
}
let result = self.anonymous_counter.clone();
increment(&mut self.anonymous_counter);
result
}
fn is_valid_label(s: &CompactString) -> bool {
if s.len() == 0 {
return false;
}
let (most, last) = s.split_at(s.len() - 1);
if !most.chars().all(|c| c == '_' || c.is_ascii_alphabetic()) {
return false;
}
last.chars().all(|c| c == '_' || c.is_ascii_alphanumeric())
}
fn filter_label_chars(s: &CompactString) -> CompactString {
let mut result = CompactString::with_capacity(0);
let mut run_of_digits = false;
for c in s.chars() {
if c.is_ascii_digit() {
if !run_of_digits {
result.push('_');
run_of_digits = true;
}
} else {
result.push(c);
run_of_digits = false;
}
}
result
self.anonymous_counter.clone()
}
}
/// Given an unsanitized full name, generate the first-pick name we'd like to
/// assign this label. (Results returned by this function are subject to veto
/// if they are invalid or already taken.)
fn preferred_label_name(s: &str) -> CompactString {
if let Some((_, suffix)) = s.rsplit_once(|c: char| !c.is_ascii_alphanumeric() && c != '_') {
// Prevent namespaces and local labels from starting with a digit.
// This ensures stuff like `#take gems 100.99orless` won't compile
// to `#take gems 10099orless` (would parse incorrectly).
let mut result = CompactString::const_new("_");
result.push_str(suffix);
result
} else {
CompactString::new(s)
/// Given the key string identifying a label, generate the first-pick name we'd
/// like to assign it. (Results returned by this function are subject to veto if
/// they are already taken.)
fn preferred_label_name(key: &str) -> CompactString {
// Extract the last part of the key: "ns~global$123.local" becomes "local"
let base_name = key
.rsplit_once(|c: char| !c.is_ascii_alphanumeric() && c != '_')
.map(|(_, base_name)| base_name)
.unwrap_or(key);
// Collapse runs of numeric digits/underscores to a single underscore
let mut result = CompactString::default();
let mut run = false;
for c in base_name.chars() {
if c.is_ascii_alphabetic() {
result.push(c);
run = false;
} else if !run {
result.push('_');
run = true;
}
}
result
}
/// Increments a string through label-safe characters.
/// Characters loop through underscore and the letters a-z.
/// Additionally, the final character is allowed to loop through 0-9.
///
/// The characters tick upward in order (underscore, a-z), odometer style,
/// growing the string on overflow. The result is similar to counting in
/// base 27, but it's not quite the same:
///
/// - The zeroth value is the empty string "" (not "_").
/// - "z" is followed by "__", "_a", "_b", etc (not "a_", "aa", "ab").
///
/// Unlike in place-value systems where "0001" and "1" are equivalent, the goal
/// of this function is to generate every possible string.
fn increment(s: &mut CompactString) {
let original_len = s.len();
// Find a character we can increment without carry
let mut last_char = s.pop();
while !(last_char == None || last_char != Some('z')) {
while last_char == Some('z') {
last_char = s.pop();
}
if let Some(c) = last_char {
// Increment character in order: 0-9, then _, then a-z
// Increment character
let incremented = match c {
'0'..'9' | 'a'..'z' => (c as u8 + 1) as char,
'9' => '_',
'a'..'z' => (c as u8 + 1) as char,
'_' => 'a',
_ => unreachable!(),
};
s.push(incremented);
// Pad to original length, like "___0"
// Pad to original length
while s.len() < original_len {
let is_last = s.len() == original_len - 1;
s.push(if is_last { '0' } else { '_' });
}
} else {
// We reached maximum value for the string: everything rolls over
for _ in 0..original_len {
s.push('_');
}
s.push('0');
} else {
// All existing chars roll over, length grows by 1
for _ in 0..(original_len + 1) {
s.push('_');
}
}
}
@@ -210,29 +174,26 @@ mod test {
}
let result = rows.join("\n");
assert_snapshot!(result, @r"
0, 1, 2, 3, 4, 5, 6, 7, 8, 9
_, a, b, c, d, e, f, g, h, i
j, k, l, m, n, o, p, q, r, s
t, u, v, w, x, y, z, _0, _1, _2
_3, _4, _5, _6, _7, _8, _9, __, _a, _b
t, u, v, w, x, y, z, __, _a, _b
_c, _d, _e, _f, _g, _h, _i, _j, _k, _l
_m, _n, _o, _p, _q, _r, _s, _t, _u, _v
_w, _x, _y, _z, a0, a1, a2, a3, a4, a5
a6, a7, a8, a9, a_, aa, ab, ac, ad, ae
_w, _x, _y, _z, a_, aa, ab, ac, ad, ae
af, ag, ah, ai, aj, ak, al, am, an, ao
ap, aq, ar, as, at, au, av, aw, ax, ay
az, b_, ba, bb, bc, bd, be, bf, bg, bh
bi, bj, bk, bl, bm, bn, bo, bp, bq, br
");
}
#[test]
fn test_increment_all_len_3() {
// Most chars are alphabetic or underscore (26 + 1 = 27).
// Last char can include digits (27 + 10 = 37).
let num_labels = 37 + 27 * 37 + 27 * 27 * 37; // 1 char + 2 chars + 3 chars
let num_labels = 27 + 27 * 27 + 27 * 27 * 27; // 1 char + 2 chars + 3 chars
let mut ctr = CompactString::new("");
let mut seen = HashSet::with_capacity(num_labels);
for _ in 0..num_labels {
increment(&mut ctr);
assert!(Registry::is_valid_label(&ctr));
assert!(!seen.contains(&ctr));
assert!(ctr.len() <= 3);
seen.insert(ctr.clone());
@@ -273,20 +234,20 @@ mod test {
let result = results.join("\n");
assert_snapshot!(result, @r#"
"foo" => foo
"ns1~foo" => _foo
"ns2~foo" => _foo0
"foo.thisloop" => _thisloop
"foo.thatloop" => _thatloop
"bar.thisloop" => _thisloop0
"bar.thatloop" => _thatloop0
"foo$1.thisloop" => _thisloop1
"foo$1.thatloop" => _thatloop1
"bar1" => bar1
"bar2" => bar2
"bar123" => bar_
"BAR123" => BAR_
"bar456" => bar_0
"BAR456" => BAR_0
"ns1~foo" => foo_
"ns2~foo" => fooa
"foo.thisloop" => thisloop
"foo.thatloop" => thatloop
"bar.thisloop" => thisloop_
"bar.thatloop" => thatloop_
"foo$1.thisloop" => thisloopa
"foo$1.thatloop" => thatloopa
"bar1" => bar_
"bar2" => bar__
"bar123" => bar_a
"BAR123" => BAR_a
"bar456" => bar_b
"BAR456" => BAR_b
"foo2bar" => foo_bar
"#);
}
@@ -82,12 +82,11 @@ error: message targets not supported for local labels
7 | :.local
|
error: local labels with section names not supported
=> test.zzt -> Title screen -> @Locals with section names (1,1) -> line 3:2
error: local label definitions cannot specify a section name
=> test.zzt -> Title screen -> @Local label definitions with section names (1,1) -> line 8:2
|
1 | @Locals with section names
2 | :touch
3 | #touch.skip
5 | #touch.skip
6 | '
7 | 'this is not
8 | :touch.skip
| ^^^^^^^^^^
4 | :.skip
|
@@ -9,14 +9,14 @@ expression: board_to_text(board)
You have hundreds of gems.
#end
:lt_
#take gems 10 lt_0
#take gems 10 lt__
#give gems 10
You have tens of gems.
#end
:lt_0
#take gems 1 lt1
:lt__
#take gems 1 lt_a
#give gems 1
You have at least one gem.
#end
:lt1
:lt_a
You don't have any gems!
@@ -6,50 +6,77 @@ expression: board_to_text(board)
'using the same local label ".loop"
:run_w
#walk w
:_loop
:loop
#try w stop
#_loop
#loop
'
:run_e
#walk e
:_loop0
:loop_
#try e stop
#_loop0
#loop_
'
:stop
#walk i
---
'Using locals before any globals are defined
:_loop1
#take gems 1 _break
#_loop1
:_break
:loopa
#take gems 1 break
#loopa
:break
---
'Multiple sections with the same name
#end
:touch
Trying to sell you some ammo...
#take gems 10 _skip
#take gems 10 skip
#give ammo 10
:_skip
:skip
#zap touch
#end
:touch
Trying to sell you some torches...
#take gems 15 _skip0
#take gems 15 skip_
#give torches 5
:_skip0
:skip_
#restore touch
---
'Multiple locals with the same name
#end
:repeat3
:_z
:_z
:_z
#take gems 1 _skip1
:repeat_
:z
:z
:z
#take gems 1 skipa
#give score 1
:_skip1
#zap _z
#_z
:_z
:skipa
#zap z
#z
:z
---
'Addressing labels with section.local
:touch
:foo
#end
:touch
:foo_
#end
:shot
'sends to the first one:
#foo
---
@alice
:shared
'this is the 1st section named "shared"
'but its local should correspond with @bob's 2nd "shared"
:local
#end
:shared
---
@bob
:shared
#end
:shared
'this is the 2nd section named "shared"
'but its local should correspond with @alice's 1st "shared"
:local
@@ -22,13 +22,13 @@ expression: board_to_text(board)
'Make sure locals exist in separate namespaces
:do_stuff
#lock
:_loop
:loop
'
:_do_stuff
:_loop0
/i#if blocked rndne _loop0
:do_stuff_
:loop_
/i#if blocked rndne loop_
'
#take time 1 _break
#_loop
:_break
#take time 1 break
#loop
:break
#unlock
+3 -1
View File
@@ -84,7 +84,9 @@ fn main() -> Result<()> {
let ctx = base_ctx.with_file_path(&input_filename);
for (i, board) in world.boards.iter_mut().enumerate() {
let ctx = ctx.with_board(i);
*board = process_labels(&board, &ctx);
if let Some(processed_board) = process_labels(&board, &ctx) {
*board = processed_board;
}
}
// Print diagnostics
+1 -1
View File
@@ -207,7 +207,7 @@ impl<'a, T: Clone> Capture<'a, T> {
#[cfg(test)]
mod tests {
use insta::assert_debug_snapshot;
use peg_macro::grammar;
use mzp_peg_macro::grammar;
use super::ParseState;
+19 -21
View File
@@ -7,7 +7,6 @@ use nom::{
error::{ErrorKind, ParseError},
multi::count,
number::complete::{le_i16, le_u8, le_u16},
sequence::tuple,
};
use crate::encoding::{decode_multiline, decode_oneline, encode_multiline, encode_oneline};
@@ -128,14 +127,13 @@ pub struct Stat {
impl World {
pub fn from_bytes(bytes: &[u8]) -> Result<World, LoadError> {
let (input, (_, num_boards)) = tuple((tag([0xff, 0xff]), le_i16)).parse(bytes)?;
let (input, (ammo, gems, keys)) =
tuple((le_i16, le_i16, count(bool_u8, 7))).parse(input)?;
let (input, (_, num_boards)) = (tag(&[0xff, 0xff][..]), le_i16).parse(bytes)?;
let (input, (ammo, gems, keys)) = (le_i16, le_i16, count(bool_u8, 7)).parse(input)?;
let (input, (health, starting_board, torches, torch_cycles, energizer_cycles)) =
tuple((le_i16, le_i16, le_i16, le_i16, le_i16)).parse(input)?;
let (input, (_, score, world_name)) = tuple((take(2usize), le_i16, pstring(20)))(input)?;
(le_i16, le_i16, le_i16, le_i16, le_i16).parse(input)?;
let (input, (_, score, world_name)) = (take(2usize), le_i16, pstring(20)).parse(input)?;
let (input, flags) = count(pstring(20), 10).parse(input)?;
let (_input, (time, time_ticks, locked)) = tuple((le_i16, le_i16, bool_u8)).parse(input)?;
let (_input, (time, time_ticks, locked)) = (le_i16, le_i16, bool_u8).parse(input)?;
// Rest of header is padding; fast-forward starting from original input
let (input, _) = take(512usize).parse(bytes)?;
@@ -214,7 +212,7 @@ impl Board {
let mut input = input;
let mut terrain = Vec::with_capacity(NUM_TILES);
while terrain.len() < NUM_TILES {
let (next_input, (count, element, color)) = tuple((le_u8, le_u8, le_u8))(input)?;
let (next_input, (count, element, color)) = (le_u8, le_u8, le_u8).parse(input)?;
input = next_input;
let count: u32 = if count == 0 { 256 } else { count.into() };
for _ in 0..count {
@@ -226,11 +224,11 @@ impl Board {
}
// Read board info
let (input, (max_shots, is_dark)) = tuple((le_u8, bool_u8))(input)?;
let (input, (max_shots, is_dark)) = (le_u8, bool_u8).parse(input)?;
let (input, (board_n, board_s, board_w, board_e)) =
tuple((le_u8, le_u8, le_u8, le_u8))(input)?;
let (input, (reenter_when_zapped, message)) = tuple((bool_u8, pstring(58)))(input)?;
let (input, (enter_x, enter_y, time_limit)) = tuple((le_u8, le_u8, le_i16))(input)?;
(le_u8, le_u8, le_u8, le_u8).parse(input)?;
let (input, (reenter_when_zapped, message)) = (bool_u8, pstring(58)).parse(input)?;
let (input, (enter_x, enter_y, time_limit)) = (le_u8, le_u8, le_i16).parse(input)?;
let (input, _) = take(16usize)(input)?;
// Read stats
@@ -239,7 +237,7 @@ impl Board {
if num_stats < 0 {
return Err("cannot have a negative number of stats".into());
}
let (_input, stats) = count(Stat::from_bytes, num_stats as usize)(input)?;
let (_input, stats) = count(Stat::from_bytes, num_stats as usize).parse(input)?;
Ok(Board {
name,
@@ -315,12 +313,12 @@ impl Board {
impl Stat {
pub fn from_bytes(input: &[u8]) -> IResult<&[u8], Self, LoadError> {
let (input, (x, y, x_step, y_step)) = tuple((le_u8, le_u8, le_i16, le_i16))(input)?;
let (input, (cycle, p1, p2, p3)) = tuple((le_i16, le_u8, le_u8, le_u8))(input)?;
let (input, (follower, leader)) = tuple((le_i16, le_i16))(input)?;
let (input, (under_element, under_color)) = tuple((le_u8, le_u8))(input)?;
let (input, (x, y, x_step, y_step)) = (le_u8, le_u8, le_i16, le_i16).parse(input)?;
let (input, (cycle, p1, p2, p3)) = (le_i16, le_u8, le_u8, le_u8).parse(input)?;
let (input, (follower, leader)) = (le_i16, le_i16).parse(input)?;
let (input, (under_element, under_color)) = (le_u8, le_u8).parse(input)?;
let (input, _) = take(4usize)(input)?;
let (input, (instruction_pointer, length)) = tuple((le_i16, le_i16))(input)?;
let (input, (instruction_pointer, length)) = (le_i16, le_i16).parse(input)?;
let (input, _) = take(8usize)(input)?;
let (input, code_bytes) = take(0.max(length) as usize)(input)?;
let code = decode_multiline(&code_bytes);
@@ -363,15 +361,15 @@ impl Stat {
result.push_padding(4);
result.push_i16(self.instruction_pointer);
// TODO: more safety around valid bind-indexes (positive? negative?)
let code_bytes = encode_multiline(&self.code).unwrap();
result.push_i16(if self.bind_index < 0 {
self.bind_index
} else {
self.code.len() as i16
code_bytes.len() as i16
});
result.push_padding(8);
if self.bind_index >= 0 {
// TODO: more safety around bind-index XOR code
let code_bytes = encode_multiline(&self.code).unwrap();
result.extend_from_slice(&code_bytes);
}
result
@@ -387,7 +385,7 @@ fn pstring(cap: u8) -> impl Fn(&[u8]) -> IResult<&[u8], Vec<u8>, LoadError> {
move |input: &[u8]| -> IResult<&[u8], Vec<u8>, LoadError> {
let (input, len) = le_u8(input)?;
if len >= cap {
return fail(input);
return fail().parse(input);
}
let (input, data) = take(len)(input)?;
let (input, _) = take(cap - len)(input)?;
+6 -2
View File
@@ -23,7 +23,11 @@
:@
:.local
---
@Locals with section names
@Local label definitions with section names
'these are okay
:touch
#touch.skip
:.skip
#touch.skip
'
'this is not
:touch.skip
+27 -1
View File
@@ -49,4 +49,30 @@ Trying to sell you some torches...
#zap .z
#.z
:.z
---
'Addressing labels with section.local
:touch
:.foo
#end
:touch
:.foo
#end
:shot
'sends to the first one:
#touch.foo
---
@alice
:shared
'this is the 1st section named "shared"
'but its local should correspond with @bob's 2nd "shared"
:.local
#end
:shared
---
@bob
:shared
#end
:shared
'this is the 2nd section named "shared"
'but its local should correspond with @alice's 1st "shared"
:.local