diff --git a/Cargo.lock b/Cargo.lock index 924ec694..4900536a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -40,6 +40,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "annotate-snippets" version = "0.11.4" @@ -214,7 +229,10 @@ version = "0.4.38" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" dependencies = [ + "android-tzdata", + "iana-time-zone", "num-traits", + "windows-targets", ] [[package]] @@ -290,6 +308,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cssparser" version = "0.31.2" @@ -661,6 +685,29 @@ dependencies = [ "syn", ] +[[package]] +name = "iana-time-zone" +version = "0.1.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "ident_case" version = "1.0.1" @@ -1795,6 +1842,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.52.0" diff --git a/eipw-lint/Cargo.toml b/eipw-lint/Cargo.toml index 0bbe2622..8c17fafc 100644 --- a/eipw-lint/Cargo.toml +++ b/eipw-lint/Cargo.toml @@ -18,7 +18,7 @@ regex = "1.11.0" serde_json = "1.0.128" serde = { version = "1.0.164", features = [ "derive" ] } url = "2.5.2" -chrono = { version = "0.4.38", default-features = false } +chrono = { version = "0.4.38", default-features = false, features = ["clock"] } educe = { version = "0.6.0", default-features = false, features = [ "Debug" ] } tokio = { optional = true, version = "1.40.0", features = [ "macros" ] } scraper = { version = "0.20.0", default-features = false } diff --git a/eipw-lint/src/lib.rs b/eipw-lint/src/lib.rs index 8f445fab..4522743d 100644 --- a/eipw-lint/src/lib.rs +++ b/eipw-lint/src/lib.rs @@ -265,6 +265,10 @@ pub fn default_lints_enum() -> impl Iterator { PreambleDate { name: preamble::Date, }, + PreambleFutureDate { + name: preamble::FutureDate, + }, PreambleFileName(preamble::FileName), PreambleLength(preamble::Length), PreambleList { @@ -87,6 +90,7 @@ where match self { Self::PreambleAuthor { name } => Box::new(name), Self::PreambleDate { name } => Box::new(name), + Self::PreambleFutureDate { name } => Box::new(name), Self::PreambleFileName(l) => Box::new(l), Self::PreambleLength(l) => Box::new(l), Self::PreambleList { name } => Box::new(name), @@ -128,6 +132,7 @@ where match self { Self::PreambleAuthor { name } => name, Self::PreambleDate { name } => name, + Self::PreambleFutureDate { name } => name, Self::PreambleFileName(l) => l, Self::PreambleLength(l) => l, Self::PreambleList { name } => name, @@ -173,6 +178,9 @@ where Self::PreambleDate { name } => DefaultLint::PreambleDate { name: preamble::Date(name.0.as_ref()), }, + Self::PreambleFutureDate { name } => DefaultLint::PreambleFutureDate { + name: preamble::FutureDate(name.0.as_ref()), + }, Self::PreambleFileName(l) => DefaultLint::PreambleFileName(preamble::FileName { name: l.name.as_ref(), format: l.format.as_ref(), diff --git a/eipw-lint/src/lints/preamble.rs b/eipw-lint/src/lints/preamble.rs index bb386137..44202a8d 100644 --- a/eipw-lint/src/lints/preamble.rs +++ b/eipw-lint/src/lints/preamble.rs @@ -7,6 +7,7 @@ pub mod author; pub mod date; pub mod file_name; +pub mod future_date; pub mod length; pub mod list; pub mod no_duplicates; @@ -25,6 +26,7 @@ pub mod url; pub use self::author::Author; pub use self::date::Date; pub use self::file_name::FileName; +pub use self::future_date::FutureDate; pub use self::length::Length; pub use self::list::List; pub use self::no_duplicates::NoDuplicates; diff --git a/eipw-lint/src/lints/preamble/future_date.rs b/eipw-lint/src/lints/preamble/future_date.rs new file mode 100644 index 00000000..2b1ce7d0 --- /dev/null +++ b/eipw-lint/src/lints/preamble/future_date.rs @@ -0,0 +1,112 @@ +/* + * Copyright 2023 The EIP.WTF Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +use eipw_snippets::Snippet; +use chrono::{NaiveDate, Utc}; + +use crate::{ + lints::{Context, Error, FetchContext, Lint}, + LevelExt, SnippetExt, +}; + +use serde::{Deserialize, Serialize}; +use std::fmt::{Debug, Display}; + +/// Validates that the `last-call-deadline` in an EIP preamble is a future date +/// when the EIP status is "Last Call". +/// +/// According to EIP-1, the `last-call-deadline` field is only required when status +/// is "Last Call", and it must be in ISO 8601 date format (YYYY-MM-DD). The date +/// must be in the future or today, as it represents when the last call period ends. +/// +/// Example valid preamble: +/// ```yaml +/// status: Last Call +/// last-call-deadline: 2024-12-31 # Must be today or a future date +/// ``` +/// +/// The lint will raise an error if: +/// - The date is in the past +/// - The date format is invalid (not YYYY-MM-DD) +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(transparent)] +pub struct FutureDate(pub S); + +impl Lint for FutureDate +where + S: Debug + Display + AsRef, +{ + fn find_resources(&self, _ctx: &FetchContext<'_>) -> Result<(), Error> { + Ok(()) + } + + fn lint<'a>(&self, slug: &'a str, ctx: &Context<'a, '_>) -> Result<(), Error> { + // Only check if status is "Last Call" + let status = match ctx.preamble().by_name("status") { + None => return Ok(()), + Some(s) => s.value().trim(), + }; + + if status != "Last Call" { + return Ok(()); + } + + // Get the deadline field + let field = match ctx.preamble().by_name(self.0.as_ref()) { + None => return Ok(()), + Some(s) => s, + }; + + let value = field.value().trim(); + + // Parse the date + let date = match NaiveDate::parse_from_str(value, "%Y-%m-%d") { + Ok(d) => d, + Err(_) => return Ok(()), // Let the Date lint handle invalid dates + }; + + // Get today's date + let today = Utc::now().date_naive(); + + // Check if date is in the future or today + if date < today { + let label = format!( + "preamble header `{}` must be today or a future date (today is {})", + self.0, + today.format("%Y-%m-%d") + ); + + let name_count = field.name().len(); + let value_count = field.value().len(); + + ctx.report( + ctx.annotation_level().title(&label).id(slug).snippet( + Snippet::source(field.source()) + .fold(false) + .line_start(field.line_start()) + .origin_opt(ctx.origin()) + .annotation( + ctx.annotation_level() + .span_utf8(field.source(), name_count + 2, value_count) + .label("must be today or a future date"), + ), + ), + )?; + } + + Ok(()) + } +} \ No newline at end of file diff --git a/eipw-lint/tests/lint_preamble_future_date.rs b/eipw-lint/tests/lint_preamble_future_date.rs new file mode 100644 index 00000000..55b5aaad --- /dev/null +++ b/eipw-lint/tests/lint_preamble_future_date.rs @@ -0,0 +1,99 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +use eipw_lint::lints::preamble::FutureDate; +use eipw_lint::reporters::Text; +use eipw_lint::Linter; + +#[tokio::test] +async fn past_date() { + let src = r#"--- +status: Last Call +last-call-deadline: 2023-12-12 +--- +hello world"#; + + let reports = Linter::>::default() + .clear_lints() + .deny("preamble-future-date", FutureDate("last-call-deadline")) + .check_slice(None, src) + .run() + .await + .unwrap() + .into_inner(); + + assert_eq!( + reports, + r#"error[preamble-future-date]: preamble header `last-call-deadline` must be today or a future date (today is 2024-12-12) + | +3 | last-call-deadline: 2023-12-12 + | ^^^^^^^^^^ must be today or a future date + | +"#, + ); +} + +#[tokio::test] +async fn today_date() { + let src = r#"--- +status: Last Call +last-call-deadline: 2024-12-12 +--- +hello world"#; + + let reports = Linter::>::default() + .clear_lints() + .deny("preamble-future-date", FutureDate("last-call-deadline")) + .check_slice(None, src) + .run() + .await + .unwrap() + .into_inner(); + + // Today's date should be valid + assert_eq!(reports, ""); +} + +#[tokio::test] +async fn future_date() { + let src = r#"--- +status: Last Call +last-call-deadline: 2025-12-12 +--- +hello world"#; + + let reports = Linter::>::default() + .clear_lints() + .deny("preamble-future-date", FutureDate("last-call-deadline")) + .check_slice(None, src) + .run() + .await + .unwrap() + .into_inner(); + + assert_eq!(reports, ""); +} + +#[tokio::test] +async fn not_last_call() { + let src = r#"--- +status: Draft +last-call-deadline: 2023-12-12 +--- +hello world"#; + + let reports = Linter::>::default() + .clear_lints() + .deny("preamble-future-date", FutureDate("last-call-deadline")) + .check_slice(None, src) + .run() + .await + .unwrap() + .into_inner(); + + // Should not error when status is not Last Call, even with past date + assert_eq!(reports, ""); +}