diff --git a/src/database/mod.rs b/src/database/mod.rs index d1732871..1a084a4a 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -10,7 +10,7 @@ use crate::{ metadata::{ Group, Message, PluginMetadata, error::{LoadMetadataError, WriteMetadataError, WriteMetadataErrorReason}, - metadata_document::MetadataDocument, + metadata_document::{MetadataDocument, MetadataWriteOptions}, }, sorting::{ error::GroupsPathError, @@ -20,6 +20,10 @@ use crate::{ }; pub use error::{ConditionEvaluationError, MetadataRetrievalError}; +const WRITE_OPTIONS: MetadataWriteOptions = MetadataWriteOptions { + write_anchors: true, +}; + /// Control behaviour when writing to files. #[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] #[non_exhaustive] @@ -114,7 +118,7 @@ impl Database { ) -> Result<(), WriteMetadataError> { validate_write_path(output_path, mode)?; - self.userlist.save(output_path) + self.userlist.save(output_path, WRITE_OPTIONS) } /// Writes a metadata file that only contains plugin Bash Tag suggestions @@ -139,7 +143,7 @@ impl Database { doc.set_plugin_metadata(minimal_plugin); } - doc.save(output_path) + doc.save(output_path, WRITE_OPTIONS) } /// Evaluate the given condition string. diff --git a/src/metadata/file.rs b/src/metadata/file.rs index 2c46e35c..80de00d9 100644 --- a/src/metadata/file.rs +++ b/src/metadata/file.rs @@ -1,6 +1,8 @@ use saphyr::{MarkedYaml, Scalar, YamlData}; use unicase::UniCase; +use crate::metadata::yaml::YamlAnchors; + use super::{ error::{ExpectedType, MultilingualMessageContentsError, ParseMetadataError}, message::{ @@ -207,34 +209,45 @@ impl EmitYaml for File { && self.display_name.is_none() } + fn has_written_anchor(&self, anchors: &YamlAnchors) -> bool { + anchors + .file_anchor(self) + .is_some_and(|a| anchors.is_anchor_written(a)) + } + fn emit_yaml(&self, emitter: &mut YamlEmitter) { - if self.is_scalar() { - emitter.write_single_quoted_str(self.name.as_str()); - } else { - emitter.begin_map(); + emitter.write_anchored_value( + |a| a.file_anchor(self).cloned(), + |e| { + if self.is_scalar() { + e.write_single_quoted_str(self.name.as_str()); + } else { + e.begin_map(); - emitter.write_map_key("name"); - emitter.write_single_quoted_str(self.name.as_str()); + e.write_map_key("name"); + e.write_single_quoted_str(self.name.as_str()); - if let Some(display_name) = &self.display_name { - emitter.write_map_key("display"); - emitter.write_single_quoted_str(display_name); - } + if let Some(display_name) = &self.display_name { + e.write_map_key("display"); + e.write_single_quoted_str(display_name); + } - emit_message_contents(&self.detail, emitter, "detail"); + emit_message_contents(&self.detail, e, "detail"); - if let Some(condition) = &self.condition { - emitter.write_map_key("condition"); - emitter.write_single_quoted_str(condition); - } + if let Some(condition) = &self.condition { + e.write_map_key("condition"); + e.write_condition(condition); + } - if let Some(constraint) = &self.constraint { - emitter.write_map_key("constraint"); - emitter.write_single_quoted_str(constraint); - } + if let Some(constraint) = &self.constraint { + e.write_map_key("constraint"); + e.write_condition(constraint); + } - emitter.end_map(); - } + e.end_map(); + } + }, + ); } } @@ -377,8 +390,50 @@ mod tests { } } + mod has_written_anchor { + use std::collections::HashMap; + + use super::*; + + #[test] + fn should_return_true_if_emitter_has_an_anchor_for_the_file_that_has_been_written() { + let file = File::new("filename".into()); + // Clone to make sure we're not relying on the same object being in the map. + let file_clone = file.clone(); + + let mut anchors = YamlAnchors::new(); + anchors.set_file_anchors(HashMap::from([(&file_clone, "file1".to_owned())])); + anchors.record_written_anchor("file1".to_owned()); + + assert!(file.has_written_anchor(&anchors)); + } + + #[test] + fn should_return_false_if_emitter_has_an_unwritten_anchor() { + let file = File::new("filename".into()); + // Clone to make sure we're not relying on the same object being in the map. + let file_clone = file.clone(); + + let mut anchors = YamlAnchors::new(); + anchors.set_file_anchors(HashMap::from([(&file_clone, "file1".to_owned())])); + + assert!(!file.has_written_anchor(&anchors)); + } + + #[test] + fn should_return_false_if_emitter_has_no_anchor_for_the_message() { + let file = File::new("filename".into()); + + let anchors = YamlAnchors::new(); + + assert!(!file.has_written_anchor(&anchors)); + } + } + mod emit_yaml { - use crate::metadata::emit; + use std::collections::HashMap; + + use crate::metadata::{emit, emit_with_anchors}; use super::*; @@ -516,5 +571,173 @@ constraint: '{}'", yaml ); } + + #[test] + fn should_emit_an_anchored_scalar_if_the_file_has_an_unwritten_anchor() { + let file = File::new("filename".into()); + + let mut anchors = YamlAnchors::new(); + anchors.set_file_anchors(HashMap::from([(&file, "file1".to_owned())])); + + let yaml = emit_with_anchors(&file, anchors); + + assert_eq!("&file1 'filename'", yaml); + } + + #[test] + fn should_emit_an_anchored_map_if_the_file_has_an_unwritten_anchor() { + let file = File::new("filename".into()).with_display_name("display1".into()); + + let mut anchors = YamlAnchors::new(); + anchors.set_file_anchors(HashMap::from([(&file, "file1".to_owned())])); + + let yaml = emit_with_anchors(&file, anchors); + + assert_eq!( + format!( + "&file1\nname: '{}'\ndisplay: '{}'", + file.name.as_str(), + file.display_name.unwrap() + ), + yaml + ); + } + + #[test] + fn should_emit_an_alias_if_the_file_has_an_anchor() { + let file = File::new("filename".into()); + + let mut anchors = YamlAnchors::new(); + anchors.set_file_anchors(HashMap::from([(&file, "file1".to_owned())])); + anchors.record_written_anchor("file1".to_owned()); + + let yaml = emit_with_anchors(&file, anchors); + + assert_eq!("*file1", yaml); + } + + #[test] + fn should_emit_an_anchor_if_the_detail_has_an_anchor_and_it_has_not_yet_been_written() { + let file = File::new("filename".into()) + .with_detail(vec![MessageContent::new("message".into())]) + .unwrap(); + + let mut anchors = YamlAnchors::new(); + anchors.set_message_contents_anchors(HashMap::from([( + file.detail(), + "content1".to_owned(), + )])); + + let yaml = emit_with_anchors(&file, anchors); + + assert_eq!( + format!( + "name: '{}'\ndetail: &content1 '{}'", + file.name.as_str(), + file.detail[0].text() + ), + yaml + ); + } + + #[test] + fn should_emit_an_alias_if_the_detail_has_an_anchor() { + let file = File::new("filename".into()) + .with_detail(vec![MessageContent::new("message".into())]) + .unwrap(); + + let mut anchors = YamlAnchors::new(); + anchors.set_message_contents_anchors(HashMap::from([( + file.detail(), + "content1".to_owned(), + )])); + anchors.record_written_anchor("content1".to_owned()); + + let yaml = emit_with_anchors(&file, anchors); + + assert_eq!(format!("name: '{}'\ndetail: *content1", file.name()), yaml); + } + + #[test] + fn should_emit_an_anchor_if_the_condition_has_an_anchor_and_it_has_not_yet_been_written() { + let file = File::new("filename".into()).with_condition("condition 1".into()); + + let mut anchors = YamlAnchors::new(); + anchors.set_condition_anchors(HashMap::from([( + file.condition().unwrap(), + "condition1".to_owned(), + )])); + + let yaml = emit_with_anchors(&file, anchors); + + assert_eq!( + format!( + "name: '{}'\ncondition: &condition1 '{}'", + file.name.as_str(), + file.condition.unwrap() + ), + yaml + ); + } + + #[test] + fn should_emit_an_alias_if_the_condition_has_an_anchor() { + let file = File::new("filename".into()).with_condition("condition1".into()); + + let mut anchors = YamlAnchors::new(); + anchors.set_condition_anchors(HashMap::from([( + file.condition().unwrap(), + "condition1".to_owned(), + )])); + anchors.record_written_anchor("condition1".to_owned()); + + let yaml = emit_with_anchors(&file, anchors); + + assert_eq!( + format!("name: '{}'\ncondition: *condition1", file.name()), + yaml + ); + } + + #[test] + fn should_emit_an_anchor_if_the_constraint_has_an_anchor_and_it_has_not_yet_been_written() { + let file = File::new("filename".into()).with_constraint("constraint1".into()); + + let mut anchors = YamlAnchors::new(); + anchors.set_condition_anchors(HashMap::from([( + file.constraint().unwrap(), + "condition1".to_owned(), + )])); + + let yaml = emit_with_anchors(&file, anchors); + + assert_eq!( + format!( + "name: '{}'\nconstraint: &condition1 '{}'", + file.name.as_str(), + file.constraint.unwrap() + ), + yaml + ); + } + + #[test] + fn should_emit_an_alias_if_the_constraint_has_an_anchor() { + let file = File::new("filename".into()).with_constraint("constraint1".into()); + + let mut anchors = YamlAnchors::new(); + anchors.set_condition_anchors(HashMap::from([( + file.constraint().unwrap(), + "condition1".to_owned(), + )])); + anchors.record_written_anchor("condition1".to_owned()); + + let yaml = emit_with_anchors(&file, anchors); + + assert_eq!( + format!("name: '{}'\nconstraint: *condition1", file.name()), + yaml + ); + } } } diff --git a/src/metadata/message.rs b/src/metadata/message.rs index 4029a741..f9278c02 100644 --- a/src/metadata/message.rs +++ b/src/metadata/message.rs @@ -2,6 +2,8 @@ use std::collections::BTreeSet; use saphyr::{MarkedYaml, Scalar, YamlData}; +use crate::metadata::yaml::YamlAnchors; + use super::{ error::{ ExpectedType, MetadataParsingErrorReason, MultilingualMessageContentsError, @@ -405,35 +407,48 @@ pub(super) fn emit_message_contents( emitter: &mut YamlEmitter, key: &'static str, ) { - match slice { - [] => {} - [detail] => { - emitter.write_map_key(key); - emitter.write_single_quoted_str(detail.text()); - } - details => { - emitter.write_map_key(key); - - details.emit_yaml(emitter); - } + if slice.is_empty() { + return; } + + emitter.write_map_key(key); + + emitter.write_anchored_value( + |a| a.message_contents_anchor(slice).cloned(), + |e| match slice { + [] => {} + [detail] => e.write_single_quoted_str(detail.text()), + details => details.emit_yaml(e), + }, + ); } impl EmitYaml for Message { + fn has_written_anchor(&self, anchors: &YamlAnchors) -> bool { + anchors + .message_anchor(self) + .is_some_and(|a| anchors.is_anchor_written(a)) + } + fn emit_yaml(&self, emitter: &mut YamlEmitter) { - emitter.begin_map(); + emitter.write_anchored_value( + |e| e.message_anchor(self).cloned(), + |e| { + e.begin_map(); - emitter.write_map_key("type"); - emitter.write_unquoted_str(&self.level.to_string()); + e.write_map_key("type"); + e.write_unquoted_str(&self.level.to_string()); - emit_message_contents(&self.content, emitter, "content"); + emit_message_contents(&self.content, e, "content"); - if let Some(condition) = &self.condition { - emitter.write_map_key("condition"); - emitter.write_single_quoted_str(condition); - } + if let Some(condition) = &self.condition { + e.write_map_key("condition"); + e.write_condition(condition); + } - emitter.end_map(); + e.end_map(); + }, + ); } } @@ -819,7 +834,53 @@ mod tests { } } + mod has_written_anchor { + use std::collections::HashMap; + + use super::*; + + #[test] + fn should_return_true_if_there_is_an_anchor_for_the_message_that_has_been_written() { + let message = Message::new(MessageType::Say, "message".into()); + // Clone to make sure we're not relying on the same object being in the map. + let message_clone = message.clone(); + + let mut anchors = YamlAnchors::new(); + anchors + .set_message_anchors(HashMap::from([(&message_clone, "message1".to_owned())])); + anchors.record_written_anchor("message1".to_owned()); + + assert!(message.has_written_anchor(&anchors)); + } + + #[test] + fn should_return_false_if_there_is_an_unwritten_anchor() { + let message = Message::new(MessageType::Say, "message".into()); + // Clone to make sure we're not relying on the same object being in the map. + let message_clone = message.clone(); + + let mut anchors = YamlAnchors::new(); + anchors + .set_message_anchors(HashMap::from([(&message_clone, "message1".to_owned())])); + + assert!(!message.has_written_anchor(&anchors)); + } + + #[test] + fn should_return_false_if_there_is_no_anchor_for_the_message() { + let message = Message::new(MessageType::Say, "message".into()); + + let anchors = YamlAnchors::new(); + + assert!(!message.has_written_anchor(&anchors)); + } + } + mod emit_yaml { + use std::collections::HashMap; + + use crate::metadata::emit_with_anchors; + use super::*; #[test] @@ -901,6 +962,160 @@ content: yaml ); } + + #[test] + fn should_emit_an_anchored_map_if_the_message_has_an_unwritten_anchor() { + let message = Message::new(MessageType::Say, "message".into()); + + let mut anchors = YamlAnchors::new(); + anchors.set_message_anchors(HashMap::from([(&message, "message1".to_owned())])); + + let yaml = emit_with_anchors(&message, anchors); + + assert_eq!( + format!( + "&message1\ntype: say\ncontent: '{}'", + message.content[0].text + ), + yaml + ); + } + + #[test] + fn should_emit_an_alias_if_the_message_has_an_anchor() { + let message = Message::new(MessageType::Say, "message".into()); + + let mut anchors = YamlAnchors::new(); + anchors.set_message_anchors(HashMap::from([(&message, "message1".to_owned())])); + anchors.record_written_anchor("message1".to_owned()); + + let yaml = emit_with_anchors(&message, anchors); + + assert_eq!("*message1", yaml); + } + + #[test] + fn should_emit_an_anchored_flow_list_if_the_message_contents_has_an_unwritten_anchor_and_is_monolingual() + { + let message = Message::new(MessageType::Say, "message".into()); + + let mut anchors = YamlAnchors::new(); + anchors.set_message_contents_anchors(HashMap::from([( + message.content(), + "content1".to_owned(), + )])); + + let yaml = emit_with_anchors(&message, anchors); + + assert_eq!( + format!( + "type: say\ncontent: &content1 '{}'", + message.content[0].text + ), + yaml + ); + } + + #[test] + fn should_emit_an_anchored_block_list_if_the_message_contents_has_an_unwritten_anchor_and_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 mut anchors = YamlAnchors::new(); + anchors.set_message_contents_anchors(HashMap::from([( + message.content(), + "content1".to_owned(), + )])); + + let yaml = emit_with_anchors(&message, anchors); + + assert_eq!( + format!( + "type: {} +content: &content1 + - lang: {} + text: '{}' + - lang: {} + text: '{}'", + message.level, + message.content[0].language(), + message.content[0].text(), + message.content[1].language(), + message.content[1].text() + ), + yaml + ); + } + + #[test] + fn should_emit_an_alias_if_the_message_contents_has_an_anchor() { + let message = Message::new(MessageType::Say, "message".into()); + + let mut anchors = YamlAnchors::new(); + anchors.set_message_contents_anchors(HashMap::from([( + message.content(), + "content1".to_owned(), + )])); + anchors.record_written_anchor("content1".to_owned()); + + let yaml = emit_with_anchors(&message, anchors); + + assert_eq!("type: say\ncontent: *content1", yaml); + } + + #[test] + fn should_emit_an_anchored_scalar_if_the_condition_has_an_unwritten_anchor() { + let message = Message::new(MessageType::Say, "message".into()) + .with_condition("condition 1".into()); + + let mut anchors = YamlAnchors::new(); + anchors.set_condition_anchors(HashMap::from([( + message.condition().unwrap(), + "condition1".to_owned(), + )])); + + let yaml = emit_with_anchors(&message, anchors); + + assert_eq!( + format!( + "type: {}\ncontent: '{}'\ncondition: &condition1 '{}'", + message.level, + message.content[0].text, + message.condition.unwrap() + ), + yaml + ); + } + + #[test] + fn should_emit_an_alias_if_the_condition_has_an_anchor() { + let message = Message::new(MessageType::Say, "message".into()) + .with_condition("condition1".into()); + + let mut anchors = YamlAnchors::new(); + anchors.set_condition_anchors(HashMap::from([( + message.condition().unwrap(), + "condition1".to_owned(), + )])); + anchors.record_written_anchor("condition1".to_owned()); + + let yaml = emit_with_anchors(&message, anchors); + + assert_eq!( + format!( + "type: {}\ncontent: '{}'\ncondition: *condition1", + message.level, message.content[0].text + ), + yaml + ); + } } } } diff --git a/src/metadata/metadata_document.rs b/src/metadata/metadata_document.rs index fd7f29f5..268058ca 100644 --- a/src/metadata/metadata_document.rs +++ b/src/metadata/metadata_document.rs @@ -8,7 +8,7 @@ use saphyr::{LoadableYamlNode, MarkedYaml, YamlData}; use crate::{ escape_ascii, logging, - metadata::{PreludeDiffSpan, error::MetadataParsingErrorReason}, + metadata::{PreludeDiffSpan, error::MetadataParsingErrorReason, yaml::YamlAnchors}, }; use super::{ @@ -25,6 +25,11 @@ use super::{ }, }; +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub(crate) struct MetadataWriteOptions { + pub write_anchors: bool, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct MetadataDocument { bash_tags: Vec, @@ -220,10 +225,22 @@ impl MetadataDocument { Ok(()) } - pub(crate) fn save(&self, file_path: &Path) -> Result<(), WriteMetadataError> { + pub(crate) fn save( + &self, + file_path: &Path, + options: MetadataWriteOptions, + ) -> Result<(), WriteMetadataError> { logging::trace!("Saving metadata list to: \"{}\"", escape_ascii(file_path)); - let mut emitter = YamlEmitter::new(); + let plugins: Vec<_> = self.ordered_plugins_iter().collect(); + + let mut anchors = YamlAnchors::new(); + + if options.write_anchors { + set_emitter_anchors(&self.messages, &plugins, &mut anchors); + } + + let mut emitter = YamlEmitter::new(anchors); emitter.begin_map(); @@ -249,12 +266,12 @@ impl MetadataDocument { self.messages.emit_yaml(&mut emitter); } - if !self.plugins.is_empty() || !self.regex_plugins.is_empty() { + if !plugins.is_empty() { emitter.write_map_key("plugins"); emitter.begin_array(); - for plugin in self.ordered_plugins_iter() { + for plugin in plugins { if !plugin.has_name_only() { plugin.emit_yaml(&mut emitter); } @@ -401,6 +418,175 @@ impl std::default::Default for MetadataDocument { } } +#[derive(Debug, Clone)] +struct OrderedCounts { + order: Vec, + counts: HashMap, +} + +impl OrderedCounts { + fn new() -> Self { + Self { + order: Vec::new(), + counts: HashMap::new(), + } + } +} + +impl OrderedCounts { + fn increment_for(&mut self, value: T) -> u32 { + *self + .counts + .entry(value) + .and_modify(|e| *e += 1) + .or_insert_with(|| { + self.order.push(value); + 1 + }) + } + + fn into_anchor_map(self, anchor_prefix: &str) -> HashMap { + self.order + .into_iter() + .filter(|e| self.counts.get(e).is_some_and(|c| *c > 1)) + .enumerate() + .map(|(i, e)| (e, format!("{}{}", anchor_prefix, i + 1))) + .collect() + } +} + +fn set_emitter_anchors<'a>( + general_messages: &'a [Message], + plugins: &[&'a PluginMetadata], + anchors: &mut YamlAnchors<'a>, +) { + let mut file_counts = OrderedCounts::new(); + let mut message_counts = OrderedCounts::new(); + let mut message_contents_counts = OrderedCounts::new(); + let mut condition_counts = OrderedCounts::new(); + + for message in general_messages { + count_message_values( + message, + &mut message_counts, + &mut message_contents_counts, + &mut condition_counts, + ); + } + + for plugin in plugins { + for file in plugin.load_after_files() { + count_file_values( + file, + &mut file_counts, + &mut message_contents_counts, + &mut condition_counts, + ); + } + + for file in plugin.requirements() { + count_file_values( + file, + &mut file_counts, + &mut message_contents_counts, + &mut condition_counts, + ); + } + + for file in plugin.incompatibilities() { + count_file_values( + file, + &mut file_counts, + &mut message_contents_counts, + &mut condition_counts, + ); + } + + for message in plugin.messages() { + count_message_values( + message, + &mut message_counts, + &mut message_contents_counts, + &mut condition_counts, + ); + } + + for tag in plugin.tags() { + if let Some(condition) = tag.condition() { + condition_counts.increment_for(condition); + } + } + + for info in plugin.dirty_info() { + if !info.detail().is_empty() { + message_contents_counts.increment_for(info.detail()); + } + } + + for info in plugin.clean_info() { + if !info.detail().is_empty() { + message_contents_counts.increment_for(info.detail()); + } + } + } + + let file_anchors = file_counts.into_anchor_map("file"); + let message_anchors = message_counts.into_anchor_map("message"); + let message_contents_anchors = message_contents_counts.into_anchor_map("contents"); + let condition_anchors = condition_counts.into_anchor_map("condition"); + + anchors.set_message_anchors(message_anchors); + anchors.set_message_contents_anchors(message_contents_anchors); + anchors.set_file_anchors(file_anchors); + anchors.set_condition_anchors(condition_anchors); +} + +fn count_message_values<'a>( + message: &'a Message, + message_counts: &mut OrderedCounts<&'a Message>, + message_contents_counts: &mut OrderedCounts<&'a [crate::metadata::MessageContent]>, + condition_counts: &mut OrderedCounts<&'a str>, +) { + let message_count = message_counts.increment_for(message); + + if message_count > 1 { + return; + } + + if !message.content().is_empty() { + message_contents_counts.increment_for(message.content()); + } + + if let Some(condition) = message.condition() { + condition_counts.increment_for(condition); + } +} + +fn count_file_values<'a>( + file: &'a crate::metadata::File, + file_counts: &mut OrderedCounts<&'a crate::metadata::File>, + message_contents_counts: &mut OrderedCounts<&'a [crate::metadata::MessageContent]>, + condition_counts: &mut OrderedCounts<&'a str>, +) { + let file_count = file_counts.increment_for(file); + + if file_count > 1 { + return; + } + + if !file.detail().is_empty() { + message_contents_counts.increment_for(file.detail()); + } + + if let Some(condition) = file.condition() { + condition_counts.increment_for(condition); + } + + if let Some(constraint) = file.constraint() { + condition_counts.increment_for(constraint); + } +} + struct MasterlistWithReplacedPrelude { masterlist: String, meta: Option, @@ -569,7 +755,9 @@ mod tests { mod metadata_document { use std::error::Error; - use crate::metadata::MessageType; + use crate::metadata::{ + MessageContent, MessageType, PluginCleaningData, Tag, TagSuggestion, + }; use super::*; @@ -1019,7 +1207,9 @@ plugins: metadata.load(&path).unwrap(); let other_path = tmp_dir.path().join("other.yaml"); - metadata.save(&other_path).unwrap(); + metadata + .save(&other_path, MetadataWriteOptions::default()) + .unwrap(); let mut other_metadata = MetadataDocument::default(); other_metadata.load(&other_path).unwrap(); @@ -1027,6 +1217,152 @@ plugins: assert_eq!(metadata, other_metadata); } + fn metadata_with_repeated_values() -> MetadataDocument { + let mut metadata = MetadataDocument::default(); + + let mut plugin = PluginMetadata::new("test1.esp").unwrap(); + + let condition1 = "file(\"test.txt\")"; + let condition2 = "file(\"other.txt\")"; + let condition3 = "file(\"third.txt\")"; + let contents1 = vec![MessageContent::new("message text 1".to_owned())]; + let contents2 = vec![ + MessageContent::new("message text 1".to_owned()).with_language("en".to_owned()), + MessageContent::new("message text 2".to_owned()).with_language("fr".to_owned()), + ]; + let contents3 = vec![MessageContent::new("message text 3".to_owned())]; + let contents4 = vec![ + MessageContent::new("message text 1".to_owned()).with_language("en".to_owned()), + MessageContent::new("message text 2".to_owned()).with_language("de".to_owned()), + ]; + let file1 = File::new("file 1".to_owned()); + let file2 = File::new("file 2".to_owned()) + .with_condition(condition1.to_owned()) + .with_detail(contents1.clone()) + .unwrap(); + let file3 = File::new("file 3".to_owned()) + .with_condition(condition1.to_owned()) + .with_constraint(condition2.to_owned()); + let file4 = File::new("file 4".to_owned()) + .with_constraint(condition3.to_owned()) + .with_detail(contents3.clone()) + .unwrap(); + + let message1 = Message::new(MessageType::Say, contents1[0].text().to_owned()); + let message2 = Message::multilingual(MessageType::Say, contents2.clone()).unwrap(); + let message3 = Message::multilingual(MessageType::Say, contents4.clone()).unwrap(); + + let tag1 = Tag::new("Relev".to_owned(), TagSuggestion::Addition) + .with_condition(condition2.to_owned()); + let tag2 = Tag::new("Delev".to_owned(), TagSuggestion::Addition) + .with_condition(condition3.to_owned()); + + let info1 = PluginCleaningData::new(0xDEAD_BEEF, "utility".to_owned()) + .with_detail(contents2.clone()) + .unwrap(); + let info2 = PluginCleaningData::new(0xDEAD_BEEF, "utility".to_owned()) + .with_detail(contents3.clone()) + .unwrap(); + + plugin.set_load_after_files(vec![file1.clone()]); + plugin.set_requirements(vec![file1.clone(), file2.clone()]); + plugin.set_incompatibilities(vec![file2.clone(), file3.clone()]); + plugin.set_messages(vec![message1.clone(), message2.clone(), message3]); + plugin.set_tags(vec![tag1, tag2]); + plugin.set_dirty_info(vec![info1]); + plugin.set_clean_info(vec![info2]); + + metadata.set_plugin_metadata(plugin); + + let mut plugin = PluginMetadata::new("test2.esp").unwrap(); + + plugin.set_messages(vec![message2]); + plugin.set_load_after_files(vec![file4]); + + metadata.set_plugin_metadata(plugin); + + metadata + } + + #[test] + fn save_should_use_anchors_and_aliases_for_repeated_metadata_when_write_anchors_is_true() { + let tmp_dir = tempdir().unwrap(); + + let path = tmp_dir.path().join("masterlist.yaml"); + + let metadata = metadata_with_repeated_values(); + + metadata + .save( + &path, + MetadataWriteOptions { + write_anchors: true, + }, + ) + .unwrap(); + + let content = std::fs::read_to_string(&path).unwrap(); + + assert_eq!( + "plugins: + - name: 'test1.esp' + after: [ &file1 'file 1' ] + req: + - *file1 + - &file2 + name: 'file 2' + detail: &contents1 'message text 1' + condition: &condition1 'file(\"test.txt\")' + inc: + - *file2 + - name: 'file 3' + condition: *condition1 + constraint: &condition2 'file(\"other.txt\")' + msg: + - type: say + content: *contents1 + - &message1 + type: say + content: &contents2 + - lang: en + text: 'message text 1' + - lang: fr + text: 'message text 2' + - type: say + content: + - lang: en + text: 'message text 1' + - lang: de + text: 'message text 2' + tag: + - name: Relev + condition: *condition2 + - name: Delev + condition: &condition3 'file(\"third.txt\")' + dirty: + - crc: 0xDEADBEEF + util: 'utility' + detail: *contents2 + clean: + - crc: 0xDEADBEEF + util: 'utility' + detail: &contents3 'message text 3' + - name: 'test2.esp' + after: + - name: 'file 4' + detail: *contents3 + constraint: *condition3 + msg: [ *message1 ]", + content + ); + + // Also check that the written metadata can be read correctly. + let mut other_metadata = MetadataDocument::default(); + other_metadata.load(&path).unwrap(); + + assert_eq!(metadata, other_metadata); + } + #[test] fn clear_should_clear_all_loaded_data() { let mut metadata = MetadataDocument::default(); @@ -1246,6 +1582,94 @@ plugins: } } + mod set_emitter_anchors { + use crate::metadata::{MessageContent, MessageType}; + + use super::*; + + #[test] + fn set_emitter_anchors_should_skip_contents_and_conditions_within_messages_that_have_already_been_seen() + { + let content1 = "test message 1"; + let content2 = "test message 2"; + let condition1 = "condition 1"; + let condition2 = "condition 2"; + let message1 = Message::new(MessageType::Say, content1.to_owned()) + .with_condition(condition1.to_owned()); + let message2 = Message::new(MessageType::Say, content2.to_owned()) + .with_condition(condition2.to_owned()); + let message3 = Message::new(MessageType::Warn, content2.to_owned()) + .with_condition(condition2.to_owned()); + let general_messages = &[ + message1.clone(), + message1.clone(), + message2.clone(), + message3, + ]; + let plugins = &[]; + + let mut anchors = YamlAnchors::new(); + set_emitter_anchors(general_messages, plugins, &mut anchors); + + assert_eq!("message1", anchors.message_anchor(&message1).unwrap()); + assert!( + anchors + .message_contents_anchor(message1.content()) + .is_none() + ); + assert!(anchors.condition_anchor(condition1).is_none()); + assert_eq!( + "contents1", + anchors.message_contents_anchor(message2.content()).unwrap() + ); + assert_eq!("condition1", anchors.condition_anchor(condition2).unwrap()); + } + + #[test] + fn set_emitter_anchors_should_skip_details_and_conditions_and_constraints_that_have_already_been_seen() + { + let content1 = "test message 1"; + let content2 = "test message 2"; + let condition1 = "condition 1"; + let condition2 = "condition 2"; + let constraint1 = "constraint 1"; + let constraint2 = "constraint 2"; + let file1 = File::new("file 1".to_owned()) + .with_condition(condition1.to_owned()) + .with_constraint(constraint1.to_owned()) + .with_detail(vec![MessageContent::new(content1.to_owned())]) + .unwrap(); + let file2 = File::new("file 2".to_owned()) + .with_condition(condition2.to_owned()) + .with_constraint(constraint2.to_owned()) + .with_detail(vec![MessageContent::new(content2.to_owned())]) + .unwrap(); + let file3 = File::new("file 3".to_owned()) + .with_condition(condition2.to_owned()) + .with_constraint(constraint2.to_owned()) + .with_detail(vec![MessageContent::new(content2.to_owned())]) + .unwrap(); + let mut plugin = PluginMetadata::new("test1.esp").unwrap(); + plugin.set_load_after_files(vec![file1.clone(), file1.clone(), file2.clone(), file3]); + let messages = &[]; + let plugins = &[&plugin]; + + let mut anchors = YamlAnchors::new(); + set_emitter_anchors(messages, plugins, &mut anchors); + + assert_eq!("file1", anchors.file_anchor(&file1).unwrap()); + assert!(anchors.message_contents_anchor(file1.detail()).is_none()); + assert!(anchors.condition_anchor(condition1).is_none()); + assert!(anchors.condition_anchor(constraint1).is_none()); + assert_eq!( + "contents1", + anchors.message_contents_anchor(file2.detail()).unwrap() + ); + assert_eq!("condition1", anchors.condition_anchor(condition2).unwrap()); + assert_eq!("condition2", anchors.condition_anchor(constraint2).unwrap()); + } + } + mod replace_prelude { use super::*; diff --git a/src/metadata/mod.rs b/src/metadata/mod.rs index 867e50c4..e8a77c95 100644 --- a/src/metadata/mod.rs +++ b/src/metadata/mod.rs @@ -18,6 +18,9 @@ pub use plugin_cleaning_data::PluginCleaningData; pub use plugin_metadata::PluginMetadata; pub use tag::{Tag, TagSuggestion}; +#[cfg(test)] +use crate::metadata::yaml::YamlAnchors; + #[derive(Debug)] struct PreludeDiffSpan { start_line: usize, @@ -27,7 +30,15 @@ struct PreludeDiffSpan { #[cfg(test)] fn emit(metadata: &T) -> String { - let mut emitter = yaml::YamlEmitter::new(); + let mut emitter = yaml::YamlEmitter::new(YamlAnchors::new()); + metadata.emit_yaml(&mut emitter); + + emitter.into_string() +} + +#[cfg(test)] +fn emit_with_anchors(metadata: &T, anchors: YamlAnchors) -> String { + let mut emitter = yaml::YamlEmitter::new(anchors); metadata.emit_yaml(&mut emitter); emitter.into_string() diff --git a/src/metadata/plugin_cleaning_data.rs b/src/metadata/plugin_cleaning_data.rs index 497fa3e7..e73f11f2 100644 --- a/src/metadata/plugin_cleaning_data.rs +++ b/src/metadata/plugin_cleaning_data.rs @@ -315,7 +315,9 @@ mod tests { } mod emit_yaml { - use crate::metadata::emit; + use std::collections::HashMap; + + use crate::metadata::{emit, emit_with_anchors, yaml::YamlAnchors}; use super::*; @@ -422,5 +424,23 @@ detail: yaml ); } + + #[test] + fn should_emit_an_alias_if_the_detail_has_an_anchor() { + let data = PluginCleaningData::new(0xDEAD_BEEF, "TES5Edit".into()) + .with_detail(vec![MessageContent::new("message".into())]) + .unwrap(); + + let mut anchors = YamlAnchors::new(); + anchors.set_message_contents_anchors(HashMap::from([( + data.detail(), + "content1".to_owned(), + )])); + anchors.record_written_anchor("content1".to_owned()); + + let yaml = emit_with_anchors(&data, anchors); + + assert_eq!("crc: 0xDEADBEEF\nutil: 'TES5Edit'\ndetail: *content1", yaml); + } } } diff --git a/src/metadata/plugin_metadata.rs b/src/metadata/plugin_metadata.rs index d2acaae8..f4cabd1e 100644 --- a/src/metadata/plugin_metadata.rs +++ b/src/metadata/plugin_metadata.rs @@ -3,7 +3,9 @@ use std::borrow::Cow; use regress::{Error as RegexImplError, Regex}; use saphyr::MarkedYaml; -use crate::{Database, case_insensitive_regex, error::ConditionEvaluationError}; +use crate::{ + Database, case_insensitive_regex, error::ConditionEvaluationError, metadata::yaml::YamlAnchors, +}; use super::{ error::{MetadataParsingErrorReason, ParseMetadataError, RegexError}, @@ -219,7 +221,7 @@ impl PluginMetadata { /// Serialises the plugin metadata as YAML. pub fn as_yaml(&self) -> String { - let mut emitter = YamlEmitter::new(); + let mut emitter = YamlEmitter::new(YamlAnchors::new()); self.emit_yaml(&mut emitter); emitter.into_string() } diff --git a/src/metadata/tag.rs b/src/metadata/tag.rs index c62f0bb0..43977798 100644 --- a/src/metadata/tag.rs +++ b/src/metadata/tag.rs @@ -117,7 +117,7 @@ impl EmitYaml for Tag { } emitter.write_map_key("condition"); - emitter.write_single_quoted_str(condition); + emitter.write_condition(condition); emitter.end_map(); } else if self.is_addition() { @@ -204,7 +204,9 @@ mod tests { } mod emit_yaml { - use crate::metadata::emit; + use std::collections::HashMap; + + use crate::metadata::{emit, emit_with_anchors, yaml::YamlAnchors}; use super::*; @@ -224,5 +226,22 @@ mod tests { assert_eq!("name: -name1\ncondition: 'condition'", yaml); } + + #[test] + fn should_emit_an_alias_if_the_condition_has_an_anchor() { + let tag = + Tag::new("name1".into(), TagSuggestion::Removal).with_condition("condition".into()); + + let mut anchors = YamlAnchors::new(); + anchors.set_condition_anchors(HashMap::from([( + tag.condition().unwrap(), + "condition1".to_owned(), + )])); + anchors.record_written_anchor("condition1".to_owned()); + + let yaml = emit_with_anchors(&tag, anchors); + + assert_eq!("name: -name1\ncondition: *condition1", yaml); + } } } diff --git a/src/metadata/yaml/emit.rs b/src/metadata/yaml/emit.rs index 315985f2..7b83775f 100644 --- a/src/metadata/yaml/emit.rs +++ b/src/metadata/yaml/emit.rs @@ -1,23 +1,109 @@ +use std::collections::{HashMap, HashSet}; + +use crate::metadata::{Message, MessageContent}; + pub(in crate::metadata) trait EmitYaml { fn is_scalar(&self) -> bool { false } + fn has_written_anchor(&self, _: &YamlAnchors) -> bool { + false + } + fn emit_yaml(&self, emitter: &mut YamlEmitter); } -#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] -pub(in crate::metadata) struct YamlEmitter { +#[derive(Clone, Debug, Eq, PartialEq, Default)] +pub(in crate::metadata) struct YamlAnchors<'a> { + message: HashMap<&'a Message, String>, + message_contents: HashMap<&'a [MessageContent], String>, + file: HashMap<&'a crate::metadata::File, String>, + condition: HashMap<&'a str, String>, + written_anchors: HashSet, +} + +impl<'a> YamlAnchors<'a> { + pub(in crate::metadata) fn new() -> Self { + YamlAnchors { + message: HashMap::new(), + message_contents: HashMap::new(), + file: HashMap::new(), + condition: HashMap::new(), + written_anchors: HashSet::new(), + } + } + + pub(in crate::metadata) fn set_message_anchors( + &mut self, + message_anchors: HashMap<&'a Message, String>, + ) { + self.message = message_anchors; + } + + pub(in crate::metadata) fn set_message_contents_anchors( + &mut self, + message_contents_anchors: HashMap<&'a [MessageContent], String>, + ) { + self.message_contents = message_contents_anchors; + } + + pub(in crate::metadata) fn set_file_anchors( + &mut self, + file_anchors: HashMap<&'a crate::metadata::File, String>, + ) { + self.file = file_anchors; + } + + pub(in crate::metadata) fn set_condition_anchors( + &mut self, + condition_anchors: HashMap<&'a str, String>, + ) { + self.condition = condition_anchors; + } + + pub(in crate::metadata) fn record_written_anchor(&mut self, anchor_name: String) { + self.written_anchors.insert(anchor_name); + } + + pub(in crate::metadata) fn message_anchor(&self, message: &Message) -> Option<&String> { + self.message.get(message) + } + + pub(in crate::metadata) fn message_contents_anchor( + &self, + message_contents: &[MessageContent], + ) -> Option<&String> { + self.message_contents.get(message_contents) + } + + pub(in crate::metadata) fn file_anchor(&self, file: &crate::metadata::File) -> Option<&String> { + self.file.get(file) + } + + pub(in crate::metadata) fn condition_anchor(&self, condition: &str) -> Option<&String> { + self.condition.get(condition) + } + + pub(in crate::metadata) fn is_anchor_written(&self, anchor_name: &str) -> bool { + self.written_anchors.contains(anchor_name) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(in crate::metadata) struct YamlEmitter<'a> { buffer: String, scope: Vec, style: YamlStyle, is_first_line_of_map: bool, + pub(in crate::metadata) anchors: YamlAnchors<'a>, } #[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] enum YamlBlock { Array, Map, + Anchor, } #[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] @@ -31,16 +117,17 @@ enum YamlStyle { Block, } -impl YamlEmitter { - const INDENT_UNIT: &str = " "; - const ARRAY_ELEMENT_PREFIX: &str = "- "; +impl<'a> YamlEmitter<'a> { + const INDENT_UNIT: &'static str = " "; + const ARRAY_ELEMENT_PREFIX: &'static str = "- "; - pub(in crate::metadata) fn new() -> Self { + pub(in crate::metadata) fn new(anchors: YamlAnchors<'a>) -> Self { Self { buffer: String::new(), scope: vec![], style: YamlStyle::Block, is_first_line_of_map: false, + anchors, } } @@ -52,6 +139,60 @@ impl YamlEmitter { self.buffer } + fn begin_anchored_element(&mut self, anchor_name: &str) { + self.write_prefix(); + + self.write("&"); + self.write(anchor_name); + + self.anchors.record_written_anchor(anchor_name.to_owned()); + self.scope.push(YamlBlock::Anchor); + } + + fn end_anchored_element(&mut self) { + if self.scope.last() == Some(&YamlBlock::Anchor) { + self.scope.pop(); + } + } + + /// Writes an anchor if the anchor hasn't yet been written, otherwise writes + /// an alias of that anchor. + fn write_alias(&mut self, anchor_name: &str) { + self.write_prefix(); + + self.write("*"); + self.write(anchor_name); + } + + pub(in crate::metadata) fn write_anchored_value( + &mut self, + get_anchor: F, + write_element: W, + ) where + F: Fn(&YamlAnchors) -> Option, + W: Fn(&mut Self), + { + if let Some(anchor) = get_anchor(&self.anchors) { + let is_anchor_written = self.anchors.is_anchor_written(&anchor); + if is_anchor_written { + self.write_alias(&anchor); + } else { + self.begin_anchored_element(&anchor); + write_element(self); + self.end_anchored_element(); + } + } else { + write_element(self); + } + } + + pub(in crate::metadata) fn write_condition(&mut self, condition: &str) { + self.write_anchored_value( + |a| a.condition_anchor(condition).cloned(), + |e| e.write_single_quoted_str(condition), + ); + } + pub(in crate::metadata) fn write_unquoted_str(&mut self, value: &str) { self.write_string(value, true); } @@ -83,9 +224,11 @@ impl YamlEmitter { self.write_array_element_prefix(); } + let in_anchored_element = self.scope.last() == Some(&YamlBlock::Anchor); + self.scope.push(YamlBlock::Map); - self.is_first_line_of_map = true; + self.is_first_line_of_map = !in_anchored_element; } pub(in crate::metadata) fn end_map(&mut self) { @@ -137,10 +280,15 @@ impl YamlEmitter { } 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 { + let scope_count = self + .scope + .iter() + .filter(|s| **s != YamlBlock::Anchor) + .skip(1) // Top-level scope needs no indentation. + .count(); + + for _ in 0..scope_count { self.write(Self::INDENT_UNIT); } } @@ -149,8 +297,8 @@ impl YamlEmitter { fn write_prefix(&mut self) { match self.scope.last() { Some(&YamlBlock::Array) => self.write_array_element_prefix(), - Some(&YamlBlock::Map) => self.write(" "), - _ => self.write_indent(), + Some(&YamlBlock::Map | &YamlBlock::Anchor) => self.write(" "), + None => self.write_indent(), } } @@ -303,7 +451,7 @@ impl EmitYaml for &[T] { fn emit_yaml(&self, emitter: &mut YamlEmitter) { match self { [] => {} - [element] if element.is_scalar() => { + [element] if element.is_scalar() || element.has_written_anchor(&emitter.anchors) => { emitter.set_style(YamlStyle::Flow); emitter.begin_array(); emitter.write(" "); @@ -348,7 +496,7 @@ mod tests { use super::*; fn emit(str: &str) -> String { - let mut emitter = YamlEmitter::new(); + let mut emitter = YamlEmitter::new(YamlAnchors::new()); emitter.write_unquoted_str(str); emitter.into_string() } @@ -395,7 +543,7 @@ mod tests { fn should_fall_back_to_single_quoting_string_that_contains_a_flow_indicator_when_style_is_flow() { fn emit_flow(str: &str) -> String { - let mut emitter = YamlEmitter::new(); + let mut emitter = YamlEmitter::new(YamlAnchors::new()); emitter.set_style(YamlStyle::Flow); emitter.write_unquoted_str(str); emitter.into_string() @@ -534,7 +682,7 @@ mod tests { fn single_quoted_str_should_emit_string_wrapped_in_single_quotes_and_with_single_quotes_doubled() { let value = "hello 'world'"; - let mut emitter = YamlEmitter::new(); + let mut emitter = YamlEmitter::new(YamlAnchors::new()); emitter.write_single_quoted_str(value); assert_eq!("'hello ''world'''", emitter.into_string()); @@ -544,11 +692,72 @@ mod tests { fn single_quoted_str_should_fall_back_to_double_quoting_string_if_it_contains_non_printable_characters() { let value = "\x1B[1mhello world\x1B[0m"; - let mut emitter = YamlEmitter::new(); + let mut emitter = YamlEmitter::new(YamlAnchors::new()); emitter.write_single_quoted_str(value); assert_eq!("\"\\e[1mhello world\\e[0m\"", emitter.into_string()); } } } + + mod slice_emit_yaml { + use super::*; + + use crate::metadata::{MessageType, emit, emit_with_anchors}; + + #[test] + fn should_emit_a_flow_style_message_list_if_it_has_only_one_element_that_can_use_an_alias() + { + let messages = vec![Message::new(MessageType::Say, "message".into())]; + + let mut anchors = YamlAnchors::new(); + anchors.set_message_anchors(HashMap::from([(&messages[0], "message1".to_owned())])); + anchors.record_written_anchor("message1".to_owned()); + + let yaml = emit_with_anchors(&messages, anchors); + + assert_eq!("[ *message1 ]", yaml); + } + + #[test] + fn should_emit_a_block_style_message_list_if_it_has_multiple_elements() { + let messages = vec![ + Message::new(MessageType::Say, "message 1".into()), + Message::new(MessageType::Say, "message 2".into()), + ]; + + let yaml = emit(&messages); + + assert_eq!( + format!( + "\n- type: say\n content: '{}'\n- type: say\n content: '{}'", + messages[0].content()[0].text(), + messages[1].content()[0].text() + ), + yaml + ); + } + + #[test] + fn should_emit_a_block_style_message_list_if_it_has_multiple_elements_and_one_alias() { + let messages = vec![ + Message::new(MessageType::Say, "message 1".into()), + Message::new(MessageType::Say, "message 2".into()), + ]; + + let mut anchors = YamlAnchors::new(); + anchors.set_message_anchors(HashMap::from([(&messages[0], "message1".to_owned())])); + anchors.record_written_anchor("message1".to_owned()); + + let yaml = emit_with_anchors(&messages, anchors); + + assert_eq!( + format!( + "\n- *message1\n- type: say\n content: '{}'", + messages[1].content()[0].text() + ), + yaml + ); + } + } } diff --git a/src/metadata/yaml/mod.rs b/src/metadata/yaml/mod.rs index a47e1103..d6761b34 100644 --- a/src/metadata/yaml/mod.rs +++ b/src/metadata/yaml/mod.rs @@ -2,7 +2,7 @@ mod emit; mod merge; mod parse; -pub(in crate::metadata) use emit::{EmitYaml, YamlEmitter}; +pub(in crate::metadata) use emit::{EmitYaml, YamlAnchors, YamlEmitter}; pub(in crate::metadata) use merge::process_merge_keys; pub(in crate::metadata) use parse::{ TryFromYaml, YamlObjectType, as_mapping, get_required_string_value, get_slice_value,