diff --git a/example_unfinished.xml b/example_unfinished.xml index 9b26868..b910ca9 100644 --- a/example_unfinished.xml +++ b/example_unfinished.xml @@ -5,13 +5,23 @@ UiContext Name + + + + An example entry for Name + + This is just a Sample Dies ist nur ein Beispiel + + Practice more + + CodeContext diff --git a/src/sort.rs b/src/sort.rs index 92dd24f..a6ea369 100644 --- a/src/sort.rs +++ b/src/sort.rs @@ -22,7 +22,7 @@ pub fn sort_main(args: &SortArgs) -> Result<(), String> { match nodes { Ok(mut ts_node) => { sort_ts_node(&mut ts_node); - Ok(()) + write_ts_to_output(&args, &ts_node) } Err(e) => Err(format!( "Could not parse input file \"{}\". Error: {e:?}.", @@ -37,11 +37,26 @@ pub fn sort_main(args: &SortArgs) -> Result<(), String> { } } -fn sort_ts_node(_ts_node: &mut TSNode) { - todo!(); +/// Sorts the TS document with the following rules: +/// 1. Context comes before no-context messages. +/// 2. Context are ordered by name. +/// 3. Messages are ordered by filename then by line. +fn sort_ts_node(ts_node: &mut TSNode) { + let contexts = &mut ts_node.contexts; + contexts.sort(); + contexts.iter_mut().for_each(|context| { + context.messages.sort(); + context + .messages + .iter_mut() + .for_each(|message| message.locations.sort()); + }); } -fn write_ts_file(args: &SortArgs, node: &TSNode) -> Result<(), String> { +/// Writes the output TS file to the specified output (file or stdout). +/// This writer will auto indent/pretty print. It will always expand empty nodes, e.g. +/// `` instead of ``. +fn write_ts_to_output(args: &SortArgs, node: &TSNode) -> Result<(), String> { let mut inner_writer: BufWriter> = match &args.output_path { None => BufWriter::new(Box::new(std::io::stdout().lock())), Some(output_path) => match std::fs::File::options() @@ -58,7 +73,8 @@ fn write_ts_file(args: &SortArgs, node: &TSNode) -> Result<(), String> { }, }; - let mut output_buffer = String::from("\n"); + let mut output_buffer = + String::from("\n\n"); let mut ser = quick_xml::se::Serializer::new(&mut output_buffer); ser.indent(' ', 2).expand_empty_elements(true); @@ -74,6 +90,65 @@ fn write_ts_file(args: &SortArgs, node: &TSNode) -> Result<(), String> { } } +#[cfg(test)] +mod sort_test { + use super::*; + use quick_xml; + + #[test] + fn sort_ts_node_ts() { + let reader_nosort = quick_xml::Reader::from_file("example_unfinished.xml") + .expect("Couldn't open example_unfinished test file"); + let mut data_nosort: TSNode = + quick_xml::de::from_reader(reader_nosort.into_inner()).expect("Parsable"); + + sort_ts_node(&mut data_nosort); + + // Validate context ordering + assert_eq!(data_nosort.contexts[0].name, Some("CodeContext".to_owned())); + assert_eq!(data_nosort.contexts[1].name, Some("UiContext".to_owned())); + + // Validate message ordering + let messages = &data_nosort.contexts[1].messages; + assert_eq!(messages[0].source, Some("This is just a Sample".to_owned())); + assert_eq!(messages[1].source, Some("Name".to_owned())); + assert_eq!(messages[2].source, Some("Practice more".to_owned())); + + // Validate locations ordering + assert_eq!( + messages[0].locations[0].filename, + Some("ui_main.cpp".to_owned()) + ); + assert_eq!(messages[0].locations[0].line, Some(144)); + assert_eq!( + messages[0].locations[1].filename, + Some("ui_potato_viewer.cpp".to_owned()) + ); + assert_eq!(messages[0].locations[1].line, Some(10)); + + assert_eq!( + messages[1].locations[0].filename, + Some("ui_main.cpp".to_owned()) + ); + assert_eq!(messages[1].locations[0].line, Some(321)); + assert_eq!( + messages[1].locations[1].filename, + Some("ui_main.cpp".to_owned()) + ); + assert_eq!(messages[1].locations[1].line, Some(456)); + assert_eq!( + messages[1].locations[2].filename, + Some("ui_potato_viewer.cpp".to_owned()) + ); + assert_eq!(messages[1].locations[2].line, Some(10)); + assert_eq!( + messages[1].locations[3].filename, + Some("ui_potato_viewer.cpp".to_owned()) + ); + assert_eq!(messages[1].locations[3].line, Some(11)); + } +} + #[cfg(test)] mod write_file_test { use super::*; @@ -89,12 +164,13 @@ mod write_file_test { input_path: "whatever".to_owned(), output_path: Some("test_result_write_to_ts.xml".to_owned()), }; - write_ts_file(&args, &data).expect("Output"); + write_ts_to_output(&args, &data).expect("Output"); let f = quick_xml::Reader::from_file("test_result_write_to_ts.xml") .expect("Couldn't open output test file"); let output_data: TSNode = quick_xml::de::from_reader(f.into_inner()).expect("Parsable"); + std::fs::remove_file("test_result_write_to_ts.xml").expect("Test should clean test file."); assert_eq!(data, output_data); } } diff --git a/src/ts_definition.rs b/src/ts_definition.rs index 55cde3c..551e050 100644 --- a/src/ts_definition.rs +++ b/src/ts_definition.rs @@ -1,5 +1,10 @@ -// https://doc.qt.io/qt-6/linguist-ts-file-format.html use serde::{Deserialize, Serialize}; +use std::cmp::Ordering; + +// This file defines the schema matching (or trying to match?) Qt's XSD +// Eventually when a proper Rust code generator exists it would be great to use that instead. +// For now they can't handle Qt's semi-weird XSD. +// https://doc.qt.io/qt-6/linguist-ts-file-format.html #[derive(Debug, Deserialize, Serialize, PartialEq)] #[serde(rename = "TS")] @@ -10,8 +15,8 @@ pub struct TSNode { source_language: Option, #[serde(rename = "@language", skip_serializing_if = "Option::is_none")] language: Option, - #[serde(rename = "context", skip_serializing_if = "Option::is_none")] - contexts: Option>, + #[serde(rename = "context", skip_serializing_if = "Vec::is_empty", default)] + pub contexts: Vec, #[serde(skip_serializing_if = "Option::is_none")] messages: Option>, #[serde(skip_serializing_if = "Option::is_none")] @@ -26,12 +31,12 @@ pub struct TSNode { translatorcomment: Option, } -#[derive(Debug, Deserialize, Serialize, PartialEq)] +#[derive(Debug, Eq, Deserialize, Serialize, PartialEq)] pub struct ContextNode { #[serde(skip_serializing_if = "Option::is_none")] - name: Option, + pub name: Option, #[serde(rename = "message")] - messages: Vec, + pub messages: Vec, #[serde(skip_serializing_if = "Option::is_none")] comment: Option, #[serde(rename = "@encoding", skip_serializing_if = "Option::is_none")] @@ -49,16 +54,16 @@ pub struct Dependency { catalog: String, } -#[derive(Debug, Deserialize, Serialize, PartialEq)] +#[derive(Debug, Eq, Deserialize, Serialize, PartialEq)] pub struct MessageNode { #[serde(skip_serializing_if = "Option::is_none")] - source: Option, + pub source: Option, #[serde(skip_serializing_if = "Option::is_none")] oldsource: Option, // Result of merge #[serde(skip_serializing_if = "Option::is_none")] translation: Option, - #[serde(skip_serializing_if = "Option::is_none")] - location: Option>, + #[serde(skip_serializing_if = "Vec::is_empty", rename = "location", default)] + pub locations: Vec, #[serde(skip_serializing_if = "Option::is_none")] comment: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -76,7 +81,7 @@ pub struct MessageNode { // todo: extra-something } -#[derive(Debug, Deserialize, Serialize, PartialEq)] +#[derive(Debug, Eq, Deserialize, Serialize, PartialEq)] pub struct TranslationNode { // Did not find a way to make it an enum // Therefore: either you have a `translation_simple` or a `numerus_forms`, but not both. @@ -92,15 +97,15 @@ pub struct TranslationNode { userdata: Option, // deprecated } -#[derive(Debug, Deserialize, Serialize, PartialEq)] +#[derive(Debug, Eq, Deserialize, Serialize, PartialEq)] pub struct LocationNode { - #[serde(rename = "@line", skip_serializing_if = "Option::is_none")] - line: Option, #[serde(rename = "@filename", skip_serializing_if = "Option::is_none")] - filename: Option, + pub filename: Option, + #[serde(rename = "@line", skip_serializing_if = "Option::is_none")] + pub line: Option, } -#[derive(Debug, Deserialize, Serialize, PartialEq)] +#[derive(Debug, Eq, Deserialize, Serialize, PartialEq)] pub struct NumerusFormNode { #[serde(default, rename = "$value", skip_serializing_if = "String::is_empty")] text: String, @@ -108,23 +113,106 @@ pub struct NumerusFormNode { filename: Option, // "yes", "no" } +impl PartialOrd for MessageNode { + fn partial_cmp(&self, other: &Self) -> Option { + let min_self = self + .locations + .iter() + .min_by_key(|location| (location.filename.as_ref(), location.line)) + .map(|location| (location.filename.as_ref(), location.line.as_ref())) + .unwrap_or_default(); + + let min_other = other + .locations + .iter() + .min_by_key(|location| (location.filename.as_ref(), location.line)) + .map(|location| (location.filename.as_ref(), location.line.as_ref())) + .unwrap_or_default(); + + // Counterintuitive, but we want to have locationless message at the end: + // handle `None` differently from default. + if min_self.0 == None && min_other.0 != None { + Some(Ordering::Greater) + } else if min_self.0 == min_other.0 && min_self.1 == None && min_other.1 != None { + Some(Ordering::Greater) + } else { + min_self.partial_cmp(&min_other) + } + } +} + +impl Ord for MessageNode { + fn cmp(&self, other: &Self) -> Ordering { + self.partial_cmp(&other) + .expect("PartialOrd should always return a value for MessageNode") + } +} + +impl Ord for LocationNode { + fn cmp(&self, other: &Self) -> Ordering { + self.partial_cmp(&other) + .expect("PartialOrd should always return a value for LocationNode") + } +} + +impl PartialOrd for LocationNode { + fn partial_cmp(&self, other: &Self) -> Option { + match self + .filename + .as_ref() + .unwrap_or(&"".to_owned()) + .to_lowercase() + .partial_cmp( + &other + .filename + .as_ref() + .unwrap_or(&"".to_owned()) + .to_lowercase(), + ) + .expect("LocationNode::filename should have an ordering") + { + Ordering::Less => Some(Ordering::Less), + Ordering::Greater => Some(Ordering::Greater), + Ordering::Equal => self.line.partial_cmp(&other.line), + } + } +} + +impl PartialOrd for ContextNode { + fn partial_cmp(&self, other: &Self) -> Option { + // Contexts are generally module or classes names; let's assume they don't need any special collation treatment. + self.name + .as_ref() + .unwrap_or(&"".to_owned()) + .to_lowercase() + .partial_cmp(&other.name.as_ref().unwrap_or(&"".to_owned()).to_lowercase()) + } +} + +impl Ord for ContextNode { + fn cmp(&self, other: &Self) -> Ordering { + // Contexts are generally module or classes names; let's assume they don't need any special collation treatment. + self.name.cmp(&other.name) + } +} + #[cfg(test)] mod test { use super::*; use quick_xml; - + // TODO: Data set. https://github.com/qt/qttranslations/ #[test] fn parse_with_numerus_forms() { let f = quick_xml::Reader::from_file("example1.xml").expect("Couldn't open example1 test file"); let data: TSNode = quick_xml::de::from_reader(f.into_inner()).expect("Parsable"); - assert_eq!(data.contexts.as_ref().unwrap().len(), 2); + assert_eq!(data.contexts.len(), 2); assert_eq!(data.version.unwrap(), "2.1"); assert_eq!(data.source_language.unwrap(), "en"); assert_eq!(data.language.unwrap(), "sv"); - let context1 = &data.contexts.as_ref().unwrap()[0]; + let context1 = &data.contexts[0]; assert_eq!(context1.name.as_ref().unwrap(), "kernel/navigationpart"); assert_eq!(context1.messages.len(), 3); @@ -180,16 +268,16 @@ mod test { .expect("Couldn't open example1 test file"); let data: TSNode = quick_xml::de::from_reader(f.into_inner()).expect("Parsable"); - assert_eq!(data.contexts.as_ref().unwrap().len(), 1); + assert_eq!(data.contexts.len(), 1); assert_eq!(data.version.unwrap(), "1.1"); assert_eq!(data.source_language, None); assert_eq!(data.language.unwrap(), "de"); - let context1 = &data.contexts.as_ref().unwrap()[0]; + let context1 = &data.contexts[0]; assert_eq!(context1.name.as_ref().unwrap(), "tst_QKeySequence"); assert_eq!(context1.messages.len(), 11); let message_c1_2 = &context1.messages[2]; - let locations = message_c1_2.location.as_ref().unwrap(); + let locations = &message_c1_2.locations; assert_eq!(locations.len(), 2); assert_eq!( locations[0].filename.as_ref().unwrap(),