work on output items
authorBen Pfaff <blp@cs.stanford.edu>
Sun, 12 Oct 2025 18:10:58 +0000 (11:10 -0700)
committerBen Pfaff <blp@cs.stanford.edu>
Sun, 12 Oct 2025 18:10:58 +0000 (11:10 -0700)
13 files changed:
rust/pspp/src/output.rs
rust/pspp/src/output/drivers/csv.rs
rust/pspp/src/output/drivers/html.rs
rust/pspp/src/output/drivers/spv.rs
rust/pspp/src/output/drivers/text.rs
rust/pspp/src/output/pivot/tests.rs
rust/pspp/src/output/spv.rs
rust/pspp/src/pc/tests.rs
rust/pspp/src/por/read.rs
rust/pspp/src/show.rs
rust/pspp/src/show_pc.rs
rust/pspp/src/show_por.rs
rust/pspp/src/sys/tests.rs

index 16cf4c5cca9cf6d7697be207848c1aa7a266ddac..5ac0a34b359544f8bca21ea3e438c34bc4bdcf4d 100644 (file)
@@ -60,7 +60,7 @@ pub struct Item {
     /// output.
     command_name: Option<String>,
 
-    /// For a group item, this is true if the group's subtree should
+    /// For a heading item, this is true if the heading's subtree should
     /// be expanded in an outline view, false otherwise.
     ///
     /// For other kinds of output items, this is true to show the item's
@@ -89,15 +89,15 @@ impl Item {
         }
     }
 
-    /// Returns a new group item suitable as the root node of an output document.
+    /// Returns a new heading item suitable as the root node of an output document.
     ///
-    /// A root node is a group whose own properties are mostly disregarded.
+    /// A root node is a heading whose own properties are mostly disregarded.
     /// Instead of having root nodes, it would make just as much sense to just
     /// keep around arrays of nodes that would serve as the top level of an
     /// output document, but we'd need more special cases instead of just using
-    /// the existing support for group items.
+    /// the existing support for heading items.
     pub fn new_root() -> Self {
-        Self::new(Details::Group(Heading(Vec::new()))).with_label(Some(String::from("Output")))
+        Self::new(Details::Heading(Heading(Vec::new()))).with_label(Some(String::from("Output")))
     }
 
     pub fn label(&self) -> Cow<'static, str> {
@@ -139,14 +139,47 @@ where
     }
 }
 
-#[derive(Clone, Debug, Serialize)]
+impl<A> FromIterator<A> for Item
+where
+    A: Into<Arc<Item>>,
+{
+    fn from_iter<T>(iter: T) -> Self
+    where
+        T: IntoIterator<Item = A>,
+    {
+        iter.into_iter().collect::<Details>().into_item()
+    }
+}
+
+impl PivotTable {
+    pub fn into_item(self) -> Item {
+        Details::Table(Box::new(self)).into_item()
+    }
+}
+
+#[derive(Clone, Debug, Default, Serialize)]
 pub struct Heading(pub Vec<Arc<Item>>);
 
+impl Heading {
+    pub fn new() -> Self {
+        Self::default()
+    }
+
+    pub fn with(mut self, item: impl Into<Arc<Item>>) -> Self {
+        self.0.push(item.into());
+        self
+    }
+
+    pub fn into_item(self) -> Item {
+        Details::Heading(self).into_item()
+    }
+}
+
 #[derive(Clone, Debug, Serialize)]
 pub enum Details {
     Chart,
     Image,
-    Group(Heading),
+    Heading(Heading),
     Message(Box<Diagnostic>),
     PageBreak,
     Table(Box<PivotTable>),
@@ -158,27 +191,18 @@ impl Details {
         Item::new(self)
     }
 
-    pub fn as_group(&self) -> Option<&[Arc<Item>]> {
-        match self {
-            Self::Group(heading) => Some(heading.0.as_slice()),
-            _ => None,
-        }
-    }
-
-    pub fn as_mut_group(&mut self) -> Option<&mut Vec<Arc<Item>>> {
+    pub fn children(&self) -> &[Arc<Item>] {
         match self {
-            Self::Group(heading) => Some(&mut heading.0),
-            _ => None,
+            Self::Heading(children) => children.0.as_slice(),
+            _ => &[],
         }
     }
 
-    pub fn children(&self) -> impl Iterator<Item = &Arc<Item>> {
+    pub fn mut_children(&mut self) -> Option<&mut Vec<Arc<Item>>> {
         match self {
-            Self::Group(children) => Some(children.0.iter()),
+            Self::Heading(heading) => Some(&mut heading.0),
             _ => None,
         }
-        .into_iter()
-        .flatten()
     }
 
     pub fn as_message(&self) -> Option<&Diagnostic> {
@@ -206,7 +230,7 @@ impl Details {
         match self {
             Details::Chart
             | Details::Image
-            | Details::Group(_)
+            | Details::Heading(_)
             | Details::Message(_)
             | Details::PageBreak
             | Details::Text(_) => None,
@@ -218,7 +242,7 @@ impl Details {
         match self {
             Details::Chart => todo!(),
             Details::Image => Cow::from("Image"),
-            Details::Group(_) => Cow::from("Group"),
+            Details::Heading(_) => Cow::from("Group"),
             Details::Message(diagnostic) => Cow::from(diagnostic.severity.as_title_str()),
             Details::PageBreak => Cow::from("Page Break"),
             Details::Table(pivot_table) => Cow::from(pivot_table.label()),
@@ -226,8 +250,8 @@ impl Details {
         }
     }
 
-    pub fn is_group(&self) -> bool {
-        matches!(self, Self::Group(_))
+    pub fn is_heading(&self) -> bool {
+        matches!(self, Self::Heading(_))
     }
 
     pub fn is_message(&self) -> bool {
@@ -255,7 +279,7 @@ where
     where
         T: IntoIterator<Item = A>,
     {
-        Self::Group(Heading(
+        Self::Heading(Heading(
             iter.into_iter().map(|value| value.into()).collect(),
         ))
     }
@@ -311,6 +335,10 @@ impl Text {
             content: value.into(),
         }
     }
+
+    pub fn into_item(self) -> Item {
+        Details::Text(Box::new(self)).into_item()
+    }
 }
 
 fn text_item_table_look() -> Arc<Look> {
@@ -401,16 +429,13 @@ impl ItemCursor {
         let Some(cur) = self.cur.take() else {
             return;
         };
-        if let Some(children) = cur.details.as_group()
-            && let Some(first_child) = children.first()
-        {
+        if let Some(first_child) = cur.details.children().first() {
             self.cur = Some(first_child.clone());
             self.stack.push((cur, 1));
         } else {
             while let Some((item, index)) = self.stack.pop() {
-                let children = item.details.as_group().unwrap();
-                if index < children.len() {
-                    self.cur = Some(children[index].clone());
+                if let Some(child) = item.details.children().get(index) {
+                    self.cur = Some(child.clone());
                     self.stack.push((item, index + 1));
                     return;
                 }
@@ -541,7 +566,7 @@ impl Item {
         match &self.details {
             Details::Chart => Class::Charts,
             Details::Image => Class::Other,
-            Details::Group(_) => Class::OutlineHeaders,
+            Details::Heading(_) => Class::OutlineHeaders,
             Details::Message(diagnostic) => match diagnostic.severity {
                 Severity::Note => Class::Notes,
                 Severity::Error | Severity::Warning => Class::Warnings,
@@ -735,7 +760,7 @@ impl Criteria {
     /// indirect) children of `item` that meet the criteria.
     fn apply(&self, item: Item) -> Item {
         fn take_children(item: &Item) -> Vec<&Item> {
-            item.details.children().map(|item| &**item).collect()
+            item.details.children().iter().map(|item| &**item).collect()
         }
         fn flatten_children<'a>(
             children: Vec<&'a Item>,
@@ -770,7 +795,7 @@ impl Criteria {
                     continue;
                 }
                 if let Some(visible) = selection.visible
-                    && !item.details.is_group()
+                    && !item.details.is_heading()
                     && visible != item.show
                 {
                     continue;
@@ -851,7 +876,7 @@ impl Criteria {
         }
         fn unflatten_item(mut item: Item, include: &mut bit_vec::Iter, out: &mut Vec<Arc<Item>>) {
             let include_item = include.next().unwrap();
-            if let Some(children) = item.details.as_mut_group() {
+            if let Some(children) = item.details.mut_children() {
                 if !include_item {
                     unflatten_items(take(children), include, out);
                     return;
@@ -880,7 +905,7 @@ impl Criteria {
         unflatten_item(
             item,
             &mut include.iter(),
-            output.details.as_mut_group().unwrap(),
+            output.details.mut_children().unwrap(),
         );
         output
     }
index 1a48eeaa30c29041ccad53bd9e0709ef89dc6664..e9bd5166b4f0d0bbe902e5c6973373f66f8ad1e0 100644 (file)
@@ -197,7 +197,7 @@ impl Driver for CsvDriver {
     fn write(&mut self, item: &Arc<Item>) {
         // todo: error handling (should not unwrap)
         match &item.details {
-            Details::Chart | Details::Image | Details::Group(_) => (),
+            Details::Chart | Details::Image | Details::Heading(_) => (),
             Details::Message(diagnostic) => {
                 self.start_item();
                 let text = diagnostic.to_string();
index 2d3492ef0e39c459cf8e01b54e537db842e938e1..f10f192d37c9ea65f3cf06a55484745afa7279fa 100644 (file)
@@ -429,7 +429,7 @@ where
         match &item.details {
             Details::Chart => todo!(),
             Details::Image => todo!(),
-            Details::Group(_) => todo!(),
+            Details::Heading(_) => todo!(),
             Details::Message(_diagnostic) => todo!(),
             Details::PageBreak => (),
             Details::Table(pivot_table) => {
index 107fed9c6c2e3a6398e7d72cf6fb5fc8a88ca1c4..97f3c2856827191d9c9d7320a6eaddaa66a29a75 100644 (file)
@@ -192,7 +192,7 @@ where
         match &item.details {
             Details::Chart => todo!(),
             Details::Image => todo!(),
-            Details::Group(children) => {
+            Details::Heading(children) => {
                 let mut attributes = Vec::<Attribute>::new();
                 if let Some(command_name) = &item.command_name {
                     attributes.push(("commandName", command_name.as_str()).into());
@@ -602,7 +602,7 @@ where
         self.next_heading_id += 1;
         self.writer
             .start_file(
-                output_viewer_name(heading_id, item.details.as_group().is_some()),
+                output_viewer_name(heading_id, item.details.is_heading()),
                 SimpleFileOptions::default(),
             )
             .unwrap(); // XXX
index e544a3782614fa70961f6adc341fb5a7ce5ccadb..a2c7be650aecad483dab004e84189e8a96473060 100644 (file)
@@ -384,7 +384,7 @@ impl TextRenderer {
         match &item.details {
             Details::Chart => todo!(),
             Details::Image => todo!(),
-            Details::Group(children) => {
+            Details::Heading(children) => {
                 for (index, child) in children.0.iter().enumerate() {
                     if index > 0 {
                         writeln!(writer)?;
index e6c22457538511dcc3ed32a963431b93caef2969..6f1c60b90b429605c3113250501f6dad6bb07d12 100644 (file)
@@ -19,7 +19,6 @@ use std::{fmt::Display, fs::File, path::Path, sync::Arc};
 use enum_map::EnumMap;
 
 use crate::output::{
-    Details,
     drivers::{
         Driver,
         cairo::{CairoConfig, CairoDriver},
@@ -174,13 +173,13 @@ pub fn assert_rendering(name: &str, pivot_table: &PivotTable, expected: &str) {
         format!("{name} actual"),
     );
 
-    let item = Arc::new(Details::Table(Box::new(pivot_table.clone())).into_item());
+    let item = Arc::new(pivot_table.clone().into_item());
     if let Some(dir) = std::env::var_os("PSPP_TEST_HTML_DIR") {
         let writer = File::create(Path::new(&dir).join(name).with_extension("html")).unwrap();
         HtmlDriver::for_writer(writer).write(&item);
     }
 
-    let item = Arc::new(Details::Table(Box::new(pivot_table.clone())).into_item());
+    let item = Arc::new(pivot_table.clone().into_item());
     if let Some(dir) = std::env::var_os("PSPP_TEST_PDF_DIR") {
         let config = CairoConfig::new(Path::new(&dir).join(name).with_extension("pdf"));
         CairoDriver::new(&config).unwrap().write(&item);
index 6dc1bdb3a732cb5d9481a2efa92e3ec630e9f23c..3125df475a18f99f31882f2c3120d1c556fb703f 100644 (file)
@@ -90,10 +90,7 @@ impl Item {
             }
         }
 
-        Ok((
-            items.into_iter().collect::<Details>().into_item(),
-            page_setup,
-        ))
+        Ok((items.into_iter().collect(), page_setup))
     }
 
     fn from_spv_reader<R>(reader: R) -> Result<(Self, Option<PageSetup>), Error>
@@ -171,7 +168,7 @@ impl Heading {
                         }
                         ContainerContent::Text(container_text) => {
                             items.push(
-                                Details::Text(Box::new(Text::new_log(container_text.decode())))
+                                Text::new_log(container_text.decode())
                                     .into_item()
                                     .with_command_name(container_text.command_name)
                                     .with_spv_info(SpvInfo::new(structure_member)),
@@ -185,8 +182,7 @@ impl Heading {
                         heading
                             .decode(archive, structure_member)?
                             .into_iter()
-                            .collect::<Details>()
-                            .into_item()
+                            .collect::<Item>()
                             .with_show(show)
                             .with_spv_info(SpvInfo::new(structure_member)),
                     );
@@ -284,12 +280,10 @@ impl Table {
             let table = LightTable::read(&mut Cursor::new(data))?;
             let pivot_table = table.decode()?;
             println!("{}", &pivot_table);
-            Ok(Details::Table(Box::new(pivot_table))
-                .into_item()
-                .with_spv_info(
-                    SpvInfo::new(structure_member)
-                        .with_members(SpvMembers::Light(self.table_structure.data_path.clone())),
-                ))
+            Ok(pivot_table.into_item().with_spv_info(
+                SpvInfo::new(structure_member)
+                    .with_members(SpvMembers::Light(self.table_structure.data_path.clone())),
+            ))
         } else {
             todo!()
         }
index b629bf179976d302c062bc936945691f1e247947..1a573f448dc96ec0d85585246ba18f61cec22e55 100644 (file)
@@ -5,7 +5,7 @@ use itertools::Itertools;
 use crate::{
     data::cases_to_output,
     output::{
-        Details, Item, Text,
+        Item, Text,
         pivot::{PivotTable, tests::assert_lines_eq},
     },
     pc::PcFile,
@@ -30,9 +30,9 @@ fn test_pcfile(name: &str) {
             output.push(PivotTable::from(&metadata).into());
             output.extend(dictionary.all_pivot_tables().into_iter().map_into());
             output.extend(cases_to_output(&dictionary, cases));
-            output.into_iter().collect::<Details>().into_item()
+            output.into_iter().collect()
         }
-        Err(error) => Details::Text(Box::new(Text::new_log(error.to_string()))).into_item(),
+        Err(error) => Text::new_log(error.to_string()).into_item(),
     };
 
     let actual = output.to_string();
index 65184ca16222db523471830df358ab2ba79097b8..1bb4c809946154e16dd928d153f1aa1e21e6a2b4 100644 (file)
@@ -1158,7 +1158,7 @@ mod tests {
     use crate::{
         data::cases_to_output,
         output::{
-            Details, Item, Text,
+            Item, Text,
             pivot::{PivotTable, tests::assert_lines_eq},
         },
         por::{PortableFile, ReadPad},
@@ -1196,9 +1196,9 @@ mod tests {
                 output.push(PivotTable::from(&metadata).into());
                 output.extend(dictionary.all_pivot_tables().into_iter().map_into());
                 output.extend(cases_to_output(&dictionary, cases));
-                output.into_iter().collect::<Details>().into_item()
+                output.into_iter().collect()
             }
-            Err(error) => Details::Text(Box::new(Text::new_log(error.to_string()))).into_item(),
+            Err(error) => Text::new_log(error.to_string()).into_item(),
         };
 
         let actual = output.to_string();
index f8d10d7829e1932f3fe51cdd73aa6cca8d0dbd67..c83cddde787ae3f96684f55783ffe13f0bc31268 100644 (file)
@@ -285,9 +285,9 @@ impl Show {
                         output.push(PivotTable::from(&metadata).into());
                         output.extend(dictionary.all_pivot_tables().into_iter().map_into());
                         output.extend(cases_to_output(&dictionary, cases));
-                        driver.borrow_mut().write(&Arc::new(
-                            output.into_iter().collect::<Details>().into_item(),
-                        ));
+                        driver
+                            .borrow_mut()
+                            .write(&Arc::new(output.into_iter().collect()));
                     }
                     Output::Json { .. } => {
                         output.show_json(&dictionary)?;
index baa5cc730b89ac8dd5cf1233bd6a0eb133ae75e2..19dded7002d3a45f5b092dffc937014c1cc31bb3 100644 (file)
@@ -20,7 +20,7 @@ use itertools::Itertools;
 use pspp::{
     data::cases_to_output,
     output::{
-        Details, Item, Text,
+        Item, Text,
         drivers::{Config, Driver},
         pivot::PivotTable,
     },
@@ -219,9 +219,9 @@ impl ShowPc {
                         let mut output = Vec::new();
                         output.extend(dictionary.all_pivot_tables().into_iter().map_into());
                         output.extend(cases_to_output(&dictionary, cases));
-                        driver.borrow_mut().write(&Arc::new(
-                            output.into_iter().collect::<Details>().into_item(),
-                        ));
+                        driver
+                            .borrow_mut()
+                            .write(&Arc::new(output.into_iter().collect()));
                     }
                     Output::Json { .. } => {
                         output.show_json(&dictionary)?;
index 9b474b639ac8b9af3f537addd60d74ce566cc922..bac7c42884c57f5d19a0d52d34e050938faa4c71 100644 (file)
@@ -20,7 +20,7 @@ use itertools::Itertools;
 use pspp::{
     data::cases_to_output,
     output::{
-        Details, Item, Text,
+        Item, Text,
         drivers::{Config, Driver},
         pivot::PivotTable,
     },
@@ -219,9 +219,9 @@ impl ShowPor {
                         let mut output = Vec::new();
                         output.extend(dictionary.all_pivot_tables().into_iter().map_into());
                         output.extend(cases_to_output(&dictionary, cases));
-                        driver.borrow_mut().write(&Arc::new(
-                            output.into_iter().collect::<Details>().into_item(),
-                        ));
+                        driver
+                            .borrow_mut()
+                            .write(&Arc::new(output.into_iter().collect()));
                     }
                     Output::Json { .. } => {
                         output.show_json(&dictionary)?;
index 292dfe73dbe6d2bc5d31eec46dd1e8701a5daa4f..d394c0a9f1290eaad61b3faa584e6c9b78392588 100644 (file)
@@ -30,7 +30,7 @@ use crate::{
     dictionary::Dictionary,
     identifier::Identifier,
     output::{
-        Details, Item, Text,
+        Item, Text,
         pivot::{Axis3, Dimension, Group, PivotTable, Value, tests::assert_lines_eq},
     },
     sys::{
@@ -808,9 +808,9 @@ where
                 }
                 output.push(pt.into());
             }
-            output.into_iter().collect::<Details>().into_item()
+            output.into_iter().collect()
         }
-        Err(error) => Details::Text(Box::new(Text::new_log(error.to_string()))).into_item(),
+        Err(error) => Text::new_log(error.to_string()).into_item(),
     };
 
     let actual = output.to_string();