1use 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 fn jvm(&self) -> &JvmWrapper;
29
30 fn get_project_class_path(&self, path: &Path) -> Result<String, JavaError>;
32
33 fn build(&self, project_root_path: &Path) -> Result<CompileResult, JavaError>;
35
36 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 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 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 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 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 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 fn create_run_result_file(
148 &self,
149 path: &Path,
150 timeout: Option<Duration>,
151 compile_result: CompileResult,
152 ) -> Result<TestRun, JavaError>;
153
154 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 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 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()); 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 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 |points| {
287 points
288 .into_iter()
289 .flat_map(|p| p.split_whitespace())
290 .collect()
291 },
292 )
293 .parse(i)
294 }
295}
296
297#[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#[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#[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}