tmc_langs_make/
plugin.rs

1//! Contains the main plugin struct.
2
3use 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    /// Parses tmc_available_points.txt which is output by the TMC tests and
32    /// contains lines like "[test] [test_one] 1.1 1.2 1.3" = "[type] [name] points".
33    fn parse_available_points(&self, available_points: &Path) -> Result<Vec<TestDesc>, MakeError> {
34        // "[test] [test_one] 1.1 1.2 1.3" = "[type] [name] points"
35        // TODO: use parser lib
36        #[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    /// Runs tests with or without valgrind according to the argument.
64    /// Returns an error if the command finishes unsuccessfully.
65    /// TODO: no option for timeout
66    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    /// Tries to build the project at the given directory, returns whether
101    /// the process finished successfully or not.
102    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
115/// Project directory:
116/// Contains a src directory and a Makefile file
117impl 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        // try to clean old log file if any
168        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        // try to run valgrind
173        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                                // failed due to lacking permissions, try to clean and rerun
189                                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        // fails on valgrind by default
223        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        // valgrind logs are only interesting if fail on valgrind error is on
229        let valgrind_log = if ran_valgrind && fail_on_valgrind_error {
230            Some(ValgrindLog::from(&valgrind_log_path)?)
231        } else {
232            None
233        };
234
235        // parse available points into a mapping from test name to test point list
236        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        // parse test results into RunResult
244        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        // xml may contain invalid utf-8, ignore invalid characters
249        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                // valgrind failed
267                run_result.status = RunStatus::TestsFailed;
268                // TODO: tests and valgrind results are not guaranteed to be in the same order
269                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                    // check for Makefile
300                    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                    // check for src
309                    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    /// Checks if the directory has a src dir and a Makefile file in it.
334    fn is_exercise_type_correct(path: &Path) -> bool {
335        path.join("src").is_dir() && path.join("Makefile").is_file()
336    }
337
338    // does not check for success
339    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        // todo: currently cannot handle function calls with multiple parameters, probably not a problem
383        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")] // check not installed on other CI platforms
410#[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            // serde_xml_rs logs a lot
421            .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    // if this test causes problems just disable it, valgrind might be writing the results in a random order
575    #[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}