diff --git a/task-maker-format/src/ioi/sanity_checks/statement.rs b/task-maker-format/src/ioi/sanity_checks/statement.rs index 81745ae8f..a132fae5a 100644 --- a/task-maker-format/src/ioi/sanity_checks/statement.rs +++ b/task-maker-format/src/ioi/sanity_checks/statement.rs @@ -3,7 +3,7 @@ use std::os::unix::ffi::OsStrExt; use std::path::{Path, PathBuf}; use std::process::Command; -use anyhow::{Context, Error}; +use anyhow::{bail, Context, Error}; use itertools::Itertools; use regex::Regex; use task_maker_diagnostics::{CodeSpan, Diagnostic}; @@ -12,6 +12,70 @@ use crate::ioi::{IOITask, SubtaskId}; use crate::sanity_checks::{make_sanity_check, SanityCheck, SanityCheckCategory}; use crate::EvaluationData; +/// List of languages supported by CMS for statements +const LANGUAGES: [&str; 60] = [ + "afrikaans", + "arabic", + "armenian", + "azerbaijani", + "belarusian", + "bengali", + "bosnian", + "bulgarian", + "catalan", + "chinese", + "croatian", + "czech", + "danish", + "dutch", + "english", + "estonian", + "filipino", + "finnish", + "french", + "georgian", + "german", + "greek", + "hebrew", + "hindi", + "hungarian", + "icelandic", + "indonesian", + "irish", + "italian", + "japanese", + "kazakh", + "korean", + "kyrgyz", + "latvian", + "lithuanian", + "macedonian", + "malay", + "mongolian", + "norwegian", + "persian", + "polish", + "portuguese", + "romanian", + "russian", + "serbian", + "sinhala", + "slovak", + "slovene", + "spanish", + "swedish", + "tajik", + "tamil", + "thai", + "turkish", + "turkmen", + "ukrainian", + "urdu", + "uzbek", + "vietnamese", + "other", +]; + /// Check that the subtasks in the statement are consistent with the ones of the task. #[derive(Debug, Default)] pub struct StatementSubtasks; @@ -107,7 +171,8 @@ impl SanityCheck for StatementSubtasks { } } -/// Check that the statement file is valid. +/// Check that there is at least a statement file, and that all statement +/// files are valid #[derive(Debug, Default)] pub struct StatementValid; make_sanity_check!(StatementValid); @@ -124,66 +189,72 @@ impl SanityCheck for StatementValid { } fn post_hook(&self, task: &IOITask, eval: &mut EvaluationData) -> Result<(), Error> { - match find_statement_pdf(task) { - None => { - let mut diagnostic = Diagnostic::error( - "Missing statement file (statement/statement.pdf or testo/testo.pdf)", - ) - .with_note("Without that file cms will not be able to import the task"); - if let Some(booklet) = task.booklets.first() { - let name = booklet.dest.file_name().unwrap(); - let name = Path::new(name); - diagnostic = diagnostic - .with_help(format!("Try: ln -s {} testo/testo.pdf", name.display())); - }; - eval.add_diagnostic(diagnostic)?; - } - Some(path) => { - // normal file or valid symlink - if path.exists() { - let mut file = std::fs::File::open(&path).with_context(|| { - format!("Failed to open statement file at {}", path.display()) - })?; - let mut buf = [0u8; 4]; - let invalid = match file.read_exact(&mut buf) { - Err(_) => true, - Ok(_) => { - // check PDF magic number - &buf != b"%PDF" - } - }; - - if invalid { - eval.add_diagnostic(Diagnostic::error(format!( - "Invalid PDF file at {}", - task.path_of(&path).display() - )))?; - } - return Ok(()); - } - // broken symlink - else if path.read_link().is_ok() { + let mut found_valid_statement = false; + + let check_statement = |path: &Path| -> Result { + // normal file or valid symlink + if path.exists() { + if check_valid_pdf(path)? { + return Ok(true); + } else { eval.add_diagnostic(Diagnostic::error(format!( - "Statement {} is a broken link", - task.path_of(&path).display() + "Invalid PDF file at {}", + task.path_of(path).display() )))?; } } + // broken symlink + else if path.read_link().is_ok() { + eval.add_diagnostic(Diagnostic::error(format!( + "Statement {} is a broken link", + task.path_of(path).display() + )))?; + } + Ok(false) + }; + + if let Some(path) = find_statement_pdf(task) { + eval.add_diagnostic( + Diagnostic::warning(format!( + "Found statement at {}", + task.path_of(&path).display() + )) + .with_note("This is deprecated, use a language specific statement instead"), + )?; + + found_valid_statement |= check_statement(&path)?; + } + + for language in LANGUAGES { + if let Some(path) = find_language_statement_pdf(task, language) { + found_valid_statement |= check_statement(&path)?; + } } + + if !found_valid_statement { + eval.add_diagnostic( + Diagnostic::error("There is no functioning statement file").with_note(format!( + "Consider adding a statement in any of the languages supported by CMS ({})", + LANGUAGES.join(", ") + )), + )?; + } + Ok(()) } } -/// Check that the statement file comes out of the compilation of one of the booklets. +/// Check that the statement files come out of the compilation of one of the booklets, +/// or that they are at least known to git #[derive(Debug, Default)] -pub struct StatementCompiled; -make_sanity_check!(StatementCompiled); +pub struct StatementCompiledOrGit; +make_sanity_check!(StatementCompiledOrGit); -impl SanityCheck for StatementCompiled { +impl SanityCheck for StatementCompiledOrGit { type Task = IOITask; fn name(&self) -> &'static str { - "StatementCompiled" + "StatementCompiledOrGit" } fn category(&self) -> SanityCheckCategory { @@ -191,98 +262,87 @@ impl SanityCheck for StatementCompiled { } fn post_hook(&self, task: &IOITask, eval: &mut EvaluationData) -> Result<(), Error> { - // If there are no booklets it may mean that the statement is compiled with an external tool - // or that the statement compilation is not done. Either way this sanity check should be - // ignored. - if task.booklets.is_empty() { - return Ok(()); - } + // the statements compiled by us + let booklet_dest = task + .booklets + .iter() + .map(|booklet| booklet.dest.canonicalize()) + .filter_map(Result::ok) + .collect::>(); - let path = match find_statement_pdf(task) { - Some(path) => path, - _ => return Ok(()), - }; - // The source of the actual statement pdf (symlinks resolved). If the symlink is broken, - // there's nothing we can do (another sanity check will warn this error). - let target = match path.canonicalize() { - Ok(path) => path, - _ => return Ok(()), - }; + let booklet_dest_list = booklet_dest + .iter() + .map(|p| task.path_of(p)) + .map(Path::to_string_lossy) + .join(", "); + + let check_statement = |path: &PathBuf| -> Result<(), Error> { + // The file is a symlink but it not known to git + if path.is_symlink() && !check_known_to_git(task, path)? { + eval.add_diagnostic( + Diagnostic::error(format!( + "The official statement at {} is a symbolic link and not known to git", + task.path_of(path).display() + )) + .with_note( + "This means that it won't be available outside of your local directory", + ) + .with_help(format!("Try git add -f {}", task.path_of(path).display())), + )?; + } - let mut booklet_dest = vec![]; - for booklet in &task.booklets { - let dest = match booklet.dest.canonicalize() { - Ok(dest) => dest, - _ => continue, + // If the file is a broken symlink, we cannot check anything. + // Another sanity check will warn the issue. + let Ok(target) = &path.canonicalize() else { + return Ok(()); }; - // this booklet corresponds to the official statement file, so we are good! - if dest == target { + + let relative_target = resolve_symlink(path)?; + + if booklet_dest.contains(target) { return Ok(()); } - booklet_dest.push(dest); - } - // We didn't find any compiled booklet referring to the official statement, this means that - // the statement that will be used isn't the one compiled by us. - let booklet_dest = booklet_dest - .iter() - .map(|p| task.path_of(p)) - .map(|p| p.to_string_lossy()) - .join(", "); - eval.add_diagnostic( - Diagnostic::warning(format!( - "The official statement at {} is not the one compiled by task-maker", - task.path_of(&path).display() - )) - .with_help(format!( - "Maybe it should be a symlink to one of the compiled PDF ({})", - booklet_dest - )), - )?; - Ok(()) - } -} + // We didn't find any compiled booklet referring to the official statement, this means that + // the statement that will be used isn't the one compiled by us. -/// Check that the statement file is known to git. -#[derive(Debug, Default)] -pub struct StatementGit; -make_sanity_check!(StatementGit); + eval.add_diagnostic( + Diagnostic::warning(format!( + "The official statement at {} is not the one compiled by task-maker", + task.path_of(target).display() + )) + .with_help(format!( + "Maybe it should be a symlink to one of the compiled PDF ({})", + booklet_dest_list + )), + )?; -impl SanityCheck for StatementGit { - type Task = IOITask; + if check_known_to_git(task, task.path_of(&relative_target))? { + return Ok(()); + } - fn name(&self) -> &'static str { - "StatementGit" - } + // The statement is not known to git - fn category(&self) -> SanityCheckCategory { - SanityCheckCategory::Statement - } + eval.add_diagnostic( + Diagnostic::error(format!( + "The official statement at {} is not compiled by task-maker and not known to git", + task.path_of(&relative_target).display() + )) + .with_note("This means that it won't be available outside of your local directory") + .with_help(format!("Try git add -f {}", task.path_of(&relative_target).display())) + )?; - fn post_hook(&self, task: &IOITask, eval: &mut EvaluationData) -> Result<(), Error> { - let path = match find_statement_pdf(task) { - None => return Ok(()), - Some(path) => path, - }; - let path = task.path_of(&path); - let raw_path = path.as_os_str().as_bytes(); - let mut command = Command::new("git"); - command.arg("ls-files").arg("-z").current_dir(&task.path); - let output = match command.output() { - // git not available - Err(_) => return Ok(()), - Ok(output) => output, + Ok(()) }; - // not a git repo - if !output.status.success() { - return Ok(()); + + if let Some(path) = find_statement_pdf(task) { + check_statement(&path)?; } - // file not know to git - if !output.stdout.is_empty() && !output.stdout.split(|&b| b == 0).any(|p| p == raw_path) { - eval.add_diagnostic( - Diagnostic::error(format!("File {} is not known to git", path.display())) - .with_help(format!("Try git add -f {}", path.display())), - )?; + + for language in LANGUAGES { + if let Some(path) = find_language_statement_pdf(task, language) { + check_statement(&path)?; + } } Ok(()) @@ -405,6 +465,19 @@ fn extract_subtasks(path: &Path, tex: &str) -> Option> { .or_else(|| check_subtasks_ois(path, tex)) } +fn resolve_symlink(path: &Path) -> Result { + let mut path = path.to_path_buf(); + let mut depth = 0; + while path.is_symlink() { + if depth >= 40 { + bail!("Too many level of symbolic links"); + } + path = path.read_link()?; + depth += 1; + } + Ok(path) +} + /// Search for the statement file, returning its path or None if it doesn't exists. /// /// Will return the path even in case of broken links. @@ -417,3 +490,61 @@ fn find_statement_pdf(task: &IOITask) -> Option { } None } + +/// Search for a language-specific statement file, returning its path or None if it doesn't exists. +/// +/// Will return the path even in case of broken links. +fn find_language_statement_pdf(task: &IOITask, language: &str) -> Option { + for path in &[ + format!("statement/{language}.pdf"), + format!("testo/{language}.pdf"), + ] { + let path = task.path.join(path); + if path.exists() || path.read_link().is_ok() { + return Some(path); + } + } + None +} + +/// Checks whether a file is a valid PDF file +fn check_valid_pdf(path: &Path) -> Result { + let mut file = std::fs::File::open(path) + .with_context(|| format!("Failed to open statement file at {}", path.display()))?; + let mut buf = [0u8; 4]; + + let valid = match file.read_exact(&mut buf) { + Err(_) => false, + Ok(_) => { + // check PDF magic number + &buf == b"%PDF" + } + }; + + Ok(valid) +} + +/// Checks whether a file is known to git +/// +/// If git is not present, there is no git repository, or no file is tracked at all +/// this will behave as if the file is known. +fn check_known_to_git(task: &IOITask, path: &Path) -> Result { + let raw_path = path.as_os_str().as_bytes(); + + let mut command = Command::new("git"); + command.arg("ls-files").arg("-z").current_dir(&task.path); + + let Ok(output) = command.output() else { + // git is not available + return Ok(true); + }; + + // not a git repo + if !output.status.success() { + return Ok(true); + } + + let known = output.stdout.is_empty() || output.stdout.split(|&b| b == 0).any(|p| p == raw_path); + + Ok(known) +} diff --git a/task-maker-format/tests/ioi_sanity_checks.rs b/task-maker-format/tests/ioi_sanity_checks.rs index d0f680511..ff91ec232 100644 --- a/task-maker-format/tests/ioi_sanity_checks.rs +++ b/task-maker-format/tests/ioi_sanity_checks.rs @@ -267,7 +267,7 @@ fn test_sanity_checks_statement_valid_missing() { let task = utils::new_task_with_context(tmpdir.path()); let warnings = get_post_warnings(&task); - has_warning(&warnings, "Missing statement file"); + has_warning(&warnings, "There is no functioning statement file"); } #[test] @@ -351,7 +351,7 @@ fn test_sanity_checks_statement_git_untracked() { let warnings = get_post_warnings(&task); has_warning( &warnings, - "File statement/statement.pdf is not known to git", + "The official statement at statement/statement.pdf is not compiled by task-maker and not known to git", ); } @@ -382,7 +382,7 @@ fn test_sanity_checks_statement_git_ignored() { let warnings = get_post_warnings(&task); has_warning( &warnings, - "File statement/statement.pdf is not known to git", + "The official statement at statement/statement.pdf is not compiled by task-maker and not known to git", ); }