pub mod tree;
pub use tree::{Entry, Node};
use crate::error::{Error, ParseError};
use crate::formats::nsmp::zone::derive_top_notes;
use std::io::{Read, Write};
pub const MAGIC: &[u8] = b"SMACEditorProject {";
pub const FORMAT: &str = "nsmpproj";
pub const KNOWN_FILE_FORMAT_VERSION: u32 = 54;
pub const LOWEST_NOTE: u8 = 17;
pub const HIGHEST_NOTE: u8 = 108;
pub const FIRST_ZONE_ID: u32 = 129;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Project {
pub root: Node,
}
#[derive(Debug, Clone, PartialEq)]
pub struct AudioFile {
pub id: u32,
pub path: String,
pub sample_rate: u32,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Stroke {
pub zone_id: u32,
pub global_id: u32,
pub file_id: u32,
pub begin: f64,
pub end: f64,
pub start: f64,
pub stop: f64,
pub loop_enabled: bool,
pub loop_start: f64,
pub loop_length: f64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Zone {
pub zone_id: u32,
pub root_key: u8,
pub enabled: bool,
pub bottom_note: u8,
pub top_note: u8,
pub strokes: Vec<ZoneStroke>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ZoneStroke {
pub global_id: u32,
pub enabled: bool,
pub gain: f64,
pub detune: i32,
pub velocity: (u8, u8),
}
#[derive(Debug, Clone, PartialEq)]
pub struct NewZone {
pub path: String,
pub sample_rate: u32,
pub frames: u64,
pub root_key: u8,
}
fn flag(node: &Node, key: &str) -> Result<bool, ParseError> {
Ok(node.get::<u8>(key)? != 0)
}
fn real(v: f64) -> String {
format!("{v:.6}")
}
impl Project {
pub fn read_from(reader: &mut impl Read) -> Result<Project, Error> {
let mut bytes = Vec::new();
reader.read_to_end(&mut bytes)?;
if !bytes.starts_with(MAGIC) {
return Err(
ParseError::UnknownFileType("not a Nord Sample Editor project".into()).into(),
);
}
let text = String::from_utf8(bytes).map_err(|e| {
ParseError::AssertFail(format!(
"project is not UTF-8 at byte {}",
e.utf8_error().valid_up_to()
))
})?;
Ok(Project::parse(&text)?)
}
pub fn parse(text: &str) -> Result<Project, ParseError> {
let root = tree::parse(text)?;
if root.name != "SMACEditorProject" {
return Err(ParseError::AssertFail(format!(
"root block is {}, not SMACEditorProject",
root.name
)));
}
Ok(Project { root })
}
pub fn write_to(&self, writer: &mut impl Write) -> Result<(), Error> {
writer.write_all(self.render().as_bytes())?;
Ok(())
}
pub fn render(&self) -> String {
let mut out = String::new();
self.root.render(&mut out, 0);
out
}
pub fn file_format_version(&self) -> Result<u32, ParseError> {
self.root.get("m_fileFormatVersion")
}
pub fn created_by(&self) -> Result<(String, String), ParseError> {
Ok((
self.root.get("m_createdByProdName")?,
self.root.get("m_createdByProdVer")?,
))
}
pub fn audio_files(&self) -> Result<Vec<AudioFile>, ParseError> {
self.root
.blocks("audio_file")
.map(|n| {
Ok(AudioFile {
id: n.get("m_id")?,
path: n.get("m_fullName")?,
sample_rate: n.get("m_sampleRate")?,
})
})
.collect()
}
pub fn strokes(&self) -> Result<Vec<Stroke>, ParseError> {
let mut out = Vec::new();
for zone in self.root.blocks("common_zone") {
let zone_id = zone.get("m_zoneId")?;
for s in zone.blocks("common_stroke") {
out.push(Stroke {
zone_id,
global_id: s.get("m_globalID")?,
file_id: s.get("m_fileID")?,
begin: s.get("m_begin")?,
end: s.get("m_end")?,
start: s.get("m_start")?,
stop: s.get("m_stop")?,
loop_enabled: flag(s, "m_loopEnabled")?,
loop_start: s.get("m_loopStart")?,
loop_length: s.get("m_loopLengthLong")?,
});
}
}
Ok(out)
}
fn instrument(&self) -> Result<&Node, ParseError> {
self.root.require("instrument")
}
fn instrument_mut(&mut self) -> Result<&mut Node, ParseError> {
self.root
.blocks_mut("instrument")
.next()
.ok_or_else(|| ParseError::AssertFail("project has no instrument block".into()))
}
pub fn name(&self) -> Result<String, ParseError> {
self.instrument()?.get("m_name")
}
pub fn set_name(&mut self, name: &str) -> Result<(), ParseError> {
self.instrument_mut()?.set_field("m_name", name)
}
pub fn zones(&self) -> Result<Vec<Zone>, ParseError> {
let map = self.instrument()?.require("map_info")?;
map.blocks("map_zone")
.map(|z| {
Ok(Zone {
zone_id: z.get("m_zoneId")?,
root_key: z.get("m_rootKey")?,
enabled: flag(z, "m_isEnabled")?,
bottom_note: z.get("m_btmNote")?,
top_note: z.get("m_topNote")?,
strokes: z
.blocks("map_stroke")
.map(|s| {
Ok(ZoneStroke {
global_id: s.get("m_globalID")?,
enabled: flag(s, "m_isEnabled")?,
gain: s.get("m_gain")?,
detune: s.get("m_detune")?,
velocity: (s.get("m_velocityMin")?, s.get("m_velocityMax")?),
})
})
.collect::<Result<_, ParseError>>()?,
})
})
.collect()
}
fn map_zone_mut(&mut self, zone_id: u32) -> Result<&mut Node, ParseError> {
let map = self
.instrument_mut()?
.blocks_mut("map_info")
.next()
.ok_or_else(|| ParseError::AssertFail("instrument has no map_info block".into()))?;
map.blocks_mut("map_zone")
.find(|z| z.field("m_zoneId") == Some(zone_id.to_string().as_str()))
.ok_or_else(|| ParseError::AssertFail(format!("no zone with id {zone_id}")))
}
pub fn set_root_key(&mut self, zone_id: u32, key: u8) -> Result<(), ParseError> {
self.map_zone_mut(zone_id)?
.set_field("m_rootKey", key.to_string())
}
pub fn set_key_range(&mut self, zone_id: u32, bottom: u8, top: u8) -> Result<(), ParseError> {
if bottom > top {
return Err(ParseError::OutOfBounds {
value: format!("{bottom}..={top}"),
bound: "a key range with its bottom at or below its top".into(),
});
}
let zone = self.map_zone_mut(zone_id)?;
zone.set_field("m_btmNote", bottom.to_string())?;
zone.set_field("m_topNote", top.to_string())
}
pub fn set_audio_path(&mut self, file_id: u32, path: &str) -> Result<(), ParseError> {
self.root
.blocks_mut("audio_file")
.find(|f| f.field("m_id") == Some(file_id.to_string().as_str()))
.ok_or_else(|| ParseError::AssertFail(format!("no audio file with id {file_id}")))?
.set_field("m_fullName", path)
}
pub fn new(name: &str, zones: &[NewZone], modified: u32) -> Result<Project, ParseError> {
if zones.is_empty() {
return Err(ParseError::AssertFail(
"a project needs at least one zone".into(),
));
}
let mut by_root: Vec<&NewZone> = zones.iter().collect();
by_root.sort_by_key(|z| z.root_key);
if by_root.windows(2).any(|w| w[0].root_key == w[1].root_key) {
return Err(ParseError::AssertFail("two zones share a root key".into()));
}
if !(LOWEST_NOTE..=HIGHEST_NOTE).contains(&by_root[0].root_key)
|| !(LOWEST_NOTE..=HIGHEST_NOTE).contains(&by_root[by_root.len() - 1].root_key)
{
return Err(ParseError::OutOfBounds {
value: "root key".into(),
bound: format!("{LOWEST_NOTE}..={HIGHEST_NOTE}"),
});
}
let numbered: Vec<(u32, &NewZone)> = by_root
.iter()
.enumerate()
.map(|(i, z)| (i as u32 + 1, *z))
.collect();
let n = numbered.len();
let count = n.to_string();
let date = modified.to_string();
let roots_high_to_low: Vec<u8> = numbered.iter().rev().map(|(_, z)| z.root_key).collect();
let tops = derive_top_notes(&roots_high_to_low);
let bottoms: Vec<u8> = (0..n)
.map(|i| tops.get(i + 1).map_or(LOWEST_NOTE, |t| t + 1))
.collect();
let mut root = Node::new("SMACEditorProject");
root.push_field("m_fileFormatVersion", KNOWN_FILE_FORMAT_VERSION.to_string());
root.push_field("m_createdByProdName", "nord-format");
root.push_field(
"m_createdByProdVer",
concat!("v", env!("CARGO_PKG_VERSION")),
);
let mut document = Node::new("document");
document.push_field("m_zones.size", &count);
document.push_field("m_instruments.size", "1");
document.push_field("m_audioFiles.size", &count);
document.push_field("m_maxStrength", "0");
document.push_field("m_minStrength", "0");
document.push_field("m_modifyDate", &date);
root.push_block(document);
for (id, z) in &numbered {
let mut file = Node::new("audio_file");
file.push_field("m_id", id.to_string());
file.push_field("m_fullName", &z.path);
file.push_field("m_sampleRate", z.sample_rate.to_string());
file.push_field("m_modifyDate", &date);
root.push_block(file);
}
for (id, z) in numbered.iter().rev() {
let mut zone = Node::new("common_zone");
zone.push_field("m_zoneId", (FIRST_ZONE_ID + id - 1).to_string());
zone.push_field("m_strokes.size", "1");
zone.push_field("m_modifyDate", &date);
zone.push_field("m_mapTopNote", "0");
zone.push_block(common_stroke(*id, z.frames, &date));
root.push_block(zone);
}
let mut instrument = Node::new("instrument");
instrument.push_field("m_id", "0");
instrument.push_field("m_type", "0");
instrument.push_field("m_name", name);
instrument.push_field("m_crc", "0");
instrument.push_field("m_crcProj", "0");
instrument.push_field("m_revision", "0");
instrument.push_field("m_zones.size", &count);
instrument.push_field("m_maps.size", "1");
instrument.push_field("m_modifyDate", &date);
instrument.push_field("m_loopDecayEnabled", "0");
instrument.push_field("m_loopDecayHi", "20.000000");
instrument.push_field("m_loopDecayLo", "20.000000");
let mut default_params = Node::new("default_params");
default_params.push_field("m_bufferSize", "0");
default_params.push_field("m_buffer", "");
instrument.push_block(default_params);
let mut attrs = Node::new("samplib_attrs");
for (key, value) in SAMPLIB_ATTRS {
attrs.push_field(*key, *value);
}
instrument.push_block(attrs);
for (key, value) in INSTRUMENT_CATEGORY_AND_EQ {
instrument.push_field(*key, *value);
}
for (id, _) in numbered.iter().rev() {
let mut zone = Node::new("instrument_zone");
zone.push_field("m_zoneId", (FIRST_ZONE_ID + id - 1).to_string());
zone.push_field("m_strokes.size", "1");
zone.push_field("m_modifyDate", &date);
let mut stroke = Node::new("instrument_stroke");
stroke.push_field("m_globalID", id.to_string());
stroke.push_field("m_modifyDate", &date);
zone.push_block(stroke);
instrument.push_block(zone);
}
let mut map = Node::new("map_info");
map.push_field("m_id", "0");
map.push_field("m_name", "Map");
map.push_field("m_gain", "1.000000");
map.push_field("m_detune", "0");
map.push_field("m_files.size", &count);
map.push_field("m_notes.size", (HIGHEST_NOTE - LOWEST_NOTE + 1).to_string());
map.push_field("m_zones.size", &count);
map.push_field("m_modifyDate", &date);
for i in 0..8 {
map.push_field(format!("m_macroSpinCtrlVal[{i}]"), "1.000000");
}
map.push_field("m_normalize", "0");
map.push_field("m_emphasisMode", "0");
for (id, _) in &numbered {
let mut file = Node::new("map_audiofile");
file.push_field("m_id", id.to_string());
file.push_field("m_isEnabled", "1");
file.push_field("m_modifyDate", &date);
map.push_block(file);
}
for note in LOWEST_NOTE..=HIGHEST_NOTE {
let mut info = Node::new("note_info");
info.push_field("m_no", note.to_string());
info.push_field("m_gain", "1.000000");
info.push_field("m_detune", "0");
info.push_field("m_modifyDate", &date);
map.push_block(info);
}
for (i, (id, z)) in numbered.iter().rev().enumerate() {
let mut zone = Node::new("map_zone");
zone.push_field("m_zoneId", (FIRST_ZONE_ID + id - 1).to_string());
zone.push_field("m_rootKey", z.root_key.to_string());
zone.push_field("m_isEnabled", "1");
zone.push_field("m_btmNote", bottoms[i].to_string());
zone.push_field("m_topNote", tops[i].to_string());
for (key, value) in MAP_ZONE_DEFAULTS {
zone.push_field(*key, *value);
}
zone.push_field("m_strokes.size", "1");
zone.push_field("m_modifyDate", &date);
let mut stroke = Node::new("map_stroke");
stroke.push_field("m_globalID", id.to_string());
for (key, value) in MAP_STROKE_DEFAULTS {
stroke.push_field(*key, *value);
}
stroke.push_field("m_modifyDate", &date);
zone.push_block(stroke);
map.push_block(zone);
}
instrument.push_block(map);
root.push_block(instrument);
Ok(Project { root })
}
}
fn common_stroke(global_id: u32, frames: u64, date: &str) -> Node {
let end = frames as f64;
let start = 1.0;
let loop_start = end / 2.0;
let loop_length = loop_start - 1.0;
let mut s = Node::new("common_stroke");
s.push_field("m_globalID", global_id.to_string());
s.push_field("m_fileID", global_id.to_string());
s.push_field("m_strokeType", "0");
s.push_field("m_strength", "0");
s.push_field("m_shortLoopUsesPitch", "1");
s.push_field("m_begin", real(0.0));
s.push_field("m_end", real(end));
s.push_field("m_start", real(start));
s.push_field("m_startSecondary", real(end / 8.0));
s.push_field("m_stop", real(end));
s.push_field("m_loopEnabled", "0");
s.push_field("m_shortLoopEnabled", "0");
s.push_field("m_loopLengthShort", real(0.0));
s.push_field("m_loopXFadeShort", "10");
s.push_field("m_loopStart", real(loop_start));
s.push_field("m_loopLengthLong", real(loop_length));
s.push_field("m_loopDecayEnabled", "0");
s.push_field("m_loopDecay", real(20.0));
s.push_field("m_loopDetune", "0");
s.push_field("m_loopXFadeLengthLong", real(loop_length * 0.15));
s.push_field("m_loopXFModeLong", "0");
s.push_field("m_release", real(0.0));
s.push_field("m_fadeInEnable", "0");
s.push_field("m_fadeInLength", real(9600.0));
s.push_field("m_fadeOutLength", real(9600.0));
s.push_field("m_fadeOutRef", "0");
s.push_field("m_fadeOutMode", "0");
s.push_field("m_fadeOutEnable", "0");
s.push_field("m_modifyDate", date);
s
}
const SAMPLIB_ATTRS: &[(&str, &str)] = &[
("m_decay", "5"),
("m_release", "30"),
("m_monoMode", "0"),
("m_dynamics", "0"),
("m_velAmpl", "1"),
("m_velTimbre", "1"),
("m_octaveShift", "0"),
("m_atkVelocityAmount", "0"),
("m_ne3SlowAttackMode", "0"),
("m_fltSlope", "0"),
("m_phaseVar", "0"),
("m_rndStart", "0"),
("m_fltKBT", "1"),
("m_voiceMode", "0"),
];
const INSTRUMENT_CATEGORY_AND_EQ: &[(&str, &str)] = &[
("m_categoryCategory", "15"),
("m_categorySubCategory", "0"),
("m_categoryTimbre", "0"),
("m_categoryEnvelope", "0"),
("m_categoryMotion", "1"),
("m_categoryProduction", "Production"),
("m_categoryOrigin", "Origin"),
("m_categoryDynamicsEnable", "0"),
("m_eqLowCutFreq", "50.000000"),
("m_eqLowCutEnable", "0"),
("m_eqMidGain_0", "0.000000"),
("m_eqMidGain_1", "0.000000"),
("m_eqMidFreq_0", "1000.000000"),
("m_eqMidFreq_1", "4000.000000"),
("m_eqMidQ_0", "0.500000"),
("m_eqMidQ_1", "0.500000"),
("m_eqMidEnable_0", "0"),
("m_eqMidEnable_1", "0"),
];
const MAP_ZONE_DEFAULTS: &[(&str, &str)] = &[
("m_zoneMode", "0"),
("m_zonePlayback", "0"),
("m_isOneShot", "0"),
("m_ImagingEnable", "0"),
("m_ImagingL", "1.000000"),
("m_ImagingR2L", "0.000000"),
("m_ImagingL2R", "0.000000"),
("m_ImagingR", "1.000000"),
("m_eqLowCutFreq", "50.000000"),
("m_eqLowCutEnable", "0"),
("m_eqMidGain_0", "0.000000"),
("m_eqMidGain_1", "0.000000"),
("m_eqMidFreq_0", "1000.000000"),
("m_eqMidFreq_1", "4000.000000"),
("m_eqMidQ_0", "0.500000"),
("m_eqMidQ_1", "0.500000"),
("m_eqMidEnable_0", "0"),
("m_eqMidEnable_1", "0"),
("m_eqHiCutFreq", "10000.000000"),
("m_eqHiCutEnable", "0"),
("m_eqMidGain_2", "0.000000"),
("m_eqMidFreq_2", "6000.000000"),
("m_eqMidQ_2", "0.500000"),
("m_eqMidEnable_2", "0"),
("m_eqMidDynGainEn_0", "0"),
("m_eqMidDynGainEn_1", "0"),
("m_eqMidDynGainEn_2", "0"),
("m_eqMidVarGainEn_0", "0"),
("m_eqMidVarGainEn_1", "0"),
("m_eqMidVarGainEn_2", "0"),
("m_eqMidVarGainAtcTime_0", "0.000000"),
("m_eqMidVarGainAtcTime_1", "0.000000"),
("m_eqMidVarGainAtcTime_2", "0.000000"),
("m_eqMidVarGainDcyTime_0", "0.000000"),
("m_eqMidVarGainDcyTime_1", "0.000000"),
("m_eqMidVarGainDcyTime_2", "0.000000"),
("m_eqDynLPEnable", "0"),
("m_eqDynLPHoldOff", "0.200000"),
("m_eqDynLPInitialFreqHi", "17000.000000"),
("m_eqDynLPInitialFreqLo", "10000.000000"),
("m_eqDynLPFinalFreqHi", "5000.000000"),
("m_eqDynLPFinalFreqLo", "3000.000000"),
("m_eqDynLPReleaseFreqHi", "8000.000000"),
("m_eqDynLPReleaseFreqLo", "3000.000000"),
("m_eqDynLPFinalFreq2Hi", "3000.000000"),
("m_eqDynLPFinalFreq2Lo", "2000.000000"),
("m_eqTrackingMultFactor", "1.000000"),
("m_eqTrackingGain1", "0.000000"),
("m_eqTrackingGain2", "0.000000"),
("m_eqTrackingGain3", "0.000000"),
("m_eqTrackingGain4", "0.000000"),
("m_eqTrackingGain5", "0.000000"),
("m_eqTrackingGain6", "0.000000"),
("m_eqTrackingGain7", "0.000000"),
("m_eqTrackingGain8", "0.000000"),
("m_eqTrackingGain9", "0.000000"),
("m_eqTrackingGain10", "0.000000"),
("m_eqTrackingEnable", "0"),
("m_eqTrackingBand1Enable", "0"),
("m_eqTrackingBand2Enable", "0"),
("m_eqTrackingBand3Enable", "0"),
("m_eqTrackingBand4Enable", "0"),
("m_eqTrackingBand5Enable", "0"),
("m_eqTrackingBand6Enable", "0"),
("m_eqTrackingBand7Enable", "0"),
("m_eqTrackingBand8Enable", "0"),
("m_eqTrackingBand9Enable", "0"),
("m_eqTrackingBand10Enable", "0"),
];
const MAP_STROKE_DEFAULTS: &[(&str, &str)] = &[
("m_isEnabled", "1"),
("m_gain", "1.000000"),
("m_detune", "0"),
("m_relStrength", "1"),
("m_relStrengthEdited", "0"),
("m_relStrengthTop", "16384"),
("m_relStrengthTopEdited", "0"),
("m_velocityMin", "0"),
("m_velocityMax", "127"),
("m_attackLen", "0.000000"),
];
#[cfg(test)]
mod tests {
use super::*;
fn three_zones() -> Project {
let zone = |path: &str, root_key| NewZone {
path: path.into(),
sample_rate: 44100,
frames: 4394,
root_key,
};
Project::new(
"Three",
&[
zone("audio/c4.wav", 60),
zone("audio/c3.wav", 48),
zone("audio/c5.wav", 72),
],
1_700_000_000,
)
.unwrap()
}
#[test]
fn a_new_project_reads_back_through_the_views() {
let project = three_zones();
assert_eq!(project.name().unwrap(), "Three");
assert_eq!(
project.file_format_version().unwrap(),
KNOWN_FILE_FORMAT_VERSION
);
assert_eq!(project.created_by().unwrap().0, "nord-format");
let files = project.audio_files().unwrap();
assert_eq!(
files
.iter()
.map(|f| (f.id, f.path.as_str()))
.collect::<Vec<_>>(),
vec![
(1, "audio/c3.wav"),
(2, "audio/c4.wav"),
(3, "audio/c5.wav")
]
);
let zones = project.zones().unwrap();
let layout: Vec<_> = zones
.iter()
.map(|z| (z.zone_id, z.root_key, z.bottom_note, z.top_note))
.collect();
assert_eq!(
layout,
vec![(131, 72, 66, 96), (130, 60, 54, 65), (129, 48, 17, 53)]
);
assert!(zones.iter().all(|z| z.enabled && z.strokes.len() == 1));
assert_eq!(zones[0].strokes[0].velocity, (0, 127));
let strokes = project.strokes().unwrap();
assert_eq!(
strokes
.iter()
.map(|s| (s.zone_id, s.global_id, s.file_id))
.collect::<Vec<_>>(),
vec![(131, 3, 3), (130, 2, 2), (129, 1, 1)]
);
assert_eq!(strokes[0].end, 4394.0);
assert_eq!(strokes[0].loop_start, 2197.0);
assert_eq!(strokes[0].loop_length, 2196.0);
assert!(!strokes[0].loop_enabled);
}
#[test]
fn a_new_project_round_trips_byte_exactly() {
let project = three_zones();
let text = project.render();
assert!(text.as_bytes().starts_with(MAGIC));
let back = Project::parse(&text).unwrap();
assert_eq!(back, project);
assert_eq!(back.render(), text);
assert!(text.contains(" m_buffer = \n"));
}
#[test]
fn setters_edit_in_place() {
let mut project = three_zones();
let before = project.render();
project.set_name("Renamed").unwrap();
project.set_root_key(130, 61).unwrap();
project.set_key_range(131, 62, 100).unwrap();
project.set_audio_path(2, "moved/c4.wav").unwrap();
let after = project.render();
assert_eq!(project.name().unwrap(), "Renamed");
let zones = project.zones().unwrap();
assert_eq!(zones[1].root_key, 61);
assert_eq!((zones[0].bottom_note, zones[0].top_note), (62, 100));
assert_eq!(project.audio_files().unwrap()[1].path, "moved/c4.wav");
let changed = before
.lines()
.zip(after.lines())
.filter(|(a, b)| a != b)
.count();
assert_eq!(changed, 5);
assert_eq!(before.lines().count(), after.lines().count());
assert!(project.set_root_key(200, 60).is_err());
assert!(project.set_audio_path(9, "x").is_err());
assert!(project.set_key_range(131, 70, 60).is_err());
}
#[test]
fn a_new_project_refuses_what_the_editor_cannot_lay_out() {
let zone = |root_key| NewZone {
path: "a.wav".into(),
sample_rate: 44100,
frames: 1,
root_key,
};
assert!(Project::new("x", &[], 0).is_err());
assert!(Project::new("x", &[zone(60), zone(60)], 0).is_err());
assert!(Project::new("x", &[zone(10)], 0).is_err());
assert!(Project::new("x", &[zone(60)], 0).is_ok());
}
#[test]
fn anything_but_a_project_is_refused() {
assert!(Project::read_from(&mut b"CBIN....".as_slice()).is_err());
assert!(Project::parse("Other {\n a = 1\n}\n").is_err());
}
}