oak_core/helpers/
building.rs

1//! Builder testing utilities for the Oak parsing framework.
2//!
3//! This module provides comprehensive testing infrastructure for builders,
4//! including file-based testing, expected output comparison, timeout handling,
5//! and test result serialization for typed root structures.
6
7use crate::{
8    Builder, Language,
9    errors::OakError,
10    helpers::{create_file, json_from_path, source_from_path},
11};
12use serde::{Deserialize, Serialize};
13use serde_json::{Serializer, ser::PrettyFormatter};
14use std::{
15    fmt::Debug,
16    path::{Path, PathBuf},
17    time::Duration,
18};
19use walkdir::WalkDir;
20
21/// A concurrent builder testing utility that can run tests against multiple files with timeout support.
22///
23/// The `BuilderTester` provides functionality to test builders against a directory
24/// of files with specific extensions, comparing actual output against expected
25/// results stored in JSON files, with configurable timeout protection.
26pub struct BuilderTester {
27    root: PathBuf,
28    extensions: Vec<String>,
29    timeout: Duration,
30}
31
32/// Expected builder test results for comparison.
33///
34/// This struct represents the expected output of a builder test, including
35/// success status, typed root structure, and any expected errors.
36#[derive(Debug, Serialize, Deserialize, PartialEq)]
37pub struct BuilderTestExpected {
38    success: bool,
39    typed_root: Option<TypedRootData>,
40    errors: Vec<String>,
41}
42
43/// Typed root data structure for builder testing.
44///
45/// Represents the typed root structure with its type name and serialized content
46/// used for testing builder output. Since TypedRoot can be any type, we serialize
47/// it as a generic structure for comparison.
48#[derive(Debug, Serialize, Deserialize, PartialEq)]
49pub struct TypedRootData {
50    type_name: String,
51    content: serde_json::Value,
52}
53
54impl BuilderTester {
55    /// Creates a new builder tester with the specified root directory and default 10-second timeout.
56    pub fn new<P: AsRef<Path>>(root: P) -> Self {
57        Self { root: root.as_ref().to_path_buf(), extensions: vec![], timeout: Duration::from_secs(10) }
58    }
59
60    /// Adds a file extension to test against.
61    pub fn with_extension(mut self, extension: impl ToString) -> Self {
62        self.extensions.push(extension.to_string());
63        self
64    }
65
66    /// Sets the timeout for building operations.
67    ///
68    /// # Arguments
69    ///
70    /// * `timeout` - The maximum duration to wait for building to complete
71    ///
72    /// # Returns
73    ///
74    /// A new `BuilderTester` with the specified timeout
75    pub fn with_timeout(mut self, timeout: Duration) -> Self {
76        self.timeout = timeout;
77        self
78    }
79
80    /// Run tests for the given builder against all files in the root directory with the specified extensions.
81    ///
82    /// # Arguments
83    ///
84    /// * `builder`: The builder to test.
85    ///
86    /// # Examples
87    ///
88    /// ```ignore
89    /// use oak_core::helpers::building::BuilderTester;
90    ///
91    /// let tester = BuilderTester::new("tests/builder").with_extension("valkyrie");
92    /// tester.run_tests(&my_builder)?;
93    /// ```
94    pub fn run_tests<L, B>(self, builder: &B) -> Result<(), OakError>
95    where
96        B: Builder<L> + Send + Sync,
97        L: Language + Send + Sync + 'static,
98        L::TypedRoot: Serialize + Debug + Sync + Send,
99    {
100        let test_files = self.find_test_files()?;
101        let force_regenerated = std::env::var("REGENERATE_TESTS").unwrap_or("0".to_string()) == "1";
102        let mut regenerated_any = false;
103
104        for file_path in test_files {
105            println!("Testing file: {}", file_path.display());
106            regenerated_any |= self.test_single_file::<L, B>(&file_path, builder, force_regenerated)?;
107        }
108
109        if regenerated_any && force_regenerated { Err(OakError::test_regenerated(self.root)) } else { Ok(()) }
110    }
111
112    fn find_test_files(&self) -> Result<Vec<PathBuf>, OakError> {
113        let mut files = Vec::new();
114
115        for entry in WalkDir::new(&self.root) {
116            let entry = entry.unwrap();
117            let path = entry.path();
118
119            if path.is_file() {
120                if let Some(ext) = path.extension() {
121                    let ext_str = ext.to_str().unwrap_or("");
122                    if self.extensions.iter().any(|e| e == ext_str) {
123                        // 忽略由 Tester 自身生成的输出文件,防止递归包含
124                        let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
125                        let is_output_file = file_name.ends_with(".parsed.json") || file_name.ends_with(".lexed.json") || file_name.ends_with(".built.json");
126
127                        if !is_output_file {
128                            files.push(path.to_path_buf());
129                        }
130                    }
131                }
132            }
133        }
134
135        Ok(files)
136    }
137
138    fn test_single_file<L, B>(&self, file_path: &Path, builder: &B, force_regenerated: bool) -> Result<bool, OakError>
139    where
140        B: Builder<L> + Send + Sync,
141        L: Language + Send + Sync + 'static,
142        L::TypedRoot: Serialize + Debug + Sync + Send,
143    {
144        let source = source_from_path(file_path)?;
145
146        // Perform build in a thread and construct test results, with main thread handling timeout control
147        use std::sync::mpsc;
148        let (tx, rx) = mpsc::channel();
149        let timeout = self.timeout;
150        let file_path_string = file_path.display().to_string();
151
152        std::thread::scope(|s| {
153            s.spawn(move || {
154                let mut cache = crate::parser::session::ParseSession::<L>::new(1024);
155                let build_out = builder.build(&source, &[], &mut cache);
156
157                // Build typed root structure if build succeeded
158                let (success, typed_root) = match &build_out.result {
159                    Ok(root) => {
160                        // Serialize the typed root to JSON for comparison
161                        match serde_json::to_value(root) {
162                            Ok(content) => {
163                                let typed_root_data = TypedRootData { type_name: std::any::type_name::<L::TypedRoot>().to_string(), content };
164                                (true, Some(typed_root_data))
165                            }
166                            Err(_) => {
167                                // If serialization fails, still mark as success but with no typed root data
168                                (true, None)
169                            }
170                        }
171                    }
172                    Err(_) => (false, None),
173                };
174
175                // Collect error messages
176                let mut error_messages: Vec<String> = build_out.diagnostics.iter().map(|e| e.to_string()).collect();
177                if let Err(e) = &build_out.result {
178                    error_messages.push(e.to_string());
179                }
180
181                let test_result = BuilderTestExpected { success, typed_root, errors: error_messages };
182
183                let _ = tx.send(Ok::<BuilderTestExpected, OakError>(test_result));
184            });
185
186            let mut regenerated = false;
187            match rx.recv_timeout(timeout) {
188                Ok(Ok(test_result)) => {
189                    let expected_file = file_path.with_extension(format!("{}.built.json", file_path.extension().unwrap_or_default().to_str().unwrap_or("")));
190
191                    if expected_file.exists() && !force_regenerated {
192                        let expected: BuilderTestExpected = json_from_path(&expected_file)?;
193
194                        if test_result != expected {
195                            println!("Test failed for file: {}", file_path.display());
196                            println!("Expected: {:#?}", expected);
197                            println!("Actual: {:#?}", test_result);
198                            return Err(OakError::custom_error("Test results do not match expected results"));
199                        }
200                    }
201                    else {
202                        let file = create_file(&expected_file)?;
203                        let mut writer = Serializer::with_formatter(file, PrettyFormatter::with_indent(b"    "));
204                        test_result.serialize(&mut writer)?;
205
206                        println!("Created expected result file: {}\nNeed rerun", expected_file.display());
207                        if force_regenerated {
208                            regenerated = true;
209                        }
210                        else {
211                            return Err(OakError::custom_error("Test expected result file was missing or regenerated"));
212                        }
213                    }
214                }
215                Ok(Err(e)) => return Err(e),
216                Err(mpsc::RecvTimeoutError::Timeout) => {
217                    return Err(OakError::custom_error(format!("Builder test timed out after {:?} for file: {}", timeout, file_path_string)));
218                }
219                Err(mpsc::RecvTimeoutError::Disconnected) => {
220                    return Err(OakError::custom_error("Builder thread disconnected unexpectedly"));
221                }
222            }
223            Ok(regenerated)
224        })
225    }
226}