tmc_langs_r/
plugin.rs

1//! Contains the LanguagePlugin implementation for R.
2
3use crate::{RStudentFilePolicy, error::RError, r_run_result::RRunResult};
4use std::{
5    collections::HashMap,
6    fs,
7    io::{Read, Seek},
8    ops::ControlFlow::{Break, Continue},
9    path::{Path, PathBuf},
10    time::Duration,
11};
12use tmc_langs_framework::{
13    Archive, ExerciseDesc, LanguagePlugin, RunResult, TestDesc, TmcCommand, TmcError,
14    nom::{IResult, Parser, branch, bytes, character, sequence},
15    nom_language::error::VerboseError,
16};
17use tmc_langs_util::{deserialize, file_util, parse_util, path_util};
18
19#[derive(Default)]
20pub struct RPlugin {}
21
22impl RPlugin {
23    pub fn new() -> Self {
24        Self {}
25    }
26}
27
28/// Project directory:
29/// Contains an R directory.
30impl LanguagePlugin for RPlugin {
31    const PLUGIN_NAME: &'static str = "r";
32    const DEFAULT_SANDBOX_IMAGE: &'static str = "eu.gcr.io/moocfi-public/tmc-sandbox-r:latest";
33    const LINE_COMMENT: &'static str = "#";
34    const BLOCK_COMMENT: Option<(&'static str, &'static str)> = None;
35    type StudentFilePolicy = RStudentFilePolicy;
36
37    fn scan_exercise(&self, path: &Path, exercise_name: String) -> Result<ExerciseDesc, TmcError> {
38        // run available points command
39        let args = if cfg!(windows) {
40            &["-e", "\"library('tmcRtestrunner');run_available_points()\""]
41        } else {
42            &["-e", "library(tmcRtestrunner);run_available_points()"]
43        };
44        let _output = TmcCommand::piped("Rscript")
45            .with(|e| e.cwd(path).args(args))
46            .output_checked()?;
47
48        // parse exercise desc
49        let points_path = path.join(".available_points.json");
50        let json_file = file_util::open_file(&points_path)?;
51        let test_descs: HashMap<String, Vec<String>> = deserialize::json_from_reader(json_file)
52            .map_err(|e| RError::JsonDeserialize(points_path, e))?;
53        let test_descs = test_descs
54            .into_iter()
55            .map(|(k, v)| TestDesc { name: k, points: v })
56            .collect();
57
58        Ok(ExerciseDesc {
59            name: exercise_name,
60            tests: test_descs,
61        })
62    }
63
64    fn run_tests_with_timeout(
65        &self,
66        path: &Path,
67        timeout: Option<Duration>,
68    ) -> Result<RunResult, TmcError> {
69        // delete results json
70        let results_path = path.join(".results.json");
71        if results_path.exists() {
72            file_util::remove_file(&results_path)?;
73        }
74
75        // run test command
76        let args = if cfg!(windows) {
77            &["-e", "\"library('tmcRtestrunner');run_tests()\""]
78        } else {
79            &["-e", "library(tmcRtestrunner);run_tests()"]
80        };
81
82        let output = if let Some(timeout) = timeout {
83            TmcCommand::piped("Rscript")
84                .with(|e| e.cwd(path).args(args))
85                .output_with_timeout_checked(timeout)?
86        } else {
87            TmcCommand::piped("Rscript")
88                .with(|e| e.cwd(path).args(args))
89                .output_checked()?
90        };
91        let stdout = String::from_utf8_lossy(&output.stdout);
92        let stderr = String::from_utf8_lossy(&output.stderr);
93        log::trace!("stdout: {stdout}");
94        log::debug!("stderr: {stderr}");
95
96        // parse test result
97        if !results_path.exists() {
98            return Err(RError::MissingTestResults {
99                path: results_path,
100                stdout: stdout.into_owned(),
101                stderr: stderr.into_owned(),
102            }
103            .into());
104        }
105        let json_file = file_util::open_file(&results_path)?;
106        let run_result: RRunResult = deserialize::json_from_reader(json_file).map_err(|e| {
107            if let Ok(s) = fs::read_to_string(&results_path) {
108                log::error!("Failed to deserialize json {s}");
109            }
110            RError::JsonDeserialize(results_path.clone(), e)
111        })?;
112        file_util::remove_file(&results_path)?;
113
114        Ok(run_result.into())
115    }
116
117    /// Checks if the directory contains R or tests/testthat
118    fn is_exercise_type_correct(path: &Path) -> bool {
119        path.join("R").exists() || path.join("tests/testthat").exists()
120    }
121
122    fn find_project_dir_in_archive<R: Read + Seek>(
123        archive: &mut Archive<R>,
124    ) -> Result<PathBuf, TmcError> {
125        let mut iter = archive.iter()?;
126        let project_dir = loop {
127            let next = iter.with_next(|file| {
128                let file_path = file.path()?;
129
130                if let Some(parent) = path_util::get_parent_of_component_in_path(&file_path, "R") {
131                    return Ok(Break(Some(parent)));
132                }
133                if let Some(parent) =
134                    path_util::get_parent_of_component_in_path(&file_path, "testthat")
135                {
136                    if let Some(parent) = parent.parent() {
137                        if parent.ends_with("tests") {
138                            return Ok(Break(Some(parent.to_path_buf())));
139                        }
140                    }
141                }
142                Ok(Continue(()))
143            });
144            match next? {
145                Continue(_) => continue,
146                Break(project_dir) => break project_dir,
147            }
148        };
149
150        match project_dir {
151            Some(project_dir) => Ok(project_dir),
152            None => Err(TmcError::NoProjectDirInArchive),
153        }
154    }
155
156    /// No operation for now. To be possibly implemented later: remove .Rdata, .Rhistory etc
157    fn clean(&self, _path: &Path) -> Result<(), TmcError> {
158        Ok(())
159    }
160
161    fn get_default_student_file_paths() -> Vec<PathBuf> {
162        vec![PathBuf::from("R")]
163    }
164
165    fn get_default_exercise_file_paths() -> Vec<PathBuf> {
166        vec![PathBuf::from("tests")]
167    }
168
169    fn points_parser(i: &str) -> IResult<&str, Vec<&str>, VerboseError<&str>> {
170        let test_parser = sequence::preceded(
171            (
172                bytes::complete::tag("test"),
173                character::complete::multispace0,
174                character::complete::char('('),
175                character::complete::multispace0,
176                parse_util::string, // parses the first argument which should be a string
177                character::complete::multispace0,
178                character::complete::char(','),
179                character::complete::multispace0,
180            ),
181            list_parser,
182        );
183        let points_for_all_tests_parser = sequence::preceded(
184            (
185                bytes::complete::tag("points_for_all_tests"),
186                character::complete::multispace0,
187                character::complete::char('('),
188                character::complete::multispace0,
189            ),
190            list_parser,
191        );
192
193        fn list_parser(i: &str) -> IResult<&str, Vec<&str>, VerboseError<&str>> {
194            sequence::delimited(
195                (
196                    character::complete::char('c'),
197                    character::complete::multispace0,
198                    character::complete::char('('),
199                    character::complete::multispace0,
200                ),
201                parse_util::comma_separated_strings,
202                (
203                    character::complete::multispace0,
204                    character::complete::char(')'),
205                ),
206            )
207            .parse(i)
208        }
209
210        branch::alt((test_parser, points_for_all_tests_parser)).parse(i)
211    }
212}
213
214#[cfg(test)]
215#[cfg(target_os = "linux")] // tmc-r-testrunner not installed on other CI platforms
216#[allow(clippy::unwrap_used)]
217mod test {
218    use super::*;
219    use std::path::PathBuf;
220    use tmc_langs_framework::RunStatus;
221    use zip::write::SimpleFileOptions;
222
223    fn init() {
224        use log::*;
225        use simple_logger::*;
226        let _ = SimpleLogger::new().with_level(LevelFilter::Debug).init();
227    }
228
229    fn file_to(
230        target_dir: impl AsRef<std::path::Path>,
231        target_relative: impl AsRef<std::path::Path>,
232        contents: impl AsRef<[u8]>,
233    ) -> PathBuf {
234        let target = target_dir.as_ref().join(target_relative);
235        if let Some(parent) = target.parent() {
236            std::fs::create_dir_all(parent).unwrap();
237        }
238        std::fs::write(&target, contents.as_ref()).unwrap();
239        target
240    }
241
242    fn dir_to_zip(source_dir: impl AsRef<std::path::Path>) -> Vec<u8> {
243        use std::io::Write;
244
245        let mut target = vec![];
246        let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut target));
247
248        for entry in walkdir::WalkDir::new(&source_dir)
249            .min_depth(1)
250            .sort_by(|a, b| a.path().cmp(b.path()))
251        {
252            let entry = entry.unwrap();
253            let rela = entry
254                .path()
255                .strip_prefix(&source_dir)
256                .unwrap()
257                .to_str()
258                .unwrap();
259            if entry.path().is_dir() {
260                zip.add_directory(rela, SimpleFileOptions::default())
261                    .unwrap();
262            } else if entry.path().is_file() {
263                zip.start_file(rela, SimpleFileOptions::default()).unwrap();
264                let bytes = std::fs::read(entry.path()).unwrap();
265                zip.write_all(&bytes).unwrap();
266            }
267        }
268
269        zip.finish().unwrap();
270        target
271    }
272
273    #[test]
274    fn scan_exercise() {
275        init();
276
277        let temp_dir = tempfile::tempdir().unwrap();
278        file_to(
279            &temp_dir,
280            "tests/testthat/test1.R",
281            r#"
282library("testthat")
283points_for_all_tests(c("r1"))
284test("sample1", c("r1.1"), {
285    expect_true(TRUE)
286})
287test("sample2", c("r1.2", "r1.3"), {
288    expect_true(TRUE)
289})
290"#,
291        );
292        file_to(
293            &temp_dir,
294            "tests/testthat/test2.R",
295            r#"
296library("testthat")
297points_for_all_tests(c("r2"))
298test("sample3", c("r2.1"), {
299    expect_true(TRUE)
300})
301"#,
302        );
303
304        let plugin = RPlugin::new();
305        let desc = plugin
306            .scan_exercise(temp_dir.path(), "ex".to_string())
307            .unwrap();
308        assert_eq!(desc.name, "ex");
309        assert_eq!(desc.tests.len(), 3);
310        for test in desc.tests {
311            match test.name.as_str() {
312                "sample1" => assert_eq!(test.points, &["r1", "r1.1"]),
313                "sample2" => assert_eq!(test.points, &["r1", "r1.2", "r1.3"]),
314                "sample3" => assert_eq!(test.points, &["r2", "r2.1"]),
315                _ => panic!(),
316            }
317        }
318    }
319
320    #[test]
321    fn run_tests_success() {
322        init();
323
324        let temp_dir = tempfile::tempdir().unwrap();
325        file_to(&temp_dir, "R/thing.R", "");
326        file_to(
327            &temp_dir,
328            "tests/testthat/testThing.R",
329            r#"
330library("testthat")
331points_for_all_tests(c("r1"))
332test("sample", c("r1.1"), {
333    expect_true(TRUE)
334})
335"#,
336        );
337
338        let plugin = RPlugin::new();
339        let run = plugin.run_tests(temp_dir.path()).unwrap();
340        assert_eq!(run.status, RunStatus::Passed);
341        assert!(run.logs.is_empty());
342        assert_eq!(run.test_results.len(), 1);
343        let res = &run.test_results[0];
344        assert!(res.successful);
345        assert_eq!(res.points, &["r1", "r1.1"]);
346        assert!(res.message.is_empty());
347        assert!(res.exception.is_empty());
348    }
349
350    #[test]
351    fn run_tests_failed() {
352        init();
353
354        let temp_dir = tempfile::tempdir().unwrap();
355        file_to(&temp_dir, "R/thing.R", "");
356        file_to(
357            &temp_dir,
358            "tests/testthat/testThing.R",
359            r#"
360library("testthat")
361points_for_all_tests(c("r1"))
362test("sample", c("r1.1"), {
363    expect_true(FALSE)
364})
365"#,
366        );
367
368        let plugin = RPlugin::new();
369        let run = plugin.run_tests(temp_dir.path()).unwrap();
370        assert_eq!(run.status, RunStatus::TestsFailed);
371        assert!(run.logs.is_empty());
372        assert_eq!(run.test_results.len(), 1);
373        let res = &run.test_results[0];
374        log::debug!("{res:#?}");
375        assert!(!res.successful);
376        assert_eq!(res.points, &["r1", "r1.1"]);
377        // assert_eq!(res.message, "FALSE is not TRUE"); // output changed on CI for some reason... TODO: fix
378        assert!(res.exception.is_empty());
379    }
380
381    #[test]
382    fn run_tests_run_failed() {
383        init();
384
385        let temp_dir = tempfile::tempdir().unwrap();
386        file_to(&temp_dir, "R/thing.R", "");
387        file_to(
388            &temp_dir,
389            "tests/testthat/testThing.R",
390            r#"
391library('testthat')
392points_for_all_tests(c("r1"))
393test("sample", c("r1.1"), {
394    expect_true(unexpected)
395})
396"#,
397        );
398
399        let plugin = RPlugin::new();
400        let run = plugin.run_tests(temp_dir.path()).unwrap();
401        assert_eq!(run.status, RunStatus::TestsFailed);
402        assert!(run.logs.is_empty());
403        assert_eq!(run.test_results.len(), 1);
404        let res = &run.test_results[0];
405        log::debug!("{res:#?}");
406        assert!(!res.successful);
407        assert_eq!(res.points, &["r1", "r1.1"]);
408        assert!(res.message.contains("object 'unexpected' not found"));
409        assert!(res.exception.is_empty());
410    }
411
412    #[test]
413    fn run_tests_timeout() {
414        init();
415
416        let temp_dir = tempfile::tempdir().unwrap();
417        file_to(&temp_dir, "R/main.R", r#"invalid R file"#);
418        file_to(&temp_dir, "tests/testthat/test.R", "");
419
420        let plugin = RPlugin::new();
421        let run = plugin
422            .run_tests_with_timeout(temp_dir.path(), Some(std::time::Duration::from_nanos(1)))
423            .unwrap_err();
424        use std::error::Error;
425        let mut source = run.source();
426        while let Some(inner) = source {
427            source = inner.source();
428            if let Some(cmd_error) = inner.downcast_ref::<tmc_langs_framework::CommandError>() {
429                if matches!(cmd_error, tmc_langs_framework::CommandError::TimeOut { .. }) {
430                    return;
431                }
432            }
433        }
434        panic!("did not time out")
435    }
436
437    #[test]
438    fn finds_project_dir_in_zip() {
439        init();
440
441        let temp_dir = tempfile::tempdir().unwrap();
442        file_to(&temp_dir, "Outer/Inner/r_project/R/main.R", "");
443
444        let bytes = dir_to_zip(&temp_dir);
445        let mut zip = Archive::zip(std::io::Cursor::new(bytes)).unwrap();
446        let dir = RPlugin::find_project_dir_in_archive(&mut zip).unwrap();
447        assert_eq!(dir, Path::new("Outer/Inner/r_project"));
448    }
449
450    #[test]
451    fn doesnt_find_project_dir_in_zip() {
452        init();
453
454        let temp_dir = tempfile::tempdir().unwrap();
455        file_to(&temp_dir, "Outer/Inner/r_project/RR/main.R", "");
456
457        let bytes = dir_to_zip(&temp_dir);
458        let mut zip = Archive::zip(std::io::Cursor::new(bytes)).unwrap();
459        let res = RPlugin::find_project_dir_in_archive(&mut zip);
460        assert!(res.is_err());
461    }
462
463    #[test]
464    fn parses_points() {
465        init();
466
467        let target = "asd";
468        assert!(RPlugin::points_parser(target).is_err());
469
470        let target = "test ( \"first arg\", \"second arg but no brace\"";
471        assert!(RPlugin::points_parser(target).is_err());
472
473        let target = r#"test("1d and 1e are solved correctly", c("W1A.1.2"), {
474  expect_equivalent(z, z_correct, tolerance=1e-5)
475  expect_true(areEqual(res, res_correct))
476})
477"#;
478        assert_eq!(RPlugin::points_parser(target).unwrap().1[0], "W1A.1.2");
479
480        let target = r#"test  (  "1d and 1e are solved correctly", c  (  "  W1A.1.2  "  )  , {
481  expect_equivalent(z, z_correct, tolerance=1e-5)
482  expect_true(areEqual(res, res_correct))
483})
484"#;
485        assert_eq!(RPlugin::points_parser(target).unwrap().1[0], "W1A.1.2");
486    }
487
488    #[test]
489    fn parsing_regression_test() {
490        init();
491
492        let temp = tempfile::tempdir().unwrap();
493        // a file like this used to cause an error before for some reason...
494        file_to(
495            &temp,
496            "tests/testthat/testExercise.R",
497            r#"library('testthat')
498"#,
499        );
500
501        let _points = RPlugin::get_available_points(temp.path()).unwrap();
502    }
503
504    #[test]
505    fn parses_points_for_all_tests() {
506        init();
507
508        let temp = tempfile::tempdir().unwrap();
509        file_to(
510            &temp,
511            "tests/testthat/testExercise.R",
512            r#"
513something
514points_for_all_tests(c("r1"))
515etc
516"#,
517        );
518
519        let points = RPlugin::get_available_points(temp.path()).unwrap();
520        assert_eq!(points, &["r1"]);
521    }
522
523    #[test]
524    fn parses_multiple_points() {
525        init();
526
527        let temp = tempfile::tempdir().unwrap();
528        file_to(
529            &temp,
530            "tests/testthat/testExercise.R",
531            r#"
532something
533test("some test", c("r1", "r2", "r3", "r4 r5"))
534etc
535"#,
536        );
537
538        let points = RPlugin::get_available_points(temp.path()).unwrap();
539        assert_eq!(points, &["r1", "r2", "r3", "r4", "r5"]);
540    }
541
542    #[test]
543    fn parses_first_arg_with_comma_regression() {
544        init();
545
546        let temp = tempfile::tempdir().unwrap();
547        file_to(
548            &temp,
549            "tests/testthat/testExercise.R",
550            r#"
551something
552test("some test, with a comma", c("r1", "r2", "r3"))
553etc
554"#,
555        );
556
557        let points = RPlugin::get_available_points(temp.path()).unwrap();
558        assert_eq!(points, &["r1", "r2", "r3"]);
559    }
560}