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
+
+
+
+
An example entry for Name
+
+
Dies ist nur ein Beispiel
+
+
+
+
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(),