1use 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
28impl 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 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 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 let results_path = path.join(".results.json");
71 if results_path.exists() {
72 file_util::remove_file(&results_path)?;
73 }
74
75 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 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 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 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, 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")] #[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!(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 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}