diff --git a/src/lib.rs b/src/lib.rs index 6c6ff18d..825f886f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -122,8 +122,10 @@ mod lint_tests { #[test] fn empty_file() { + // An empty file is reported by the recommended `no-empty-file` rule. let diagnostics = lint_recommended_rules(""); - assert!(diagnostics.is_empty()); + assert_eq!(diagnostics.len(), 1); + assert_eq!(diagnostics[0].details.code, "no-empty-file"); } #[test] @@ -293,8 +295,10 @@ const _foo = 42; #[test] fn empty_file_with_ast() { + // An empty file is reported by the recommended `no-empty-file` rule. let parsed_source = parse(""); let diagnostics = lint_recommended_rules_with_ast(&parsed_source); - assert!(diagnostics.is_empty()); + assert_eq!(diagnostics.len(), 1); + assert_eq!(diagnostics[0].details.code, "no-empty-file"); } } diff --git a/src/rules.rs b/src/rules.rs index ff86954a..8a23b190 100644 --- a/src/rules.rs +++ b/src/rules.rs @@ -61,6 +61,7 @@ pub mod no_duplicate_case; pub mod no_empty; pub mod no_empty_character_class; pub mod no_empty_enum; +pub mod no_empty_file; pub mod no_empty_interface; pub mod no_empty_pattern; pub mod no_eval; @@ -305,6 +306,7 @@ fn get_all_rules_raw() -> Vec> { Box::new(no_empty::NoEmpty), Box::new(no_empty_character_class::NoEmptyCharacterClass), Box::new(no_empty_enum::NoEmptyEnum), + Box::new(no_empty_file::NoEmptyFile), Box::new(no_empty_interface::NoEmptyInterface), Box::new(no_empty_pattern::NoEmptyPattern), Box::new(no_eval::NoEval), diff --git a/src/rules/no_empty_file.rs b/src/rules/no_empty_file.rs new file mode 100644 index 00000000..77e90a55 --- /dev/null +++ b/src/rules/no_empty_file.rs @@ -0,0 +1,181 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. + +use super::{Context, LintRule}; +use crate::Program; +use deno_ast::swc::common::comments::Comment; +use deno_ast::swc::common::comments::CommentKind; +use deno_ast::view::{Expr, Lit, ModuleItem, Stmt}; +use deno_ast::{SourceRange, SourceRanged}; +use derive_more::Display; +use once_cell::sync::Lazy; +use regex::Regex; + +#[derive(Debug)] +pub struct NoEmptyFile; + +const CODE: &str = "no-empty-file"; + +#[derive(Display)] +enum NoEmptyFileMessage { + #[display(fmt = "Empty files are not allowed")] + Empty, +} + +#[derive(Display)] +enum NoEmptyFileHint { + #[display(fmt = "Delete this file or add some code to it")] + DeleteOrAddCode, +} + +impl LintRule for NoEmptyFile { + fn tags(&self) -> crate::tags::Tags { + &[crate::tags::RECOMMENDED] + } + + fn code(&self) -> &'static str { + CODE + } + + fn lint_program_with_ast_view<'view>( + &self, + context: &mut Context<'view>, + program: Program<'view>, + ) { + // A triple-slash reference directive counts as meaningful content even + // though it is technically a comment, so such files are exempt. + if context.all_comments().any(is_triple_slash_reference) { + return; + } + + let is_empty = match program { + Program::Module(module) => module.body.iter().all(is_empty_module_item), + Program::Script(script) => { + script.body.iter().all(is_empty_top_level_stmt) + } + }; + + if !is_empty { + return; + } + + // Cap the reported span at 100 characters to avoid emitting an enormous + // diagnostic for comment-heavy (but otherwise empty) files. + let start = program.start(); + let text = program.text_fast(context.text_info()); + let range = match text.char_indices().nth(100) { + Some((byte_offset, _)) => SourceRange::new(start, start + byte_offset), + None => program.range(), + }; + + context.add_diagnostic_with_hint( + range, + CODE, + NoEmptyFileMessage::Empty, + NoEmptyFileHint::DeleteOrAddCode, + ); + } +} + +fn is_empty_module_item(item: &ModuleItem) -> bool { + match item { + // Imports and exports are meaningful code. + ModuleItem::ModuleDecl(_) => false, + ModuleItem::Stmt(stmt) => is_empty_top_level_stmt(stmt), + } +} + +fn is_empty_top_level_stmt(stmt: &Stmt) -> bool { + match stmt { + Stmt::Empty(_) => true, + // A bare string literal statement at the top level is a directive + // (e.g. `"use strict";`) and carries no real code. + Stmt::Expr(expr_stmt) => matches!(expr_stmt.expr, Expr::Lit(Lit::Str(_))), + Stmt::Block(block) => block.stmts.iter().all(is_empty_nested_stmt), + _ => false, + } +} + +fn is_empty_nested_stmt(stmt: &Stmt) -> bool { + match stmt { + Stmt::Empty(_) => true, + Stmt::Block(block) => block.stmts.iter().all(is_empty_nested_stmt), + // Unlike at the top level, a string literal nested in a block is a real + // expression statement rather than a directive. + _ => false, + } +} + +fn is_triple_slash_reference(comment: &Comment) -> bool { + if comment.kind != CommentKind::Line { + return false; + } + + static TSR_REGEX: Lazy = Lazy::new(|| { + Regex::new(r#"^/\s* {})()", + "(() => {})();", + r#"/// "#, + }; + } + + #[test] + fn no_empty_file_invalid() { + assert_lint_err! { + NoEmptyFile, + "": [{ col: 0, message: NoEmptyFileMessage::Empty, hint: NoEmptyFileHint::DeleteOrAddCode }], + " ": [{ col: 0, message: NoEmptyFileMessage::Empty, hint: NoEmptyFileHint::DeleteOrAddCode }], + "\t": [{ col: 0, message: NoEmptyFileMessage::Empty, hint: NoEmptyFileHint::DeleteOrAddCode }], + "\n": [{ col: 0, message: NoEmptyFileMessage::Empty, hint: NoEmptyFileHint::DeleteOrAddCode }], + "\r": [{ col: 0, message: NoEmptyFileMessage::Empty, hint: NoEmptyFileHint::DeleteOrAddCode }], + "\r\n": [{ col: 0, message: NoEmptyFileMessage::Empty, hint: NoEmptyFileHint::DeleteOrAddCode }], + "// comment": [{ col: 0, message: NoEmptyFileMessage::Empty, hint: NoEmptyFileHint::DeleteOrAddCode }], + "/* comment */": [{ col: 0, message: NoEmptyFileMessage::Empty, hint: NoEmptyFileHint::DeleteOrAddCode }], + "#!/usr/bin/env node": [{ col: 0, message: NoEmptyFileMessage::Empty, hint: NoEmptyFileHint::DeleteOrAddCode }], + "'use asm';": [{ col: 0, message: NoEmptyFileMessage::Empty, hint: NoEmptyFileHint::DeleteOrAddCode }], + "'use strict';": [{ col: 0, message: NoEmptyFileMessage::Empty, hint: NoEmptyFileHint::DeleteOrAddCode }], + r#""use strict""#: [{ col: 0, message: NoEmptyFileMessage::Empty, hint: NoEmptyFileHint::DeleteOrAddCode }], + r#""""#: [{ col: 0, message: NoEmptyFileMessage::Empty, hint: NoEmptyFileHint::DeleteOrAddCode }], + ";": [{ col: 0, message: NoEmptyFileMessage::Empty, hint: NoEmptyFileHint::DeleteOrAddCode }], + ";;": [{ col: 0, message: NoEmptyFileMessage::Empty, hint: NoEmptyFileHint::DeleteOrAddCode }], + "{}": [{ col: 0, message: NoEmptyFileMessage::Empty, hint: NoEmptyFileHint::DeleteOrAddCode }], + "{;;}": [{ col: 0, message: NoEmptyFileMessage::Empty, hint: NoEmptyFileHint::DeleteOrAddCode }], + "{{}}": [{ col: 0, message: NoEmptyFileMessage::Empty, hint: NoEmptyFileHint::DeleteOrAddCode }], + r#""";"#: [{ col: 0, message: NoEmptyFileMessage::Empty, hint: NoEmptyFileHint::DeleteOrAddCode }], + r#""use strict";"#: [{ col: 0, message: NoEmptyFileMessage::Empty, hint: NoEmptyFileHint::DeleteOrAddCode }], + }; + } +}