Implement support for writing YAML

This commit is contained in:
Oliver Hamlet
2025-03-25 20:56:11 +00:00
parent ce1742ef54
commit 051372318d
12 changed files with 1412 additions and 25 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ Currently complete:
- [x] Library versioning
- [ ] Setting a logging callback
- [x] Parsing metadata from YAML
- [ ] Serialising metadata to YAML
- [x] Serialising metadata to YAML
- [x] Game-related functionality
- [x] Plugin-related functionality
- [x] Archive-related functionality
+18 -3
View File
@@ -363,7 +363,7 @@ impl std::fmt::Display for YamlMergeKeyError {
impl std::error::Error for YamlMergeKeyError {}
/// Represents an error that occurred while trying to write metadata to a file.
#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
#[derive(Debug)]
pub struct WriteMetadataError {
path: PathBuf,
reason: WriteMetadataErrorReason,
@@ -386,14 +386,29 @@ impl std::fmt::Display for WriteMetadataError {
WriteMetadataErrorReason::PathAlreadyExists => {
write!(f, "the path \"{}\" already exists", self.path.display())
}
WriteMetadataErrorReason::IoError(_) => write!(f, "an I/O error occurred"),
}
}
}
impl std::error::Error for WriteMetadataError {}
impl std::error::Error for WriteMetadataError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match &self.reason {
WriteMetadataErrorReason::IoError(e) => Some(e),
_ => None,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
#[derive(Debug)]
pub(crate) enum WriteMetadataErrorReason {
ParentDirectoryNotFound,
PathAlreadyExists,
IoError(std::io::Error),
}
impl From<std::io::Error> for WriteMetadataErrorReason {
fn from(value: std::io::Error) -> Self {
WriteMetadataErrorReason::IoError(value)
}
}
+165 -3
View File
@@ -2,13 +2,16 @@ use saphyr::{MarkedYaml, YamlData};
use unicase::UniCase;
use super::{
error::ExpectedType,
error::{MultilingualMessageContentsError, ParseMetadataError},
message::{MessageContent, parse_message_contents_yaml, validate_message_contents},
error::{ExpectedType, MultilingualMessageContentsError, ParseMetadataError},
message::{
MessageContent, emit_message_contents, parse_message_contents_yaml,
validate_message_contents,
},
yaml::{
YamlObjectType, as_string_node, get_required_string_value, get_string_value,
parse_condition,
},
yaml_emit::{EmitYaml, YamlEmitter},
};
/// Represents a file in a game's Data folder, including files in
@@ -155,3 +158,162 @@ impl TryFrom<&MarkedYaml> for File {
}
}
}
impl EmitYaml for File {
fn is_scalar(&self) -> bool {
self.condition.is_none() && self.detail.is_empty() && self.display_name.is_none()
}
fn emit_yaml(&self, emitter: &mut YamlEmitter) {
if self.is_scalar() {
emitter.single_quoted_str(self.name.as_str());
} else {
emitter.begin_map();
emitter.map_key("name");
emitter.single_quoted_str(self.name.as_str());
if let Some(display_name) = &self.display_name {
emitter.map_key("display");
emitter.single_quoted_str(display_name);
}
if let Some(condition) = &self.condition {
emitter.map_key("condition");
emitter.single_quoted_str(condition);
}
emit_message_contents(&self.detail, emitter, "detail");
emitter.end_map();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
mod emit_yaml {
use crate::metadata::emit;
use super::*;
#[test]
fn should_emit_only_name_scalar_if_other_fields_are_empty() {
let file = File::new("filename".into());
let yaml = emit(&file);
assert_eq!(format!("'{}'", file.name.as_str()), yaml);
}
#[test]
fn should_emit_map_with_display_if_display_name_is_not_empty() {
let file = File::new("filename".into()).with_display_name("display1".into());
let yaml = emit(&file);
assert_eq!(
format!(
"name: '{}'\ndisplay: '{}'",
file.name.as_str(),
file.display_name.unwrap()
),
yaml
);
}
#[test]
fn should_emit_map_with_condition_if_it_is_not_empty() {
let file = File::new("filename".into()).with_condition("condition1".into());
let yaml = emit(&file);
assert_eq!(
format!(
"name: '{}'\ncondition: '{}'",
file.name.as_str(),
file.condition.unwrap()
),
yaml
);
}
#[test]
fn should_emit_map_with_a_detail_string_if_detail_is_monolingual() {
let file = File::new("filename".into())
.with_detail(vec![MessageContent::new("message".into())])
.unwrap();
let yaml = emit(&file);
assert_eq!(
format!(
"name: '{}'\ndetail: '{}'",
file.name.as_str(),
file.detail[0].text()
),
yaml
);
}
#[test]
fn should_emit_map_with_a_detail_array_if_detail_is_multilingual() {
let file = File::new("filename".into())
.with_detail(vec![
MessageContent::new("english".into()).with_language("en".into()),
MessageContent::new("french".into()).with_language("fr".into()),
])
.unwrap();
let yaml = emit(&file);
assert_eq!(
format!(
"name: '{}'
detail:
- lang: {}
text: '{}'
- lang: {}
text: '{}'",
file.name.as_str(),
file.detail[0].language(),
file.detail[0].text(),
file.detail[1].language(),
file.detail[1].text()
),
yaml
);
}
#[test]
fn should_emit_map_with_all_fields_set() {
let file = File::new("filename".into())
.with_display_name("display1".into())
.with_condition("condition1".into())
.with_detail(vec![
MessageContent::new("english".into()).with_language("en".into()),
MessageContent::new("french".into()).with_language("fr".into()),
])
.unwrap();
let yaml = emit(&file);
assert_eq!(
format!(
"name: '{}'
display: '{}'
condition: '{}'
detail:
- lang: {}
text: '{}'
- lang: {}
text: '{}'",
file.name.as_str(),
file.display_name.unwrap(),
file.condition.unwrap(),
file.detail[0].language(),
file.detail[0].text(),
file.detail[1].language(),
file.detail[1].text()
),
yaml
);
}
}
}
+101
View File
@@ -4,6 +4,7 @@ use super::error::ParseMetadataError;
use super::yaml::{
YamlObjectType, get_as_hash, get_required_string_value, get_string_value, get_strings_vec_value,
};
use super::yaml_emit::{EmitYaml, YamlEmitter};
/// Represents a group to which plugin metadata objects can belong.
#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
@@ -89,3 +90,103 @@ impl TryFrom<&MarkedYaml> for Group {
})
}
}
impl EmitYaml for Group {
fn is_scalar(&self) -> bool {
false
}
fn emit_yaml(&self, emitter: &mut YamlEmitter) {
emitter.begin_map();
emitter.map_key("name");
emitter.single_quoted_str(&self.name);
if let Some(description) = &self.description {
emitter.map_key("description");
emitter.single_quoted_str(description);
}
if !self.after_groups.is_empty() {
emitter.map_key("after");
emitter.begin_array();
for after in &self.after_groups {
emitter.unquoted_str(after);
}
emitter.end_array();
}
emitter.end_map();
}
}
#[cfg(test)]
mod tests {
use super::*;
mod emit_yaml {
use super::*;
use crate::metadata::emit;
#[test]
fn should_omit_description_and_after_keys_if_their_fields_are_empty() {
let group = Group::new("name".into());
let yaml = emit(&group);
assert_eq!(format!("name: '{}'", group.name), yaml);
}
#[test]
fn should_include_description_key_if_a_description_is_set() {
let group = Group::new("name".into()).with_description("desc".into());
let yaml = emit(&group);
assert_eq!(
format!(
"name: '{}'\ndescription: '{}'",
group.name,
group.description.unwrap()
),
yaml
);
}
#[test]
fn should_include_after_key_if_after_groups_is_not_empty() {
let group =
Group::new("name".into()).with_after_groups(vec!["after1".into(), "after2".into()]);
let yaml = emit(&group);
assert_eq!(
format!(
"name: '{}'\nafter:\n - {}\n - {}",
group.name, group.after_groups[0], group.after_groups[1]
),
yaml
);
}
#[test]
fn should_emit_map_with_all_fields_set() {
let group = Group::new("name".into())
.with_description("desc".into())
.with_after_groups(vec!["after1".into(), "after2".into()]);
let yaml = emit(&group);
assert_eq!(
format!(
"name: '{}'\ndescription: '{}'\nafter:\n - {}\n - {}",
group.name,
group.description.unwrap(),
group.after_groups[0],
group.after_groups[1]
),
yaml
);
}
}
}
+59
View File
@@ -4,6 +4,8 @@ use super::error::ExpectedType;
use super::error::ParseMetadataError;
use super::yaml::{YamlObjectType, get_required_string_value};
use super::yaml_emit::EmitYaml;
use super::yaml_emit::YamlEmitter;
/// Represents a URL at which the parent plugin can be found.
#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)]
@@ -76,3 +78,60 @@ impl TryFrom<&MarkedYaml> for Location {
}
}
}
impl EmitYaml for Location {
fn is_scalar(&self) -> bool {
self.name.is_none()
}
fn emit_yaml(&self, emitter: &mut YamlEmitter) {
if let Some(name) = &self.name {
emitter.begin_map();
emitter.map_key("link");
emitter.single_quoted_str(&self.url);
emitter.map_key("name");
emitter.single_quoted_str(name);
emitter.end_map();
} else {
emitter.single_quoted_str(&self.url);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
mod emit_yaml {
use crate::metadata::emit;
use super::*;
#[test]
fn should_emit_url_only_if_there_is_no_name() {
let location = Location::new("http://www.example.com".into());
let yaml = emit(&location);
assert_eq!(format!("'{}'", location.url), yaml);
}
#[test]
fn should_emit_map_if_there_is_a_name() {
let location =
Location::new("http://www.example.com".into()).with_name("example".into());
let yaml = emit(&location);
assert_eq!(
format!(
"link: '{}'\nname: '{}'",
location.url,
location.name.unwrap()
),
yaml
);
}
}
}
+177
View File
@@ -9,6 +9,7 @@ use super::{
YamlObjectType, as_string_node, get_as_hash, get_required_string_value,
get_strings_vec_value, parse_condition,
},
yaml_emit::{EmitYaml, YamlEmitter},
};
/// Codes used to indicate the type of a message.
@@ -25,6 +26,16 @@ pub enum MessageType {
Error,
}
impl std::fmt::Display for MessageType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MessageType::Say => write!(f, "say"),
MessageType::Warn => write!(f, "warn"),
MessageType::Error => write!(f, "error"),
}
}
}
/// Represents a message's localised text content.
#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
pub struct MessageContent {
@@ -322,8 +333,69 @@ impl TryFrom<&MarkedYaml> for Message {
}
}
impl EmitYaml for MessageContent {
fn is_scalar(&self) -> bool {
false
}
fn emit_yaml(&self, emitter: &mut YamlEmitter) {
emitter.begin_map();
emitter.map_key("lang");
emitter.unquoted_str(&self.language);
emitter.map_key("text");
emitter.single_quoted_str(&self.text);
emitter.end_map();
}
}
pub(super) fn emit_message_contents(
slice: &[MessageContent],
emitter: &mut YamlEmitter,
key: &'static str,
) {
match slice {
[] => {}
[detail] => {
emitter.map_key(key);
emitter.single_quoted_str(detail.text());
}
details => {
emitter.map_key(key);
details.emit_yaml(emitter);
}
}
}
impl EmitYaml for Message {
fn is_scalar(&self) -> bool {
false
}
fn emit_yaml(&self, emitter: &mut YamlEmitter) {
emitter.begin_map();
emitter.map_key("type");
emitter.unquoted_str(&self.message_type.to_string());
emit_message_contents(&self.content, emitter, "content");
if let Some(condition) = &self.condition {
emitter.map_key("condition");
emitter.single_quoted_str(condition);
}
emitter.end_map();
}
}
#[cfg(test)]
mod tests {
use crate::metadata::emit;
use super::*;
mod select_message_content {
@@ -344,4 +416,109 @@ mod tests {
assert_eq!(&slice[0], content.unwrap());
}
}
mod message_content {
use super::*;
mod emit_yaml {
use super::*;
#[test]
fn should_emit_map() {
let content = MessageContent::new("message".into()).with_language("fr".into());
let yaml = emit(&content);
assert_eq!(
format!("lang: {}\ntext: '{}'", content.language, content.text),
yaml
);
}
}
}
mod message {
use super::*;
mod emit_yaml {
use super::*;
#[test]
fn should_emit_say_message_type_correctly() {
let message = Message::new(MessageType::Say, "message".into());
let yaml = emit(&message);
assert_eq!(
format!("type: say\ncontent: '{}'", message.content[0].text),
yaml
);
}
#[test]
fn should_emit_warn_message_type_correctly() {
let message = Message::new(MessageType::Warn, "message".into());
let yaml = emit(&message);
assert_eq!(
format!("type: warn\ncontent: '{}'", message.content[0].text),
yaml
);
}
#[test]
fn should_emit_error_message_type_correctly() {
let message = Message::new(MessageType::Error, "message".into());
let yaml = emit(&message);
assert_eq!(
format!("type: error\ncontent: '{}'", message.content[0].text),
yaml
);
}
#[test]
fn should_emit_condition_if_it_is_not_empty() {
let message = Message::new(MessageType::Say, "message".into())
.with_condition("condition1".into());
let yaml = emit(&message);
assert_eq!(
format!(
"type: {}\ncontent: '{}'\ncondition: '{}'",
message.message_type,
message.content[0].text,
message.condition.unwrap()
),
yaml
);
}
#[test]
fn should_emit_a_content_array_if_content_is_multilingual() {
let message = Message::multilingual(
MessageType::Say,
vec![
MessageContent::new("english".into()).with_language("en".into()),
MessageContent::new("french".into()).with_language("fr".into()),
],
)
.unwrap();
let yaml = emit(&message);
assert_eq!(
format!(
"type: {}
content:
- lang: {}
text: '{}'
- lang: {}
text: '{}'",
message.message_type,
message.content[0].language(),
message.content[0].text(),
message.content[1].language(),
message.content[1].text()
),
yaml
);
}
}
}
}
+60 -15
View File
@@ -6,6 +6,8 @@ use std::{
use saphyr::{MarkedYaml, YamlData};
use crate::logging;
use super::{
error::{
ExpectedType, LoadMetadataError, MetadataDocumentParsingError, ParseMetadataError,
@@ -16,6 +18,7 @@ use super::{
message::Message,
plugin_metadata::PluginMetadata,
yaml::{YamlObjectType, as_string_node, get_as_slice},
yaml_emit::{EmitYaml, YamlEmitter},
};
static MERGE_KEY: LazyLock<MarkedYaml> = LazyLock::new(|| as_string_node("<<"));
@@ -203,25 +206,48 @@ impl MetadataDocument {
}
pub fn save(&self, file_path: &Path) -> Result<(), WriteMetadataError> {
// let mut hash = saphyr::Hash::new();
logging::trace!("Saving metadata list to: {}", file_path.display());
// hash.insert(
// Yaml::String("bash_tags".into()),
// Yaml::Array(
// self.bash_tags
// .iter()
// .map(|b| Yaml::String(b.to_string()))
// .collect(),
// ),
// );
let mut emitter = YamlEmitter::new();
// let mut yaml = Yaml::Hash(hash);
if !self.bash_tags.is_empty() {
emitter.map_key("bash_tags");
// let mut output = String::new();
// FIXME: Can't handle the error because it's a type that's not exported by saphyr.
// let emitter = YamlEmitter::new(&mut output).dump(&yaml)?;
emitter.begin_array();
todo!()
for tag in &self.bash_tags {
emitter.unquoted_str(tag);
}
emitter.end_array();
}
if !self.groups.is_empty() {
emitter.map_key("groups");
self.groups.emit_yaml(&mut emitter);
}
if !self.messages.is_empty() {
emitter.map_key("globals");
self.messages.emit_yaml(&mut emitter);
}
if !self.plugins.is_empty() || !self.regex_plugins.is_empty() {
emitter.map_key("plugins");
emitter.begin_array();
for plugin in self.plugins() {
plugin.emit_yaml(&mut emitter);
}
emitter.end_array();
}
std::fs::write(file_path, emitter.into_string())
.map_err(|e| WriteMetadataError::new(file_path.into(), e.into()))?;
Ok(())
}
pub fn bash_tags(&self) -> &[String] {
@@ -497,4 +523,23 @@ plugins:
let mut metadata_list = MetadataDocument::default();
metadata_list.load(&path).unwrap();
}
#[test]
fn save_should_write_the_loaded_metadata() {
let tmp_dir = tempdir().unwrap();
let path = tmp_dir.path().join("masterlist.yaml");
std::fs::write(&path, METADATA_LIST_YAML).unwrap();
let mut metadata = MetadataDocument::default();
metadata.load(&path).unwrap();
let other_path = tmp_dir.path().join("other.yaml");
metadata.save(&other_path).unwrap();
let mut other_metadata = MetadataDocument::default();
other_metadata.load(&other_path).unwrap();
assert_eq!(metadata, other_metadata);
}
}
+10 -1
View File
@@ -8,7 +8,8 @@ pub(crate) mod metadata_document;
mod plugin_cleaning_data;
pub(crate) mod plugin_metadata;
mod tag;
pub(crate) mod yaml;
mod yaml;
mod yaml_emit;
pub use file::{File, Filename};
pub use group::Group;
@@ -17,3 +18,11 @@ pub use message::{Message, MessageContent, MessageType, select_message_content};
pub use plugin_cleaning_data::PluginCleaningData;
pub use plugin_metadata::PluginMetadata;
pub use tag::{Tag, TagSuggestion};
#[cfg(test)]
fn emit<T: yaml_emit::EmitYaml>(metadata: &T) -> String {
let mut emitter = yaml_emit::YamlEmitter::new();
metadata.emit_yaml(&mut emitter);
emitter.into_string()
}
+155 -1
View File
@@ -2,8 +2,12 @@ use saphyr::MarkedYaml;
use super::{
error::{MultilingualMessageContentsError, ParseMetadataError},
message::{MessageContent, parse_message_contents_yaml, validate_message_contents},
message::{
MessageContent, emit_message_contents, parse_message_contents_yaml,
validate_message_contents,
},
yaml::{YamlObjectType, as_string_node, get_as_hash, get_required_string_value, get_u32_value},
yaml_emit::{EmitYaml, YamlEmitter},
};
/// Represents data identifying the plugin under which it is stored as dirty or
@@ -146,3 +150,153 @@ impl TryFrom<&MarkedYaml> for PluginCleaningData {
})
}
}
impl EmitYaml for PluginCleaningData {
fn is_scalar(&self) -> bool {
false
}
fn emit_yaml(&self, emitter: &mut YamlEmitter) {
emitter.begin_map();
emitter.map_key("crc");
emitter.unquoted_str(&format!("0x{:08X}", self.crc));
emitter.map_key("util");
emitter.single_quoted_str(&self.cleaning_utility);
if self.itm_count > 0 {
emitter.map_key("itm");
emitter.u32(self.itm_count);
}
if self.deleted_reference_count > 0 {
emitter.map_key("udr");
emitter.u32(self.deleted_reference_count);
}
if self.deleted_navmesh_count > 0 {
emitter.map_key("nav");
emitter.u32(self.deleted_navmesh_count);
}
emit_message_contents(&self.detail, emitter, "detail");
emitter.end_map();
}
}
#[cfg(test)]
mod tests {
use super::*;
mod emit_yaml {
use crate::metadata::emit;
use super::*;
#[test]
fn should_omit_zero_counts() {
let data = PluginCleaningData::new(0xDEADBEEF, "TES5Edit".into());
let yaml = emit(&data);
assert_eq!("crc: 0xDEADBEEF\nutil: 'TES5Edit'", yaml);
}
#[test]
fn should_emit_non_zero_counts() {
let data = PluginCleaningData::new(0xDEADBEEF, "TES5Edit".into())
.with_itm_count(1)
.with_deleted_reference_count(2)
.with_deleted_navmesh_count(3);
let yaml = emit(&data);
assert_eq!(
"crc: 0xDEADBEEF\nutil: 'TES5Edit'\nitm: 1\nudr: 2\nnav: 3",
yaml
);
}
#[test]
fn should_emit_map_with_a_detail_string_if_detail_is_monolingual() {
let data = PluginCleaningData::new(0xDEADBEEF, "TES5Edit".into())
.with_detail(vec![MessageContent::new("message".into())])
.unwrap();
let yaml = emit(&data);
assert_eq!(
format!(
"crc: 0xDEADBEEF\nutil: 'TES5Edit'\ndetail: '{}'",
data.detail[0].text()
),
yaml
);
}
#[test]
fn should_emit_map_with_a_detail_array_if_detail_is_multilingual() {
let data = PluginCleaningData::new(0xDEADBEEF, "TES5Edit".into())
.with_detail(vec![
MessageContent::new("english".into()).with_language("en".into()),
MessageContent::new("french".into()).with_language("fr".into()),
])
.unwrap();
let yaml = emit(&data);
assert_eq!(
format!(
"crc: 0xDEADBEEF
util: 'TES5Edit'
detail:
- lang: {}
text: '{}'
- lang: {}
text: '{}'",
data.detail[0].language(),
data.detail[0].text(),
data.detail[1].language(),
data.detail[1].text()
),
yaml
);
}
#[test]
fn should_emit_map_with_all_fields_set() {
let data = PluginCleaningData::new(0xDEADBEEF, "TES5Edit".into())
.with_itm_count(1)
.with_deleted_reference_count(2)
.with_deleted_navmesh_count(3)
.with_detail(vec![
MessageContent::new("english".into()).with_language("en".into()),
MessageContent::new("french".into()).with_language("fr".into()),
])
.unwrap();
let yaml = emit(&data);
assert_eq!(
format!(
"crc: 0xDEADBEEF
util: '{}'
itm: {}
udr: {}
nav: {}
detail:
- lang: {}
text: '{}'
- lang: {}
text: '{}'",
data.cleaning_utility,
data.itm_count,
data.deleted_reference_count,
data.deleted_navmesh_count,
data.detail[0].language(),
data.detail[0].text(),
data.detail[1].language(),
data.detail[1].text()
),
yaml
);
}
}
}
+293 -1
View File
@@ -13,6 +13,7 @@ use super::{
yaml::{
YamlObjectType, get_as_hash, get_as_slice, get_required_string_value, get_string_value,
},
yaml_emit::{EmitYaml, YamlEmitter},
};
pub(crate) const GHOST_FILE_EXTENSION: &str = ".ghost";
@@ -209,7 +210,9 @@ impl PluginMetadata {
/// Serialises the plugin metadata as YAML.
pub fn as_yaml(&self) -> String {
todo!()
let mut emitter = YamlEmitter::new();
self.emit_yaml(&mut emitter);
emitter.into_string()
}
}
@@ -353,3 +356,292 @@ fn get_vec<'a, T: TryFrom<&'a MarkedYaml, Error = impl Into<ParseMetadataError>>
.map(|e| T::try_from(e).map_err(Into::into))
.collect::<Result<Vec<T>, _>>()
}
impl EmitYaml for PluginMetadata {
fn is_scalar(&self) -> bool {
false
}
fn emit_yaml(&self, emitter: &mut YamlEmitter) {
emitter.begin_map();
emitter.map_key("name");
emitter.single_quoted_str(self.name());
if !self.locations.is_empty() {
emitter.map_key("url");
self.locations.emit_yaml(emitter);
}
if let Some(group) = &self.group {
emitter.map_key("group");
emitter.single_quoted_str(group);
}
if !self.load_after.is_empty() {
emitter.map_key("after");
self.load_after.emit_yaml(emitter);
}
if !self.requirements.is_empty() {
emitter.map_key("req");
self.requirements.emit_yaml(emitter);
}
if !self.incompatibilities.is_empty() {
emitter.map_key("inc");
self.incompatibilities.emit_yaml(emitter);
}
if !self.messages.is_empty() {
emitter.map_key("msg");
self.messages.emit_yaml(emitter);
}
if !self.tags.is_empty() {
emitter.map_key("tag");
self.tags.emit_yaml(emitter);
}
if !self.dirty_info.is_empty() {
emitter.map_key("dirty");
self.dirty_info.emit_yaml(emitter);
}
if !self.clean_info.is_empty() {
emitter.map_key("clean");
self.clean_info.emit_yaml(emitter);
}
emitter.end_map();
}
}
#[cfg(test)]
mod tests {
use super::*;
mod as_yaml {
use super::*;
#[test]
fn should_return_a_yaml_string_representation() {
let mut plugin = PluginMetadata::new("test.esp").unwrap();
plugin.set_load_after_files(vec![File::new("other.esp".into())]);
let yaml = plugin.as_yaml();
assert_eq!(
format!(
"name: '{}'\nafter: ['{}']",
plugin.name.string,
plugin.load_after[0].name()
),
yaml
);
}
}
mod emit_yaml {
use super::*;
use crate::metadata::{MessageType, TagSuggestion, emit};
#[test]
fn should_omit_group_if_not_set() {
let plugin = PluginMetadata::new("test.esp").unwrap();
let yaml = emit(&plugin);
assert_eq!(format!("name: '{}'", plugin.name.string), yaml);
}
#[test]
fn should_emit_group_if_set() {
let mut plugin = PluginMetadata::new("test.esp").unwrap();
plugin.set_group("group1");
let yaml = emit(&plugin);
assert_eq!(
format!(
"name: '{}'\ngroup: '{}'",
plugin.name.string,
plugin.group.unwrap()
),
yaml
);
}
#[test]
fn should_emit_a_single_scalar_load_after_file_in_flow_style() {
let mut plugin = PluginMetadata::new("test.esp").unwrap();
plugin.set_load_after_files(vec![File::new("other.esp".into())]);
let yaml = emit(&plugin);
assert_eq!(
format!(
"name: '{}'\nafter: ['{}']",
plugin.name.string,
plugin.load_after[0].name()
),
yaml
);
}
#[test]
fn should_emit_a_single_non_scalar_load_after_file_in_block_style() {
let mut plugin = PluginMetadata::new("test.esp").unwrap();
plugin.set_load_after_files(vec![
File::new("other.esp".into()).with_condition("condition1".into()),
]);
let yaml = emit(&plugin);
assert_eq!(
format!(
"name: '{}'\nafter:\n - name: '{}'\n condition: '{}'",
plugin.name.string,
plugin.load_after[0].name(),
plugin.load_after[0].condition().unwrap(),
),
yaml
);
}
#[test]
fn should_emit_multiple_load_after_files_in_block_style() {
let mut plugin = PluginMetadata::new("test.esp").unwrap();
plugin.set_load_after_files(vec![
File::new("other1.esp".into()),
File::new("other2.esp".into()),
]);
let yaml = emit(&plugin);
assert_eq!(
format!(
"name: '{}'\nafter:\n - '{}'\n - '{}'",
plugin.name.string,
plugin.load_after[0].name(),
plugin.load_after[1].name(),
),
yaml
);
}
#[test]
fn should_emit_a_single_scalar_requirements_in_flow_style() {
let mut plugin = PluginMetadata::new("test.esp").unwrap();
plugin.set_requirements(vec![File::new("other.esp".into())]);
let yaml = emit(&plugin);
assert_eq!(
format!(
"name: '{}'\nreq: ['{}']",
plugin.name.string,
plugin.requirements[0].name()
),
yaml
);
}
#[test]
fn should_emit_a_single_scalar_incompatibility_in_flow_style() {
let mut plugin = PluginMetadata::new("test.esp").unwrap();
plugin.set_incompatibilities(vec![File::new("other.esp".into())]);
let yaml = emit(&plugin);
assert_eq!(
format!(
"name: '{}'\ninc: ['{}']",
plugin.name.string,
plugin.incompatibilities[0].name()
),
yaml
);
}
#[test]
fn should_emit_messages() {
let mut plugin = PluginMetadata::new("test.esp").unwrap();
plugin.set_messages(vec![
Message::new(MessageType::Say, "content1".into()),
Message::new(MessageType::Say, "content2".into()),
]);
let yaml = emit(&plugin);
assert_eq!(
format!(
"name: '{}'\nmsg:\n - type: {}\n content: '{}'\n - type: {}\n content: '{}'",
plugin.name.string,
plugin.messages[0].message_type(),
plugin.messages[0].content()[0].text(),
plugin.messages[1].message_type(),
plugin.messages[1].content()[0].text(),
),
yaml
);
}
#[test]
fn should_emit_a_single_scalar_tag_in_flow_style() {
let mut plugin = PluginMetadata::new("test.esp").unwrap();
plugin.set_tags(vec![Tag::new("Relev".into(), TagSuggestion::Addition)]);
let yaml = emit(&plugin);
assert_eq!(
format!(
"name: '{}'\ntag: [{}]",
plugin.name.string,
plugin.tags[0].name()
),
yaml
);
}
#[test]
fn should_emit_dirty_info() {
let mut plugin = PluginMetadata::new("test.esp").unwrap();
plugin.set_dirty_info(vec![PluginCleaningData::new(0xDEADBEEF, "utility".into())]);
let yaml = emit(&plugin);
assert_eq!(
format!(
"name: '{}'\ndirty:\n - crc: 0x{:8X}\n util: '{}'",
plugin.name(),
plugin.dirty_info[0].crc(),
plugin.dirty_info[0].cleaning_utility()
),
yaml
);
}
#[test]
fn should_emit_clean_info() {
let mut plugin = PluginMetadata::new("test.esp").unwrap();
plugin.set_clean_info(vec![PluginCleaningData::new(0xDEADBEEF, "utility".into())]);
let yaml = emit(&plugin);
assert_eq!(
format!(
"name: '{}'\nclean:\n - crc: 0x{:8X}\n util: '{}'",
plugin.name(),
plugin.clean_info[0].crc(),
plugin.clean_info[0].cleaning_utility()
),
yaml
);
}
#[test]
fn should_emit_a_single_scalar_location_in_flow_style() {
let mut plugin = PluginMetadata::new("test.esp").unwrap();
plugin.set_locations(vec![Location::new("https://www.example.com".into())]);
let yaml = emit(&plugin);
assert_eq!(
format!(
"name: '{}'\nurl: ['{}']",
plugin.name(),
plugin.locations[0].url()
),
yaml
);
}
}
}
+58
View File
@@ -3,6 +3,8 @@ use saphyr::YamlData;
use super::error::ExpectedType;
use super::error::ParseMetadataError;
use super::yaml::{YamlObjectType, get_required_string_value, parse_condition};
use super::yaml_emit::EmitYaml;
use super::yaml_emit::YamlEmitter;
/// Represents whether a Bash Tag suggestion is for addition or removal.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)]
@@ -96,3 +98,59 @@ fn name_and_suggestion(value: &str) -> (String, TagSuggestion) {
(value.to_string(), TagSuggestion::Addition)
}
}
impl EmitYaml for Tag {
fn is_scalar(&self) -> bool {
self.condition.is_none()
}
fn emit_yaml(&self, emitter: &mut YamlEmitter) {
if let Some(condition) = &self.condition {
emitter.begin_map();
emitter.map_key("name");
if self.is_addition() {
emitter.unquoted_str(&self.name);
} else {
emitter.unquoted_str(&format!("-{}", self.name));
}
emitter.map_key("condition");
emitter.single_quoted_str(condition);
emitter.end_map();
} else if self.is_addition() {
emitter.unquoted_str(&self.name);
} else {
emitter.unquoted_str(&format!("-{}", self.name));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
mod emit_yaml {
use crate::metadata::emit;
use super::*;
#[test]
fn should_emit_name_only_if_unconditional_addition() {
let tag = Tag::new("name1".into(), TagSuggestion::Addition);
let yaml = emit(&tag);
assert_eq!(tag.name(), yaml);
}
#[test]
fn should_emit_map_if_there_is_a_condition() {
let tag =
Tag::new("name1".into(), TagSuggestion::Removal).with_condition("condition".into());
let yaml = emit(&tag);
assert_eq!("name: -name1\ncondition: 'condition'", yaml);
}
}
}
+315
View File
@@ -0,0 +1,315 @@
pub trait EmitYaml {
fn is_scalar(&self) -> bool;
fn emit_yaml(&self, emitter: &mut YamlEmitter);
}
#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
pub struct YamlEmitter {
buffer: String,
scope: Vec<YamlBlock>,
style: YamlStyle,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
enum YamlBlock {
Array,
Map,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
enum YamlStyle {
Flow,
Block,
}
impl YamlEmitter {
const INDENT_UNIT: &str = " ";
const ARRAY_ELEMENT_PREFIX: &str = "- ";
pub fn new() -> Self {
Self {
buffer: String::new(),
scope: vec![],
style: YamlStyle::Block,
}
}
pub fn into_string(self) -> String {
self.buffer
}
pub fn unquoted_str(&mut self, value: &str) {
if self.style == YamlStyle::Block {
self.write_prefix();
}
if can_emit_unquoted(value, self.style) {
self.write(value);
} else if can_single_quote(value) {
self.write(&single_quote(value));
} else {
self.write(&double_quote(value));
}
}
pub fn single_quoted_str(&mut self, value: &str) {
if self.style == YamlStyle::Block {
self.write_prefix();
}
if can_single_quote(value) {
self.write(&single_quote(value));
} else {
self.write(&double_quote(value));
}
}
pub fn u32(&mut self, value: u32) {
if self.style == YamlStyle::Block {
self.write_prefix();
}
self.write(&value.to_string());
}
pub fn begin_map(&mut self) {
if self.scope.last() == Some(&YamlBlock::Array) {
self.end_line();
self.write_indent();
self.write(Self::ARRAY_ELEMENT_PREFIX);
}
}
pub fn end_map(&mut self) {
if self.scope.last() == Some(&YamlBlock::Map) {
self.scope.pop();
}
}
/// This assumes that the given key is valid to be written as an unquoted string, and expects a string literal so that it's obvious that a given value is valid.
pub fn map_key(&mut self, key: &'static str) {
match self.scope.last() {
Some(&YamlBlock::Map) => {
self.end_line();
self.write_indent();
}
_ => self.scope.push(YamlBlock::Map),
}
self.write(&format!("{}:", key));
}
pub fn begin_array(&mut self) {
if self.style == YamlStyle::Flow {
if self.scope.last() == Some(&YamlBlock::Map) {
self.write(" ");
}
self.write("[");
}
self.scope.push(YamlBlock::Array);
}
pub fn end_array(&mut self) {
if self.scope.last() == Some(&YamlBlock::Array) {
self.scope.pop();
}
if self.style == YamlStyle::Flow {
self.write("]");
}
}
pub fn set_flow_style(&mut self) {
self.style = YamlStyle::Flow;
}
pub fn set_block_style(&mut self) {
self.style = YamlStyle::Block;
}
fn end_line(&mut self) {
self.write("\n");
}
fn write_indent(&mut self) {
// If in a map, no indent is needed, but an array needs an indent, and a map in an array needs an indent.
if !self.scope.is_empty() {
for _ in 0..self.scope.len() - 1 {
self.write(Self::INDENT_UNIT);
}
}
}
fn write_prefix(&mut self) {
match self.scope.last() {
Some(&YamlBlock::Array) => {
self.end_line();
self.write_indent();
self.write(Self::ARRAY_ELEMENT_PREFIX);
}
Some(&YamlBlock::Map) => self.write(" "),
_ => self.write_indent(),
}
}
fn write(&mut self, value: &str) {
self.buffer += value;
}
}
fn can_single_quote(value: &str) -> bool {
// Single-quoted strings are restricted to printable characters
// <https://yaml.org/spec/1.2.2/#732-single-quoted-style>
value.chars().all(is_printable)
}
fn is_printable(c: char) -> bool {
// <https://yaml.org/spec/1.2.2/#51-character-set>
matches!(c,
'\x09' | '\x0A' | '\x0D' | '\x20'..='\x7E' | '\u{0085}' | '\u{00A0}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' | '\u{010000}'..='\u{10FFFF}'
)
}
fn is_yaml_whitespace(c: char) -> bool {
c == ' ' || c == '\t'
}
fn is_flow_indicator(c: char) -> bool {
matches!(c, '[' | ']' | '{' | '}' | ',')
}
fn can_emit_unquoted(value: &str, style: YamlStyle) -> bool {
// <https://yaml.org/spec/1.2.2/#733-plain-style>
if value.is_empty()
|| value.starts_with(is_yaml_whitespace)
|| value.ends_with(is_yaml_whitespace)
{
return false;
}
if value.starts_with(|c| {
matches!(
c,
',' | '['
| ']'
| '{'
| '}'
| '#'
| '&'
| '*'
| '!'
| '|'
| '>'
| '\''
| '"'
| '%'
| '@'
| '`'
)
}) {
return false;
}
if value.starts_with("? ")
|| value.starts_with("?\t")
|| value.starts_with("- ")
|| value.starts_with("-\t")
{
return false;
}
if value.contains(": ")
|| value.contains(":\t")
|| value.contains(" #")
|| value.contains("\t#")
{
return false;
}
if style == YamlStyle::Flow {
!value.contains(is_flow_indicator)
} else {
true
}
}
fn single_quote(value: &str) -> String {
// Single-quoted strings need single quotes escaped by repeating them.
// <https://yaml.org/spec/1.2.2/#732-single-quoted-style>
format!("'{}'", value.replace('\'', "''"))
}
fn double_quote(value: &str) -> String {
// <https://yaml.org/spec/1.2.2/#731-double-quoted-style>
value
.chars()
.map(|c| {
if is_printable(c) {
c.to_string()
} else {
match c {
'\x00' => "\\0".to_string(),
'\x07' => "\\a".to_string(),
'\x08' => "\\b".to_string(),
'\x09' => "\\t".to_string(),
'\x0A' => "\\n".to_string(),
'\x0B' => "\\v".to_string(),
'\x0C' => "\\f".to_string(),
'\x0D' => "\\r".to_string(),
'\x1B' => "\\e".to_string(),
'\x20' => "\\x20".to_string(),
'"' => "\\\"".to_string(),
'/' => "\\/".to_string(),
'\\' => "\\\\".to_string(),
'\u{0085}' => "\\N".to_string(),
'\u{00A0}' => "\\_".to_string(),
'\u{2028}' => "\\L".to_string(),
'\u{2029}' => "\\P".to_string(),
'\u{00}'..='\u{FF}' => format!("\\x{:2X}", u32::from(c)),
'\u{0100}'..='\u{FFFF}' => format!("\\u{:4X}", u32::from(c)),
c => format!("\\U{:8X}", u32::from(c)),
}
}
})
.collect()
}
impl<T: EmitYaml> EmitYaml for &[T] {
fn is_scalar(&self) -> bool {
false
}
fn emit_yaml(&self, emitter: &mut YamlEmitter) {
match self {
[] => {}
[element] if element.is_scalar() => {
emitter.set_flow_style();
emitter.begin_array();
element.emit_yaml(emitter);
emitter.end_array();
emitter.set_block_style();
}
elements => {
emitter.begin_array();
for element in elements.iter() {
element.emit_yaml(emitter);
}
emitter.end_array();
}
}
}
}
impl<T: EmitYaml> EmitYaml for Vec<T> {
fn is_scalar(&self) -> bool {
false
}
fn emit_yaml(&self, emitter: &mut YamlEmitter) {
self.as_slice().emit_yaml(emitter);
}
}