Skip to main content

ocelot_engine/
engine.rs

1use crate::discovered_test::DiscoveredTest;
2use crate::failed_test_result::FailedTestResult;
3use crate::test_run_summary::TestRunSummary;
4use ocelot_ast::item_kind::ItemKind;
5use ocelot_base::compilation_context::CompilationContext;
6use ocelot_base::compilation_stage::CompilationStage;
7use ocelot_base::error::OcelotError;
8use ocelot_base::file_path::FilePath;
9use ocelot_base::render_source_diagnostics::render_source_diagnostics;
10use ocelot_base::result::OcelotResult;
11use ocelot_base::result::OptionExt;
12use ocelot_base::result::ResultExt;
13use ocelot_base::source_file::SourceFile;
14use ocelot_pal::pal::PalHandle;
15
16#[derive(Debug, Clone)]
17pub struct Engine {
18    pal: PalHandle,
19}
20
21impl Engine {
22    pub fn new(pal: PalHandle) -> Self {
23        Self { pal }
24    }
25
26    pub fn run_script(&self, path: impl Into<FilePath>) -> OcelotResult<()> {
27        let source_file = self.load_source_file(path.into())?;
28        let script = self.parse_script(&source_file)?;
29        ocelot_interpreter::interpret_script::interpret_script(&script, &*self.pal)?;
30        Ok(())
31    }
32
33    pub fn discover_tests(&self, path: impl Into<FilePath>) -> OcelotResult<Vec<DiscoveredTest>> {
34        let source_file = self.load_source_file(path.into())?;
35        let script = self.parse_script(&source_file)?;
36
37        Ok(script
38            .items
39            .into_iter()
40            .filter_map(|item| match item.kind {
41                ItemKind::Test(test_item) => {
42                    Some(DiscoveredTest::new(test_item.name, test_item.span))
43                }
44                ItemKind::Statement(_) => None,
45            })
46            .collect())
47    }
48
49    pub fn run_test(&self, path: impl Into<FilePath>, test_name: &str) -> OcelotResult<()> {
50        let source_file = self.load_source_file(path.into())?;
51        let script = self.parse_script(&source_file)?;
52        let test_item = script
53            .items
54            .iter()
55            .find_map(|item| match &item.kind {
56                ItemKind::Test(test_item) if test_item.name == test_name => Some(test_item),
57                _ => None,
58            })
59            .context(format!("unknown test `{test_name}`"))?;
60
61        ocelot_interpreter::interpreter::Interpreter::new(&*self.pal)
62            .interpret_statements(&test_item.body)
63            .context(format!("test `{}` failed", test_item.name))?;
64
65        Ok(())
66    }
67
68    pub fn run_tests(&self, path: impl Into<FilePath>) -> OcelotResult<TestRunSummary> {
69        let source_file = self.load_source_file(path.into())?;
70        let script = self.parse_script(&source_file)?;
71        let interpreter = ocelot_interpreter::interpreter::Interpreter::new(&*self.pal);
72        let mut summary = TestRunSummary::new();
73
74        for item in &script.items {
75            let ItemKind::Test(test_item) = &item.kind else {
76                continue;
77            };
78
79            match interpreter.interpret_statements(&test_item.body) {
80                Ok(()) => summary.passed.push(test_item.name.clone()),
81                Err(error) => summary.failed.push(FailedTestResult::new(
82                    test_item.name.clone(),
83                    OcelotError::message(format!("test `{}` failed", test_item.name))
84                        .with_source(error)
85                        .to_test_string(),
86                )),
87            }
88        }
89
90        Ok(summary)
91    }
92
93    fn load_source_file(&self, path: FilePath) -> OcelotResult<SourceFile> {
94        let source = self.pal.read_file_to_string(&path)?;
95        Ok(SourceFile::new(path, source))
96    }
97
98    fn parse_script(&self, source_file: &SourceFile) -> OcelotResult<ocelot_ast::script::Script> {
99        let mut compilation_context = CompilationContext::default();
100        let script =
101            ocelot_parser::parse_script::parse_script(source_file, &mut compilation_context)?;
102
103        match script {
104            Some(script) => Ok(script),
105            None if compilation_context.has_errors() => Err(OcelotError::compilation_error(
106                CompilationStage::Parser,
107            )
108            .with_source(OcelotError::message(render_source_diagnostics(
109                &compilation_context.source_diagnostics.diagnostics,
110            )))),
111            None => Err(OcelotError::message(
112                "parser returned no script without reporting diagnostics",
113            )),
114        }
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::Engine;
121    use expect_test::expect;
122    use ocelot_base::compilation_stage::CompilationStage;
123    use ocelot_base::error::ErrorKind;
124    use ocelot_pal::pal::PalHandle;
125    use ocelot_pal::pal_mock::PalMock;
126
127    #[test]
128    fn run_script_reads_and_executes_a_file() {
129        let pal = PalMock::new();
130        pal.set_file("examples/hello_world.ocelot", "println(\"hello, world\");");
131
132        let engine = Engine::new(PalHandle::new(pal.clone()));
133
134        engine.run_script("examples/hello_world.ocelot").unwrap();
135
136        expect![[r#"
137            READ FILE: examples/hello_world.ocelot
138            PRINT: hello, world
139
140        "#]]
141        .assert_eq(&pal.get_effects());
142        assert_eq!(pal.take_printed_output(), "hello, world\n");
143    }
144
145    #[test]
146    fn run_script_ignores_test_items() {
147        let pal = PalMock::new();
148        pal.set_file(
149            "examples/mixed.ocelot",
150            "println(\"setup\"); test \"prints hello\" { println(\"hello\"); }",
151        );
152
153        let engine = Engine::new(PalHandle::new(pal.clone()));
154
155        engine.run_script("examples/mixed.ocelot").unwrap();
156
157        assert_eq!(pal.take_printed_output(), "setup\n");
158    }
159
160    #[test]
161    fn discover_tests_returns_names_in_source_order() {
162        let pal = PalMock::new();
163        pal.set_file(
164            "examples/tests.ocelot",
165            "test \"first\" { println(\"one\"); } test \"second\" { println(\"two\"); }",
166        );
167
168        let engine = Engine::new(PalHandle::new(pal));
169
170        let tests = engine.discover_tests("examples/tests.ocelot").unwrap();
171
172        assert_eq!(tests.len(), 2);
173        assert_eq!(tests[0].name, "first");
174        assert_eq!(tests[1].name, "second");
175    }
176
177    #[test]
178    fn run_test_executes_only_the_named_test() {
179        let pal = PalMock::new();
180        pal.set_file(
181            "examples/tests.ocelot",
182            "test \"first\" { println(\"one\"); } test \"second\" { println(\"two\"); }",
183        );
184
185        let engine = Engine::new(PalHandle::new(pal.clone()));
186
187        engine.run_test("examples/tests.ocelot", "second").unwrap();
188
189        assert_eq!(pal.take_printed_output(), "two\n");
190    }
191
192    #[test]
193    fn run_test_reports_the_failing_test_name() {
194        let pal = PalMock::new();
195        pal.set_file(
196            "examples/tests.ocelot",
197            "test \"broken\" { println(missing_value); }",
198        );
199
200        let engine = Engine::new(PalHandle::new(pal));
201
202        let error = engine
203            .run_test("examples/tests.ocelot", "broken")
204            .unwrap_err();
205
206        assert!(error.to_test_string().contains("test `broken` failed"));
207    }
208
209    #[test]
210    fn run_tests_reports_unknown_test_names() {
211        let pal = PalMock::new();
212        pal.set_file(
213            "examples/tests.ocelot",
214            "test \"first\" { println(\"one\"); }",
215        );
216
217        let engine = Engine::new(PalHandle::new(pal));
218
219        let error = engine
220            .run_test("examples/tests.ocelot", "missing")
221            .unwrap_err();
222
223        assert!(error.to_test_string().contains("unknown test `missing`"));
224    }
225
226    #[test]
227    fn run_script_tags_parser_failures_as_compilation_errors() {
228        let pal = PalMock::new();
229        pal.set_file("examples/broken.ocelot", "println();");
230
231        let engine = Engine::new(PalHandle::new(pal));
232
233        let error = engine.run_script("examples/broken.ocelot").unwrap_err();
234
235        assert!(matches!(
236            error.kind(),
237            ErrorKind::CompilationError(CompilationStage::Parser)
238        ));
239        assert!(
240            error
241                .to_test_string()
242                .contains("type error: `println` expects exactly one argument")
243        );
244    }
245
246    #[test]
247    fn run_tests_reports_passes_and_failures_for_all_tests() {
248        let pal = PalMock::new();
249        pal.set_file(
250            "examples/tests.ocelot",
251            "test \"first\" { println(\"one\"); } \
252             test \"broken\" { println(missing_value); } \
253             test \"third\" { println(\"three\"); }",
254        );
255
256        let engine = Engine::new(PalHandle::new(pal.clone()));
257
258        let summary = engine.run_tests("examples/tests.ocelot").unwrap();
259
260        assert_eq!(summary.passed, vec!["first", "third"]);
261        assert_eq!(summary.failed.len(), 1);
262        assert_eq!(summary.failed[0].name, "broken");
263        assert!(summary.failed[0].message.contains("test `broken` failed"));
264        assert!(
265            summary.failed[0]
266                .message
267                .contains("unresolved identifier `missing_value`")
268        );
269        assert_eq!(pal.take_printed_output(), "one\nthree\n");
270    }
271}