1use crate::{
4 AntStudentFilePolicy, CompileResult, JvmWrapper, SEPARATOR, TestRun, error::JavaError,
5 java_plugin::JavaPlugin,
6};
7use std::{
8 env,
9 ffi::OsStr,
10 io::{Read, Seek},
11 ops::ControlFlow::{Break, Continue},
12 path::{Path, PathBuf},
13 time::Duration,
14};
15use tmc_langs_framework::{
16 Archive, ExerciseDesc, Language, LanguagePlugin, RunResult, StyleValidationResult, TmcCommand,
17 TmcError, nom::IResult, nom_language::error::VerboseError,
18};
19use tmc_langs_util::{file_util, path_util};
20use walkdir::WalkDir;
21
22pub struct AntPlugin {
23 jvm: JvmWrapper,
24}
25
26impl AntPlugin {
27 pub fn new() -> Result<Self, JavaError> {
28 let jvm = crate::instantiate_jvm()?;
29 Ok(Self { jvm })
30 }
31
32 fn get_ant_executable(&self) -> &'static str {
33 if cfg!(windows) {
34 let command = TmcCommand::piped("ant");
35 if let Ok(status) = command.with(|e| e.arg("-version")).status() {
36 if status.success() {
37 return "ant";
38 }
39 }
40 "ant.bat"
42 } else {
43 "ant"
44 }
45 }
46
47 pub fn copy_tmc_junit_runner(dest_path: &Path) -> Result<(), JavaError> {
50 log::debug!("copying TMC Junit runner");
51
52 let runner_dir = dest_path.join("lib").join("testrunner");
53 let runner_path = runner_dir.join("tmc-junit-runner.jar");
54
55 if !runner_path.exists() {
57 log::debug!("writing tmc-junit-runner to {}", runner_path.display());
58 file_util::write_to_file(super::TMC_JUNIT_RUNNER_BYTES, &runner_path)?;
59 } else {
60 log::debug!("already exists");
61 }
62 Ok(())
63 }
64}
65
66impl LanguagePlugin for AntPlugin {
71 const PLUGIN_NAME: &'static str = "apache-ant";
72 const DEFAULT_SANDBOX_IMAGE: &'static str = "eu.gcr.io/moocfi-public/tmc-sandbox-java:latest";
73 const LINE_COMMENT: &'static str = "//";
74 const BLOCK_COMMENT: Option<(&'static str, &'static str)> = Some(("/*", "*/"));
75 type StudentFilePolicy = AntStudentFilePolicy;
76
77 fn check_code_style(
78 &self,
79 path: &Path,
80 locale: Language,
81 ) -> Result<Option<StyleValidationResult>, TmcError> {
82 Ok(Some(self.run_checkstyle(&locale, path)?))
83 }
84
85 fn scan_exercise(&self, path: &Path, exercise_name: String) -> Result<ExerciseDesc, TmcError> {
87 if !Self::is_exercise_type_correct(path) {
88 return JavaError::InvalidExercise(path.to_path_buf()).into();
89 }
90
91 let compile_result = self.build(path)?;
92 Ok(self.scan_exercise_with_compile_result(path, exercise_name, compile_result)?)
93 }
94
95 fn run_tests_with_timeout(
96 &self,
97 project_root_path: &Path,
98 timeout: Option<Duration>,
99 ) -> Result<RunResult, TmcError> {
100 Ok(self.run_java_tests(project_root_path, timeout)?)
101 }
102
103 fn find_project_dir_in_archive<R: Read + Seek>(
104 archive: &mut Archive<R>,
105 ) -> Result<PathBuf, TmcError> {
106 let mut iter = archive.iter()?;
107 let mut src_parents = vec![];
108 let mut test_parents = vec![];
109 let project_dir = loop {
110 let next = iter.with_next(|file| {
111 let file_path = file.path()?;
112
113 if file.is_file() {
114 if let Some(parent) = path_util::get_parent_of_named(&file_path, "build.xml") {
116 return Ok(Break(Some(parent)));
117 }
118 } else if file.is_dir() {
119 if let Some(src_parent) =
121 path_util::get_parent_of_component_in_path(&file_path, "src")
122 {
123 if test_parents.contains(&src_parent) {
124 return Ok(Break(Some(src_parent)));
126 } else {
127 src_parents.push(src_parent)
128 }
129 }
130
131 if let Some(test_parent) =
133 path_util::get_parent_of_component_in_path(&file_path, "test")
134 {
135 if src_parents.contains(&test_parent) {
136 return Ok(Break(Some(test_parent)));
138 } else {
139 test_parents.push(test_parent)
140 }
141 }
142 }
143
144 Ok(Continue(()))
145 });
146 match next? {
147 Continue(_) => continue,
148 Break(project_dir) => break project_dir,
149 }
150 };
151
152 match project_dir {
153 Some(project_dir) => Ok(project_dir),
154 None => Err(TmcError::NoProjectDirInArchive),
155 }
156 }
157
158 fn is_exercise_type_correct(path: &Path) -> bool {
160 path.join("build.xml").is_file() || path.join("test").is_dir() && path.join("src").is_dir()
161 }
162
163 fn clean(&self, path: &Path) -> Result<(), TmcError> {
164 log::debug!("cleaning project at {}", path.display());
165
166 let stdout_path = path.join("build_log.txt");
168 let stdout = file_util::create_file(&stdout_path)?;
169 let stderr_path = path.join("build_errors.txt");
170 let stderr = file_util::create_file(&stderr_path)?;
171
172 let ant_exec = self.get_ant_executable();
173 let _output = TmcCommand::new(ant_exec)
174 .with(|e| e.arg("clean").stdout(stdout).stderr(stderr).cwd(path))
175 .output_checked()?;
176 file_util::remove_file(&stdout_path)?;
177 file_util::remove_file(&stderr_path)?;
178 Ok(())
179 }
180
181 fn points_parser(i: &str) -> IResult<&str, Vec<&str>, VerboseError<&str>> {
182 Self::java_points_parser(i)
183 }
184
185 fn get_default_student_file_paths() -> Vec<PathBuf> {
186 vec![PathBuf::from("src")]
187 }
188
189 fn get_default_exercise_file_paths() -> Vec<PathBuf> {
190 vec![PathBuf::from("test")]
191 }
192}
193
194impl JavaPlugin for AntPlugin {
195 const TEST_DIR: &'static str = "test";
196
197 fn jvm(&self) -> &JvmWrapper {
198 &self.jvm
199 }
200
201 fn get_project_class_path(&self, path: &Path) -> Result<String, JavaError> {
203 let path = file_util::canonicalize(path)?;
205 let mut paths = vec![];
206
207 let lib_dir = path.join("lib");
209 for entry in WalkDir::new(&lib_dir) {
210 let entry = entry?;
211
212 if entry.path().is_file() && entry.path().extension() == Some(OsStr::new("jar")) {
213 paths.push(entry.path().to_path_buf());
214 }
215 }
216 paths.push(lib_dir);
217 paths.push(path.join("build").join("test").join("classes"));
218 paths.push(path.join("build").join("classes"));
219
220 let java_home = Self::get_java_home()?;
221 let tools_jar_path = java_home.join("..").join("lib").join("tools.jar");
223 if tools_jar_path.exists() {
224 paths.push(tools_jar_path);
225 } else {
226 log::warn!("no tools.jar found; skip adding to class path");
227 }
228
229 let paths = paths
231 .into_iter()
232 .filter_map(|p| p.to_str().map(str::to_string))
233 .collect::<Vec<_>>();
234
235 Self::copy_tmc_junit_runner(&path)?;
237 Ok(paths.join(SEPARATOR))
238 }
239
240 fn build(&self, project_root_path: &Path) -> Result<CompileResult, JavaError> {
241 log::info!("building project at {}", project_root_path.display());
242
243 let ant_exec = self.get_ant_executable();
244 let output = TmcCommand::piped(ant_exec)
245 .with(|e| e.arg("compile-test").cwd(project_root_path))
246 .output()?;
247
248 log::debug!("stdout: {}", String::from_utf8_lossy(&output.stdout));
250 log::debug!("stderr: {}", String::from_utf8_lossy(&output.stderr));
251 let stdout_path = project_root_path.join("build_log.txt");
252 let stderr_path = project_root_path.join("build_errors.txt");
253 file_util::write_to_file(&output.stdout, stdout_path)?;
254 file_util::write_to_file(&output.stderr, stderr_path)?;
255
256 Ok(CompileResult {
257 status_code: output.status,
258 stdout: output.stdout,
259 stderr: output.stderr,
260 })
261 }
262
263 fn create_run_result_file(
264 &self,
265 path: &Path,
266 timeout: Option<Duration>,
267 compile_result: CompileResult,
268 ) -> Result<TestRun, JavaError> {
269 log::info!("running tests for project at {}", path.display());
270
271 let mut arguments = vec![];
273 if let Ok(jvm_options) = env::var("JVM_OPTIONS") {
275 arguments.extend(
276 jvm_options
277 .split(" +")
278 .map(|s| s.trim())
279 .filter(|s| !s.is_empty())
280 .map(|s| s.to_string()),
281 )
282 }
283 let test_dir = path.join("test");
285 let result_file_name = "results.txt";
286 let result_file = path.join(result_file_name);
287 arguments.push(format!("-Dtmc.test_class_dir={}", test_dir.display()));
288 arguments.push(format!("-Dtmc.results_file={result_file_name}"));
291 let endorsed_libs_path = path.join("lib/endorsed");
293 if endorsed_libs_path.exists() {
294 arguments.push(format!(
295 "-Djava.endorsed.dirs={}",
296 endorsed_libs_path.display()
297 ));
298 }
299 let exercise = self.scan_exercise_with_compile_result(
301 path,
302 format!("{}{}", path.display(), "/test"), compile_result,
304 )?;
305 arguments.push("-cp".to_string());
307 let class_path = self.get_project_class_path(path)?;
308 arguments.push(class_path);
309 arguments.push("fi.helsinki.cs.tmc.testrunner.Main".to_string());
311 for desc in exercise.tests {
313 let mut s = String::new();
314 s.push_str(&desc.name.replace(' ', "."));
315 s.push('{');
316 s.push_str(&desc.points.join(","));
317 s.push('}');
318 arguments.push(s);
319 }
320
321 log::debug!("java args '{}' in {}", arguments.join(" "), path.display());
322 let command = TmcCommand::piped("java").with(|e| e.cwd(path).args(&arguments));
323 let output = if let Some(timeout) = timeout {
324 command.output_with_timeout(timeout)?
325 } else {
326 command.output()?
327 };
328
329 Ok(TestRun {
330 test_results: result_file,
331 stdout: output.stdout,
332 stderr: output.stderr,
333 })
334 }
335}
336
337#[cfg(test)]
338#[allow(clippy::unwrap_used)]
339mod test {
340 use super::*;
341 use std::fs;
342 use tmc_langs_framework::{Archive, StyleValidationStrategy};
343 use tmc_langs_util::deserialize;
344 use zip::write::SimpleFileOptions;
345
346 fn init() {
347 use log::*;
348 use simple_logger::*;
349 let _ = SimpleLogger::new()
350 .with_level(LevelFilter::Debug)
351 .with_module_level("j4rs", LevelFilter::Warn)
353 .init();
354 }
355
356 fn file_to(
357 target_dir: impl AsRef<std::path::Path>,
358 target_relative: impl AsRef<std::path::Path>,
359 contents: impl AsRef<[u8]>,
360 ) -> PathBuf {
361 let target = target_dir.as_ref().join(target_relative);
362 if let Some(parent) = target.parent() {
363 std::fs::create_dir_all(parent).unwrap();
364 }
365 std::fs::write(&target, contents.as_ref()).unwrap();
366 target
367 }
368
369 fn dir_to(
370 target_dir: impl AsRef<std::path::Path>,
371 target_relative: impl AsRef<std::path::Path>,
372 ) -> PathBuf {
373 let target = target_dir.as_ref().join(target_relative);
374 std::fs::create_dir_all(&target).unwrap();
375 target
376 }
377
378 fn dir_to_temp(source_dir: impl AsRef<std::path::Path>) -> tempfile::TempDir {
379 let temp = tempfile::TempDir::new().unwrap();
380 for entry in walkdir::WalkDir::new(&source_dir).min_depth(1) {
381 let entry = entry.unwrap();
382 let rela = entry.path().strip_prefix(&source_dir).unwrap();
383 let target = temp.path().join(rela);
384 if entry.path().is_dir() {
385 std::fs::create_dir(target).unwrap();
386 } else if entry.path().is_file() {
387 std::fs::copy(entry.path(), target).unwrap();
388 }
389 }
390 temp
391 }
392
393 fn dir_to_zip(source_dir: impl AsRef<std::path::Path>) -> Vec<u8> {
394 use std::io::Write;
395
396 let mut target = vec![];
397 let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut target));
398
399 for entry in walkdir::WalkDir::new(&source_dir)
400 .min_depth(1)
401 .sort_by(|a, b| a.path().cmp(b.path()))
402 {
403 let entry = entry.unwrap();
404 let rela = entry
405 .path()
406 .strip_prefix(&source_dir)
407 .unwrap()
408 .to_str()
409 .unwrap();
410 if entry.path().is_dir() {
411 zip.add_directory(rela, SimpleFileOptions::default())
412 .unwrap();
413 } else if entry.path().is_file() {
414 zip.start_file(rela, SimpleFileOptions::default()).unwrap();
415 let bytes = std::fs::read(entry.path()).unwrap();
416 zip.write_all(&bytes).unwrap();
417 }
418 }
419
420 zip.finish().unwrap();
421 target
422 }
423
424 #[test]
425 fn copies_tmc_junit_runner() {
426 init();
427
428 let temp = tempfile::TempDir::new().unwrap();
429 let jar_dir = temp.path().join("dir");
430 let jar_path = jar_dir.join("lib/testrunner/tmc-junit-runner.jar");
431 assert!(!jar_path.exists());
432 AntPlugin::copy_tmc_junit_runner(&jar_dir).unwrap();
433 assert!(jar_path.exists());
434 }
435
436 #[test]
437 fn gets_project_class_path() {
438 init();
439
440 let temp = tempfile::TempDir::new().unwrap();
441 let test_path = temp.path().join("dir");
442 file_to(&test_path, "lib/junit-4.13.2.jar", "");
443 file_to(&test_path, "lib/edu-test-utils-0.5.0.jar", "");
444
445 let plugin = AntPlugin::new().unwrap();
446 let cp = plugin.get_project_class_path(&test_path).unwrap();
447
448 let test_path = file_util::canonicalize(&test_path).unwrap();
450 let sep = std::path::MAIN_SEPARATOR;
451 let expected_junit = format!("{0}{1}lib{1}junit-4.13.2.jar", test_path.display(), sep);
452 assert!(
453 cp.contains(&expected_junit),
454 "Classpath {cp} did not contain junit (looked for {expected_junit})",
455 );
456 let expected_utils = format!(
457 "{0}{1}lib{1}edu-test-utils-0.5.0.jar",
458 test_path.display(),
459 sep
460 );
461 assert!(
462 cp.contains(&expected_utils),
463 "Classpath {cp} did not contain edu-test-utils (looked for {expected_utils}",
464 );
465 let expected_classes = format!("{0}{1}build{1}classes", test_path.display(), sep);
466 assert!(
467 cp.contains(&expected_classes),
468 "Classpath {cp} did not contain build{sep}classes (looked for {expected_classes}",
469 );
470 let expected_test_classes =
471 format!("{0}{1}build{1}test{1}classes", test_path.display(), sep);
472 assert!(
473 cp.contains(&expected_test_classes),
474 "Classpath {cp} did not contain build/test/classes (looked for {expected_test_classes}",
475 );
476 }
485
486 #[test]
487 fn builds() {
488 init();
489
490 let temp_dir = dir_to_temp("tests/data/ant-exercise");
491 let plugin = AntPlugin::new().unwrap();
492 let compile_result = plugin.build(temp_dir.path()).unwrap();
493 assert!(compile_result.status_code.success());
494 }
498
499 #[test]
500 fn creates_run_result_file() {
501 init();
502
503 let temp_dir = dir_to_temp("tests/data/ant-exercise");
504 let plugin = AntPlugin::new().unwrap();
505 let compile_result = plugin.build(temp_dir.path()).unwrap();
506 let test_run = plugin
507 .create_run_result_file(temp_dir.path(), None, compile_result)
508 .unwrap();
509 log::trace!("stdout: {}", String::from_utf8_lossy(&test_run.stdout));
510 log::debug!("stderr: {}", String::from_utf8_lossy(&test_run.stderr));
511 let res = fs::read_to_string(test_run.test_results).unwrap();
515 let test_cases: Vec<super::super::TestCase> = deserialize::json_from_str(&res).unwrap();
516
517 let test_case = &test_cases[0];
518 assert_eq!(test_case.class_name, "ArithTest");
519 assert_eq!(test_case.method_name, "testAdd");
520 assert_eq!(test_case.status, super::super::TestCaseStatus::Passed);
521 assert_eq!(test_case.point_names[0], "arith-funcs");
522 assert!(test_case.message.is_none());
523 assert!(test_case.exception.is_none());
524
525 let test_case = &test_cases[1];
526 assert_eq!(test_case.class_name, "ArithTest");
527 assert_eq!(test_case.method_name, "testSub");
528 assert_eq!(test_case.status, super::super::TestCaseStatus::Failed);
529 assert_eq!(test_case.point_names[0], "arith-funcs");
530 assert!(test_case.message.as_ref().unwrap().starts_with("expected:"));
531
532 let exception = test_case.exception.as_ref().unwrap();
533 assert!(exception.message.as_ref().unwrap().starts_with("expected:"));
535 let stack_trace = &exception.stack_trace[0];
538 assert_eq!(stack_trace.declaring_class, "org.junit.Assert");
539 assert_eq!(stack_trace.file_name.as_ref().unwrap(), "Assert.java");
540 assert_eq!(stack_trace.method_name, "fail");
541 }
542
543 #[test]
544 fn scans_exercise() {
545 init();
546
547 let temp_dir = dir_to_temp("tests/data/ant-exercise");
548 let plugin = AntPlugin::new().unwrap();
549 let exercises = plugin
550 .scan_exercise(temp_dir.path(), "test".to_string())
551 .unwrap();
552 assert_eq!(exercises.name, "test");
553 assert_eq!(exercises.tests.len(), 4);
554 assert_eq!(exercises.tests[0].name, "ArithTest testAdd");
555 assert_eq!(exercises.tests[0].points, ["arith-funcs"]);
556 }
557
558 #[test]
559 fn runs_checkstyle() {
560 init();
561
562 let temp_dir = dir_to_temp("tests/data/ant-exercise");
563 let plugin = AntPlugin::new().unwrap();
564 let checkstyle_result = plugin
565 .check_code_style(temp_dir.path(), Language::from_639_3("fin").unwrap())
566 .unwrap()
567 .unwrap();
568
569 assert_eq!(checkstyle_result.strategy, StyleValidationStrategy::Fail);
570 let validation_errors = checkstyle_result.validation_errors.unwrap();
571 let errors = validation_errors.get(Path::new("Arith.java")).unwrap();
572 assert_eq!(errors.len(), 1);
573 let error = &errors[0];
574 assert_eq!(error.column, 0);
575 assert_eq!(error.line, 7);
576 assert!(error.message.starts_with("Sisennys väärin"));
577 assert_eq!(
578 error.source_name,
579 "com.puppycrawl.tools.checkstyle.checks.indentation.IndentationCheck"
580 );
581 }
582
583 #[test]
584 fn runs_tests() {
585 init();
586
587 let temp_dir = dir_to_temp("tests/data/ant-exercise");
588 let plugin = AntPlugin::new().unwrap();
589 let test_result = plugin
590 .run_tests_with_timeout(Path::new(temp_dir.path()), None)
591 .unwrap();
592 log::debug!("{test_result:?}");
593 assert_eq!(
594 test_result.status,
595 tmc_langs_framework::RunStatus::TestsFailed
596 );
597 }
598
599 #[test]
600 fn runs_tests_with_timeout() {
601 init();
602
603 let temp_dir = dir_to_temp("tests/data/ant-exercise");
604 let plugin = AntPlugin::new().unwrap();
605 let test_result_err = plugin
606 .run_tests_with_timeout(Path::new(temp_dir.path()), Some(Duration::from_nanos(1)))
607 .unwrap_err();
608 log::debug!("{test_result_err:?}");
609
610 use std::error::Error;
612 let mut source = test_result_err.source();
613 while let Some(inner) = source {
614 source = inner.source();
615 if let Some(cmd_error) = inner.downcast_ref::<tmc_langs_framework::CommandError>() {
616 if matches!(cmd_error, tmc_langs_framework::CommandError::TimeOut { .. }) {
617 return;
618 }
619 }
620 }
621 panic!("no timeout error found");
622 }
623
624 #[test]
625 fn exercise_type_is_correct() {
626 let temp = tempfile::tempdir().unwrap();
627 file_to(&temp, "build.xml", "");
628 assert!(AntPlugin::is_exercise_type_correct(temp.path()));
629
630 let temp = tempfile::tempdir().unwrap();
631 dir_to(&temp, "test");
632 dir_to(&temp, "src");
633 assert!(AntPlugin::is_exercise_type_correct(temp.path()));
634 }
635
636 #[test]
637 fn exercise_type_is_not_correct() {
638 let temp = tempfile::tempdir().unwrap();
639 file_to(&temp, "buid.xml", "");
640 file_to(&temp, "dir/build.xml", "");
641 file_to(&temp, "test", "");
642 dir_to(&temp, "src");
643 assert!(!AntPlugin::is_exercise_type_correct(temp.path()));
644 }
645
646 #[test]
647 fn cleans() {
648 init();
649
650 let temp_dir = dir_to_temp("tests/data/ant-exercise");
651 let test_path = temp_dir.path();
652 let plugin = AntPlugin::new().unwrap();
653 plugin.clean(test_path).unwrap();
654 }
655
656 #[test]
657 fn finds_project_dir_in_zip() {
658 init();
659
660 let temp_dir = tempfile::tempdir().unwrap();
661 dir_to(&temp_dir, "Outer/Inner/ant-exercise/src");
662 dir_to(&temp_dir, "Outer/Inner/ant-exercise/test");
663
664 let zip_contents = dir_to_zip(&temp_dir);
665 let mut zip = Archive::zip(std::io::Cursor::new(zip_contents)).unwrap();
666 let dir = AntPlugin::find_project_dir_in_archive(&mut zip).unwrap();
667 assert_eq!(dir, Path::new("Outer/Inner/ant-exercise"));
668 }
669
670 #[test]
671 fn finds_project_dir_in_zip_build() {
672 init();
673
674 let temp_dir = tempfile::tempdir().unwrap();
675 file_to(&temp_dir, "Outer/Inner/ant-exercise/build.xml", "build!");
676
677 let zip_contents = dir_to_zip(&temp_dir);
678 let mut zip = Archive::zip(std::io::Cursor::new(zip_contents)).unwrap();
679 let dir = AntPlugin::find_project_dir_in_archive(&mut zip).unwrap();
680 assert_eq!(dir, Path::new("Outer/Inner/ant-exercise"));
681 }
682
683 #[test]
684 fn doesnt_find_project_dir_in_zip() {
685 init();
686
687 let temp_dir = tempfile::tempdir().unwrap();
688 dir_to(&temp_dir, "Outer/Inner/ant-exercise/srcb");
689
690 let zip_contents = dir_to_zip(&temp_dir);
691 let mut zip = Archive::zip(std::io::Cursor::new(zip_contents)).unwrap();
692 let dir = AntPlugin::find_project_dir_in_archive(&mut zip);
693 assert!(dir.is_err());
694 }
695}