Add support for emitting YAML with anchors and aliases

Support is currently limited to aliasing:

- Condition and constraint strings
- File values
- Message values
- MessageContent arrays (including single-value arrays that are
  serialised as strings)

An anchor is written if the same value appears more than once in the
metadata document being written, and if an anchor for that value has not
already been written. If a value has already been written with an
anchor, later appearances of that value will be written as aliases of
that anchor.

Anchors are named according to the type of data they're for, followed by
an incrementing number, e.g. file1, message1, contents1, condition1.

This behaviour is configurable within libloot, the configuration options
will be exposed externally once the functionality is more settled.
This commit is contained in:
Oliver Hamlet
2026-01-02 19:49:05 +00:00
parent 72dde58e43
commit da00052245
10 changed files with 1203 additions and 76 deletions
+7 -3
View File
@@ -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.
+245 -22
View File
@@ -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
);
}
}
}
+235 -20
View File
@@ -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
);
}
}
}
}
+431 -7
View File
@@ -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<String>,
@@ -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<T> {
order: Vec<T>,
counts: HashMap<T, u32>,
}
impl<T> OrderedCounts<T> {
fn new() -> Self {
Self {
order: Vec::new(),
counts: HashMap::new(),
}
}
}
impl<T: Eq + std::hash::Hash + Copy> OrderedCounts<T> {
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<T, String> {
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<PreludeDiffSpan>,
@@ -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::*;
+12 -1
View File
@@ -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<T: yaml::EmitYaml>(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<T: yaml::EmitYaml>(metadata: &T, anchors: YamlAnchors) -> String {
let mut emitter = yaml::YamlEmitter::new(anchors);
metadata.emit_yaml(&mut emitter);
emitter.into_string()
+21 -1
View File
@@ -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);
}
}
}
+4 -2
View File
@@ -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()
}
+21 -2
View File
@@ -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);
}
}
}
+226 -17
View File
@@ -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<String>,
}
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<YamlBlock>,
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<F, W>(
&mut self,
get_anchor: F,
write_element: W,
) where
F: Fn(&YamlAnchors) -> Option<String>,
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<T: EmitYaml> 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
);
}
}
}
+1 -1
View File
@@ -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,