tmc_langs_java/
java_plugin.rs

1//! Common functionality for all Java plugins
2
3use crate::{
4    CompileResult, JvmWrapper, TestCase, TestCaseStatus, TestMethod, TestRun, error::JavaError,
5};
6use j4rs::InvocationArg;
7use serde::{Deserialize, Serialize};
8use std::{
9    collections::HashMap,
10    convert::TryFrom,
11    ffi::OsStr,
12    path::{Path, PathBuf},
13    time::Duration,
14};
15use tmc_langs_framework::{
16    ExerciseDesc, Language, LanguagePlugin, RunResult, RunStatus, StyleValidationError,
17    StyleValidationResult, StyleValidationStrategy, TestDesc, TestResult, TmcCommand,
18    nom::{IResult, Parser, bytes, character, combinator, sequence},
19    nom_language::error::VerboseError,
20};
21use tmc_langs_util::{deserialize, file_util, parse_util};
22use walkdir::WalkDir;
23
24pub(crate) trait JavaPlugin: LanguagePlugin {
25    const TEST_DIR: &'static str;
26
27    /// Returns a reference to the inner Jvm.
28    fn jvm(&self) -> &JvmWrapper;
29
30    /// Constructs a CLASSPATH for the given path (see https://docs.oracle.com/javase/tutorial/essential/environment/paths.html).
31    fn get_project_class_path(&self, path: &Path) -> Result<String, JavaError>;
32
33    /// Builds the Java project.
34    fn build(&self, project_root_path: &Path) -> Result<CompileResult, JavaError>;
35
36    /// Runs the tests for the given project.
37    fn run_java_tests(
38        &self,
39        project_root_path: &Path,
40        timeout: Option<Duration>,
41    ) -> Result<RunResult, JavaError> {
42        log::info!(
43            "running tests for project at {}",
44            project_root_path.display()
45        );
46
47        let compile_result = self.build(project_root_path)?;
48        if !compile_result.status_code.success() {
49            return Ok(self.run_result_from_failed_compilation(compile_result));
50        }
51
52        let test_result =
53            self.create_run_result_file(project_root_path, timeout, compile_result)?;
54        let result = self.parse_test_result(&test_result);
55        if let Err(err) = file_util::remove_file(&test_result.test_results) {
56            log::warn!("Failed to remove test results file: {err}");
57        }
58        result
59    }
60
61    /// Parses test results.
62    fn parse_test_result(&self, results: &TestRun) -> Result<RunResult, JavaError> {
63        let result_file = file_util::open_file(&results.test_results)?;
64        let test_case_records: Vec<TestCase> = deserialize::json_from_reader(&result_file)?;
65
66        let mut test_results: Vec<TestResult> = vec![];
67        let mut status = RunStatus::Passed;
68        for test_case in test_case_records {
69            if test_case.status == TestCaseStatus::Failed {
70                status = RunStatus::TestsFailed;
71            }
72            test_results.push(self.convert_test_case_result(test_case));
73        }
74
75        let mut logs = HashMap::new();
76        logs.insert(
77            "stdout".to_string(),
78            String::from_utf8_lossy(&results.stdout).into_owned(),
79        );
80        logs.insert(
81            "stderr".to_string(),
82            String::from_utf8_lossy(&results.stderr).into_owned(),
83        );
84        Ok(RunResult {
85            status,
86            test_results,
87            logs,
88        })
89    }
90
91    /// Converts a Java test case into a tmc-langs test result.
92    fn convert_test_case_result(&self, test_case: TestCase) -> TestResult {
93        let mut exceptions = vec![];
94        let mut points = vec![];
95
96        if let Some(exception) = test_case.exception {
97            if let Some(message) = exception.message {
98                exceptions.push(message);
99            }
100            for stack_trace in exception.stack_trace {
101                exceptions.push(stack_trace.to_string())
102            }
103        }
104
105        points.extend(test_case.point_names);
106
107        let name = format!("{} {}", test_case.class_name, test_case.method_name);
108        let successful = test_case.status == TestCaseStatus::Passed;
109        let message = test_case.message.unwrap_or_default();
110
111        TestResult {
112            name,
113            successful,
114            points,
115            message,
116            exception: exceptions,
117        }
118    }
119
120    /// Tries to parse the java.home property.
121    fn parse_java_home(properties: &str) -> Option<PathBuf> {
122        for line in properties.lines() {
123            if line.contains("java.home") {
124                return line.split('=').nth(1).map(|s| PathBuf::from(s.trim()));
125            }
126        }
127
128        log::warn!("No java.home found in {properties}");
129        None
130    }
131
132    /// Tries to find the java.home property.
133    fn get_java_home() -> Result<PathBuf, JavaError> {
134        let output = TmcCommand::piped("java")
135            .with(|e| e.arg("-XshowSettings:properties").arg("-version"))
136            .output()?;
137
138        // information is printed to stderr
139        let stderr = String::from_utf8_lossy(&output.stderr);
140        match Self::parse_java_home(&stderr) {
141            Some(java_home) => Ok(java_home),
142            None => Err(JavaError::NoJavaHome),
143        }
144    }
145
146    /// Runs tests and writes the results into a file.
147    fn create_run_result_file(
148        &self,
149        path: &Path,
150        timeout: Option<Duration>,
151        compile_result: CompileResult,
152    ) -> Result<TestRun, JavaError>;
153
154    /// Checks the compile result and scans an exercise.
155    fn scan_exercise_with_compile_result(
156        &self,
157        path: &Path,
158        exercise_name: String,
159        compile_result: CompileResult,
160    ) -> Result<ExerciseDesc, JavaError> {
161        if !Self::is_exercise_type_correct(path) {
162            return Err(JavaError::InvalidExercise(path.to_path_buf()));
163        } else if !compile_result.status_code.success() {
164            return Err(JavaError::Compilation {
165                stdout: String::from_utf8_lossy(&compile_result.stdout).into_owned(),
166                stderr: String::from_utf8_lossy(&compile_result.stderr).into_owned(),
167            });
168        }
169
170        let mut source_files = vec![];
171        for entry in WalkDir::new(path.join(Self::TEST_DIR)) {
172            let entry = entry?;
173            let ext = entry.path().extension();
174            if ext == Some(OsStr::new("java")) || ext == Some(OsStr::new("jar")) {
175                source_files.push(entry.into_path());
176            }
177        }
178        let class_path = self.get_project_class_path(path)?;
179
180        log::info!("class path: {class_path}");
181        log::info!("source files: {source_files:?}");
182
183        let scan_results = self.jvm().with(|jvm| {
184            let test_scanner = jvm.create_instance(
185                "fi.helsinki.cs.tmc.testscanner.TestScanner",
186                InvocationArg::empty(),
187            )?;
188
189            jvm.invoke(
190                &test_scanner,
191                "setClassPath",
192                &[InvocationArg::try_from(class_path)?],
193            )?;
194
195            for source_file in source_files {
196                let file = jvm.create_instance(
197                    "java.io.File",
198                    &[InvocationArg::try_from(&*source_file.to_string_lossy())?],
199                )?;
200                jvm.invoke(&test_scanner, "addSource", &[InvocationArg::from(file)])?;
201            }
202            let scan_results = jvm.invoke(&test_scanner, "findTests", InvocationArg::empty())?;
203            jvm.invoke(&test_scanner, "clearSources", InvocationArg::empty())?;
204
205            let scan_results: Vec<TestMethod> = jvm.to_rust(scan_results)?;
206            Ok(scan_results)
207        })?;
208
209        let tests = scan_results
210            .into_iter()
211            .map(|s| TestDesc {
212                name: format!("{} {}", s.class_name, s.method_name),
213                points: s.points,
214            })
215            .collect();
216
217        Ok(ExerciseDesc {
218            name: exercise_name,
219            tests,
220        })
221    }
222
223    /// Creates a run result from a failed compilation.
224    fn run_result_from_failed_compilation(&self, compile_result: CompileResult) -> RunResult {
225        let mut logs = HashMap::new();
226        logs.insert(
227            "stdout".to_string(),
228            String::from_utf8_lossy(&compile_result.stdout).into_owned(),
229        );
230        logs.insert(
231            "stderr".to_string(),
232            String::from_utf8_lossy(&compile_result.stderr).into_owned(),
233        );
234        RunResult {
235            status: RunStatus::CompileFailed,
236            test_results: vec![],
237            logs,
238        }
239    }
240
241    /// Runs checkstyle.
242    fn run_checkstyle(
243        &self,
244        locale: &Language,
245        path: &Path,
246    ) -> Result<StyleValidationResult, JavaError> {
247        let path = path.to_string_lossy();
248        let result = self.jvm().with(|jvm| {
249            let file =
250                jvm.create_instance("java.io.File", &[InvocationArg::try_from(path.as_ref())?])?;
251            let locale_code = locale.to_639_1().unwrap_or_else(|| locale.to_639_3()); // Java requires 639-1 if one exists
252            let locale =
253                jvm.create_instance("java.util.Locale", &[InvocationArg::try_from(locale_code)?])?;
254            let checkstyle_runner = jvm.create_instance(
255                "fi.helsinki.cs.tmc.stylerunner.CheckstyleRunner",
256                &[InvocationArg::from(file), InvocationArg::from(locale)],
257            )?;
258            let result = jvm.invoke(&checkstyle_runner, "run", InvocationArg::empty())?;
259            let result: JavaStyleValidationResult = jvm.to_rust(result)?;
260            Ok(result)
261        })?;
262
263        log::debug!("Validation result: {result:?}");
264        Ok(result.into())
265    }
266
267    /// Parses @Points("1.1") point annotations.
268    fn java_points_parser(i: &str) -> IResult<&str, Vec<&str>, VerboseError<&str>> {
269        combinator::map(
270            sequence::delimited(
271                (
272                    character::complete::char('@'),
273                    character::complete::multispace0,
274                    bytes::complete::tag_no_case("points"),
275                    character::complete::multispace0,
276                    character::complete::char('('),
277                    character::complete::multispace0,
278                ),
279                parse_util::comma_separated_strings,
280                (
281                    character::complete::multispace0,
282                    character::complete::char(')'),
283                ),
284            ),
285            // splits each point by whitespace
286            |points| {
287                points
288                    .into_iter()
289                    .flat_map(|p| p.split_whitespace())
290                    .collect()
291            },
292        )
293        .parse(i)
294    }
295}
296
297/// Determines how style errors are handled.
298#[derive(Debug, Deserialize, Serialize, PartialEq, Eq)]
299#[serde(rename_all = "UPPERCASE")]
300pub enum JavaStyleValidationStrategy {
301    Fail,
302    Warn,
303    Disabled,
304}
305
306impl From<JavaStyleValidationStrategy> for StyleValidationStrategy {
307    fn from(value: JavaStyleValidationStrategy) -> Self {
308        match value {
309            JavaStyleValidationStrategy::Fail => Self::Fail,
310            JavaStyleValidationStrategy::Warn => Self::Warn,
311            JavaStyleValidationStrategy::Disabled => Self::Disabled,
312        }
313    }
314}
315
316/// A style validation error.
317#[derive(Debug, Deserialize, Serialize, PartialEq, Eq)]
318#[serde(rename_all = "camelCase")]
319pub struct JavaStyleValidationError {
320    pub column: u32,
321    pub line: u32,
322    pub message: String,
323    pub source_name: String,
324}
325
326impl From<JavaStyleValidationError> for StyleValidationError {
327    fn from(value: JavaStyleValidationError) -> Self {
328        Self {
329            column: value.column,
330            line: value.line,
331            message: value.message,
332            source_name: value.source_name,
333        }
334    }
335}
336
337/// The result of a style check.
338#[derive(Debug, Deserialize, Serialize, PartialEq, Eq)]
339#[serde(rename_all = "camelCase")]
340pub struct JavaStyleValidationResult {
341    pub strategy: JavaStyleValidationStrategy,
342    pub validation_errors: Option<HashMap<PathBuf, Vec<JavaStyleValidationError>>>,
343}
344
345impl From<JavaStyleValidationResult> for StyleValidationResult {
346    fn from(value: JavaStyleValidationResult) -> Self {
347        Self {
348            strategy: value.strategy.into(),
349            validation_errors: value.validation_errors.map(|hm| {
350                hm.into_iter()
351                    .map(|(k, v)| (k, v.into_iter().map(Into::into).collect()))
352                    .collect()
353            }),
354        }
355    }
356}
357
358#[cfg(test)]
359#[allow(clippy::unwrap_used)]
360mod test {
361    use super::*;
362    use crate::SEPARATOR;
363    use std::io::{Read, Seek};
364    use tmc_langs_framework::{Archive, TmcError};
365
366    fn init() {
367        use log::*;
368        use simple_logger::*;
369        let _ = SimpleLogger::new()
370            .with_level(LevelFilter::Debug)
371            .with_module_level("j4rs", LevelFilter::Warn)
372            .init();
373    }
374
375    fn dir_to_temp(source_dir: impl AsRef<std::path::Path>) -> tempfile::TempDir {
376        let temp = tempfile::TempDir::new().unwrap();
377        for entry in walkdir::WalkDir::new(&source_dir).min_depth(1) {
378            let entry = entry.unwrap();
379            let rela = entry.path().strip_prefix(&source_dir).unwrap();
380            let target = temp.path().join(rela);
381            if entry.path().is_dir() {
382                std::fs::create_dir(target).unwrap();
383            } else if entry.path().is_file() {
384                std::fs::copy(entry.path(), target).unwrap();
385            }
386        }
387        temp
388    }
389
390    struct Stub {
391        jvm: JvmWrapper,
392    }
393
394    impl Stub {
395        fn new() -> Self {
396            Self {
397                jvm: crate::instantiate_jvm().unwrap(),
398            }
399        }
400    }
401
402    impl LanguagePlugin for Stub {
403        const PLUGIN_NAME: &'static str = "stub";
404        const DEFAULT_SANDBOX_IMAGE: &'static str = "stub-image";
405        const LINE_COMMENT: &'static str = "//";
406        const BLOCK_COMMENT: Option<(&'static str, &'static str)> = Some(("/*", "*/"));
407        type StudentFilePolicy = tmc_langs_framework::EverythingIsStudentFilePolicy;
408
409        fn scan_exercise(
410            &self,
411            _path: &Path,
412            _exercise_name: String,
413        ) -> Result<ExerciseDesc, TmcError> {
414            unimplemented!()
415        }
416
417        fn run_tests_with_timeout(
418            &self,
419            _path: &Path,
420            _timeout: Option<Duration>,
421        ) -> Result<RunResult, TmcError> {
422            unimplemented!()
423        }
424
425        fn find_project_dir_in_archive<R: Read + Seek>(
426            _archive: &mut Archive<R>,
427        ) -> Result<PathBuf, TmcError> {
428            unimplemented!()
429        }
430
431        fn is_exercise_type_correct(_path: &Path) -> bool {
432            true
433        }
434
435        fn clean(&self, _path: &Path) -> Result<(), TmcError> {
436            unimplemented!()
437        }
438
439        fn get_default_student_file_paths() -> Vec<PathBuf> {
440            unimplemented!()
441        }
442
443        fn get_default_exercise_file_paths() -> Vec<PathBuf> {
444            unimplemented!()
445        }
446
447        fn points_parser(i: &str) -> IResult<&str, Vec<&str>, VerboseError<&str>> {
448            Self::java_points_parser(i)
449        }
450    }
451
452    impl JavaPlugin for Stub {
453        const TEST_DIR: &'static str = "test";
454
455        fn jvm(&self) -> &JvmWrapper {
456            &self.jvm
457        }
458        fn get_project_class_path(&self, path: &Path) -> Result<String, JavaError> {
459            let path = path.to_str().unwrap();
460            let cp = format!(
461                "{path}/lib/edu-test-utils-0.5.0.jar{SEPARATOR}{path}/lib/junit-4.13.2.jar"
462            );
463            Ok(cp)
464        }
465
466        fn build(&self, _project_root_path: &Path) -> Result<CompileResult, JavaError> {
467            Ok(CompileResult {
468                status_code: tmc_langs_framework::ExitStatus::Exited(0),
469                stdout: vec![],
470                stderr: vec![],
471            })
472        }
473
474        fn create_run_result_file(
475            &self,
476            path: &Path,
477            _timeout: Option<Duration>,
478            _compile_result: CompileResult,
479        ) -> Result<TestRun, JavaError> {
480            let path = path.join("runresult");
481            std::fs::write(
482                &path,
483                r#"[{
484                "className": "cls1",
485                "methodName": "mtd1",
486                "pointNames": [],
487                "status": "PASSED",
488                "message": null,
489                "exception": null
490            },{
491                "className": "cls2",
492                "methodName": "mtd2",
493                "pointNames": [],
494                "status": "FAILED",
495                "message": null,
496                "exception": null
497            }]"#,
498            )
499            .unwrap();
500            Ok(TestRun {
501                test_results: path,
502                stdout: vec![],
503                stderr: vec![],
504            })
505        }
506    }
507
508    #[test]
509    fn runs_java_tests() {
510        init();
511
512        let temp_dir = tempfile::tempdir().unwrap();
513        let plugin = Stub::new();
514        let result = plugin.run_java_tests(temp_dir.path(), None).unwrap();
515        assert_eq!(result.status, RunStatus::TestsFailed);
516    }
517
518    #[test]
519    fn parses_test_results() {
520        init();
521
522        use std::io::Write;
523        let mut temp_file = tempfile::NamedTempFile::new().unwrap();
524        temp_file
525            .write_all(
526                br#"[{
527                "className": "cls1",
528                "methodName": "mtd1",
529                "pointNames": [],
530                "status": "PASSED",
531                "message": null,
532                "exception": null
533            },{
534                "className": "cls2",
535                "methodName": "mtd2",
536                "pointNames": [],
537                "status": "FAILED",
538                "message": null,
539                "exception": null
540            }]"#,
541            )
542            .unwrap();
543
544        let plugin = Stub::new();
545        let test_run = TestRun {
546            test_results: temp_file.path().to_path_buf(),
547            stdout: vec![],
548            stderr: vec![],
549        };
550        let run_result = plugin.parse_test_result(&test_run).unwrap();
551        assert_eq!(run_result.status, RunStatus::TestsFailed);
552    }
553
554    #[test]
555    fn converts_test_case_result() {
556        init();
557
558        let plugin = Stub::new();
559        let test_case = TestCase {
560            class_name: "cls".to_string(),
561            exception: None,
562            message: None,
563            method_name: "mtd".to_string(),
564            point_names: vec!["1".to_string(), "2".to_string()],
565            status: TestCaseStatus::Failed,
566        };
567        let test_result = plugin.convert_test_case_result(test_case);
568        assert_eq!(test_result.points, &["1", "2"]);
569    }
570
571    #[test]
572    fn parses_java_home() {
573        init();
574
575        let properties = r#"Property settings:
576    awt.toolkit = sun.awt.X11.XToolkit
577    java.ext.dirs = /usr/lib/jvm/java-8-openjdk-amd64/jre/lib/ext
578        /usr/java/packages/lib/ext
579    java.home = /usr/lib/jvm/java-8-openjdk-amd64/jre
580    user.timezone = 
581
582openjdk version "1.8.0_252"S
583"#;
584
585        let parsed = Stub::parse_java_home(properties);
586        assert_eq!(
587            Some(PathBuf::from("/usr/lib/jvm/java-8-openjdk-amd64/jre")),
588            parsed,
589        );
590    }
591
592    #[test]
593    fn gets_java_home() {
594        init();
595
596        let _java_home = Stub::get_java_home().unwrap();
597    }
598
599    #[test]
600    fn scans_exercise_with_compile_result() {
601        init();
602
603        let temp_dir = dir_to_temp("tests/data/ant-exercise");
604
605        let plugin = Stub::new();
606        let compile_result = CompileResult {
607            stdout: vec![],
608            stderr: vec![],
609            status_code: tmc_langs_framework::ExitStatus::Exited(0),
610        };
611        let desc = plugin
612            .scan_exercise_with_compile_result(temp_dir.path(), "ex".to_string(), compile_result)
613            .unwrap();
614        assert_eq!(desc.tests[0].points[0], "arith-funcs");
615    }
616
617    #[test]
618    fn creates_run_result_from_failed_compilation() {
619        init();
620
621        let plugin = Stub::new();
622        let compile_result = CompileResult {
623            status_code: tmc_langs_framework::ExitStatus::Exited(0),
624            stdout: "hello, 世界".as_bytes().to_vec(),
625            stderr: "エラー".as_bytes().to_vec(),
626        };
627        let run_result = plugin.run_result_from_failed_compilation(compile_result);
628        assert_eq!(run_result.logs.get("stdout").unwrap(), "hello, 世界");
629        assert_eq!(run_result.logs.get("stderr").unwrap(), "エラー");
630    }
631
632    #[test]
633    fn runs_checkstyle() {
634        init();
635
636        let temp_dir = dir_to_temp("tests/data/ant-exercise");
637
638        let plugin = Stub::new();
639        let validation_result = plugin
640            .run_checkstyle(&Language::from_639_3("fin").unwrap(), temp_dir.path())
641            .unwrap();
642        log::debug!("{validation_result:#?}");
643        let validation_errors = validation_result.validation_errors.unwrap();
644        let validation_error = validation_errors.values().next().unwrap().first().unwrap();
645        assert!(validation_error.message.contains("Sisennys väärin"));
646    }
647
648    #[test]
649    fn parses_points() {
650        assert!(Stub::java_points_parser("asd").is_err());
651        assert!(Stub::java_points_parser(r#"@points("help""#).is_err());
652
653        assert_eq!(
654            Stub::java_points_parser(r#"@points("point")"#).unwrap().1,
655            &["point"]
656        );
657        assert_eq!(
658            Stub::java_points_parser(r#"@  PoInTs  (  "  another point  "  )  "#)
659                .unwrap()
660                .1,
661            &["another", "point"]
662        );
663        assert_eq!(
664            Stub::java_points_parser(r#"@points("point", "another point"  ,  "asd")"#)
665                .unwrap()
666                .1,
667            &["point", "another", "point", "asd"]
668        );
669    }
670}