diff --git a/Cargo.toml b/Cargo.toml index 7bd41fbce..3e3ad4452 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,10 @@ if_chain = "1.0.1" [dev-dependencies] annotate-snippets = { version = "0.9.0", features = ["color"] } +# TODO(@magurotuna): This branch contains a patch that fixes how double-width +# characters in diagnostic annotations are displayed. Once it lands, we'll +# upgrade it. +# annotate-snippets = { git = "https://github.com/magurotuna/annotate-snippets-rs", branch = "check-display-width", features = ["color"] } ansi_term = "0.12.1" atty = "0.2.14" clap = "2.33.3" diff --git a/docs/rules/prefer_ascii.md b/docs/rules/prefer_ascii.md new file mode 100644 index 000000000..61ea95188 --- /dev/null +++ b/docs/rules/prefer_ascii.md @@ -0,0 +1,47 @@ +Ensures that the code is fully written in ASCII characters. + +V8, the JavaScript engine Deno relies on, provides a method that strings get +populated outside V8's heap. In particular, if they are composed of one-byte +characters only, V8 can handle them much more efficiently through +[`v8::String::ExternalOneByteStringResource`]. In order to leverage this V8 +feature in the internal of Deno, this rule checks if all characters in the code +are ASCII. + +[`v8::String::ExternalOneByteStringResource`]: https://v8.github.io/api/head/classv8_1_1String_1_1ExternalOneByteStringResource.html + +That said, you can also make use of this lint rule for something other than +Deno's internal JavaScript code. If you want to make sure your codebase is made +up of ASCII characters only (e.g. want to disallow non-ASCII identifiers) for +some reasons, then this rule will be helpful. + +### Invalid: + +```typescript +const Ο€ = Math.PI; + +// string literals are also checked +const ninja = "πŸ₯·"; + +function こんにけは(名前: string) { + console.log(`こんにけは、${名前}さん`); +} + +// β€œcomments” are also checked +// ^ ^ +// | U+201D +// U+201C +``` + +### Valid: + +```typescript +const pi = Math.PI; + +const ninja = "ninja"; + +function hello(name: string) { + console.log(`Hello, ${name}`); +} + +// "comments" are also checked +``` diff --git a/examples/dlint/diagnostics.rs b/examples/dlint/diagnostics.rs new file mode 100644 index 000000000..0c6fbb30b --- /dev/null +++ b/examples/dlint/diagnostics.rs @@ -0,0 +1,201 @@ +// Copyright 2020-2021 the Deno authors. All rights reserved. MIT license. +use annotate_snippets::display_list; +use annotate_snippets::snippet; +use ast_view::SourceFile; +use ast_view::SourceFileTextInfo; +use deno_lint::diagnostic::LintDiagnostic; +use deno_lint::diagnostic::Range; + +pub fn display_diagnostics( + diagnostics: &[LintDiagnostic], + source_file: &SourceFileTextInfo, +) { + for diagnostic in diagnostics { + let (slice_source, char_range) = + get_slice_source_and_range(source_file, &diagnostic.range); + let footer = if let Some(hint) = &diagnostic.hint { + vec![snippet::Annotation { + label: Some(hint), + id: None, + annotation_type: snippet::AnnotationType::Help, + }] + } else { + vec![] + }; + + let snippet = snippet::Snippet { + title: Some(snippet::Annotation { + label: Some(&diagnostic.message), + id: Some(&diagnostic.code), + annotation_type: snippet::AnnotationType::Error, + }), + footer, + slices: vec![snippet::Slice { + source: slice_source, + line_start: diagnostic.range.start.line_index + 1, // make 1-indexed + origin: Some(&diagnostic.filename), + fold: false, + annotations: vec![snippet::SourceAnnotation { + range: char_range.as_tuple(), + label: "", + annotation_type: snippet::AnnotationType::Error, + }], + }], + opt: display_list::FormatOptions { + color: true, + anonymized_line_numbers: false, + margin: None, + }, + }; + let display_list = display_list::DisplayList::from(snippet); + eprintln!("{}", display_list); + } +} + +#[derive(Debug, PartialEq, Eq)] +struct CharRange { + /// 0-indexed number that represents what index this range starts at in the + /// snippet. + /// Counted on a character basis, not UTF-8 bytes. + start_index: usize, + + /// 0-indexed number that represents what index this range ends at in the + /// snippet. + /// Counted on a character basis, not UTF-8 bytes. + end_index: usize, +} + +impl CharRange { + fn as_tuple(&self) -> (usize, usize) { + (self.start_index, self.end_index) + } +} + +// Return slice of source code covered by diagnostic +// and adjusted range of diagnostic (ie. original range - start line +// of sliced source code). +fn get_slice_source_and_range<'a>( + source_file: &'a SourceFileTextInfo, + range: &Range, +) -> (&'a str, CharRange) { + let first_line_start = + source_file.line_start(range.start.line_index).0 as usize; + let last_line_end = source_file.line_end(range.end.line_index).0 as usize; + let text = source_file.text(); + let start_index = + text[first_line_start..range.start.byte_pos].chars().count(); + let end_index = text[first_line_start..range.end.byte_pos].chars().count(); + let slice_str = &text[first_line_start..last_line_end]; + ( + slice_str, + CharRange { + start_index, + end_index, + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use deno_lint::diagnostic::{Position, Range}; + use swc_common::BytePos; + + fn into_text_info(source_code: impl Into) -> SourceFileTextInfo { + SourceFileTextInfo::new(BytePos(0), source_code.into()) + } + + fn position(byte: u32, info: &SourceFileTextInfo) -> Position { + let b = BytePos(byte); + Position::new(b, info.line_and_column_index(b)) + } + + #[test] + fn slice_range_a() { + let text_info = into_text_info("const a = 42;"); + // 'a' + let range = Range { + start: position(6, &text_info), + end: position(7, &text_info), + }; + + let (slice, char_range) = get_slice_source_and_range(&text_info, &range); + assert_eq!(slice, "const a = 42;"); + assert_eq!( + char_range, + CharRange { + start_index: 6, + end_index: 7, + } + ); + } + + #[test] + fn slice_range_あ() { + let text_info = into_text_info("const あ = 42;"); + // 'あ', which takes up three bytes + let range = Range { + start: position(6, &text_info), + end: position(9, &text_info), + }; + + let (slice, char_range) = get_slice_source_and_range(&text_info, &range); + assert_eq!(slice, "const あ = 42;"); + assert_eq!( + char_range, + CharRange { + start_index: 6, + end_index: 7, + } + ); + } + + #[test] + fn slice_range_あい() { + let text_info = into_text_info("const あい = 42;"); + // 'い', which takes up three bytes + let range = Range { + start: position(9, &text_info), + end: position(12, &text_info), + }; + + let (slice, char_range) = get_slice_source_and_range(&text_info, &range); + assert_eq!(slice, "const あい = 42;"); + assert_eq!( + char_range, + CharRange { + start_index: 7, + end_index: 8, + } + ); + } + + #[test] + fn slice_range_across_lines() { + let src = r#" +const a = `γ‚γ„γ†γˆγŠ +かきくけこ`; +const b = 42; +"#; + let text_info = into_text_info(src); + // "えお\nかきく" + let range = Range { + start: position(21, &text_info), + end: position(37, &text_info), + }; + + let (slice, char_range) = get_slice_source_and_range(&text_info, &range); + assert_eq!( + slice, + r#"const a = `γ‚γ„γ†γˆγŠ +かきくけこ`;"# + ); + assert_eq!( + char_range, + CharRange { + start_index: 14, + end_index: 20, + } + ); + } +} diff --git a/examples/dlint/main.rs b/examples/dlint/main.rs index 9a7181840..320e12822 100644 --- a/examples/dlint/main.rs +++ b/examples/dlint/main.rs @@ -1,9 +1,6 @@ // Copyright 2020-2021 the Deno authors. All rights reserved. MIT license. -use annotate_snippets::display_list; -use annotate_snippets::snippet; use anyhow::bail; use anyhow::Error as AnyError; -use ast_view::SourceFile; use ast_view::SourceFileTextInfo; use clap::App; use clap::AppSettings; @@ -11,7 +8,6 @@ use clap::Arg; use clap::SubCommand; use deno_lint::ast_parser::{get_default_es_config, get_default_ts_config}; use deno_lint::diagnostic::LintDiagnostic; -use deno_lint::diagnostic::Range; use deno_lint::linter::LinterBuilder; use deno_lint::rules::{get_all_rules, get_recommended_rules}; use log::debug; @@ -24,6 +20,7 @@ use swc_ecmascript::parser::{EsConfig, Syntax, TsConfig}; mod color; mod config; +mod diagnostics; mod js; mod lexer; mod rules; @@ -69,69 +66,6 @@ fn create_cli_app<'a, 'b>() -> App<'a, 'b> { ) } -// Return slice of source code covered by diagnostic -// and adjusted range of diagnostic (ie. original range - start line -// of sliced source code). -fn get_slice_source_and_range<'a>( - source_file: &'a SourceFileTextInfo, - range: &Range, -) -> (&'a str, (usize, usize)) { - let first_line_start = - source_file.line_start(range.start.line_index).0 as usize; - let last_line_end = source_file.line_end(range.end.line_index).0 as usize; - let adjusted_start = range.start.byte_pos - first_line_start; - let adjusted_end = range.end.byte_pos - first_line_start; - let adjusted_range = (adjusted_start, adjusted_end); - let slice_str = &source_file.text()[first_line_start..last_line_end]; - (slice_str, adjusted_range) -} - -fn display_diagnostics( - diagnostics: &[LintDiagnostic], - source_file: &SourceFileTextInfo, -) { - for diagnostic in diagnostics { - let (slice_source, range) = - get_slice_source_and_range(source_file, &diagnostic.range); - let footer = if let Some(hint) = &diagnostic.hint { - vec![snippet::Annotation { - label: Some(hint), - id: None, - annotation_type: snippet::AnnotationType::Help, - }] - } else { - vec![] - }; - - let snippet = snippet::Snippet { - title: Some(snippet::Annotation { - label: Some(&diagnostic.message), - id: Some(&diagnostic.code), - annotation_type: snippet::AnnotationType::Error, - }), - footer, - slices: vec![snippet::Slice { - source: slice_source, - line_start: diagnostic.range.start.line_index + 1, // make 1-indexed - origin: Some(&diagnostic.filename), - fold: false, - annotations: vec![snippet::SourceAnnotation { - range, - label: "", - annotation_type: snippet::AnnotationType::Error, - }], - }], - opt: display_list::FormatOptions { - color: true, - anonymized_line_numbers: false, - margin: None, - }, - }; - let display_list = display_list::DisplayList::from(snippet); - eprintln!("{}", display_list); - } -} - fn run_linter( paths: Vec, filter_rule_name: Option<&str>, @@ -204,7 +138,7 @@ fn run_linter( })?; for d in file_diagnostics.lock().unwrap().values() { - display_diagnostics(&d.diagnostics, &d.source_file); + diagnostics::display_diagnostics(&d.diagnostics, &d.source_file); } let err_count = error_counts.load(Ordering::Relaxed); diff --git a/src/rules.rs b/src/rules.rs index 345b1efe6..d4bd5ed1c 100644 --- a/src/rules.rs +++ b/src/rules.rs @@ -83,6 +83,7 @@ pub mod no_var; pub mod no_window_prefix; pub mod no_with; pub mod prefer_as_const; +pub mod prefer_ascii; pub mod prefer_const; pub mod prefer_namespace_keyword; pub mod prefer_primordials; @@ -219,6 +220,7 @@ pub fn get_all_rules() -> Vec> { no_window_prefix::NoWindowPrefix::new(), no_with::NoWith::new(), prefer_as_const::PreferAsConst::new(), + prefer_ascii::PreferAscii::new(), prefer_const::PreferConst::new(), prefer_namespace_keyword::PreferNamespaceKeyword::new(), prefer_primordials::PreferPrimordials::new(), diff --git a/src/rules/prefer_ascii.rs b/src/rules/prefer_ascii.rs new file mode 100644 index 000000000..3930c3cba --- /dev/null +++ b/src/rules/prefer_ascii.rs @@ -0,0 +1,174 @@ +// Copyright 2020-2021 the Deno authors. All rights reserved. MIT license. +use super::{Context, LintRule}; +use crate::{Program, ProgramRef}; +use swc_common::{BytePos, Span}; + +pub struct PreferAscii; + +const CODE: &str = "prefer-ascii"; +const MESSAGE: &str = "Non-ASCII characters are not allowed"; + +fn hint(c: char) -> String { + format!("`{}` is not an ASCII. Consider replacing it", c) +} + +impl LintRule for PreferAscii { + fn new() -> Box { + Box::new(PreferAscii) + } + + fn code(&self) -> &'static str { + CODE + } + + fn lint_program(&self, _context: &mut Context, _program: ProgramRef<'_>) { + unreachable!(); + } + + fn lint_program_with_ast_view( + &self, + context: &mut Context, + _program: Program<'_>, + ) { + let mut not_asciis = Vec::new(); + + let mut src_chars = context.source_file().text().char_indices().peekable(); + while let Some((i, c)) = src_chars.next() { + if let Some(&(pi, _)) = src_chars.peek() { + if (pi > i + 1) || !c.is_ascii() { + let span = Span::new( + BytePos(i as u32), + BytePos(pi as u32), + Default::default(), + ); + not_asciis.push((c, span)); + } + } + } + + for (c, span) in not_asciis { + context.add_diagnostic_with_hint(span, CODE, MESSAGE, hint(c)); + } + } + + #[cfg(feature = "docs")] + fn docs(&self) -> &'static str { + include_str!("../../docs/rules/prefer_ascii.md") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn prefer_ascii_valid() { + assert_lint_ok! { + PreferAscii, + r#"const pi = Math.PI;"#, + r#"const ninja = "ninja";"#, + r#" +function hello(name: string) { + console.log(`Hello, ${name}`); +} + "#, + r#"// "comments" are also checked"#, + r#"/* "comments" are also checked */"#, + }; + } + + #[test] + fn prefer_ascii_invalid() { + assert_lint_err! { + PreferAscii, + r#"const Ο€ = Math.PI;"#: [ + { + line: 1, + col: 6, + message: MESSAGE, + hint: hint('Ο€'), + }, + ], + r#"const ninja = "πŸ₯·";"#: [ + { + line: 1, + col: 15, + message: MESSAGE, + hint: hint('πŸ₯·'), + }, + ], + r#"function こんにけは(名前: string) {}"#: [ + { + line: 1, + col: 9, + message: MESSAGE, + hint: hint('こ'), + }, + { + line: 1, + col: 10, + message: MESSAGE, + hint: hint('γ‚“'), + }, + { + line: 1, + col: 11, + message: MESSAGE, + hint: hint('に'), + }, + { + line: 1, + col: 12, + message: MESSAGE, + hint: hint('け'), + }, + { + line: 1, + col: 13, + message: MESSAGE, + hint: hint('は'), + }, + { + line: 1, + col: 15, + message: MESSAGE, + hint: hint('名'), + }, + { + line: 1, + col: 16, + message: MESSAGE, + hint: hint('前'), + }, + ], + r#"// β€œcomments” are also checked"#: [ + { + line: 1, + col: 3, + message: MESSAGE, + hint: hint('β€œ'), + }, + { + line: 1, + col: 12, + message: MESSAGE, + hint: hint('”'), + }, + ], + r#"/* β€œcomments” are also checked */"#: [ + { + line: 1, + col: 3, + message: MESSAGE, + hint: hint('β€œ'), + }, + { + line: 1, + col: 12, + message: MESSAGE, + hint: hint('”'), + }, + ], + }; + } +}