From 2df5dfb5d3e47e5e4c5b13040fa6d0830ca86e35 Mon Sep 17 00:00:00 2001 From: SteveLauC Date: Fri, 4 Oct 2024 13:50:25 +0800 Subject: [PATCH] feat: check use of keys that do not exist (#6) * feat: check use of keys that do not exist * refactor: rust_src_to_check() remove support for symlink * style: clippy * feat: check use of keys that do not exist --- Cargo.lock | 298 +++++++++++++++++++++++- Cargo.toml | 7 + src/checker.rs | 7 +- src/cli_opt.rs | 133 +++++++++++ src/{parse.rs => locale_file_parser.rs} | 1 + src/locale_key_collector.rs | 184 +++++++++++++++ src/main.rs | 33 ++- src/rules/key_and_eng_matches.rs | 6 +- src/rules/missing_translations.rs | 6 +- src/rules/mod.rs | 13 +- src/rules/use_of_keys_do_not_exist.rs | 29 +++ 11 files changed, 695 insertions(+), 22 deletions(-) create mode 100644 src/cli_opt.rs rename src/{parse.rs => locale_file_parser.rs} (98%) create mode 100644 src/locale_key_collector.rs create mode 100644 src/rules/use_of_keys_do_not_exist.rs diff --git a/Cargo.lock b/Cargo.lock index be11d21..70cb94e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,24 +2,147 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "anstream" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64e15c1ab1f89faffbf04a634d5e1962e9074f2741eef6d97f3c4e322426d526" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bec1de6f59aedf83baf9ff929c98f2ad654b97c9510f4e70cf6f661d49fd5b1" + +[[package]] +name = "anstyle-parse" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb47de1e80c2b463c735db5b217a0ddc39d612e7ac9e2e96a5aed1f57616c1cb" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d36fc52c7f6c869915e99412912f22093507da8d9e942ceaf66fe4b7c14422a" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bf74e1b6e971609db8ca7a9ce79fd5768ab6ae46441c572e46cf596f59e57f8" +dependencies = [ + "anstyle", + "windows-sys 0.52.0", +] + [[package]] name = "bitflags" version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "clap" +version = "4.5.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7be5744db7978a28d9df86a214130d106a89ce49644cbc4e3f0c22c3fba30615" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5fbc17d3ef8278f55b282b2a2e75ae6f6c7d4bb70ed3d0382375104bfafdb4b" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ac6a0c7b1a9e9a5186361f67dfa1b88213572f427fb9ab038efb2bd8c582dab" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97" + +[[package]] +name = "colorchoice" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0" + [[package]] name = "equivalent" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +[[package]] +name = "errno" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "fastrand" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6" + [[package]] name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "indexmap" version = "2.2.6" @@ -30,12 +153,30 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + [[package]] name = "itoa" version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" +[[package]] +name = "libc" +version = "0.2.159" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "561d97a539a36e26a9a5fad1ea11a3039a67714694aaa379433e580854bc3dc5" + +[[package]] +name = "linux-raw-sys" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" + [[package]] name = "once_cell" version = "1.19.0" @@ -60,12 +201,34 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rustix" +version = "0.38.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8acb788b847c24f28525660c4d7758620a7210875711f79e7f663cc152726811" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + [[package]] name = "ryu" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "serde" version = "1.0.204" @@ -99,25 +262,49 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "syn" -version = "2.0.71" +version = "2.0.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b146dcf730474b4bcd16c311627b31ede9ab149045db4d6088b3becaea046462" +checksum = "89132cd0bf050864e1d38dc3bbc07a0eb8e7530af26344d3d2bbbef83499f590" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f2c9fc62d0beef6951ccffd757e241266a2c833136efbe35af6cd2567dca5b" +dependencies = [ + "cfg-if", + "fastrand", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + [[package]] name = "topgrade_i18n_locale_file_checker" version = "0.1.0" dependencies = [ "bitflags", + "clap", "indexmap", "once_cell", + "proc-macro2", "serde_yaml_ng", + "syn", + "tempfile", + "walkdir", ] [[package]] @@ -131,3 +318,110 @@ name = "unsafe-libyaml" version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" diff --git a/Cargo.toml b/Cargo.toml index 3dfce92..3dbbcc5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,13 @@ edition = "2021" [dependencies] bitflags = "2.6.0" +clap = { version = "4.5.19", features = ["derive"] } indexmap = "2.2.6" once_cell = "1.19.0" +proc-macro2 = { version = "1.0.86", features = ["span-locations"] } serde_yaml_ng = "0.10.0" +syn = { version = "2.0.79", features = ["full", "visit"] } +walkdir = "2.5.0" + +[dev-dependencies] +tempfile = "3.13.0" \ No newline at end of file diff --git a/src/checker.rs b/src/checker.rs index a6534f4..d2ce49e 100644 --- a/src/checker.rs +++ b/src/checker.rs @@ -1,6 +1,7 @@ //! This file contains the checker type. -use crate::parse::LocalizedTexts; +use crate::locale_file_parser::LocalizedTexts; +use crate::locale_key_collector::LocaleKey; use crate::rules::Rule; use crate::rules::ERROR_STORAGE; @@ -22,9 +23,9 @@ impl Checker { } /// Run the check process. - pub(crate) fn check(&self, localized_texts: &LocalizedTexts) { + pub(crate) fn check(&self, localized_texts: &LocalizedTexts, locale_keys: &[LocaleKey]) { for rule in self.rules.iter() { - rule.check(localized_texts) + rule.check(localized_texts, locale_keys) } } diff --git a/src/cli_opt.rs b/src/cli_opt.rs new file mode 100644 index 0000000..5d80c7e --- /dev/null +++ b/src/cli_opt.rs @@ -0,0 +1,133 @@ +//! This module defines this tool's CLI options. + +use clap::Parser; +use std::{ + borrow::Cow, + path::{Path, PathBuf}, +}; + +#[derive(Parser, Debug)] +pub(crate) struct Cli { + /// The path to the locale file + #[arg(long)] + locale_file: PathBuf, + /// Rust files to check. + /// + /// If any path points to a directory, then all the Rust files in that directory + /// will be checked. + #[arg(long, required = true)] + rust_src_to_check: Vec, +} + +impl Cli { + /// Accesses the `--locale-file` option. + pub(crate) fn locale_file(&self) -> &Path { + &self.locale_file + } + + /// Flattens the input paths and returns it. + /// + /// For directories, it will walk through the directory and get all the Rust + /// files. + /// + /// Symlink will be silently ignored. + pub(crate) fn rust_src_to_check(&self) -> Vec> { + let mut rust_files_to_check = Vec::with_capacity(self.rust_src_to_check.len()); + + for entry_path in self.rust_src_to_check.iter() { + let entry_metadata = std::fs::symlink_metadata(entry_path).unwrap_or_else(|e| { + panic!( + "Error: cannot get the metadata of the specified file {} due to error {:?}", + entry_path.display(), + e + ) + }); + + if entry_metadata.is_file() { + if is_rust_file(entry_path) { + rust_files_to_check.push(Cow::Borrowed(entry_path.as_path())); + } + } else if entry_metadata.is_dir() { + let walk_dir_iter = walkdir::WalkDir::new(entry_path); + for res_entry in walk_dir_iter { + let entry = res_entry.unwrap_or_else(|e| { + panic!( + "Error: cannot get the entry of the specified file due to error {:?}", + e + ) + }); + + let entry_path = entry.path(); + let entry_metadata = entry.metadata().unwrap_or_else(|e| { + panic!( + "Error: cannot get the metadata of the specified file {} due to error {:?}", + entry_path.display(), + e + ) + }); + + if entry_metadata.is_file() && is_rust_file(entry_path) { + rust_files_to_check.push(Cow::Owned(entry_path.to_path_buf())); + } + } + } + } + + rust_files_to_check + } +} + +/// Returns if the given path points to a Rust file by checking its file extension. +fn is_rust_file + ?Sized>(file_path: &P) -> bool { + const RUST_FILE_EXTENSION: &str = "rs"; + + if let Some(extension) = file_path.as_ref().extension() { + if extension == RUST_FILE_EXTENSION { + return true; + } + } + + false +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn test_cli_rust_src_to_check() { + let root_tempdir = tempdir().unwrap(); + let root_tempdir_path = root_tempdir.path(); + + let file_foo = root_tempdir_path.join("foo"); + std::fs::File::create_new(&file_foo).unwrap(); + let file_bar_rs = root_tempdir_path.join("bar.rs"); + std::fs::File::create_new(&file_bar_rs).unwrap(); + let dir_baz = root_tempdir_path.join("baz"); + std::fs::create_dir(&dir_baz).unwrap(); + let file_qux_rs_under_dir_baz = dir_baz.join("qux.rs"); + std::fs::File::create_new(&file_qux_rs_under_dir_baz).unwrap(); + + let cli = Cli { + // This field won't be used so let's give it a NULL value + locale_file: PathBuf::new(), + rust_src_to_check: vec![file_foo.clone(), file_bar_rs.clone(), dir_baz.clone()], + }; + + let flattened = cli.rust_src_to_check(); + assert_eq!( + flattened, + [file_bar_rs.clone(), file_qux_rs_under_dir_baz.clone()] + ); + + let file_quux_rs_under_dir_baz = dir_baz.join("quux"); + std::fs::File::create_new(&file_quux_rs_under_dir_baz).unwrap(); + + let flattened = cli.rust_src_to_check(); + assert_eq!( + flattened, + [file_bar_rs.clone(), file_qux_rs_under_dir_baz.clone()] + ); + } +} diff --git a/src/parse.rs b/src/locale_file_parser.rs similarity index 98% rename from src/parse.rs rename to src/locale_file_parser.rs index 8b802e1..bb1f764 100644 --- a/src/parse.rs +++ b/src/locale_file_parser.rs @@ -40,6 +40,7 @@ impl Translations { /// Represents all the localized texts used by Topgrade. #[derive(Debug)] pub(crate) struct LocalizedTexts { + /// Locale key => All the translations. pub(crate) texts: IndexMap, } diff --git a/src/locale_key_collector.rs b/src/locale_key_collector.rs new file mode 100644 index 0000000..3121d5a --- /dev/null +++ b/src/locale_key_collector.rs @@ -0,0 +1,184 @@ +//! This files contains a `LocaleKeyCollector` type that finds the invocation +//! of `rust_i18n::t!()` in Topgrade's source code and extracts the locale +//! key. + +use proc_macro2::TokenTree; +use std::borrow::Cow; +use std::path::Path; +use syn::spanned::Spanned; +use syn::visit::Visit; + +/// A collector that finds the invocation of `rust_i18n::t!()` macro and collects +/// its locale key. +#[derive(Debug)] +pub(crate) struct LocaleKeyCollector<'path> { + /// Collected locale keys. + locale_keys: Vec>, +} + +impl<'path> LocaleKeyCollector<'path> { + /// Creates a new collector with keys set empty. + pub(crate) fn new() -> Self { + Self { + locale_keys: Vec::new(), + } + } + + /// Collects the invocation of `t!()` from `files`. + pub(crate) fn collect(&mut self, files: &'path [Cow<'path, Path>]) { + for file in files { + let str = std::fs::read_to_string(file) + .unwrap_or_else(|err| panic!("failed to read file {}: {}", file.display(), err)); + let parsed_file = syn::parse_file(&str) + .unwrap_or_else(|e| panic!("failed to parse file {} due to {}", file.display(), e)); + + let mut single_file_collector = SingleFileLocalenKeyCollector { + file, + locale_keys: Vec::new(), + }; + + single_file_collector.visit_file(&parsed_file); + + self.locale_keys + .extend(single_file_collector.locale_keys.into_iter()); + } + } + + /// Gets the reference to the collected locale keys. + pub(crate) fn locale_keys(&self) -> &[LocaleKey<'path>] { + &self.locale_keys + } +} + +/// Collector that is responsible for a single file. +/// +/// # NOTE +/// This is a workaround to enable us to have the file path info while +/// invoking `visit_macro()`. +struct SingleFileLocalenKeyCollector<'path> { + /// File path. + file: &'path Path, + /// Keys collected from `file`. + locale_keys: Vec>, +} + +impl<'ast, 'path> Visit<'ast> for SingleFileLocalenKeyCollector<'path> { + fn visit_macro(&mut self, i: &'ast syn::Macro) { + let path_segments = &i.path.segments; + let path_segments_len = path_segments.len(); + + let last_segment = path_segments + .last() + .expect("macro invocation should have at least 1 path segment"); + if last_segment.ident == "t" { + // invocation: t!() + if path_segments_len == 1 { + self.locale_keys.push(LocaleKey::new(i, self.file)); + } + + if path_segments_len == 2 { + let first_segment = path_segments.get(0).expect("len == 2"); + // invocation: rust_i18n::t!() + if first_segment.ident == "rust_i18n" { + self.locale_keys.push(LocaleKey::new(i, self.file)); + } + } + } + + syn::visit::visit_macro(self, i); + } +} + +/// Info about a locale key. +#[derive(Debug, PartialEq)] +pub(crate) struct LocaleKey<'path> { + /// Locale key. + pub(crate) key: String, + /// path of the file where the `t!()` macro is invoked. + pub(crate) file: &'path Path, + /// Line number of the start of invocation, starts from 1. + pub(crate) line: usize, + /// Column number of the start of invocation, starts from 0. + pub(crate) column: usize, +} + +impl<'path> LocaleKey<'path> { + /// Constructs a `LocaleKey` from the given info. + fn new(mac: &syn::Macro, file: &'path Path) -> Self { + let token_stream = mac.tokens.clone(); + + let mut token_tree_iter = token_stream.into_iter(); + let translation_key = token_tree_iter + .next() + .expect("t!() needs at least 1 argument"); + let key = match translation_key { + TokenTree::Literal(literal) => literal.to_string().trim_matches('"').to_string(), + _ => panic!("The first argument to t!() should be a string literal"), + }; + + let span = mac.span(); + let start = span.start(); + let line = start.line; + let column = start.column; + + Self { + key, + file, + line, + column, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn test_single_file_collector_works() { + let file_contents = r#"t!("first_key"); + rust_i18n::t!("second_key"); +foo::bar::t!("not a key"); +::foo::bar::t!("not a key"); +"#; + let path = PathBuf::from("foo.rs"); + let mut collector = SingleFileLocalenKeyCollector { + file: &path, + locale_keys: Vec::new(), + }; + collector.visit_file(&syn::parse_file(&file_contents).unwrap()); + + assert_eq!( + collector.locale_keys, + vec![ + LocaleKey { + key: "first_key".to_string(), + file: Path::new("foo.rs"), + line: 1, + column: 0 + }, + LocaleKey { + key: "second_key".to_string(), + file: Path::new("foo.rs"), + line: 2, + column: 1 + }, + ] + ); + } + + #[test] + #[should_panic(expected = "The first argument to t!() should be a string literal")] + fn test_single_file_collector_locale_key_is_not_string_literal() { + let file_contents = r#" +t!(key); +"#; + let path = PathBuf::from("foo.rs"); + let mut collector = SingleFileLocalenKeyCollector { + file: &path, + locale_keys: Vec::new(), + }; + collector.visit_file(&syn::parse_file(&file_contents).unwrap()); + } +} diff --git a/src/main.rs b/src/main.rs index a5b149a..e5c8089 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,38 +1,49 @@ +#![cfg(unix)] + mod checker; -mod parse; +mod cli_opt; +mod locale_file_parser; +mod locale_key_collector; mod rules; use crate::checker::Checker; -use crate::parse::LocalizedTexts; +use crate::cli_opt::Cli; +use crate::locale_file_parser::LocalizedTexts; +use crate::locale_key_collector::LocaleKeyCollector; use crate::rules::key_and_eng_matches::KeyEngMatches; use crate::rules::missing_translations::MissingTranslations; +use crate::rules::use_of_keys_do_not_exist::UseOfKeysDoNotExist; +use clap::Parser; use serde_yaml_ng::from_reader; use serde_yaml_ng::Value as Yaml; -use std::env::args; use std::fs::File; const EXIT_CODE_ON_ERROR: i32 = 1; fn main() { - let file_name = args() - .nth(1) - .expect("Error: a yaml file should be specified"); - let file = File::open(&file_name).unwrap_or_else(|e| { + let cli = Cli::parse(); + + let locale_file = File::open(cli.locale_file()).unwrap_or_else(|e| { panic!( - "Error: cannot open the specified file {file_name} due to error {:?}", + "Error: cannot open the specified file {} due to error {:?}", + cli.locale_file().display(), e ) }); - let contents: Yaml = from_reader(&file).unwrap(); - + let contents: Yaml = from_reader(&locale_file).unwrap(); let localized_texts = LocalizedTexts::new(contents); + let rust_files_to_check = cli.rust_src_to_check(); + let mut collector = LocaleKeyCollector::new(); + collector.collect(&rust_files_to_check); + let mut checker = Checker::new(); checker.register_rule(MissingTranslations); checker.register_rule(KeyEngMatches); + checker.register_rule(UseOfKeysDoNotExist); - checker.check(&localized_texts); + checker.check(&localized_texts, collector.locale_keys()); checker.report_to_user(); diff --git a/src/rules/key_and_eng_matches.rs b/src/rules/key_and_eng_matches.rs index 4baa75d..d995749 100644 --- a/src/rules/key_and_eng_matches.rs +++ b/src/rules/key_and_eng_matches.rs @@ -9,7 +9,11 @@ use super::Rule; pub(crate) struct KeyEngMatches; impl Rule for KeyEngMatches { - fn check(&self, localized_texts: &crate::parse::LocalizedTexts) { + fn check( + &self, + localized_texts: &crate::locale_file_parser::LocalizedTexts, + _locale_keys: &[crate::locale_key_collector::LocaleKey], + ) { for (key, translations) in localized_texts.texts.iter() { let en = &translations.en; diff --git a/src/rules/missing_translations.rs b/src/rules/missing_translations.rs index 6dfaa3d..f8ae2d1 100644 --- a/src/rules/missing_translations.rs +++ b/src/rules/missing_translations.rs @@ -31,7 +31,11 @@ impl MissingLanguages { pub(crate) struct MissingTranslations; impl Rule for MissingTranslations { - fn check(&self, localized_texts: &crate::LocalizedTexts) { + fn check( + &self, + localized_texts: &crate::LocalizedTexts, + _locale_keys: &[crate::locale_key_collector::LocaleKey], + ) { for (key, translations) in localized_texts.texts.iter() { let mut missing_langs = MissingLanguages::empty(); diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 965e38b..2123a93 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -1,5 +1,6 @@ pub(crate) mod key_and_eng_matches; pub(crate) mod missing_translations; +pub(crate) mod use_of_keys_do_not_exist; use crate::LocalizedTexts; use once_cell::sync::Lazy; @@ -20,13 +21,13 @@ pub(crate) trait Rule { /// Name of this rule. fn name() -> &'static str where - Self: Sized, // remove it from the vtable + Self: Sized, // remove it from the vtable to make `trait Rule` object safe. { let full_name = std::any::type_name::(); let maybe_start_idx = full_name.rfind(':'); match maybe_start_idx { Some(start_idx) => &full_name[start_idx + 1..], - None => "UNKNOWN", + None => "Unknown rule name", } } @@ -35,7 +36,7 @@ pub(crate) trait Rule { /// When `error_msg` is `Some`, it will be stored and reported to users as well. fn report_error(key: String, error_msg: Option) where - Self: Sized, // remove it from the vtable + Self: Sized, // remove it from the vtable to make `trait Rule` object safe. { // SAFETY: // It is safe to directly modify the global static variable as there is only 1 thread. @@ -52,5 +53,9 @@ pub(crate) trait Rule { } /// Begin the check. - fn check(&self, localized_texts: &LocalizedTexts); + fn check( + &self, + localized_texts: &LocalizedTexts, + locale_keys: &[crate::locale_key_collector::LocaleKey], + ); } diff --git a/src/rules/use_of_keys_do_not_exist.rs b/src/rules/use_of_keys_do_not_exist.rs new file mode 100644 index 0000000..64742fc --- /dev/null +++ b/src/rules/use_of_keys_do_not_exist.rs @@ -0,0 +1,29 @@ +//! A rule that checks if Topgrade uses any locale keys that do not exist. + +use super::Rule; + +/// Checks if Topgrade uses any locale keys that do not exist. +pub(crate) struct UseOfKeysDoNotExist; + +impl Rule for UseOfKeysDoNotExist { + fn check( + &self, + localized_texts: &crate::locale_file_parser::LocalizedTexts, + locale_keys: &[crate::locale_key_collector::LocaleKey], + ) { + for locale_key in locale_keys { + if !localized_texts.texts.contains_key(&locale_key.key) { + Self::report_error( + format!( + "file '{}' / line '{}' / column '{}' / key '{}'", + locale_key.file.display(), + locale_key.line, + locale_key.column, + locale_key.key + ), + None, + ); + } + } + } +}