1use crate::{
4 check_log::CheckLog, error::MakeError, policy::MakeStudentFilePolicy, valgrind_log::ValgrindLog,
5};
6use once_cell::sync::Lazy;
7use regex::Regex;
8use std::{
9 collections::HashMap,
10 io::{self, BufRead, BufReader, Read, Seek},
11 ops::ControlFlow::{Break, Continue},
12 path::{Path, PathBuf},
13 time::Duration,
14};
15use tmc_langs_framework::{
16 Archive, CommandError, ExerciseDesc, LanguagePlugin, Output, PopenError, RunResult, RunStatus,
17 TestDesc, TmcCommand, TmcError, TmcProjectYml,
18 nom::{IResult, Parser, bytes, character, combinator, sequence},
19 nom_language::error::VerboseError,
20};
21use tmc_langs_util::{FileError, file_util, path_util};
22
23#[derive(Default)]
24pub struct MakePlugin {}
25
26impl MakePlugin {
27 pub fn new() -> Self {
28 Self {}
29 }
30
31 fn parse_available_points(&self, available_points: &Path) -> Result<Vec<TestDesc>, MakeError> {
34 #[allow(clippy::unwrap_used)]
37 static RE: Lazy<Regex> = Lazy::new(|| {
38 Regex::new(r#"\[(?P<type>.*)\] \[(?P<name>.*)\] (?P<points>.*)"#).unwrap()
39 });
40
41 let mut tests = vec![];
42
43 let file = file_util::open_file(available_points)?;
44
45 let reader = BufReader::new(file);
46 for line in reader.lines() {
47 let line = line.map_err(|e| FileError::FileRead(available_points.to_path_buf(), e))?;
48
49 if let Some(captures) = RE.captures(&line) {
50 if &captures["type"] == "test" {
51 let name = captures["name"].to_string();
52 let points = captures["points"]
53 .split_whitespace()
54 .map(str::to_string)
55 .collect();
56 tests.push(TestDesc { name, points });
57 }
58 }
59 }
60 Ok(tests)
61 }
62
63 fn run_tests_with_valgrind(
67 &self,
68 path: &Path,
69 run_valgrind: bool,
70 ) -> Result<Output, MakeError> {
71 let arg = if run_valgrind {
72 "run-test-with-valgrind"
73 } else {
74 "run-test"
75 };
76 log::info!("Running make {arg}");
77
78 let output = TmcCommand::piped("make")
79 .with(|e| e.cwd(path).arg(arg))
80 .output()?;
81
82 log::trace!("stdout: {}", String::from_utf8_lossy(&output.stdout));
83 let stderr = String::from_utf8_lossy(&output.stderr);
84 log::debug!("stderr: {stderr}");
85
86 if !output.status.success() {
87 if run_valgrind {
88 return Err(MakeError::RunningTestsWithValgrind(
89 output.status,
90 stderr.into_owned(),
91 ));
92 } else {
93 return Err(MakeError::RunningTests(output.status, stderr.into_owned()));
94 }
95 }
96
97 Ok(output)
98 }
99
100 fn build(&self, dir: &Path) -> Result<Output, MakeError> {
103 log::debug!("building {}", dir.display());
104 let output = TmcCommand::piped("make")
105 .with(|e| e.cwd(dir).arg("test"))
106 .output()?;
107
108 log::trace!("stdout:\n{}", String::from_utf8_lossy(&output.stdout));
109 log::debug!("stderr:\n{}", String::from_utf8_lossy(&output.stderr));
110
111 Ok(output)
112 }
113}
114
115impl LanguagePlugin for MakePlugin {
118 const PLUGIN_NAME: &'static str = "make";
119 const DEFAULT_SANDBOX_IMAGE: &'static str = "eu.gcr.io/moocfi-public/tmc-sandbox-make:latest";
120 const LINE_COMMENT: &'static str = "//";
121 const BLOCK_COMMENT: Option<(&'static str, &'static str)> = Some(("/*", "*/"));
122 type StudentFilePolicy = MakeStudentFilePolicy;
123
124 fn scan_exercise(&self, path: &Path, exercise_name: String) -> Result<ExerciseDesc, TmcError> {
125 if !Self::is_exercise_type_correct(path) {
126 return MakeError::NoExerciseFound(path.to_path_buf()).into();
127 }
128
129 self.run_tests_with_valgrind(path, false)?;
130
131 let available_points_path = path.join("test/tmc_available_points.txt");
132
133 if !available_points_path.exists() {
134 return MakeError::CantFindAvailablePoints(available_points_path).into();
135 }
136
137 let tests = self.parse_available_points(&available_points_path)?;
138 Ok(ExerciseDesc {
139 name: exercise_name,
140 tests,
141 })
142 }
143
144 fn run_tests_with_timeout(
145 &self,
146 path: &Path,
147 _timeout: Option<Duration>,
148 ) -> Result<RunResult, TmcError> {
149 let output = self.build(path)?;
150 if !output.status.success() {
151 let mut logs = HashMap::new();
152 logs.insert(
153 "stdout".to_string(),
154 String::from_utf8_lossy(&output.stdout).into_owned(),
155 );
156 logs.insert(
157 "stderr".to_string(),
158 String::from_utf8_lossy(&output.stderr).into_owned(),
159 );
160 return Ok(RunResult {
161 status: RunStatus::CompileFailed,
162 test_results: vec![],
163 logs,
164 });
165 }
166
167 let base_test_path = path.join("test");
169 let valgrind_log_path = base_test_path.join("valgrind.log");
170 let _ = file_util::remove_file(&valgrind_log_path);
171
172 let mut ran_valgrind = true;
174 let valgrind_run = self.run_tests_with_valgrind(path, true);
175 let output = match valgrind_run {
176 Ok(output) => output,
177 Err(error) => {
178 if let Ok(valgrind_log) = file_util::read_file_to_string_lossy(&valgrind_log_path) {
179 log::warn!("Failed to run valgrind but a valgrind.log exists: {valgrind_log}");
180 }
181 match error {
182 MakeError::Tmc(TmcError::Command(command_error)) => {
183 match command_error {
184 CommandError::Popen(_, PopenError::IoError(io_error))
185 | CommandError::FailedToRun(_, PopenError::IoError(io_error))
186 if io_error.kind() == io::ErrorKind::PermissionDenied =>
187 {
188 self.clean(path)?;
190 match self.run_tests_with_valgrind(path, false) {
191 Ok(output) => output,
192 Err(err) => {
193 log::error!(
194 "Running with valgrind failed after trying to clean! {err}"
195 );
196 ran_valgrind = false;
197 log::info!("Running without valgrind");
198 self.run_tests_with_valgrind(path, false)?
199 }
200 }
201 }
202 _ => {
203 ran_valgrind = false;
204 log::info!("Running without valgrind");
205 self.run_tests_with_valgrind(path, false)?
206 }
207 }
208 }
209 MakeError::RunningTestsWithValgrind(..) => {
210 ran_valgrind = false;
211 log::info!("Running without valgrind");
212 self.run_tests_with_valgrind(path, false)?
213 }
214 err => {
215 log::warn!("unexpected error {err:?}");
216 return Err(err.into());
217 }
218 }
219 }
220 };
221
222 let fail_on_valgrind_error = match TmcProjectYml::load_or_default(path) {
224 Ok(parsed) => parsed.fail_on_valgrind_error.unwrap_or(true),
225 Err(_) => true,
226 };
227
228 let valgrind_log = if ran_valgrind && fail_on_valgrind_error {
230 Some(ValgrindLog::from(&valgrind_log_path)?)
231 } else {
232 None
233 };
234
235 let available_points_path = base_test_path.join("tmc_available_points.txt");
237 let tests = self.parse_available_points(&available_points_path)?;
238 let mut ids_to_points = HashMap::new();
239 for test in tests {
240 ids_to_points.insert(test.name, test.points);
241 }
242
243 let test_results_path = base_test_path.join("tmc_test_results.xml");
245
246 let file_bytes = file_util::read_file(&test_results_path)?;
247
248 let file_string = String::from_utf8_lossy(&file_bytes);
250
251 let check_log: CheckLog = serde_xml_rs::from_str(&file_string)
252 .map_err(|e| MakeError::XmlParseError(test_results_path, e))?;
253 let mut logs = HashMap::new();
254 logs.insert(
255 "stdout".to_string(),
256 String::from_utf8_lossy(&output.stdout).into_owned(),
257 );
258 logs.insert(
259 "stderr".to_string(),
260 String::from_utf8_lossy(&output.stdout).into_owned(),
261 );
262 let mut run_result = check_log.into_run_result(ids_to_points, logs);
263
264 if let Some(valgrind_log) = valgrind_log {
265 if valgrind_log.errors {
266 run_result.status = RunStatus::TestsFailed;
268 for (test_result, valgrind_result) in run_result
270 .test_results
271 .iter_mut()
272 .zip(valgrind_log.results.into_iter())
273 {
274 if valgrind_result.errors {
275 if test_result.successful {
276 test_result.message += " - Failed due to errors in valgrind log; see log below. Try submitting to server, some leaks might be platform dependent";
277 }
278 test_result.exception.extend(valgrind_result.log);
279 }
280 }
281 }
282 }
283
284 Ok(run_result)
285 }
286
287 fn find_project_dir_in_archive<R: Read + Seek>(
288 archive: &mut Archive<R>,
289 ) -> Result<PathBuf, TmcError> {
290 let mut iter = archive.iter()?;
291
292 let mut makefile_parents = vec![];
293 let mut src_parents = vec![];
294 let project_dir = loop {
295 let next = iter.with_next(|file| {
296 let file_path = file.path()?;
297
298 if file.is_file() {
299 if let Some(parent) = path_util::get_parent_of_named(&file_path, "Makefile") {
301 if src_parents.contains(&parent) {
302 return Ok(Break(Some(parent)));
303 } else {
304 makefile_parents.push(parent);
305 }
306 }
307 } else if file.is_dir() {
308 if let Some(parent) =
310 path_util::get_parent_of_component_in_path(&file_path, "src")
311 {
312 if makefile_parents.contains(&parent) {
313 return Ok(Break(Some(parent)));
314 } else {
315 src_parents.push(parent);
316 }
317 }
318 }
319 Ok(Continue(()))
320 });
321 match next? {
322 Continue(_) => continue,
323 Break(project_dir) => break project_dir,
324 }
325 };
326 if let Some(project_dir) = project_dir {
327 Ok(project_dir)
328 } else {
329 Err(TmcError::NoProjectDirInArchive)
330 }
331 }
332
333 fn is_exercise_type_correct(path: &Path) -> bool {
335 path.join("src").is_dir() && path.join("Makefile").is_file()
336 }
337
338 fn clean(&self, path: &Path) -> Result<(), TmcError> {
340 let output = TmcCommand::piped("make")
341 .with(|e| e.cwd(path).arg("clean"))
342 .output()?;
343
344 if output.status.success() {
345 log::info!("Cleaned make project");
346 } else {
347 log::warn!("Cleaning make project was not successful");
348 }
349
350 Ok(())
351 }
352
353 fn get_default_student_file_paths() -> Vec<PathBuf> {
354 vec![PathBuf::from("src")]
355 }
356
357 fn get_default_exercise_file_paths() -> Vec<PathBuf> {
358 vec![PathBuf::from("test")]
359 }
360
361 fn points_parser(i: &str) -> IResult<&str, Vec<&str>, VerboseError<&str>> {
362 fn tmc_register_test_parser(i: &str) -> IResult<&str, Vec<&str>, VerboseError<&str>> {
363 sequence::delimited(
364 (
365 bytes::complete::tag("tmc_register_test"),
366 character::complete::multispace0,
367 character::complete::char('('),
368 character::complete::multispace0,
369 arg_parser,
370 arg_parser,
371 ),
372 string_parser,
373 (
374 character::complete::multispace0,
375 character::complete::char(')'),
376 ),
377 )
378 .parse(i)
379 .map(|(a, b)| (a, vec![b]))
380 }
381
382 fn arg_parser(i: &str) -> IResult<&str, &str, VerboseError<&str>> {
384 combinator::value(
385 "",
386 (
387 bytes::complete::take_till(|c: char| c.is_whitespace() || c == ','),
388 character::complete::char(','),
389 character::complete::multispace0,
390 ),
391 )
392 .parse(i)
393 }
394
395 fn string_parser(i: &str) -> IResult<&str, &str, VerboseError<&str>> {
396 sequence::delimited(
397 character::complete::char('"'),
398 bytes::complete::is_not("\""),
399 character::complete::char('"'),
400 )
401 .parse(i)
402 }
403
404 tmc_register_test_parser(i)
405 }
406}
407
408#[cfg(test)]
409#[cfg(target_os = "linux")] #[allow(clippy::unwrap_used)]
411mod test {
412 use super::*;
413 use zip::write::SimpleFileOptions;
414
415 fn init() {
416 use log::*;
417 use simple_logger::*;
418 let _ = SimpleLogger::new()
419 .with_level(LevelFilter::Debug)
420 .with_module_level("serde_xml_rs", LevelFilter::Warn)
422 .init();
423 }
424
425 fn file_to(
426 target_dir: impl AsRef<std::path::Path>,
427 target_relative: impl AsRef<std::path::Path>,
428 contents: impl AsRef<[u8]>,
429 ) -> PathBuf {
430 let target = target_dir.as_ref().join(target_relative);
431 if let Some(parent) = target.parent() {
432 std::fs::create_dir_all(parent).unwrap();
433 }
434 std::fs::write(&target, contents.as_ref()).unwrap();
435 target
436 }
437
438 fn dir_to(
439 target_dir: impl AsRef<std::path::Path>,
440 target_relative: impl AsRef<std::path::Path>,
441 ) -> PathBuf {
442 let target = target_dir.as_ref().join(target_relative);
443 std::fs::create_dir_all(&target).unwrap();
444 target
445 }
446
447 fn dir_to_temp(source_dir: impl AsRef<std::path::Path>) -> tempfile::TempDir {
448 let temp = tempfile::TempDir::new().unwrap();
449 for entry in walkdir::WalkDir::new(&source_dir).min_depth(1) {
450 let entry = entry.unwrap();
451 let rela = entry.path().strip_prefix(&source_dir).unwrap();
452 let target = temp.path().join(rela);
453 if entry.path().is_dir() {
454 std::fs::create_dir(target).unwrap();
455 } else if entry.path().is_file() {
456 std::fs::copy(entry.path(), target).unwrap();
457 }
458 }
459 temp
460 }
461
462 fn dir_to_zip(source_dir: impl AsRef<std::path::Path>) -> Vec<u8> {
463 use std::io::Write;
464
465 let mut target = vec![];
466 let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut target));
467
468 for entry in walkdir::WalkDir::new(&source_dir)
469 .min_depth(1)
470 .sort_by(|a, b| a.path().cmp(b.path()))
471 {
472 let entry = entry.unwrap();
473 let rela = entry
474 .path()
475 .strip_prefix(&source_dir)
476 .unwrap()
477 .to_str()
478 .unwrap();
479 if entry.path().is_dir() {
480 zip.add_directory(rela, SimpleFileOptions::default())
481 .unwrap();
482 } else if entry.path().is_file() {
483 zip.start_file(rela, SimpleFileOptions::default()).unwrap();
484 let bytes = std::fs::read(entry.path()).unwrap();
485 zip.write_all(&bytes).unwrap();
486 }
487 }
488
489 zip.finish().unwrap();
490 target
491 }
492
493 #[test]
494 fn parses_exercise_desc() {
495 init();
496
497 let temp_dir = tempfile::tempdir().unwrap();
498 let available_points = file_to(
499 &temp_dir,
500 "available_points.txt",
501 r#"
502[test] [test1] point1 point2 point3 point4
503[test] [test2] point5
504[nontest] [nontest1] nonpoint
505test [invalid] point6
506[test] invalid point6
507"#,
508 );
509
510 let plugin = MakePlugin::new();
511 let exercise_desc = plugin.parse_available_points(&available_points).unwrap();
512 assert_eq!(exercise_desc.len(), 2);
513 assert_eq!(exercise_desc[0].points.len(), 4);
514 }
515
516 #[test]
517 fn scans_exercise() {
518 init();
519
520 let temp = dir_to_temp("tests/data/passing-exercise");
521 let plugin = MakePlugin::new();
522 let exercise_desc = plugin
523 .scan_exercise(temp.path(), "test".to_string())
524 .unwrap();
525
526 assert_eq!(exercise_desc.name, "test");
527 assert_eq!(exercise_desc.tests.len(), 1);
528 let test = &exercise_desc.tests[0];
529 assert_eq!(test.name, "test_one");
530 assert_eq!(test.points.len(), 1);
531 assert_eq!(test.points[0], "1.1");
532 }
533
534 #[test]
535 fn runs_tests() {
536 init();
537
538 let temp = dir_to_temp("tests/data/passing-exercise");
539 let plugin = MakePlugin::new();
540 let run_result = plugin.run_tests(temp.path()).unwrap();
541 assert_eq!(run_result.status, RunStatus::Passed);
542 let test_results = run_result.test_results;
543 assert_eq!(test_results.len(), 1);
544 let test_result = &test_results[0];
545 assert_eq!(test_result.name, "test_one");
546 assert!(test_result.successful);
547 assert_eq!(test_result.message, "Passed");
548 assert!(test_result.exception.is_empty());
549 let points = &test_result.points;
550 assert_eq!(points.len(), 1);
551 let point = &points[0];
552 assert_eq!(point, "1.1");
553 }
554
555 #[test]
556 fn runs_tests_failing() {
557 init();
558
559 let temp = dir_to_temp("tests/data/failing-exercise");
560 let plugin = MakePlugin::new();
561 let run_result = plugin.run_tests(temp.path()).unwrap();
562 assert_eq!(run_result.status, RunStatus::TestsFailed);
563 let test_results = &run_result.test_results;
564 assert_eq!(test_results.len(), 1);
565 let test_result = &test_results[0];
566 assert_eq!(test_result.name, "test_one");
567 assert!(!test_result.successful);
568 assert!(test_result.message.contains("Should have returned: 1"));
569 let points = &test_result.points;
570 assert_eq!(points.len(), 1);
571 assert_eq!(points[0], "1.1");
572 }
573
574 #[test]
576 fn runs_tests_failing_valgrind() {
577 init();
578
579 let temp = dir_to_temp("tests/data/valgrind-failing-exercise");
580 let plugin = MakePlugin::new();
581 let run_result = plugin.run_tests(temp.path()).unwrap();
582 assert_eq!(run_result.status, RunStatus::TestsFailed);
583 let test_results = &run_result.test_results;
584 assert_eq!(test_results.len(), 2);
585
586 let test_one = &test_results[0];
587 assert_eq!(test_one.name, "test_one");
588 assert!(test_one.successful);
589 assert_eq!(test_one.points.len(), 1);
590 assert_eq!(test_one.points[0], "1.1");
591
592 let test_two = &test_results[1];
593 assert_eq!(test_two.name, "test_two");
594 assert!(test_two.successful);
595 assert_eq!(test_two.points.len(), 1);
596 assert_eq!(test_two.points[0], "1.2");
597 }
598
599 #[test]
600 fn finds_project_dir_in_zip() {
601 init();
602 let temp_dir = tempfile::tempdir().unwrap();
603 dir_to(&temp_dir, "Outer/Inner/make_project/src");
604 file_to(&temp_dir, "Outer/Inner/make_project/Makefile", "");
605
606 let zip_contents = dir_to_zip(&temp_dir);
607 let mut zip = Archive::zip(std::io::Cursor::new(zip_contents)).unwrap();
608 let dir = MakePlugin::find_project_dir_in_archive(&mut zip).unwrap();
609 assert_eq!(dir, Path::new("Outer/Inner/make_project"));
610 }
611
612 #[test]
613 fn doesnt_find_project_dir_in_zip() {
614 init();
615
616 let temp_dir = tempfile::tempdir().unwrap();
617 dir_to(&temp_dir, "Outer/Inner/make_project/src");
618 file_to(&temp_dir, "Outer/Inner/make_project/Makefil", "");
619
620 let zip_contents = dir_to_zip(&temp_dir);
621 let mut zip = Archive::zip(std::io::Cursor::new(zip_contents)).unwrap();
622 let dir = MakePlugin::find_project_dir_in_archive(&mut zip);
623 assert!(dir.is_err());
624 }
625
626 #[test]
627 fn parses_points() {
628 assert!(
629 MakePlugin::points_parser(
630 "tmc_register_test(s, test_insertion_empty_list, \"dlink_insert);",
631 )
632 .is_err()
633 );
634
635 assert_eq!(
636 MakePlugin::points_parser(
637 "tmc_register_test(s, test_insertion_empty_list, \"dlink_insert\");",
638 )
639 .unwrap()
640 .1[0],
641 "dlink_insert"
642 );
643 }
644
645 #[test]
646 fn does_not_parse_check_function() {
647 assert!(
648 MakePlugin::points_parser(
649 r#"tmc_register_test(Suite *s, TFun tf, const char *fname, const char *points)
650{
651 // stuff
652}
653
654int tmc_run_tests(int argc, const char **argv, Suite *s)
655{
656 func("--print-available-points")
657}
658"#
659 )
660 .is_err()
661 )
662 }
663}