mirror of
https://github.com/loot/libloot.git
synced 2026-07-27 14:16:01 -07:00
Implement support for writing YAML
This commit is contained in:
@@ -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
@@ -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
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user