From 142e0e7cfc063cc0274cfbd597abd9ae877cf3a6 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Mon, 12 Oct 2026 05:41:36 +0000 Subject: [PATCH 01/49] lint, format, check: 14 files Work in progress. --- src/collections/bit_set.rs | 33 ++++++++++++++++++++++++-- src/collections/lib.rs | 2 +- src/format/css/misc.rs | 13 ++++------- src/format/css/parse.rs | 6 ++--- src/format/css/value_parser.rs | 3 +-- src/format/handlebars/lexer.rs | 23 ++++++++----------- src/format/html/verify.rs | 15 ++++-------- src/format/text.rs | 12 +++------- src/lint/ast/decl.rs | 15 +++++------- src/lint/js_plugin/ast.rs | 5 ++-- src/lint/js_plugin/host.rs | 7 +++--- src/md/blocks.rs | 2 +- src/md/html_renderer.rs | 42 ++++++---------------------------- src/md/parser.rs | 6 ++--- 14 files changed, 81 insertions(+), 103 deletions(-) diff --git a/src/collections/bit_set.rs b/src/collections/bit_set.rs index 0a079ff04985..92b034b0dc67 100644 --- a/src/collections/bit_set.rs +++ b/src/collections/bit_set.rs @@ -277,6 +277,35 @@ pub const fn num_masks_for(bit_length: usize) -> usize { bit_length.div_ceil(usize::BITS as usize) } +/// A set of bytes, in 32 bytes. It can be a `const` or a `static`. +pub type ByteSet = ArrayBitSet<256, { num_masks_for(256) }>; + +impl ByteSet { + pub const fn of(bytes: &[u8]) -> ByteSet { + let (mut set, mut i) = (ByteSet::init_empty(), 0); + while i < bytes.len() { + set.set(bytes[i] as usize); + i += 1; + } + set + } + + /// With `first..=last` too. + pub const fn with_range(mut self, first: u8, last: u8) -> ByteSet { + let mut byte = first as usize; + while byte <= last as usize { + self.set(byte); + byte += 1; + } + self + } + + #[inline] + pub const fn has(&self, byte: u8) -> bool { + self.is_set(byte as usize) + } +} + /// A bit set with static size, which is backed by an array of usize. /// This set is good for sets with a larger size, but may use /// more bytes than necessary if your set is small. @@ -321,7 +350,7 @@ impl ArrayBitSet { /// Returns true if the bit at the specified index /// is present in the set, false otherwise. - pub fn is_set(&self, index: usize) -> bool { + pub const fn is_set(&self, index: usize) -> bool { debug_assert!(index < Self::BIT_LENGTH); if NUM_MASKS == 0 { return false; // doesn't compile in this case @@ -339,7 +368,7 @@ impl ArrayBitSet { } /// Adds a specific bit to the bit set - pub fn set(&mut self, index: usize) { + pub const fn set(&mut self, index: usize) { debug_assert!(index < Self::BIT_LENGTH); if NUM_MASKS == 0 { return; // doesn't compile in this case diff --git a/src/collections/lib.rs b/src/collections/lib.rs index 57539df10913..75cd53e615fb 100644 --- a/src/collections/lib.rs +++ b/src/collections/lib.rs @@ -41,7 +41,7 @@ pub use paste::paste as __mal_paste; pub use vec_ext::{ByteVecExt, OffsetByteList, VecExt, prepend_from}; pub use bit_set::{ - AutoBitSet, DynamicBitSet, DynamicBitSetList, DynamicBitSetUnmanaged, IntegerBitSet, + AutoBitSet, ByteSet, DynamicBitSet, DynamicBitSetList, DynamicBitSetUnmanaged, IntegerBitSet, }; // `multi_array_list` imports from here; canonical impl lives in diff --git a/src/format/css/misc.rs b/src/format/css/misc.rs index 7df9808d94aa..4c942f59654d 100644 --- a/src/format/css/misc.rs +++ b/src/format/css/misc.rs @@ -1,6 +1,7 @@ //! Prettier's `print/misc.js`, and the utilities of Prettier that it uses: strings, numbers, units. -use crate::text::{self, ByteSet, make_string}; +use crate::text::{self, make_string}; +use bun_core::strings; use std::borrow::Cow; /// The units of `css-units-list`, as they are written. @@ -110,8 +111,7 @@ pub(crate) fn print_string(raw: &[u8], single_quote: bool, out: &mut Vec) { /// `adjustStrings` pub(crate) fn adjust_strings(value: &[u8], single_quote: bool) -> Cow<'_, [u8]> { - static QUOTES: ByteSet = ByteSet::new(b"\"'"); - if QUOTES.find(value, 0).is_none() { + if !strings::contains_any(value, b"\"'") { return Cow::Borrowed(value); } let mut out = Vec::with_capacity(value.len()); @@ -270,14 +270,11 @@ pub(crate) fn quote_attribute_value(value: Cow<'_, [u8]>, single_quote: bool) -> /// `maybeToLowerCase` pub(crate) fn maybe_to_lower_case(value: &[u8]) -> Cow<'_, [u8]> { - static SIGNS: ByteSet = ByteSet::new(b"$@#"); - static OPEN: ByteSet = ByteSet::new(b"("); - static CLOSE: ByteSet = ByteSet::new(b")"); - let keeps_case = SIGNS.find(value, 0).is_some() + let keeps_case = strings::contains_any(value, b"$@#") || value.starts_with(b"%") || value.starts_with(b"--") || value.starts_with(b":--") - || (OPEN.find(value, 0).is_some() && CLOSE.find(value, 0).is_some()); + || (strings::contains_char(value, b'(') && strings::contains_char(value, b')')); match keeps_case { true => Cow::Borrowed(value), false => text::to_lower_case(value), diff --git a/src/format/css/parse.rs b/src/format/css/parse.rs index f273f2237a85..23cb31abcd81 100644 --- a/src/format/css/parse.rs +++ b/src/format/css/parse.rs @@ -8,7 +8,8 @@ use super::selector_parser::{SelectorId, Selectors}; use super::value_parser::{ValueId, Values}; use crate::stack::Stack; use crate::syntax_error::{Message, Refusal, Refused}; -use crate::text::{self, ByteSet}; +use crate::text; +use bun_core::strings; use rustc_hash::FxHashMap; use std::borrow::Cow; use std::cell::RefCell; @@ -584,8 +585,7 @@ impl<'a> Context<'a> { None => Cow::Borrowed(value), }); } else { - static BANG: ByteSet = ByteSet::new(b"!"); - let has_bang = BANG.find(value, 0).is_some(); + let has_bang = strings::contains_char(value, b'!'); if has_bang && let Some(at) = find_directive(value, b"!default") { node.scss_default = true; node.raw_scss_default = Some(&value[at..]) diff --git a/src/format/css/value_parser.rs b/src/format/css/value_parser.rs index fe7235db728a..62fa9519c8c1 100644 --- a/src/format/css/value_parser.rs +++ b/src/format/css/value_parser.rs @@ -776,8 +776,7 @@ impl ValuesParser<'_> { let mut ind = 0; loop { let word = &self.text()[word_start..word_start + word_len]; - static AT: ByteSet = ByteSet::new(b"@"); - let index = AT.find(word, ind + 1).unwrap_or(word_len); + let index = text::index_of_from(word, b"@", ind + 1).unwrap_or(word_len); let value = &word[ind..index]; let (start, end) = (word_start + ind, word_start + index); let loc = Source { diff --git a/src/format/handlebars/lexer.rs b/src/format/handlebars/lexer.rs index d9384cfd75e2..e691d89131bd 100644 --- a/src/format/handlebars/lexer.rs +++ b/src/format/handlebars/lexer.rs @@ -3,6 +3,7 @@ use super::Error; use super::positions::Positions; use crate::syntax_error::{Message, Refusal}; +use bun_collections::ByteSet; use bun_core::strings; #[derive(Copy, Clone, PartialEq, Eq, Debug)] @@ -57,19 +58,13 @@ enum State { Raw, } -/// Which bytes are ASCII that `[^\s!"#%-,\.\/;->@\[-\^`\{-~]` matches. -const IS_ASCII_OF_ID: [bool; 256] = { - let mut table = [false; 256]; - let mut byte = 0; - while byte < 0x80 { - table[byte] = !matches!( - byte as u8, - b'\t' | b'\n' | 0x0B | 0x0C | b'\r' | b' ' | b'!' | b'"' | b'#' | b'%'..=b',' | b'.' | b'/' | b';'..=b'>' | b'@' | b'['..=b'^' | b'`' | b'{'..=b'~' - ); - byte += 1; - } - table -}; +/// The ASCII that `[^\s!"#%-,\.\/;->@\[-\^`\{-~]` matches. +const ASCII_OF_ID: ByteSet = ByteSet::of(b"$-:?_\x7F") + .with_range(0x00, 0x08) + .with_range(0x0E, 0x1F) + .with_range(b'0', b'9') + .with_range(b'A', b'Z') + .with_range(b'a', b'z'); /// How many bytes at the start of `text` can be in an `ID`. pub(crate) fn id_len(text: &[u8]) -> usize { @@ -77,7 +72,7 @@ pub(crate) fn id_len(text: &[u8]) -> usize { loop { at += text[at..] .iter() - .take_while(|byte| IS_ASCII_OF_ID[**byte as usize]) + .take_while(|byte| ASCII_OF_ID.has(**byte)) .count(); match text.get(at) { Some(0x80..) if strings::js_whitespace_len(&text[at..]) == 0 => at += 1, diff --git a/src/format/html/verify.rs b/src/format/html/verify.rs index b690f2e260d7..b3ef1ba58c6a 100644 --- a/src/format/html/verify.rs +++ b/src/format/html/verify.rs @@ -11,6 +11,7 @@ use super::Parser; use super::ast::{Id, Kind, Tree}; use super::parse; +use bun_collections::ByteSet; use rustc_hash::FxHasher; use std::hash::Hasher; @@ -30,15 +31,7 @@ struct Reader { } /// The bytes that do not always count, or next to which another does not. -const IS_SPECIAL: [bool; 256] = { - let mut is_special = [false; 256]; - let (bytes, mut at) = (b"eE\\\xC2\xC4\xE1\xE2\xE3\xEF", 0); - while at < bytes.len() { - is_special[bytes[at] as usize] = true; - at += 1; - } - is_special -}; +const SPECIAL: ByteSet = ByteSet::of(b"eE\\\xC2\xC4\xE1\xE2\xE3\xEF"); /// `/^[+-]?0+(?!\d)/.test(text)` fn is_zero(text: &[u8]) -> bool { @@ -66,13 +59,13 @@ impl Reader { fn count(&mut self, text: &[u8]) { for (at, &byte) in text.iter().enumerate() { self.add(byte); - if IS_SPECIAL[usize::from(byte)] { + if SPECIAL.has(byte) { self.look_around(text, at); } } } - /// Takes back what does not count because of the byte at `at`, which is one of `IS_SPECIAL`. + /// Takes back what does not count because of the byte at `at`, which is one of `SPECIAL`. fn look_around(&mut self, text: &[u8], at: usize) { let (before, rest) = text.split_at_checked(at).unwrap_or_default(); let previous = before.last().copied().unwrap_or(0); diff --git a/src/format/text.rs b/src/format/text.rs index 2fb223f4dc0e..bf2af8a00b25 100644 --- a/src/format/text.rs +++ b/src/format/text.rs @@ -25,17 +25,11 @@ pub(crate) fn leading_white_space_len(text: &[u8]) -> usize { /// A set of bytes, for the ends of tokens: they are a few bytes away, and there are more kinds of them /// than the vectorized search takes at once. -pub(crate) struct ByteSet([bool; 256]); +pub(crate) struct ByteSet(bun_collections::ByteSet); impl ByteSet { pub(crate) const fn new(bytes: &[u8]) -> ByteSet { - let mut set = [false; 256]; - let mut i = 0; - while i < bytes.len() { - set[bytes[i] as usize] = true; - i += 1; - } - ByteSet(set) + ByteSet(bun_collections::ByteSet::of(bytes)) } /// The index of the first byte of `text` from `from` on that is in the set. @@ -43,7 +37,7 @@ impl ByteSet { pub(crate) fn find(&self, text: &[u8], from: usize) -> Option { let rest = text.get(from..)?; rest.iter() - .position(|&byte| self.0[byte as usize]) + .position(|&byte| self.0.has(byte)) .map(|at| from + at) } } diff --git a/src/lint/ast/decl.rs b/src/lint/ast/decl.rs index ef2b84cbb0b1..943d62ad0730 100644 --- a/src/lint/ast/decl.rs +++ b/src/lint/ast/decl.rs @@ -6,6 +6,7 @@ use super::{ }; use crate::span::Span; use crate::tokens::skip_trivia; +use bun_collections::ByteSet; use bun_sema::bind::{ClassOwner, FnOwner, MemberOwner}; use bun_sema::hir::{self, NameKind}; @@ -722,15 +723,11 @@ pub(super) fn is_computed(key: hir::PropKey, name_kind: NameKind) -> bool { /// `\\`: what `token_len` says, without looking for anything else that a token can be. #[inline] fn ascii_name_len(text: &[u8]) -> Option { - static IS_PART: [bool; 256] = { - let (mut table, mut b) = ([false; 256], 0usize); - while b < 128 { - table[b] = (b as u8).is_ascii_alphanumeric() || b as u8 == b'_' || b as u8 == b'$'; - b += 1; - } - table - }; - let is_part = |b: &u8| IS_PART[*b as usize]; + static PART: ByteSet = ByteSet::of(b"_$") + .with_range(b'0', b'9') + .with_range(b'A', b'Z') + .with_range(b'a', b'z'); + let is_part = |b: &u8| PART.has(*b); if !text .first() .is_some_and(|b| is_part(b) && !b.is_ascii_digit()) diff --git a/src/lint/js_plugin/ast.rs b/src/lint/js_plugin/ast.rs index a029059677a1..b1c454fc7151 100644 --- a/src/lint/js_plugin/ast.rs +++ b/src/lint/js_plugin/ast.rs @@ -17,6 +17,7 @@ use crate::ast::File; use crate::estree::{Dialect, Nodes, Object, Sink, VNode, Value}; use crate::selector::{EsNode, Selector}; use crate::span::Span; +use bun_collections::ByteSet; use bun_core::strings::Utf16OffsetTable; /// What the number in a word is. @@ -491,12 +492,12 @@ pub(super) fn write<'a>( file: &'a File<'a>, offsets: &Utf16OffsetTable, selectors: &[Option<&Selector>], - listened: Option<&[bool; 256]>, + listened: Option<&ByteSet>, out: &mut Vec, ) -> Numbered<'a> { let writer = walk(file, offsets, selectors); let tree = &writer.tree; - let is_listened = |listened: &[bool; 256]| tree.types.iter().any(|&it| listened[it as usize]); + let is_listened = |listened: &ByteSet| tree.types.iter().any(|&it| listened.has(it)); if listened.is_some_and(|it| !is_listened(it)) && writer.matches.iter().all(Vec::is_empty) { wire::words(out, &[0; HEADER]); return writer.nodes; diff --git a/src/lint/js_plugin/host.rs b/src/lint/js_plugin/host.rs index 4cfb4835d1f9..251416c9e9fc 100644 --- a/src/lint/js_plugin/host.rs +++ b/src/lint/js_plugin/host.rs @@ -12,6 +12,7 @@ use crate::options::Json; use crate::rule::Kind; use crate::selector::Selector; use crate::span::Span; +use bun_collections::ByteSet; use bun_core::printer::json_stringify; use bun_core::strings::Utf16OffsetTable; use bun_threading::Guarded; @@ -570,10 +571,10 @@ impl<'e> Host<'e> { let selectors: Vec> = selectors.iter().map(Option::as_deref).collect(); let types = types.and_then(Json::as_array).map(|types| { - let mut listened = [false; 256]; + let mut listened = ByteSet::init_empty(); for it in types.iter().filter_map(|it| number(Some(it))) { - if let Some(it) = listened.get_mut(it as usize) { - *it = true; + if let Ok(it) = u8::try_from(it) { + listened.set(it as usize); } } listened diff --git a/src/md/blocks.rs b/src/md/blocks.rs index 2a9ed480f5f7..5434adebd1b5 100644 --- a/src/md/blocks.rs +++ b/src/md/blocks.rs @@ -605,7 +605,7 @@ impl Parser<'_> { if let Some(extensions) = self.extensions && line.indent < self.code_indent_offset && off < self.size - && self.starts_extension_leaf[self.text[off as usize] as usize] + && self.starts_extension_leaf.has(self.text[off as usize]) { let interrupts_paragraph = effective_pivot_type == LineType::Text; let end = (extensions.leaf)(&types::LeafStart { diff --git a/src/md/html_renderer.rs b/src/md/html_renderer.rs index ea718a0247b3..863939f5023c 100644 --- a/src/md/html_renderer.rs +++ b/src/md/html_renderer.rs @@ -7,41 +7,13 @@ use crate::helpers; use crate::output::{OutputBuffer, try_extend, try_push}; use crate::types; use crate::types::{BlockType, JsResult, Renderer, RendererImpl, SpanDetail, SpanType, TextType}; +use bun_collections::ByteSet; /// The bytes of a URL that are written as they are. -static IS_URL_SAFE: [bool; 256] = { - let mut table = [false; 256]; - let mut byte = 0; - while byte < 256 { - table[byte] = matches!( - byte as u8, - b'A'..=b'Z' - | b'a'..=b'z' - | b'0'..=b'9' - | b'-' - | b'.' - | b'_' - | b'~' - | b':' - | b'/' - | b'?' - | b'#' - | b'@' - | b'!' - | b'$' - | b'(' - | b')' - | b'*' - | b'+' - | b',' - | b';' - | b'=' - | b'%' - ); - byte += 1; - } - table -}; +static URL_SAFE: ByteSet = ByteSet::of(b"-._~:/?#@!$()*+,;=%") + .with_range(b'A', b'Z') + .with_range(b'a', b'z') + .with_range(b'0', b'9'); pub(crate) struct HtmlRenderer<'src> { pub out: OutputBuffer, @@ -516,7 +488,7 @@ impl<'src> HtmlRenderer<'src> { fn write_url_safe_run(&mut self, txt: &[u8]) -> usize { let len = txt .iter() - .take_while(|&&byte| IS_URL_SAFE[byte as usize]) + .take_while(|&&byte| URL_SAFE.has(byte)) .count(); if len > 0 { self.write(&txt[..len]); @@ -527,7 +499,7 @@ impl<'src> HtmlRenderer<'src> { fn write_url_byte(&mut self, byte: u8) { match byte { b'&' | b'\'' => self.write(strings::html_escape_entity(byte).unwrap()), - _ if IS_URL_SAFE[byte as usize] => self.write_byte(byte), + _ if URL_SAFE.has(byte) => self.write_byte(byte), _ => { let [hi, lo] = bun_core::fmt::hex_byte_upper(byte); self.write(&[b'%', hi, lo]); diff --git a/src/md/parser.rs b/src/md/parser.rs index 0c8028f58b68..150977054a2a 100644 --- a/src/md/parser.rs +++ b/src/md/parser.rs @@ -144,7 +144,7 @@ pub(crate) struct Parser<'a> { // The index of the one of them that was asked about last. pub(crate) inline_line: usize, // `Extensions::leaf_bytes` - pub(crate) starts_extension_leaf: [bool; 256], + pub(crate) starts_extension_leaf: bun_collections::ByteSet, // Where the lines of the document start. pub(crate) line_starts: Vec, // The labels of the footnotes that are defined, normalized. @@ -368,7 +368,7 @@ impl<'a> Parser<'a> { extensions: None, inline_lines: Vec::new(), inline_line: 0, - starts_extension_leaf: [false; 256], + starts_extension_leaf: bun_collections::ByteSet::init_empty(), line_starts: Vec::new(), footnote_labels: bun_collections::StringSet::new(), }; @@ -431,7 +431,7 @@ impl<'a> Parser<'a> { self.mark_char_map[c as usize] |= MARK_HIDES | MARK_EXTENSION; } for &c in extensions.leaf_bytes { - self.starts_extension_leaf[c as usize] = true; + self.starts_extension_leaf.set(c as usize); } self.extensions = Some(extensions); } From 049de705b2f3e954c12c313ef2d07e863a512577 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Mon, 12 Oct 2026 05:46:14 +0000 Subject: [PATCH 02/49] lint, format, check: 5 files Work in progress. --- docs/lint/rules/bun-test/no-focused-tests.mdx | 32 ++++++++++ docs/lint/rules/eslint/eqeqeq.mdx | 58 +++++++++++++++++++ docs/lint/rules/eslint/no-var.mdx | 28 +++++++++ .../rules/typescript/no-floating-promises.mdx | 55 ++++++++++++++++++ docs/lint/rules/unicorn/no-null.mdx | 45 ++++++++++++++ 5 files changed, 218 insertions(+) create mode 100644 docs/lint/rules/bun-test/no-focused-tests.mdx create mode 100644 docs/lint/rules/eslint/eqeqeq.mdx create mode 100644 docs/lint/rules/eslint/no-var.mdx create mode 100644 docs/lint/rules/typescript/no-floating-promises.mdx create mode 100644 docs/lint/rules/unicorn/no-null.mdx diff --git a/docs/lint/rules/bun-test/no-focused-tests.mdx b/docs/lint/rules/bun-test/no-focused-tests.mdx new file mode 100644 index 000000000000..3667271de586 --- /dev/null +++ b/docs/lint/rules/bun-test/no-focused-tests.mdx @@ -0,0 +1,32 @@ +--- +title: "bun:test/no-focused-tests" +description: "Disallow `test.only` and `describe.only`" +--- + +`.only` makes `bun test` run that test and skip every other one in the file. It is useful while debugging. Committed by accident, it hides failures in the tests that no longer run. + +This rule is on by default in files that import from `"bun:test"`. + +## Examples + +Reported: + +{/* bad */} +```ts +import { expect, test } from "bun:test"; + +test.only("adds", () => { + expect(1 + 1).toBe(2); +}); +``` + +Not reported: + +{/* good */} +```ts +import { expect, test } from "bun:test"; + +test("adds", () => { + expect(1 + 1).toBe(2); +}); +``` diff --git a/docs/lint/rules/eslint/eqeqeq.mdx b/docs/lint/rules/eslint/eqeqeq.mdx new file mode 100644 index 000000000000..8743810fdf8a --- /dev/null +++ b/docs/lint/rules/eslint/eqeqeq.mdx @@ -0,0 +1,58 @@ +--- +title: "eqeqeq" +description: "Require `===` and `!==` instead of `==` and `!=`" +--- + +`==` and `!=` convert their operands before comparing them, so `"" == 0` and `[] == false` are both `true`. `===` and `!==` compare without converting. + +## Examples + +Reported: + +{/* bad */} +```js +if (count == 0) { + reset(); +} +``` + +Not reported: + +{/* good */} +```js +if (count === 0) { + reset(); +} +``` + +## Options + +```toml bunfig.toml +[lint.rules] +eqeqeq = ["error", "smart"] +``` + +| Option | What it does | +| --- | --- | +| `"always"` (default) | Report every `==` and `!=`. | +| `"smart"` | Allow `==` when comparing with `null`, comparing two literals, or comparing the result of `typeof`. | + +With `"always"`, a second option decides what happens with `null`: `{ "null": "always" }` (default) reports `x == null`, `"ignore"` allows it, and `"never"` reports `x === null` instead. + +With `"smart"`, this is allowed, because `== null` is a common way to test for both `null` and `undefined`: + +{/* good {"options": ["smart"]} */} +```js +if (value == null) { + value = fallback; +} +``` + +And this is still reported: + +{/* bad {"options": ["smart"]} */} +```js +if (value == other) { + merge(value, other); +} +``` diff --git a/docs/lint/rules/eslint/no-var.mdx b/docs/lint/rules/eslint/no-var.mdx new file mode 100644 index 000000000000..7ccd1b26a775 --- /dev/null +++ b/docs/lint/rules/eslint/no-var.mdx @@ -0,0 +1,28 @@ +--- +title: "no-var" +description: "Require `let` or `const` instead of `var`" +--- + +A `var` belongs to the whole function, not to the block it is written in, and it can be read before the line that declares it. `let` and `const` are scoped to their block and cannot be used before they are declared, which turns a class of silent bugs into errors. + +`--fix` rewrites `var` to `let` where that cannot change what the code does. + +## Examples + +Reported: + +{/* bad */} +```js +for (var i = 0; i < 3; i++) { + setTimeout(() => console.log(i)); // 3, 3, 3 +} +``` + +Not reported: + +{/* good */} +```js +for (let i = 0; i < 3; i++) { + setTimeout(() => console.log(i)); // 0, 1, 2 +} +``` diff --git a/docs/lint/rules/typescript/no-floating-promises.mdx b/docs/lint/rules/typescript/no-floating-promises.mdx new file mode 100644 index 000000000000..39a8ecbc1b4c --- /dev/null +++ b/docs/lint/rules/typescript/no-floating-promises.mdx @@ -0,0 +1,55 @@ +--- +title: "@typescript-eslint/no-floating-promises" +description: "Require promises to be awaited, returned, or handled" +--- + +A promise that nobody awaits runs on its own. If it rejects, the error is not caught by the `try` around the call, and the code after it runs before the work is done. + +A promise counts as handled if it is awaited, returned, given a `.catch()` or a second argument to `.then()`, or marked as ignored on purpose with `void`. + +This rule needs type information. Run `bun lint --type-aware`. + +## Examples + +Reported: + +{/* bad */} +```ts +async function save(data: string): Promise {} + +function onClick() { + save("draft"); +} +``` + +Not reported: + +{/* good */} +```ts +async function save(data: string): Promise {} + +async function onClick() { + await save("draft"); +} + +function onBlur() { + void save("draft"); +} +``` + +## Options + +| Option | Default | What it does | +| --- | --- | --- | +| `ignoreVoid` | `true` | Allow `void promise` as a way to say "ignored on purpose". | +| `ignoreIIFE` | `false` | Allow an async function that is called where it is written: `(async () => { ... })()`. | +| `checkThenables` | `false` | Also report objects that only have a `then` method. | +| `allowForKnownSafePromises` | `[]` | Promise types that never need handling. | +| `allowForKnownSafeCalls` | `[]` | Functions that return promises you never need to handle. | + +{/* bad {"options": [{"ignoreVoid": false}]} */} +```ts +async function save(data: string): Promise {} + +void save("draft"); +``` diff --git a/docs/lint/rules/unicorn/no-null.mdx b/docs/lint/rules/unicorn/no-null.mdx new file mode 100644 index 000000000000..afdd779155ed --- /dev/null +++ b/docs/lint/rules/unicorn/no-null.mdx @@ -0,0 +1,45 @@ +--- +title: "unicorn/no-null" +description: "Disallow the `null` literal in favor of `undefined`" +--- + +JavaScript has two values for "nothing". Using only `undefined` means one check instead of two, and default parameters and destructuring defaults apply to `undefined` but not to `null`. + +Strict comparisons such as `x === null` are allowed by default, because code often has to test what an API returned. + +## Examples + +Reported: + +{/* bad */} +```js +let selected = null; +``` + +Not reported: + +{/* good */} +```js +let selected; + +if (document.querySelector("dialog") === null) { + selected = undefined; +} +``` + +## Options + +| Option | Default | What it does | +| --- | --- | --- | +| `checkStrictEquality` | `false` | Also report `x === null` and `x !== null`. | + +{/* bad {"options": [{"checkStrictEquality": true}]} */} +```js +if (selected === null) { + selected = first(); +} +``` + +## When not to use it + +Many APIs return or expect `null`: the DOM, JSON, databases. If your code talks to them a lot, this rule gets in the way. From e4d628b97472fda89c20505a318fba28c81c42d3 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Mon, 12 Oct 2026 05:46:49 +0000 Subject: [PATCH 03/49] lint, format, check: 3 files Work in progress. --- docs/docs.json | 7 ++- docs/format/oxfmt.mdx | 103 +++++++++++++++++++++++++++++++++++++ docs/format/prettier.mdx | 106 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 214 insertions(+), 2 deletions(-) create mode 100644 docs/format/oxfmt.mdx create mode 100644 docs/format/prettier.mdx diff --git a/docs/docs.json b/docs/docs.json index dccf9df3d8d9..7501590a5d37 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -72,14 +72,17 @@ "/runtime/index", "/runtime/watch-mode", "/runtime/check", - "/runtime/lint", - "/runtime/format", "/runtime/debugger", "/runtime/repl", "/runtime/bunfig", "/runtime/code-generation-from-strings" ] }, + { + "group": "Linter & Formatter", + "icon": "sparkles", + "pages": ["/runtime/lint", "/runtime/format", "/format/prettier", "/format/oxfmt"] + }, { "group": "File & Module System", "icon": "file", diff --git a/docs/format/oxfmt.mdx b/docs/format/oxfmt.mdx new file mode 100644 index 000000000000..41beb1ec5cdf --- /dev/null +++ b/docs/format/oxfmt.mdx @@ -0,0 +1,103 @@ +--- +title: "Migrate from oxfmt" +description: "Replace oxfmt with bun format: same configuration, same flags, same output" +--- + +[`bun format`](/runtime/format) reads your `.oxfmtrc.json` and prints what oxfmt 0.72 prints. Most projects only change the command. + + + + + ```json package.json icon="file-json" + { + "scripts": { + "format": "bun format", + "format:check": "bun format --check" + } + } + ``` + + Do this first. While the `format` script says `oxfmt`, `bun format` [runs that script](/runtime/format#notes). + + + + + ```bash terminal icon="terminal" + bun format --doctor + ``` + + `--doctor` reads your configuration and changes nothing. + + + + + ```bash terminal icon="terminal" + bun format --check + ``` + + ```txt + $ bun format --check + Checking formatting... + + All matched files use the correct format. + Finished in 3ms on 3 files using 8 threads. + ``` + + If you pin a version of oxfmt before 0.72, a few files change once, like when you upgrade oxfmt. + + + + + Replace `oxfmt`, `npx oxfmt`, and `bunx oxfmt` with `bun format`. + + + + + ```bash terminal icon="terminal" + bun remove oxfmt + ``` + + If you have no `.oxfmtrc.json`, add an empty one (`{}`) first. Without it, Bun only knows this is an oxfmt project from the `oxfmt` dependency. + + + + +| Instead of | Run | +| ----------------- | ---------------------- | +| `oxfmt` | `bun format` | +| `oxfmt --check .` | `bun format --check` | +| `oxfmt -c a.json` | `bun format -c a.json` | + +## What Bun reads + +- **Configuration files**: `.oxfmtrc.json`, `.oxfmtrc.jsonc`, and `oxfmt.config.ts`, with `overrides` and `ignorePatterns`. +- **vite-plus**: the `fmt` key of the nearest `vite.config.ts`, as `vp fmt` does. +- **Every option**, including `sortImports`, `sortPackageJson`, `sortTailwindcss`, `jsdoc`, and `insertFinalNewline`. +- **Comments**: `// oxfmt-ignore` and `// prettier-ignore`. +- **Flags**: every flag of oxfmt 0.72 except `--lsp` and `--migrate`. + +With an oxfmt configuration, Bun also follows oxfmt's defaults: + +- Lines wrap at 100 columns. +- Every `.gitignore` in the repository counts, not only the one in the current directory. +- `.prettierrc` files are not read. +- TOML files are formatted. +- What a run prints is what oxfmt prints. + +`sortTailwindcss` needs `tailwindcss` version 3 or 4 installed in the project. Bun asks it for the order of the classes. + +A `[format]` section in [`bunfig.toml`](/runtime/bunfig#formatter) overrides `.oxfmtrc.json`, like a flag does. + +## What differs + +If `bun format` prints something oxfmt 0.72 does not, that is a bug. [Open an issue](https://github.com/oven-sh/bun/issues) with the code. + +These differences are on purpose: + +- **The output is oxfmt 0.72's**, whichever version you had installed. +- **`--lsp` and `--migrate` are errors.** For editors, use [`bun lint --lsp`](/runtime/format#format-in-your-editor). +- **No Svelte.** With `svelte` in the configuration, Bun leaves `.svelte` files alone and exits with `2`. `--allow-unsupported` makes that a warning. +- **Errors and warnings are in Bun's words.** +- **A TOML file that is not valid is left alone**, with a warning. oxfmt rewrites the rest of the file. +- **A file that is not UTF-8 is formatted**, and the other bytes stay as they are. oxfmt exits with `2`. +- **It never damages a file.** Before writing, Bun checks that the result is the same program or document. If not, it leaves the file alone and reports it. diff --git a/docs/format/prettier.mdx b/docs/format/prettier.mdx new file mode 100644 index 000000000000..5c09dac318ec --- /dev/null +++ b/docs/format/prettier.mdx @@ -0,0 +1,106 @@ +--- +title: "Migrate from Prettier" +description: "Replace Prettier with bun format: same configuration, same flags, same output" +--- + +[`bun format`](/runtime/format) reads your Prettier configuration and prints what Prettier 3 prints. Most projects only change the command. + + + + + ```json package.json icon="file-json" + { + "scripts": { + "format": "bun format", + "format:check": "bun format --check" + } + } + ``` + + Do this first. While the `format` script says `prettier`, `bun format` [runs that script](/runtime/format#notes). + + + + + ```bash terminal icon="terminal" + bun format --doctor + ``` + + `--doctor` reads your configuration and changes nothing. + + + + + ```bash terminal icon="terminal" + bun format --check + ``` + + ```txt + $ bun format --check + Checking formatting... + All matched files use Prettier code style! + ``` + + + + + Replace `prettier`, `npx prettier`, and `bunx prettier` with `bun format`. You can drop lint-staged for [`bun format --staged`](/runtime/format#format-before-you-commit). + + + + + ```bash terminal icon="terminal" + bun remove prettier + ``` + + Keep `prettier` if `--doctor` says it is still needed, or if your editor's Prettier extension uses it. + + + + +| Instead of | Run | +| -------------------------------- | ---------------------------------- | +| `prettier --write .` | `bun format` | +| `prettier --write src test` | `bun format src test` | +| `prettier --check .` | `bun format --check` | +| `prettier -l .` | `bun format -l` | +| `prettier --stdin-filepath a.ts` | `bun format --stdin-filepath a.ts` | + +## What Bun reads + +- **Configuration files**: `.prettierrc` in JSON, YAML, TOML, JavaScript, or TypeScript, `prettier.config.*`, and `"prettier"` in `package.json`. Each file uses the nearest one, and `overrides` work. +- **`.editorconfig`**, `.prettierignore`, and `.gitignore`. +- **Comments**: `// prettier-ignore`, `@format`, `@prettier`, `@noformat`, `@noprettier`. +- **Flags**: every flag of Prettier 3 except `--file-info` and `--support-info`. + +With a Prettier configuration, `--check` and `--list-different` print what Prettier prints, so scripts that read the output keep working. + +A `[format]` section in [`bunfig.toml`](/runtime/bunfig#formatter) overrides the Prettier configuration, like a flag does. + +## Plugins + +Bun does not load Prettier plugins. What happens depends on the plugin. + +- **Built in**: `@trivago/prettier-plugin-sort-imports`, `@ianvs/prettier-plugin-sort-imports`, and `prettier-plugin-organize-imports`, with every option. Keep the name in `plugins`. You can uninstall the package. +- **Sent to your installed Prettier**: the files of a plugin that adds a language, like `prettier-plugin-svelte` or `prettier-plugin-astro`. Keep `prettier` and every plugin in `plugins` installed. +- **Not supported**: any other plugin. Bun skips the files that configuration covers and exits with `2`. + +`--allow-unsupported` formats the skipped files without the plugin, and turns the error into a warning. + +Files that go to your installed Prettier are formatted at Prettier's speed. If `prettier` or the plugin is missing, Bun leaves those files alone, formats the rest, and exits with `2`. + +## What differs + +If `bun format` prints something Prettier 3 does not, that is a bug. [Open an issue](https://github.com/oven-sh/bun/issues) with the code. + +These differences are on purpose: + +- **It writes by default.** `--write` is accepted and changes nothing. +- **No arguments means the current directory.** +- **It only prints the files it changed**, then a summary on stderr. +- **An unknown flag is an error.** Prettier warns and goes on. +- **Exit code `2` means something went wrong**, like a syntax error or a skipped file. Prettier uses `1` for some of these. +- **It never damages a file.** Before writing, Bun checks that the result is the same program or document. If not, it leaves the file alone and reports it. This covers a few inputs where Prettier drops code, such as `` in a Handlebars template. +- **Bytes that are not UTF-8 stay as they are.** Prettier replaces them. +- **It does not write through a symlink that leaves the repository.** +- **`--cache` has no effect.** Every run formats every file. From 97481d643d07b7ba76b58a3cecb06ebd90da66ff Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Mon, 12 Oct 2026 05:48:42 +0000 Subject: [PATCH 04/49] lint, format, check: 2 files Work in progress. --- src/lint/docs.rs | 42 ++++++++++++++++++++++++++++++++++++++++++ src/lint/lib.rs | 1 + 2 files changed, 43 insertions(+) create mode 100644 src/lint/docs.rs diff --git a/src/lint/docs.rs b/src/lint/docs.rs new file mode 100644 index 000000000000..ed13878dfa3a --- /dev/null +++ b/src/lint/docs.rs @@ -0,0 +1,42 @@ +//! Where the page about a rule is: `docs/lint/rules//.mdx`. + +use crate::rule::{Meta, Plugin}; + +impl Plugin { + /// The directory of the pages about its rules. The rules of `react-hooks` are in that of `react`. + pub const fn directory_of_docs(self) -> &'static str { + match self { + Plugin::Eslint => "eslint", + Plugin::TypeScript => "typescript", + Plugin::ReactHooks | Plugin::React => "react", + Plugin::Import => "import", + Plugin::Node => "node", + Plugin::Oxc => "oxc", + Plugin::Unicorn => "unicorn", + Plugin::ReactPerf => "react-perf", + Plugin::JsxA11y => "jsx-a11y", + Plugin::Nextjs => "nextjs", + Plugin::Promise => "promise", + Plugin::Jest => "jest", + Plugin::Vitest => "vitest", + Plugin::Jsdoc => "jsdoc", + Plugin::Vue => "vue", + Plugin::Bun => "bun", + Plugin::BunTest => "bun-test", + Plugin::Prettier => "prettier", + } + } +} + +impl Meta { + /// `typescript/no-explicit-any`: the page about the rule, under `docs/lint/rules` and without `.mdx`. + pub fn page(&self) -> Vec { + let directory = self.plugin.directory_of_docs(); + [directory.as_bytes(), b"/", self.name.as_bytes()].concat() + } + + /// `https://bun.com/docs/lint/rules/typescript/no-explicit-any` + pub fn docs_url(&self) -> Vec { + [&b"https://bun.com/docs/lint/rules/"[..], &self.page()].concat() + } +} diff --git a/src/lint/lib.rs b/src/lint/lib.rs index fbbcc5d4e208..b5cecbb2588f 100644 --- a/src/lint/lib.rs +++ b/src/lint/lib.rs @@ -17,6 +17,7 @@ pub mod ast; pub mod code_path; pub mod context; +pub mod docs; pub(crate) mod estree; pub mod fix; pub mod formats; From 620e8e55189b27309ecea180e92c6ddf0da81754 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Mon, 12 Oct 2026 05:51:30 +0000 Subject: [PATCH 05/49] Format --- docs/lint/rules/bun-test/no-focused-tests.mdx | 2 ++ docs/lint/rules/eslint/eqeqeq.mdx | 12 ++++++++---- docs/lint/rules/eslint/no-var.mdx | 2 ++ .../rules/typescript/no-floating-promises.mdx | 17 ++++++++++------- docs/lint/rules/unicorn/no-null.mdx | 7 +++++-- src/md/html_renderer.rs | 5 +---- 6 files changed, 28 insertions(+), 17 deletions(-) diff --git a/docs/lint/rules/bun-test/no-focused-tests.mdx b/docs/lint/rules/bun-test/no-focused-tests.mdx index 3667271de586..1941bdf1ab2c 100644 --- a/docs/lint/rules/bun-test/no-focused-tests.mdx +++ b/docs/lint/rules/bun-test/no-focused-tests.mdx @@ -12,6 +12,7 @@ This rule is on by default in files that import from `"bun:test"`. Reported: {/* bad */} + ```ts import { expect, test } from "bun:test"; @@ -23,6 +24,7 @@ test.only("adds", () => { Not reported: {/* good */} + ```ts import { expect, test } from "bun:test"; diff --git a/docs/lint/rules/eslint/eqeqeq.mdx b/docs/lint/rules/eslint/eqeqeq.mdx index 8743810fdf8a..41ffdb00be73 100644 --- a/docs/lint/rules/eslint/eqeqeq.mdx +++ b/docs/lint/rules/eslint/eqeqeq.mdx @@ -10,6 +10,7 @@ description: "Require `===` and `!==` instead of `==` and `!=`" Reported: {/* bad */} + ```js if (count == 0) { reset(); @@ -19,6 +20,7 @@ if (count == 0) { Not reported: {/* good */} + ```js if (count === 0) { reset(); @@ -32,16 +34,17 @@ if (count === 0) { eqeqeq = ["error", "smart"] ``` -| Option | What it does | -| --- | --- | -| `"always"` (default) | Report every `==` and `!=`. | -| `"smart"` | Allow `==` when comparing with `null`, comparing two literals, or comparing the result of `typeof`. | +| Option | What it does | +| -------------------- | --------------------------------------------------------------------------------------------------- | +| `"always"` (default) | Report every `==` and `!=`. | +| `"smart"` | Allow `==` when comparing with `null`, comparing two literals, or comparing the result of `typeof`. | With `"always"`, a second option decides what happens with `null`: `{ "null": "always" }` (default) reports `x == null`, `"ignore"` allows it, and `"never"` reports `x === null` instead. With `"smart"`, this is allowed, because `== null` is a common way to test for both `null` and `undefined`: {/* good {"options": ["smart"]} */} + ```js if (value == null) { value = fallback; @@ -51,6 +54,7 @@ if (value == null) { And this is still reported: {/* bad {"options": ["smart"]} */} + ```js if (value == other) { merge(value, other); diff --git a/docs/lint/rules/eslint/no-var.mdx b/docs/lint/rules/eslint/no-var.mdx index 7ccd1b26a775..ac6ced6d22a8 100644 --- a/docs/lint/rules/eslint/no-var.mdx +++ b/docs/lint/rules/eslint/no-var.mdx @@ -12,6 +12,7 @@ A `var` belongs to the whole function, not to the block it is written in, and it Reported: {/* bad */} + ```js for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i)); // 3, 3, 3 @@ -21,6 +22,7 @@ for (var i = 0; i < 3; i++) { Not reported: {/* good */} + ```js for (let i = 0; i < 3; i++) { setTimeout(() => console.log(i)); // 0, 1, 2 diff --git a/docs/lint/rules/typescript/no-floating-promises.mdx b/docs/lint/rules/typescript/no-floating-promises.mdx index 39a8ecbc1b4c..d39ad5e4a0cc 100644 --- a/docs/lint/rules/typescript/no-floating-promises.mdx +++ b/docs/lint/rules/typescript/no-floating-promises.mdx @@ -14,6 +14,7 @@ This rule needs type information. Run `bun lint --type-aware`. Reported: {/* bad */} + ```ts async function save(data: string): Promise {} @@ -25,6 +26,7 @@ function onClick() { Not reported: {/* good */} + ```ts async function save(data: string): Promise {} @@ -39,15 +41,16 @@ function onBlur() { ## Options -| Option | Default | What it does | -| --- | --- | --- | -| `ignoreVoid` | `true` | Allow `void promise` as a way to say "ignored on purpose". | -| `ignoreIIFE` | `false` | Allow an async function that is called where it is written: `(async () => { ... })()`. | -| `checkThenables` | `false` | Also report objects that only have a `then` method. | -| `allowForKnownSafePromises` | `[]` | Promise types that never need handling. | -| `allowForKnownSafeCalls` | `[]` | Functions that return promises you never need to handle. | +| Option | Default | What it does | +| --------------------------- | ------- | -------------------------------------------------------------------------------------- | +| `ignoreVoid` | `true` | Allow `void promise` as a way to say "ignored on purpose". | +| `ignoreIIFE` | `false` | Allow an async function that is called where it is written: `(async () => { ... })()`. | +| `checkThenables` | `false` | Also report objects that only have a `then` method. | +| `allowForKnownSafePromises` | `[]` | Promise types that never need handling. | +| `allowForKnownSafeCalls` | `[]` | Functions that return promises you never need to handle. | {/* bad {"options": [{"ignoreVoid": false}]} */} + ```ts async function save(data: string): Promise {} diff --git a/docs/lint/rules/unicorn/no-null.mdx b/docs/lint/rules/unicorn/no-null.mdx index afdd779155ed..3ce366d5e20a 100644 --- a/docs/lint/rules/unicorn/no-null.mdx +++ b/docs/lint/rules/unicorn/no-null.mdx @@ -12,6 +12,7 @@ Strict comparisons such as `x === null` are allowed by default, because code oft Reported: {/* bad */} + ```js let selected = null; ``` @@ -19,6 +20,7 @@ let selected = null; Not reported: {/* good */} + ```js let selected; @@ -29,11 +31,12 @@ if (document.querySelector("dialog") === null) { ## Options -| Option | Default | What it does | -| --- | --- | --- | +| Option | Default | What it does | +| --------------------- | ------- | ------------------------------------------ | | `checkStrictEquality` | `false` | Also report `x === null` and `x !== null`. | {/* bad {"options": [{"checkStrictEquality": true}]} */} + ```js if (selected === null) { selected = first(); diff --git a/src/md/html_renderer.rs b/src/md/html_renderer.rs index 863939f5023c..45271496b521 100644 --- a/src/md/html_renderer.rs +++ b/src/md/html_renderer.rs @@ -486,10 +486,7 @@ impl<'src> HtmlRenderer<'src> { /// Writes the bytes at the start of `txt` that are written as they are. /// Returns how many there are. fn write_url_safe_run(&mut self, txt: &[u8]) -> usize { - let len = txt - .iter() - .take_while(|&&byte| URL_SAFE.has(byte)) - .count(); + let len = txt.iter().take_while(|&&byte| URL_SAFE.has(byte)).count(); if len > 0 { self.write(&txt[..len]); } From a6a15fca909e3c3a46271bb3885c7849f464e821 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Mon, 12 Oct 2026 06:23:29 +0000 Subject: [PATCH 06/49] lint, format: 1178 files --- completions/bun.bash | 4 +- completions/bun.fish | 198 ++- completions/bun.zsh | 230 +-- docs/docs.json | 13 +- docs/format/oxfmt.mdx | 103 -- docs/format/prettier.mdx | 106 -- docs/guides/format/migrate-from-oxfmt.mdx | 109 ++ docs/guides/format/migrate-from-prettier.mdx | 165 +++ docs/guides/lint/migrate-from-eslint.mdx | 152 ++ docs/guides/lint/migrate-from-oxlint.mdx | 131 ++ docs/index.mdx | 2 + docs/lint/plugins.mdx | 162 +++ .../bun-test/consistent-test-filename.mdx | 76 + .../rules/bun-test/consistent-test-it.mdx | 98 ++ docs/lint/rules/bun-test/expect-expect.mdx | 125 ++ docs/lint/rules/bun-test/max-expects.mdx | 89 ++ .../rules/bun-test/max-nested-describe.mdx | 83 ++ docs/lint/rules/bun-test/no-alias-methods.mdx | 54 + .../rules/bun-test/no-commented-out-tests.mdx | 53 + .../rules/bun-test/no-conditional-expect.mdx | 81 ++ .../rules/bun-test/no-conditional-in-test.mdx | 95 ++ .../rules/bun-test/no-conditional-tests.mdx | 66 + .../lint/rules/bun-test/no-disabled-tests.mdx | 58 + docs/lint/rules/bun-test/no-done-callback.mdx | 60 + .../rules/bun-test/no-duplicate-hooks.mdx | 55 + docs/lint/rules/bun-test/no-export.mdx | 51 + docs/lint/rules/bun-test/no-hooks.mdx | 65 + .../rules/bun-test/no-identical-title.mdx | 105 ++ .../no-interpolation-in-snapshots.mdx | 59 + .../rules/bun-test/no-large-snapshots.mdx | 72 + .../bun-test/no-restricted-jest-methods.mdx | 72 + .../rules/bun-test/no-restricted-matchers.mdx | 82 ++ .../rules/bun-test/no-standalone-expect.mdx | 69 + docs/lint/rules/bun-test/no-test-prefixes.mdx | 50 + .../bun-test/no-test-return-statement.mdx | 66 + .../no-unneeded-async-expect-function.mdx | 57 + .../padding-around-after-all-blocks.mdx | 52 + .../bun-test/padding-around-test-blocks.mdx | 51 + .../rules/bun-test/prefer-called-once.mdx | 45 + .../rules/bun-test/prefer-called-times.mdx | 42 + .../rules/bun-test/prefer-called-with.mdx | 46 + .../bun-test/prefer-comparison-matcher.mdx | 56 + .../prefer-describe-function-title.mdx | 53 + docs/lint/rules/bun-test/prefer-each.mdx | 50 + .../bun-test/prefer-ending-with-an-expect.mdx | 114 ++ .../bun-test/prefer-equality-matcher.mdx | 48 + .../bun-test/prefer-expect-assertions.mdx | 121 ++ .../rules/bun-test/prefer-expect-resolves.mdx | 44 + .../rules/bun-test/prefer-expect-type-of.mdx | 38 + .../rules/bun-test/prefer-hooks-in-order.mdx | 62 + .../rules/bun-test/prefer-hooks-on-top.mdx | 64 + .../rules/bun-test/prefer-lowercase-title.mdx | 101 ++ .../prefer-mock-promise-shorthand.mdx | 48 + .../bun-test/prefer-mock-return-shorthand.mdx | 58 + .../rules/bun-test/prefer-snapshot-hint.mdx | 75 + docs/lint/rules/bun-test/prefer-spy-on.mdx | 63 + .../prefer-strict-boolean-matchers.mdx | 40 + .../rules/bun-test/prefer-strict-equal.mdx | 38 + .../rules/bun-test/prefer-to-be-falsy.mdx | 44 + .../rules/bun-test/prefer-to-be-truthy.mdx | 44 + docs/lint/rules/bun-test/prefer-to-be.mdx | 64 + .../lint/rules/bun-test/prefer-to-contain.mdx | 52 + .../prefer-to-have-been-called-times.mdx | 40 + .../bun-test/prefer-to-have-been-called.mdx | 45 + .../rules/bun-test/prefer-to-have-length.mdx | 41 + docs/lint/rules/bun-test/prefer-todo.mdx | 42 + docs/lint/rules/bun-test/require-hook.mdx | 79 + .../bun-test/require-mock-type-parameters.mdx | 35 + .../rules/bun-test/require-test-timeout.mdx | 86 ++ .../bun-test/require-to-throw-message.mdx | 42 + .../bun-test/require-top-level-describe.mdx | 78 + .../bun-test/valid-describe-callback.mdx | 85 ++ .../bun-test/valid-expect-in-promise.mdx | 113 ++ docs/lint/rules/bun-test/valid-expect.mdx | 131 ++ docs/lint/rules/bun-test/valid-title.mdx | 121 ++ docs/lint/rules/bun-test/warn-todo.mdx | 36 + .../rules/bun/consistent-directive-prefix.mdx | 57 + docs/lint/rules/bun/format.mdx | 62 + .../rules/bun/jsx-no-forbidden-nesting.mdx | 82 ++ .../lint/rules/bun/no-array-just-to-count.mdx | 66 + docs/lint/rules/bun/no-cast-to-brand.mdx | 93 ++ .../rules/bun/no-console-window-flash.mdx | 60 + docs/lint/rules/bun/no-cwd-dependent-path.mdx | 84 ++ .../rules/bun/no-eager-dynamic-import.mdx | 73 + docs/lint/rules/bun/no-eager-native-addon.mdx | 49 + .../lint/rules/bun/no-env-at-module-scope.mdx | 73 + docs/lint/rules/bun/no-literal-temp-dir.mdx | 53 + .../rules/bun/no-negative-repeat-count.mdx | 60 + .../rules/bun/no-redundant-catch-type.mdx | 38 + docs/lint/rules/bun/no-restricted-text.mdx | 70 + .../rules/bun/no-side-effects-on-import.mdx | 97 ++ docs/lint/rules/bun/no-unbounded-requests.mdx | 87 ++ .../rules/bun/no-unhandled-emitter-errors.mdx | 97 ++ .../lint/rules/bun/no-unportable-commands.mdx | 89 ++ .../no-unseeded-random-in-property-test.mdx | 65 + .../rules/bun/no-useless-spread-fallback.mdx | 75 + docs/lint/rules/bun/prefer-builtin-sleep.mdx | 56 + docs/lint/rules/bun/prefer-lazy-schema.mdx | 98 ++ docs/lint/rules/bun/prefer-local-module.mdx | 67 + docs/lint/rules/bun/prefer-timeout-signal.mdx | 67 + .../bun/prefer-url-for-sibling-files.mdx | 72 + .../rules/bun/react-no-latest-value-ref.mdx | 84 ++ .../bun/react-no-local-render-helpers.mdx | 82 ++ .../rules/bun/react-no-namespace-hooks.mdx | 57 + .../bun/react-prefer-updater-function.mdx | 82 ++ docs/lint/rules/eslint/accessor-pairs.mdx | 90 ++ .../rules/eslint/array-callback-return.mdx | 81 ++ docs/lint/rules/eslint/arrow-body-style.mdx | 77 + docs/lint/rules/eslint/block-scoped-var.mdx | 50 + docs/lint/rules/eslint/callback-return.mdx | 80 + docs/lint/rules/eslint/camelcase.mdx | 108 ++ .../rules/eslint/capitalized-comments.mdx | 88 ++ .../rules/eslint/class-methods-use-this.mdx | 97 ++ docs/lint/rules/eslint/complexity.mdx | 96 ++ docs/lint/rules/eslint/consistent-return.mdx | 81 ++ docs/lint/rules/eslint/consistent-this.mdx | 77 + docs/lint/rules/eslint/constructor-super.mdx | 83 ++ docs/lint/rules/eslint/curly.mdx | 123 ++ docs/lint/rules/eslint/default-case-last.mdx | 38 + docs/lint/rules/eslint/default-case.mdx | 90 ++ docs/lint/rules/eslint/default-param-last.mdx | 32 + docs/lint/rules/eslint/dot-notation.mdx | 62 + docs/lint/rules/eslint/for-direction.mdx | 56 + docs/lint/rules/eslint/func-name-matching.mdx | 102 ++ docs/lint/rules/eslint/func-names.mdx | 125 ++ docs/lint/rules/eslint/func-style.mdx | 141 ++ docs/lint/rules/eslint/getter-return.mdx | 93 ++ docs/lint/rules/eslint/global-require.mdx | 55 + .../rules/eslint/grouped-accessor-pairs.mdx | 107 ++ docs/lint/rules/eslint/guard-for-in.mdx | 59 + .../lint/rules/eslint/handle-callback-err.mdx | 73 + docs/lint/rules/eslint/id-blacklist.mdx | 54 + docs/lint/rules/eslint/id-denylist.mdx | 75 + docs/lint/rules/eslint/id-length.mdx | 113 ++ docs/lint/rules/eslint/id-match.mdx | 138 ++ docs/lint/rules/eslint/init-declarations.mdx | 105 ++ .../eslint/logical-assignment-operators.mdx | 84 ++ .../rules/eslint/max-classes-per-file.mdx | 78 + docs/lint/rules/eslint/max-depth.mdx | 81 ++ .../rules/eslint/max-lines-per-function.mdx | 102 ++ docs/lint/rules/eslint/max-lines.mdx | 72 + .../rules/eslint/max-nested-callbacks.mdx | 73 + docs/lint/rules/eslint/max-params.mdx | 75 + docs/lint/rules/eslint/max-statements.mdx | 87 ++ docs/lint/rules/eslint/new-cap.mdx | 94 ++ docs/lint/rules/eslint/no-alert.mdx | 46 + .../rules/eslint/no-array-constructor.mdx | 39 + .../eslint/no-async-promise-executor.mdx | 36 + docs/lint/rules/eslint/no-await-in-loop.mdx | 60 + docs/lint/rules/eslint/no-bitwise.mdx | 71 + .../rules/eslint/no-buffer-constructor.mdx | 28 + docs/lint/rules/eslint/no-caller.mdx | 30 + .../rules/eslint/no-case-declarations.mdx | 43 + docs/lint/rules/eslint/no-catch-shadow.mdx | 42 + docs/lint/rules/eslint/no-class-assign.mdx | 40 + .../lint/rules/eslint/no-compare-neg-zero.mdx | 44 + docs/lint/rules/eslint/no-cond-assign.mdx | 80 + docs/lint/rules/eslint/no-console.mdx | 65 + docs/lint/rules/eslint/no-const-assign.mdx | 49 + .../eslint/no-constant-binary-expression.mdx | 62 + .../rules/eslint/no-constant-condition.mdx | 93 ++ .../rules/eslint/no-constructor-return.mdx | 58 + docs/lint/rules/eslint/no-continue.mdx | 39 + docs/lint/rules/eslint/no-control-regex.mdx | 57 + docs/lint/rules/eslint/no-debugger.mdx | 31 + docs/lint/rules/eslint/no-delete-var.mdx | 32 + docs/lint/rules/eslint/no-div-regex.mdx | 26 + docs/lint/rules/eslint/no-dupe-args.mdx | 32 + .../rules/eslint/no-dupe-class-members.mdx | 92 ++ docs/lint/rules/eslint/no-dupe-else-if.mdx | 66 + docs/lint/rules/eslint/no-dupe-keys.mdx | 63 + docs/lint/rules/eslint/no-duplicate-case.mdx | 63 + .../rules/eslint/no-duplicate-imports.mdx | 85 ++ docs/lint/rules/eslint/no-else-return.mdx | 89 ++ .../rules/eslint/no-empty-character-class.mdx | 36 + docs/lint/rules/eslint/no-empty-function.mdx | 97 ++ docs/lint/rules/eslint/no-empty-pattern.mdx | 68 + .../rules/eslint/no-empty-static-block.mdx | 48 + docs/lint/rules/eslint/no-empty.mdx | 72 + docs/lint/rules/eslint/no-eq-null.mdx | 40 + docs/lint/rules/eslint/no-eval.mdx | 69 + docs/lint/rules/eslint/no-ex-assign.mdx | 62 + docs/lint/rules/eslint/no-extend-native.mdx | 70 + docs/lint/rules/eslint/no-extra-bind.mdx | 60 + .../rules/eslint/no-extra-boolean-cast.mdx | 92 ++ docs/lint/rules/eslint/no-extra-label.mdx | 50 + docs/lint/rules/eslint/no-fallthrough.mdx | 114 ++ docs/lint/rules/eslint/no-func-assign.mdx | 58 + docs/lint/rules/eslint/no-global-assign.mdx | 58 + .../rules/eslint/no-implicit-coercion.mdx | 75 + .../lint/rules/eslint/no-implicit-globals.mdx | 97 ++ docs/lint/rules/eslint/no-implied-eval.mdx | 47 + docs/lint/rules/eslint/no-import-assign.mdx | 36 + docs/lint/rules/eslint/no-inline-comments.mdx | 75 + .../rules/eslint/no-inner-declarations.mdx | 104 ++ docs/lint/rules/eslint/no-invalid-regexp.mdx | 51 + docs/lint/rules/eslint/no-invalid-this.mdx | 126 ++ .../rules/eslint/no-irregular-whitespace.mdx | 84 ++ docs/lint/rules/eslint/no-iterator.mdx | 32 + docs/lint/rules/eslint/no-label-var.mdx | 51 + docs/lint/rules/eslint/no-labels.mdx | 85 ++ docs/lint/rules/eslint/no-lone-blocks.mdx | 76 + docs/lint/rules/eslint/no-lonely-if.mdx | 55 + docs/lint/rules/eslint/no-loop-func.mdx | 71 + .../rules/eslint/no-loss-of-precision.mdx | 34 + docs/lint/rules/eslint/no-magic-numbers.mdx | 135 ++ .../eslint/no-misleading-character-class.mdx | 66 + docs/lint/rules/eslint/no-mixed-requires.mdx | 80 + docs/lint/rules/eslint/no-multi-assign.mdx | 63 + docs/lint/rules/eslint/no-multi-str.mdx | 42 + docs/lint/rules/eslint/no-native-reassign.mdx | 46 + .../rules/eslint/no-negated-condition.mdx | 58 + docs/lint/rules/eslint/no-negated-in-lhs.mdx | 30 + docs/lint/rules/eslint/no-nested-ternary.mdx | 40 + docs/lint/rules/eslint/no-new-func.mdx | 36 + .../eslint/no-new-native-nonconstructor.mdx | 33 + docs/lint/rules/eslint/no-new-object.mdx | 27 + docs/lint/rules/eslint/no-new-require.mdx | 29 + docs/lint/rules/eslint/no-new-symbol.mdx | 26 + docs/lint/rules/eslint/no-new-wrappers.mdx | 41 + docs/lint/rules/eslint/no-new.mdx | 30 + .../eslint/no-nonoctal-decimal-escape.mdx | 31 + docs/lint/rules/eslint/no-obj-calls.mdx | 45 + .../rules/eslint/no-object-constructor.mdx | 39 + docs/lint/rules/eslint/no-octal-escape.mdx | 30 + docs/lint/rules/eslint/no-octal.mdx | 31 + docs/lint/rules/eslint/no-param-reassign.mdx | 76 + docs/lint/rules/eslint/no-path-concat.mdx | 34 + docs/lint/rules/eslint/no-plusplus.mdx | 58 + docs/lint/rules/eslint/no-process-env.mdx | 38 + docs/lint/rules/eslint/no-process-exit.mdx | 46 + .../eslint/no-promise-executor-return.mdx | 72 + docs/lint/rules/eslint/no-proto.mdx | 30 + .../rules/eslint/no-prototype-builtins.mdx | 40 + docs/lint/rules/eslint/no-redeclare.mdx | 106 ++ docs/lint/rules/eslint/no-regex-spaces.mdx | 33 + .../rules/eslint/no-restricted-exports.mdx | 89 ++ .../rules/eslint/no-restricted-globals.mdx | 73 + .../rules/eslint/no-restricted-imports.mdx | 123 ++ .../rules/eslint/no-restricted-modules.mdx | 66 + .../rules/eslint/no-restricted-properties.mdx | 82 ++ .../rules/eslint/no-restricted-syntax.mdx | 82 ++ docs/lint/rules/eslint/no-return-assign.mdx | 62 + docs/lint/rules/eslint/no-return-await.mdx | 51 + docs/lint/rules/eslint/no-script-url.mdx | 30 + docs/lint/rules/eslint/no-self-assign.mdx | 49 + docs/lint/rules/eslint/no-self-compare.mdx | 48 + docs/lint/rules/eslint/no-sequences.mdx | 67 + docs/lint/rules/eslint/no-setter-return.mdx | 46 + .../eslint/no-shadow-restricted-names.mdx | 64 + docs/lint/rules/eslint/no-shadow.mdx | 131 ++ docs/lint/rules/eslint/no-sparse-arrays.mdx | 31 + docs/lint/rules/eslint/no-sync.mdx | 76 + .../eslint/no-template-curly-in-string.mdx | 29 + docs/lint/rules/eslint/no-ternary.mdx | 31 + .../rules/eslint/no-this-before-super.mdx | 67 + docs/lint/rules/eslint/no-throw-literal.mdx | 38 + docs/lint/rules/eslint/no-unassigned-vars.mdx | 50 + docs/lint/rules/eslint/no-undef-init.mdx | 34 + docs/lint/rules/eslint/no-undef.mdx | 87 ++ docs/lint/rules/eslint/no-undefined.mdx | 50 + .../rules/eslint/no-underscore-dangle.mdx | 91 ++ .../rules/eslint/no-unexpected-multiline.mdx | 68 + .../eslint/no-unmodified-loop-condition.mdx | 109 ++ .../lint/rules/eslint/no-unneeded-ternary.mdx | 55 + .../lint/rules/eslint/no-unreachable-loop.mdx | 67 + docs/lint/rules/eslint/no-unreachable.mdx | 69 + docs/lint/rules/eslint/no-unsafe-finally.mdx | 58 + docs/lint/rules/eslint/no-unsafe-negation.mdx | 72 + .../eslint/no-unsafe-optional-chaining.mdx | 76 + .../rules/eslint/no-unused-expressions.mdx | 84 ++ docs/lint/rules/eslint/no-unused-labels.mdx | 52 + .../no-unused-private-class-members.mdx | 63 + docs/lint/rules/eslint/no-unused-vars.mdx | 132 ++ .../rules/eslint/no-use-before-define.mdx | 125 ++ .../rules/eslint/no-useless-assignment.mdx | 71 + .../rules/eslint/no-useless-backreference.mdx | 35 + docs/lint/rules/eslint/no-useless-call.mdx | 35 + docs/lint/rules/eslint/no-useless-catch.mdx | 73 + .../rules/eslint/no-useless-computed-key.mdx | 65 + docs/lint/rules/eslint/no-useless-concat.mdx | 31 + .../rules/eslint/no-useless-constructor.mdx | 54 + docs/lint/rules/eslint/no-useless-escape.mdx | 48 + docs/lint/rules/eslint/no-useless-rename.mdx | 49 + docs/lint/rules/eslint/no-useless-return.mdx | 50 + docs/lint/rules/eslint/no-void.mdx | 52 + .../lint/rules/eslint/no-warning-comments.mdx | 91 ++ docs/lint/rules/eslint/no-with.mdx | 31 + docs/lint/rules/eslint/object-shorthand.mdx | 145 ++ docs/lint/rules/eslint/one-var.mdx | 141 ++ .../lint/rules/eslint/operator-assignment.mdx | 60 + .../rules/eslint/prefer-arrow-callback.mdx | 74 + docs/lint/rules/eslint/prefer-const.mdx | 97 ++ .../rules/eslint/prefer-destructuring.mdx | 81 ++ .../eslint/prefer-exponentiation-operator.mdx | 28 + .../eslint/prefer-named-capture-group.mdx | 34 + .../rules/eslint/prefer-numeric-literals.mdx | 35 + .../rules/eslint/prefer-object-has-own.mdx | 42 + .../rules/eslint/prefer-object-spread.mdx | 43 + .../eslint/prefer-promise-reject-errors.mdx | 56 + docs/lint/rules/eslint/prefer-reflect.mdx | 55 + .../rules/eslint/prefer-regex-literals.mdx | 56 + docs/lint/rules/eslint/prefer-rest-params.mdx | 37 + docs/lint/rules/eslint/prefer-spread.mdx | 36 + docs/lint/rules/eslint/prefer-template.mdx | 33 + .../rules/eslint/preserve-caught-error.mdx | 120 ++ docs/lint/rules/eslint/radix.mdx | 48 + .../rules/eslint/require-atomic-updates.mdx | 88 ++ docs/lint/rules/eslint/require-await.mdx | 53 + .../rules/eslint/require-unicode-regexp.mdx | 49 + docs/lint/rules/eslint/require-yield.mdx | 36 + docs/lint/rules/eslint/sort-imports.mdx | 103 ++ docs/lint/rules/eslint/sort-keys.mdx | 97 ++ docs/lint/rules/eslint/sort-vars.mdx | 53 + docs/lint/rules/eslint/strict.mdx | 96 ++ docs/lint/rules/eslint/symbol-description.mdx | 30 + docs/lint/rules/eslint/unicode-bom.mdx | 37 + docs/lint/rules/eslint/use-isnan.mdx | 77 + docs/lint/rules/eslint/valid-typeof.mdx | 74 + docs/lint/rules/eslint/vars-on-top.mdx | 46 + docs/lint/rules/eslint/yoda.mdx | 86 ++ .../consistent-type-specifier-style.mdx | 66 + docs/lint/rules/import/default.mdx | 62 + .../rules/import/dynamic-import-chunkname.mdx | 93 ++ .../import/enforce-node-protocol-usage.mdx | 68 + docs/lint/rules/import/export.mdx | 111 ++ docs/lint/rules/import/exports-last.mdx | 50 + docs/lint/rules/import/extensions.mdx | 126 ++ docs/lint/rules/import/first.mdx | 61 + docs/lint/rules/import/group-exports.mdx | 79 + docs/lint/rules/import/imports-first.mdx | 52 + docs/lint/rules/import/max-dependencies.mdx | 53 + docs/lint/rules/import/named.mdx | 64 + docs/lint/rules/import/namespace.mdx | 73 + .../rules/import/newline-after-import.mdx | 103 ++ docs/lint/rules/import/no-absolute-path.mdx | 63 + docs/lint/rules/import/no-amd.mdx | 49 + .../import/no-anonymous-default-export.mdx | 87 ++ docs/lint/rules/import/no-commonjs.mdx | 96 ++ docs/lint/rules/import/no-cycle.mdx | 131 ++ docs/lint/rules/import/no-default-export.mdx | 42 + docs/lint/rules/import/no-deprecated.mdx | 67 + docs/lint/rules/import/no-duplicates.mdx | 82 ++ docs/lint/rules/import/no-dynamic-require.mdx | 50 + .../rules/import/no-empty-named-blocks.mdx | 40 + .../import/no-extraneous-dependencies.mdx | 136 ++ .../rules/import/no-import-module-exports.mdx | 73 + .../lint/rules/import/no-internal-modules.mdx | 94 ++ docs/lint/rules/import/no-mutable-exports.mdx | 44 + .../import/no-named-as-default-member.mdx | 52 + .../lint/rules/import/no-named-as-default.mdx | 46 + docs/lint/rules/import/no-named-default.mdx | 44 + docs/lint/rules/import/no-named-export.mdx | 50 + docs/lint/rules/import/no-namespace.mdx | 48 + docs/lint/rules/import/no-nodejs-modules.mdx | 48 + .../rules/import/no-relative-packages.mdx | 73 + .../import/no-relative-parent-imports.mdx | 92 ++ .../lint/rules/import/no-restricted-paths.mdx | 84 ++ docs/lint/rules/import/no-self-import.mdx | 52 + .../rules/import/no-unassigned-import.mdx | 66 + docs/lint/rules/import/no-unresolved.mdx | 100 ++ docs/lint/rules/import/no-unused-modules.mdx | 100 ++ .../rules/import/no-useless-path-segments.mdx | 99 ++ .../rules/import/no-webpack-loader-syntax.mdx | 30 + docs/lint/rules/import/order.mdx | 134 ++ .../rules/import/prefer-default-export.mdx | 67 + docs/lint/rules/import/unambiguous.mdx | 62 + docs/lint/rules/jest/consistent-test-it.mdx | 98 ++ docs/lint/rules/jest/expect-expect.mdx | 125 ++ docs/lint/rules/jest/max-expects.mdx | 89 ++ docs/lint/rules/jest/max-nested-describe.mdx | 83 ++ docs/lint/rules/jest/no-alias-methods.mdx | 54 + .../rules/jest/no-commented-out-tests.mdx | 53 + .../lint/rules/jest/no-conditional-expect.mdx | 81 ++ .../rules/jest/no-conditional-in-test.mdx | 78 + .../rules/jest/no-confusing-set-timeout.mdx | 70 + .../rules/jest/no-deprecated-functions.mdx | 70 + docs/lint/rules/jest/no-disabled-tests.mdx | 52 + docs/lint/rules/jest/no-done-callback.mdx | 60 + docs/lint/rules/jest/no-duplicate-hooks.mdx | 55 + docs/lint/rules/jest/no-export.mdx | 62 + docs/lint/rules/jest/no-focused-tests.mdx | 42 + docs/lint/rules/jest/no-hooks.mdx | 65 + docs/lint/rules/jest/no-identical-title.mdx | 87 ++ .../jest/no-interpolation-in-snapshots.mdx | 59 + docs/lint/rules/jest/no-jasmine-globals.mdx | 70 + docs/lint/rules/jest/no-large-snapshots.mdx | 88 ++ docs/lint/rules/jest/no-mocks-import.mdx | 50 + .../rules/jest/no-restricted-jest-methods.mdx | 59 + .../rules/jest/no-restricted-matchers.mdx | 82 ++ docs/lint/rules/jest/no-standalone-expect.mdx | 69 + docs/lint/rules/jest/no-test-prefixes.mdx | 58 + .../rules/jest/no-test-return-statement.mdx | 66 + .../no-unneeded-async-expect-function.mdx | 57 + .../rules/jest/no-untyped-mock-factory.mdx | 42 + .../jest/padding-around-after-all-blocks.mdx | 52 + .../rules/jest/padding-around-test-blocks.mdx | 51 + docs/lint/rules/jest/prefer-called-with.mdx | 46 + .../rules/jest/prefer-comparison-matcher.mdx | 56 + docs/lint/rules/jest/prefer-each.mdx | 50 + .../jest/prefer-ending-with-an-expect.mdx | 101 ++ .../rules/jest/prefer-equality-matcher.mdx | 48 + .../rules/jest/prefer-expect-assertions.mdx | 121 ++ .../rules/jest/prefer-expect-resolves.mdx | 44 + .../lint/rules/jest/prefer-hooks-in-order.mdx | 62 + docs/lint/rules/jest/prefer-hooks-on-top.mdx | 64 + .../jest/prefer-importing-jest-globals.mdx | 68 + docs/lint/rules/jest/prefer-jest-mocked.mdx | 47 + .../rules/jest/prefer-lowercase-title.mdx | 101 ++ .../jest/prefer-mock-promise-shorthand.mdx | 48 + .../jest/prefer-mock-return-shorthand.mdx | 58 + docs/lint/rules/jest/prefer-snapshot-hint.mdx | 75 + docs/lint/rules/jest/prefer-spy-on.mdx | 53 + docs/lint/rules/jest/prefer-strict-equal.mdx | 38 + docs/lint/rules/jest/prefer-to-be.mdx | 64 + docs/lint/rules/jest/prefer-to-contain.mdx | 52 + .../jest/prefer-to-have-been-called-times.mdx | 40 + .../rules/jest/prefer-to-have-been-called.mdx | 45 + .../lint/rules/jest/prefer-to-have-length.mdx | 41 + docs/lint/rules/jest/prefer-todo.mdx | 40 + docs/lint/rules/jest/require-hook.mdx | 78 + .../rules/jest/require-to-throw-message.mdx | 42 + .../rules/jest/require-top-level-describe.mdx | 78 + .../rules/jest/valid-describe-callback.mdx | 108 ++ .../rules/jest/valid-expect-in-promise.mdx | 111 ++ docs/lint/rules/jest/valid-expect.mdx | 109 ++ docs/lint/rules/jest/valid-title.mdx | 111 ++ docs/lint/rules/jsx-a11y/alt-text.mdx | 66 + .../rules/jsx-a11y/anchor-ambiguous-text.mdx | 64 + .../rules/jsx-a11y/anchor-has-content.mdx | 55 + docs/lint/rules/jsx-a11y/anchor-is-valid.mdx | 72 + .../aria-activedescendant-has-tabindex.mdx | 37 + docs/lint/rules/jsx-a11y/aria-props.mdx | 30 + docs/lint/rules/jsx-a11y/aria-proptypes.mdx | 51 + docs/lint/rules/jsx-a11y/aria-role.mdx | 59 + .../jsx-a11y/aria-unsupported-elements.mdx | 38 + .../rules/jsx-a11y/autocomplete-valid.mdx | 62 + .../jsx-a11y/click-events-have-key-events.mdx | 35 + .../jsx-a11y/control-has-associated-label.mdx | 110 ++ .../rules/jsx-a11y/heading-has-content.mdx | 48 + docs/lint/rules/jsx-a11y/html-has-lang.mdx | 54 + docs/lint/rules/jsx-a11y/iframe-has-title.mdx | 46 + .../lint/rules/jsx-a11y/img-redundant-alt.mdx | 43 + .../jsx-a11y/interactive-supports-focus.mdx | 70 + .../jsx-a11y/label-has-associated-control.mdx | 139 ++ docs/lint/rules/jsx-a11y/lang.mdx | 40 + .../lint/rules/jsx-a11y/media-has-caption.mdx | 68 + .../jsx-a11y/mouse-events-have-key-events.mdx | 55 + docs/lint/rules/jsx-a11y/no-access-key.mdx | 26 + .../jsx-a11y/no-aria-hidden-on-focusable.mdx | 44 + docs/lint/rules/jsx-a11y/no-autofocus.mdx | 52 + .../jsx-a11y/no-distracting-elements.mdx | 42 + ...ractive-element-to-noninteractive-role.mdx | 89 ++ ...no-noninteractive-element-interactions.mdx | 75 + ...nteractive-element-to-interactive-role.mdx | 85 ++ .../jsx-a11y/no-noninteractive-tabindex.mdx | 72 + .../rules/jsx-a11y/no-redundant-roles.mdx | 59 + .../no-static-element-interactions.mdx | 81 ++ .../rules/jsx-a11y/prefer-tag-over-role.mdx | 43 + .../jsx-a11y/role-has-required-aria-props.mdx | 47 + .../jsx-a11y/role-supports-aria-props.mdx | 45 + docs/lint/rules/jsx-a11y/scope.mdx | 38 + .../rules/jsx-a11y/tabindex-no-positive.mdx | 40 + .../lint/rules/nextjs/google-font-display.mdx | 40 + .../rules/nextjs/google-font-preconnect.mdx | 32 + docs/lint/rules/nextjs/inline-script-id.mdx | 61 + docs/lint/rules/nextjs/next-script-for-ga.mdx | 53 + .../nextjs/no-assign-module-variable.mdx | 38 + .../nextjs/no-async-client-component.mdx | 62 + ...re-interactive-script-outside-document.mdx | 63 + docs/lint/rules/nextjs/no-css-tags.mdx | 43 + .../nextjs/no-document-import-in-page.mdx | 60 + docs/lint/rules/nextjs/no-duplicate-head.mdx | 83 ++ docs/lint/rules/nextjs/no-head-element.mdx | 61 + .../nextjs/no-head-import-in-document.mdx | 51 + .../rules/nextjs/no-html-link-for-pages.mdx | 62 + docs/lint/rules/nextjs/no-img-element.mdx | 49 + .../lint/rules/nextjs/no-page-custom-font.mdx | 93 ++ .../nextjs/no-script-component-in-head.mdx | 48 + .../nextjs/no-styled-jsx-in-document.mdx | 54 + docs/lint/rules/nextjs/no-sync-scripts.mdx | 40 + .../nextjs/no-title-in-document-head.mdx | 66 + docs/lint/rules/nextjs/no-typos.mdx | 58 + .../rules/nextjs/no-unwanted-polyfillio.mdx | 44 + docs/lint/rules/node/callback-return.mdx | 79 + docs/lint/rules/node/exports-style.mdx | 77 + docs/lint/rules/node/global-require.mdx | 44 + docs/lint/rules/node/handle-callback-err.mdx | 72 + docs/lint/rules/node/no-exports-assign.mdx | 48 + docs/lint/rules/node/no-mixed-requires.mdx | 83 ++ docs/lint/rules/node/no-new-require.mdx | 33 + docs/lint/rules/node/no-path-concat.mdx | 39 + docs/lint/rules/node/no-process-env.mdx | 56 + docs/lint/rules/node/no-process-exit.mdx | 44 + docs/lint/rules/node/no-sync.mdx | 65 + docs/lint/rules/node/no-top-level-await.mdx | 67 + .../no-unsupported-features/es-builtins.mdx | 83 ++ .../no-unsupported-features/es-syntax.mdx | 112 ++ .../no-unsupported-features/node-builtins.mdx | 100 ++ docs/lint/rules/oxc/approx-constant.mdx | 37 + .../oxc/bad-array-method-on-arguments.mdx | 34 + docs/lint/rules/oxc/bad-bitwise-operator.mdx | 51 + .../lint/rules/oxc/bad-char-at-comparison.mdx | 39 + .../rules/oxc/bad-comparison-sequence.mdx | 36 + docs/lint/rules/oxc/bad-match-all-arg.mdx | 38 + docs/lint/rules/oxc/bad-min-max-func.mdx | 29 + .../oxc/bad-object-literal-comparison.mdx | 38 + docs/lint/rules/oxc/bad-replace-all-arg.mdx | 34 + docs/lint/rules/oxc/branches-sharing-code.mdx | 73 + docs/lint/rules/oxc/const-comparisons.mdx | 65 + docs/lint/rules/oxc/double-comparisons.mdx | 47 + docs/lint/rules/oxc/erasing-op.mdx | 32 + .../rules/oxc/misrefactored-assign-op.mdx | 36 + docs/lint/rules/oxc/missing-throw.mdx | 41 + .../lint/rules/oxc/no-accumulating-spread.mdx | 60 + docs/lint/rules/oxc/no-async-await.mdx | 56 + .../rules/oxc/no-async-endpoint-handlers.mdx | 90 ++ docs/lint/rules/oxc/no-barrel-file.mdx | 63 + docs/lint/rules/oxc/no-const-enum.mdx | 43 + docs/lint/rules/oxc/no-map-spread.mdx | 108 ++ docs/lint/rules/oxc/no-optional-chaining.mdx | 53 + .../rules/oxc/no-rest-spread-properties.mdx | 63 + .../oxc/no-this-in-exported-function.mdx | 71 + .../rules/oxc/number-arg-out-of-range.mdx | 59 + .../lint/rules/oxc/only-used-in-recursion.mdx | 65 + .../rules/oxc/uninvoked-array-callback.mdx | 50 + docs/lint/rules/prettier/prettier.mdx | 96 ++ docs/lint/rules/promise/always-return.mdx | 87 ++ docs/lint/rules/promise/avoid-new.mdx | 43 + docs/lint/rules/promise/catch-or-return.mdx | 98 ++ .../rules/promise/no-callback-in-promise.mdx | 67 + .../rules/promise/no-multiple-resolved.mdx | 72 + docs/lint/rules/promise/no-nesting.mdx | 40 + docs/lint/rules/promise/no-new-statics.mdx | 32 + .../rules/promise/no-promise-in-callback.mdx | 61 + .../rules/promise/no-return-in-finally.mdx | 57 + docs/lint/rules/promise/no-return-wrap.mdx | 64 + docs/lint/rules/promise/param-names.mdx | 58 + .../promise/prefer-await-to-callbacks.mdx | 50 + .../rules/promise/prefer-await-to-then.mdx | 81 ++ docs/lint/rules/promise/prefer-catch.mdx | 41 + docs/lint/rules/promise/spec-only.mdx | 51 + docs/lint/rules/promise/valid-params.mdx | 44 + .../rules/react-perf/jsx-no-jsx-as-prop.mdx | 96 ++ .../react-perf/jsx-no-new-array-as-prop.mdx | 109 ++ .../jsx-no-new-function-as-prop.mdx | 127 ++ .../react-perf/jsx-no-new-object-as-prop.mdx | 109 ++ docs/lint/rules/react/boolean-prop-naming.mdx | 102 ++ docs/lint/rules/react/button-has-type.mdx | 62 + docs/lint/rules/react/capitalized-calls.mdx | 55 + .../checked-requires-onchange-or-readonly.mdx | 53 + .../react/default-props-match-prop-types.mdx | 82 ++ .../rules/react/destructuring-assignment.mdx | 112 ++ docs/lint/rules/react/display-name.mdx | 90 ++ docs/lint/rules/react/error-boundaries.mdx | 56 + docs/lint/rules/react/exhaustive-deps.mdx | 97 ++ .../rules/react/forbid-component-props.mdx | 83 ++ docs/lint/rules/react/forbid-dom-props.mdx | 55 + docs/lint/rules/react/forbid-elements.mdx | 54 + .../rules/react/forbid-foreign-prop-types.mdx | 78 + docs/lint/rules/react/forbid-prop-types.mdx | 114 ++ .../lint/rules/react/forward-ref-uses-ref.mdx | 36 + .../react/function-component-definition.mdx | 99 ++ docs/lint/rules/react/globals.mdx | 90 ++ docs/lint/rules/react/hook-use-state.mdx | 71 + docs/lint/rules/react/hooks.mdx | 73 + .../rules/react/iframe-missing-sandbox.mdx | 44 + docs/lint/rules/react/immutability.mdx | 107 ++ .../lint/rules/react/incompatible-library.mdx | 57 + docs/lint/rules/react/invariant.mdx | 40 + docs/lint/rules/react/jsx-boolean-value.mdx | 80 + .../rules/react/jsx-child-element-spacing.mdx | 78 + .../react/jsx-closing-bracket-location.mdx | 100 ++ .../rules/react/jsx-closing-tag-location.mdx | 63 + .../rules/react/jsx-curly-brace-presence.mdx | 100 ++ docs/lint/rules/react/jsx-curly-newline.mdx | 84 ++ docs/lint/rules/react/jsx-curly-spacing.mdx | 85 ++ docs/lint/rules/react/jsx-equals-spacing.mdx | 45 + .../rules/react/jsx-filename-extension.mdx | 69 + .../rules/react/jsx-first-prop-new-line.mdx | 80 + docs/lint/rules/react/jsx-fragments.mdx | 87 ++ docs/lint/rules/react/jsx-handler-names.mdx | 103 ++ docs/lint/rules/react/jsx-indent-props.mdx | 84 ++ docs/lint/rules/react/jsx-indent.mdx | 102 ++ docs/lint/rules/react/jsx-key.mdx | 96 ++ docs/lint/rules/react/jsx-max-depth.mdx | 90 ++ .../rules/react/jsx-max-props-per-line.mdx | 74 + docs/lint/rules/react/jsx-newline.mdx | 100 ++ docs/lint/rules/react/jsx-no-bind.mdx | 80 + .../rules/react/jsx-no-comment-textnodes.mdx | 46 + .../jsx-no-constructed-context-values.mdx | 61 + .../rules/react/jsx-no-duplicate-props.mdx | 42 + .../lint/rules/react/jsx-no-leaked-render.mdx | 77 + docs/lint/rules/react/jsx-no-literals.mdx | 111 ++ docs/lint/rules/react/jsx-no-script-url.mdx | 57 + docs/lint/rules/react/jsx-no-target-blank.mdx | 106 ++ docs/lint/rules/react/jsx-no-undef.mdx | 60 + .../rules/react/jsx-no-useless-fragment.mdx | 89 ++ .../react/jsx-one-expression-per-line.mdx | 86 ++ docs/lint/rules/react/jsx-pascal-case.mdx | 80 + .../rules/react/jsx-props-no-multi-spaces.mdx | 38 + .../rules/react/jsx-props-no-spread-multi.mdx | 32 + .../rules/react/jsx-props-no-spreading.mdx | 81 ++ .../rules/react/jsx-sort-default-props.mdx | 78 + docs/lint/rules/react/jsx-sort-props.mdx | 112 ++ docs/lint/rules/react/jsx-tag-spacing.mdx | 95 ++ docs/lint/rules/react/jsx-wrap-multilines.mdx | 127 ++ docs/lint/rules/react/memo-dependencies.mdx | 50 + .../react/no-access-state-in-setstate.mdx | 47 + .../react/no-adjacent-inline-elements.mdx | 63 + docs/lint/rules/react/no-array-index-key.mdx | 38 + .../react/no-arrow-function-lifecycle.mdx | 47 + docs/lint/rules/react/no-children-prop.mdx | 60 + docs/lint/rules/react/no-clone-element.mdx | 38 + .../rules/react/no-danger-with-children.mdx | 46 + docs/lint/rules/react/no-danger.mdx | 46 + docs/lint/rules/react/no-deprecated.mdx | 61 + .../react/no-deriving-state-in-effects.mdx | 58 + .../rules/react/no-did-mount-set-state.mdx | 62 + .../rules/react/no-did-update-set-state.mdx | 62 + .../rules/react/no-direct-mutation-state.mdx | 62 + docs/lint/rules/react/no-find-dom-node.mdx | 46 + .../rules/react/no-invalid-html-attribute.mdx | 78 + docs/lint/rules/react/no-is-mounted.mdx | 46 + docs/lint/rules/react/no-multi-comp.mdx | 80 + docs/lint/rules/react/no-namespace.mdx | 28 + .../react/no-object-type-as-default-prop.mdx | 59 + docs/lint/rules/react/no-react-children.mdx | 54 + .../no-redundant-should-component-update.mdx | 46 + .../rules/react/no-render-return-value.mdx | 36 + docs/lint/rules/react/no-set-state.mdx | 40 + docs/lint/rules/react/no-string-refs.mdx | 62 + docs/lint/rules/react/no-this-in-sfc.mdx | 45 + docs/lint/rules/react/no-typos.mdx | 79 + .../rules/react/no-unescaped-entities.mdx | 62 + docs/lint/rules/react/no-unknown-property.mdx | 72 + docs/lint/rules/react/no-unsafe.mdx | 64 + .../react/no-unstable-nested-components.mdx | 93 ++ .../no-unused-class-component-methods.mdx | 50 + .../lint/rules/react/no-unused-prop-types.mdx | 127 ++ docs/lint/rules/react/no-unused-state.mdx | 67 + docs/lint/rules/react/prefer-es6-class.mdx | 59 + .../rules/react/prefer-read-only-props.mdx | 58 + .../rules/react/prefer-stateless-function.mdx | 65 + .../react/preserve-manual-memoization.mdx | 47 + docs/lint/rules/react/prop-types.mdx | 128 ++ docs/lint/rules/react/purity.mdx | 53 + docs/lint/rules/react/react-in-jsx-scope.mdx | 47 + docs/lint/rules/react/refs.mdx | 56 + .../rules/react/require-default-props.mdx | 111 ++ .../lint/rules/react/require-optimization.mdx | 64 + .../rules/react/require-render-return.mdx | 36 + docs/lint/rules/react/rules-of-hooks.mdx | 101 ++ docs/lint/rules/react/self-closing-comp.mdx | 67 + docs/lint/rules/react/set-state-in-effect.mdx | 119 ++ docs/lint/rules/react/set-state-in-render.mdx | 83 ++ docs/lint/rules/react/sort-comp.mdx | 117 ++ docs/lint/rules/react/sort-default-props.mdx | 78 + docs/lint/rules/react/sort-prop-types.mdx | 121 ++ .../lint/rules/react/state-in-constructor.mdx | 72 + docs/lint/rules/react/static-components.mdx | 55 + .../rules/react/static-property-placement.mdx | 111 ++ docs/lint/rules/react/style-prop-object.mdx | 60 + docs/lint/rules/react/syntax.mdx | 57 + docs/lint/rules/react/todo.mdx | 100 ++ docs/lint/rules/react/unsupported-syntax.mdx | 59 + docs/lint/rules/react/use-memo.mdx | 67 + .../react/void-dom-elements-no-children.mdx | 41 + docs/lint/rules/react/void-use-memo.mdx | 66 + .../adjacent-overload-signatures.mdx | 54 + docs/lint/rules/typescript/array-type.mdx | 76 + docs/lint/rules/typescript/await-thenable.mdx | 76 + docs/lint/rules/typescript/ban-ts-comment.mdx | 93 ++ .../rules/typescript/ban-tslint-comment.mdx | 29 + docs/lint/rules/typescript/ban-types.mdx | 52 + .../class-literal-property-style.mdx | 91 ++ .../typescript/class-methods-use-this.mdx | 93 ++ .../consistent-generic-constructors.mdx | 67 + .../consistent-indexed-object-style.mdx | 72 + .../rules/typescript/consistent-return.mdx | 82 ++ .../typescript/consistent-type-assertions.mdx | 106 ++ .../consistent-type-definitions.mdx | 64 + .../typescript/consistent-type-exports.mdx | 86 ++ .../typescript/consistent-type-imports.mdx | 93 ++ .../rules/typescript/default-param-last.mdx | 55 + docs/lint/rules/typescript/dot-notation.mdx | 97 ++ .../explicit-function-return-type.mdx | 114 ++ .../explicit-member-accessibility.mdx | 94 ++ .../explicit-module-boundary-types.mdx | 120 ++ .../rules/typescript/init-declarations.mdx | 93 ++ docs/lint/rules/typescript/max-params.mdx | 67 + .../lint/rules/typescript/member-ordering.mdx | 139 ++ .../typescript/method-signature-style.mdx | 69 + .../rules/typescript/naming-convention.mdx | 126 ++ .../rules/typescript/no-array-constructor.mdx | 37 + .../lint/rules/typescript/no-array-delete.mdx | 35 + .../rules/typescript/no-base-to-string.mdx | 75 + .../no-confusing-non-null-assertion.mdx | 37 + .../no-confusing-void-expression.mdx | 108 ++ docs/lint/rules/typescript/no-deprecated.mdx | 101 ++ .../typescript/no-dupe-class-members.mdx | 56 + .../typescript/no-duplicate-enum-values.mdx | 56 + .../no-duplicate-type-constituents.mdx | 60 + .../rules/typescript/no-dynamic-delete.mdx | 43 + .../rules/typescript/no-empty-function.mdx | 100 ++ .../rules/typescript/no-empty-interface.mdx | 62 + .../rules/typescript/no-empty-object-type.mdx | 70 + .../lint/rules/typescript/no-explicit-any.mdx | 62 + .../no-extra-non-null-assertion.mdx | 40 + .../rules/typescript/no-extraneous-class.mdx | 94 ++ .../lint/rules/typescript/no-for-in-array.mdx | 60 + .../no-generated-empty-object-type.mdx | 57 + .../lint/rules/typescript/no-implied-eval.mdx | 49 + .../no-import-type-side-effects.mdx | 44 + .../rules/typescript/no-inferrable-types.mdx | 74 + .../lint/rules/typescript/no-invalid-this.mdx | 78 + .../rules/typescript/no-invalid-void-type.mdx | 81 ++ docs/lint/rules/typescript/no-loop-func.mdx | 63 + .../rules/typescript/no-loss-of-precision.mdx | 33 + .../rules/typescript/no-magic-numbers.mdx | 133 ++ .../no-meaningless-void-operator.mdx | 62 + docs/lint/rules/typescript/no-misused-new.mdx | 53 + .../rules/typescript/no-misused-promises.mdx | 117 ++ .../rules/typescript/no-misused-spread.mdx | 104 ++ docs/lint/rules/typescript/no-mixed-enums.mdx | 53 + docs/lint/rules/typescript/no-namespace.mdx | 105 ++ ...o-non-null-asserted-nullish-coalescing.mdx | 44 + .../no-non-null-asserted-optional-chain.mdx | 44 + .../typescript/no-non-null-assertion.mdx | 43 + docs/lint/rules/typescript/no-redeclare.mdx | 113 ++ .../no-redundant-type-constituents.mdx | 73 + .../rules/typescript/no-require-imports.mdx | 67 + .../typescript/no-restricted-imports.mdx | 96 ++ .../rules/typescript/no-restricted-types.mdx | 81 ++ docs/lint/rules/typescript/no-shadow.mdx | 151 ++ docs/lint/rules/typescript/no-this-alias.mdx | 79 + docs/lint/rules/typescript/no-type-alias.mdx | 77 + ...no-unnecessary-boolean-literal-compare.mdx | 70 + .../typescript/no-unnecessary-condition.mdx | 119 ++ ...ecessary-parameter-property-assignment.mdx | 48 + .../typescript/no-unnecessary-qualifier.mdx | 65 + .../no-unnecessary-template-expression.mdx | 63 + .../no-unnecessary-type-arguments.mdx | 56 + .../no-unnecessary-type-assertion.mdx | 87 ++ .../no-unnecessary-type-constraint.mdx | 52 + .../no-unnecessary-type-conversion.mdx | 58 + .../no-unnecessary-type-parameters.mdx | 72 + .../rules/typescript/no-unsafe-argument.mdx | 58 + .../rules/typescript/no-unsafe-assignment.mdx | 54 + docs/lint/rules/typescript/no-unsafe-call.mdx | 58 + .../no-unsafe-declaration-merging.mdx | 63 + .../typescript/no-unsafe-enum-assignment.mdx | 74 + .../typescript/no-unsafe-enum-comparison.mdx | 83 ++ .../typescript/no-unsafe-function-type.mdx | 62 + .../typescript/no-unsafe-member-access.mdx | 70 + .../rules/typescript/no-unsafe-return.mdx | 67 + .../typescript/no-unsafe-type-assertion.mdx | 50 + .../typescript/no-unsafe-unary-minus.mdx | 44 + .../typescript/no-unused-expressions.mdx | 89 ++ .../no-unused-private-class-members.mdx | 61 + docs/lint/rules/typescript/no-unused-vars.mdx | 131 ++ .../rules/typescript/no-use-before-define.mdx | 136 ++ .../typescript/no-useless-constructor.mdx | 70 + .../no-useless-default-assignment.mdx | 93 ++ .../typescript/no-useless-empty-export.mdx | 50 + .../lint/rules/typescript/no-var-requires.mdx | 52 + .../typescript/no-wrapper-object-types.mdx | 55 + .../non-nullable-type-assertion-style.mdx | 46 + .../rules/typescript/only-throw-error.mdx | 98 ++ .../rules/typescript/parameter-properties.mdx | 81 ++ .../lint/rules/typescript/prefer-as-const.mdx | 43 + .../rules/typescript/prefer-destructuring.mdx | 107 ++ .../typescript/prefer-enum-initializers.mdx | 43 + docs/lint/rules/typescript/prefer-find.mdx | 48 + docs/lint/rules/typescript/prefer-for-of.mdx | 48 + .../rules/typescript/prefer-function-type.mdx | 64 + .../lint/rules/typescript/prefer-includes.mdx | 64 + .../typescript/prefer-literal-enum-member.mdx | 79 + .../typescript/prefer-namespace-keyword.mdx | 46 + .../typescript/prefer-nullish-coalescing.mdx | 113 ++ .../typescript/prefer-optional-chain.mdx | 99 ++ .../prefer-promise-reject-errors.mdx | 96 ++ .../prefer-readonly-parameter-types.mdx | 111 ++ .../lint/rules/typescript/prefer-readonly.mdx | 90 ++ .../prefer-reduce-type-parameter.mdx | 68 + .../rules/typescript/prefer-regexp-exec.mdx | 42 + .../typescript/prefer-return-this-type.mdx | 54 + .../prefer-string-starts-ends-with.mdx | 72 + .../typescript/prefer-ts-expect-error.mdx | 32 + .../typescript/promise-function-async.mdx | 85 ++ .../related-getter-setter-pairs.mdx | 44 + .../typescript/require-array-sort-compare.mdx | 48 + docs/lint/rules/typescript/require-await.mdx | 45 + .../typescript/restrict-plus-operands.mdx | 68 + .../restrict-template-expressions.mdx | 88 ++ docs/lint/rules/typescript/return-await.mdx | 109 ++ .../typescript/sort-type-constituents.mdx | 62 + .../typescript/strict-boolean-expressions.mdx | 123 ++ .../rules/typescript/strict-void-return.mdx | 83 ++ .../switch-exhaustiveness-check.mdx | 116 ++ .../typescript/triple-slash-reference.mdx | 55 + docs/lint/rules/typescript/typedef.mdx | 105 ++ docs/lint/rules/typescript/unbound-method.mdx | 74 + .../rules/typescript/unified-signatures.mdx | 73 + ...use-unknown-in-catch-callback-variable.mdx | 46 + docs/lint/rules/unicorn/catch-error-name.mdx | 81 ++ docs/lint/rules/unicorn/consistent-assert.mdx | 54 + .../rules/unicorn/consistent-date-clone.mdx | 36 + .../unicorn/consistent-empty-array-spread.mdx | 34 + .../consistent-existence-index-check.mdx | 58 + .../unicorn/consistent-function-scoping.mdx | 106 ++ .../consistent-template-literal-escape.mdx | 35 + .../rules/unicorn/custom-error-definition.mdx | 67 + .../lint/rules/unicorn/empty-brace-spaces.mdx | 42 + docs/lint/rules/unicorn/error-message.mdx | 56 + docs/lint/rules/unicorn/escape-case.mdx | 40 + .../rules/unicorn/explicit-length-check.mdx | 86 ++ .../rules/unicorn/explicit-timer-delay.mdx | 53 + docs/lint/rules/unicorn/filename-case.mdx | 83 ++ docs/lint/rules/unicorn/import-style.mdx | 107 ++ docs/lint/rules/unicorn/max-nested-calls.mdx | 61 + docs/lint/rules/unicorn/new-for-builtins.mdx | 64 + .../unicorn/no-abusive-eslint-disable.mdx | 48 + .../rules/unicorn/no-accessor-recursion.mdx | 58 + .../unicorn/no-anonymous-default-export.mdx | 62 + .../unicorn/no-array-callback-reference.mdx | 61 + .../no-array-fill-with-reference-type.mdx | 44 + docs/lint/rules/unicorn/no-array-for-each.mdx | 60 + .../unicorn/no-array-method-this-argument.mdx | 57 + docs/lint/rules/unicorn/no-array-reduce.mdx | 50 + docs/lint/rules/unicorn/no-array-reverse.mdx | 58 + docs/lint/rules/unicorn/no-array-sort.mdx | 73 + .../unicorn/no-await-expression-member.mdx | 51 + .../unicorn/no-await-in-promise-methods.mdx | 34 + .../rules/unicorn/no-confusing-array-with.mdx | 45 + docs/lint/rules/unicorn/no-console-spaces.mdx | 41 + .../lint/rules/unicorn/no-document-cookie.mdx | 36 + docs/lint/rules/unicorn/no-empty-file.mdx | 50 + docs/lint/rules/unicorn/no-hex-escape.mdx | 41 + .../rules/unicorn/no-immediate-mutation.mdx | 72 + .../rules/unicorn/no-instanceof-array.mdx | 30 + .../rules/unicorn/no-instanceof-builtins.mdx | 66 + .../unicorn/no-invalid-fetch-options.mdx | 52 + .../no-invalid-remove-event-listener.mdx | 34 + .../rules/unicorn/no-length-as-slice-end.mdx | 28 + docs/lint/rules/unicorn/no-lonely-if.mdx | 53 + .../unicorn/no-magic-array-flat-depth.mdx | 32 + .../rules/unicorn/no-negated-condition.mdx | 56 + .../unicorn/no-negation-in-equality-check.mdx | 44 + docs/lint/rules/unicorn/no-nested-ternary.mdx | 44 + docs/lint/rules/unicorn/no-new-array.mdx | 37 + docs/lint/rules/unicorn/no-new-buffer.mdx | 28 + .../no-object-as-default-parameter.mdx | 34 + docs/lint/rules/unicorn/no-process-exit.mdx | 67 + .../no-single-promise-in-promise-methods.mdx | 48 + .../rules/unicorn/no-static-only-class.mdx | 65 + docs/lint/rules/unicorn/no-thenable.mdx | 60 + .../lint/rules/unicorn/no-this-assignment.mdx | 51 + .../rules/unicorn/no-typeof-undefined.mdx | 51 + .../no-unnecessary-array-flat-depth.mdx | 29 + .../no-unnecessary-array-splice-count.mdx | 36 + .../rules/unicorn/no-unnecessary-await.mdx | 38 + .../unicorn/no-unnecessary-slice-end.mdx | 34 + .../no-unreadable-array-destructuring.mdx | 37 + .../lint/rules/unicorn/no-unreadable-iife.mdx | 36 + .../no-useless-collection-argument.mdx | 40 + .../no-useless-error-capture-stack-trace.mdx | 58 + .../unicorn/no-useless-fallback-in-spread.mdx | 40 + .../unicorn/no-useless-iterator-to-array.mdx | 70 + .../rules/unicorn/no-useless-length-check.mdx | 44 + .../no-useless-promise-resolve-reject.mdx | 69 + docs/lint/rules/unicorn/no-useless-spread.mdx | 70 + .../rules/unicorn/no-useless-switch-case.mdx | 62 + .../rules/unicorn/no-useless-undefined.mdx | 83 ++ docs/lint/rules/unicorn/no-zero-fractions.mdx | 32 + .../rules/unicorn/number-literal-case.mdx | 46 + .../unicorn/numeric-separators-style.mdx | 105 ++ .../unicorn/prefer-add-event-listener.mdx | 47 + docs/lint/rules/unicorn/prefer-array-find.mdx | 51 + .../rules/unicorn/prefer-array-flat-map.mdx | 58 + docs/lint/rules/unicorn/prefer-array-flat.mdx | 38 + .../rules/unicorn/prefer-array-index-of.mdx | 35 + docs/lint/rules/unicorn/prefer-array-some.mdx | 51 + docs/lint/rules/unicorn/prefer-at.mdx | 69 + .../rules/unicorn/prefer-bigint-literals.mdx | 39 + .../unicorn/prefer-blob-reading-methods.mdx | 51 + .../rules/unicorn/prefer-class-fields.mdx | 79 + .../rules/unicorn/prefer-classlist-toggle.mdx | 48 + docs/lint/rules/unicorn/prefer-code-point.mdx | 32 + docs/lint/rules/unicorn/prefer-date-now.mdx | 34 + .../unicorn/prefer-default-parameters.mdx | 60 + .../rules/unicorn/prefer-dom-node-append.mdx | 35 + .../rules/unicorn/prefer-dom-node-dataset.mdx | 49 + .../rules/unicorn/prefer-dom-node-remove.mdx | 30 + .../unicorn/prefer-dom-node-text-content.mdx | 36 + .../rules/unicorn/prefer-event-target.mdx | 46 + .../lint/rules/unicorn/prefer-export-from.mdx | 77 + .../lint/rules/unicorn/prefer-global-this.mdx | 53 + .../unicorn/prefer-import-meta-properties.mdx | 53 + docs/lint/rules/unicorn/prefer-includes.mdx | 47 + .../unicorn/prefer-keyboard-event-key.mdx | 52 + .../prefer-logical-operator-over-ternary.mdx | 36 + .../rules/unicorn/prefer-math-min-max.mdx | 34 + docs/lint/rules/unicorn/prefer-math-trunc.mdx | 42 + .../rules/unicorn/prefer-modern-dom-apis.mdx | 53 + .../rules/unicorn/prefer-modern-math-apis.mdx | 30 + docs/lint/rules/unicorn/prefer-module.mdx | 56 + .../prefer-native-coercion-functions.mdx | 46 + .../rules/unicorn/prefer-negative-index.mdx | 44 + .../rules/unicorn/prefer-node-protocol.mdx | 45 + .../rules/unicorn/prefer-number-coercion.mdx | 37 + .../unicorn/prefer-number-properties.mdx | 69 + .../unicorn/prefer-object-from-entries.mdx | 64 + .../unicorn/prefer-optional-catch-binding.mdx | 50 + .../unicorn/prefer-prototype-methods.mdx | 41 + .../rules/unicorn/prefer-query-selector.mdx | 36 + .../rules/unicorn/prefer-reflect-apply.mdx | 47 + .../lint/rules/unicorn/prefer-regexp-test.mdx | 56 + .../unicorn/prefer-response-static-json.mdx | 44 + docs/lint/rules/unicorn/prefer-set-has.mdx | 56 + docs/lint/rules/unicorn/prefer-set-size.mdx | 44 + .../lint/rules/unicorn/prefer-single-call.mdx | 71 + docs/lint/rules/unicorn/prefer-spread.mdx | 49 + docs/lint/rules/unicorn/prefer-string-raw.mdx | 33 + .../unicorn/prefer-string-replace-all.mdx | 32 + .../rules/unicorn/prefer-string-slice.mdx | 30 + .../prefer-string-starts-ends-with.mdx | 33 + .../unicorn/prefer-string-trim-start-end.mdx | 28 + .../rules/unicorn/prefer-structured-clone.mdx | 49 + docs/lint/rules/unicorn/prefer-ternary.mdx | 79 + .../rules/unicorn/prefer-top-level-await.mdx | 58 + docs/lint/rules/unicorn/prefer-type-error.mdx | 44 + .../lint/rules/unicorn/relative-url-style.mdx | 52 + .../unicorn/require-array-join-separator.mdx | 31 + .../unicorn/require-module-attributes.mdx | 34 + .../unicorn/require-module-specifiers.mdx | 34 + ...equire-number-to-fixed-digits-argument.mdx | 29 + .../require-post-message-target-origin.mdx | 43 + .../lint/rules/unicorn/switch-case-braces.mdx | 76 + .../unicorn/switch-case-break-position.mdx | 40 + .../unicorn/text-encoding-identifier-case.mdx | 66 + docs/lint/rules/unicorn/throw-new-error.mdx | 34 + .../lint/rules/vitest/consistent-each-for.mdx | 80 + .../rules/vitest/consistent-test-filename.mdx | 76 + docs/lint/rules/vitest/consistent-test-it.mdx | 98 ++ .../rules/vitest/consistent-vitest-vi.mdx | 62 + docs/lint/rules/vitest/expect-expect.mdx | 129 ++ .../lint/rules/vitest/hoisted-apis-on-top.mdx | 57 + docs/lint/rules/vitest/max-expects.mdx | 89 ++ .../lint/rules/vitest/max-nested-describe.mdx | 83 ++ docs/lint/rules/vitest/no-alias-methods.mdx | 54 + .../rules/vitest/no-commented-out-tests.mdx | 53 + .../rules/vitest/no-conditional-expect.mdx | 81 ++ .../rules/vitest/no-conditional-in-test.mdx | 95 ++ .../rules/vitest/no-conditional-tests.mdx | 68 + docs/lint/rules/vitest/no-disabled-tests.mdx | 56 + docs/lint/rules/vitest/no-duplicate-hooks.mdx | 55 + docs/lint/rules/vitest/no-focused-tests.mdx | 44 + docs/lint/rules/vitest/no-hooks.mdx | 65 + docs/lint/rules/vitest/no-identical-title.mdx | 87 ++ .../lint/rules/vitest/no-import-node-test.mdx | 48 + .../vitest/no-importing-vitest-globals.mdx | 65 + .../vitest/no-interpolation-in-snapshots.mdx | 59 + docs/lint/rules/vitest/no-large-snapshots.mdx | 88 ++ docs/lint/rules/vitest/no-mocks-import.mdx | 50 + .../rules/vitest/no-restricted-matchers.mdx | 82 ++ .../rules/vitest/no-restricted-vi-methods.mdx | 58 + .../rules/vitest/no-standalone-expect.mdx | 69 + docs/lint/rules/vitest/no-test-prefixes.mdx | 58 + .../rules/vitest/no-test-return-statement.mdx | 66 + .../no-unneeded-async-expect-function.mdx | 57 + .../padding-around-after-all-blocks.mdx | 52 + .../vitest/padding-around-test-blocks.mdx | 51 + .../prefer-called-exactly-once-with.mdx | 59 + docs/lint/rules/vitest/prefer-called-once.mdx | 45 + .../lint/rules/vitest/prefer-called-times.mdx | 42 + docs/lint/rules/vitest/prefer-called-with.mdx | 46 + .../vitest/prefer-comparison-matcher.mdx | 56 + .../vitest/prefer-describe-function-title.mdx | 53 + docs/lint/rules/vitest/prefer-each.mdx | 50 + .../rules/vitest/prefer-equality-matcher.mdx | 48 + .../rules/vitest/prefer-expect-assertions.mdx | 121 ++ .../rules/vitest/prefer-expect-resolves.mdx | 44 + .../rules/vitest/prefer-expect-type-of.mdx | 38 + .../rules/vitest/prefer-hooks-in-order.mdx | 62 + .../lint/rules/vitest/prefer-hooks-on-top.mdx | 64 + .../rules/vitest/prefer-import-in-mock.mdx | 50 + .../prefer-importing-vitest-globals.mdx | 52 + .../rules/vitest/prefer-lowercase-title.mdx | 113 ++ .../vitest/prefer-mock-promise-shorthand.mdx | 48 + .../vitest/prefer-mock-return-shorthand.mdx | 58 + .../rules/vitest/prefer-snapshot-hint.mdx | 75 + docs/lint/rules/vitest/prefer-spy-on.mdx | 53 + .../vitest/prefer-strict-boolean-matchers.mdx | 40 + .../lint/rules/vitest/prefer-strict-equal.mdx | 38 + docs/lint/rules/vitest/prefer-to-be-falsy.mdx | 44 + .../lint/rules/vitest/prefer-to-be-object.mdx | 43 + .../lint/rules/vitest/prefer-to-be-truthy.mdx | 44 + docs/lint/rules/vitest/prefer-to-be.mdx | 64 + docs/lint/rules/vitest/prefer-to-contain.mdx | 52 + .../prefer-to-have-been-called-times.mdx | 40 + .../rules/vitest/prefer-to-have-length.mdx | 41 + docs/lint/rules/vitest/prefer-todo.mdx | 40 + .../vitest/require-awaited-expect-poll.mdx | 40 + docs/lint/rules/vitest/require-hook.mdx | 78 + ...-test-context-for-concurrent-snapshots.mdx | 48 + .../vitest/require-mock-type-parameters.mdx | 56 + .../rules/vitest/require-test-timeout.mdx | 78 + .../rules/vitest/require-to-throw-message.mdx | 42 + .../vitest/require-top-level-describe.mdx | 78 + .../rules/vitest/valid-describe-callback.mdx | 116 ++ .../rules/vitest/valid-expect-in-promise.mdx | 110 ++ docs/lint/rules/vitest/valid-expect.mdx | 121 ++ docs/lint/rules/vitest/valid-title.mdx | 111 ++ docs/lint/rules/vitest/warn-todo.mdx | 36 + docs/runtime/bunfig.mdx | 272 ++-- docs/runtime/format.mdx | 482 ++----- docs/runtime/lint.mdx | 1285 ++--------------- docs/snippets/cli/format.mdx | 101 ++ docs/snippets/cli/lint.mdx | 98 ++ docs/snippets/guides.jsx | 11 + scripts/build/codegen.ts | 25 +- scripts/glob-sources.ts | 4 + src/bun_core/fmt.rs | 35 + src/codegen/generate-lint-docs.ts | 69 + src/format/CLAUDE.md | 14 +- src/format/js/sort_imports/settings.rs | 2 +- src/format/options.rs | 2 + src/format/syntax_error.rs | 58 +- src/glob/filtered.rs | 257 ++++ src/glob/lib.rs | 1 + src/glob/matcher.rs | 441 +++++- src/glob/pattern.rs | 68 +- src/glob/segments.rs | 4 + src/lint/CLAUDE.md | 95 ++ src/lint/context.rs | 28 +- src/lint/driver/args.rs | 43 + src/lint/driver/bunfig.rs | 350 ++++- src/lint/driver/cli.rs | 295 ++-- src/lint/driver/configs.rs | 217 ++- src/lint/driver/discover.rs | 16 +- src/lint/driver/doctor.rs | 703 +++++++++ src/lint/driver/evaluate-start.js | 6 +- src/lint/driver/evaluate.rs | 22 +- src/lint/driver/fmt/cli.rs | 308 +++- src/lint/driver/fmt/config.rs | 105 +- src/lint/driver/fmt/doctor.rs | 278 ++++ src/lint/driver/fmt/files.rs | 19 +- src/lint/driver/fmt/mod.rs | 227 ++- src/lint/driver/fmt/prettier.rs | 4 +- src/lint/driver/fmt/tailwind.js | 2 +- src/lint/driver/fmt/tailwind.rs | 6 +- src/lint/driver/format/excerpt.rs | 39 +- src/lint/driver/format/mod.rs | 30 +- src/lint/driver/format/stylish.rs | 33 +- src/lint/driver/format/summary.rs | 19 +- src/lint/driver/git.rs | 337 +++++ src/lint/driver/init.rs | 126 ++ src/lint/driver/lib.rs | 4 + src/lint/driver/lint.rs | 20 +- src/lint/driver/native.rs | 121 ++ src/lint/driver/results.rs | 3 + src/lint/driver/run.rs | 293 ++-- src/lint/driver/typed.rs | 22 +- .../eslint/rules/no_async_promise_executor.rs | 5 +- src/lint/eslint/rules/no_case_declarations.rs | 6 +- src/lint/eslint/rules/no_class_assign.rs | 6 +- src/lint/eslint/rules/no_compare_neg_zero.rs | 8 +- src/lint/eslint/rules/no_cond_assign.rs | 17 +- src/lint/eslint/rules/no_const_assign.rs | 11 +- .../rules/no_constant_binary_expression.rs | 16 +- .../eslint/rules/no_dupe_class_members.rs | 3 +- src/lint/eslint/rules/no_dupe_keys.rs | 4 +- src/lint/eslint/rules/no_duplicate_case.rs | 7 +- src/lint/eslint/rules/no_empty.rs | 8 +- .../eslint/rules/no_empty_character_class.rs | 4 +- src/lint/eslint/rules/no_empty_pattern.rs | 15 +- src/lint/eslint/rules/no_fallthrough.rs | 7 +- src/lint/eslint/rules/no_func_assign.rs | 7 +- src/lint/eslint/rules/no_loss_of_precision.rs | 10 +- src/lint/eslint/rules/no_octal.rs | 10 +- .../eslint/rules/no_prototype_builtins.rs | 9 +- src/lint/eslint/rules/no_redeclare.rs | 10 +- src/lint/eslint/rules/no_sparse_arrays.rs | 5 +- .../eslint/rules/no_unexpected_multiline.rs | 4 +- src/lint/eslint/rules/no_unreachable.rs | 40 +- src/lint/eslint/rules/no_unsafe_finally.rs | 6 +- src/lint/eslint/rules/no_unsafe_negation.rs | 7 +- .../eslint/rules/no_useless_assignment.rs | 39 +- src/lint/eslint/rules/no_useless_catch.rs | 8 +- src/lint/eslint/rules/no_useless_escape.rs | 11 +- .../eslint/rules/preserve_caught_error.rs | 8 +- src/lint/eslint/rules/valid_typeof.rs | 14 +- src/lint/formats.rs | 24 +- src/lint/js_plugin/host.rs | 11 +- src/lint/json.rs | 22 +- src/lint/linter/config/eslintrc.rs | 3 +- src/lint/linter/config/flat.rs | 118 +- src/lint/linter/config/mod.rs | 61 +- src/lint/linter/config/rc.rs | 10 +- src/lint/linter/disable.rs | 5 +- src/lint/linter/fixer.rs | 25 +- src/lint/linter/message.rs | 8 + src/lint/linter/mod.rs | 11 +- src/lint/linter/registry.rs | 87 ++ src/lint/linter/resolved.rs | 45 +- src/lint/paths.rs | 17 + src/lint/plugins/import_minimatch.rs | 115 +- src/lint/plugins/prettier.rs | 2 +- .../rules/bun_consistent_directive_prefix.rs | 2 +- .../rules/bun_no_array_just_to_count.rs | 2 +- .../plugins/rules/bun_no_cast_to_brand.rs | 4 +- .../rules/bun_no_console_window_flash.rs | 2 +- .../rules/bun_no_cwd_dependent_path.rs | 2 +- .../rules/bun_no_eager_dynamic_import.rs | 2 +- .../rules/bun_no_eager_native_addon.rs | 2 +- .../rules/bun_no_env_at_module_scope.rs | 4 +- .../plugins/rules/bun_no_literal_temp_dir.rs | 2 +- .../rules/bun_no_negative_repeat_count.rs | 2 +- .../rules/bun_no_redundant_catch_type.rs | 2 +- .../plugins/rules/bun_no_restricted_text.rs | 2 +- .../rules/bun_no_side_effects_on_import.rs | 4 +- .../rules/bun_no_unbounded_requests.rs | 4 +- .../rules/bun_no_unhandled_emitter_errors.rs | 2 +- .../rules/bun_no_unportable_commands.rs | 8 +- ...bun_no_unseeded_random_in_property_test.rs | 4 +- .../rules/bun_no_useless_spread_fallback.rs | 2 +- .../plugins/rules/bun_prefer_builtin_sleep.rs | 2 +- .../plugins/rules/bun_prefer_lazy_schema.rs | 4 +- .../plugins/rules/bun_prefer_local_module.rs | 2 +- .../rules/bun_prefer_timeout_signal.rs | 2 +- .../rules/bun_prefer_url_for_sibling_files.rs | 2 +- .../rules/bun_react_no_latest_value_ref.rs | 4 +- .../bun_react_no_local_render_helpers.rs | 2 +- .../rules/bun_react_no_namespace_hooks.rs | 2 +- .../bun_react_prefer_updater_function.rs | 2 +- .../rules/import_no_import_module_exports.rs | 6 +- .../rules/import_no_internal_modules.rs | 6 +- .../rules/import_no_restricted_paths.rs | 16 +- .../rules/react_forbid_component_props.rs | 4 +- .../react/rules/react_jsx_handler_names.rs | 4 +- src/lint/react/rules/react_jsx_pascal_case.rs | 4 +- src/lint/react/rules/react_no_danger.rs | 4 +- .../react_no_unstable_nested_components.rs | 4 +- src/lint/rule.rs | 28 +- .../typescript/rules/no_dupe_class_members.rs | 3 +- src/lint/typescript/rules/no_redeclare.rs | 2 +- src/runtime/cli/format_command.rs | 5 +- src/runtime/cli/init/README.default.md | 2 + src/runtime/cli/init/README2.default.md | 2 + src/runtime/cli/init/react-app/bunfig.toml | 9 +- src/runtime/cli/init/react-shadcn/bunfig.toml | 7 + .../cli/init/react-tailwind/bunfig.toml | 9 +- src/runtime/cli/lint_command.rs | 92 +- src/runtime/cli/mod.rs | 84 +- src/runtime/cli/run_command.rs | 87 +- src/runtime/cli/script_or_command.rs | 43 +- src/runtime/cli/update_interactive_command.rs | 35 +- test/cli/format/doctor.test.ts | 228 +++ test/cli/format/format.test.ts | 210 +-- test/cli/format/oracle/fuzz/near.ts | 2 +- test/cli/format/oxfmt-cli/cases.json | 40 - test/cli/format/oxfmt-cli/oxfmt-cli.test.ts | 8 +- test/cli/format/tailwind/tailwind.test.ts | 29 +- test/cli/init/init.test.ts | 30 + test/cli/lint/bun-rules.test.ts | 10 +- test/cli/lint/config-files.test.ts | 156 +- test/cli/lint/doctor.test.ts | 442 ++++++ test/cli/lint/git.test.ts | 411 ++++++ test/cli/lint/help.test.ts | 125 ++ test/cli/lint/hints.test.ts | 220 +++ test/cli/lint/init.test.ts | 325 +++++ test/cli/lint/js-parsers.test.ts | 6 +- test/cli/lint/js-plugins.test.ts | 26 +- test/cli/lint/lint.test.ts | 80 +- .../cli/lint/oracle/driver/oxfmt-fixtures.mjs | 5 +- test/cli/lint/output.test.ts | 342 +++++ test/cli/lint/prettier-rule.test.ts | 28 +- 1178 files changed, 74392 insertions(+), 3347 deletions(-) delete mode 100644 docs/format/oxfmt.mdx delete mode 100644 docs/format/prettier.mdx create mode 100644 docs/guides/format/migrate-from-oxfmt.mdx create mode 100644 docs/guides/format/migrate-from-prettier.mdx create mode 100644 docs/guides/lint/migrate-from-eslint.mdx create mode 100644 docs/guides/lint/migrate-from-oxlint.mdx create mode 100644 docs/lint/plugins.mdx create mode 100644 docs/lint/rules/bun-test/consistent-test-filename.mdx create mode 100644 docs/lint/rules/bun-test/consistent-test-it.mdx create mode 100644 docs/lint/rules/bun-test/expect-expect.mdx create mode 100644 docs/lint/rules/bun-test/max-expects.mdx create mode 100644 docs/lint/rules/bun-test/max-nested-describe.mdx create mode 100644 docs/lint/rules/bun-test/no-alias-methods.mdx create mode 100644 docs/lint/rules/bun-test/no-commented-out-tests.mdx create mode 100644 docs/lint/rules/bun-test/no-conditional-expect.mdx create mode 100644 docs/lint/rules/bun-test/no-conditional-in-test.mdx create mode 100644 docs/lint/rules/bun-test/no-conditional-tests.mdx create mode 100644 docs/lint/rules/bun-test/no-disabled-tests.mdx create mode 100644 docs/lint/rules/bun-test/no-done-callback.mdx create mode 100644 docs/lint/rules/bun-test/no-duplicate-hooks.mdx create mode 100644 docs/lint/rules/bun-test/no-export.mdx create mode 100644 docs/lint/rules/bun-test/no-hooks.mdx create mode 100644 docs/lint/rules/bun-test/no-identical-title.mdx create mode 100644 docs/lint/rules/bun-test/no-interpolation-in-snapshots.mdx create mode 100644 docs/lint/rules/bun-test/no-large-snapshots.mdx create mode 100644 docs/lint/rules/bun-test/no-restricted-jest-methods.mdx create mode 100644 docs/lint/rules/bun-test/no-restricted-matchers.mdx create mode 100644 docs/lint/rules/bun-test/no-standalone-expect.mdx create mode 100644 docs/lint/rules/bun-test/no-test-prefixes.mdx create mode 100644 docs/lint/rules/bun-test/no-test-return-statement.mdx create mode 100644 docs/lint/rules/bun-test/no-unneeded-async-expect-function.mdx create mode 100644 docs/lint/rules/bun-test/padding-around-after-all-blocks.mdx create mode 100644 docs/lint/rules/bun-test/padding-around-test-blocks.mdx create mode 100644 docs/lint/rules/bun-test/prefer-called-once.mdx create mode 100644 docs/lint/rules/bun-test/prefer-called-times.mdx create mode 100644 docs/lint/rules/bun-test/prefer-called-with.mdx create mode 100644 docs/lint/rules/bun-test/prefer-comparison-matcher.mdx create mode 100644 docs/lint/rules/bun-test/prefer-describe-function-title.mdx create mode 100644 docs/lint/rules/bun-test/prefer-each.mdx create mode 100644 docs/lint/rules/bun-test/prefer-ending-with-an-expect.mdx create mode 100644 docs/lint/rules/bun-test/prefer-equality-matcher.mdx create mode 100644 docs/lint/rules/bun-test/prefer-expect-assertions.mdx create mode 100644 docs/lint/rules/bun-test/prefer-expect-resolves.mdx create mode 100644 docs/lint/rules/bun-test/prefer-expect-type-of.mdx create mode 100644 docs/lint/rules/bun-test/prefer-hooks-in-order.mdx create mode 100644 docs/lint/rules/bun-test/prefer-hooks-on-top.mdx create mode 100644 docs/lint/rules/bun-test/prefer-lowercase-title.mdx create mode 100644 docs/lint/rules/bun-test/prefer-mock-promise-shorthand.mdx create mode 100644 docs/lint/rules/bun-test/prefer-mock-return-shorthand.mdx create mode 100644 docs/lint/rules/bun-test/prefer-snapshot-hint.mdx create mode 100644 docs/lint/rules/bun-test/prefer-spy-on.mdx create mode 100644 docs/lint/rules/bun-test/prefer-strict-boolean-matchers.mdx create mode 100644 docs/lint/rules/bun-test/prefer-strict-equal.mdx create mode 100644 docs/lint/rules/bun-test/prefer-to-be-falsy.mdx create mode 100644 docs/lint/rules/bun-test/prefer-to-be-truthy.mdx create mode 100644 docs/lint/rules/bun-test/prefer-to-be.mdx create mode 100644 docs/lint/rules/bun-test/prefer-to-contain.mdx create mode 100644 docs/lint/rules/bun-test/prefer-to-have-been-called-times.mdx create mode 100644 docs/lint/rules/bun-test/prefer-to-have-been-called.mdx create mode 100644 docs/lint/rules/bun-test/prefer-to-have-length.mdx create mode 100644 docs/lint/rules/bun-test/prefer-todo.mdx create mode 100644 docs/lint/rules/bun-test/require-hook.mdx create mode 100644 docs/lint/rules/bun-test/require-mock-type-parameters.mdx create mode 100644 docs/lint/rules/bun-test/require-test-timeout.mdx create mode 100644 docs/lint/rules/bun-test/require-to-throw-message.mdx create mode 100644 docs/lint/rules/bun-test/require-top-level-describe.mdx create mode 100644 docs/lint/rules/bun-test/valid-describe-callback.mdx create mode 100644 docs/lint/rules/bun-test/valid-expect-in-promise.mdx create mode 100644 docs/lint/rules/bun-test/valid-expect.mdx create mode 100644 docs/lint/rules/bun-test/valid-title.mdx create mode 100644 docs/lint/rules/bun-test/warn-todo.mdx create mode 100644 docs/lint/rules/bun/consistent-directive-prefix.mdx create mode 100644 docs/lint/rules/bun/format.mdx create mode 100644 docs/lint/rules/bun/jsx-no-forbidden-nesting.mdx create mode 100644 docs/lint/rules/bun/no-array-just-to-count.mdx create mode 100644 docs/lint/rules/bun/no-cast-to-brand.mdx create mode 100644 docs/lint/rules/bun/no-console-window-flash.mdx create mode 100644 docs/lint/rules/bun/no-cwd-dependent-path.mdx create mode 100644 docs/lint/rules/bun/no-eager-dynamic-import.mdx create mode 100644 docs/lint/rules/bun/no-eager-native-addon.mdx create mode 100644 docs/lint/rules/bun/no-env-at-module-scope.mdx create mode 100644 docs/lint/rules/bun/no-literal-temp-dir.mdx create mode 100644 docs/lint/rules/bun/no-negative-repeat-count.mdx create mode 100644 docs/lint/rules/bun/no-redundant-catch-type.mdx create mode 100644 docs/lint/rules/bun/no-restricted-text.mdx create mode 100644 docs/lint/rules/bun/no-side-effects-on-import.mdx create mode 100644 docs/lint/rules/bun/no-unbounded-requests.mdx create mode 100644 docs/lint/rules/bun/no-unhandled-emitter-errors.mdx create mode 100644 docs/lint/rules/bun/no-unportable-commands.mdx create mode 100644 docs/lint/rules/bun/no-unseeded-random-in-property-test.mdx create mode 100644 docs/lint/rules/bun/no-useless-spread-fallback.mdx create mode 100644 docs/lint/rules/bun/prefer-builtin-sleep.mdx create mode 100644 docs/lint/rules/bun/prefer-lazy-schema.mdx create mode 100644 docs/lint/rules/bun/prefer-local-module.mdx create mode 100644 docs/lint/rules/bun/prefer-timeout-signal.mdx create mode 100644 docs/lint/rules/bun/prefer-url-for-sibling-files.mdx create mode 100644 docs/lint/rules/bun/react-no-latest-value-ref.mdx create mode 100644 docs/lint/rules/bun/react-no-local-render-helpers.mdx create mode 100644 docs/lint/rules/bun/react-no-namespace-hooks.mdx create mode 100644 docs/lint/rules/bun/react-prefer-updater-function.mdx create mode 100644 docs/lint/rules/eslint/accessor-pairs.mdx create mode 100644 docs/lint/rules/eslint/array-callback-return.mdx create mode 100644 docs/lint/rules/eslint/arrow-body-style.mdx create mode 100644 docs/lint/rules/eslint/block-scoped-var.mdx create mode 100644 docs/lint/rules/eslint/callback-return.mdx create mode 100644 docs/lint/rules/eslint/camelcase.mdx create mode 100644 docs/lint/rules/eslint/capitalized-comments.mdx create mode 100644 docs/lint/rules/eslint/class-methods-use-this.mdx create mode 100644 docs/lint/rules/eslint/complexity.mdx create mode 100644 docs/lint/rules/eslint/consistent-return.mdx create mode 100644 docs/lint/rules/eslint/consistent-this.mdx create mode 100644 docs/lint/rules/eslint/constructor-super.mdx create mode 100644 docs/lint/rules/eslint/curly.mdx create mode 100644 docs/lint/rules/eslint/default-case-last.mdx create mode 100644 docs/lint/rules/eslint/default-case.mdx create mode 100644 docs/lint/rules/eslint/default-param-last.mdx create mode 100644 docs/lint/rules/eslint/dot-notation.mdx create mode 100644 docs/lint/rules/eslint/for-direction.mdx create mode 100644 docs/lint/rules/eslint/func-name-matching.mdx create mode 100644 docs/lint/rules/eslint/func-names.mdx create mode 100644 docs/lint/rules/eslint/func-style.mdx create mode 100644 docs/lint/rules/eslint/getter-return.mdx create mode 100644 docs/lint/rules/eslint/global-require.mdx create mode 100644 docs/lint/rules/eslint/grouped-accessor-pairs.mdx create mode 100644 docs/lint/rules/eslint/guard-for-in.mdx create mode 100644 docs/lint/rules/eslint/handle-callback-err.mdx create mode 100644 docs/lint/rules/eslint/id-blacklist.mdx create mode 100644 docs/lint/rules/eslint/id-denylist.mdx create mode 100644 docs/lint/rules/eslint/id-length.mdx create mode 100644 docs/lint/rules/eslint/id-match.mdx create mode 100644 docs/lint/rules/eslint/init-declarations.mdx create mode 100644 docs/lint/rules/eslint/logical-assignment-operators.mdx create mode 100644 docs/lint/rules/eslint/max-classes-per-file.mdx create mode 100644 docs/lint/rules/eslint/max-depth.mdx create mode 100644 docs/lint/rules/eslint/max-lines-per-function.mdx create mode 100644 docs/lint/rules/eslint/max-lines.mdx create mode 100644 docs/lint/rules/eslint/max-nested-callbacks.mdx create mode 100644 docs/lint/rules/eslint/max-params.mdx create mode 100644 docs/lint/rules/eslint/max-statements.mdx create mode 100644 docs/lint/rules/eslint/new-cap.mdx create mode 100644 docs/lint/rules/eslint/no-alert.mdx create mode 100644 docs/lint/rules/eslint/no-array-constructor.mdx create mode 100644 docs/lint/rules/eslint/no-async-promise-executor.mdx create mode 100644 docs/lint/rules/eslint/no-await-in-loop.mdx create mode 100644 docs/lint/rules/eslint/no-bitwise.mdx create mode 100644 docs/lint/rules/eslint/no-buffer-constructor.mdx create mode 100644 docs/lint/rules/eslint/no-caller.mdx create mode 100644 docs/lint/rules/eslint/no-case-declarations.mdx create mode 100644 docs/lint/rules/eslint/no-catch-shadow.mdx create mode 100644 docs/lint/rules/eslint/no-class-assign.mdx create mode 100644 docs/lint/rules/eslint/no-compare-neg-zero.mdx create mode 100644 docs/lint/rules/eslint/no-cond-assign.mdx create mode 100644 docs/lint/rules/eslint/no-console.mdx create mode 100644 docs/lint/rules/eslint/no-const-assign.mdx create mode 100644 docs/lint/rules/eslint/no-constant-binary-expression.mdx create mode 100644 docs/lint/rules/eslint/no-constant-condition.mdx create mode 100644 docs/lint/rules/eslint/no-constructor-return.mdx create mode 100644 docs/lint/rules/eslint/no-continue.mdx create mode 100644 docs/lint/rules/eslint/no-control-regex.mdx create mode 100644 docs/lint/rules/eslint/no-debugger.mdx create mode 100644 docs/lint/rules/eslint/no-delete-var.mdx create mode 100644 docs/lint/rules/eslint/no-div-regex.mdx create mode 100644 docs/lint/rules/eslint/no-dupe-args.mdx create mode 100644 docs/lint/rules/eslint/no-dupe-class-members.mdx create mode 100644 docs/lint/rules/eslint/no-dupe-else-if.mdx create mode 100644 docs/lint/rules/eslint/no-dupe-keys.mdx create mode 100644 docs/lint/rules/eslint/no-duplicate-case.mdx create mode 100644 docs/lint/rules/eslint/no-duplicate-imports.mdx create mode 100644 docs/lint/rules/eslint/no-else-return.mdx create mode 100644 docs/lint/rules/eslint/no-empty-character-class.mdx create mode 100644 docs/lint/rules/eslint/no-empty-function.mdx create mode 100644 docs/lint/rules/eslint/no-empty-pattern.mdx create mode 100644 docs/lint/rules/eslint/no-empty-static-block.mdx create mode 100644 docs/lint/rules/eslint/no-empty.mdx create mode 100644 docs/lint/rules/eslint/no-eq-null.mdx create mode 100644 docs/lint/rules/eslint/no-eval.mdx create mode 100644 docs/lint/rules/eslint/no-ex-assign.mdx create mode 100644 docs/lint/rules/eslint/no-extend-native.mdx create mode 100644 docs/lint/rules/eslint/no-extra-bind.mdx create mode 100644 docs/lint/rules/eslint/no-extra-boolean-cast.mdx create mode 100644 docs/lint/rules/eslint/no-extra-label.mdx create mode 100644 docs/lint/rules/eslint/no-fallthrough.mdx create mode 100644 docs/lint/rules/eslint/no-func-assign.mdx create mode 100644 docs/lint/rules/eslint/no-global-assign.mdx create mode 100644 docs/lint/rules/eslint/no-implicit-coercion.mdx create mode 100644 docs/lint/rules/eslint/no-implicit-globals.mdx create mode 100644 docs/lint/rules/eslint/no-implied-eval.mdx create mode 100644 docs/lint/rules/eslint/no-import-assign.mdx create mode 100644 docs/lint/rules/eslint/no-inline-comments.mdx create mode 100644 docs/lint/rules/eslint/no-inner-declarations.mdx create mode 100644 docs/lint/rules/eslint/no-invalid-regexp.mdx create mode 100644 docs/lint/rules/eslint/no-invalid-this.mdx create mode 100644 docs/lint/rules/eslint/no-irregular-whitespace.mdx create mode 100644 docs/lint/rules/eslint/no-iterator.mdx create mode 100644 docs/lint/rules/eslint/no-label-var.mdx create mode 100644 docs/lint/rules/eslint/no-labels.mdx create mode 100644 docs/lint/rules/eslint/no-lone-blocks.mdx create mode 100644 docs/lint/rules/eslint/no-lonely-if.mdx create mode 100644 docs/lint/rules/eslint/no-loop-func.mdx create mode 100644 docs/lint/rules/eslint/no-loss-of-precision.mdx create mode 100644 docs/lint/rules/eslint/no-magic-numbers.mdx create mode 100644 docs/lint/rules/eslint/no-misleading-character-class.mdx create mode 100644 docs/lint/rules/eslint/no-mixed-requires.mdx create mode 100644 docs/lint/rules/eslint/no-multi-assign.mdx create mode 100644 docs/lint/rules/eslint/no-multi-str.mdx create mode 100644 docs/lint/rules/eslint/no-native-reassign.mdx create mode 100644 docs/lint/rules/eslint/no-negated-condition.mdx create mode 100644 docs/lint/rules/eslint/no-negated-in-lhs.mdx create mode 100644 docs/lint/rules/eslint/no-nested-ternary.mdx create mode 100644 docs/lint/rules/eslint/no-new-func.mdx create mode 100644 docs/lint/rules/eslint/no-new-native-nonconstructor.mdx create mode 100644 docs/lint/rules/eslint/no-new-object.mdx create mode 100644 docs/lint/rules/eslint/no-new-require.mdx create mode 100644 docs/lint/rules/eslint/no-new-symbol.mdx create mode 100644 docs/lint/rules/eslint/no-new-wrappers.mdx create mode 100644 docs/lint/rules/eslint/no-new.mdx create mode 100644 docs/lint/rules/eslint/no-nonoctal-decimal-escape.mdx create mode 100644 docs/lint/rules/eslint/no-obj-calls.mdx create mode 100644 docs/lint/rules/eslint/no-object-constructor.mdx create mode 100644 docs/lint/rules/eslint/no-octal-escape.mdx create mode 100644 docs/lint/rules/eslint/no-octal.mdx create mode 100644 docs/lint/rules/eslint/no-param-reassign.mdx create mode 100644 docs/lint/rules/eslint/no-path-concat.mdx create mode 100644 docs/lint/rules/eslint/no-plusplus.mdx create mode 100644 docs/lint/rules/eslint/no-process-env.mdx create mode 100644 docs/lint/rules/eslint/no-process-exit.mdx create mode 100644 docs/lint/rules/eslint/no-promise-executor-return.mdx create mode 100644 docs/lint/rules/eslint/no-proto.mdx create mode 100644 docs/lint/rules/eslint/no-prototype-builtins.mdx create mode 100644 docs/lint/rules/eslint/no-redeclare.mdx create mode 100644 docs/lint/rules/eslint/no-regex-spaces.mdx create mode 100644 docs/lint/rules/eslint/no-restricted-exports.mdx create mode 100644 docs/lint/rules/eslint/no-restricted-globals.mdx create mode 100644 docs/lint/rules/eslint/no-restricted-imports.mdx create mode 100644 docs/lint/rules/eslint/no-restricted-modules.mdx create mode 100644 docs/lint/rules/eslint/no-restricted-properties.mdx create mode 100644 docs/lint/rules/eslint/no-restricted-syntax.mdx create mode 100644 docs/lint/rules/eslint/no-return-assign.mdx create mode 100644 docs/lint/rules/eslint/no-return-await.mdx create mode 100644 docs/lint/rules/eslint/no-script-url.mdx create mode 100644 docs/lint/rules/eslint/no-self-assign.mdx create mode 100644 docs/lint/rules/eslint/no-self-compare.mdx create mode 100644 docs/lint/rules/eslint/no-sequences.mdx create mode 100644 docs/lint/rules/eslint/no-setter-return.mdx create mode 100644 docs/lint/rules/eslint/no-shadow-restricted-names.mdx create mode 100644 docs/lint/rules/eslint/no-shadow.mdx create mode 100644 docs/lint/rules/eslint/no-sparse-arrays.mdx create mode 100644 docs/lint/rules/eslint/no-sync.mdx create mode 100644 docs/lint/rules/eslint/no-template-curly-in-string.mdx create mode 100644 docs/lint/rules/eslint/no-ternary.mdx create mode 100644 docs/lint/rules/eslint/no-this-before-super.mdx create mode 100644 docs/lint/rules/eslint/no-throw-literal.mdx create mode 100644 docs/lint/rules/eslint/no-unassigned-vars.mdx create mode 100644 docs/lint/rules/eslint/no-undef-init.mdx create mode 100644 docs/lint/rules/eslint/no-undef.mdx create mode 100644 docs/lint/rules/eslint/no-undefined.mdx create mode 100644 docs/lint/rules/eslint/no-underscore-dangle.mdx create mode 100644 docs/lint/rules/eslint/no-unexpected-multiline.mdx create mode 100644 docs/lint/rules/eslint/no-unmodified-loop-condition.mdx create mode 100644 docs/lint/rules/eslint/no-unneeded-ternary.mdx create mode 100644 docs/lint/rules/eslint/no-unreachable-loop.mdx create mode 100644 docs/lint/rules/eslint/no-unreachable.mdx create mode 100644 docs/lint/rules/eslint/no-unsafe-finally.mdx create mode 100644 docs/lint/rules/eslint/no-unsafe-negation.mdx create mode 100644 docs/lint/rules/eslint/no-unsafe-optional-chaining.mdx create mode 100644 docs/lint/rules/eslint/no-unused-expressions.mdx create mode 100644 docs/lint/rules/eslint/no-unused-labels.mdx create mode 100644 docs/lint/rules/eslint/no-unused-private-class-members.mdx create mode 100644 docs/lint/rules/eslint/no-unused-vars.mdx create mode 100644 docs/lint/rules/eslint/no-use-before-define.mdx create mode 100644 docs/lint/rules/eslint/no-useless-assignment.mdx create mode 100644 docs/lint/rules/eslint/no-useless-backreference.mdx create mode 100644 docs/lint/rules/eslint/no-useless-call.mdx create mode 100644 docs/lint/rules/eslint/no-useless-catch.mdx create mode 100644 docs/lint/rules/eslint/no-useless-computed-key.mdx create mode 100644 docs/lint/rules/eslint/no-useless-concat.mdx create mode 100644 docs/lint/rules/eslint/no-useless-constructor.mdx create mode 100644 docs/lint/rules/eslint/no-useless-escape.mdx create mode 100644 docs/lint/rules/eslint/no-useless-rename.mdx create mode 100644 docs/lint/rules/eslint/no-useless-return.mdx create mode 100644 docs/lint/rules/eslint/no-void.mdx create mode 100644 docs/lint/rules/eslint/no-warning-comments.mdx create mode 100644 docs/lint/rules/eslint/no-with.mdx create mode 100644 docs/lint/rules/eslint/object-shorthand.mdx create mode 100644 docs/lint/rules/eslint/one-var.mdx create mode 100644 docs/lint/rules/eslint/operator-assignment.mdx create mode 100644 docs/lint/rules/eslint/prefer-arrow-callback.mdx create mode 100644 docs/lint/rules/eslint/prefer-const.mdx create mode 100644 docs/lint/rules/eslint/prefer-destructuring.mdx create mode 100644 docs/lint/rules/eslint/prefer-exponentiation-operator.mdx create mode 100644 docs/lint/rules/eslint/prefer-named-capture-group.mdx create mode 100644 docs/lint/rules/eslint/prefer-numeric-literals.mdx create mode 100644 docs/lint/rules/eslint/prefer-object-has-own.mdx create mode 100644 docs/lint/rules/eslint/prefer-object-spread.mdx create mode 100644 docs/lint/rules/eslint/prefer-promise-reject-errors.mdx create mode 100644 docs/lint/rules/eslint/prefer-reflect.mdx create mode 100644 docs/lint/rules/eslint/prefer-regex-literals.mdx create mode 100644 docs/lint/rules/eslint/prefer-rest-params.mdx create mode 100644 docs/lint/rules/eslint/prefer-spread.mdx create mode 100644 docs/lint/rules/eslint/prefer-template.mdx create mode 100644 docs/lint/rules/eslint/preserve-caught-error.mdx create mode 100644 docs/lint/rules/eslint/radix.mdx create mode 100644 docs/lint/rules/eslint/require-atomic-updates.mdx create mode 100644 docs/lint/rules/eslint/require-await.mdx create mode 100644 docs/lint/rules/eslint/require-unicode-regexp.mdx create mode 100644 docs/lint/rules/eslint/require-yield.mdx create mode 100644 docs/lint/rules/eslint/sort-imports.mdx create mode 100644 docs/lint/rules/eslint/sort-keys.mdx create mode 100644 docs/lint/rules/eslint/sort-vars.mdx create mode 100644 docs/lint/rules/eslint/strict.mdx create mode 100644 docs/lint/rules/eslint/symbol-description.mdx create mode 100644 docs/lint/rules/eslint/unicode-bom.mdx create mode 100644 docs/lint/rules/eslint/use-isnan.mdx create mode 100644 docs/lint/rules/eslint/valid-typeof.mdx create mode 100644 docs/lint/rules/eslint/vars-on-top.mdx create mode 100644 docs/lint/rules/eslint/yoda.mdx create mode 100644 docs/lint/rules/import/consistent-type-specifier-style.mdx create mode 100644 docs/lint/rules/import/default.mdx create mode 100644 docs/lint/rules/import/dynamic-import-chunkname.mdx create mode 100644 docs/lint/rules/import/enforce-node-protocol-usage.mdx create mode 100644 docs/lint/rules/import/export.mdx create mode 100644 docs/lint/rules/import/exports-last.mdx create mode 100644 docs/lint/rules/import/extensions.mdx create mode 100644 docs/lint/rules/import/first.mdx create mode 100644 docs/lint/rules/import/group-exports.mdx create mode 100644 docs/lint/rules/import/imports-first.mdx create mode 100644 docs/lint/rules/import/max-dependencies.mdx create mode 100644 docs/lint/rules/import/named.mdx create mode 100644 docs/lint/rules/import/namespace.mdx create mode 100644 docs/lint/rules/import/newline-after-import.mdx create mode 100644 docs/lint/rules/import/no-absolute-path.mdx create mode 100644 docs/lint/rules/import/no-amd.mdx create mode 100644 docs/lint/rules/import/no-anonymous-default-export.mdx create mode 100644 docs/lint/rules/import/no-commonjs.mdx create mode 100644 docs/lint/rules/import/no-cycle.mdx create mode 100644 docs/lint/rules/import/no-default-export.mdx create mode 100644 docs/lint/rules/import/no-deprecated.mdx create mode 100644 docs/lint/rules/import/no-duplicates.mdx create mode 100644 docs/lint/rules/import/no-dynamic-require.mdx create mode 100644 docs/lint/rules/import/no-empty-named-blocks.mdx create mode 100644 docs/lint/rules/import/no-extraneous-dependencies.mdx create mode 100644 docs/lint/rules/import/no-import-module-exports.mdx create mode 100644 docs/lint/rules/import/no-internal-modules.mdx create mode 100644 docs/lint/rules/import/no-mutable-exports.mdx create mode 100644 docs/lint/rules/import/no-named-as-default-member.mdx create mode 100644 docs/lint/rules/import/no-named-as-default.mdx create mode 100644 docs/lint/rules/import/no-named-default.mdx create mode 100644 docs/lint/rules/import/no-named-export.mdx create mode 100644 docs/lint/rules/import/no-namespace.mdx create mode 100644 docs/lint/rules/import/no-nodejs-modules.mdx create mode 100644 docs/lint/rules/import/no-relative-packages.mdx create mode 100644 docs/lint/rules/import/no-relative-parent-imports.mdx create mode 100644 docs/lint/rules/import/no-restricted-paths.mdx create mode 100644 docs/lint/rules/import/no-self-import.mdx create mode 100644 docs/lint/rules/import/no-unassigned-import.mdx create mode 100644 docs/lint/rules/import/no-unresolved.mdx create mode 100644 docs/lint/rules/import/no-unused-modules.mdx create mode 100644 docs/lint/rules/import/no-useless-path-segments.mdx create mode 100644 docs/lint/rules/import/no-webpack-loader-syntax.mdx create mode 100644 docs/lint/rules/import/order.mdx create mode 100644 docs/lint/rules/import/prefer-default-export.mdx create mode 100644 docs/lint/rules/import/unambiguous.mdx create mode 100644 docs/lint/rules/jest/consistent-test-it.mdx create mode 100644 docs/lint/rules/jest/expect-expect.mdx create mode 100644 docs/lint/rules/jest/max-expects.mdx create mode 100644 docs/lint/rules/jest/max-nested-describe.mdx create mode 100644 docs/lint/rules/jest/no-alias-methods.mdx create mode 100644 docs/lint/rules/jest/no-commented-out-tests.mdx create mode 100644 docs/lint/rules/jest/no-conditional-expect.mdx create mode 100644 docs/lint/rules/jest/no-conditional-in-test.mdx create mode 100644 docs/lint/rules/jest/no-confusing-set-timeout.mdx create mode 100644 docs/lint/rules/jest/no-deprecated-functions.mdx create mode 100644 docs/lint/rules/jest/no-disabled-tests.mdx create mode 100644 docs/lint/rules/jest/no-done-callback.mdx create mode 100644 docs/lint/rules/jest/no-duplicate-hooks.mdx create mode 100644 docs/lint/rules/jest/no-export.mdx create mode 100644 docs/lint/rules/jest/no-focused-tests.mdx create mode 100644 docs/lint/rules/jest/no-hooks.mdx create mode 100644 docs/lint/rules/jest/no-identical-title.mdx create mode 100644 docs/lint/rules/jest/no-interpolation-in-snapshots.mdx create mode 100644 docs/lint/rules/jest/no-jasmine-globals.mdx create mode 100644 docs/lint/rules/jest/no-large-snapshots.mdx create mode 100644 docs/lint/rules/jest/no-mocks-import.mdx create mode 100644 docs/lint/rules/jest/no-restricted-jest-methods.mdx create mode 100644 docs/lint/rules/jest/no-restricted-matchers.mdx create mode 100644 docs/lint/rules/jest/no-standalone-expect.mdx create mode 100644 docs/lint/rules/jest/no-test-prefixes.mdx create mode 100644 docs/lint/rules/jest/no-test-return-statement.mdx create mode 100644 docs/lint/rules/jest/no-unneeded-async-expect-function.mdx create mode 100644 docs/lint/rules/jest/no-untyped-mock-factory.mdx create mode 100644 docs/lint/rules/jest/padding-around-after-all-blocks.mdx create mode 100644 docs/lint/rules/jest/padding-around-test-blocks.mdx create mode 100644 docs/lint/rules/jest/prefer-called-with.mdx create mode 100644 docs/lint/rules/jest/prefer-comparison-matcher.mdx create mode 100644 docs/lint/rules/jest/prefer-each.mdx create mode 100644 docs/lint/rules/jest/prefer-ending-with-an-expect.mdx create mode 100644 docs/lint/rules/jest/prefer-equality-matcher.mdx create mode 100644 docs/lint/rules/jest/prefer-expect-assertions.mdx create mode 100644 docs/lint/rules/jest/prefer-expect-resolves.mdx create mode 100644 docs/lint/rules/jest/prefer-hooks-in-order.mdx create mode 100644 docs/lint/rules/jest/prefer-hooks-on-top.mdx create mode 100644 docs/lint/rules/jest/prefer-importing-jest-globals.mdx create mode 100644 docs/lint/rules/jest/prefer-jest-mocked.mdx create mode 100644 docs/lint/rules/jest/prefer-lowercase-title.mdx create mode 100644 docs/lint/rules/jest/prefer-mock-promise-shorthand.mdx create mode 100644 docs/lint/rules/jest/prefer-mock-return-shorthand.mdx create mode 100644 docs/lint/rules/jest/prefer-snapshot-hint.mdx create mode 100644 docs/lint/rules/jest/prefer-spy-on.mdx create mode 100644 docs/lint/rules/jest/prefer-strict-equal.mdx create mode 100644 docs/lint/rules/jest/prefer-to-be.mdx create mode 100644 docs/lint/rules/jest/prefer-to-contain.mdx create mode 100644 docs/lint/rules/jest/prefer-to-have-been-called-times.mdx create mode 100644 docs/lint/rules/jest/prefer-to-have-been-called.mdx create mode 100644 docs/lint/rules/jest/prefer-to-have-length.mdx create mode 100644 docs/lint/rules/jest/prefer-todo.mdx create mode 100644 docs/lint/rules/jest/require-hook.mdx create mode 100644 docs/lint/rules/jest/require-to-throw-message.mdx create mode 100644 docs/lint/rules/jest/require-top-level-describe.mdx create mode 100644 docs/lint/rules/jest/valid-describe-callback.mdx create mode 100644 docs/lint/rules/jest/valid-expect-in-promise.mdx create mode 100644 docs/lint/rules/jest/valid-expect.mdx create mode 100644 docs/lint/rules/jest/valid-title.mdx create mode 100644 docs/lint/rules/jsx-a11y/alt-text.mdx create mode 100644 docs/lint/rules/jsx-a11y/anchor-ambiguous-text.mdx create mode 100644 docs/lint/rules/jsx-a11y/anchor-has-content.mdx create mode 100644 docs/lint/rules/jsx-a11y/anchor-is-valid.mdx create mode 100644 docs/lint/rules/jsx-a11y/aria-activedescendant-has-tabindex.mdx create mode 100644 docs/lint/rules/jsx-a11y/aria-props.mdx create mode 100644 docs/lint/rules/jsx-a11y/aria-proptypes.mdx create mode 100644 docs/lint/rules/jsx-a11y/aria-role.mdx create mode 100644 docs/lint/rules/jsx-a11y/aria-unsupported-elements.mdx create mode 100644 docs/lint/rules/jsx-a11y/autocomplete-valid.mdx create mode 100644 docs/lint/rules/jsx-a11y/click-events-have-key-events.mdx create mode 100644 docs/lint/rules/jsx-a11y/control-has-associated-label.mdx create mode 100644 docs/lint/rules/jsx-a11y/heading-has-content.mdx create mode 100644 docs/lint/rules/jsx-a11y/html-has-lang.mdx create mode 100644 docs/lint/rules/jsx-a11y/iframe-has-title.mdx create mode 100644 docs/lint/rules/jsx-a11y/img-redundant-alt.mdx create mode 100644 docs/lint/rules/jsx-a11y/interactive-supports-focus.mdx create mode 100644 docs/lint/rules/jsx-a11y/label-has-associated-control.mdx create mode 100644 docs/lint/rules/jsx-a11y/lang.mdx create mode 100644 docs/lint/rules/jsx-a11y/media-has-caption.mdx create mode 100644 docs/lint/rules/jsx-a11y/mouse-events-have-key-events.mdx create mode 100644 docs/lint/rules/jsx-a11y/no-access-key.mdx create mode 100644 docs/lint/rules/jsx-a11y/no-aria-hidden-on-focusable.mdx create mode 100644 docs/lint/rules/jsx-a11y/no-autofocus.mdx create mode 100644 docs/lint/rules/jsx-a11y/no-distracting-elements.mdx create mode 100644 docs/lint/rules/jsx-a11y/no-interactive-element-to-noninteractive-role.mdx create mode 100644 docs/lint/rules/jsx-a11y/no-noninteractive-element-interactions.mdx create mode 100644 docs/lint/rules/jsx-a11y/no-noninteractive-element-to-interactive-role.mdx create mode 100644 docs/lint/rules/jsx-a11y/no-noninteractive-tabindex.mdx create mode 100644 docs/lint/rules/jsx-a11y/no-redundant-roles.mdx create mode 100644 docs/lint/rules/jsx-a11y/no-static-element-interactions.mdx create mode 100644 docs/lint/rules/jsx-a11y/prefer-tag-over-role.mdx create mode 100644 docs/lint/rules/jsx-a11y/role-has-required-aria-props.mdx create mode 100644 docs/lint/rules/jsx-a11y/role-supports-aria-props.mdx create mode 100644 docs/lint/rules/jsx-a11y/scope.mdx create mode 100644 docs/lint/rules/jsx-a11y/tabindex-no-positive.mdx create mode 100644 docs/lint/rules/nextjs/google-font-display.mdx create mode 100644 docs/lint/rules/nextjs/google-font-preconnect.mdx create mode 100644 docs/lint/rules/nextjs/inline-script-id.mdx create mode 100644 docs/lint/rules/nextjs/next-script-for-ga.mdx create mode 100644 docs/lint/rules/nextjs/no-assign-module-variable.mdx create mode 100644 docs/lint/rules/nextjs/no-async-client-component.mdx create mode 100644 docs/lint/rules/nextjs/no-before-interactive-script-outside-document.mdx create mode 100644 docs/lint/rules/nextjs/no-css-tags.mdx create mode 100644 docs/lint/rules/nextjs/no-document-import-in-page.mdx create mode 100644 docs/lint/rules/nextjs/no-duplicate-head.mdx create mode 100644 docs/lint/rules/nextjs/no-head-element.mdx create mode 100644 docs/lint/rules/nextjs/no-head-import-in-document.mdx create mode 100644 docs/lint/rules/nextjs/no-html-link-for-pages.mdx create mode 100644 docs/lint/rules/nextjs/no-img-element.mdx create mode 100644 docs/lint/rules/nextjs/no-page-custom-font.mdx create mode 100644 docs/lint/rules/nextjs/no-script-component-in-head.mdx create mode 100644 docs/lint/rules/nextjs/no-styled-jsx-in-document.mdx create mode 100644 docs/lint/rules/nextjs/no-sync-scripts.mdx create mode 100644 docs/lint/rules/nextjs/no-title-in-document-head.mdx create mode 100644 docs/lint/rules/nextjs/no-typos.mdx create mode 100644 docs/lint/rules/nextjs/no-unwanted-polyfillio.mdx create mode 100644 docs/lint/rules/node/callback-return.mdx create mode 100644 docs/lint/rules/node/exports-style.mdx create mode 100644 docs/lint/rules/node/global-require.mdx create mode 100644 docs/lint/rules/node/handle-callback-err.mdx create mode 100644 docs/lint/rules/node/no-exports-assign.mdx create mode 100644 docs/lint/rules/node/no-mixed-requires.mdx create mode 100644 docs/lint/rules/node/no-new-require.mdx create mode 100644 docs/lint/rules/node/no-path-concat.mdx create mode 100644 docs/lint/rules/node/no-process-env.mdx create mode 100644 docs/lint/rules/node/no-process-exit.mdx create mode 100644 docs/lint/rules/node/no-sync.mdx create mode 100644 docs/lint/rules/node/no-top-level-await.mdx create mode 100644 docs/lint/rules/node/no-unsupported-features/es-builtins.mdx create mode 100644 docs/lint/rules/node/no-unsupported-features/es-syntax.mdx create mode 100644 docs/lint/rules/node/no-unsupported-features/node-builtins.mdx create mode 100644 docs/lint/rules/oxc/approx-constant.mdx create mode 100644 docs/lint/rules/oxc/bad-array-method-on-arguments.mdx create mode 100644 docs/lint/rules/oxc/bad-bitwise-operator.mdx create mode 100644 docs/lint/rules/oxc/bad-char-at-comparison.mdx create mode 100644 docs/lint/rules/oxc/bad-comparison-sequence.mdx create mode 100644 docs/lint/rules/oxc/bad-match-all-arg.mdx create mode 100644 docs/lint/rules/oxc/bad-min-max-func.mdx create mode 100644 docs/lint/rules/oxc/bad-object-literal-comparison.mdx create mode 100644 docs/lint/rules/oxc/bad-replace-all-arg.mdx create mode 100644 docs/lint/rules/oxc/branches-sharing-code.mdx create mode 100644 docs/lint/rules/oxc/const-comparisons.mdx create mode 100644 docs/lint/rules/oxc/double-comparisons.mdx create mode 100644 docs/lint/rules/oxc/erasing-op.mdx create mode 100644 docs/lint/rules/oxc/misrefactored-assign-op.mdx create mode 100644 docs/lint/rules/oxc/missing-throw.mdx create mode 100644 docs/lint/rules/oxc/no-accumulating-spread.mdx create mode 100644 docs/lint/rules/oxc/no-async-await.mdx create mode 100644 docs/lint/rules/oxc/no-async-endpoint-handlers.mdx create mode 100644 docs/lint/rules/oxc/no-barrel-file.mdx create mode 100644 docs/lint/rules/oxc/no-const-enum.mdx create mode 100644 docs/lint/rules/oxc/no-map-spread.mdx create mode 100644 docs/lint/rules/oxc/no-optional-chaining.mdx create mode 100644 docs/lint/rules/oxc/no-rest-spread-properties.mdx create mode 100644 docs/lint/rules/oxc/no-this-in-exported-function.mdx create mode 100644 docs/lint/rules/oxc/number-arg-out-of-range.mdx create mode 100644 docs/lint/rules/oxc/only-used-in-recursion.mdx create mode 100644 docs/lint/rules/oxc/uninvoked-array-callback.mdx create mode 100644 docs/lint/rules/prettier/prettier.mdx create mode 100644 docs/lint/rules/promise/always-return.mdx create mode 100644 docs/lint/rules/promise/avoid-new.mdx create mode 100644 docs/lint/rules/promise/catch-or-return.mdx create mode 100644 docs/lint/rules/promise/no-callback-in-promise.mdx create mode 100644 docs/lint/rules/promise/no-multiple-resolved.mdx create mode 100644 docs/lint/rules/promise/no-nesting.mdx create mode 100644 docs/lint/rules/promise/no-new-statics.mdx create mode 100644 docs/lint/rules/promise/no-promise-in-callback.mdx create mode 100644 docs/lint/rules/promise/no-return-in-finally.mdx create mode 100644 docs/lint/rules/promise/no-return-wrap.mdx create mode 100644 docs/lint/rules/promise/param-names.mdx create mode 100644 docs/lint/rules/promise/prefer-await-to-callbacks.mdx create mode 100644 docs/lint/rules/promise/prefer-await-to-then.mdx create mode 100644 docs/lint/rules/promise/prefer-catch.mdx create mode 100644 docs/lint/rules/promise/spec-only.mdx create mode 100644 docs/lint/rules/promise/valid-params.mdx create mode 100644 docs/lint/rules/react-perf/jsx-no-jsx-as-prop.mdx create mode 100644 docs/lint/rules/react-perf/jsx-no-new-array-as-prop.mdx create mode 100644 docs/lint/rules/react-perf/jsx-no-new-function-as-prop.mdx create mode 100644 docs/lint/rules/react-perf/jsx-no-new-object-as-prop.mdx create mode 100644 docs/lint/rules/react/boolean-prop-naming.mdx create mode 100644 docs/lint/rules/react/button-has-type.mdx create mode 100644 docs/lint/rules/react/capitalized-calls.mdx create mode 100644 docs/lint/rules/react/checked-requires-onchange-or-readonly.mdx create mode 100644 docs/lint/rules/react/default-props-match-prop-types.mdx create mode 100644 docs/lint/rules/react/destructuring-assignment.mdx create mode 100644 docs/lint/rules/react/display-name.mdx create mode 100644 docs/lint/rules/react/error-boundaries.mdx create mode 100644 docs/lint/rules/react/exhaustive-deps.mdx create mode 100644 docs/lint/rules/react/forbid-component-props.mdx create mode 100644 docs/lint/rules/react/forbid-dom-props.mdx create mode 100644 docs/lint/rules/react/forbid-elements.mdx create mode 100644 docs/lint/rules/react/forbid-foreign-prop-types.mdx create mode 100644 docs/lint/rules/react/forbid-prop-types.mdx create mode 100644 docs/lint/rules/react/forward-ref-uses-ref.mdx create mode 100644 docs/lint/rules/react/function-component-definition.mdx create mode 100644 docs/lint/rules/react/globals.mdx create mode 100644 docs/lint/rules/react/hook-use-state.mdx create mode 100644 docs/lint/rules/react/hooks.mdx create mode 100644 docs/lint/rules/react/iframe-missing-sandbox.mdx create mode 100644 docs/lint/rules/react/immutability.mdx create mode 100644 docs/lint/rules/react/incompatible-library.mdx create mode 100644 docs/lint/rules/react/invariant.mdx create mode 100644 docs/lint/rules/react/jsx-boolean-value.mdx create mode 100644 docs/lint/rules/react/jsx-child-element-spacing.mdx create mode 100644 docs/lint/rules/react/jsx-closing-bracket-location.mdx create mode 100644 docs/lint/rules/react/jsx-closing-tag-location.mdx create mode 100644 docs/lint/rules/react/jsx-curly-brace-presence.mdx create mode 100644 docs/lint/rules/react/jsx-curly-newline.mdx create mode 100644 docs/lint/rules/react/jsx-curly-spacing.mdx create mode 100644 docs/lint/rules/react/jsx-equals-spacing.mdx create mode 100644 docs/lint/rules/react/jsx-filename-extension.mdx create mode 100644 docs/lint/rules/react/jsx-first-prop-new-line.mdx create mode 100644 docs/lint/rules/react/jsx-fragments.mdx create mode 100644 docs/lint/rules/react/jsx-handler-names.mdx create mode 100644 docs/lint/rules/react/jsx-indent-props.mdx create mode 100644 docs/lint/rules/react/jsx-indent.mdx create mode 100644 docs/lint/rules/react/jsx-key.mdx create mode 100644 docs/lint/rules/react/jsx-max-depth.mdx create mode 100644 docs/lint/rules/react/jsx-max-props-per-line.mdx create mode 100644 docs/lint/rules/react/jsx-newline.mdx create mode 100644 docs/lint/rules/react/jsx-no-bind.mdx create mode 100644 docs/lint/rules/react/jsx-no-comment-textnodes.mdx create mode 100644 docs/lint/rules/react/jsx-no-constructed-context-values.mdx create mode 100644 docs/lint/rules/react/jsx-no-duplicate-props.mdx create mode 100644 docs/lint/rules/react/jsx-no-leaked-render.mdx create mode 100644 docs/lint/rules/react/jsx-no-literals.mdx create mode 100644 docs/lint/rules/react/jsx-no-script-url.mdx create mode 100644 docs/lint/rules/react/jsx-no-target-blank.mdx create mode 100644 docs/lint/rules/react/jsx-no-undef.mdx create mode 100644 docs/lint/rules/react/jsx-no-useless-fragment.mdx create mode 100644 docs/lint/rules/react/jsx-one-expression-per-line.mdx create mode 100644 docs/lint/rules/react/jsx-pascal-case.mdx create mode 100644 docs/lint/rules/react/jsx-props-no-multi-spaces.mdx create mode 100644 docs/lint/rules/react/jsx-props-no-spread-multi.mdx create mode 100644 docs/lint/rules/react/jsx-props-no-spreading.mdx create mode 100644 docs/lint/rules/react/jsx-sort-default-props.mdx create mode 100644 docs/lint/rules/react/jsx-sort-props.mdx create mode 100644 docs/lint/rules/react/jsx-tag-spacing.mdx create mode 100644 docs/lint/rules/react/jsx-wrap-multilines.mdx create mode 100644 docs/lint/rules/react/memo-dependencies.mdx create mode 100644 docs/lint/rules/react/no-access-state-in-setstate.mdx create mode 100644 docs/lint/rules/react/no-adjacent-inline-elements.mdx create mode 100644 docs/lint/rules/react/no-array-index-key.mdx create mode 100644 docs/lint/rules/react/no-arrow-function-lifecycle.mdx create mode 100644 docs/lint/rules/react/no-children-prop.mdx create mode 100644 docs/lint/rules/react/no-clone-element.mdx create mode 100644 docs/lint/rules/react/no-danger-with-children.mdx create mode 100644 docs/lint/rules/react/no-danger.mdx create mode 100644 docs/lint/rules/react/no-deprecated.mdx create mode 100644 docs/lint/rules/react/no-deriving-state-in-effects.mdx create mode 100644 docs/lint/rules/react/no-did-mount-set-state.mdx create mode 100644 docs/lint/rules/react/no-did-update-set-state.mdx create mode 100644 docs/lint/rules/react/no-direct-mutation-state.mdx create mode 100644 docs/lint/rules/react/no-find-dom-node.mdx create mode 100644 docs/lint/rules/react/no-invalid-html-attribute.mdx create mode 100644 docs/lint/rules/react/no-is-mounted.mdx create mode 100644 docs/lint/rules/react/no-multi-comp.mdx create mode 100644 docs/lint/rules/react/no-namespace.mdx create mode 100644 docs/lint/rules/react/no-object-type-as-default-prop.mdx create mode 100644 docs/lint/rules/react/no-react-children.mdx create mode 100644 docs/lint/rules/react/no-redundant-should-component-update.mdx create mode 100644 docs/lint/rules/react/no-render-return-value.mdx create mode 100644 docs/lint/rules/react/no-set-state.mdx create mode 100644 docs/lint/rules/react/no-string-refs.mdx create mode 100644 docs/lint/rules/react/no-this-in-sfc.mdx create mode 100644 docs/lint/rules/react/no-typos.mdx create mode 100644 docs/lint/rules/react/no-unescaped-entities.mdx create mode 100644 docs/lint/rules/react/no-unknown-property.mdx create mode 100644 docs/lint/rules/react/no-unsafe.mdx create mode 100644 docs/lint/rules/react/no-unstable-nested-components.mdx create mode 100644 docs/lint/rules/react/no-unused-class-component-methods.mdx create mode 100644 docs/lint/rules/react/no-unused-prop-types.mdx create mode 100644 docs/lint/rules/react/no-unused-state.mdx create mode 100644 docs/lint/rules/react/prefer-es6-class.mdx create mode 100644 docs/lint/rules/react/prefer-read-only-props.mdx create mode 100644 docs/lint/rules/react/prefer-stateless-function.mdx create mode 100644 docs/lint/rules/react/preserve-manual-memoization.mdx create mode 100644 docs/lint/rules/react/prop-types.mdx create mode 100644 docs/lint/rules/react/purity.mdx create mode 100644 docs/lint/rules/react/react-in-jsx-scope.mdx create mode 100644 docs/lint/rules/react/refs.mdx create mode 100644 docs/lint/rules/react/require-default-props.mdx create mode 100644 docs/lint/rules/react/require-optimization.mdx create mode 100644 docs/lint/rules/react/require-render-return.mdx create mode 100644 docs/lint/rules/react/rules-of-hooks.mdx create mode 100644 docs/lint/rules/react/self-closing-comp.mdx create mode 100644 docs/lint/rules/react/set-state-in-effect.mdx create mode 100644 docs/lint/rules/react/set-state-in-render.mdx create mode 100644 docs/lint/rules/react/sort-comp.mdx create mode 100644 docs/lint/rules/react/sort-default-props.mdx create mode 100644 docs/lint/rules/react/sort-prop-types.mdx create mode 100644 docs/lint/rules/react/state-in-constructor.mdx create mode 100644 docs/lint/rules/react/static-components.mdx create mode 100644 docs/lint/rules/react/static-property-placement.mdx create mode 100644 docs/lint/rules/react/style-prop-object.mdx create mode 100644 docs/lint/rules/react/syntax.mdx create mode 100644 docs/lint/rules/react/todo.mdx create mode 100644 docs/lint/rules/react/unsupported-syntax.mdx create mode 100644 docs/lint/rules/react/use-memo.mdx create mode 100644 docs/lint/rules/react/void-dom-elements-no-children.mdx create mode 100644 docs/lint/rules/react/void-use-memo.mdx create mode 100644 docs/lint/rules/typescript/adjacent-overload-signatures.mdx create mode 100644 docs/lint/rules/typescript/array-type.mdx create mode 100644 docs/lint/rules/typescript/await-thenable.mdx create mode 100644 docs/lint/rules/typescript/ban-ts-comment.mdx create mode 100644 docs/lint/rules/typescript/ban-tslint-comment.mdx create mode 100644 docs/lint/rules/typescript/ban-types.mdx create mode 100644 docs/lint/rules/typescript/class-literal-property-style.mdx create mode 100644 docs/lint/rules/typescript/class-methods-use-this.mdx create mode 100644 docs/lint/rules/typescript/consistent-generic-constructors.mdx create mode 100644 docs/lint/rules/typescript/consistent-indexed-object-style.mdx create mode 100644 docs/lint/rules/typescript/consistent-return.mdx create mode 100644 docs/lint/rules/typescript/consistent-type-assertions.mdx create mode 100644 docs/lint/rules/typescript/consistent-type-definitions.mdx create mode 100644 docs/lint/rules/typescript/consistent-type-exports.mdx create mode 100644 docs/lint/rules/typescript/consistent-type-imports.mdx create mode 100644 docs/lint/rules/typescript/default-param-last.mdx create mode 100644 docs/lint/rules/typescript/dot-notation.mdx create mode 100644 docs/lint/rules/typescript/explicit-function-return-type.mdx create mode 100644 docs/lint/rules/typescript/explicit-member-accessibility.mdx create mode 100644 docs/lint/rules/typescript/explicit-module-boundary-types.mdx create mode 100644 docs/lint/rules/typescript/init-declarations.mdx create mode 100644 docs/lint/rules/typescript/max-params.mdx create mode 100644 docs/lint/rules/typescript/member-ordering.mdx create mode 100644 docs/lint/rules/typescript/method-signature-style.mdx create mode 100644 docs/lint/rules/typescript/naming-convention.mdx create mode 100644 docs/lint/rules/typescript/no-array-constructor.mdx create mode 100644 docs/lint/rules/typescript/no-array-delete.mdx create mode 100644 docs/lint/rules/typescript/no-base-to-string.mdx create mode 100644 docs/lint/rules/typescript/no-confusing-non-null-assertion.mdx create mode 100644 docs/lint/rules/typescript/no-confusing-void-expression.mdx create mode 100644 docs/lint/rules/typescript/no-deprecated.mdx create mode 100644 docs/lint/rules/typescript/no-dupe-class-members.mdx create mode 100644 docs/lint/rules/typescript/no-duplicate-enum-values.mdx create mode 100644 docs/lint/rules/typescript/no-duplicate-type-constituents.mdx create mode 100644 docs/lint/rules/typescript/no-dynamic-delete.mdx create mode 100644 docs/lint/rules/typescript/no-empty-function.mdx create mode 100644 docs/lint/rules/typescript/no-empty-interface.mdx create mode 100644 docs/lint/rules/typescript/no-empty-object-type.mdx create mode 100644 docs/lint/rules/typescript/no-explicit-any.mdx create mode 100644 docs/lint/rules/typescript/no-extra-non-null-assertion.mdx create mode 100644 docs/lint/rules/typescript/no-extraneous-class.mdx create mode 100644 docs/lint/rules/typescript/no-for-in-array.mdx create mode 100644 docs/lint/rules/typescript/no-generated-empty-object-type.mdx create mode 100644 docs/lint/rules/typescript/no-implied-eval.mdx create mode 100644 docs/lint/rules/typescript/no-import-type-side-effects.mdx create mode 100644 docs/lint/rules/typescript/no-inferrable-types.mdx create mode 100644 docs/lint/rules/typescript/no-invalid-this.mdx create mode 100644 docs/lint/rules/typescript/no-invalid-void-type.mdx create mode 100644 docs/lint/rules/typescript/no-loop-func.mdx create mode 100644 docs/lint/rules/typescript/no-loss-of-precision.mdx create mode 100644 docs/lint/rules/typescript/no-magic-numbers.mdx create mode 100644 docs/lint/rules/typescript/no-meaningless-void-operator.mdx create mode 100644 docs/lint/rules/typescript/no-misused-new.mdx create mode 100644 docs/lint/rules/typescript/no-misused-promises.mdx create mode 100644 docs/lint/rules/typescript/no-misused-spread.mdx create mode 100644 docs/lint/rules/typescript/no-mixed-enums.mdx create mode 100644 docs/lint/rules/typescript/no-namespace.mdx create mode 100644 docs/lint/rules/typescript/no-non-null-asserted-nullish-coalescing.mdx create mode 100644 docs/lint/rules/typescript/no-non-null-asserted-optional-chain.mdx create mode 100644 docs/lint/rules/typescript/no-non-null-assertion.mdx create mode 100644 docs/lint/rules/typescript/no-redeclare.mdx create mode 100644 docs/lint/rules/typescript/no-redundant-type-constituents.mdx create mode 100644 docs/lint/rules/typescript/no-require-imports.mdx create mode 100644 docs/lint/rules/typescript/no-restricted-imports.mdx create mode 100644 docs/lint/rules/typescript/no-restricted-types.mdx create mode 100644 docs/lint/rules/typescript/no-shadow.mdx create mode 100644 docs/lint/rules/typescript/no-this-alias.mdx create mode 100644 docs/lint/rules/typescript/no-type-alias.mdx create mode 100644 docs/lint/rules/typescript/no-unnecessary-boolean-literal-compare.mdx create mode 100644 docs/lint/rules/typescript/no-unnecessary-condition.mdx create mode 100644 docs/lint/rules/typescript/no-unnecessary-parameter-property-assignment.mdx create mode 100644 docs/lint/rules/typescript/no-unnecessary-qualifier.mdx create mode 100644 docs/lint/rules/typescript/no-unnecessary-template-expression.mdx create mode 100644 docs/lint/rules/typescript/no-unnecessary-type-arguments.mdx create mode 100644 docs/lint/rules/typescript/no-unnecessary-type-assertion.mdx create mode 100644 docs/lint/rules/typescript/no-unnecessary-type-constraint.mdx create mode 100644 docs/lint/rules/typescript/no-unnecessary-type-conversion.mdx create mode 100644 docs/lint/rules/typescript/no-unnecessary-type-parameters.mdx create mode 100644 docs/lint/rules/typescript/no-unsafe-argument.mdx create mode 100644 docs/lint/rules/typescript/no-unsafe-assignment.mdx create mode 100644 docs/lint/rules/typescript/no-unsafe-call.mdx create mode 100644 docs/lint/rules/typescript/no-unsafe-declaration-merging.mdx create mode 100644 docs/lint/rules/typescript/no-unsafe-enum-assignment.mdx create mode 100644 docs/lint/rules/typescript/no-unsafe-enum-comparison.mdx create mode 100644 docs/lint/rules/typescript/no-unsafe-function-type.mdx create mode 100644 docs/lint/rules/typescript/no-unsafe-member-access.mdx create mode 100644 docs/lint/rules/typescript/no-unsafe-return.mdx create mode 100644 docs/lint/rules/typescript/no-unsafe-type-assertion.mdx create mode 100644 docs/lint/rules/typescript/no-unsafe-unary-minus.mdx create mode 100644 docs/lint/rules/typescript/no-unused-expressions.mdx create mode 100644 docs/lint/rules/typescript/no-unused-private-class-members.mdx create mode 100644 docs/lint/rules/typescript/no-unused-vars.mdx create mode 100644 docs/lint/rules/typescript/no-use-before-define.mdx create mode 100644 docs/lint/rules/typescript/no-useless-constructor.mdx create mode 100644 docs/lint/rules/typescript/no-useless-default-assignment.mdx create mode 100644 docs/lint/rules/typescript/no-useless-empty-export.mdx create mode 100644 docs/lint/rules/typescript/no-var-requires.mdx create mode 100644 docs/lint/rules/typescript/no-wrapper-object-types.mdx create mode 100644 docs/lint/rules/typescript/non-nullable-type-assertion-style.mdx create mode 100644 docs/lint/rules/typescript/only-throw-error.mdx create mode 100644 docs/lint/rules/typescript/parameter-properties.mdx create mode 100644 docs/lint/rules/typescript/prefer-as-const.mdx create mode 100644 docs/lint/rules/typescript/prefer-destructuring.mdx create mode 100644 docs/lint/rules/typescript/prefer-enum-initializers.mdx create mode 100644 docs/lint/rules/typescript/prefer-find.mdx create mode 100644 docs/lint/rules/typescript/prefer-for-of.mdx create mode 100644 docs/lint/rules/typescript/prefer-function-type.mdx create mode 100644 docs/lint/rules/typescript/prefer-includes.mdx create mode 100644 docs/lint/rules/typescript/prefer-literal-enum-member.mdx create mode 100644 docs/lint/rules/typescript/prefer-namespace-keyword.mdx create mode 100644 docs/lint/rules/typescript/prefer-nullish-coalescing.mdx create mode 100644 docs/lint/rules/typescript/prefer-optional-chain.mdx create mode 100644 docs/lint/rules/typescript/prefer-promise-reject-errors.mdx create mode 100644 docs/lint/rules/typescript/prefer-readonly-parameter-types.mdx create mode 100644 docs/lint/rules/typescript/prefer-readonly.mdx create mode 100644 docs/lint/rules/typescript/prefer-reduce-type-parameter.mdx create mode 100644 docs/lint/rules/typescript/prefer-regexp-exec.mdx create mode 100644 docs/lint/rules/typescript/prefer-return-this-type.mdx create mode 100644 docs/lint/rules/typescript/prefer-string-starts-ends-with.mdx create mode 100644 docs/lint/rules/typescript/prefer-ts-expect-error.mdx create mode 100644 docs/lint/rules/typescript/promise-function-async.mdx create mode 100644 docs/lint/rules/typescript/related-getter-setter-pairs.mdx create mode 100644 docs/lint/rules/typescript/require-array-sort-compare.mdx create mode 100644 docs/lint/rules/typescript/require-await.mdx create mode 100644 docs/lint/rules/typescript/restrict-plus-operands.mdx create mode 100644 docs/lint/rules/typescript/restrict-template-expressions.mdx create mode 100644 docs/lint/rules/typescript/return-await.mdx create mode 100644 docs/lint/rules/typescript/sort-type-constituents.mdx create mode 100644 docs/lint/rules/typescript/strict-boolean-expressions.mdx create mode 100644 docs/lint/rules/typescript/strict-void-return.mdx create mode 100644 docs/lint/rules/typescript/switch-exhaustiveness-check.mdx create mode 100644 docs/lint/rules/typescript/triple-slash-reference.mdx create mode 100644 docs/lint/rules/typescript/typedef.mdx create mode 100644 docs/lint/rules/typescript/unbound-method.mdx create mode 100644 docs/lint/rules/typescript/unified-signatures.mdx create mode 100644 docs/lint/rules/typescript/use-unknown-in-catch-callback-variable.mdx create mode 100644 docs/lint/rules/unicorn/catch-error-name.mdx create mode 100644 docs/lint/rules/unicorn/consistent-assert.mdx create mode 100644 docs/lint/rules/unicorn/consistent-date-clone.mdx create mode 100644 docs/lint/rules/unicorn/consistent-empty-array-spread.mdx create mode 100644 docs/lint/rules/unicorn/consistent-existence-index-check.mdx create mode 100644 docs/lint/rules/unicorn/consistent-function-scoping.mdx create mode 100644 docs/lint/rules/unicorn/consistent-template-literal-escape.mdx create mode 100644 docs/lint/rules/unicorn/custom-error-definition.mdx create mode 100644 docs/lint/rules/unicorn/empty-brace-spaces.mdx create mode 100644 docs/lint/rules/unicorn/error-message.mdx create mode 100644 docs/lint/rules/unicorn/escape-case.mdx create mode 100644 docs/lint/rules/unicorn/explicit-length-check.mdx create mode 100644 docs/lint/rules/unicorn/explicit-timer-delay.mdx create mode 100644 docs/lint/rules/unicorn/filename-case.mdx create mode 100644 docs/lint/rules/unicorn/import-style.mdx create mode 100644 docs/lint/rules/unicorn/max-nested-calls.mdx create mode 100644 docs/lint/rules/unicorn/new-for-builtins.mdx create mode 100644 docs/lint/rules/unicorn/no-abusive-eslint-disable.mdx create mode 100644 docs/lint/rules/unicorn/no-accessor-recursion.mdx create mode 100644 docs/lint/rules/unicorn/no-anonymous-default-export.mdx create mode 100644 docs/lint/rules/unicorn/no-array-callback-reference.mdx create mode 100644 docs/lint/rules/unicorn/no-array-fill-with-reference-type.mdx create mode 100644 docs/lint/rules/unicorn/no-array-for-each.mdx create mode 100644 docs/lint/rules/unicorn/no-array-method-this-argument.mdx create mode 100644 docs/lint/rules/unicorn/no-array-reduce.mdx create mode 100644 docs/lint/rules/unicorn/no-array-reverse.mdx create mode 100644 docs/lint/rules/unicorn/no-array-sort.mdx create mode 100644 docs/lint/rules/unicorn/no-await-expression-member.mdx create mode 100644 docs/lint/rules/unicorn/no-await-in-promise-methods.mdx create mode 100644 docs/lint/rules/unicorn/no-confusing-array-with.mdx create mode 100644 docs/lint/rules/unicorn/no-console-spaces.mdx create mode 100644 docs/lint/rules/unicorn/no-document-cookie.mdx create mode 100644 docs/lint/rules/unicorn/no-empty-file.mdx create mode 100644 docs/lint/rules/unicorn/no-hex-escape.mdx create mode 100644 docs/lint/rules/unicorn/no-immediate-mutation.mdx create mode 100644 docs/lint/rules/unicorn/no-instanceof-array.mdx create mode 100644 docs/lint/rules/unicorn/no-instanceof-builtins.mdx create mode 100644 docs/lint/rules/unicorn/no-invalid-fetch-options.mdx create mode 100644 docs/lint/rules/unicorn/no-invalid-remove-event-listener.mdx create mode 100644 docs/lint/rules/unicorn/no-length-as-slice-end.mdx create mode 100644 docs/lint/rules/unicorn/no-lonely-if.mdx create mode 100644 docs/lint/rules/unicorn/no-magic-array-flat-depth.mdx create mode 100644 docs/lint/rules/unicorn/no-negated-condition.mdx create mode 100644 docs/lint/rules/unicorn/no-negation-in-equality-check.mdx create mode 100644 docs/lint/rules/unicorn/no-nested-ternary.mdx create mode 100644 docs/lint/rules/unicorn/no-new-array.mdx create mode 100644 docs/lint/rules/unicorn/no-new-buffer.mdx create mode 100644 docs/lint/rules/unicorn/no-object-as-default-parameter.mdx create mode 100644 docs/lint/rules/unicorn/no-process-exit.mdx create mode 100644 docs/lint/rules/unicorn/no-single-promise-in-promise-methods.mdx create mode 100644 docs/lint/rules/unicorn/no-static-only-class.mdx create mode 100644 docs/lint/rules/unicorn/no-thenable.mdx create mode 100644 docs/lint/rules/unicorn/no-this-assignment.mdx create mode 100644 docs/lint/rules/unicorn/no-typeof-undefined.mdx create mode 100644 docs/lint/rules/unicorn/no-unnecessary-array-flat-depth.mdx create mode 100644 docs/lint/rules/unicorn/no-unnecessary-array-splice-count.mdx create mode 100644 docs/lint/rules/unicorn/no-unnecessary-await.mdx create mode 100644 docs/lint/rules/unicorn/no-unnecessary-slice-end.mdx create mode 100644 docs/lint/rules/unicorn/no-unreadable-array-destructuring.mdx create mode 100644 docs/lint/rules/unicorn/no-unreadable-iife.mdx create mode 100644 docs/lint/rules/unicorn/no-useless-collection-argument.mdx create mode 100644 docs/lint/rules/unicorn/no-useless-error-capture-stack-trace.mdx create mode 100644 docs/lint/rules/unicorn/no-useless-fallback-in-spread.mdx create mode 100644 docs/lint/rules/unicorn/no-useless-iterator-to-array.mdx create mode 100644 docs/lint/rules/unicorn/no-useless-length-check.mdx create mode 100644 docs/lint/rules/unicorn/no-useless-promise-resolve-reject.mdx create mode 100644 docs/lint/rules/unicorn/no-useless-spread.mdx create mode 100644 docs/lint/rules/unicorn/no-useless-switch-case.mdx create mode 100644 docs/lint/rules/unicorn/no-useless-undefined.mdx create mode 100644 docs/lint/rules/unicorn/no-zero-fractions.mdx create mode 100644 docs/lint/rules/unicorn/number-literal-case.mdx create mode 100644 docs/lint/rules/unicorn/numeric-separators-style.mdx create mode 100644 docs/lint/rules/unicorn/prefer-add-event-listener.mdx create mode 100644 docs/lint/rules/unicorn/prefer-array-find.mdx create mode 100644 docs/lint/rules/unicorn/prefer-array-flat-map.mdx create mode 100644 docs/lint/rules/unicorn/prefer-array-flat.mdx create mode 100644 docs/lint/rules/unicorn/prefer-array-index-of.mdx create mode 100644 docs/lint/rules/unicorn/prefer-array-some.mdx create mode 100644 docs/lint/rules/unicorn/prefer-at.mdx create mode 100644 docs/lint/rules/unicorn/prefer-bigint-literals.mdx create mode 100644 docs/lint/rules/unicorn/prefer-blob-reading-methods.mdx create mode 100644 docs/lint/rules/unicorn/prefer-class-fields.mdx create mode 100644 docs/lint/rules/unicorn/prefer-classlist-toggle.mdx create mode 100644 docs/lint/rules/unicorn/prefer-code-point.mdx create mode 100644 docs/lint/rules/unicorn/prefer-date-now.mdx create mode 100644 docs/lint/rules/unicorn/prefer-default-parameters.mdx create mode 100644 docs/lint/rules/unicorn/prefer-dom-node-append.mdx create mode 100644 docs/lint/rules/unicorn/prefer-dom-node-dataset.mdx create mode 100644 docs/lint/rules/unicorn/prefer-dom-node-remove.mdx create mode 100644 docs/lint/rules/unicorn/prefer-dom-node-text-content.mdx create mode 100644 docs/lint/rules/unicorn/prefer-event-target.mdx create mode 100644 docs/lint/rules/unicorn/prefer-export-from.mdx create mode 100644 docs/lint/rules/unicorn/prefer-global-this.mdx create mode 100644 docs/lint/rules/unicorn/prefer-import-meta-properties.mdx create mode 100644 docs/lint/rules/unicorn/prefer-includes.mdx create mode 100644 docs/lint/rules/unicorn/prefer-keyboard-event-key.mdx create mode 100644 docs/lint/rules/unicorn/prefer-logical-operator-over-ternary.mdx create mode 100644 docs/lint/rules/unicorn/prefer-math-min-max.mdx create mode 100644 docs/lint/rules/unicorn/prefer-math-trunc.mdx create mode 100644 docs/lint/rules/unicorn/prefer-modern-dom-apis.mdx create mode 100644 docs/lint/rules/unicorn/prefer-modern-math-apis.mdx create mode 100644 docs/lint/rules/unicorn/prefer-module.mdx create mode 100644 docs/lint/rules/unicorn/prefer-native-coercion-functions.mdx create mode 100644 docs/lint/rules/unicorn/prefer-negative-index.mdx create mode 100644 docs/lint/rules/unicorn/prefer-node-protocol.mdx create mode 100644 docs/lint/rules/unicorn/prefer-number-coercion.mdx create mode 100644 docs/lint/rules/unicorn/prefer-number-properties.mdx create mode 100644 docs/lint/rules/unicorn/prefer-object-from-entries.mdx create mode 100644 docs/lint/rules/unicorn/prefer-optional-catch-binding.mdx create mode 100644 docs/lint/rules/unicorn/prefer-prototype-methods.mdx create mode 100644 docs/lint/rules/unicorn/prefer-query-selector.mdx create mode 100644 docs/lint/rules/unicorn/prefer-reflect-apply.mdx create mode 100644 docs/lint/rules/unicorn/prefer-regexp-test.mdx create mode 100644 docs/lint/rules/unicorn/prefer-response-static-json.mdx create mode 100644 docs/lint/rules/unicorn/prefer-set-has.mdx create mode 100644 docs/lint/rules/unicorn/prefer-set-size.mdx create mode 100644 docs/lint/rules/unicorn/prefer-single-call.mdx create mode 100644 docs/lint/rules/unicorn/prefer-spread.mdx create mode 100644 docs/lint/rules/unicorn/prefer-string-raw.mdx create mode 100644 docs/lint/rules/unicorn/prefer-string-replace-all.mdx create mode 100644 docs/lint/rules/unicorn/prefer-string-slice.mdx create mode 100644 docs/lint/rules/unicorn/prefer-string-starts-ends-with.mdx create mode 100644 docs/lint/rules/unicorn/prefer-string-trim-start-end.mdx create mode 100644 docs/lint/rules/unicorn/prefer-structured-clone.mdx create mode 100644 docs/lint/rules/unicorn/prefer-ternary.mdx create mode 100644 docs/lint/rules/unicorn/prefer-top-level-await.mdx create mode 100644 docs/lint/rules/unicorn/prefer-type-error.mdx create mode 100644 docs/lint/rules/unicorn/relative-url-style.mdx create mode 100644 docs/lint/rules/unicorn/require-array-join-separator.mdx create mode 100644 docs/lint/rules/unicorn/require-module-attributes.mdx create mode 100644 docs/lint/rules/unicorn/require-module-specifiers.mdx create mode 100644 docs/lint/rules/unicorn/require-number-to-fixed-digits-argument.mdx create mode 100644 docs/lint/rules/unicorn/require-post-message-target-origin.mdx create mode 100644 docs/lint/rules/unicorn/switch-case-braces.mdx create mode 100644 docs/lint/rules/unicorn/switch-case-break-position.mdx create mode 100644 docs/lint/rules/unicorn/text-encoding-identifier-case.mdx create mode 100644 docs/lint/rules/unicorn/throw-new-error.mdx create mode 100644 docs/lint/rules/vitest/consistent-each-for.mdx create mode 100644 docs/lint/rules/vitest/consistent-test-filename.mdx create mode 100644 docs/lint/rules/vitest/consistent-test-it.mdx create mode 100644 docs/lint/rules/vitest/consistent-vitest-vi.mdx create mode 100644 docs/lint/rules/vitest/expect-expect.mdx create mode 100644 docs/lint/rules/vitest/hoisted-apis-on-top.mdx create mode 100644 docs/lint/rules/vitest/max-expects.mdx create mode 100644 docs/lint/rules/vitest/max-nested-describe.mdx create mode 100644 docs/lint/rules/vitest/no-alias-methods.mdx create mode 100644 docs/lint/rules/vitest/no-commented-out-tests.mdx create mode 100644 docs/lint/rules/vitest/no-conditional-expect.mdx create mode 100644 docs/lint/rules/vitest/no-conditional-in-test.mdx create mode 100644 docs/lint/rules/vitest/no-conditional-tests.mdx create mode 100644 docs/lint/rules/vitest/no-disabled-tests.mdx create mode 100644 docs/lint/rules/vitest/no-duplicate-hooks.mdx create mode 100644 docs/lint/rules/vitest/no-focused-tests.mdx create mode 100644 docs/lint/rules/vitest/no-hooks.mdx create mode 100644 docs/lint/rules/vitest/no-identical-title.mdx create mode 100644 docs/lint/rules/vitest/no-import-node-test.mdx create mode 100644 docs/lint/rules/vitest/no-importing-vitest-globals.mdx create mode 100644 docs/lint/rules/vitest/no-interpolation-in-snapshots.mdx create mode 100644 docs/lint/rules/vitest/no-large-snapshots.mdx create mode 100644 docs/lint/rules/vitest/no-mocks-import.mdx create mode 100644 docs/lint/rules/vitest/no-restricted-matchers.mdx create mode 100644 docs/lint/rules/vitest/no-restricted-vi-methods.mdx create mode 100644 docs/lint/rules/vitest/no-standalone-expect.mdx create mode 100644 docs/lint/rules/vitest/no-test-prefixes.mdx create mode 100644 docs/lint/rules/vitest/no-test-return-statement.mdx create mode 100644 docs/lint/rules/vitest/no-unneeded-async-expect-function.mdx create mode 100644 docs/lint/rules/vitest/padding-around-after-all-blocks.mdx create mode 100644 docs/lint/rules/vitest/padding-around-test-blocks.mdx create mode 100644 docs/lint/rules/vitest/prefer-called-exactly-once-with.mdx create mode 100644 docs/lint/rules/vitest/prefer-called-once.mdx create mode 100644 docs/lint/rules/vitest/prefer-called-times.mdx create mode 100644 docs/lint/rules/vitest/prefer-called-with.mdx create mode 100644 docs/lint/rules/vitest/prefer-comparison-matcher.mdx create mode 100644 docs/lint/rules/vitest/prefer-describe-function-title.mdx create mode 100644 docs/lint/rules/vitest/prefer-each.mdx create mode 100644 docs/lint/rules/vitest/prefer-equality-matcher.mdx create mode 100644 docs/lint/rules/vitest/prefer-expect-assertions.mdx create mode 100644 docs/lint/rules/vitest/prefer-expect-resolves.mdx create mode 100644 docs/lint/rules/vitest/prefer-expect-type-of.mdx create mode 100644 docs/lint/rules/vitest/prefer-hooks-in-order.mdx create mode 100644 docs/lint/rules/vitest/prefer-hooks-on-top.mdx create mode 100644 docs/lint/rules/vitest/prefer-import-in-mock.mdx create mode 100644 docs/lint/rules/vitest/prefer-importing-vitest-globals.mdx create mode 100644 docs/lint/rules/vitest/prefer-lowercase-title.mdx create mode 100644 docs/lint/rules/vitest/prefer-mock-promise-shorthand.mdx create mode 100644 docs/lint/rules/vitest/prefer-mock-return-shorthand.mdx create mode 100644 docs/lint/rules/vitest/prefer-snapshot-hint.mdx create mode 100644 docs/lint/rules/vitest/prefer-spy-on.mdx create mode 100644 docs/lint/rules/vitest/prefer-strict-boolean-matchers.mdx create mode 100644 docs/lint/rules/vitest/prefer-strict-equal.mdx create mode 100644 docs/lint/rules/vitest/prefer-to-be-falsy.mdx create mode 100644 docs/lint/rules/vitest/prefer-to-be-object.mdx create mode 100644 docs/lint/rules/vitest/prefer-to-be-truthy.mdx create mode 100644 docs/lint/rules/vitest/prefer-to-be.mdx create mode 100644 docs/lint/rules/vitest/prefer-to-contain.mdx create mode 100644 docs/lint/rules/vitest/prefer-to-have-been-called-times.mdx create mode 100644 docs/lint/rules/vitest/prefer-to-have-length.mdx create mode 100644 docs/lint/rules/vitest/prefer-todo.mdx create mode 100644 docs/lint/rules/vitest/require-awaited-expect-poll.mdx create mode 100644 docs/lint/rules/vitest/require-hook.mdx create mode 100644 docs/lint/rules/vitest/require-local-test-context-for-concurrent-snapshots.mdx create mode 100644 docs/lint/rules/vitest/require-mock-type-parameters.mdx create mode 100644 docs/lint/rules/vitest/require-test-timeout.mdx create mode 100644 docs/lint/rules/vitest/require-to-throw-message.mdx create mode 100644 docs/lint/rules/vitest/require-top-level-describe.mdx create mode 100644 docs/lint/rules/vitest/valid-describe-callback.mdx create mode 100644 docs/lint/rules/vitest/valid-expect-in-promise.mdx create mode 100644 docs/lint/rules/vitest/valid-expect.mdx create mode 100644 docs/lint/rules/vitest/valid-title.mdx create mode 100644 docs/lint/rules/vitest/warn-todo.mdx create mode 100644 docs/snippets/cli/format.mdx create mode 100644 docs/snippets/cli/lint.mdx create mode 100644 src/codegen/generate-lint-docs.ts create mode 100644 src/glob/filtered.rs create mode 100644 src/lint/driver/doctor.rs create mode 100644 src/lint/driver/fmt/doctor.rs create mode 100644 src/lint/driver/git.rs create mode 100644 src/lint/driver/init.rs create mode 100644 src/lint/driver/native.rs create mode 100644 test/cli/format/doctor.test.ts create mode 100644 test/cli/lint/doctor.test.ts create mode 100644 test/cli/lint/git.test.ts create mode 100644 test/cli/lint/help.test.ts create mode 100644 test/cli/lint/hints.test.ts create mode 100644 test/cli/lint/init.test.ts create mode 100644 test/cli/lint/output.test.ts diff --git a/completions/bun.bash b/completions/bun.bash index 77b601ca1d91..aa9a41752fb8 100644 --- a/completions/bun.bash +++ b/completions/bun.bash @@ -195,10 +195,10 @@ _bun_completions() { COMPREPLY=( $(compgen -f -W "--project --pretty --no-pretty --all --threads --timing --cwd --help -p -h" -- "${cur_word}") ); return;; lint) - COMPREPLY=( $(compgen -f -W "--config --no-config-lookup --flavor --rule --global --parser-options --ext --fix --fix-dry-run --fix-type --ignore-pattern --no-ignore --no-warn-ignored --stdin --stdin-filename --quiet --max-warnings --format --all --output-file --color --no-color --no-inline-config --report-unused-disable-directives --report-unused-disable-directives-severity --report-unused-inline-configs --suppress-all --suppress-rule --suppressions-location --prune-suppressions --pass-on-unpruned-suppressions --no-error-on-unmatched-pattern --pass-on-no-patterns --exit-on-fatal-error --allow-unsupported --native-plugin-rules --no-native-plugin-rules --print-config --type-aware --no-type-aware --infer-globals --no-infer-globals --project --type-check --allow --warn --deny --deny-warnings --silent --ignore-path --disable-nested-config --fix-suggestions --fix-dangerously --init --rules --threads --timing --cwd --help -c -f -o -p -A -W -D -h" -- "${cur_word}") ); + COMPREPLY=( $(compgen -f -W "--config --no-config-lookup --rule --rules --init --global --no-inline-config --report-unused-disable-directives --print-config --doctor --flavor --allow-unsupported --staged --changed --ignore-pattern --ignore-path --no-ignore --ext --stdin --stdin-filename --list-files --no-error-on-unmatched-pattern --cwd --format --all --output-file --quiet --max-warnings --color --no-color --timing --fix --fix-dry-run --fix-type --fix-suggestions --fix-dangerously --type-aware --no-type-aware --project --type-check --infer-globals --no-infer-globals --threads --version --help --help-all --parser-options --parser --plugin --native-plugin-rules --no-native-plugin-rules --report-unused-disable-directives-severity --report-unused-inline-configs --no-warn-ignored --suppress-all --suppress-rule --suppressions-location --prune-suppressions --pass-on-unpruned-suppressions --pass-on-no-patterns --exit-on-fatal-error --concurrency --env-info --no-eslintrc --env --rulesdir --resolve-plugins-relative-to --allow --warn --deny --deny-warnings --silent --disable-nested-config --import-plugin --react-plugin --jsdoc-plugin --jest-plugin --vitest-plugin --jsx-a11y-plugin --nextjs-plugin --react-perf-plugin --promise-plugin --node-plugin --vue-plugin --disable-unicorn-plugin --disable-oxc-plugin --disable-typescript-plugin -c -f -o -p -v -h -A -W -D" -- "${cur_word}") ); return;; format) - COMPREPLY=( $(compgen -f -W "--check --list-different --stdin-filepath --config --no-config --disable-nested-config --no-editorconfig --config-precedence --ignore-path --ignore-pattern --with-node-modules --no-error-on-unmatched-pattern --ignore-unknown --find-config-path --log-level --print-width --tab-width --use-tabs --no-semi --single-quote --jsx-single-quote --quote-props --trailing-comma --no-bracket-spacing --bracket-same-line --arrow-parens --object-wrap --single-attribute-per-line --html-whitespace-sensitivity --vue-indent-script-and-style --prose-wrap --embedded-language-formatting --end-of-line --require-pragma --check-ignore-pragma --insert-pragma --range-start --range-end --threads --allow-unsupported --flavor --init --timing --cwd --help -c -l -u -h" -- "${cur_word}") ); + COMPREPLY=( $(compgen -f -W "--config --no-config --no-editorconfig --init --doctor --print-width --tab-width --use-tabs --no-semi --single-quote --jsx-single-quote --quote-props --trailing-comma --no-bracket-spacing --bracket-same-line --arrow-parens --object-wrap --single-attribute-per-line --html-whitespace-sensitivity --vue-indent-script-and-style --prose-wrap --embedded-language-formatting --end-of-line --no-insert-final-newline --sort-imports --sort-tailwindcss --sort-package-json --jsdoc --staged --changed --stdin-filepath --ignore-path --ignore-pattern --with-node-modules --toml --no-error-on-unmatched-pattern --ignore-unknown --allow-unsupported --list-files --cwd --check --list-different --log-level --format --color --no-color --timing --threads --no-verify --version --help --all --help-all --write --config-precedence --find-config-path --parser --plugin --require-pragma --check-ignore-pragma --insert-pragma --range-start --range-end --cursor-offset --experimental-ternaries --experimental-operator-position --jsx-bracket-same-line --flavor --disable-nested-config -u -c -l -f -v -h -w" -- "${cur_word}") ); return;; create|c) COMPREPLY=( $(compgen -W "--force --no-install --help --no-git --verbose --no-package-json --open next react" -- "${cur_word}") ); diff --git a/completions/bun.fish b/completions/bun.fish index 556e3670d8c7..1ec288c83604 100644 --- a/completions/bun.fish +++ b/completions/bun.fish @@ -220,80 +220,97 @@ complete -c bun -n "__fish_seen_subcommand_from check" -l "threads" -r -d "Numbe complete -c bun -n "__fish_seen_subcommand_from check" -l "timing" -d "Print load and check times" complete -c bun -n "__fish_seen_subcommand_from check" -l "cwd" -r -d "Set the working directory" complete -c bun -n "__fish_use_subcommand" -a "lint" -d "Lint JavaScript and TypeScript" -f -complete -c bun -n "__fish_seen_subcommand_from lint" -s "c" -l "config" -r -F -d "Use this configuration file instead of looking for one" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "no-config-lookup" -d "Do not look for a configuration file" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "flavor" -r -d "Whose configuration files count where there are both: eslint or oxlint" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "rule" -r -d "Configure a rule: --rule 'eqeqeq: [error, smart]'" +complete -c bun -n "__fish_seen_subcommand_from lint" -s "c" -l "config" -r -F -d "Use this config file instead of looking for one" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "no-config-lookup" -d "Do not look for a config file" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "rule" -r -d "Set a rule: --rule 'eqeqeq: [error, smart]'" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "rules" -d "List the built-in rules" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "init" -d "Add a [lint] section to bunfig.toml" complete -c bun -n "__fish_seen_subcommand_from lint" -l "global" -r -d "Define global variables: --global a,b:true" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "parser-options" -r -d "Set parser options: --parser-options projectService:true" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "ext" -r -d "Lint files with these extensions too" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "fix" -d "Fix what can be fixed, and write the files" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "fix-dry-run" -d "Fix without writing. The json format has the fixed code" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "fix-type" -r -d "Only apply fixes of rules of these types: directive, problem, suggestion, layout" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "ignore-pattern" -r -d "Ignore the files that match" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "no-inline-config" -d "Ignore eslint-disable and other config comments" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "report-unused-disable-directives" -d "Report eslint-disable comments that disable nothing" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "print-config" -r -F -d "Print the config for a file and exit" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "doctor" -d "Show which rules run natively and which packages you can remove" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "flavor" -r -d "Use the eslint or the oxlint config in a project that has both" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "allow-unsupported" -d "Warn instead of failing when the config has a rule or file that is not supported" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "staged" -d "Only lint files that are staged in git" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "changed" -d "Only lint files that differ from a branch or commit (default: the default branch)" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "ignore-pattern" -r -d "Skip files that match this pattern" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "ignore-path" -r -F -d "Read ignore patterns from this file instead of .eslintignore" complete -c bun -n "__fish_seen_subcommand_from lint" -l "no-ignore" -d "Lint ignored files too" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "no-warn-ignored" -d "Do not warn about an ignored file that is named as an argument" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "stdin" -d "Lint the code on standard input" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "stdin-filename" -r -F -d "The file name that the code on standard input is linted as" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "quiet" -d "Report errors only" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "max-warnings" -r -d "Exit with 1 if there are more warnings than this" -complete -c bun -n "__fish_seen_subcommand_from lint" -s "f" -l "format" -r -d "stylish (default), pretty, json, json-with-metadata, unix, github, agent, checkstyle, junit, gitlab, sarif" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "ext" -r -d "Also lint files with these extensions" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "stdin" -d "Lint code from standard input" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "stdin-filename" -r -F -d "The file name to use for code from standard input" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "list-files" -d "Print the files that would be linted and exit" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "no-error-on-unmatched-pattern" -d "Do not fail when an argument matches no file" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "cwd" -r -d "Set the working directory" +complete -c bun -n "__fish_seen_subcommand_from lint" -s "f" -l "format" -r -d "pretty (default), stylish, json, json-with-metadata, unix, github, agent, checkstyle, junit, gitlab, sarif" complete -c bun -n "__fish_seen_subcommand_from lint" -l "all" -d "Show every problem (pretty and agent group identical problems above 50)" complete -c bun -n "__fish_seen_subcommand_from lint" -s "o" -l "output-file" -r -F -d "Write the report to a file" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "quiet" -d "Report errors only" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "max-warnings" -r -d "Exit with 1 if there are more warnings than this" complete -c bun -n "__fish_seen_subcommand_from lint" -l "color" -d "Always use colors" complete -c bun -n "__fish_seen_subcommand_from lint" -l "no-color" -d "Never use colors" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "no-inline-config" -d "Ignore eslint-disable and other configuration comments" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "report-unused-disable-directives" -d "Report eslint-disable comments that disable nothing, as errors" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "report-unused-disable-directives-severity" -r -d "The same, as off, warn or error" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "report-unused-inline-configs" -r -d "Report configuration comments that change nothing" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "suppress-all" -d "Tolerate the errors that there are now: write them to eslint-suppressions.json" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "suppress-rule" -r -d "The same for the errors of a rule" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "suppressions-location" -r -F -d "Another file than eslint-suppressions.json" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "prune-suppressions" -d "Remove from that file what no longer occurs" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "pass-on-unpruned-suppressions" -d "Do not fail if that file has what no longer occurs" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "no-error-on-unmatched-pattern" -d "Do not fail if an argument matches no file" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "pass-on-no-patterns" -d "Exit with 0 if there are no arguments, instead of linting ." -complete -c bun -n "__fish_seen_subcommand_from lint" -l "exit-on-fatal-error" -d "Exit with 2 if a file cannot be parsed" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "allow-unsupported" -d "Only warn about rules and files of the configuration that cannot be linted yet" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "native-plugin-rules" -r -d "The plugins whose built-in rules run in place of the installed package: import,react-hooks (default: all)" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "no-native-plugin-rules" -d "Run the rules of the installed plugins, in JavaScript, not the built-in ones" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "print-config" -r -F -d "Print the configuration of a file, and lint nothing" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "type-aware" -d "Run the rules that need types, whatever the configuration says" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "timing" -d "Print how long each phase took" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "fix" -d "Fix problems and write the files" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "fix-dry-run" -d "Fix without writing. The json format has the fixed code" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "fix-type" -r -d "Only apply fixes from rules of these types: directive, problem, suggestion, layout" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "fix-suggestions" -d "Apply suggestions" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "fix-dangerously" -d "Apply every fix and suggestion, even those that can change behavior" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "type-aware" -d "Also run the rules that need types" complete -c bun -n "__fish_seen_subcommand_from lint" -l "no-type-aware" -d "Skip the rules that need types" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "infer-globals" -d "The globals of a file are what the types of its project declare, too" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "no-infer-globals" -d "Without a configuration file: those of browsers and of Node.js" -complete -c bun -n "__fish_seen_subcommand_from lint" -s "p" -l "project" -r -F -d "The tsconfig.json for the rules that need types" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "type-check" -d "With --type-aware: report type errors too" -complete -c bun -n "__fish_seen_subcommand_from lint" -s "A" -l "allow" -r -d "Turn a rule off, or with an .oxlintrc.json a category" -complete -c bun -n "__fish_seen_subcommand_from lint" -s "W" -l "warn" -r -d "The same: make it a warning" -complete -c bun -n "__fish_seen_subcommand_from lint" -s "D" -l "deny" -r -d "The same: make it an error" +complete -c bun -n "__fish_seen_subcommand_from lint" -s "p" -l "project" -r -F -d "Use this tsconfig.json for the rules that need types (alias: --tsconfig)" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "type-check" -d "With --type-aware, report type errors too" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "infer-globals" -d "Also treat what the project's types declare as globals" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "no-infer-globals" -d "Do not take globals from the project's types" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "threads" -r -d "Number of threads (default: one per CPU core)" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "help-all" -d "The same as --help --all" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "parser-options" -r -d "Set parser options: --parser-options projectService:true" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "parser" -r -d "Parse files with this package" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "plugin" -r -d "Load these plugins" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "native-plugin-rules" -r -d "Use the built-in rules for these plugins only: import,react-hooks (default: all)" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "no-native-plugin-rules" -d "Run plugin rules from the installed packages, in JavaScript" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "report-unused-disable-directives-severity" -r -d "Report unused eslint-disable comments as off, warn or error" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "report-unused-inline-configs" -r -d "Report config comments that change nothing" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "no-warn-ignored" -d "Do not warn when an argument names an ignored file" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "suppress-all" -d "Record the current errors in eslint-suppressions.json and stop reporting them" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "suppress-rule" -r -d "The same, for the errors of one rule" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "suppressions-location" -r -F -d "Use this file instead of eslint-suppressions.json" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "prune-suppressions" -d "Remove recorded errors that no longer occur from that file" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "pass-on-unpruned-suppressions" -d "Do not fail when that file has errors that no longer occur" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "pass-on-no-patterns" -d "Exit with 0 when there are no arguments, instead of linting ." +complete -c bun -n "__fish_seen_subcommand_from lint" -l "exit-on-fatal-error" -d "Exit with 2 if a file cannot be parsed" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "concurrency" -r -d "The same as --threads" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "env-info" -d "Print the versions of Bun, ESLint and TypeScript" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "no-eslintrc" -d "ESLint 8: do not look for .eslintrc files" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "env" -r -d "ESLint 8: turn on these environments" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "rulesdir" -r -F -d "ESLint 8: load rules from these directories" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "resolve-plugins-relative-to" -r -F -d "ESLint 8: look for plugins from this directory" +complete -c bun -n "__fish_seen_subcommand_from lint" -s "A" -l "allow" -r -d "Turn off a rule or a category" +complete -c bun -n "__fish_seen_subcommand_from lint" -s "W" -l "warn" -r -d "Make a rule or a category a warning" +complete -c bun -n "__fish_seen_subcommand_from lint" -s "D" -l "deny" -r -d "Make a rule or a category an error" complete -c bun -n "__fish_seen_subcommand_from lint" -l "deny-warnings" -d "Exit with 1 if there are warnings" complete -c bun -n "__fish_seen_subcommand_from lint" -l "silent" -d "Print no problems" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "ignore-path" -r -F -d "A file with patterns to ignore, in place of .eslintignore" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "disable-nested-config" -d "Use the configuration file of the working directory for every file" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "fix-suggestions" -d "Apply suggestions, as oxlint does" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "fix-dangerously" -d "Apply every fix and suggestion, as oxlint does" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "init" -d "Write an .oxlintrc.json with the defaults of oxlint" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "rules" -d "List the rules that are built in" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "threads" -r -d "Number of threads (default: one per CPU core)" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "timing" -d "Print how long each phase took" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "cwd" -r -d "Set the working directory" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "disable-nested-config" -d "Use the config file in the working directory for every file" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "import-plugin" -d "Turn on the import plugin" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "react-plugin" -d "Turn on the react plugin" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "jsdoc-plugin" -d "Turn on the jsdoc plugin" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "jest-plugin" -d "Turn on the jest plugin" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "vitest-plugin" -d "Turn on the vitest plugin" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "jsx-a11y-plugin" -d "Turn on the jsx-a11y plugin" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "nextjs-plugin" -d "Turn on the nextjs plugin" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "react-perf-plugin" -d "Turn on the react-perf plugin" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "promise-plugin" -d "Turn on the promise plugin" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "node-plugin" -d "Turn on the node plugin" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "vue-plugin" -d "Turn on the vue plugin" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "disable-unicorn-plugin" -d "Turn off the unicorn plugin" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "disable-oxc-plugin" -d "Turn off the oxc plugin" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "disable-typescript-plugin" -d "Turn off the typescript plugin" complete -c bun -n "__fish_use_subcommand" -a "format" -d "Format code the way Prettier does" -f -complete -c bun -n "__fish_seen_subcommand_from format" -s "c" -l "check" -d "Do not write. Exit with 1 if a file is not formatted" -complete -c bun -n "__fish_seen_subcommand_from format" -s "l" -l "list-different" -d "Do not write. Print the files that are not formatted, and exit with 1 if there are any" -complete -c bun -n "__fish_seen_subcommand_from format" -l "stdin-filepath" -r -F -d "Format standard input as that file, and print the result" -complete -c bun -n "__fish_seen_subcommand_from format" -l "config" -r -F -d "Use this configuration file instead of looking for one" -complete -c bun -n "__fish_seen_subcommand_from format" -l "no-config" -d "Do not look for a configuration file" -complete -c bun -n "__fish_seen_subcommand_from format" -l "disable-nested-config" -d "Use the configuration file of the working directory for every file" +complete -c bun -n "__fish_seen_subcommand_from format" -l "config" -r -F -d "Use this config file instead of looking for one" +complete -c bun -n "__fish_seen_subcommand_from format" -l "no-config" -d "Do not look for a config file" complete -c bun -n "__fish_seen_subcommand_from format" -l "no-editorconfig" -d "Do not read .editorconfig" -complete -c bun -n "__fish_seen_subcommand_from format" -l "config-precedence" -r -d "cli-override (default), file-override, or prefer-file" -complete -c bun -n "__fish_seen_subcommand_from format" -l "ignore-path" -r -F -d "Files with patterns to ignore (default: .gitignore and .prettierignore)" -complete -c bun -n "__fish_seen_subcommand_from format" -l "ignore-pattern" -r -d "Ignore the files that match, as a line of .prettierignore does" -complete -c bun -n "__fish_seen_subcommand_from format" -l "with-node-modules" -d "Format files in node_modules too" -complete -c bun -n "__fish_seen_subcommand_from format" -l "no-error-on-unmatched-pattern" -d "Do not fail if an argument matches no file" -complete -c bun -n "__fish_seen_subcommand_from format" -s "u" -l "ignore-unknown" -d "Say nothing about a file that there is no parser for" -complete -c bun -n "__fish_seen_subcommand_from format" -l "find-config-path" -r -F -d "Print the configuration file of a file, and format nothing" -complete -c bun -n "__fish_seen_subcommand_from format" -l "log-level" -r -d "silent, error, warn, log (default), or debug" +complete -c bun -n "__fish_seen_subcommand_from format" -l "init" -d "Add a [format] section to bunfig.toml" +complete -c bun -n "__fish_seen_subcommand_from format" -l "doctor" -d "Show which files are formatted natively and which packages you can remove" complete -c bun -n "__fish_seen_subcommand_from format" -l "print-width" -r -d "The line length to wrap at (default: 80)" complete -c bun -n "__fish_seen_subcommand_from format" -l "tab-width" -r -d "Spaces per indentation level (default: 2)" complete -c bun -n "__fish_seen_subcommand_from format" -l "use-tabs" -d "Indent with tabs" @@ -302,27 +319,60 @@ complete -c bun -n "__fish_seen_subcommand_from format" -l "single-quote" -d "Us complete -c bun -n "__fish_seen_subcommand_from format" -l "jsx-single-quote" -d "Use single quotes in JSX" complete -c bun -n "__fish_seen_subcommand_from format" -l "quote-props" -r -d "as-needed (default), consistent, or preserve" complete -c bun -n "__fish_seen_subcommand_from format" -l "trailing-comma" -r -d "all (default), es5, or none" -complete -c bun -n "__fish_seen_subcommand_from format" -l "no-bracket-spacing" -d "No spaces between the braces of an object literal" +complete -c bun -n "__fish_seen_subcommand_from format" -l "no-bracket-spacing" -d "No spaces inside the braces of an object literal" complete -c bun -n "__fish_seen_subcommand_from format" -l "bracket-same-line" -d "Put the closing bracket of a multi-line element at the end of the last line" complete -c bun -n "__fish_seen_subcommand_from format" -l "arrow-parens" -r -d "always (default) or avoid" complete -c bun -n "__fish_seen_subcommand_from format" -l "object-wrap" -r -d "preserve (default) or collapse" complete -c bun -n "__fish_seen_subcommand_from format" -l "single-attribute-per-line" -d "One attribute per line in HTML, Vue, and JSX" complete -c bun -n "__fish_seen_subcommand_from format" -l "html-whitespace-sensitivity" -r -d "css (default), strict, or ignore" -complete -c bun -n "__fish_seen_subcommand_from format" -l "vue-indent-script-and-style" -d "Indent what is in the script and style tags of Vue files" +complete -c bun -n "__fish_seen_subcommand_from format" -l "vue-indent-script-and-style" -d "Indent the code in the script and style tags of Vue files" complete -c bun -n "__fish_seen_subcommand_from format" -l "prose-wrap" -r -d "preserve (default), always, or never" complete -c bun -n "__fish_seen_subcommand_from format" -l "embedded-language-formatting" -r -d "auto (default) or off" complete -c bun -n "__fish_seen_subcommand_from format" -l "end-of-line" -r -d "lf (default), crlf, cr, or auto" -complete -c bun -n "__fish_seen_subcommand_from format" -l "require-pragma" -d "Only format files that start with a comment that has @format or @prettier" -complete -c bun -n "__fish_seen_subcommand_from format" -l "check-ignore-pragma" -d "Do not format files that start with a comment that has @noformat or @noprettier" -complete -c bun -n "__fish_seen_subcommand_from format" -l "insert-pragma" -d "Put a comment with @format at the top of the files that are formatted" +complete -c bun -n "__fish_seen_subcommand_from format" -l "no-insert-final-newline" -d "Do not end files with a newline" +complete -c bun -n "__fish_seen_subcommand_from format" -l "sort-imports" -d "Sort and group imports" +complete -c bun -n "__fish_seen_subcommand_from format" -l "sort-tailwindcss" -d "Sort Tailwind CSS classes" +complete -c bun -n "__fish_seen_subcommand_from format" -l "sort-package-json" -d "Sort the keys of package.json" +complete -c bun -n "__fish_seen_subcommand_from format" -l "jsdoc" -d "Format JSDoc comments" +complete -c bun -n "__fish_seen_subcommand_from format" -l "staged" -d "Only format files that are staged in git, and stage them again" +complete -c bun -n "__fish_seen_subcommand_from format" -l "changed" -d "Only format files that differ from a branch or commit (default: the default branch)" +complete -c bun -n "__fish_seen_subcommand_from format" -l "stdin-filepath" -r -F -d "Format standard input as this file and print the result" +complete -c bun -n "__fish_seen_subcommand_from format" -l "ignore-path" -r -F -d "Read ignore patterns from these files (default: .gitignore and .prettierignore)" +complete -c bun -n "__fish_seen_subcommand_from format" -l "ignore-pattern" -r -d "Skip files that match this pattern" +complete -c bun -n "__fish_seen_subcommand_from format" -l "with-node-modules" -d "Format files in node_modules too" +complete -c bun -n "__fish_seen_subcommand_from format" -l "toml" -d "Format TOML files too" +complete -c bun -n "__fish_seen_subcommand_from format" -l "no-error-on-unmatched-pattern" -d "Do not fail when an argument matches no file" +complete -c bun -n "__fish_seen_subcommand_from format" -s "u" -l "ignore-unknown" -d "Ignore files in an unknown language" +complete -c bun -n "__fish_seen_subcommand_from format" -l "allow-unsupported" -d "Warn instead of failing when the language of a file is not supported" +complete -c bun -n "__fish_seen_subcommand_from format" -l "list-files" -d "Print the files that would be formatted and exit" +complete -c bun -n "__fish_seen_subcommand_from format" -l "cwd" -r -d "Set the working directory" +complete -c bun -n "__fish_seen_subcommand_from format" -s "c" -l "check" -d "Do not write. Exit with 1 if a file needs formatting" +complete -c bun -n "__fish_seen_subcommand_from format" -s "l" -l "list-different" -d "Do not write. Print the files that need formatting and exit with 1 if there are any" +complete -c bun -n "__fish_seen_subcommand_from format" -l "log-level" -r -d "silent, error, warn, log (default), or debug" +complete -c bun -n "__fish_seen_subcommand_from format" -s "f" -l "format" -r -d "With --doctor, print the report as json" +complete -c bun -n "__fish_seen_subcommand_from format" -l "color" -d "Always use colors" +complete -c bun -n "__fish_seen_subcommand_from format" -l "no-color" -d "Never use colors" +complete -c bun -n "__fish_seen_subcommand_from format" -l "timing" -d "Print how long each phase took" +complete -c bun -n "__fish_seen_subcommand_from format" -l "threads" -r -d "Number of threads (default: one per CPU core)" +complete -c bun -n "__fish_seen_subcommand_from format" -l "no-verify" -d "Skip the check that formatted code still means the same" +complete -c bun -n "__fish_seen_subcommand_from format" -l "all" -d "With --help, print every flag" +complete -c bun -n "__fish_seen_subcommand_from format" -l "help-all" -d "The same as --help --all" +complete -c bun -n "__fish_seen_subcommand_from format" -s "w" -l "write" -d "Write the files. This is the default" +complete -c bun -n "__fish_seen_subcommand_from format" -l "config-precedence" -r -d "cli-override (default), file-override, or prefer-file" +complete -c bun -n "__fish_seen_subcommand_from format" -l "find-config-path" -r -F -d "Print the path of the config for a file and exit" +complete -c bun -n "__fish_seen_subcommand_from format" -l "parser" -r -d "Read every file with this parser" +complete -c bun -n "__fish_seen_subcommand_from format" -l "plugin" -r -d "Load these plugins" +complete -c bun -n "__fish_seen_subcommand_from format" -l "require-pragma" -d "Only format files that start with a @format or @prettier comment" +complete -c bun -n "__fish_seen_subcommand_from format" -l "check-ignore-pragma" -d "Skip files that start with a @noformat or @noprettier comment" +complete -c bun -n "__fish_seen_subcommand_from format" -l "insert-pragma" -d "Put a @format comment at the top of formatted files" complete -c bun -n "__fish_seen_subcommand_from format" -l "range-start" -r -d "Only format the statements from this offset on" complete -c bun -n "__fish_seen_subcommand_from format" -l "range-end" -r -d "Only format the statements up to this offset" -complete -c bun -n "__fish_seen_subcommand_from format" -l "threads" -r -d "Number of threads (default: one per CPU core)" -complete -c bun -n "__fish_seen_subcommand_from format" -l "allow-unsupported" -d "Only warn about files in a language that is not supported" -complete -c bun -n "__fish_seen_subcommand_from format" -l "flavor" -r -d "Without a configuration file, format like prettier or like oxfmt" -complete -c bun -n "__fish_seen_subcommand_from format" -l "init" -d "Write an .oxfmtrc.json, with which files are formatted like oxfmt does" -complete -c bun -n "__fish_seen_subcommand_from format" -l "timing" -d "Print how long each phase took" -complete -c bun -n "__fish_seen_subcommand_from format" -l "cwd" -r -d "Set the working directory" +complete -c bun -n "__fish_seen_subcommand_from format" -l "cursor-offset" -r -d "With --stdin-filepath, print on stderr where this offset ends up" +complete -c bun -n "__fish_seen_subcommand_from format" -l "experimental-ternaries" -d "Put the question mark of a ternary after the condition" +complete -c bun -n "__fish_seen_subcommand_from format" -l "experimental-operator-position" -r -d "end (default) or start" +complete -c bun -n "__fish_seen_subcommand_from format" -l "jsx-bracket-same-line" -d "The old name of --bracket-same-line" +complete -c bun -n "__fish_seen_subcommand_from format" -l "flavor" -r -d "Without a config file, format like prettier or like oxfmt" +complete -c bun -n "__fish_seen_subcommand_from format" -l "disable-nested-config" -d "Use the config file in the working directory for every file" complete -c bun -n "__fish_use_subcommand" -a "dedupe" -d "Remove duplicate versions from the lockfile" -f complete -c bun -n "__fish_use_subcommand" -a "prune" -d "Remove packages that are not in the lockfile from node_modules" -f complete -c bun -n "__fish_seen_subcommand_from audit; and not __fish_seen_subcommand_from fix" -a "fix" -d "Upgrade vulnerable packages to the lowest safe version" -f diff --git a/completions/bun.zsh b/completions/bun.zsh index 2a8add6c259b..c3df74caccb0 100644 --- a/completions/bun.zsh +++ b/completions/bun.zsh @@ -768,73 +768,102 @@ _bun_lint_completion() { _arguments -s -C \ '1: :->cmd1' \ '*: :_files' \ - '--config[Use this configuration file instead of looking for one]:config:_files' \ - '-c[Use this configuration file instead of looking for one]:config:_files' \ - '--no-config-lookup[Do not look for a configuration file]' \ - '--flavor[Whose configuration files count where there are both\: eslint or oxlint]:flavor' \ - '--rule[Configure a rule\: --rule '"'"'eqeqeq\: \[error, smart\]'"'"']:rule' \ + '--config[Use this config file instead of looking for one]:config:_files' \ + '-c[Use this config file instead of looking for one]:config:_files' \ + '--no-config-lookup[Do not look for a config file]' \ + '--rule[Set a rule\: --rule '"'"'eqeqeq\: \[error, smart\]'"'"']:rule' \ + '--rules[List the built-in rules]' \ + '--init[Add a \[lint\] section to bunfig.toml]' \ '--global[Define global variables\: --global a,b\:true]:global' \ - '--parser-options[Set parser options\: --parser-options projectService\:true]:parser-options' \ - '--ext[Lint files with these extensions too]:ext' \ - '--fix[Fix what can be fixed, and write the files]' \ - '--fix-dry-run[Fix without writing. The json format has the fixed code]' \ - '--fix-type[Only apply fixes of rules of these types\: directive, problem, suggestion, layout]:fix-type' \ - '--ignore-pattern[Ignore the files that match]:ignore-pattern' \ + '--no-inline-config[Ignore eslint-disable and other config comments]' \ + '--report-unused-disable-directives[Report eslint-disable comments that disable nothing]' \ + '--print-config[Print the config for a file and exit]:print-config:_files' \ + '--doctor[Show which rules run natively and which packages you can remove]' \ + '--flavor[Use the eslint or the oxlint config in a project that has both]:flavor' \ + '--allow-unsupported[Warn instead of failing when the config has a rule or file that is not supported]' \ + '--staged[Only lint files that are staged in git]' \ + '--changed[Only lint files that differ from a branch or commit (default\: the default branch)]' \ + '--ignore-pattern[Skip files that match this pattern]:ignore-pattern' \ + '--ignore-path[Read ignore patterns from this file instead of .eslintignore]:ignore-path:_files' \ '--no-ignore[Lint ignored files too]' \ - '--no-warn-ignored[Do not warn about an ignored file that is named as an argument]' \ - '--stdin[Lint the code on standard input]' \ - '--stdin-filename[The file name that the code on standard input is linted as]:stdin-filename:_files' \ - '--quiet[Report errors only]' \ - '--max-warnings[Exit with 1 if there are more warnings than this]:max-warnings' \ - '--format[stylish (default), pretty, json, json-with-metadata, unix, github, agent, checkstyle, junit, gitlab, sarif]:format' \ - '-f[stylish (default), pretty, json, json-with-metadata, unix, github, agent, checkstyle, junit, gitlab, sarif]:format' \ + '--ext[Also lint files with these extensions]:ext' \ + '--stdin[Lint code from standard input]' \ + '--stdin-filename[The file name to use for code from standard input]:stdin-filename:_files' \ + '--list-files[Print the files that would be linted and exit]' \ + '--no-error-on-unmatched-pattern[Do not fail when an argument matches no file]' \ + '--cwd[Set the working directory]:cwd:_files -/' \ + '--format[pretty (default), stylish, json, json-with-metadata, unix, github, agent, checkstyle, junit, gitlab, sarif]:format' \ + '-f[pretty (default), stylish, json, json-with-metadata, unix, github, agent, checkstyle, junit, gitlab, sarif]:format' \ '--all[Show every problem (pretty and agent group identical problems above 50)]' \ '--output-file[Write the report to a file]:output-file:_files' \ '-o[Write the report to a file]:output-file:_files' \ + '--quiet[Report errors only]' \ + '--max-warnings[Exit with 1 if there are more warnings than this]:max-warnings' \ '--color[Always use colors]' \ '--no-color[Never use colors]' \ - '--no-inline-config[Ignore eslint-disable and other configuration comments]' \ - '--report-unused-disable-directives[Report eslint-disable comments that disable nothing, as errors]' \ - '--report-unused-disable-directives-severity[The same, as off, warn or error]:report-unused-disable-directives-severity' \ - '--report-unused-inline-configs[Report configuration comments that change nothing]:report-unused-inline-configs' \ - '--suppress-all[Tolerate the errors that there are now\: write them to eslint-suppressions.json]' \ - '--suppress-rule[The same for the errors of a rule]:suppress-rule' \ - '--suppressions-location[Another file than eslint-suppressions.json]:suppressions-location:_files' \ - '--prune-suppressions[Remove from that file what no longer occurs]' \ - '--pass-on-unpruned-suppressions[Do not fail if that file has what no longer occurs]' \ - '--no-error-on-unmatched-pattern[Do not fail if an argument matches no file]' \ - '--pass-on-no-patterns[Exit with 0 if there are no arguments, instead of linting .]' \ - '--exit-on-fatal-error[Exit with 2 if a file cannot be parsed]' \ - '--allow-unsupported[Only warn about rules and files of the configuration that cannot be linted yet]' \ - '--native-plugin-rules[The plugins whose built-in rules run in place of the installed package\: import,react-hooks (default\: all)]:native-plugin-rules' \ - '--no-native-plugin-rules[Run the rules of the installed plugins, in JavaScript, not the built-in ones]' \ - '--print-config[Print the configuration of a file, and lint nothing]:print-config:_files' \ - '--type-aware[Run the rules that need types, whatever the configuration says]' \ + '--timing[Print how long each phase took]' \ + '--fix[Fix problems and write the files]' \ + '--fix-dry-run[Fix without writing. The json format has the fixed code]' \ + '--fix-type[Only apply fixes from rules of these types\: directive, problem, suggestion, layout]:fix-type' \ + '--fix-suggestions[Apply suggestions]' \ + '--fix-dangerously[Apply every fix and suggestion, even those that can change behavior]' \ + '--type-aware[Also run the rules that need types]' \ '--no-type-aware[Skip the rules that need types]' \ - '--infer-globals[The globals of a file are what the types of its project declare, too]' \ - '--no-infer-globals[Without a configuration file\: those of browsers and of Node.js]' \ - '--project[The tsconfig.json for the rules that need types]:project:_files' \ - '-p[The tsconfig.json for the rules that need types]:project:_files' \ - '--type-check[With --type-aware\: report type errors too]' \ - '--allow[Turn a rule off, or with an .oxlintrc.json a category]:allow' \ - '-A[Turn a rule off, or with an .oxlintrc.json a category]:allow' \ - '--warn[The same\: make it a warning]:warn' \ - '-W[The same\: make it a warning]:warn' \ - '--deny[The same\: make it an error]:deny' \ - '-D[The same\: make it an error]:deny' \ + '--project[Use this tsconfig.json for the rules that need types (alias\: --tsconfig)]:project:_files' \ + '-p[Use this tsconfig.json for the rules that need types (alias\: --tsconfig)]:project:_files' \ + '--type-check[With --type-aware, report type errors too]' \ + '--infer-globals[Also treat what the project'"'"'s types declare as globals]' \ + '--no-infer-globals[Do not take globals from the project'"'"'s types]' \ + '--threads[Number of threads (default\: one per CPU core)]:threads' \ + '--version[Print the version of Bun]' \ + '-v[Print the version of Bun]' \ + '--help[Print the common flags. With --all, every flag]' \ + '-h[Print the common flags. With --all, every flag]' \ + '--help-all[The same as --help --all]' \ + '--parser-options[Set parser options\: --parser-options projectService\:true]:parser-options' \ + '--parser[Parse files with this package]:parser' \ + '--plugin[Load these plugins]:plugin' \ + '--native-plugin-rules[Use the built-in rules for these plugins only\: import,react-hooks (default\: all)]:native-plugin-rules' \ + '--no-native-plugin-rules[Run plugin rules from the installed packages, in JavaScript]' \ + '--report-unused-disable-directives-severity[Report unused eslint-disable comments as off, warn or error]:report-unused-disable-directives-severity' \ + '--report-unused-inline-configs[Report config comments that change nothing]:report-unused-inline-configs' \ + '--no-warn-ignored[Do not warn when an argument names an ignored file]' \ + '--suppress-all[Record the current errors in eslint-suppressions.json and stop reporting them]' \ + '--suppress-rule[The same, for the errors of one rule]:suppress-rule' \ + '--suppressions-location[Use this file instead of eslint-suppressions.json]:suppressions-location:_files' \ + '--prune-suppressions[Remove recorded errors that no longer occur from that file]' \ + '--pass-on-unpruned-suppressions[Do not fail when that file has errors that no longer occur]' \ + '--pass-on-no-patterns[Exit with 0 when there are no arguments, instead of linting .]' \ + '--exit-on-fatal-error[Exit with 2 if a file cannot be parsed]' \ + '--concurrency[The same as --threads]:concurrency' \ + '--env-info[Print the versions of Bun, ESLint and TypeScript]' \ + '--no-eslintrc[ESLint 8\: do not look for .eslintrc files]' \ + '--env[ESLint 8\: turn on these environments]:env' \ + '--rulesdir[ESLint 8\: load rules from these directories]:rulesdir:_files' \ + '--resolve-plugins-relative-to[ESLint 8\: look for plugins from this directory]:resolve-plugins-relative-to:_files' \ + '--allow[Turn off a rule or a category]:allow' \ + '-A[Turn off a rule or a category]:allow' \ + '--warn[Make a rule or a category a warning]:warn' \ + '-W[Make a rule or a category a warning]:warn' \ + '--deny[Make a rule or a category an error]:deny' \ + '-D[Make a rule or a category an error]:deny' \ '--deny-warnings[Exit with 1 if there are warnings]' \ '--silent[Print no problems]' \ - '--ignore-path[A file with patterns to ignore, in place of .eslintignore]:ignore-path:_files' \ - '--disable-nested-config[Use the configuration file of the working directory for every file]' \ - '--fix-suggestions[Apply suggestions, as oxlint does]' \ - '--fix-dangerously[Apply every fix and suggestion, as oxlint does]' \ - '--init[Write an .oxlintrc.json with the defaults of oxlint]' \ - '--rules[List the rules that are built in]' \ - '--threads[Number of threads (default\: one per CPU core)]:threads' \ - '--timing[Print how long each phase took]' \ - '--cwd[Set the working directory]:cwd:_files -/' \ - '--help[Print this help menu]' \ - '-h[Print this help menu]' && + '--disable-nested-config[Use the config file in the working directory for every file]' \ + '--import-plugin[Turn on the import plugin]' \ + '--react-plugin[Turn on the react plugin]' \ + '--jsdoc-plugin[Turn on the jsdoc plugin]' \ + '--jest-plugin[Turn on the jest plugin]' \ + '--vitest-plugin[Turn on the vitest plugin]' \ + '--jsx-a11y-plugin[Turn on the jsx-a11y plugin]' \ + '--nextjs-plugin[Turn on the nextjs plugin]' \ + '--react-perf-plugin[Turn on the react-perf plugin]' \ + '--promise-plugin[Turn on the promise plugin]' \ + '--node-plugin[Turn on the node plugin]' \ + '--vue-plugin[Turn on the vue plugin]' \ + '--disable-unicorn-plugin[Turn off the unicorn plugin]' \ + '--disable-oxc-plugin[Turn off the oxc plugin]' \ + '--disable-typescript-plugin[Turn off the typescript plugin]' && ret=0 } @@ -842,24 +871,11 @@ _bun_format_completion() { _arguments -s -C \ '1: :->cmd1' \ '*: :_files' \ - '--check[Do not write. Exit with 1 if a file is not formatted]' \ - '-c[Do not write. Exit with 1 if a file is not formatted]' \ - '--list-different[Do not write. Print the files that are not formatted, and exit with 1 if there are any]' \ - '-l[Do not write. Print the files that are not formatted, and exit with 1 if there are any]' \ - '--stdin-filepath[Format standard input as that file, and print the result]:stdin-filepath:_files' \ - '--config[Use this configuration file instead of looking for one]:config:_files' \ - '--no-config[Do not look for a configuration file]' \ - '--disable-nested-config[Use the configuration file of the working directory for every file]' \ + '--config[Use this config file instead of looking for one]:config:_files' \ + '--no-config[Do not look for a config file]' \ '--no-editorconfig[Do not read .editorconfig]' \ - '--config-precedence[cli-override (default), file-override, or prefer-file]:config-precedence' \ - '--ignore-path[Files with patterns to ignore (default\: .gitignore and .prettierignore)]:ignore-path:_files' \ - '--ignore-pattern[Ignore the files that match, as a line of .prettierignore does]:ignore-pattern' \ - '--with-node-modules[Format files in node_modules too]' \ - '--no-error-on-unmatched-pattern[Do not fail if an argument matches no file]' \ - '--ignore-unknown[Say nothing about a file that there is no parser for]' \ - '-u[Say nothing about a file that there is no parser for]' \ - '--find-config-path[Print the configuration file of a file, and format nothing]:find-config-path:_files' \ - '--log-level[silent, error, warn, log (default), or debug]:log-level' \ + '--init[Add a \[format\] section to bunfig.toml]' \ + '--doctor[Show which files are formatted natively and which packages you can remove]' \ '--print-width[The line length to wrap at (default\: 80)]:print-width' \ '--tab-width[Spaces per indentation level (default\: 2)]:tab-width' \ '--use-tabs[Indent with tabs]' \ @@ -868,29 +884,69 @@ _bun_format_completion() { '--jsx-single-quote[Use single quotes in JSX]' \ '--quote-props[as-needed (default), consistent, or preserve]:quote-props' \ '--trailing-comma[all (default), es5, or none]:trailing-comma' \ - '--no-bracket-spacing[No spaces between the braces of an object literal]' \ + '--no-bracket-spacing[No spaces inside the braces of an object literal]' \ '--bracket-same-line[Put the closing bracket of a multi-line element at the end of the last line]' \ '--arrow-parens[always (default) or avoid]:arrow-parens' \ '--object-wrap[preserve (default) or collapse]:object-wrap' \ '--single-attribute-per-line[One attribute per line in HTML, Vue, and JSX]' \ '--html-whitespace-sensitivity[css (default), strict, or ignore]:html-whitespace-sensitivity' \ - '--vue-indent-script-and-style[Indent what is in the script and style tags of Vue files]' \ + '--vue-indent-script-and-style[Indent the code in the script and style tags of Vue files]' \ '--prose-wrap[preserve (default), always, or never]:prose-wrap' \ '--embedded-language-formatting[auto (default) or off]:embedded-language-formatting' \ '--end-of-line[lf (default), crlf, cr, or auto]:end-of-line' \ - '--require-pragma[Only format files that start with a comment that has @format or @prettier]' \ - '--check-ignore-pragma[Do not format files that start with a comment that has @noformat or @noprettier]' \ - '--insert-pragma[Put a comment with @format at the top of the files that are formatted]' \ + '--no-insert-final-newline[Do not end files with a newline]' \ + '--sort-imports[Sort and group imports]' \ + '--sort-tailwindcss[Sort Tailwind CSS classes]' \ + '--sort-package-json[Sort the keys of package.json]' \ + '--jsdoc[Format JSDoc comments]' \ + '--staged[Only format files that are staged in git, and stage them again]' \ + '--changed[Only format files that differ from a branch or commit (default\: the default branch)]' \ + '--stdin-filepath[Format standard input as this file and print the result]:stdin-filepath:_files' \ + '--ignore-path[Read ignore patterns from these files (default\: .gitignore and .prettierignore)]:ignore-path:_files' \ + '--ignore-pattern[Skip files that match this pattern]:ignore-pattern' \ + '--with-node-modules[Format files in node_modules too]' \ + '--toml[Format TOML files too]' \ + '--no-error-on-unmatched-pattern[Do not fail when an argument matches no file]' \ + '--ignore-unknown[Ignore files in an unknown language]' \ + '-u[Ignore files in an unknown language]' \ + '--allow-unsupported[Warn instead of failing when the language of a file is not supported]' \ + '--list-files[Print the files that would be formatted and exit]' \ + '--cwd[Set the working directory]:cwd:_files -/' \ + '--check[Do not write. Exit with 1 if a file needs formatting]' \ + '-c[Do not write. Exit with 1 if a file needs formatting]' \ + '--list-different[Do not write. Print the files that need formatting and exit with 1 if there are any]' \ + '-l[Do not write. Print the files that need formatting and exit with 1 if there are any]' \ + '--log-level[silent, error, warn, log (default), or debug]:log-level' \ + '--format[With --doctor, print the report as json]:format' \ + '-f[With --doctor, print the report as json]:format' \ + '--color[Always use colors]' \ + '--no-color[Never use colors]' \ + '--timing[Print how long each phase took]' \ + '--threads[Number of threads (default\: one per CPU core)]:threads' \ + '--no-verify[Skip the check that formatted code still means the same]' \ + '--version[Print the version of Bun]' \ + '-v[Print the version of Bun]' \ + '--help[Print the common flags. With --all, every flag]' \ + '-h[Print the common flags. With --all, every flag]' \ + '--all[With --help, print every flag]' \ + '--help-all[The same as --help --all]' \ + '--write[Write the files. This is the default]' \ + '-w[Write the files. This is the default]' \ + '--config-precedence[cli-override (default), file-override, or prefer-file]:config-precedence' \ + '--find-config-path[Print the path of the config for a file and exit]:find-config-path:_files' \ + '--parser[Read every file with this parser]:parser' \ + '--plugin[Load these plugins]:plugin' \ + '--require-pragma[Only format files that start with a @format or @prettier comment]' \ + '--check-ignore-pragma[Skip files that start with a @noformat or @noprettier comment]' \ + '--insert-pragma[Put a @format comment at the top of formatted files]' \ '--range-start[Only format the statements from this offset on]:range-start' \ '--range-end[Only format the statements up to this offset]:range-end' \ - '--threads[Number of threads (default\: one per CPU core)]:threads' \ - '--allow-unsupported[Only warn about files in a language that is not supported]' \ - '--flavor[Without a configuration file, format like prettier or like oxfmt]:flavor' \ - '--init[Write an .oxfmtrc.json, with which files are formatted like oxfmt does]' \ - '--timing[Print how long each phase took]' \ - '--cwd[Set the working directory]:cwd:_files -/' \ - '--help[Print this help menu]' \ - '-h[Print this help menu]' && + '--cursor-offset[With --stdin-filepath, print on stderr where this offset ends up]:cursor-offset' \ + '--experimental-ternaries[Put the question mark of a ternary after the condition]' \ + '--experimental-operator-position[end (default) or start]:experimental-operator-position' \ + '--jsx-bracket-same-line[The old name of --bracket-same-line]' \ + '--flavor[Without a config file, format like prettier or like oxfmt]:flavor' \ + '--disable-nested-config[Use the config file in the working directory for every file]' && ret=0 } diff --git a/docs/docs.json b/docs/docs.json index 7501590a5d37..2a1311b53aba 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -81,7 +81,7 @@ { "group": "Linter & Formatter", "icon": "sparkles", - "pages": ["/runtime/lint", "/runtime/format", "/format/prettier", "/format/oxfmt"] + "pages": ["/runtime/lint", "/lint/rules/index", "/lint/plugins", "/runtime/format"] }, { "group": "File & Module System", @@ -314,7 +314,6 @@ } ] }, - { "tab": "Guides", "icon": "map", @@ -505,6 +504,16 @@ "/guides/test/svelte-test" ] }, + { + "group": "Linter & Formatter", + "icon": "sparkles", + "pages": [ + "/guides/lint/migrate-from-eslint", + "/guides/lint/migrate-from-oxlint", + "/guides/format/migrate-from-prettier", + "/guides/format/migrate-from-oxfmt" + ] + }, { "group": "Runtime & Debugging", "icon": "bug", diff --git a/docs/format/oxfmt.mdx b/docs/format/oxfmt.mdx deleted file mode 100644 index 41beb1ec5cdf..000000000000 --- a/docs/format/oxfmt.mdx +++ /dev/null @@ -1,103 +0,0 @@ ---- -title: "Migrate from oxfmt" -description: "Replace oxfmt with bun format: same configuration, same flags, same output" ---- - -[`bun format`](/runtime/format) reads your `.oxfmtrc.json` and prints what oxfmt 0.72 prints. Most projects only change the command. - - - - - ```json package.json icon="file-json" - { - "scripts": { - "format": "bun format", - "format:check": "bun format --check" - } - } - ``` - - Do this first. While the `format` script says `oxfmt`, `bun format` [runs that script](/runtime/format#notes). - - - - - ```bash terminal icon="terminal" - bun format --doctor - ``` - - `--doctor` reads your configuration and changes nothing. - - - - - ```bash terminal icon="terminal" - bun format --check - ``` - - ```txt - $ bun format --check - Checking formatting... - - All matched files use the correct format. - Finished in 3ms on 3 files using 8 threads. - ``` - - If you pin a version of oxfmt before 0.72, a few files change once, like when you upgrade oxfmt. - - - - - Replace `oxfmt`, `npx oxfmt`, and `bunx oxfmt` with `bun format`. - - - - - ```bash terminal icon="terminal" - bun remove oxfmt - ``` - - If you have no `.oxfmtrc.json`, add an empty one (`{}`) first. Without it, Bun only knows this is an oxfmt project from the `oxfmt` dependency. - - - - -| Instead of | Run | -| ----------------- | ---------------------- | -| `oxfmt` | `bun format` | -| `oxfmt --check .` | `bun format --check` | -| `oxfmt -c a.json` | `bun format -c a.json` | - -## What Bun reads - -- **Configuration files**: `.oxfmtrc.json`, `.oxfmtrc.jsonc`, and `oxfmt.config.ts`, with `overrides` and `ignorePatterns`. -- **vite-plus**: the `fmt` key of the nearest `vite.config.ts`, as `vp fmt` does. -- **Every option**, including `sortImports`, `sortPackageJson`, `sortTailwindcss`, `jsdoc`, and `insertFinalNewline`. -- **Comments**: `// oxfmt-ignore` and `// prettier-ignore`. -- **Flags**: every flag of oxfmt 0.72 except `--lsp` and `--migrate`. - -With an oxfmt configuration, Bun also follows oxfmt's defaults: - -- Lines wrap at 100 columns. -- Every `.gitignore` in the repository counts, not only the one in the current directory. -- `.prettierrc` files are not read. -- TOML files are formatted. -- What a run prints is what oxfmt prints. - -`sortTailwindcss` needs `tailwindcss` version 3 or 4 installed in the project. Bun asks it for the order of the classes. - -A `[format]` section in [`bunfig.toml`](/runtime/bunfig#formatter) overrides `.oxfmtrc.json`, like a flag does. - -## What differs - -If `bun format` prints something oxfmt 0.72 does not, that is a bug. [Open an issue](https://github.com/oven-sh/bun/issues) with the code. - -These differences are on purpose: - -- **The output is oxfmt 0.72's**, whichever version you had installed. -- **`--lsp` and `--migrate` are errors.** For editors, use [`bun lint --lsp`](/runtime/format#format-in-your-editor). -- **No Svelte.** With `svelte` in the configuration, Bun leaves `.svelte` files alone and exits with `2`. `--allow-unsupported` makes that a warning. -- **Errors and warnings are in Bun's words.** -- **A TOML file that is not valid is left alone**, with a warning. oxfmt rewrites the rest of the file. -- **A file that is not UTF-8 is formatted**, and the other bytes stay as they are. oxfmt exits with `2`. -- **It never damages a file.** Before writing, Bun checks that the result is the same program or document. If not, it leaves the file alone and reports it. diff --git a/docs/format/prettier.mdx b/docs/format/prettier.mdx deleted file mode 100644 index 5c09dac318ec..000000000000 --- a/docs/format/prettier.mdx +++ /dev/null @@ -1,106 +0,0 @@ ---- -title: "Migrate from Prettier" -description: "Replace Prettier with bun format: same configuration, same flags, same output" ---- - -[`bun format`](/runtime/format) reads your Prettier configuration and prints what Prettier 3 prints. Most projects only change the command. - - - - - ```json package.json icon="file-json" - { - "scripts": { - "format": "bun format", - "format:check": "bun format --check" - } - } - ``` - - Do this first. While the `format` script says `prettier`, `bun format` [runs that script](/runtime/format#notes). - - - - - ```bash terminal icon="terminal" - bun format --doctor - ``` - - `--doctor` reads your configuration and changes nothing. - - - - - ```bash terminal icon="terminal" - bun format --check - ``` - - ```txt - $ bun format --check - Checking formatting... - All matched files use Prettier code style! - ``` - - - - - Replace `prettier`, `npx prettier`, and `bunx prettier` with `bun format`. You can drop lint-staged for [`bun format --staged`](/runtime/format#format-before-you-commit). - - - - - ```bash terminal icon="terminal" - bun remove prettier - ``` - - Keep `prettier` if `--doctor` says it is still needed, or if your editor's Prettier extension uses it. - - - - -| Instead of | Run | -| -------------------------------- | ---------------------------------- | -| `prettier --write .` | `bun format` | -| `prettier --write src test` | `bun format src test` | -| `prettier --check .` | `bun format --check` | -| `prettier -l .` | `bun format -l` | -| `prettier --stdin-filepath a.ts` | `bun format --stdin-filepath a.ts` | - -## What Bun reads - -- **Configuration files**: `.prettierrc` in JSON, YAML, TOML, JavaScript, or TypeScript, `prettier.config.*`, and `"prettier"` in `package.json`. Each file uses the nearest one, and `overrides` work. -- **`.editorconfig`**, `.prettierignore`, and `.gitignore`. -- **Comments**: `// prettier-ignore`, `@format`, `@prettier`, `@noformat`, `@noprettier`. -- **Flags**: every flag of Prettier 3 except `--file-info` and `--support-info`. - -With a Prettier configuration, `--check` and `--list-different` print what Prettier prints, so scripts that read the output keep working. - -A `[format]` section in [`bunfig.toml`](/runtime/bunfig#formatter) overrides the Prettier configuration, like a flag does. - -## Plugins - -Bun does not load Prettier plugins. What happens depends on the plugin. - -- **Built in**: `@trivago/prettier-plugin-sort-imports`, `@ianvs/prettier-plugin-sort-imports`, and `prettier-plugin-organize-imports`, with every option. Keep the name in `plugins`. You can uninstall the package. -- **Sent to your installed Prettier**: the files of a plugin that adds a language, like `prettier-plugin-svelte` or `prettier-plugin-astro`. Keep `prettier` and every plugin in `plugins` installed. -- **Not supported**: any other plugin. Bun skips the files that configuration covers and exits with `2`. - -`--allow-unsupported` formats the skipped files without the plugin, and turns the error into a warning. - -Files that go to your installed Prettier are formatted at Prettier's speed. If `prettier` or the plugin is missing, Bun leaves those files alone, formats the rest, and exits with `2`. - -## What differs - -If `bun format` prints something Prettier 3 does not, that is a bug. [Open an issue](https://github.com/oven-sh/bun/issues) with the code. - -These differences are on purpose: - -- **It writes by default.** `--write` is accepted and changes nothing. -- **No arguments means the current directory.** -- **It only prints the files it changed**, then a summary on stderr. -- **An unknown flag is an error.** Prettier warns and goes on. -- **Exit code `2` means something went wrong**, like a syntax error or a skipped file. Prettier uses `1` for some of these. -- **It never damages a file.** Before writing, Bun checks that the result is the same program or document. If not, it leaves the file alone and reports it. This covers a few inputs where Prettier drops code, such as `` in a Handlebars template. -- **Bytes that are not UTF-8 stay as they are.** Prettier replaces them. -- **It does not write through a symlink that leaves the repository.** -- **`--cache` has no effect.** Every run formats every file. diff --git a/docs/guides/format/migrate-from-oxfmt.mdx b/docs/guides/format/migrate-from-oxfmt.mdx new file mode 100644 index 000000000000..2f0b258c51e0 --- /dev/null +++ b/docs/guides/format/migrate-from-oxfmt.mdx @@ -0,0 +1,109 @@ +--- +title: Migrate from oxfmt to bun format +sidebarTitle: Migrate from oxfmt +mode: center +--- + +`bun format` reads your `.oxfmtrc.json` and prints what oxfmt 0.72 prints. In most projects you only change the command. + +```json package.json icon="file-json" +{ + "scripts": { + "format": "oxfmt", // [!code --] + "format:check": "oxfmt --check", // [!code --] + "format": "bun format", // [!code ++] + "format:check": "bun format --check" // [!code ++] + } +} +``` + +Flags and arguments stay the same. Change CI and Git hooks too. + +Edit the script first. If `package.json` has a `format` script, `bun format` runs that script, so you'd still get oxfmt. + +--- + +Check that nothing changes. With an oxfmt config, a run prints what oxfmt prints. + +```sh terminal icon="terminal" +bun format --check +``` + +```txt +$ bun format --check +Checking formatting... + +All matched files use the correct format. +Finished in 3ms on 3 files using 8 threads. +``` + +If you're on a version of oxfmt before 0.72, a few files change once, like when you upgrade oxfmt. + +--- + +Run `--doctor` to see what Bun does with your config. It doesn't format or write anything. + +```sh terminal icon="terminal" +bun format --doctor +``` + +```txt +$ bun format --doctor +Using .oxfmtrc.json (oxfmt config) + + 3 files are formatted natively + +You can remove: oxfmt +``` + +--- + +Uninstall oxfmt. + +```sh terminal icon="terminal" +bun remove oxfmt +``` + +No `.oxfmtrc.json`? Add one with `{}` in it first. Without a config file, the `oxfmt` dependency is how Bun knows to format like oxfmt and not like Prettier. + +--- + +Bun reads everything oxfmt reads: + +- `.oxfmtrc.json`, `.oxfmtrc.jsonc`, and `oxfmt.config.ts`, with `overrides` and `ignorePatterns` +- The `fmt` key of `vite.config.ts`, if you use vite-plus +- Every option, including `sortImports`, `sortPackageJson`, `sortTailwindcss`, and `jsdoc` +- `// oxfmt-ignore` and `// prettier-ignore` comments +- Every flag of oxfmt 0.72 except `--lsp` and `--migrate` + +It also follows oxfmt's defaults: lines wrap at 100 columns, every `.gitignore` in the repository counts, TOML files are formatted, and `.prettierrc` is ignored. + +--- + +If your editor uses oxfmt's language server, switch it to [Bun's](/runtime/format#editors). + +```sh terminal icon="terminal" +oxfmt --lsp # [!code --] +bun lint --lsp # [!code ++] +``` + +--- + +## Differences from oxfmt + +If `bun format` and oxfmt 0.72 print different code, that's a bug. [Open an issue](https://github.com/oven-sh/bun/issues). These differences are intentional: + +- **The output is oxfmt 0.72's**, whatever version you had installed. +- **No `--lsp` or `--migrate`.** +- **No Svelte.** With `svelte` in the config, Bun skips `.svelte` files and exits with code `2`. `--allow-unsupported` makes that a warning. +- **Errors and warnings are worded differently.** +- **An invalid TOML file is left alone**, with a warning. oxfmt rewrites the parts it can read. +- **A file that isn't valid UTF-8 is still formatted**, and the invalid bytes are kept. oxfmt exits with code `2`. +- **A file is verified before it's written.** If the result isn't the same program or document, Bun leaves the file alone and reports it. + +--- + +See also: + +- [`bun format`](/runtime/format) +- [Migrate from oxlint to bun lint](/guides/lint/migrate-from-oxlint) diff --git a/docs/guides/format/migrate-from-prettier.mdx b/docs/guides/format/migrate-from-prettier.mdx new file mode 100644 index 000000000000..a7d53f7ea345 --- /dev/null +++ b/docs/guides/format/migrate-from-prettier.mdx @@ -0,0 +1,165 @@ +--- +title: Migrate from Prettier to bun format +sidebarTitle: Migrate from Prettier +mode: center +--- + +`bun format` reads your `.prettierrc` and prints what Prettier 3 prints. In most projects you only change the command. + +```json package.json icon="file-json" +{ + "scripts": { + "format": "prettier --write .", // [!code --] + "format:check": "prettier --check .", // [!code --] + "format": "bun format", // [!code ++] + "format:check": "bun format --check" // [!code ++] + } +} +``` + +`bun format` writes by default and formats the current directory, so `--write .` goes away. Other flags and arguments stay the same. Change CI and Git hooks too. + +Edit the script first. If `package.json` has a `format` script, `bun format` runs that script, so you'd still get Prettier. + +--- + +Check that nothing changes. With a Prettier config, `--check` prints what Prettier prints. + +```sh terminal icon="terminal" +bun format --check +``` + +```txt +$ bun format --check +Checking formatting... +All matched files use Prettier code style! +``` + +--- + +Run `--doctor` to see what Bun does with your config. It doesn't format or write anything. + +```sh terminal icon="terminal" +bun format --doctor +``` + +```txt +$ bun format --doctor +Using .prettierrc (Prettier config) + + 4 files are formatted natively + +Built-in plugins: @ianvs/prettier-plugin-sort-imports + +You can remove: @ianvs/prettier-plugin-sort-imports, prettier +``` + +--- + +Uninstall what `--doctor` says you can remove. Leave `.prettierrc` as it is, including `plugins`. + +```sh terminal icon="terminal" +bun remove prettier @ianvs/prettier-plugin-sort-imports +``` + +Keep `prettier` if your editor's Prettier extension uses it, or switch the editor to [Bun's formatter](/runtime/format#editors). + +--- + +Bun reads everything Prettier reads: + +- `.prettierrc` in JSON, YAML, TOML, JavaScript, or TypeScript, `prettier.config.*`, and `"prettier"` in `package.json`, with `overrides` +- `.editorconfig`, `.prettierignore`, and `.gitignore` +- `// prettier-ignore` comments and the `@format` and `@noformat` pragmas +- Every flag of Prettier 3 except `--file-info` and `--support-info` + +--- + +Bun doesn't load Prettier plugins. The three that sort imports are built in, with all their options: + +- `@trivago/prettier-plugin-sort-imports` +- `@ianvs/prettier-plugin-sort-imports` +- `prettier-plugin-organize-imports` + +--- + +If you use `prettier-plugin-tailwindcss`, take it out of `.prettierrc` and turn on Bun's own [class sorting](/runtime/format#sort-tailwind-css-classes). + +```json .prettierrc icon="file-json" +{ + "plugins": ["prettier-plugin-tailwindcss"], // [!code --] + "tailwindFunctions": ["clsx"], // [!code --] + "semi": false +} +``` + +```toml bunfig.toml icon="settings" +[format.sortTailwindcss] +functions = ["clsx"] +``` + +--- + +Svelte, Astro, and other languages that come from a plugin go to your installed Prettier. Keep `prettier` and every plugin in `plugins` installed. `--doctor` shows which files those are. + +```txt +Using .prettierrc (Prettier config) + + 3 files are formatted natively + 1 file goes to your installed Prettier: 1 *.svelte + +Built-in plugins: @ianvs/prettier-plugin-sort-imports +Plugins that use your installed Prettier: prettier-plugin-svelte + +Still needed: @ianvs/prettier-plugin-sort-imports, prettier, prettier-plugin-svelte (some files go to them) +``` + +--- + +With any other plugin, Bun skips the files that config covers and exits with code `2`. A plugin could print them differently, and Bun won't guess. Pass `--allow-unsupported` to format them without the plugin. + +```txt +[error] 3 files were not formatted because the Prettier config uses plugins that bun format does not include: prettier-plugin-brace-style +[error] Use --allow-unsupported to format them without the plugins. +``` + +--- + +You don't have to keep `.prettierrc`. The same options work in [`bunfig.toml`](/runtime/format#options). + +```json .prettierrc icon="file-json" +{ + "semi": false, // [!code --] + "singleQuote": true // [!code --] +} +``` + +```toml bunfig.toml icon="settings" +[format] +semi = false +singleQuote = true +``` + +--- + +## Differences from Prettier + +If `bun format` and Prettier 3 print different code, that's a bug. [Open an issue](https://github.com/oven-sh/bun/issues). These differences are intentional: + +- **It writes by default.** `--write` is accepted and does nothing. +- **No arguments means the current directory.** +- **Only changed files are listed**, followed by a summary on stderr. +- **An unknown flag is an error.** Prettier warns and continues. +- **`--cache` does nothing.** Every run formats every file. +- **Exit code `2` means something went wrong**, like a syntax error or a skipped file. Prettier uses `1` for some of these. +- **A file is verified before it's written.** If the result isn't the same program or document, Bun leaves the file alone and reports it. That covers a few inputs where Prettier drops code, like `` in a Handlebars template. +- **Bytes that aren't valid UTF-8 are kept.** Prettier replaces them. +- **Symlinks that lead out of the repository aren't written through.** +- **`console.log` in your config file is hidden** unless the config fails to load. + +--- + +See also: + +- [`bun format`](/runtime/format) +- [Migrate from ESLint to bun lint](/guides/lint/migrate-from-eslint) diff --git a/docs/guides/lint/migrate-from-eslint.mdx b/docs/guides/lint/migrate-from-eslint.mdx new file mode 100644 index 000000000000..58ce98fb0f5c --- /dev/null +++ b/docs/guides/lint/migrate-from-eslint.mdx @@ -0,0 +1,152 @@ +--- +title: Migrate from ESLint to bun lint +sidebarTitle: Migrate from ESLint +mode: center +--- + +`bun lint` reads your `eslint.config.js` and prints the same report as ESLint 10. In most projects you only change the command. + +```json package.json icon="file-json" +{ + "scripts": { + "lint": "eslint --max-warnings 0", // [!code --] + "lint": "bun lint --max-warnings 0" // [!code ++] + } +} +``` + +Flags and arguments stay the same: `eslint --fix src` becomes `bun lint --fix src`. Change CI, lint-staged, and Git hooks too. + +Edit the script first. If `package.json` has a `lint` script, `bun lint` runs that script, so you'd still get ESLint. + +--- + +Run it. You get the same rules, messages, and exit code as with `eslint`. + +```sh terminal icon="terminal" +bun run lint +``` + +```txt +$ bun lint --max-warnings 0 + +/app/src/index.ts + 1:1 warning Open an issue instead of leaving a TODO local/no-todo + 3:7 error 'fallback' is never reassigned. Use 'const' instead prefer-const + 4:27 error Invalid typeof comparison value valid-typeof + +✖ 3 problems (2 errors, 1 warning) + 1 error and 0 warnings potentially fixable with the `--fix` option. + +Linted 3 files [115.93ms] +error: script "lint" exited with code 1 +``` + +--- + +Run `--doctor` to see what Bun does with your config. It doesn't lint or write anything. + +```sh terminal icon="terminal" +bun lint --doctor +``` + +```txt +$ bun lint --max-warnings 0 --doctor +Using eslint.config.js (ESLint flat config) + + 3 files to lint + + 25 rules run natively + 1 rule runs in JavaScript + lint/plugin.js 1 + +You can remove: eslint +Still needed: typescript-eslint (eslint.config.js loads it) +``` + +- **Natively**: the rule is built into Bun. That covers ESLint's core rules, typescript-eslint, `react`, `react-hooks`, `import`, `n`, and `prettier/prettier`. +- **In JavaScript**: Bun loads the plugin and runs it like ESLint does. It works, but it's slower. See [ESLint plugins](/lint/plugins). + +--- + +Uninstall what `--doctor` says you can remove. Keep every package that `eslint.config.js` imports, because Bun runs that file. + +```sh terminal icon="terminal" +bun remove eslint +``` + +--- + +Type-aware rules need no changes. If your config asks for types, they run. + +```js eslint.config.js icon="/icons/javascript.svg" +import tseslint from "typescript-eslint"; + +export default tseslint.config(...tseslint.configs.recommendedTypeChecked, { + languageOptions: { parserOptions: { projectService: true } }, +}); +``` + +The types come from Bun's built-in type checker ([`bun check`](/runtime/check)), not from the `typescript` package. It behaves like TypeScript 7. + +--- + +Still on ESLint 8? `.eslintrc.*`, `eslintConfig` in `package.json`, and `.eslintignore` work when there is no `eslint.config.js`. So do ESLint 8's flags. + +```sh terminal icon="terminal" +eslint --ext .js,.ts,.tsx src # [!code --] +bun lint --ext .js,.ts,.tsx src # [!code ++] +``` + +--- + +If your config asks for something Bun can't do, Bun lints everything else, tells you what it skipped, and exits with code `2`. + +```txt +note: 1 deprecated formatting rule did not run: semi + Use bun format instead. +error: 1 rule did not run because rules that run in JavaScript cannot use type information: local/typed + Everything else was linted. Use --allow-unsupported to make this a warning. +``` + +There are two such cases: a JavaScript rule that needs types, and files in [other languages](/lint/plugins#other-languages) when the `eslint` package isn't installed. Pass `--allow-unsupported` to turn the error into a warning. + +--- + +If the project also has an oxlint config, tell Bun which one to use. + +```json package.json icon="file-json" +{ + "scripts": { + "lint": "oxlint && eslint", // [!code --] + "lint": "bun lint --flavor=oxlint && bun lint --flavor=eslint" // [!code ++] + } +} +``` + +--- + +## Differences from ESLint + +If `bun lint` and ESLint 10 disagree about a rule, that's a bug. [Open an issue](https://github.com/oven-sh/bun/issues). These differences are intentional: + +- **Built-in rules have a fixed version**, whatever you have installed: ESLint 10.12, typescript-eslint 8.71, eslint-plugin-react 7.37, react-hooks 7.1, import 2.32, n 18.4. [`--no-native-plugin-rules`](/lint/plugins#run-the-installed-package-instead) runs your installed plugins instead. +- **Deprecated formatting rules don't run**: `semi`, `quotes`, `indent`, and so on. Use [`bun format`](/runtime/format) or `@stylistic/eslint-plugin`. +- **No custom formatters.** `-f` only takes built-in formats such as `stylish`, `json`, `unix`, `junit`, and `sarif`. +- **No `--inspect-config` or `--mcp`.** +- **`--cache` does nothing.** Every run lints every file. +- **No config file is not an error.** Bun uses [its defaults](/runtime/lint#default-rules). +- **Syntax errors can be worded differently.** Bun's parser replaces `espree`, `@typescript-eslint/parser`, and `@babel/eslint-parser`. +- **A type-aware rule without type information is skipped**, with a warning. typescript-eslint throws. +- **A file that no `tsconfig.json` includes is still linted**, with the nearest `tsconfig.json`. ESLint stops. +- **`--fix` never writes a file that no longer parses.** Bun drops those fixes and warns. +- **`console.log` in your config file is hidden** unless the config fails to load. +- **One extra line on stderr** with the number of files and the time. + +--- + +See also: + +- [`bun lint`](/runtime/lint) +- [ESLint plugins](/lint/plugins) +- [Migrate from Prettier to bun format](/guides/format/migrate-from-prettier) diff --git a/docs/guides/lint/migrate-from-oxlint.mdx b/docs/guides/lint/migrate-from-oxlint.mdx new file mode 100644 index 000000000000..9a7f9a8f26d0 --- /dev/null +++ b/docs/guides/lint/migrate-from-oxlint.mdx @@ -0,0 +1,131 @@ +--- +title: Migrate from oxlint to bun lint +sidebarTitle: Migrate from oxlint +mode: center +--- + +`bun lint` reads your `.oxlintrc.json` and reports the same problems as oxlint 1.87. In most projects you only change the command. + +```json package.json icon="file-json" +{ + "scripts": { + "lint": "oxlint --deny-warnings", // [!code --] + "lint": "bun lint --deny-warnings" // [!code ++] + } +} +``` + +Flags and arguments stay the same: `oxlint -D no-console src` becomes `bun lint -D no-console src`. Change CI, lint-staged, and Git hooks too. + +Edit the script first. If `package.json` has a `lint` script, `bun lint` runs that script, so you'd still get oxlint. + +--- + +Run it. The problems, messages, help texts, and exit code are oxlint's. + +```sh terminal icon="terminal" +bun run lint +``` + +```txt +$ bun lint --deny-warnings +1 | export function label(value: unknown) { +2 | console.log(value); + ^ +warn: Unexpected console statement. no-console + at src/index.ts:2:3 + +note: Delete this console statement. + +1 | export function label(value: unknown) { +2 | console.log(value); +3 | return typeof value === "strng" ? value : "none"; + ^ +error: Invalid `typeof` comparison value. valid-typeof + at src/index.ts:3:27 + +note: Did you mean `"string"`? + +Linted 1 file [18.39ms] +error: script "lint" exited with code 1 +``` + +Only the default layout is Bun's. Every other format, such as `-f unix`, `-f json`, or `-f github`, prints exactly what oxlint prints. + +--- + +Run `--doctor` to see what Bun does with your config. It doesn't lint or write anything. + +```sh terminal icon="terminal" +bun lint --doctor +``` + +--- + +Uninstall oxlint. + +```sh terminal icon="terminal" +bun remove oxlint oxlint-tsgolint +``` + +`import { defineConfig } from "oxlint"` in an `oxlint.config.ts` keeps working without the package. + +If the project has no config file, run `oxlint --init` first. Without one, Bun only uses oxlint's defaults while `package.json` depends on `oxlint`. After that you get [Bun's defaults](/runtime/lint#default-rules). + +--- + +`--type-aware` and `--type-check` work without `oxlint-tsgolint`. The types come from Bun's built-in type checker ([`bun check`](/runtime/check)). + +```sh terminal icon="terminal" +oxlint --type-aware # [!code --] +bun lint --type-aware # [!code ++] +``` + +--- + +`jsPlugins` work. Bun runs them in JavaScript. See [ESLint plugins](/lint/plugins). + +```json .oxlintrc.json icon="file-json" +{ + "jsPlugins": ["./lint/plugin.js"], + "rules": { "local/no-todo": "warn" } +} +``` + +--- + +If the project also has an ESLint config, tell Bun which one to use. + +```json package.json icon="file-json" +{ + "scripts": { + "lint": "oxlint && eslint", // [!code --] + "lint": "bun lint --flavor=oxlint && bun lint --flavor=eslint" // [!code ++] + } +} +``` + +--- + +## Differences from oxlint + +If `bun lint` and oxlint 1.87 disagree about a rule, that's a bug. [Open an issue](https://github.com/oven-sh/bun/issues). These differences are intentional: + +- **The rules are oxlint 1.87's**, whatever version you had installed. +- **No `jsdoc/*` or `vue/*` rules.** If you turn one on by name, the run exits with code `2`. If you only list the plugin, you get a note. +- **An unknown rule doesn't stop the run.** oxlint rejects the config. Bun runs the other rules, then exits with code `2`. `--allow-unsupported` turns that into a warning. +- **Output is sorted** by file and line. oxlint prints files as its threads finish. +- **Syntax errors can be worded differently.** oxlint also rejects a few programs that Bun lints, such as `let a; let a;`. +- **Patterns in rule options are JavaScript regular expressions.** oxlint uses Rust's `regex` crate. +- **Control flow rules follow ESLint** where oxlint differs: `no-unreachable`, `no-fallthrough`, `getter-return`, `array-callback-return`, `constructor-super`, `no-this-before-super`, `no-useless-assignment`. +- **`--fix` never writes a file that no longer parses.** Bun leaves the file alone and warns. +- **`--init` adds `[lint]` to `bunfig.toml`.** With an oxlint config in place it does nothing. +- **`--lsp` starts [Bun's language server](/runtime/lint#editors)**, not oxlint's. + +--- + +See also: + +- [`bun lint`](/runtime/lint) +- [ESLint plugins](/lint/plugins) +- [Migrate from oxfmt to bun format](/guides/format/migrate-from-oxfmt) diff --git a/docs/index.mdx b/docs/index.mdx index 81d57656e641..1ffd101ee977 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -79,6 +79,8 @@ Bun ships as a single, dependency-free binary and includes a runtime, package ma - Test Runner: Jest-compatible, TypeScript-first tests with snapshots, DOM, and watch mode. - Bundler: Native bundling for JS/TS/JSX with splitting, plugins, and HTML imports. - Type Checker: Check a TypeScript project on every CPU core with [`bun check`](/runtime/check). +- Linter: Find bugs with 1,000 built-in rules and no configuration with [`bun lint`](/runtime/lint). +- Formatter: Format JavaScript, TypeScript, CSS, JSON, Markdown, and more with [`bun format`](/runtime/format). Explore each area using the cards above. Each section is structured with an overview, short examples, reference, and best practices for fast scanning and deep dives. diff --git a/docs/lint/plugins.mdx b/docs/lint/plugins.mdx new file mode 100644 index 000000000000..62a2e45fc2ed --- /dev/null +++ b/docs/lint/plugins.mdx @@ -0,0 +1,162 @@ +--- +title: "ESLint plugins" +description: "Run ESLint plugins in bun lint, and write your own rules" +--- + +`bun lint` runs ESLint plugins, both from npm and your own. Rules get the same `context`, `sourceCode`, scopes, and fixers as in ESLint. + +This plugin has one rule, which reports `TODO` comments: + +```js lint/plugin.js icon="/icons/javascript.svg" +const noTodo = { + meta: { + type: "suggestion", + messages: { todo: "Open an issue instead of leaving a TODO." }, + schema: [], + }, + create(context) { + const { sourceCode } = context; + return { + Program() { + for (const comment of sourceCode.getAllComments()) { + if (comment.value.includes("TODO")) { + context.report({ loc: comment.loc, messageId: "todo" }); + } + } + }, + }; + }, +}; + +export default { + meta: { name: "local" }, + rules: { "no-todo": noTodo }, +}; +``` + +Load it from an ESLint config or an oxlint config: + + + +```js eslint.config.js icon="/icons/javascript.svg" +import local from "./lint/plugin.js"; + +export default [ + { + plugins: { local }, + rules: { "local/no-todo": "warn" }, + }, +]; +``` + +```json .oxlintrc.json icon="file-json" +{ + "jsPlugins": ["./lint/plugin.js"], + "rules": { "local/no-todo": "warn" } +} +``` + + + +```bash terminal icon="terminal" +bun lint +``` + +```txt +/app/src/index.js + 1:1 warning Open an issue instead of leaving a TODO local/no-todo + +✖ 1 problem (0 errors, 1 warning) + +Linted 3 files [77.36ms] +``` + +You don't need the `eslint` package. To learn the API, read ESLint's guide to [plugins](https://eslint.org/docs/latest/extend/plugins). + + + `bunfig.toml` can't load plugins. Once you add an `eslint.config.js` or `.oxlintrc.json`, that file decides which + rules are on, and Bun's defaults no longer apply. + + +## Plugins from npm + +Install and import them as usual. + +```js eslint.config.js icon="/icons/javascript.svg" +import perfectionist from "eslint-plugin-perfectionist"; + +export default [ + { + plugins: { perfectionist }, + rules: { "perfectionist/sort-imports": "error" }, + }, +]; +``` + +Six plugins are built into Bun and run natively: `@typescript-eslint`, `react`, `react-hooks`, `import`, `n`, and `prettier`. `bun lint --doctor` shows which of your rules run natively and which run in JavaScript. + +## Performance + +JavaScript rules are slower than native ones. `bun lint --timing` shows how much time they take. + +- Only turn on a plugin's rules for the files that need them. Files without JavaScript rules pay nothing. +- Put a plugin in its own module. If you define it inside `eslint.config.js`, every thread has to run the whole config file. + +## Run the installed package instead + +To run a built-in plugin from `node_modules`, for example because you pin an older version: + +```bash terminal icon="terminal" +bun lint --no-native-plugin-rules +bun lint --native-plugin-rules=@typescript-eslint,import # only these two stay native +``` + +```toml bunfig.toml icon="settings" +[lint] +nativePluginRules = ["@typescript-eslint", "import"] +``` + +ESLint's core rules are always native. Keep `@typescript-eslint` native if you use type-aware rules, because JavaScript rules don't get types. + +## `prettier/prettier` + +`prettier/prettier` from [eslint-plugin-prettier](https://github.com/prettier/eslint-plugin-prettier) is native. It compares each file with the output of [`bun format`](/runtime/format) and never starts Prettier. + +Bun falls back to the installed package, and says why, when it can't guarantee the same output as your Prettier: + +- You have a version other than Prettier 3.9. +- You use a Prettier plugin that `bun format` doesn't have. +- The file isn't JavaScript or TypeScript. + +Without Prettier, use [`bun/format`](/lint/rules/bun/format). It checks files against `bun format`. + +## Other languages + +Bun parses JavaScript and TypeScript. It hands other files to the `eslint` package in your project, so keep `eslint` 8.57 or later installed if you use: + +- A plugin language, such as `json/json` from `@eslint/json`, `markdown/gfm`, or `css/css`. +- A parser for another file type, such as `vue-eslint-parser`, `svelte-eslint-parser`, or `yaml-eslint-parser`. +- `eslint-plugin-html`. + +You still run `bun lint`, and these files show up in the same report. + +## Processors + +Processors work. This config lints the code blocks in Markdown files: + +```js eslint.config.js icon="/icons/javascript.svg" +import markdown from "@eslint/markdown"; + +export default [ + { files: ["**/*.md"], plugins: { markdown }, processor: "markdown/markdown" }, + { files: ["**/*.md/*.js"], rules: { "no-var": "error" } }, +]; +``` + +## Notes + +- **JavaScript rules don't get types.** `parserServices.program` is `null`, even with `--type-aware`. A rule that calls typescript-eslint's `getParserServices()` doesn't run, and `bun lint` exits with code `2`. +- **Plugins run on several threads.** Each thread imports the plugin, so module-level state is per thread, not per run. +- **A renamed built-in plugin runs in JavaScript.** For example, `@antfu/eslint-config` registers `@typescript-eslint` as `ts`. +- **A rule that throws ends the run** with exit code `2`. With an oxlint config, Bun reports the error for that file and continues. +- **Type-aware rules skip files that have a processor.** diff --git a/docs/lint/rules/bun-test/consistent-test-filename.mdx b/docs/lint/rules/bun-test/consistent-test-filename.mdx new file mode 100644 index 000000000000..29db9720fa3e --- /dev/null +++ b/docs/lint/rules/bun-test/consistent-test-filename.mdx @@ -0,0 +1,76 @@ +--- +title: "bun:test/consistent-test-filename" +description: "Require test files to follow one naming pattern" +--- + +When some test files are named `user.test.ts` and others `user.spec.ts`, a glob or a search written for one name misses the other. This rule picks one. By default a file that ends in `.test` or `.spec` plus `.js`, `.jsx`, `.ts` or `.tsx` is a test file, and its name has to use `.test`. + +Only files that import from `"bun:test"` are checked. + +## Examples + +Reported, in a file named `src/user.spec.ts`: + +{/* bad {"filename": "src/user.spec.ts"} */} + +```ts +import { expect, test } from "bun:test"; + +test("has a name", () => { + expect(loadUser().name).toBe("Ada"); +}); +``` + +Not reported, in a file named `src/user.test.ts`: + +{/* good {"filename": "src/user.test.ts"} */} + +```ts +import { expect, test } from "bun:test"; + +test("has a name", () => { + expect(loadUser().name).toBe("Ada"); +}); +``` + +## Options + +Both options are regular expressions, written as strings and tested against the file's path. `/pattern/` works too. Flags after the last slash are ignored. + +| Option | Default | What it does | +| ---------------- | ---------------------------- | -------------------------------------------------------- | +| `pattern` | `.*\.test\.[tj]sx?$` | What the path of a test file has to match. | +| `allTestPattern` | `.*\.(test\|spec)\.[tj]sx?$` | Which files are test files. Other files are not checked. | + +```toml bunfig.toml +[lint.rules] +"bun:test/consistent-test-filename" = ["error", { pattern = '.*\.spec\.ts$' }] +``` + +With that `pattern`, `src/user.test.ts` is reported and `src/user.spec.ts` is not: + +{/* bad {"options": [{"pattern": ".*\\.spec\\.ts$"}], "filename": "src/user.test.ts"} */} + +```ts +import { test } from "bun:test"; + +test.todo("has a name"); +``` + +{/* good {"options": [{"pattern": ".*\\.spec\\.ts$"}], "filename": "src/user.spec.ts"} */} + +```ts +import { test } from "bun:test"; + +test.todo("has a name"); +``` + +With `allTestPattern` set to `__tests__`, every file under a `__tests__` directory is a test file. `__tests__/user.ts` is reported because it does not end in `.test.ts`: + +{/* bad {"options": [{"allTestPattern": "__tests__"}], "filename": "__tests__/user.ts"} */} + +```ts +import { test } from "bun:test"; + +test.todo("has a name"); +``` diff --git a/docs/lint/rules/bun-test/consistent-test-it.mdx b/docs/lint/rules/bun-test/consistent-test-it.mdx new file mode 100644 index 000000000000..7707adca1b02 --- /dev/null +++ b/docs/lint/rules/bun-test/consistent-test-it.mdx @@ -0,0 +1,98 @@ +--- +title: "bun:test/consistent-test-it" +description: "Enforce one of `test` or `it` for defining tests" +--- + +`test` and `it` are two names for the same function. When a project uses both at random, a reader wonders whether the difference means something. It does not. + +By default, a test at the top level of a file uses `test`, and a test inside a `describe` uses `it`. + +`--fix` renames the call if the file already imports the other name. + +## Examples + +Reported: + +{/* bad {"count": 2} */} + +```ts +import { describe, expect, it, test } from "bun:test"; + +it("adds", () => { + expect(1 + 1).toBe(2); +}); + +describe("subtract", () => { + test("goes below zero", () => { + expect(1 - 2).toBe(-1); + }); +}); +``` + +Not reported: + +{/* good */} + +```ts +import { describe, expect, it, test } from "bun:test"; + +test("adds", () => { + expect(1 + 1).toBe(2); +}); + +describe("subtract", () => { + it("goes below zero", () => { + expect(1 - 2).toBe(-1); + }); +}); +``` + +Forms such as `it.skip`, `it.only` and `it.each` are checked too: + +{/* bad */} + +```ts +import { expect, it } from "bun:test"; + +it.skip("adds", () => { + expect(1 + 1).toBe(2); +}); +``` + +## Options + +| Option | Default | What it does | +| ---------------- | ------------------------------------------------- | ------------------------------------------------------------------ | +| `fn` | `"test"` | The name for tests at the top level of a file: `"test"` or `"it"`. | +| `withinDescribe` | the value of `fn` if you set it, otherwise `"it"` | The name for tests inside a `describe`. | + +To use `test` everywhere, set only `fn`: + +```toml bunfig.toml +[lint.rules] +"bun:test/consistent-test-it" = ["error", { fn = "test" }] +``` + +{/* bad {"options": [{"fn": "test"}]} */} + +```ts +import { describe, expect, it } from "bun:test"; + +describe("subtract", () => { + it("goes below zero", () => { + expect(1 - 2).toBe(-1); + }); +}); +``` + +{/* good {"options": [{"fn": "test"}]} */} + +```ts +import { describe, expect, test } from "bun:test"; + +describe("subtract", () => { + test("goes below zero", () => { + expect(1 - 2).toBe(-1); + }); +}); +``` diff --git a/docs/lint/rules/bun-test/expect-expect.mdx b/docs/lint/rules/bun-test/expect-expect.mdx new file mode 100644 index 000000000000..631532962760 --- /dev/null +++ b/docs/lint/rules/bun-test/expect-expect.mdx @@ -0,0 +1,125 @@ +--- +title: "bun:test/expect-expect" +description: "Require every test to make at least one assertion" +--- + +A test with no assertion passes as long as the code does not throw. It is green in the report whether the result is right or wrong. Most of the time the `expect` was forgotten, or removed while the test was being changed. + +Calls to `expect` and `expectTypeOf` count as assertions. `test.todo` and `test.skip` are not checked. + +## Examples + +Reported: + +{/* bad */} + +```ts +import { test } from "bun:test"; + +test("parses the port", () => { + parseConfig("port = 3000"); +}); +``` + +Not reported: + +{/* good */} + +```ts +import { expect, test } from "bun:test"; + +test("parses the port", () => { + expect(parseConfig("port = 3000")).toEqual({ port: 3000 }); +}); + +test.todo("rejects a negative port"); + +test.skip("reads the file", () => { + readConfig("app.conf"); +}); +``` + +## Options + +| Option | Default | What it does | +| ------------------------------ | ---------------------------- | --------------------------------------------------------------------------------------------------- | +| `assertFunctionNames` | `["expect", "expectTypeOf"]` | The functions that count as an assertion. Your list replaces the default, so keep `"expect"` in it. | +| `additionalTestBlockFunctions` | `[]` | Other `bun:test` functions to check like `test`, such as `"afterEach"`. | + +### `assertFunctionNames` + +The rule does not look inside the helpers that a test calls. This test is reported even though `checkUser` calls `expect`: + +{/* bad */} + +```ts +import { expect, test } from "bun:test"; + +function checkUser(user) { + expect(user.id).toBeGreaterThan(0); +} + +test("creates a user", () => { + checkUser(createUser()); +}); +``` + +Add the helper's name: + +```toml bunfig.toml +[lint.rules] +"bun:test/expect-expect" = ["error", { assertFunctionNames = ["expect", "checkUser"] }] +``` + +{/* good {"options": [{"assertFunctionNames": ["expect", "checkUser"]}]} */} + +```ts +import { expect, test } from "bun:test"; + +function checkUser(user) { + expect(user.id).toBeGreaterThan(0); +} + +test("creates a user", () => { + checkUser(createUser()); +}); +``` + +A name can be a chain with dots. `*` stands for any letters and digits in one part of the chain, and `**` for any number of parts. Upper and lower case are treated the same. + +{/* good {"options": [{"assertFunctionNames": ["expect", "request.**.expect"]}]} */} + +```ts +import { test } from "bun:test"; + +test("is healthy", async () => { + await request(app).get("/health").expect(200); +}); +``` + +### `additionalTestBlockFunctions` + +This option only works for functions that come from `bun:test`. A wrapper of your own is not checked, even if you list it. + +{/* bad {"options": [{"additionalTestBlockFunctions": ["afterEach"]}]} */} + +```ts +import { afterEach } from "bun:test"; + +afterEach(() => { + closeDatabase(); +}); +``` + +{/* good {"options": [{"additionalTestBlockFunctions": ["testWithDatabase"]}]} */} + +```ts +import { afterAll } from "bun:test"; +import { closeDatabase, testWithDatabase } from "./helpers"; + +afterAll(closeDatabase); + +testWithDatabase("saves the user", db => { + db.save(createUser()); +}); +``` diff --git a/docs/lint/rules/bun-test/max-expects.mdx b/docs/lint/rules/bun-test/max-expects.mdx new file mode 100644 index 000000000000..6aad70a421be --- /dev/null +++ b/docs/lint/rules/bun-test/max-expects.mdx @@ -0,0 +1,89 @@ +--- +title: "bun:test/max-expects" +description: "Enforce a maximum number of `expect()` calls in a test" +--- + +A test with many assertions usually checks several things at once. The first `expect` that fails stops the test, so one failure hides the ones after it, and the test's name no longer says what broke. + +By default a test can have 5 `expect()` calls. Each call after that is reported. + +## Examples + +Reported: + +{/* bad {"count": 1} */} + +```ts +import { expect, test } from "bun:test"; + +test("creates a user", () => { + const user = createUser({ name: "Ada", email: "ada@example.com" }); + expect(user.id).toBeGreaterThan(0); + expect(user.name).toBe("Ada"); + expect(user.email).toBe("ada@example.com"); + expect(user.role).toBe("member"); + expect(user.active).toBe(true); + expect(user.createdAt).toBeInstanceOf(Date); +}); +``` + +Not reported: + +{/* good */} + +```ts +import { expect, test } from "bun:test"; + +test("keeps the name and the email", () => { + const user = createUser({ name: "Ada", email: "ada@example.com" }); + expect(user).toMatchObject({ name: "Ada", email: "ada@example.com" }); +}); + +test("fills in the defaults", () => { + const user = createUser({ name: "Ada", email: "ada@example.com" }); + expect(user.id).toBeGreaterThan(0); + expect(user.role).toBe("member"); + expect(user.active).toBe(true); + expect(user.createdAt).toBeInstanceOf(Date); +}); +``` + +Calls are counted per function and per block. An `expect` inside a loop or a callback does not add to the count of the test around it: + +{/* good {"options": [{"max": 1}]} */} + +```ts +import { expect, test } from "bun:test"; + +test("lists active users", () => { + const users = listUsers({ active: true }); + expect(users).toHaveLength(2); + for (const user of users) { + expect(user.active).toBe(true); + } +}); +``` + +## Options + +| Option | Default | What it does | +| ------ | ------- | --------------------------------------- | +| `max` | `5` | The number of `expect()` calls allowed. | + +```toml bunfig.toml +[lint.rules] +"bun:test/max-expects" = ["error", { max = 2 }] +``` + +{/* bad {"options": [{"max": 2}], "count": 1} */} + +```ts +import { expect, test } from "bun:test"; + +test("parses a URL", () => { + const url = parse("https://example.com:8080/docs"); + expect(url.host).toBe("example.com"); + expect(url.port).toBe(8080); + expect(url.path).toBe("/docs"); +}); +``` diff --git a/docs/lint/rules/bun-test/max-nested-describe.mdx b/docs/lint/rules/bun-test/max-nested-describe.mdx new file mode 100644 index 000000000000..e7236d702629 --- /dev/null +++ b/docs/lint/rules/bun-test/max-nested-describe.mdx @@ -0,0 +1,83 @@ +--- +title: "bun:test/max-nested-describe" +description: "Enforce a maximum depth of nested `describe()` calls" +--- + +Every level of `describe` can add its own hooks and variables. To understand a test that sits many levels deep, you have to read all the blocks around it, and the test's full name gets long. + +By default `describe` blocks can be 5 levels deep. A `describe` at a deeper level is reported. Forms such as `describe.each` count as levels too. + +## Examples + +These examples use a maximum of 2: + +```toml bunfig.toml +[lint.rules] +"bun:test/max-nested-describe" = ["error", { max = 2 }] +``` + +Reported: + +{/* bad {"options": [{"max": 2}], "count": 1} */} + +```ts +import { describe, expect, test } from "bun:test"; + +describe("cart", () => { + describe("checkout", () => { + describe("with a coupon", () => { + test("lowers the total", () => { + expect(checkout({ coupon: "SAVE10" }).total).toBe(90); + }); + }); + }); +}); +``` + +Not reported: + +{/* good {"options": [{"max": 2}]} */} + +```ts +import { describe, expect, test } from "bun:test"; + +describe("cart", () => { + describe("checkout", () => { + test("a coupon lowers the total", () => { + expect(checkout({ coupon: "SAVE10" }).total).toBe(90); + }); + }); +}); +``` + +## Options + +| Option | Default | What it does | +| ------ | ------- | --------------------------------------------------------------------- | +| `max` | `5` | The number of levels allowed. With `0`, every `describe` is reported. | + +{/* bad {"options": [{"max": 1}]} */} + +```ts +import { describe, expect, test } from "bun:test"; + +describe("parse", () => { + describe.each(["json", "toml"])("%s", format => { + test("accepts an empty file", () => { + expect(parse("", format)).toEqual({}); + }); + }); +}); +``` + +{/* bad {"options": [{"max": 0}]} */} + +```ts +import { describe, expect, test } from "bun:test"; + +describe("parse", () => { + test("accepts an empty file", () => { + expect(parse("", "json")).toEqual({}); + }); +}); +``` diff --git a/docs/lint/rules/bun-test/no-alias-methods.mdx b/docs/lint/rules/bun-test/no-alias-methods.mdx new file mode 100644 index 000000000000..850078ac31d6 --- /dev/null +++ b/docs/lint/rules/bun-test/no-alias-methods.mdx @@ -0,0 +1,54 @@ +--- +title: "bun:test/no-alias-methods" +description: "Disallow matcher aliases such as `toBeCalled` in favor of `toHaveBeenCalled`" +--- + +Some matchers have two names: `toBeCalled` is the same matcher as `toHaveBeenCalled`. If a project uses both, a search for one name misses the tests that use the other. + +`--fix` renames the matcher. + +## Examples + +Reported: + +{/* bad */} + +```ts +import { expect, mock, test } from "bun:test"; + +test("saves the draft", () => { + const save = mock(); + save("draft"); + expect(save).toBeCalledWith("draft"); +}); +``` + +Not reported: + +{/* good */} + +```ts +import { expect, mock, test } from "bun:test"; + +test("saves the draft", () => { + const save = mock(); + save("draft"); + expect(save).toHaveBeenCalledWith("draft"); +}); +``` + +The full list: + +| Reported | Write instead | +| ------------------ | -------------------------- | +| `toBeCalled` | `toHaveBeenCalled` | +| `toBeCalledTimes` | `toHaveBeenCalledTimes` | +| `toBeCalledWith` | `toHaveBeenCalledWith` | +| `lastCalledWith` | `toHaveBeenLastCalledWith` | +| `nthCalledWith` | `toHaveBeenNthCalledWith` | +| `toReturn` | `toHaveReturned` | +| `toReturnTimes` | `toHaveReturnedTimes` | +| `toReturnWith` | `toHaveReturnedWith` | +| `lastReturnedWith` | `toHaveLastReturnedWith` | +| `nthReturnedWith` | `toHaveNthReturnedWith` | +| `toThrowError` | `toThrow` | diff --git a/docs/lint/rules/bun-test/no-commented-out-tests.mdx b/docs/lint/rules/bun-test/no-commented-out-tests.mdx new file mode 100644 index 000000000000..74ec34d5f1a4 --- /dev/null +++ b/docs/lint/rules/bun-test/no-commented-out-tests.mdx @@ -0,0 +1,53 @@ +--- +title: "bun:test/no-commented-out-tests" +description: "Disallow tests that are inside a comment" +--- + +A test inside a comment never runs, and the test output does not say so. It is easy to comment one out while debugging and forget it. + +To turn a test off for now, use `test.skip` or `test.todo`, which show up in the summary. If nobody needs the test, delete it. + +A comment is reported if one of its lines starts with a call to `test`, `it` or `describe`. That includes `xtest`, `fit`, `test.only` and the like. + +## Examples + +Reported: + +{/* bad */} + +```ts +import { expect, test } from "bun:test"; + +// test("rounds down", () => { +// expect(Math.floor(1.9)).toBe(1); +// }); +``` + +{/* bad */} + +```ts +import { describe, expect, it } from "bun:test"; + +describe("Math.floor", () => { + /* + it.only("rounds down", () => { + expect(Math.floor(1.9)).toBe(1); + }); + */ +}); +``` + +Not reported: + +{/* good */} + +```ts +import { expect, test } from "bun:test"; + +test.skip("rounds down", () => { + expect(Math.floor(1.9)).toBe(1); +}); + +// test negative numbers too +test.todo("rounds negative numbers down"); +``` diff --git a/docs/lint/rules/bun-test/no-conditional-expect.mdx b/docs/lint/rules/bun-test/no-conditional-expect.mdx new file mode 100644 index 000000000000..f4e2564ffe52 --- /dev/null +++ b/docs/lint/rules/bun-test/no-conditional-expect.mdx @@ -0,0 +1,81 @@ +--- +title: "bun:test/no-conditional-expect" +description: "Disallow `expect` inside `if`, `catch` and other code that only runs sometimes" +--- + +An `expect` that only runs under a condition may not run at all. If the condition is false, or nothing is thrown, the test passes without checking anything. + +In a test, the rule reports an `expect` inside an `if`, a `switch`, a `catch` block, a `.catch()` callback, `a ? b : c`, or an operand of `&&`, `||` or `??`. + +It also checks `function` declarations that a test uses. It does not look inside an anonymous `function () {}`, even when that is the test callback. + +## Examples + +Reported, because the test passes if `JSON.parse` stops throwing: + +{/* bad */} + +```ts +import { expect, test } from "bun:test"; + +test("rejects bad JSON", () => { + try { + JSON.parse("{"); + } catch (error) { + expect(error).toBeInstanceOf(SyntaxError); + } +}); +``` + +{/* bad */} + +```ts +import { expect, test } from "bun:test"; +import { readFile } from "node:fs/promises"; + +test("rejects a missing file", async () => { + await readFile("missing.txt").catch(error => { + expect(error.code).toBe("ENOENT"); + }); +}); +``` + +{/* bad */} + +```ts +import { expect, test } from "bun:test"; + +const users = [{ name: "Ada", admin: true }]; + +test("finds the admin", () => { + const admin = users.find(user => user.admin); + if (admin) { + expect(admin.name).toBe("Ada"); + } +}); +``` + +Not reported: + +{/* good */} + +```ts +import { expect, test } from "bun:test"; +import { readFile } from "node:fs/promises"; + +const users = [{ name: "Ada", admin: true }]; + +test("rejects bad JSON", () => { + expect(() => JSON.parse("{")).toThrow(SyntaxError); +}); + +test("rejects a missing file", async () => { + await expect(readFile("missing.txt")).rejects.toThrow("ENOENT"); +}); + +test("finds the admin", () => { + const admin = users.find(user => user.admin); + expect(admin).toBeDefined(); + expect(admin?.name).toBe("Ada"); +}); +``` diff --git a/docs/lint/rules/bun-test/no-conditional-in-test.mdx b/docs/lint/rules/bun-test/no-conditional-in-test.mdx new file mode 100644 index 000000000000..ab2fd5452fbc --- /dev/null +++ b/docs/lint/rules/bun-test/no-conditional-in-test.mdx @@ -0,0 +1,95 @@ +--- +title: "bun:test/no-conditional-in-test" +description: "Disallow `if`, `switch`, `? :`, `&&`, `||` and `??` inside a test" +--- + +A test with a branch in it checks different things on different runs. When it fails you first have to find out which path it took, and a path that is never taken is never tested. The branch often repeats the logic of the code under test, mistakes included. + +Write one test for each case, or a table of cases with `test.each`. Conditions outside a test are not reported. + +## Examples + +Reported: + +{/* bad */} + +```ts +import { expect, test } from "bun:test"; +import { label, users } from "./users"; + +test("labels every user", () => { + for (const user of users) { + const expected = user.admin ? "Admin" : "Member"; + expect(label(user)).toBe(expected); + } +}); +``` + +Not reported: + +{/* good */} + +```ts +import { expect, test } from "bun:test"; +import { label } from "./users"; + +test.each([ + [true, "Admin"], + [false, "Member"], +])("labels a user with admin: %s", (admin, expected) => { + expect(label({ admin })).toBe(expected); +}); +``` + +Reported: + +{/* bad */} + +```ts +import { expect, test } from "bun:test"; +import { homeDirectory } from "./paths"; + +test("finds the home directory", () => { + if (process.platform === "win32") { + expect(homeDirectory()).toMatch(/^C:/); + } else { + expect(homeDirectory()).toMatch(/^\//); + } +}); +``` + +Not reported, because the condition decides which test runs and is not inside one: + +{/* good */} + +```ts +import { expect, test } from "bun:test"; +import { homeDirectory } from "./paths"; + +const isWindows = process.platform === "win32"; + +test.skipIf(!isWindows)("finds the home directory on Windows", () => { + expect(homeDirectory()).toMatch(/^C:/); +}); + +test.skipIf(isWindows)("finds the home directory", () => { + expect(homeDirectory()).toMatch(/^\//); +}); +``` + +The argument of `skipIf` counts as part of the test, so an `||` written there is reported. Put the condition in a variable first. + +{/* bad */} + +```ts +import { expect, test } from "bun:test"; +import { homeDirectory } from "./paths"; + +test.skipIf(process.env.CI || process.platform === "win32")("finds the home directory", () => { + expect(homeDirectory()).toMatch(/^\//); +}); +``` + +## When not to use it + +If a condition in a test is fine for you as long as every `expect` runs, use `bun:test/no-conditional-expect` instead. diff --git a/docs/lint/rules/bun-test/no-conditional-tests.mdx b/docs/lint/rules/bun-test/no-conditional-tests.mdx new file mode 100644 index 000000000000..52c12f192fbd --- /dev/null +++ b/docs/lint/rules/bun-test/no-conditional-tests.mdx @@ -0,0 +1,66 @@ +--- +title: "bun:test/no-conditional-tests" +description: "Disallow `test` and `describe` inside an `if` statement" +--- + +A test declared inside an `if` only exists when the condition is true. When it is false, the test is missing from the output, so nobody sees that it did not run. + +Use `test.if(condition)` or `test.skipIf(condition)`. The test is always declared, and it is shown as skipped when it does not run. `describe` has the same two methods. + +The rule is about the `if` around a test. For an `if` inside a test, see `bun:test/no-conditional-in-test`. + +## Examples + +Reported: + +{/* bad */} + +```ts +import { expect, test } from "bun:test"; + +if (process.platform === "linux") { + test("reads the kernel version", async () => { + expect(await Bun.file("/proc/version").text()).toContain("Linux"); + }); +} +``` + +{/* bad */} + +```ts +import { describe, expect, test } from "bun:test"; +import { connect } from "./database"; + +describe("database", () => { + if (process.env.DATABASE_URL) { + test("connects", async () => { + expect(await connect()).toBeDefined(); + }); + } +}); +``` + +Not reported: + +{/* good */} + +```ts +import { expect, test } from "bun:test"; + +test.if(process.platform === "linux")("reads the kernel version", async () => { + expect(await Bun.file("/proc/version").text()).toContain("Linux"); +}); +``` + +{/* good */} + +```ts +import { describe, expect, test } from "bun:test"; +import { connect } from "./database"; + +describe.skipIf(!process.env.DATABASE_URL)("database", () => { + test("connects", async () => { + expect(await connect()).toBeDefined(); + }); +}); +``` diff --git a/docs/lint/rules/bun-test/no-disabled-tests.mdx b/docs/lint/rules/bun-test/no-disabled-tests.mdx new file mode 100644 index 000000000000..8ce2c3b73d9a --- /dev/null +++ b/docs/lint/rules/bun-test/no-disabled-tests.mdx @@ -0,0 +1,58 @@ +--- +title: "bun:test/no-disabled-tests" +description: "Disallow skipped tests such as `test.skip`, `xtest` and `xdescribe`" +--- + +A skipped test does not run, and `bun test` still passes. `.skip` is useful while you work on something else. If it stays in the code, nobody notices when the behavior it covered breaks. + +The rule reports `.skip` on `test`, `it` and `describe`, the `x` prefix (`xtest`, `xit`, `xdescribe`), and a test with a title but no function. `test.todo` and `test.skipIf(condition)` are allowed. + +This rule is on by default, as a warning, in files that import from `"bun:test"`. + +## Examples + +Reported: + +{/* bad {"count": 2} */} + +```ts +import { describe, expect, test, xtest } from "bun:test"; + +describe.skip("checkout", () => { + test("adds the tax", () => { + expect(total(100)).toBe(108); + }); +}); + +xtest("applies the coupon", () => { + expect(total(100, "SAVE10")).toBe(97.2); +}); +``` + +A test with no function has nothing to run: + +{/* bad */} + +```ts +import { test } from "bun:test"; + +test("refunds the order"); +``` + +Not reported: + +{/* good */} + +```ts +import { expect, test } from "bun:test"; + +test("adds the tax", () => { + expect(total(100)).toBe(108); +}); + +test.todo("refunds the order"); + +test.skipIf(process.platform === "win32")("follows the symlink", () => { + expect(resolve("current")).toBe("releases/42"); +}); +``` diff --git a/docs/lint/rules/bun-test/no-done-callback.mdx b/docs/lint/rules/bun-test/no-done-callback.mdx new file mode 100644 index 000000000000..7de5a4d29994 --- /dev/null +++ b/docs/lint/rules/bun-test/no-done-callback.mdx @@ -0,0 +1,60 @@ +--- +title: "bun:test/no-done-callback" +description: "Disallow the `done` callback in tests and hooks in favor of promises" +--- + +A test that takes a `done` parameter waits until `done()` is called. If some code path never calls it, the test hangs until it times out, and the timeout does not say why. An `async` test, or one that returns a promise, ends when the promise settles and fails with the error it rejects with. + +The name of the parameter does not matter. Any test or hook callback with exactly one parameter is reported. + +## Examples + +Reported: + +{/* bad */} + +```ts +import { expect, test } from "bun:test"; + +test("reads the config", done => { + readConfig((error, config) => { + expect(config.port).toBe(3000); + done(); + }); +}); +``` + +Not reported: + +{/* good */} + +```ts +import { expect, test } from "bun:test"; + +test("reads the config", async () => { + const config = await readConfig(); + expect(config.port).toBe(3000); +}); +``` + +If the API only takes a callback, wrap it in a promise: + +{/* good */} + +```ts +import { afterAll } from "bun:test"; + +afterAll(() => new Promise(resolve => server.close(resolve))); +``` + +The callback of `test.each([...])` is not checked, because its parameters are the values of the row: + +{/* good */} + +```ts +import { expect, test } from "bun:test"; + +test.each([1, 2, 3])("%i is positive", value => { + expect(value).toBeGreaterThan(0); +}); +``` diff --git a/docs/lint/rules/bun-test/no-duplicate-hooks.mdx b/docs/lint/rules/bun-test/no-duplicate-hooks.mdx new file mode 100644 index 000000000000..a5bbfd5dbcaa --- /dev/null +++ b/docs/lint/rules/bun-test/no-duplicate-hooks.mdx @@ -0,0 +1,55 @@ +--- +title: "bun:test/no-duplicate-hooks" +description: "Disallow using the same hook twice in one `describe` block" +--- + +Two `beforeEach` calls in the same block both run, so nothing fails. But someone who reads the first one assumes it is all the setup and misses the second, which can be many lines below. + +Hooks are counted per `describe` block, and the top level of the file counts as a block. A nested `describe` can have its own `beforeEach`. + +## Examples + +Reported: + +{/* bad */} + +```ts +import { beforeEach, describe, test } from "bun:test"; + +describe("cart", () => { + beforeEach(() => { + cart.clear(); + }); + + test("starts empty", () => {}); + + beforeEach(() => { + cart.add("apple"); + }); +}); +``` + +Not reported: + +{/* good */} + +```ts +import { beforeEach, describe, test } from "bun:test"; + +describe("cart", () => { + beforeEach(() => { + cart.clear(); + cart.add("apple"); + }); + + test("has one item", () => {}); + + describe("after checkout", () => { + beforeEach(() => { + cart.checkout(); + }); + + test("is empty", () => {}); + }); +}); +``` diff --git a/docs/lint/rules/bun-test/no-export.mdx b/docs/lint/rules/bun-test/no-export.mdx new file mode 100644 index 000000000000..f3e4ea99ab1f --- /dev/null +++ b/docs/lint/rules/bun-test/no-export.mdx @@ -0,0 +1,51 @@ +--- +title: "bun:test/no-export" +description: "Disallow exports from a file that has tests" +--- + +When another file imports from a test file, the whole test file runs, including its `test()` calls, so its tests can run as part of the importing file. Put shared helpers in a file of their own. + +The rule only checks files named like tests: the name ends in `test` or `spec` before the extension, such as `cart.test.ts`, or the file is under a `__tests__` directory. A file with no `test` or `it` call can export anything. Exported types are allowed, because importing a type does not run the file. + +This rule is on by default in files that import from `"bun:test"`. + +## Examples + +Reported: + +{/* bad {"filename": "src/cart.test.ts"} */} + +```ts +import { expect, test } from "bun:test"; + +export function makeCart() { + return { items: [] }; +} + +test("starts empty", () => { + expect(makeCart().items).toEqual([]); +}); +``` + +Not reported, with the helper in a file of its own: + +{/* file "helpers.ts" */} + +```ts +export function makeCart() { + return { items: [] }; +} +``` + +{/* good {"filename": "cart.test.ts"} */} + +```ts +import { expect, test } from "bun:test"; +import { makeCart } from "./helpers"; + +export type Cart = ReturnType; + +test("starts empty", () => { + expect(makeCart().items).toEqual([]); +}); +``` diff --git a/docs/lint/rules/bun-test/no-hooks.mdx b/docs/lint/rules/bun-test/no-hooks.mdx new file mode 100644 index 000000000000..c2f1e69de791 --- /dev/null +++ b/docs/lint/rules/bun-test/no-hooks.mdx @@ -0,0 +1,65 @@ +--- +title: "bun:test/no-hooks" +description: "Disallow `beforeAll`, `beforeEach`, `afterEach` and `afterAll`" +--- + +A hook usually sets a variable that every test in the block shares. To understand one test you have to find the hooks above it, and a test that changes the shared value can break the next one. A test that calls a setup function itself shows everything it depends on. + +## Examples + +Reported: + +{/* bad */} + +```ts +import { beforeEach, expect, test } from "bun:test"; + +let cart; + +beforeEach(() => { + cart = createCart(); +}); + +test("starts empty", () => { + expect(cart.items).toEqual([]); +}); +``` + +Not reported: + +{/* good */} + +```ts +import { expect, test } from "bun:test"; + +test("starts empty", () => { + const cart = createCart(); + expect(cart.items).toEqual([]); +}); +``` + +## Options + +| Option | Default | What it does | +| ------- | ------- | ------------------------ | +| `allow` | `[]` | Names of hooks to allow. | + +With `{ "allow": ["afterEach"] }`, only `beforeAll` is reported here: + +{/* bad {"options": [{"allow": ["afterEach"]}], "count": 1} */} + +```ts +import { afterEach, beforeAll, mock } from "bun:test"; + +afterEach(() => { + mock.restore(); +}); + +beforeAll(() => { + server.listen(); +}); +``` + +## When not to use it + +Hooks are the usual way to start something slow once per file, such as a server or a database, and to stop it afterwards. If your tests do that, allow those hooks or leave the rule off. diff --git a/docs/lint/rules/bun-test/no-identical-title.mdx b/docs/lint/rules/bun-test/no-identical-title.mdx new file mode 100644 index 000000000000..46529c809d3c --- /dev/null +++ b/docs/lint/rules/bun-test/no-identical-title.mdx @@ -0,0 +1,105 @@ +--- +title: "bun:test/no-identical-title" +description: "Disallow two tests or two `describe` blocks with the same title in one block" +--- + +When two tests in the same block share a title, a failure report does not tell you which one failed, and filtering by name runs both. The second one is often a copy that kept the old title. + +Titles are compared inside one block. The same title in two different `describe` blocks is fine. + +## Examples + +Reported: + +{/* bad */} + +```ts +import { describe, expect, test } from "bun:test"; + +describe("parsePrice", () => { + test("reads a number", () => { + expect(parsePrice("12")).toBe(12); + }); + + test("reads a number", () => { + expect(parsePrice("12.50")).toBe(12.5); + }); +}); +``` + +Two `describe` blocks next to each other are compared the same way: + +{/* bad */} + +```ts +import { describe, test } from "bun:test"; + +describe("cart", () => { + test("adds an item", () => {}); +}); + +describe("cart", () => { + test("removes an item", () => {}); +}); +``` + +Not reported: + +{/* good */} + +```ts +import { describe, expect, test } from "bun:test"; + +describe("parsePrice", () => { + test("reads a whole number", () => { + expect(parsePrice("12")).toBe(12); + }); + + test("reads a decimal", () => { + expect(parsePrice("12.50")).toBe(12.5); + }); +}); + +describe("formatPrice", () => { + test("reads a whole number", () => { + expect(formatPrice(12)).toBe("12.00"); + }); +}); +``` + +Only titles that are plain strings are compared. A title built from a variable is skipped, and so is `.each`, which fills in the title for each row: + +{/* good */} + +```ts +import { expect, test } from "bun:test"; + +test.each([1, 2])("doubles %i", n => { + expect(double(n)).toBe(n * 2); +}); + +test.each([-1, -2])("doubles %i", n => { + expect(double(n)).toBe(n * 2); +}); + +test("rounds " + mode, () => {}); +test("rounds " + mode, () => {}); +``` + +`test.if()`, `test.skipIf()` and the other conditional forms are skipped too, because a pair with opposite conditions is one test: + +{/* good */} + +```ts +import { expect, test } from "bun:test"; + +const isWindows = process.platform === "win32"; + +test.if(isWindows)("finds the home directory", () => { + expect(homeDirectory()).toStartWith("C:"); +}); + +test.if(!isWindows)("finds the home directory", () => { + expect(homeDirectory()).toStartWith("/"); +}); +``` diff --git a/docs/lint/rules/bun-test/no-interpolation-in-snapshots.mdx b/docs/lint/rules/bun-test/no-interpolation-in-snapshots.mdx new file mode 100644 index 000000000000..d6fbbb5a430d --- /dev/null +++ b/docs/lint/rules/bun-test/no-interpolation-in-snapshots.mdx @@ -0,0 +1,59 @@ +--- +title: "bun:test/no-interpolation-in-snapshots" +description: "Disallow `${}` in inline snapshots" +--- + +An inline snapshot is text that the test runner writes into your file and rewrites when you update snapshots. With `${}` in it, the expected text is computed while the test runs, so the runner cannot write it back, and the snapshot no longer shows what the output looks like. + +The rule checks the template literals passed to `toMatchInlineSnapshot` and `toThrowErrorMatchingInlineSnapshot`. + +## Examples + +Reported: + +{/* bad */} + +```ts +import { expect, test } from "bun:test"; + +test("creates a user", () => { + const user = createUser("Ada"); + expect(user).toMatchInlineSnapshot(` + { + "id": ${user.id}, + "name": "Ada", + } + `); +}); +``` + +{/* bad */} + +```ts +import { expect, test } from "bun:test"; + +test("rejects a missing file", () => { + expect(() => load(path)).toThrowErrorMatchingInlineSnapshot(`"cannot read ${path}"`); +}); +``` + +Not reported. For a value that changes from run to run, pass a property matcher as the first argument and keep the snapshot plain text: + +{/* good */} + +```ts +import { expect, test } from "bun:test"; + +test("creates a user", () => { + const user = createUser("Ada"); + expect(user).toMatchInlineSnapshot( + { id: expect.any(Number) }, + ` + { + "id": Any, + "name": "Ada", + } + `, + ); +}); +``` diff --git a/docs/lint/rules/bun-test/no-large-snapshots.mdx b/docs/lint/rules/bun-test/no-large-snapshots.mdx new file mode 100644 index 000000000000..4589e9d0755e --- /dev/null +++ b/docs/lint/rules/bun-test/no-large-snapshots.mdx @@ -0,0 +1,72 @@ +--- +title: "bun:test/no-large-snapshots" +description: "Disallow snapshots that are longer than a set number of lines" +--- + +Nobody reads a snapshot of several hundred lines in a review. When it changes, it gets updated without a look, and then the test passes whatever the code does. A short snapshot, or an assertion on the few values that matter, still tells you something when it fails. + +The rule counts the lines of the first argument of `toMatchInlineSnapshot` and `toThrowErrorMatchingInlineSnapshot`. The limit is 50 lines. A snapshot that comes after a property matcher is not counted, and snapshots in `.snap` files are not checked. + +## Examples + +These examples set `inlineMaxSize` to `3` to stay short. + +Reported: + +{/* bad {"options": [{"inlineMaxSize": 3}]} */} + +```ts +import { expect, test } from "bun:test"; + +test("loads the user", () => { + expect(loadUser(1)).toMatchInlineSnapshot(` + { + "email": "ada@example.com", + "id": 1, + "name": "Ada", + } + `); +}); +``` + +Not reported: + +{/* good {"options": [{"inlineMaxSize": 3}]} */} + +```ts +import { expect, test } from "bun:test"; + +test("loads the user", () => { + const user = loadUser(1); + expect(user.name).toBe("Ada"); + expect(user.email).toMatchInlineSnapshot(`"ada@example.com"`); +}); +``` + +## Options + +| Option | Default | What it does | +| --------------- | ---------------------- | ------------------------------------------------- | +| `inlineMaxSize` | the value of `maxSize` | The most lines an inline snapshot can have. | +| `maxSize` | `50` | The limit to use when `inlineMaxSize` is not set. | + +```toml bunfig.toml +[lint.rules] +"bun:test/no-large-snapshots" = ["error", { inlineMaxSize = 20 }] +``` + +{/* bad {"options": [{"maxSize": 3}]} */} + +```ts +import { expect, test } from "bun:test"; + +test("lists the roles", () => { + expect(roles()).toMatchInlineSnapshot(` + [ + "admin", + "editor", + "viewer", + ] + `); +}); +``` diff --git a/docs/lint/rules/bun-test/no-restricted-jest-methods.mdx b/docs/lint/rules/bun-test/no-restricted-jest-methods.mdx new file mode 100644 index 000000000000..a35ecdd9bbb9 --- /dev/null +++ b/docs/lint/rules/bun-test/no-restricted-jest-methods.mdx @@ -0,0 +1,72 @@ +--- +title: "bun:test/no-restricted-jest-methods" +description: "Disallow the `jest` and `vi` methods that you list" +--- + +Some teams agree not to use parts of the `jest` object: no module mocks, no fake timers, no spies. This rule reports the methods you list, with your own message if you give one, so the agreement does not depend on review. + +The rule does nothing until you give it a list. + +## Examples + +These examples restrict `advanceTimersByTime` and `spyOn`. + +Reported: + +{/* bad {"count": 2, "options": [{"advanceTimersByTime": null, "spyOn": "Pass the dependency in instead of spying on it"}]} */} + +```ts +import { expect, jest, test } from "bun:test"; + +test("retries after a second", () => { + const send = jest.spyOn(mailer, "send"); + retry(send); + jest.advanceTimersByTime(1000); + expect(send).toHaveBeenCalledTimes(2); +}); +``` + +Not reported: + +{/* good {"options": [{"advanceTimersByTime": null, "spyOn": "Pass the dependency in instead of spying on it"}]} */} + +```ts +import { expect, jest, test } from "bun:test"; + +test("retries once", () => { + const send = jest.fn(); + retry(send); + expect(send).toHaveBeenCalledTimes(2); +}); +``` + +`vi` is checked the same way as `jest`: + +{/* bad {"options": [{"spyOn": "Pass the dependency in instead of spying on it"}]} */} + +```ts +import { vi } from "bun:test"; + +const send = vi.spyOn(mailer, "send"); +``` + +Only methods of `jest` and `vi` are checked. The `spyOn` and `mock` functions that `"bun:test"` exports are not: + +{/* good {"options": [{"spyOn": "Pass the dependency in instead of spying on it"}]} */} + +```ts +import { spyOn } from "bun:test"; + +const send = spyOn(mailer, "send"); +``` + +## Options + +One object. Each key is the name of a method. The value is the message to report. + +```toml bunfig.toml +[lint.rules] +"bun:test/no-restricted-jest-methods" = ["error", { spyOn = "Pass the dependency in instead of spying on it" }] +``` + +Where the config format has `null`, a `null` value gives the default message, which is ``Use of `spyOn` is not allowed``. diff --git a/docs/lint/rules/bun-test/no-restricted-matchers.mdx b/docs/lint/rules/bun-test/no-restricted-matchers.mdx new file mode 100644 index 000000000000..e986f8baa78c --- /dev/null +++ b/docs/lint/rules/bun-test/no-restricted-matchers.mdx @@ -0,0 +1,82 @@ +--- +title: "bun:test/no-restricted-matchers" +description: "Disallow the matchers and modifiers you list" +--- + +Some matchers are too loose for your tests. `toBeTruthy()` passes for `1`, `"no"` and `[]`, so it does not prove that a value is `true`. This rule reports the matchers and modifiers you name in its options. + +Without options it reports nothing. + +## Examples + +Both examples use `{ "toBeTruthy": "Use toBe(true) instead" }`. + +Reported: + +{/* bad {"options": [{"toBeTruthy": "Use toBe(true) instead"}]} */} + +```ts +import { expect, test } from "bun:test"; + +test("a new user is active", () => { + const user = { active: true }; + expect(user.active).toBeTruthy(); +}); +``` + +Not reported: + +{/* good {"options": [{"toBeTruthy": "Use toBe(true) instead"}]} */} + +```ts +import { expect, test } from "bun:test"; + +test("a new user is active", () => { + const user = { active: true }; + expect(user.active).toBe(true); +}); +``` + +## Options + +One object. A key is everything you write after `expect(..)`, joined with dots. A value is a hint that is shown with the report, or `null` for no hint. + +```json +{ + "toBeTruthy": "Use toBe(true) instead", + "not.toHaveBeenCalledWith": null, + "resolves": "Await the promise, then check the value" +} +``` + +| Key | What it reports | +| ----------------------------------------------------- | ----------------------------------------------------- | +| A matcher, such as `"toBe"` | `expect(a).toBe(b)`, but not `expect(a).not.toBe(b)`. | +| A chain, such as `"not.toBe"` | `expect(a).not.toBe(b)`, but not `expect(a).toBe(b)`. | +| `"not"`, `"resolves"` or `"rejects"` | Every chain that starts with that modifier. | +| A chain that ends in `.not`, such as `"resolves.not"` | Every chain that starts with it. | + +A matcher is only matched as the whole chain, so `"toBe"` allows this: + +{/* good {"options": [{"toBe": null}]} */} + +```ts +import { expect, test } from "bun:test"; + +test("ids differ", () => { + expect(createId()).not.toBe(createId()); +}); +``` + +A modifier matches whatever comes after it: + +{/* bad {"options": [{"resolves": null}], "count": 2} */} + +```ts +import { expect, test } from "bun:test"; + +test("loads the user", async () => { + await expect(loadUser(1)).resolves.toEqual({ id: 1 }); + await expect(loadUser(2)).resolves.not.toBeNull(); +}); +``` diff --git a/docs/lint/rules/bun-test/no-standalone-expect.mdx b/docs/lint/rules/bun-test/no-standalone-expect.mdx new file mode 100644 index 000000000000..4f25426596f0 --- /dev/null +++ b/docs/lint/rules/bun-test/no-standalone-expect.mdx @@ -0,0 +1,69 @@ +--- +title: "bun:test/no-standalone-expect" +description: "Disallow `expect` outside of a `test` or `it` block" +--- + +An `expect` written directly in a `describe` block or at the top of the file is not part of any test. It runs while the file is loaded, before the first test starts, and a failure there does not show up as a failed test with a name. Most of the time a `test(..)` around it was forgotten. An `expect` in a hook such as `beforeEach` is reported too. + +An `expect` in a function declaration, or in a function stored in a variable, is allowed, because a test can call that helper. So are `expect.extend()` and asymmetric matchers such as `expect.any(String)`. + +## Examples + +Reported: + +{/* bad {"count": 2} */} + +```ts +import { beforeEach, describe, expect } from "bun:test"; + +describe("cart", () => { + const cart = createCart(); + expect(cart.items).toHaveLength(0); + + beforeEach(() => { + expect(cart.total).toBe(0); + }); +}); +``` + +Not reported: + +{/* good */} + +```ts +import { describe, expect, test } from "bun:test"; + +function expectEmpty(cart: Cart) { + expect(cart.items).toHaveLength(0); +} + +describe("cart", () => { + test("starts empty", () => { + const cart = createCart(); + expectEmpty(cart); + expect(cart.total).toBe(0); + }); +}); +``` + +## Options + +| Option | Default | What it does | +| ------------------------------ | ------- | ---------------------------------------------------------------------------------------------------------- | +| `additionalTestBlockFunctions` | `[]` | Names of other functions to treat like `test`. An `expect` in a callback passed to one of them is allowed. | + +Use it for your own wrappers around `test`, or to allow `expect` in a hook. A name can have dots. + +{/* good {"options": [{"additionalTestBlockFunctions": ["beforeEach", "admin.test"]}]} */} + +```ts +import { beforeEach, expect } from "bun:test"; + +beforeEach(() => { + expect(database.isConnected()).toBe(true); +}); + +admin.test("can delete a user", () => { + expect(deleteUser(1)).toBe(true); +}); +``` diff --git a/docs/lint/rules/bun-test/no-test-prefixes.mdx b/docs/lint/rules/bun-test/no-test-prefixes.mdx new file mode 100644 index 000000000000..a32effb2dc8a --- /dev/null +++ b/docs/lint/rules/bun-test/no-test-prefixes.mdx @@ -0,0 +1,50 @@ +--- +title: "bun:test/no-test-prefixes" +description: "Require `.skip` instead of the `x` prefix" +--- + +`xit`, `xtest` and `xdescribe` are short names for `it.skip`, `test.skip` and `describe.skip`. One extra letter is easy to miss in a review, and a search for `.skip` does not find it. + +`--fix` rewrites `xtest` to `test.skip` if the file already imports `test`. The same goes for `xit` with `it` and `xdescribe` with `describe`. + +## Examples + +Reported: + +{/* bad {"count": 2} */} + +```ts +import { expect, xdescribe, xtest } from "bun:test"; + +xtest("subtracts", () => { + expect(3 - 1).toBe(2); +}); + +xdescribe("division", () => {}); +``` + +Not reported: + +{/* good */} + +```ts +import { describe, expect, test } from "bun:test"; + +test.skip("subtracts", () => { + expect(3 - 1).toBe(2); +}); + +describe.skip("division", () => {}); +``` + +What follows the name is kept. This is reported, and the long form is `it.skip.each`: + +{/* bad */} + +```ts +import { expect, xit } from "bun:test"; + +xit.each([1, 2, 3])("%i is positive", n => { + expect(n).toBeGreaterThan(0); +}); +``` diff --git a/docs/lint/rules/bun-test/no-test-return-statement.mdx b/docs/lint/rules/bun-test/no-test-return-statement.mdx new file mode 100644 index 000000000000..ef02d049f7af --- /dev/null +++ b/docs/lint/rules/bun-test/no-test-return-statement.mdx @@ -0,0 +1,66 @@ +--- +title: "bun:test/no-test-return-statement" +description: "Disallow `return expect(..)` in a test" +--- + +`bun test` ignores what a test returns, unless it is a promise to wait for. A matcher such as `toBe()` returns nothing, so the `return` in `return expect(total).toBe(3)` does nothing. It only makes the reader wonder what the value is for. + +The rule reports `return expect(..).matcher(..)` in the callback of `test` or `it`, and in a function declaration that is passed to one of them. + +## Examples + +Reported: + +{/* bad */} + +```ts +import { expect, test } from "bun:test"; + +test("adds the prices", () => { + const total = sum([1, 2]); + return expect(total).toBe(3); +}); +``` + +{/* bad */} + +```ts +import { expect, test } from "bun:test"; + +test("adds the prices", addsThePrices); + +function addsThePrices() { + return expect(sum([1, 2])).toBe(3); +} +``` + +Not reported: + +{/* good */} + +```ts +import { expect, test } from "bun:test"; + +test("adds the prices", () => { + const total = sum([1, 2]); + expect(total).toBe(3); +}); +``` + +Other `return` statements are not reported. That includes a returned promise, which `bun test` waits for: + +{/* good */} + +```ts +import { expect, test } from "bun:test"; + +test("loads the prices", () => { + return expect(loadPrices()).resolves.toEqual([1, 2]); +}); + +test("loads the taxes", () => { + return loadTaxes().then(taxes => { + expect(taxes).toEqual([0.2]); + }); +}); +``` diff --git a/docs/lint/rules/bun-test/no-unneeded-async-expect-function.mdx b/docs/lint/rules/bun-test/no-unneeded-async-expect-function.mdx new file mode 100644 index 000000000000..3cfc8a8f8743 --- /dev/null +++ b/docs/lint/rules/bun-test/no-unneeded-async-expect-function.mdx @@ -0,0 +1,57 @@ +--- +title: "bun:test/no-unneeded-async-expect-function" +description: "Disallow an async function around a single awaited call in `expect`" +--- + +`expect` takes a promise. An async function that only awaits one call gives `expect` the same promise as the call itself, with more to read. + +`--fix` replaces the function with the call. + +## Examples + +Reported: + +{/* bad {"count": 2} */} + +```ts +import { expect, test } from "bun:test"; + +test("rejects an empty name", async () => { + await expect(async () => { + await saveUser({ name: "" }); + }).rejects.toThrow("name is required"); + + await expect(async () => await saveUser({ name: "" })).rejects.toThrow(); +}); +``` + +Not reported: + +{/* good */} + +```ts +import { expect, test } from "bun:test"; + +test("rejects an empty name", async () => { + await expect(saveUser({ name: "" })).rejects.toThrow("name is required"); +}); +``` + +A function that does more than await one call is needed, and is not reported: + +{/* good */} + +```ts +import { expect, test } from "bun:test"; + +test("rejects a second save", async () => { + await expect(async () => { + await saveUser({ name: "Ada" }); + await saveUser({ name: "Ada" }); + }).rejects.toThrow("name is taken"); +}); +``` + +## When not to use it + +The two forms differ if the call can throw before it returns a promise. The async function turns that error into a rejection. Without the function, the error is thrown before `expect` runs. diff --git a/docs/lint/rules/bun-test/padding-around-after-all-blocks.mdx b/docs/lint/rules/bun-test/padding-around-after-all-blocks.mdx new file mode 100644 index 000000000000..a7b9a7a5e14c --- /dev/null +++ b/docs/lint/rules/bun-test/padding-around-after-all-blocks.mdx @@ -0,0 +1,52 @@ +--- +title: "bun:test/padding-around-after-all-blocks" +description: "Require a blank line before each `afterAll` call" +--- + +An `afterAll` hook written right under another statement is easy to miss when you look for a file's cleanup code. A blank line above it sets it apart. + +Only the line above the call is checked. Comments directly above the call belong to it, so the blank line goes above them. An `afterAll` that is the first statement of a file or of a `describe` callback needs no blank line. + +`--fix` inserts the blank line. + +## Examples + +Reported: + +{/* bad */} + +```ts +import { afterAll, describe } from "bun:test"; + +describe("database", () => { + const db = connect(); + afterAll(() => db.close()); +}); +``` + +Two `afterAll` calls in a row need a blank line between them too: + +{/* bad {"count": 2} */} + +```ts +import { afterAll } from "bun:test"; + +const server = startServer(); +afterAll(() => server.stop()); +afterAll(() => removeTempFiles()); +``` + +Not reported: + +{/* good */} + +```ts +import { afterAll, describe } from "bun:test"; + +describe("database", () => { + const db = connect(); + + // Close the connection so the process can exit. + afterAll(() => db.close()); +}); +``` diff --git a/docs/lint/rules/bun-test/padding-around-test-blocks.mdx b/docs/lint/rules/bun-test/padding-around-test-blocks.mdx new file mode 100644 index 000000000000..df42cd43e1de --- /dev/null +++ b/docs/lint/rules/bun-test/padding-around-test-blocks.mdx @@ -0,0 +1,51 @@ +--- +title: "bun:test/padding-around-test-blocks" +description: "Require a blank line before each `test` and `it` call" +--- + +Tests written right under each other, or right under setup code, are hard to tell apart when you scan a file. A blank line above each `test` or `it` call separates them. + +Only the line above the call is checked. Comments directly above the call belong to it, so the blank line goes above them. A test that is the first statement of a file or of a `describe` callback needs no blank line. + +`--fix` inserts the blank line. + +## Examples + +Reported: + +{/* bad {"count": 2} */} + +```ts +import { describe, expect, test } from "bun:test"; + +describe("cart", () => { + const cart = createCart(); + test("starts empty", () => { + expect(cart.items).toEqual([]); + }); + test.skip("adds an item", () => { + expect(cart.add("apple").items).toEqual(["apple"]); + }); +}); +``` + +Not reported: + +{/* good */} + +```ts +import { describe, expect, test } from "bun:test"; + +describe("cart", () => { + const cart = createCart(); + + test("starts empty", () => { + expect(cart.items).toEqual([]); + }); + + // Waits for the new storage layer. + test.skip("adds an item", () => { + expect(cart.add("apple").items).toEqual(["apple"]); + }); +}); +``` diff --git a/docs/lint/rules/bun-test/prefer-called-once.mdx b/docs/lint/rules/bun-test/prefer-called-once.mdx new file mode 100644 index 000000000000..12806a104f39 --- /dev/null +++ b/docs/lint/rules/bun-test/prefer-called-once.mdx @@ -0,0 +1,45 @@ +--- +title: "bun:test/prefer-called-once" +description: "Require `toHaveBeenCalledOnce()` instead of `toHaveBeenCalledTimes(1)`" +--- + +`toHaveBeenCalledTimes(1)` and `toHaveBeenCalledOnce()` assert the same thing. This rule picks the shorter one, so a project writes it one way. The alias `toBeCalledTimes(1)` is reported too. + +`--fix` rewrites both to `toHaveBeenCalledOnce()`. + +## Examples + +Reported: + +{/* bad */} + +```ts +import { expect, mock, test } from "bun:test"; + +test("notifies the listener", () => { + const listener = mock(); + publish("ready", listener); + expect(listener).toHaveBeenCalledTimes(1); +}); +``` + +Not reported: + +{/* good */} + +```ts +import { expect, mock, test } from "bun:test"; + +test("notifies the listener", () => { + const listener = mock(); + publish("ready", listener); + expect(listener).toHaveBeenCalledOnce(); + + publish("done", listener); + expect(listener).toHaveBeenCalledTimes(2); +}); +``` + +## When not to use it + +`bun:test/prefer-called-times` asks for the opposite. Turn on one of the two, not both. diff --git a/docs/lint/rules/bun-test/prefer-called-times.mdx b/docs/lint/rules/bun-test/prefer-called-times.mdx new file mode 100644 index 000000000000..684ee7446974 --- /dev/null +++ b/docs/lint/rules/bun-test/prefer-called-times.mdx @@ -0,0 +1,42 @@ +--- +title: "bun:test/prefer-called-times" +description: "Require `toHaveBeenCalledTimes(1)` instead of `toHaveBeenCalledOnce()`" +--- + +`toHaveBeenCalledOnce()` and `toHaveBeenCalledTimes(1)` assert the same thing. With `toHaveBeenCalledTimes()` every call count is written the same way, and a new expected count is a change of one number. + +`--fix` rewrites the matcher. + +## Examples + +Reported: + +{/* bad */} + +```ts +import { expect, mock, test } from "bun:test"; + +test("does not retry after a success", () => { + const request = mock(); + retryFetch(request); + expect(request).toHaveBeenCalledOnce(); +}); +``` + +Not reported: + +{/* good */} + +```ts +import { expect, mock, test } from "bun:test"; + +test("does not retry after a success", () => { + const request = mock(); + retryFetch(request); + expect(request).toHaveBeenCalledTimes(1); +}); +``` + +## When not to use it + +`bun:test/prefer-called-once` asks for the opposite. Turn on one of the two, not both. diff --git a/docs/lint/rules/bun-test/prefer-called-with.mdx b/docs/lint/rules/bun-test/prefer-called-with.mdx new file mode 100644 index 000000000000..f0920c0d15d0 --- /dev/null +++ b/docs/lint/rules/bun-test/prefer-called-with.mdx @@ -0,0 +1,46 @@ +--- +title: "bun:test/prefer-called-with" +description: "Require `toHaveBeenCalledWith()` instead of `toHaveBeenCalled()`" +--- + +`toHaveBeenCalled()` passes whatever the mock was called with, so the test keeps passing when the code starts to pass the wrong arguments. `toHaveBeenCalledWith(...)` checks the arguments too. The alias `toBeCalled()` is reported the same way. + +`.not.toHaveBeenCalled()` is allowed: a mock that never ran has no arguments to check. + +The rename is offered as a suggestion, which `--fix-suggestions` applies. It leaves the parentheses empty. Fill in the arguments you expect: `toHaveBeenCalledWith()` only passes for a call without arguments. + +## Examples + +Reported: + +{/* bad */} + +```ts +import { expect, mock, test } from "bun:test"; + +test("sends a welcome email", () => { + const send = mock(); + register("ada@example.com", send); + expect(send).toHaveBeenCalled(); +}); +``` + +Not reported: + +{/* good */} + +```ts +import { expect, mock, test } from "bun:test"; + +test("sends a welcome email", () => { + const send = mock(); + register("ada@example.com", send); + expect(send).toHaveBeenCalledWith("ada@example.com", "welcome"); +}); + +test("sends nothing for an invalid address", () => { + const send = mock(); + register("not an address", send); + expect(send).not.toHaveBeenCalled(); +}); +``` diff --git a/docs/lint/rules/bun-test/prefer-comparison-matcher.mdx b/docs/lint/rules/bun-test/prefer-comparison-matcher.mdx new file mode 100644 index 000000000000..e797be91f50d --- /dev/null +++ b/docs/lint/rules/bun-test/prefer-comparison-matcher.mdx @@ -0,0 +1,56 @@ +--- +title: "bun:test/prefer-comparison-matcher" +description: "Require matchers such as `toBeGreaterThan()` instead of comparing inside `expect()`" +--- + +`expect(price < 100).toBe(true)` compares first and hands `expect` only the result. When it fails, the message says that `false` is not `true`, and you do not see the price. `expect(price).toBeLessThan(100)` prints both numbers. + +The rule reports `>`, `>=`, `<` and `<=` inside `expect()` when the result is checked against `true` or `false` with `toBe()`, `toEqual()` or `toStrictEqual()`. + +The rewrite is offered as a suggestion, which `--fix-suggestions` applies. + +## Examples + +Reported: + +{/* bad {"count": 2} */} + +```ts +import { expect, test } from "bun:test"; + +test("applies the discount", () => { + const price = discounted(100); + expect(price < 100).toBe(true); + expect(price < 0).toBe(false); +}); +``` + +Not reported: + +{/* good */} + +```ts +import { expect, test } from "bun:test"; + +test("applies the discount", () => { + const price = discounted(100); + expect(price).toBeLessThan(100); + expect(price).toBeGreaterThanOrEqual(0); +}); +``` + +The comparison matchers only take numbers and bigints, so a comparison with a string literal or a template literal is left alone: + +{/* good */} + +```ts +import { expect, test } from "bun:test"; + +test("sorts after the first entry", () => { + expect(lastName() > "Adams").toBe(true); +}); +``` + +## When not to use it + +The rule cannot see types. It also reports comparisons of strings or dates that are held in variables, and the comparison matchers reject those values. diff --git a/docs/lint/rules/bun-test/prefer-describe-function-title.mdx b/docs/lint/rules/bun-test/prefer-describe-function-title.mdx new file mode 100644 index 000000000000..12405bc6117c --- /dev/null +++ b/docs/lint/rules/bun-test/prefer-describe-function-title.mdx @@ -0,0 +1,53 @@ +--- +title: "bun:test/prefer-describe-function-title" +description: "Require `describe(fn, ...)` instead of a string with the name of an imported function" +--- + +`describe` takes a function or a class as its title and uses its name. A string that repeats the name of something you import stays behind when the function is renamed, and the test output then names a function that no longer exists. + +The rule reports a `describe` title that is a string equal to an imported name, and also `slugify.name`. + +`--fix-suggestions` replaces the title with the name, unless the import is type-only. + +## Examples + +Reported: + +{/* bad */} + +```ts +import { describe, expect, test } from "bun:test"; +import { slugify } from "./slugify"; + +describe("slugify", () => { + test("replaces spaces", () => { + expect(slugify("a b")).toBe("a-b"); + }); +}); +``` + +{/* bad */} + +```ts +import { describe } from "bun:test"; +import { slugify } from "./slugify"; + +describe(slugify.name, () => {}); +``` + +Not reported: + +{/* good */} + +```ts +import { describe, expect, test } from "bun:test"; +import { slugify } from "./slugify"; + +describe(slugify, () => { + test("replaces spaces", () => { + expect(slugify("a b")).toBe("a-b"); + }); +}); + +describe("slugify with options", () => {}); +``` diff --git a/docs/lint/rules/bun-test/prefer-each.mdx b/docs/lint/rules/bun-test/prefer-each.mdx new file mode 100644 index 000000000000..589007996792 --- /dev/null +++ b/docs/lint/rules/bun-test/prefer-each.mdx @@ -0,0 +1,50 @@ +--- +title: "bun:test/prefer-each" +description: "Require `test.each` and `describe.each` instead of a loop around tests" +--- + +A `for` loop that declares tests works, but the table of cases is hidden in the loop and every title has to be built by hand. `test.each` and `describe.each` take the table as an argument and fill in the title for each row. + +The rule reports `for`, `for...of` and `for...in` loops that contain a `test`, a `describe` or a hook such as `beforeEach`. A loop inside a test is fine, and `cases.forEach(...)` around a test is not reported. + +## Examples + +Reported: + +{/* bad */} + +```ts +import { expect, test } from "bun:test"; + +const cases = [ + [1, 2], + [2, 4], +]; + +for (const [input, expected] of cases) { + test(`doubles ${input}`, () => { + expect(input * 2).toBe(expected); + }); +} +``` + +Not reported: + +{/* good */} + +```ts +import { expect, test } from "bun:test"; + +test.each([ + [1, 2], + [2, 4], +])("doubles %i", (input, expected) => { + expect(input * 2).toBe(expected); +}); + +test("every price is positive", () => { + for (const price of [5, 10]) { + expect(price).toBeGreaterThan(0); + } +}); +``` diff --git a/docs/lint/rules/bun-test/prefer-ending-with-an-expect.mdx b/docs/lint/rules/bun-test/prefer-ending-with-an-expect.mdx new file mode 100644 index 000000000000..c9cd5a254927 --- /dev/null +++ b/docs/lint/rules/bun-test/prefer-ending-with-an-expect.mdx @@ -0,0 +1,114 @@ +--- +title: "bun:test/prefer-ending-with-an-expect" +description: "Require the last statement of a test to be an assertion" +--- + +Nothing checks what a test does after its last assertion. If the test ends with a click, a request or a save, that step can break and the test still passes. + +The last statement of the test has to be a call to `expect`, with or without `await`. A test with an empty body is reported, and so is one that ends with an `if`, a loop or a `return`, even if there is an `expect` inside. + +## Examples + +Reported: + +{/* bad */} + +```ts +import { expect, test } from "bun:test"; + +test("renames the user", async () => { + const user = await createUser("Ada"); + expect(user.name).toBe("Ada"); + await user.rename("Grace"); +}); +``` + +{/* bad */} + +```ts +import { expect, test } from "bun:test"; + +test("loads the user", () => { + return expect(loadUser(1)).resolves.toBeDefined(); +}); +``` + +Not reported: + +{/* good */} + +```ts +import { expect, test } from "bun:test"; + +test("renames the user", async () => { + const user = await createUser("Ada"); + await user.rename("Grace"); + expect(user.name).toBe("Grace"); +}); + +test("loads the user", async () => { + await expect(loadUser(1)).resolves.toBeDefined(); +}); +``` + +## Options + +| Option | Default | What it does | +| ------------------------------ | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `assertFunctionNames` | see below | The functions that count as an assertion, besides `expect` itself. | +| `additionalTestBlockFunctions` | `[]` | Other functions to check like `test`. Only names that `bun:test` defines have an effect, such as `afterEach`. Your own wrappers around `test` are not checked. | + +Without `assertFunctionNames`, any call with `expect` somewhere in its name counts: + +{/* good */} + +```ts +import { test } from "bun:test"; + +test("has a valid user", () => { + expectValidUser(loadUser(1)); +}); + +test("responds with 200", async () => { + await request(app).get("/users").expect(200); +}); +``` + +With `assertFunctionNames`, only `expect` and the names in the list count. A name can be a dotted path. `*` stands for any letters and digits in one part of the path, and `**` for any number of parts. + +{/* good {"options": [{"assertFunctionNames": ["assert*", "request.**.expect"]}]} */} + +```ts +import { test } from "bun:test"; + +test("has a valid user", () => { + assertValidUser(loadUser(1)); +}); + +test("responds with 200", async () => { + await request(app).get("/users").expect(200); +}); +``` + +{/* bad {"options": [{"assertFunctionNames": ["assert*"]}]} */} + +```ts +import { test } from "bun:test"; + +test("has a valid user", () => { + expectValidUser(loadUser(1)); +}); +``` + +With `additionalTestBlockFunctions`, a hook can be held to the same rule: + +{/* bad {"options": [{"additionalTestBlockFunctions": ["afterEach"]}]} */} + +```ts +import { afterEach, expect } from "bun:test"; + +afterEach(() => { + expect(liveSockets()).toBe(0); + resetDatabase(); +}); +``` diff --git a/docs/lint/rules/bun-test/prefer-equality-matcher.mdx b/docs/lint/rules/bun-test/prefer-equality-matcher.mdx new file mode 100644 index 000000000000..52ca9111b68b --- /dev/null +++ b/docs/lint/rules/bun-test/prefer-equality-matcher.mdx @@ -0,0 +1,48 @@ +--- +title: "bun:test/prefer-equality-matcher" +description: "Require `toBe` or `toEqual` instead of comparing with `===` inside `expect`" +--- + +`expect(a === b).toBe(true)` compares the values before `expect` sees them. When the test fails, the output says that `false` is not `true`. It does not show the two values that differ. + +The rule reports `===` and `!==` inside `expect` when the result is matched against `true` or `false` with `toBe`, `toEqual` or `toStrictEqual`. `==` and `!=` are not reported. + +`--fix-suggestions` rewrites the assertion with `toBe`. Your editor also offers `toEqual` and `toStrictEqual`. + +## Examples + +Reported: + +{/* bad {"count": 2} */} + +```ts +import { expect, test } from "bun:test"; + +test("the session belongs to the user", () => { + expect(session.userId === user.id).toBe(true); + expect(user.role !== "admin").toBe(true); +}); +``` + +Not reported: + +{/* good */} + +```ts +import { expect, test } from "bun:test"; + +test("the session belongs to the user", () => { + expect(session.userId).toBe(user.id); + expect(user.role).not.toBe("admin"); +}); +``` + +{/* good */} + +```ts +import { expect, test } from "bun:test"; + +test("the port matches the string in the config", () => { + expect(server.port == config.port).toBe(true); +}); +``` diff --git a/docs/lint/rules/bun-test/prefer-expect-assertions.mdx b/docs/lint/rules/bun-test/prefer-expect-assertions.mdx new file mode 100644 index 000000000000..771c4b173723 --- /dev/null +++ b/docs/lint/rules/bun-test/prefer-expect-assertions.mdx @@ -0,0 +1,121 @@ +--- +title: "bun:test/prefer-expect-assertions" +description: "Require `expect.assertions()` or `expect.hasAssertions()` at the start of every test" +--- + +An `expect` in a `catch`, a callback or a loop may never run, and a test in which no `expect` runs passes. `expect.hasAssertions()` makes the test fail unless at least one assertion ran. `expect.assertions(2)` makes it fail unless exactly two ran. + +The first statement of every test has to be one of the two. The rule also reports `expect.hasAssertions()` with an argument, and `expect.assertions()` with anything but one number. + +`--fix-suggestions` adds `expect.hasAssertions()`. + +## Examples + +Reported: + +{/* bad */} + +```ts +import { expect, test } from "bun:test"; + +test("rejects a wrong password", async () => { + try { + await login("ada", "wrong"); + } catch (error) { + expect(error).toBeInstanceOf(AuthError); + } +}); +``` + +{/* bad */} + +```ts +import { expect, test } from "bun:test"; + +test("adds", () => { + expect.assertions("1"); + expect(1 + 1).toBe(2); +}); +``` + +Not reported: + +{/* good */} + +```ts +import { expect, test } from "bun:test"; + +test("rejects a wrong password", async () => { + expect.assertions(1); + try { + await login("ada", "wrong"); + } catch (error) { + expect(error).toBeInstanceOf(AuthError); + } +}); +``` + +A `beforeEach` or `afterEach` hook that calls `expect.hasAssertions()` covers the tests after it in the same `describe`, or in the file if it is at the top level: + +{/* good */} + +```ts +import { beforeEach, expect, test } from "bun:test"; + +beforeEach(() => { + expect.hasAssertions(); +}); + +test("adds", () => { + expect(1 + 1).toBe(2); +}); +``` + +## Options + +Without options, every test is checked. With one or more of them, only the tests that match at least one are checked. + +| Option | Default | What it does | +| ----------------------------------- | ------- | -------------------------------------------------------- | +| `onlyFunctionsWithAsyncKeyword` | `false` | Check tests that are `async` functions. | +| `onlyFunctionsWithExpectInCallback` | `false` | Check tests that call `expect` inside a nested function. | +| `onlyFunctionsWithExpectInLoop` | `false` | Check tests that call `expect` inside a loop. | + +{/* bad {"options": [{"onlyFunctionsWithExpectInCallback": true}]} */} + +```ts +import { expect, test } from "bun:test"; + +test("emits the saved user", () => { + store.on("save", user => { + expect(user.name).toBe("Ada"); + }); + store.save({ name: "Ada" }); +}); +``` + +{/* bad {"options": [{"onlyFunctionsWithExpectInLoop": true}]} */} + +```ts +import { expect, test } from "bun:test"; + +test("every price is positive", () => { + for (const price of loadPrices()) { + expect(price).toBeGreaterThan(0); + } +}); +``` + +{/* good {"options": [{"onlyFunctionsWithAsyncKeyword": true, "onlyFunctionsWithExpectInLoop": true}]} */} + +```ts +import { expect, test } from "bun:test"; + +test("adds", () => { + expect(1 + 1).toBe(2); +}); +``` + +## When not to use it + +In a test that calls `expect` directly, one statement after another, the extra line proves nothing. Use the options to check only the tests where an assertion can be skipped. diff --git a/docs/lint/rules/bun-test/prefer-expect-resolves.mdx b/docs/lint/rules/bun-test/prefer-expect-resolves.mdx new file mode 100644 index 000000000000..a478d1c5c8cd --- /dev/null +++ b/docs/lint/rules/bun-test/prefer-expect-resolves.mdx @@ -0,0 +1,44 @@ +--- +title: "bun:test/prefer-expect-resolves" +description: "Require `await expect(promise).resolves` instead of `expect(await promise)`" +--- + +In `expect(await promise)`, a rejected promise throws before `expect` is called. The test fails with that error alone, and nothing says which assertion it belongs to. With `.resolves`, the promise goes to `expect`, and a rejection fails that assertion. + +`--fix-suggestions` rewrites the assertion. + +## Examples + +Reported: + +{/* bad */} + +```ts +import { expect, test } from "bun:test"; + +test("loads the user", async () => { + expect(await loadUser(1)).toEqual({ id: 1, name: "Ada" }); +}); +``` + +Not reported: + +{/* good */} + +```ts +import { expect, test } from "bun:test"; + +test("loads the user", async () => { + await expect(loadUser(1)).resolves.toEqual({ id: 1, name: "Ada" }); +}); + +test("loads the user and checks two fields", async () => { + const user = await loadUser(1); + expect(user.id).toBe(1); + expect(user.name).toBe("Ada"); +}); +``` + +## When not to use it + +`await` accepts any value, but `.resolves` fails if it does not get a promise. If a function returns a plain value in some cases, keep `expect(await ...)` there. diff --git a/docs/lint/rules/bun-test/prefer-expect-type-of.mdx b/docs/lint/rules/bun-test/prefer-expect-type-of.mdx new file mode 100644 index 000000000000..c341e8e3905d --- /dev/null +++ b/docs/lint/rules/bun-test/prefer-expect-type-of.mdx @@ -0,0 +1,38 @@ +--- +title: "bun:test/prefer-expect-type-of" +description: "Require `toBeTypeOf` instead of `expect(typeof value).toBe(...)`" +--- + +`expect(typeof value).toBe("string")` hands `expect` only the name of the type. When the test fails, the output compares `"number"` with `"string"` and does not show the value. `expect(value).toBeTypeOf("string")` shows it. + +The rule reports `typeof` inside `expect` with `toBe` or `toEqual`, also after `.not`. + +`--fix` rewrites the assertion. + +## Examples + +Reported: + +{/* bad {"count": 2} */} + +```ts +import { expect, test } from "bun:test"; + +test("the user has a name", () => { + expect(typeof user.name).toBe("string"); + expect(typeof user.save).not.toEqual("undefined"); +}); +``` + +Not reported: + +{/* good */} + +```ts +import { expect, test } from "bun:test"; + +test("the user has a name", () => { + expect(user.name).toBeTypeOf("string"); + expect(user.save).not.toBeTypeOf("undefined"); +}); +``` diff --git a/docs/lint/rules/bun-test/prefer-hooks-in-order.mdx b/docs/lint/rules/bun-test/prefer-hooks-in-order.mdx new file mode 100644 index 000000000000..ebd155d3b1d0 --- /dev/null +++ b/docs/lint/rules/bun-test/prefer-hooks-in-order.mdx @@ -0,0 +1,62 @@ +--- +title: "bun:test/prefer-hooks-in-order" +description: "Require hooks to be written in the order they run" +--- + +`bun test` runs hooks in a fixed order: `beforeAll`, `beforeEach`, `afterEach`, `afterAll`. If a file lists them in another order, you read the teardown before the setup it undoes, and the order on the page is not the order that runs. + +Each `describe` block is checked on its own. Only hooks that follow each other are compared: any other call between two hooks, such as a test, starts a new group. + +## Examples + +Reported: + +{/* bad */} + +```ts +import { afterEach, beforeEach } from "bun:test"; + +afterEach(() => { + db.rollback(); +}); + +beforeEach(() => { + db.begin(); +}); +``` + +Not reported: + +{/* good */} + +```ts +import { afterEach, beforeEach } from "bun:test"; + +beforeEach(() => { + db.begin(); +}); + +afterEach(() => { + db.rollback(); +}); +``` + +The test between these two hooks starts a new group, so this rule says nothing. `bun:test/prefer-hooks-on-top` reports it. + +{/* good */} + +```ts +import { afterEach, beforeEach, test } from "bun:test"; + +afterEach(() => { + db.rollback(); +}); + +test("saves a user", () => { + db.save(user); +}); + +beforeEach(() => { + db.begin(); +}); +``` diff --git a/docs/lint/rules/bun-test/prefer-hooks-on-top.mdx b/docs/lint/rules/bun-test/prefer-hooks-on-top.mdx new file mode 100644 index 000000000000..380b8593a816 --- /dev/null +++ b/docs/lint/rules/bun-test/prefer-hooks-on-top.mdx @@ -0,0 +1,64 @@ +--- +title: "bun:test/prefer-hooks-on-top" +description: "Require hooks to come before the tests in their block" +--- + +A hook applies to every test in its block, wherever it is written. A `beforeEach` below a test still runs before that test, and someone who reads the file from the top does not know that yet. + +Each `describe` block is checked on its own. + +## Examples + +Reported: + +{/* bad */} + +```ts +import { beforeEach, expect, test } from "bun:test"; + +test("starts empty", () => { + expect(cart.items).toHaveLength(0); +}); + +beforeEach(() => { + cart.clear(); +}); +``` + +Not reported: + +{/* good */} + +```ts +import { beforeEach, expect, test } from "bun:test"; + +beforeEach(() => { + cart.clear(); +}); + +test("starts empty", () => { + expect(cart.items).toHaveLength(0); +}); +``` + +A hook at the top of a `describe` block is fine, even if there is a test before the block: + +{/* good */} + +```ts +import { beforeEach, describe, expect, test } from "bun:test"; + +test("loads the shop", () => { + expect(shop.ready).toBe(true); +}); + +describe("cart", () => { + beforeEach(() => { + cart.clear(); + }); + + test("starts empty", () => { + expect(cart.items).toHaveLength(0); + }); +}); +``` diff --git a/docs/lint/rules/bun-test/prefer-lowercase-title.mdx b/docs/lint/rules/bun-test/prefer-lowercase-title.mdx new file mode 100644 index 000000000000..15c81b0f915b --- /dev/null +++ b/docs/lint/rules/bun-test/prefer-lowercase-title.mdx @@ -0,0 +1,101 @@ +--- +title: "bun:test/prefer-lowercase-title" +description: "Require the titles of tests and `describe` blocks to start with a lowercase letter" +--- + +`bun test` prints a title after the titles of the blocks around it, as in `cart > adds an item`. A capital in the middle of that line reads like the start of a new sentence, and output where some titles have one and others do not is harder to scan. + +`--fix` makes the first letter lowercase. It leaves the titles alone in a file that calls `toMatchSnapshot()` or `toThrowErrorMatchingSnapshot()`, because snapshots are stored under the title of their test. + +## Examples + +Reported: + +{/* bad */} + +```ts +import { expect, test } from "bun:test"; + +test("Adds an item", () => { + expect(cart.add(item)).toBe(1); +}); +``` + +Not reported: + +{/* good */} + +```ts +import { expect, test } from "bun:test"; + +test("adds an item", () => { + expect(cart.add(item)).toBe(1); +}); +``` + +## Options + +| Option | Default | What it does | +| ----------------------------- | ------- | ---------------------------------------------------------------------- | +| `ignore` | `[]` | Functions to leave alone: `"describe"`, `"test"`, `"it"`. | +| `allowedPrefixes` | `[]` | Allow a title that starts with one of these strings. | +| `ignoreTopLevelDescribe` | `false` | Allow a capital in a `describe` that is not inside another block. | +| `lowercaseFirstCharacterOnly` | `true` | Set it to `false` to report an uppercase letter anywhere in the title. | + +`allowedPrefixes` is for titles that start with a name you cannot change: + +{/* good {"options": [{"allowedPrefixes": ["GET", "POST"]}]} */} + +```ts +import { test } from "bun:test"; + +test("GET /users returns a list", () => {}); +``` + +With `ignoreTopLevelDescribe`, the outer `describe` can be named after a class or a component. Everything inside it is still checked: + +{/* good {"options": [{"ignoreTopLevelDescribe": true}]} */} + +```ts +import { describe, test } from "bun:test"; + +describe("Cart", () => { + test("adds an item", () => {}); +}); +``` + +{/* bad {"options": [{"ignoreTopLevelDescribe": true}], "count": 1} */} + +```ts +import { describe, test } from "bun:test"; + +describe("Cart", () => { + describe("Checkout", () => { + test("adds an item", () => {}); + }); +}); +``` + +With `{ "ignore": ["describe"] }`, no `describe` is checked: + +{/* good {"options": [{"ignore": ["describe"]}]} */} + +```ts +import { describe, test } from "bun:test"; + +describe("Cart", () => { + describe("Checkout", () => { + test("adds an item", () => {}); + }); +}); +``` + +With `{ "lowercaseFirstCharacterOnly": false }`: + +{/* bad {"options": [{"lowercaseFirstCharacterOnly": false}]} */} + +```ts +import { test } from "bun:test"; + +test("adds an Item", () => {}); +``` diff --git a/docs/lint/rules/bun-test/prefer-mock-promise-shorthand.mdx b/docs/lint/rules/bun-test/prefer-mock-promise-shorthand.mdx new file mode 100644 index 000000000000..f1ae471afb53 --- /dev/null +++ b/docs/lint/rules/bun-test/prefer-mock-promise-shorthand.mdx @@ -0,0 +1,48 @@ +--- +title: "bun:test/prefer-mock-promise-shorthand" +description: "Require `mockResolvedValue()` and `mockRejectedValue()` for mocks that return a promise" +--- + +A mock has methods for the two things an async function can do. `mockResolvedValue(user)` says the same as `mockReturnValue(Promise.resolve(user))` or `mockImplementation(() => Promise.resolve(user))`, without the wrapping. + +| Reported | Write | +| ------------------------------------------------------------------------------------- | ------------------------------------------------------ | +| `mockReturnValue(Promise.resolve(x))`, `mockImplementation(() => Promise.resolve(x))` | `mockResolvedValue(x)` | +| `mockReturnValue(Promise.reject(x))`, `mockImplementation(() => Promise.reject(x))` | `mockRejectedValue(x)` | +| `mockReturnValueOnce(..)`, `mockImplementationOnce(..)` | `mockResolvedValueOnce(x)`, `mockRejectedValueOnce(x)` | + +`--fix` rewrites the call. + +## Examples + +Reported: + +{/* bad {"count": 2} */} + +```ts +import { mock } from "bun:test"; + +const fetchUser = mock().mockReturnValue(Promise.resolve({ name: "Ada" })); +const saveUser = mock().mockImplementationOnce(() => Promise.reject(new Error("offline"))); +``` + +Not reported: + +{/* good */} + +```ts +import { mock } from "bun:test"; + +const fetchUser = mock().mockResolvedValue({ name: "Ada" }); +const saveUser = mock().mockRejectedValueOnce(new Error("offline")); +``` + +An implementation that takes parameters is allowed, because its result can depend on them: + +{/* good */} + +```ts +import { mock } from "bun:test"; + +const fetchUser = mock().mockImplementation((id: number) => Promise.resolve({ id })); +``` diff --git a/docs/lint/rules/bun-test/prefer-mock-return-shorthand.mdx b/docs/lint/rules/bun-test/prefer-mock-return-shorthand.mdx new file mode 100644 index 000000000000..4df855aa5c94 --- /dev/null +++ b/docs/lint/rules/bun-test/prefer-mock-return-shorthand.mdx @@ -0,0 +1,58 @@ +--- +title: "bun:test/prefer-mock-return-shorthand" +description: "Require `mockReturnValue()` for a mock that only returns a value" +--- + +`mockImplementation(() => value)` wraps a value in a function that does nothing but return it. `mockReturnValue(value)` says the same with less to read. The same goes for `mockImplementationOnce()` and `mockReturnValueOnce()`. + +`--fix` rewrites the call. If the returned value contains a function call or `new`, the mock is reported but not rewritten, because `mockReturnValue(createUser())` runs `createUser()` once instead of on every call. + +## Examples + +Reported: + +{/* bad {"count": 2} */} + +```ts +import { mock } from "bun:test"; + +const getPort = mock().mockImplementation(() => 3000); + +const getUser = mock().mockImplementationOnce(() => { + return { name: "Ada" }; +}); +``` + +Not reported: + +{/* good */} + +```ts +import { mock } from "bun:test"; + +const getPort = mock().mockReturnValue(3000); + +const getUser = mock().mockReturnValueOnce({ name: "Ada" }); +``` + +A function that takes parameters, is `async`, does more than return, or returns a variable that can change needs `mockImplementation()`, so it is not reported: + +{/* good */} + +```ts +import { mock } from "bun:test"; + +let visits = 0; + +const double = mock().mockImplementation((n: number) => n * 2); +const load = mock().mockImplementation(async () => "done"); +const countVisits = mock().mockImplementation(() => visits); +const nextVisit = mock().mockImplementation(() => { + visits += 1; + return "ok"; +}); +``` + +## When not to use it + +`mockReturnValue({ name: "Ada" })` returns the same object on every call, and `mockImplementation(() => ({ name: "Ada" }))` returns a new one. If the code under test changes what the mock returns, keep the function and disable the rule on that line. diff --git a/docs/lint/rules/bun-test/prefer-snapshot-hint.mdx b/docs/lint/rules/bun-test/prefer-snapshot-hint.mdx new file mode 100644 index 000000000000..67911e3e1989 --- /dev/null +++ b/docs/lint/rules/bun-test/prefer-snapshot-hint.mdx @@ -0,0 +1,75 @@ +--- +title: "bun:test/prefer-snapshot-hint" +description: "Require a hint on `toMatchSnapshot()` and `toThrowErrorMatchingSnapshot()`" +--- + +Snapshots in a `.snap` file are named after the test and numbered: `renders the menu 1`, `renders the menu 2`. With several in one test, a reviewer cannot tell which snapshot belongs to which `expect`, and a new one in the middle renumbers the ones after it. A hint becomes part of the name: `renders the menu: closed 1`. + +By default a hint is only required in a test that has more than one snapshot. Inline snapshots are not checked. + +## Examples + +Reported: + +{/* bad {"count": 2} */} + +```ts +import { expect, test } from "bun:test"; + +test("renders the menu", () => { + expect(renderMenu({ open: false })).toMatchSnapshot(); + expect(renderMenu({ open: true })).toMatchSnapshot(); +}); +``` + +Not reported: + +{/* good */} + +```ts +import { expect, test } from "bun:test"; + +test("renders the menu", () => { + expect(renderMenu({ open: false })).toMatchSnapshot("closed"); + expect(renderMenu({ open: true })).toMatchSnapshot("open"); +}); + +test("renders the footer", () => { + expect(renderFooter()).toMatchSnapshot(); +}); +``` + +If `toMatchSnapshot()` gets property matchers, the hint is the second argument: + +{/* good */} + +```ts +import { expect, test } from "bun:test"; + +test("creates users", () => { + expect(createUser("Ada")).toMatchSnapshot({ id: expect.any(Number) }, "first user"); + expect(createUser("Grace")).toMatchSnapshot({ id: expect.any(Number) }, "second user"); +}); +``` + +## Options + +```toml bunfig.toml +[lint.rules] +"bun:test/prefer-snapshot-hint" = ["error", "always"] +``` + +| Option | What it does | +| ------------------- | ------------------------------------------------------ | +| `"multi"` (default) | Require a hint if the test has more than one snapshot. | +| `"always"` | Require a hint on every snapshot. | + +{/* bad {"options": ["always"]} */} + +```ts +import { expect, test } from "bun:test"; + +test("renders the footer", () => { + expect(renderFooter()).toMatchSnapshot(); +}); +``` diff --git a/docs/lint/rules/bun-test/prefer-spy-on.mdx b/docs/lint/rules/bun-test/prefer-spy-on.mdx new file mode 100644 index 000000000000..dde2ab5869fa --- /dev/null +++ b/docs/lint/rules/bun-test/prefer-spy-on.mdx @@ -0,0 +1,63 @@ +--- +title: "bun:test/prefer-spy-on" +description: "Require `spyOn()` instead of assigning `jest.fn()` to a property" +--- + +`Date.now = jest.fn()` throws the real `Date.now` away. Unless you saved it and put it back by hand, every test that runs afterwards gets the mock. `spyOn(Date, "now")` remembers the original, so `mockRestore()` or `mock.restore()` brings it back. + +A spy calls the original function until you give it an implementation, so the suggestion turns `obj.save = jest.fn()` into `jest.spyOn(obj, 'save').mockImplementation()`. + +The rewrite is a suggestion in your editor. `--fix` does not apply it. + +## Examples + +Reported: + +{/* bad {"count": 2} */} + +```ts +import { beforeEach, jest } from "bun:test"; + +beforeEach(() => { + Date.now = jest.fn(() => 1700000000000); + console.warn = jest.fn(); +}); +``` + +Not reported: + +{/* good */} + +```ts +import { afterEach, beforeEach, mock, spyOn } from "bun:test"; + +beforeEach(() => { + spyOn(Date, "now").mockImplementation(() => 1700000000000); + spyOn(console, "warn").mockImplementation(() => {}); +}); + +afterEach(() => { + mock.restore(); +}); +``` + +A mock that is stored in a variable or written in an object literal replaces nothing, so it is not reported: + +{/* good */} + +```ts +import { jest } from "bun:test"; + +const onSave = jest.fn(); +const logger = { warn: jest.fn() }; +``` + +The rule looks for `jest.fn()` and `vi.fn()`. It does not report `mock()`, which has the same problem: + +{/* good */} + +```ts +import { mock } from "bun:test"; + +Date.now = mock(() => 1700000000000); +``` diff --git a/docs/lint/rules/bun-test/prefer-strict-boolean-matchers.mdx b/docs/lint/rules/bun-test/prefer-strict-boolean-matchers.mdx new file mode 100644 index 000000000000..fc53187a8d60 --- /dev/null +++ b/docs/lint/rules/bun-test/prefer-strict-boolean-matchers.mdx @@ -0,0 +1,40 @@ +--- +title: "bun:test/prefer-strict-boolean-matchers" +description: "Require `toBe(true)` and `toBe(false)` instead of `toBeTruthy()` and `toBeFalsy()`" +--- + +`toBeTruthy()` passes for `1`, `"no"` and `{}`. `toBeFalsy()` passes for `0`, `""`, `null` and `undefined`. If a function should return a boolean and starts to return `undefined`, a test with `toBeFalsy()` still passes. `toBe(false)` fails. + +The rewrite is a suggestion in your editor. `--fix` does not apply it, because a test that passed with a value that is not a boolean fails afterwards. + +## Examples + +Reported: + +{/* bad {"count": 2} */} + +```ts +import { expect, test } from "bun:test"; + +test("checks the password", () => { + expect(isValid("correct horse")).toBeTruthy(); + expect(isValid("")).toBeFalsy(); +}); +``` + +Not reported: + +{/* good */} + +```ts +import { expect, test } from "bun:test"; + +test("checks the password", () => { + expect(isValid("correct horse")).toBe(true); + expect(isValid("")).toBe(false); +}); +``` + +## When not to use it + +Some tests only care that there is a value: a match from `String.prototype.match()`, a non-empty string. There `toBeTruthy()` says what you mean. diff --git a/docs/lint/rules/bun-test/prefer-strict-equal.mdx b/docs/lint/rules/bun-test/prefer-strict-equal.mdx new file mode 100644 index 000000000000..7a3393694290 --- /dev/null +++ b/docs/lint/rules/bun-test/prefer-strict-equal.mdx @@ -0,0 +1,38 @@ +--- +title: "bun:test/prefer-strict-equal" +description: "Require `toStrictEqual()` instead of `toEqual()`" +--- + +`toEqual()` skips properties that are `undefined`, treats a hole in an array like `undefined`, and does not compare the classes of the two objects. So `expect({ name: "Ada", email: undefined }).toEqual({ name: "Ada" })` passes. `toStrictEqual()` checks all three, and that test fails. + +The rewrite is a suggestion in your editor. `--fix` does not apply it, because a test that passes with `toEqual()` can fail with `toStrictEqual()`. + +## Examples + +Reported: + +{/* bad */} + +```ts +import { expect, test } from "bun:test"; + +test("creates a user", () => { + expect(createUser("Ada")).toEqual({ name: "Ada" }); +}); +``` + +Not reported: + +{/* good */} + +```ts +import { expect, test } from "bun:test"; + +test("creates a user", () => { + expect(createUser("Ada")).toStrictEqual({ name: "Ada" }); +}); +``` + +## When not to use it + +If your code builds objects with optional properties set to `undefined`, or you compare class instances with plain objects on purpose, `toEqual()` is the matcher you want. diff --git a/docs/lint/rules/bun-test/prefer-to-be-falsy.mdx b/docs/lint/rules/bun-test/prefer-to-be-falsy.mdx new file mode 100644 index 000000000000..b8b097444bc4 --- /dev/null +++ b/docs/lint/rules/bun-test/prefer-to-be-falsy.mdx @@ -0,0 +1,44 @@ +--- +title: "bun:test/prefer-to-be-falsy" +description: "Require `toBeFalsy()` instead of `toBe(false)`" +--- + +`toBe(false)` passes only for the value `false`. `toBeFalsy()` passes for everything that counts as false in an `if`: `false`, `0`, `""`, `null`, `undefined`, and `NaN`. With this rule, a test does not depend on which of those values the code returns. + +The rule reports `toBe(false)`, `toEqual(false)`, and `toStrictEqual(false)`, with or without `.not`. + +`--fix-suggestions` replaces the matcher with `toBeFalsy()`. `--fix` does not, because the new matcher accepts more values. + +## Examples + +Reported: + +{/* bad {"count": 2} */} + +```ts +import { expect, test } from "bun:test"; + +test("a new cart has no discount", () => { + const cart = createCart(); + expect(cart.hasDiscount).toBe(false); + expect(cart.isOpen).not.toEqual(false); +}); +``` + +Not reported: + +{/* good */} + +```ts +import { expect, test } from "bun:test"; + +test("a new cart has no discount", () => { + const cart = createCart(); + expect(cart.hasDiscount).toBeFalsy(); + expect(cart.isOpen).not.toBeFalsy(); +}); +``` + +## When not to use it + +If a function must return exactly `false`, and `undefined` or `0` would be a bug, `toBe(false)` is the stricter and better check. Leave this rule off in that case. diff --git a/docs/lint/rules/bun-test/prefer-to-be-truthy.mdx b/docs/lint/rules/bun-test/prefer-to-be-truthy.mdx new file mode 100644 index 000000000000..9237d077ef51 --- /dev/null +++ b/docs/lint/rules/bun-test/prefer-to-be-truthy.mdx @@ -0,0 +1,44 @@ +--- +title: "bun:test/prefer-to-be-truthy" +description: "Require `toBeTruthy()` instead of `toBe(true)`" +--- + +`toBe(true)` passes only for the value `true`. `toBeTruthy()` passes for everything that counts as true in an `if`, such as `1`, `"yes"`, or an object. With this rule, a test does not depend on which of those values the code returns. + +The rule reports `toBe(true)`, `toEqual(true)`, and `toStrictEqual(true)`, with or without `.not`. + +`--fix-suggestions` replaces the matcher with `toBeTruthy()`. `--fix` does not, because the new matcher accepts more values. + +## Examples + +Reported: + +{/* bad {"count": 2} */} + +```ts +import { expect, test } from "bun:test"; + +test("an admin can delete posts", () => { + const user = createUser({ role: "admin" }); + expect(user.canDelete).toBe(true); + expect(user.isBanned).not.toEqual(true); +}); +``` + +Not reported: + +{/* good */} + +```ts +import { expect, test } from "bun:test"; + +test("an admin can delete posts", () => { + const user = createUser({ role: "admin" }); + expect(user.canDelete).toBeTruthy(); + expect(user.isBanned).not.toBeTruthy(); +}); +``` + +## When not to use it + +If a function must return exactly `true`, and `1` or a string would be a bug, `toBe(true)` is the stricter and better check. Leave this rule off in that case. diff --git a/docs/lint/rules/bun-test/prefer-to-be.mdx b/docs/lint/rules/bun-test/prefer-to-be.mdx new file mode 100644 index 000000000000..62b470264af8 --- /dev/null +++ b/docs/lint/rules/bun-test/prefer-to-be.mdx @@ -0,0 +1,64 @@ +--- +title: "bun:test/prefer-to-be" +description: "Require `toBe()` for primitive literals, and `toBeNull()`, `toBeUndefined()`, `toBeNaN()`" +--- + +`toEqual()` and `toStrictEqual()` compare objects property by property. A string, a number or a boolean has nothing to walk through, so `toBe()` does the same check and tells the reader that one plain value is expected. For `null`, `undefined` and `NaN` there are matchers that name the value. + +| Written | Use instead | +| -------------------------------------------- | --------------------------- | +| `toEqual(2)`, `toStrictEqual("config")` | `toBe(2)`, `toBe("config")` | +| `toBe(null)`, `toEqual(null)` | `toBeNull()` | +| `toBe(undefined)`, `not.toBeDefined()` | `toBeUndefined()` | +| `not.toBe(undefined)`, `not.toBeUndefined()` | `toBeDefined()` | +| `toBe(NaN)`, `toEqual(NaN)` | `toBeNaN()` | + +`--fix` rewrites the matcher. + +## Examples + +Reported: + +{/* bad {"count": 5} */} + +```ts +import { expect, test } from "bun:test"; + +test("parses the header", () => { + expect(header.version).toEqual(2); + expect(header.name).toStrictEqual("config"); + expect(header.parent).toBe(null); + expect(header.size).not.toBe(undefined); + expect(header.ratio).toEqual(NaN); +}); +``` + +Not reported: + +{/* good */} + +```ts +import { expect, test } from "bun:test"; + +test("parses the header", () => { + expect(header.version).toBe(2); + expect(header.name).toBe("config"); + expect(header.parent).toBeNull(); + expect(header.size).toBeDefined(); + expect(header.ratio).toBeNaN(); +}); +``` + +Only literals count. Objects, arrays and variables are left alone, and so is a number written with a decimal point: + +{/* good */} + +```ts +import { expect, test } from "bun:test"; + +test("parses the body", () => { + expect(body.tags).toEqual(["a", "b"]); + expect(body.length).toEqual(expectedLength); + expect(body.scale).toEqual(0.5); +}); +``` diff --git a/docs/lint/rules/bun-test/prefer-to-contain.mdx b/docs/lint/rules/bun-test/prefer-to-contain.mdx new file mode 100644 index 000000000000..d05dde5c80ef --- /dev/null +++ b/docs/lint/rules/bun-test/prefer-to-contain.mdx @@ -0,0 +1,52 @@ +--- +title: "bun:test/prefer-to-contain" +description: "Require `toContain()` instead of comparing the result of `includes()` with a boolean" +--- + +When `expect(tags.includes("new")).toBe(true)` fails, the message only says that `false` is not `true`. `expect(tags).toContain("new")` prints the array and the item it looked for, so you see what is wrong without a debugger. + +The rule reports `includes()` with one argument inside `expect()`, compared with `true` or `false` by `toBe()`, `toEqual()`, or `toStrictEqual()`. + +`--fix-suggestions` rewrites the assertion. `--fix` does not, because not every object with an `includes()` method works with `toContain()`. + +## Examples + +Reported: + +{/* bad {"count": 2} */} + +```ts +import { expect, test } from "bun:test"; + +test("a draft is tagged", () => { + const tags = getTags(draft); + expect(tags.includes("draft")).toBe(true); + expect(tags.includes("published")).toEqual(false); +}); +``` + +Not reported: + +{/* good */} + +```ts +import { expect, test } from "bun:test"; + +test("a draft is tagged", () => { + const tags = getTags(draft); + expect(tags).toContain("draft"); + expect(tags).not.toContain("published"); +}); +``` + +A start index has no equivalent in `toContain()`, so this is allowed: + +{/* good */} + +```ts +import { expect, test } from "bun:test"; + +test("the tag is not first", () => { + expect(getTags(draft).includes("draft", 1)).toBe(true); +}); +``` diff --git a/docs/lint/rules/bun-test/prefer-to-have-been-called-times.mdx b/docs/lint/rules/bun-test/prefer-to-have-been-called-times.mdx new file mode 100644 index 000000000000..4358fc046edb --- /dev/null +++ b/docs/lint/rules/bun-test/prefer-to-have-been-called-times.mdx @@ -0,0 +1,40 @@ +--- +title: "bun:test/prefer-to-have-been-called-times" +description: "Require `toHaveBeenCalledTimes()` instead of checking the length of `mock.calls`" +--- + +`expect(save.mock.calls).toHaveLength(2)` treats the mock as a plain array. When it fails, the message is about the length of an array. `expect(save).toHaveBeenCalledTimes(2)` says what you test, and its failure message is about the number of calls. + +`--fix` rewrites the assertion and keeps `.not`. + +## Examples + +Reported: + +{/* bad {"count": 2} */} + +```ts +import { expect, mock, test } from "bun:test"; + +test("saves each item once", () => { + const save = mock(); + saveAll(["a", "b"], save); + expect(save.mock.calls).toHaveLength(2); + expect(save.mock.calls).not.toHaveLength(0); +}); +``` + +Not reported: + +{/* good */} + +```ts +import { expect, mock, test } from "bun:test"; + +test("saves each item once", () => { + const save = mock(); + saveAll(["a", "b"], save); + expect(save).toHaveBeenCalledTimes(2); + expect(save.mock.calls[0]).toHaveLength(1); +}); +``` diff --git a/docs/lint/rules/bun-test/prefer-to-have-been-called.mdx b/docs/lint/rules/bun-test/prefer-to-have-been-called.mdx new file mode 100644 index 000000000000..11b7530f5749 --- /dev/null +++ b/docs/lint/rules/bun-test/prefer-to-have-been-called.mdx @@ -0,0 +1,45 @@ +--- +title: "bun:test/prefer-to-have-been-called" +description: "Require `toHaveBeenCalled()` instead of `toHaveBeenCalledTimes(0)`" +--- + +"Called zero times" is a roundabout way to say "not called", and `.not.toHaveBeenCalledTimes(0)` is a double negative for "called". `toHaveBeenCalled()` and `not.toHaveBeenCalled()` say it directly. + +The alias `toBeCalledTimes(0)` is reported too. Any other count is fine. + +`--fix` rewrites `toHaveBeenCalledTimes(0)` to `not.toHaveBeenCalled()`, and `not.toHaveBeenCalledTimes(0)` to `toHaveBeenCalled()`. + +## Examples + +Reported: + +{/* bad {"count": 2} */} + +```ts +import { expect, mock, test } from "bun:test"; + +test("only notifies on failure", () => { + const onError = mock(); + const onDone = mock(); + run(task, { onError, onDone }); + expect(onError).toHaveBeenCalledTimes(0); + expect(onDone).not.toHaveBeenCalledTimes(0); +}); +``` + +Not reported: + +{/* good */} + +```ts +import { expect, mock, test } from "bun:test"; + +test("only notifies on failure", () => { + const onError = mock(); + const onDone = mock(); + run(task, { onError, onDone }); + expect(onError).not.toHaveBeenCalled(); + expect(onDone).toHaveBeenCalled(); + expect(onDone).toHaveBeenCalledTimes(1); +}); +``` diff --git a/docs/lint/rules/bun-test/prefer-to-have-length.mdx b/docs/lint/rules/bun-test/prefer-to-have-length.mdx new file mode 100644 index 000000000000..5c5b0412eee5 --- /dev/null +++ b/docs/lint/rules/bun-test/prefer-to-have-length.mdx @@ -0,0 +1,41 @@ +--- +title: "bun:test/prefer-to-have-length" +description: "Require `toHaveLength()` instead of comparing the `length` property" +--- + +When `expect(users.length).toBe(2)` fails, the message shows two bare numbers. `expect(users).toHaveLength(2)` labels them as the expected and the received length. If the value has no `length` property, it says so instead of comparing `undefined` with `2`. + +The rule reports `.length` inside `expect()` when the matcher is `toBe()`, `toEqual()`, or `toStrictEqual()`. + +`--fix` rewrites the assertion. + +## Examples + +Reported: + +{/* bad {"count": 2} */} + +```ts +import { expect, test } from "bun:test"; + +test("finds the active users", () => { + const users = findUsers({ active: true }); + expect(users.length).toBe(2); + expect(users[0].roles.length).not.toEqual(0); +}); +``` + +Not reported: + +{/* good */} + +```ts +import { expect, test } from "bun:test"; + +test("finds the active users", () => { + const users = findUsers({ active: true }); + expect(users).toHaveLength(2); + expect(users[0].roles).not.toHaveLength(0); + expect(users.length).toBeGreaterThan(1); +}); +``` diff --git a/docs/lint/rules/bun-test/prefer-todo.mdx b/docs/lint/rules/bun-test/prefer-todo.mdx new file mode 100644 index 000000000000..260eb61ed2ad --- /dev/null +++ b/docs/lint/rules/bun-test/prefer-todo.mdx @@ -0,0 +1,42 @@ +--- +title: "bun:test/prefer-todo" +description: "Require `test.todo` for tests that have no body yet" +--- + +A test with an empty body passes, so the summary counts a test you have not written as one that works. `test.todo` says what it is, and `bun test` lists it apart from the passing tests until you write it. + +A test with only a title is reported too, and so is an empty `test.skip`. + +`--fix` rewrites the call to `test.todo` and removes the empty function. It leaves a test that has options, such as `{ timeout: 500 }`, for you to change. + +## Examples + +Reported: + +{/* bad {"count": 3} */} + +```ts +import { test } from "bun:test"; + +test("rejects an expired token", () => {}); + +test("refreshes the token"); + +test.skip("logs out", function () {}); +``` + +Not reported: + +{/* good */} + +```ts +import { expect, test } from "bun:test"; + +test.todo("rejects an expired token"); + +test.todoIf(process.platform === "win32")("reads the keychain", () => {}); + +test("accepts a valid token", () => { + expect(verify(token)).toBe(true); +}); +``` diff --git a/docs/lint/rules/bun-test/require-hook.mdx b/docs/lint/rules/bun-test/require-hook.mdx new file mode 100644 index 000000000000..4c3bc59414c3 --- /dev/null +++ b/docs/lint/rules/bun-test/require-hook.mdx @@ -0,0 +1,79 @@ +--- +title: "bun:test/require-hook" +description: "Require setup and teardown code to be inside a hook" +--- + +Code at the top level of a test file, or directly in the body of a `describe`, runs once while `bun test` collects the tests. It runs even if every test in that block is skipped, and the state it builds is shared by all tests, so one test can break the next. Code in `beforeEach`, `beforeAll`, `afterEach` or `afterAll` runs when the tests do. + +Two things are reported in those places: a function call that stands alone as a statement, and a `let` or `var` that starts with a value other than `null` or `undefined`. + +`const` declarations and calls of `describe`, `test`, `it` and the hooks are allowed. So are `setDefaultTimeout()`, `expectTypeOf()`, and calls on `mock`, `expect`, `jest` and `vi`, such as `mock.module()` and `expect.extend()`. + +## Examples + +Reported: + +{/* bad {"count": 3} */} + +```ts +import { describe, expect, test } from "bun:test"; + +let db = connect(); +db.seed(["ada", "grace"]); + +describe("users", () => { + clearCache(); + + test("finds a user", () => { + expect(db.find("ada")).toBeDefined(); + }); +}); +``` + +Not reported: + +{/* good */} + +```ts +import { beforeAll, beforeEach, describe, expect, mock, setDefaultTimeout, test } from "bun:test"; + +setDefaultTimeout(10_000); +mock.module("./mailer", () => ({ send: mock() })); + +const names = ["ada", "grace"]; +let db; + +beforeAll(() => { + db = connect(); + db.seed(names); +}); + +describe("users", () => { + beforeEach(() => { + clearCache(); + }); + + test("finds a user", () => { + expect(db.find("ada")).toBeDefined(); + }); +}); +``` + +## Options + +| Option | Default | What it does | +| ---------------------- | ------- | ------------------------------------------------------------------------------------------------- | +| `allowedFunctionCalls` | `[]` | Names of functions that you can call outside a hook. Write a method with its object: `"db.seed"`. | + +{/* good {"options": [{"allowedFunctionCalls": ["installMatchers", "db.seed"]}]} */} + +```ts +import { expect, test } from "bun:test"; + +installMatchers(); +db.seed(["ada"]); + +test("finds a user", () => { + expect(db.find("ada")).toBeUser(); +}); +``` diff --git a/docs/lint/rules/bun-test/require-mock-type-parameters.mdx b/docs/lint/rules/bun-test/require-mock-type-parameters.mdx new file mode 100644 index 000000000000..2baced043293 --- /dev/null +++ b/docs/lint/rules/bun-test/require-mock-type-parameters.mdx @@ -0,0 +1,35 @@ +--- +title: "bun:test/require-mock-type-parameters" +description: "Require a type parameter on `jest.fn()` and `vi.fn()`" +--- + +A plain `jest.fn()` stands for a function that takes any arguments and returns anything. TypeScript then accepts `mockReturnValue(42)` for a function that returns a string, and it says nothing when the real function changes and the mock no longer matches it. With a type parameter, the mock is checked like the function it replaces. + +A mock with an implementation, such as `jest.fn(() => 1)`, is reported too. `mock()` is not checked, and neither are JavaScript files. + +## Examples + +Reported: + +{/* bad {"count": 2} */} + +```ts +import { jest, vi } from "bun:test"; + +const findUser = jest.fn(); +const log = vi.fn((message: string) => {}); +``` + +Not reported: + +{/* good */} + +```ts +import { jest, mock, vi } from "bun:test"; + +type User = { name: string }; + +const findUser = jest.fn<(id: string) => Promise>(); +const log = vi.fn<(message: string) => void>(message => {}); +const send = mock(); +``` diff --git a/docs/lint/rules/bun-test/require-test-timeout.mdx b/docs/lint/rules/bun-test/require-test-timeout.mdx new file mode 100644 index 000000000000..a0201bbed58a --- /dev/null +++ b/docs/lint/rules/bun-test/require-test-timeout.mdx @@ -0,0 +1,86 @@ +--- +title: "bun:test/require-test-timeout" +description: "Require every test to set a timeout" +--- + +A test without a timeout of its own gets the default, 5 seconds unless you run `bun test --timeout`. That is a long wait for a unit test that hangs, and too short for a test that starts a server, which then fails now and then on a slow machine. This rule makes you choose the limit for each test. + +You can give the timeout as a number after the test function, as `{ timeout }` in the options, or for all the tests below with `setDefaultTimeout()` or `jest.setTimeout()`. + +Tests marked `.skip` or `.todo` are not checked. + +## Examples + +Reported: + +{/* bad */} + +```ts +import { expect, test } from "bun:test"; + +test("loads the report", async () => { + expect(await loadReport()).toBeDefined(); +}); +``` + +Not reported: + +{/* good */} + +```ts +import { expect, test } from "bun:test"; + +test("loads the report", async () => { + expect(await loadReport()).toBeDefined(); +}, 10_000); + +test( + "exports the report", + async () => { + expect(await exportReport()).toBeDefined(); + }, + { timeout: 30_000 }, +); +``` + +{/* good */} + +```ts +import { expect, setDefaultTimeout, test } from "bun:test"; + +setDefaultTimeout(10_000); + +test("loads the report", async () => { + expect(await loadReport()).toBeDefined(); +}); +``` + +The timeout has to be written as a number that is not negative. A variable is reported, and so are options without `timeout`: + +{/* bad {"count": 2} */} + +```ts +import { test } from "bun:test"; + +const SLOW = 30_000; + +test( + "exports the report", + async () => { + await exportReport(); + }, + SLOW, +); + +test( + "prints the report", + async () => { + await printReport(); + }, + { retry: 2 }, +); +``` + +## When not to use it + +If the default timeout, or the one you pass to `bun test --timeout`, suits all your tests, a number on each test adds nothing. diff --git a/docs/lint/rules/bun-test/require-to-throw-message.mdx b/docs/lint/rules/bun-test/require-to-throw-message.mdx new file mode 100644 index 000000000000..8621c812ac38 --- /dev/null +++ b/docs/lint/rules/bun-test/require-to-throw-message.mdx @@ -0,0 +1,42 @@ +--- +title: "bun:test/require-to-throw-message" +description: "Require `toThrow()` to say which error it expects" +--- + +`toThrow()` without an argument passes for any error at all, including a `TypeError` from a typo in the code under test. The test stays green although the check you meant to test was never reached. Pass part of the message, a regular expression, or the error class. + +`.not.toThrow()` is allowed, because there is no error to describe. + +## Examples + +Reported: + +{/* bad {"count": 3} */} + +```ts +import { expect, test } from "bun:test"; + +test("rejects a negative amount", async () => { + expect(() => withdraw(-5)).toThrow(); + expect(() => withdraw(-5)).toThrowError(); + await expect(transfer(-5)).rejects.toThrow(); +}); +``` + +Not reported: + +{/* good */} + +```ts +import { expect, test } from "bun:test"; + +test("rejects a negative amount", async () => { + expect(() => withdraw(-5)).toThrow("amount must be positive"); + expect(() => withdraw(-5)).toThrow(RangeError); + await expect(transfer(-5)).rejects.toThrow(/positive/); +}); + +test("accepts a positive amount", () => { + expect(() => withdraw(5)).not.toThrow(); +}); +``` diff --git a/docs/lint/rules/bun-test/require-top-level-describe.mdx b/docs/lint/rules/bun-test/require-top-level-describe.mdx new file mode 100644 index 000000000000..980f91f32f4c --- /dev/null +++ b/docs/lint/rules/bun-test/require-top-level-describe.mdx @@ -0,0 +1,78 @@ +--- +title: "bun:test/require-top-level-describe" +description: "Require tests and hooks to be inside a `describe` block" +--- + +A test at the top level of a file shows up in the output with no group name, so a failure says "starts empty" but not what starts empty. A hook at the top level runs for every test in the file, including the ones somebody adds later for something else. Inside a `describe`, both say what they belong to. + +## Examples + +Reported: + +{/* bad {"count": 2} */} + +```ts +import { beforeEach, expect, test } from "bun:test"; + +beforeEach(() => { + cart.clear(); +}); + +test("starts empty", () => { + expect(cart.size).toBe(0); +}); +``` + +Not reported: + +{/* good */} + +```ts +import { beforeEach, describe, expect, test } from "bun:test"; + +describe("cart", () => { + beforeEach(() => { + cart.clear(); + }); + + test("starts empty", () => { + expect(cart.size).toBe(0); + }); +}); +``` + +## Options + +| Option | Default | What it does | +| ------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------- | +| `maxNumberOfTopLevelDescribes` | no limit | How many `describe` blocks a file can have at the top level. Each one after that is reported. Nested blocks do not count. | + +{/* bad {"options": [{"maxNumberOfTopLevelDescribes": 1}], "count": 1} */} + +```ts +import { describe, test } from "bun:test"; + +describe("cart", () => { + test.todo("starts empty"); +}); + +describe("checkout", () => { + test.todo("charges the card"); +}); +``` + +{/* good {"options": [{"maxNumberOfTopLevelDescribes": 1}]} */} + +```ts +import { describe, test } from "bun:test"; + +describe("shop", () => { + describe("cart", () => { + test.todo("starts empty"); + }); + + describe("checkout", () => { + test.todo("charges the card"); + }); +}); +``` diff --git a/docs/lint/rules/bun-test/valid-describe-callback.mdx b/docs/lint/rules/bun-test/valid-describe-callback.mdx new file mode 100644 index 000000000000..bfe47f20d8c6 --- /dev/null +++ b/docs/lint/rules/bun-test/valid-describe-callback.mdx @@ -0,0 +1,85 @@ +--- +title: "bun:test/valid-describe-callback" +description: "Require `describe` to get a callback that is a function with no parameters" +--- + +`bun test` calls the callback of `describe` to collect the tests inside it. Without a callback there is nothing to collect. The callback is not a test, so it has no `done` to call at the end. + +The rule reports a `describe` with no callback, a callback that is not a function, and a callback with parameters. An `async` callback is allowed, because `bun test` waits for the promise it returns. + +This rule is on by default in files that import from `"bun:test"`. + +## Examples + +Reported: + +{/* bad */} + +```ts +import { describe, test } from "bun:test"; + +describe("cart", done => { + test("adds an item", () => {}); + done(); +}); +``` + +{/* bad {"count": 2} */} + +```ts +import { describe } from "bun:test"; + +describe("cart"); + +describe("checkout", "adds the tax"); +``` + +Not reported: + +{/* good */} + +```ts +import { describe, expect, test } from "bun:test"; + +describe("cart", () => { + test("adds an item", () => { + expect(add(item).length).toBe(1); + }); +}); + +describe("checkout", async () => { + const rates = await loadRates(); + + test("adds the tax", () => { + expect(total(100, rates)).toBe(108); + }); +}); +``` + +`describe.todo` and `describe.skip` need only a title. A callback that you pass by name is taken to be a function: + +{/* good */} + +```ts +import { describe } from "bun:test"; +import { cartTests } from "./shared"; + +describe.todo("refunds"); +describe.skip("exports"); + +describe("cart", cartTests); +``` + +The callback of `describe.each` gets the values of a row, so it can have parameters: + +{/* good */} + +```ts +import { describe, expect, test } from "bun:test"; + +describe.each([1, 2, 3])("cart with %i items", count => { + test("counts them", () => { + expect(fill(count).length).toBe(count); + }); +}); +``` diff --git a/docs/lint/rules/bun-test/valid-expect-in-promise.mdx b/docs/lint/rules/bun-test/valid-expect-in-promise.mdx new file mode 100644 index 000000000000..dfe52f8d089d --- /dev/null +++ b/docs/lint/rules/bun-test/valid-expect-in-promise.mdx @@ -0,0 +1,113 @@ +--- +title: "bun:test/valid-expect-in-promise" +description: "Require a promise chain that contains `expect` to be awaited or returned from the test" +--- + +A callback passed to `.then()`, `.catch()` or `.finally()` runs later. If the test neither awaits nor returns the promise, `bun test` does not wait for it. The test is over before the `expect` in the callback runs, so a failed assertion does not fail that test. + +This rule is on by default in files that import from `"bun:test"`. + +## Examples + +Reported: + +{/* bad */} + +```ts +import { expect, test } from "bun:test"; + +test("loads the user", () => { + loadUser(1).then(user => { + expect(user.name).toBe("Ada"); + }); +}); +``` + +A promise kept in a variable has to be awaited or returned too: + +{/* bad */} + +```ts +import { expect, test } from "bun:test"; + +test("loads the user", () => { + const check = loadUser(1).then(user => { + expect(user.name).toBe("Ada"); + }); +}); +``` + +A chain written after the `return` of the test never starts: + +{/* bad */} + +```ts +import { expect, test } from "bun:test"; + +test("loads both users", () => { + return loadUser(1).then(user => { + expect(user.name).toBe("Ada"); + }); + loadUser(2).then(user => { + expect(user.name).toBe("Grace"); + }); +}); +``` + +Not reported: + +{/* good */} + +```ts +import { expect, test } from "bun:test"; + +test("loads the user", () => { + return loadUser(1).then(user => { + expect(user.name).toBe("Ada"); + }); +}); + +test("loads the admin", async () => { + await loadUser(2).then(user => { + expect(user.role).toBe("admin"); + }); +}); + +test("loads the guest", async () => { + const user = await loadUser(3); + expect(user.role).toBe("guest"); +}); +``` + +Several promises can be awaited together: + +{/* good */} + +```ts +import { expect, test } from "bun:test"; + +test("loads both users", async () => { + const first = loadUser(1).then(user => { + expect(user.name).toBe("Ada"); + }); + const second = loadUser(2).then(user => { + expect(user.name).toBe("Grace"); + }); + await Promise.all([first, second]); +}); +``` + +A test with a parameter is not checked, because calling `done` is another way to make `bun test` wait: + +{/* good */} + +```ts +import { expect, test } from "bun:test"; + +test("loads the user", done => { + loadUser(1).then(user => { + expect(user.name).toBe("Ada"); + done(); + }); +}); +``` diff --git a/docs/lint/rules/bun-test/valid-expect.mdx b/docs/lint/rules/bun-test/valid-expect.mdx new file mode 100644 index 000000000000..2e1663a4945f --- /dev/null +++ b/docs/lint/rules/bun-test/valid-expect.mdx @@ -0,0 +1,131 @@ +--- +title: "bun:test/valid-expect" +description: "Require `expect` to get a value, end in a called matcher, and be awaited when it is async" +--- + +`expect(value)` checks nothing by itself. The check happens when a matcher such as `.toBe()` is called, so an `expect` with no matcher, or with a matcher that is not called, passes whatever the value is. + +`.resolves` and `.rejects` make the matcher return a promise. If the test does not await or return it, the test can end first, and a failed assertion does not fail the test. + +This rule is on by default in files that import from `"bun:test"`. + +## Examples + +Reported: + +{/* bad {"count": 4} */} + +```ts +import { expect, test } from "bun:test"; + +test("adds the tax", () => { + expect(total(100)); // no matcher + expect(total(100)).toBe; // the matcher is not called + expect().toBe(108); // no value + expect(total(100)).never.toBe(0); // `never` is not a modifier +}); +``` + +{/* bad */} + +```ts +import { expect, test } from "bun:test"; + +test("loads the user", () => { + expect(loadUser(1)).resolves.toEqual({ id: 1 }); +}); +``` + +Not reported: + +{/* good */} + +```ts +import { expect, test } from "bun:test"; + +test("adds the tax", () => { + expect(total(100)).toBe(108); + expect(total(100)).not.toBe(0); +}); + +test("loads the user", async () => { + await expect(loadUser(1)).resolves.toEqual({ id: 1 }); +}); + +test("rejects an unknown id", () => { + return expect(loadUser(-1)).rejects.toThrow("not found"); +}); +``` + +A second argument is the message to print on failure, and does not count toward `maxArgs`. `expect().pass()` and `expect().fail()` need no value: + +{/* good */} + +```ts +import { expect, test } from "bun:test"; + +test("adds the tax", () => { + expect(total(100), "total after tax").toBe(108); +}); + +test("never calls back", () => { + subscribe(() => expect().fail("called back")); +}); +``` + +Only a statement is reported. An `expect` that you keep in a variable or pass on can still get its matcher, or its `await`, somewhere else: + +{/* good */} + +```ts +import { expect, test } from "bun:test"; + +test("loads both users", async () => { + const first = expect(loadUser(1)).resolves.toEqual({ id: 1 }); + const second = expect(loadUser(2)).resolves.toEqual({ id: 2 }); + await Promise.all([first, second]); +}); +``` + +## Options + +| Option | Default | What it does | +| --------------- | --------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| `alwaysAwait` | `false` | Report every async assertion that is not awaited where it is written, also one that is returned or kept in a variable. | +| `asyncMatchers` | `["toResolve", "toReject"]` | Matchers that return a promise, and so have to be awaited or returned. Your list replaces the default one. | +| `minArgs` | `1` | The fewest arguments `expect` can take. | +| `maxArgs` | `1` | The most arguments `expect` can take. | + +{/* bad {"options": [{"alwaysAwait": true}]} */} + +```ts +import { expect, test } from "bun:test"; + +test("rejects an unknown id", () => { + return expect(loadUser(-1)).rejects.toThrow("not found"); +}); +``` + +`asyncMatchers` is for matchers of your own, added with `expect.extend()`, that return a promise: + +{/* bad {"options": [{"asyncMatchers": ["toResolveWith"]}]} */} + +```ts +import { expect, test } from "bun:test"; + +test("loads the user", () => { + expect(loadUser(1)).toResolveWith({ id: 1 }); +}); +``` + +With `{ "minArgs": 2 }`, every `expect` needs a message: + +{/* bad {"options": [{"minArgs": 2}]} */} + +```ts +import { expect, test } from "bun:test"; + +test("adds the tax", () => { + expect(total(100)).toBe(108); +}); +``` diff --git a/docs/lint/rules/bun-test/valid-title.mdx b/docs/lint/rules/bun-test/valid-title.mdx new file mode 100644 index 000000000000..78e7c286a49b --- /dev/null +++ b/docs/lint/rules/bun-test/valid-title.mdx @@ -0,0 +1,121 @@ +--- +title: "bun:test/valid-title" +description: "Require the titles of tests and `describe` blocks to be clean, non-empty strings" +--- + +The title is how you find a test in the output when it fails. The rule reports a test title that is not a string, an empty title, a space at the start or the end, and a title that starts with the name of the function it is passed to, as in `test("test adds an item")`. With options, it also holds titles to your own naming rules. + +`--fix` removes the spaces and the repeated word. It leaves the titles alone in a file that calls `toMatchSnapshot()` or `toThrowErrorMatchingSnapshot()`, because snapshots are stored under the title of their test. + +## Examples + +Reported: + +{/* bad {"count": 5} */} + +```ts +import { describe, test } from "bun:test"; + +describe("describe cart", () => { + test("", () => {}); + test(" adds an item", () => {}); + test("test removes an item", () => {}); + test(404, () => {}); +}); +``` + +Not reported: + +{/* good */} + +```ts +import { describe, test } from "bun:test"; + +describe("cart", () => { + test("adds an item", () => {}); + test(`removes ${count} items`, () => {}); + test("responds to " + path, () => {}); +}); +``` + +`bun test` takes a function, a class or a number as the title of a `describe`, so that is allowed by default: + +{/* good */} + +```ts +import { describe, test } from "bun:test"; + +describe(Cart, () => { + test("adds an item", () => {}); +}); +``` + +## Options + +| Option | Default | What it does | +| -------------------------- | ------- | --------------------------------------------------------------------------- | +| `allowArguments` | `false` | Allow a title that is a plain variable, such as `title`. | +| `ignoreTypeOfDescribeName` | `true` | Allow a `describe` title that is not a string. | +| `ignoreTypeOfTestName` | `false` | Allow a `test` or `it` title that is not a string. | +| `ignoreSpaces` | `false` | Allow spaces at the start and the end. | +| `disallowedWords` | `[]` | Whole words that a title cannot contain. Upper and lower case are the same. | +| `mustMatch` | none | A pattern that every title has to match. | +| `mustNotMatch` | none | A pattern that no title can match. | + +A variable as the title of a test is reported by default, because the rule cannot tell what it holds: + +{/* bad */} + +```ts +import { test } from "bun:test"; + +test(title, () => {}); +``` + +{/* good {"options": [{"allowArguments": true}]} */} + +```ts +import { test } from "bun:test"; + +test(title, () => {}); +``` + +{/* bad {"options": [{"ignoreTypeOfDescribeName": false}]} */} + +```ts +import { describe, test } from "bun:test"; + +describe(Cart, () => { + test("adds an item", () => {}); +}); +``` + +{/* bad {"options": [{"disallowedWords": ["should"]}]} */} + +```ts +import { test } from "bun:test"; + +test("Should add an item", () => {}); +``` + +`mustMatch` and `mustNotMatch` take a regular expression written as a string, `"^adds "` or `"/^adds /"`. Flags after the last `/` are ignored. To choose the message of the report, give a pair: + +{/* bad {"options": [{"mustNotMatch": ["\\.$", "Do not end a title with a period"]}]} */} + +```ts +import { test } from "bun:test"; + +test("adds an item.", () => {}); +``` + +To have a pattern for only some functions, give an object with the keys `describe`, `test` and `it`: + +{/* bad {"options": [{"mustMatch": {"it": "^should "}}], "count": 1} */} + +```ts +import { it, test } from "bun:test"; + +it("adds an item", () => {}); + +test("adds an item", () => {}); +``` diff --git a/docs/lint/rules/bun-test/warn-todo.mdx b/docs/lint/rules/bun-test/warn-todo.mdx new file mode 100644 index 000000000000..88ff991bb9a4 --- /dev/null +++ b/docs/lint/rules/bun-test/warn-todo.mdx @@ -0,0 +1,36 @@ +--- +title: "bun:test/warn-todo" +description: "Disallow `.todo` on `test`, `it` and `describe`" +--- + +`test.todo` stands for a test that nobody has written yet, or one that does not pass yet. `bun test` still passes, so the behavior it names stays untested for as long as the line is forgotten. + +## Examples + +Reported: + +{/* bad {"count": 2} */} + +```ts +import { describe, test } from "bun:test"; + +test.todo("refunds the order"); + +describe.todo("checkout"); +``` + +Not reported: + +{/* good */} + +```ts +import { expect, test } from "bun:test"; + +test("refunds the order", () => { + expect(refund(order).total).toBe(0); +}); +``` + +## When not to use it + +If you use `.todo` to plan tests and read the list in the test output, this rule only repeats it. diff --git a/docs/lint/rules/bun/consistent-directive-prefix.mdx b/docs/lint/rules/bun/consistent-directive-prefix.mdx new file mode 100644 index 000000000000..f4d53a296376 --- /dev/null +++ b/docs/lint/rules/bun/consistent-directive-prefix.mdx @@ -0,0 +1,57 @@ +--- +title: "bun/consistent-directive-prefix" +description: "Require comments that turn rules off to start with the same word, `eslint` or `oxlint`" +--- + +`eslint-disable` and `oxlint-disable` comments both turn a rule off. If a project mixes them, a search for one spelling misses the other, and ESLint ignores the `oxlint` ones. + +The default is `eslint`, which ESLint, oxlint, and `bun lint` all read. The rule checks `-disable`, `-disable-line`, `-disable-next-line`, and `-enable` comments. + +`--fix` rewrites the prefix. + +## Examples + +Reported: + +{/* bad */} + +```js +// oxlint-disable-next-line no-console +console.log(version); +``` + +Not reported: + +{/* good */} + +```js +// eslint-disable-next-line no-console +console.log(version); +``` + +## Options + +| Option | Default | What it does | +| -------- | ---------- | ------------------------------------------------- | +| `prefix` | `"eslint"` | The word to start with: `"eslint"` or `"oxlint"`. | + +```toml bunfig.toml +[lint.rules] +"bun/consistent-directive-prefix" = ["error", { prefix = "oxlint" }] +``` + +{/* bad {"options": [{"prefix": "oxlint"}]} */} + +```js +/* eslint-disable no-console */ +console.log(version); +/* eslint-enable no-console */ +``` + +{/* good {"options": [{"prefix": "oxlint"}]} */} + +```js +/* oxlint-disable no-console */ +console.log(version); +/* oxlint-enable no-console */ +``` diff --git a/docs/lint/rules/bun/format.mdx b/docs/lint/rules/bun/format.mdx new file mode 100644 index 000000000000..44bd1aef1ca8 --- /dev/null +++ b/docs/lint/rules/bun/format.mdx @@ -0,0 +1,62 @@ +--- +title: "bun/format" +description: "Require code to be formatted the way `bun format` prints it" +--- + +This rule compares each file with what `bun format` prints and reports every place where they differ. Formatting problems then show up next to your lint problems, in the terminal and in the editor. + +It reads the same settings as `bun format`: your `.prettierrc`, `.editorconfig`, and the `[format]` section of `bunfig.toml`. It skips the files that `bun format` skips. The options and the messages are the same as for `prettier/prettier`, and you do not need Prettier installed. + +A file that Bun cannot format the way your config asks, for example because of a Prettier plugin that `bun format` does not have, gets one report that says why. + +`--fix` formats the file. + +## Examples + +Reported: + +{/* bad */} + +```js +const user = { name: "Ada" }; +``` + +Not reported: + +{/* good */} + +```js +const user = { name: "Ada" }; +``` + +## Options + +The first option is an object of formatting options, with the names they have in a `.prettierrc`. They win over your config files. + +```toml bunfig.toml +[lint.rules] +"bun/format" = ["error", { singleQuote = true, semi = false }] +``` + +{/* good {"options": [{"singleQuote": true, "semi": false}]} */} + +```js +const user = { name: "Ada" }; +``` + +{/* bad {"options": [{"singleQuote": true, "semi": false}]} */} + +```js +const user = { name: "Ada" }; +``` + +The second option is an object with these keys: + +| Option | Default | What it does | +| ----------------- | ------- | ----------------------------------------------------------------------------------------------------------------- | +| `usePrettierrc` | `true` | Read config files such as `.prettierrc` and `.editorconfig`. | +| `fileInfoOptions` | `{}` | `ignorePath` names the ignore file or files to read. `withNodeModules: true` also checks files in `node_modules`. | + +## When not to use it + +If `bun format --check` already runs in CI, this rule does the same work a second time. diff --git a/docs/lint/rules/bun/jsx-no-forbidden-nesting.mdx b/docs/lint/rules/bun/jsx-no-forbidden-nesting.mdx new file mode 100644 index 000000000000..3444a7389b1d --- /dev/null +++ b/docs/lint/rules/bun/jsx-no-forbidden-nesting.mdx @@ -0,0 +1,82 @@ +--- +title: "bun/jsx-no-forbidden-nesting" +description: "Disallow a JSX element inside another element that cannot contain it" +--- + +HTML does not allow a link or a button inside another link or button. Browsers differ in what a click on the inner element does. In server-rendered HTML, the parser ends an `a` or a `button` where the next one starts and ignores a `form` inside a `form`, so the DOM does not match your JSX. + +The inner element is reported at any depth, not only as a direct child. By default the rule knows these pairs: + +| Outer | Cannot contain | +| -------- | -------------- | +| `a` | `a`, `button` | +| `button` | `a`, `button` | +| `form` | `form` | +| `label` | `label` | + +Only the tag names are compared, so a `Link` component that renders an `a` does not count as an `a`. + +## Examples + +Reported: + +{/* bad */} + +```jsx +const card = ( + +

{post.title}

+ +
+); +``` + +Not reported: + +{/* good */} + +```jsx +const card = ( + +); +``` + +## Options + +| Option | Default | What it does | +| ------- | --------------- | ----------------------------------------------------------------- | +| `pairs` | The table above | A list of `{ outer, inner }` names. It replaces the default list. | + +Use it for your own components: + +```toml bunfig.toml +[lint.rules] +"bun/jsx-no-forbidden-nesting" = ["error", { pairs = [{ outer = "Link", inner = "Button" }] }] +``` + +{/* bad {"options": [{"pairs": [{"outer": "Link", "inner": "Button"}]}]} */} + +```jsx +const row = ( + + + +); +``` + +The default pairs no longer apply then. Add them to the list if you want to keep them: + +{/* good {"options": [{"pairs": [{"outer": "Link", "inner": "Button"}]}]} */} + +```jsx +const row = ( + + + +); +``` diff --git a/docs/lint/rules/bun/no-array-just-to-count.mdx b/docs/lint/rules/bun/no-array-just-to-count.mdx new file mode 100644 index 000000000000..e011376ec986 --- /dev/null +++ b/docs/lint/rules/bun/no-array-just-to-count.mdx @@ -0,0 +1,66 @@ +--- +title: "bun/no-array-just-to-count" +description: "Disallow `.split(separator).length` with a one-character separator" +--- + +`text.split("\n").length` copies every line of `text` into a new array, reads the length, and throws the array away. For a large string that is a lot of memory for one number. A loop that counts the separator allocates nothing. + +Only a literal separator of one character is reported, not a variable or a regular expression. + +## Examples + +Reported: + +{/* bad */} + +```js +const lineCount = source.split("\n").length; +``` + +Not reported: + +{/* good */} + +```js +function count(text, separator) { + let found = 0; + for (let at = text.indexOf(separator); at !== -1; at = text.indexOf(separator, at + 1)) { + found++; + } + return found; +} + +const lineCount = count(source, "\n") + 1; +``` + +An array that is used for more than its length is fine, and so is a longer separator: + +{/* good */} + +```js +const lines = source.split("\n"); +console.log(lines.length, lines[0]); + +const paragraphCount = source.split("\n\n").length; +``` + +## Options + +| Option | Default | What it does | +| --------------- | ------- | ----------------------------------------------------------------------------------------------------------------------- | +| `countFunction` | none | The name of a function that is called as `countFunction(string, separator)` and returns how often the separator occurs. | + +Without `countFunction`, the rule only reports. With it, `--fix` rewrites `source.split("\n").length` to `count(source, "\n") + 1`, and `source.split("\n").length - 1` to `count(source, "\n")`. The fix applies only where a function of that name is in scope. + +```toml bunfig.toml +[lint.rules] +"bun/no-array-just-to-count" = ["error", { countFunction = "count" }] +``` + +{/* bad {"options": [{"countFunction": "count"}]} */} + +```js +import { count } from "./strings.js"; + +const commas = row.split(",").length - 1; +``` diff --git a/docs/lint/rules/bun/no-cast-to-brand.mdx b/docs/lint/rules/bun/no-cast-to-brand.mdx new file mode 100644 index 000000000000..1047eea3994c --- /dev/null +++ b/docs/lint/rules/bun/no-cast-to-brand.mdx @@ -0,0 +1,93 @@ +--- +title: "bun/no-cast-to-brand" +description: "Disallow casts to a branded type outside the module that declares it" +--- + +A branded type such as `UserId` says "this string has been checked". That only holds if every `UserId` comes from the function that checks. `raw as UserId` anywhere else makes one from a value that nothing has checked, and the compiler accepts it. + +The rule does nothing until you list your branded types in `branded`. + +## Examples + +These examples use this config: + +```toml bunfig.toml +[lint.rules] +"bun/no-cast-to-brand" = ["error", { branded = [{ module = "src/ids", type = "UserId" }] }] +``` + +Reported: + +{/* bad {"filename": "src/routes.ts", "options": [{"branded": [{"module": "src/ids", "type": "UserId"}]}]} */} + +```ts +import type { UserId } from "./ids"; + +export function userOf(params: { id: string }) { + return load(params.id as UserId); +} +``` + +Casts to an array, tuple, or union of the type, or to an alias of it, are reported too: + +{/* bad {"filename": "src/routes.ts", "options": [{"branded": [{"module": "src/ids", "type": "UserId"}]}]} */} + +```ts +import type { UserId } from "./ids"; + +const admins = ["u_1", "u_2"] as UserId[]; +``` + +Not reported: + +{/* good {"filename": "src/routes.ts", "options": [{"branded": [{"module": "src/ids", "type": "UserId"}]}]} */} + +```ts +import { parseUserId } from "./ids"; + +export function userOf(params: { id: string }) { + return load(parseUserId(params.id)); +} +``` + +The module that declares the type is where the cast belongs: + +{/* good {"filename": "src/ids.ts", "options": [{"branded": [{"module": "src/ids", "type": "UserId"}]}]} */} + +```ts +export type UserId = string & { readonly brand: unique symbol }; + +export function parseUserId(text: string): UserId { + if (!text.startsWith("u_")) throw new TypeError(`not a user ID: ${text}`); + return text as UserId; +} +``` + +## Options + +`branded` is a list of objects with these keys: + +| Key | Default | What it does | +| ---------- | -------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `module` | required | The end of the path of the file that declares the type, without the extension: `"src/ids"` or `"ids"`. A package name works too. | +| `type` | required | The name of the type as the module exports it. | +| `mint` | `[]` | Functions of the module that make a value of the type without checking it. | +| `mintedIn` | `[]` | The ends of the paths of the files that may import the `mint` functions, with the extension. | + +With `mint`, importing or re-exporting one of those functions is reported in every file that is not in `mintedIn`: + +{/* bad {"filename": "src/routes.ts", "options": [{"branded": [{"module": "src/ids", "type": "UserId", "mint": ["unsafeUserId"], "mintedIn": ["src/db.ts"]}]}]} */} + +```ts +import { unsafeUserId } from "./ids"; + +const id = unsafeUserId(request.headers.get("x-user") ?? ""); +``` + +{/* good {"filename": "src/db.ts", "options": [{"branded": [{"module": "src/ids", "type": "UserId", "mint": ["unsafeUserId"], "mintedIn": ["src/db.ts"]}]}]} */} + +```ts +import { unsafeUserId } from "./ids"; + +export const toUser = (row: { id: string }) => ({ id: unsafeUserId(row.id) }); +``` diff --git a/docs/lint/rules/bun/no-console-window-flash.mdx b/docs/lint/rules/bun/no-console-window-flash.mdx new file mode 100644 index 000000000000..e16092d2a80f --- /dev/null +++ b/docs/lint/rules/bun/no-console-window-flash.mdx @@ -0,0 +1,60 @@ +--- +title: "bun/no-console-window-flash" +description: "Require `windowsHide` for a child process that does not share a stream with its parent" +--- + +On Windows, a child process that is started from an app without a console gets a console window of its own. The window opens and closes again as the process runs, which users see as a black flash. `windowsHide: true` prevents it, and has no effect on other systems. + +The rule looks at `exec`, `execSync`, `execFile`, `execFileSync`, `spawn`, and `spawnSync` of `node:child_process`, and at `Bun.spawn` and `Bun.spawnSync`. + +## Examples + +Reported: + +{/* bad */} + +```js +import { execFile } from "node:child_process"; + +execFile("git", ["status", "--short"], (error, stdout) => { + show(stdout); +}); +``` + +{/* bad */} + +```js +const git = Bun.spawn(["git", "status", "--short"], { stdout: "pipe" }); +``` + +Not reported: + +{/* good */} + +```js +import { execFile } from "node:child_process"; + +execFile("git", ["status", "--short"], { windowsHide: true }, (error, stdout) => { + show(stdout); +}); + +const git = Bun.spawn(["git", "status", "--short"], { stdout: "pipe", windowsHide: true }); +``` + +A process that inherits a stream writes to the terminal of its parent, so it is not reported. Any value for `windowsHide` counts, because writing `false` is a deliberate choice. Options that are not an object literal in the call, or that spread another object, are not checked: + +{/* good */} + +```js +import { spawn } from "node:child_process"; + +spawn("bun", ["test"], { stdio: "inherit" }); +spawn("bun", ["test"], { stdio: ["ignore", "inherit", "pipe"] }); +spawn("notepad", [file], { windowsHide: false }); +spawn("bun", ["test"], options); +spawn("bun", ["test"], { ...options, cwd }); +``` + +## When not to use it + +Code that never runs on Windows does not need the option. diff --git a/docs/lint/rules/bun/no-cwd-dependent-path.mdx b/docs/lint/rules/bun/no-cwd-dependent-path.mdx new file mode 100644 index 000000000000..252b992842d4 --- /dev/null +++ b/docs/lint/rules/bun/no-cwd-dependent-path.mdx @@ -0,0 +1,84 @@ +--- +title: "bun/no-cwd-dependent-path" +description: "Disallow relative paths that depend on the directory the process was started in" +--- + +`readFileSync("config.json")` does not look next to the file that calls it. It looks in the directory that the process was started in. The code works when you run it from the project root and fails with `ENOENT` when it runs from anywhere else. Start from `import.meta.dirname` to name a file next to your source. + +The rule checks a string or a template literal that starts with a relative path, in these places: + +- The functions of `node:fs` and `node:fs/promises`, except `glob` and `globSync`. For `copyFile`, `cp`, `link`, and `rename`, both paths are checked. For `symlink`, only the second one is, because the target is relative to the link. +- `path.resolve()`, if every argument is such a literal. +- `Bun.file()` and `Bun.write()`. + +## Examples + +Reported: + +{/* bad */} + +```js +import { readFileSync } from "node:fs"; + +const config = JSON.parse(readFileSync("config.json", "utf8")); +``` + +{/* bad {"count": 2} */} + +```js +import path from "node:path"; + +const schema = Bun.file(`schemas/${name}.json`); +const output = path.resolve("dist", "index.js"); +``` + +Not reported: + +{/* good */} + +```js +import { readFileSync } from "node:fs"; +import path from "node:path"; + +const config = JSON.parse(readFileSync(path.join(import.meta.dirname, "config.json"), "utf8")); +const schema = Bun.file(new URL(`./schemas/${name}.json`, import.meta.url)); +const output = path.resolve(import.meta.dirname, "dist", "index.js"); +``` + +Absolute paths and paths that come from a variable are fine: + +{/* good */} + +```js +import { readFileSync } from "node:fs"; + +readFileSync("/etc/hosts", "utf8"); +readFileSync(process.argv[2], "utf8"); +readFileSync(`${root}/config.json`, "utf8"); +``` + +## Options + +| Option | Default | What it does | +| ----------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| `functions` | `[]` | Names of more functions that take a path as their first argument. A name matches both `loadFixture()` and `helpers.loadFixture()`. | + +{/* bad {"count": 2, "options": [{"functions": ["loadFixture"]}]} */} + +```js +const user = loadFixture("fixtures/user.json"); +const team = helpers.loadFixture("fixtures/team.json"); +``` + +## When not to use it + +A command-line tool often means the working directory. It reads the `package.json` of the project it was started in. Say so with `process.cwd()`, or turn the rule off for those files: + +{/* good */} + +```js +import { readFileSync } from "node:fs"; +import path from "node:path"; + +const manifest = readFileSync(path.resolve(process.cwd(), "package.json"), "utf8"); +``` diff --git a/docs/lint/rules/bun/no-eager-dynamic-import.mdx b/docs/lint/rules/bun/no-eager-dynamic-import.mdx new file mode 100644 index 000000000000..986fb8629bef --- /dev/null +++ b/docs/lint/rules/bun/no-eager-dynamic-import.mdx @@ -0,0 +1,73 @@ +--- +title: "bun/no-eager-dynamic-import" +description: "Disallow `import()` that runs as soon as the module is loaded" +--- + +The point of `import()` is to load a module only when it is needed. An `import()` at the top level of a file runs as soon as the file is imported, so the module is always loaded. With `await`, everything that imports the file waits for it too. Move the call into the function that needs the module, or write an `import` statement. + +## Examples + +Reported: + +{/* bad */} + +```js +const { marked } = await import("marked"); + +export function render(text) { + return marked.parse(text); +} +``` + +Not reported: + +{/* good */} + +```js +export async function render(text) { + const { marked } = await import("marked"); + return marked.parse(text); +} +``` + +{/* good */} + +```js +import { marked } from "marked"; + +export function render(text) { + return marked.parse(text); +} +``` + +A function that is called right where it is defined, a `static` field, and a `static` block also run while the file loads: + +{/* bad {"count": 3} */} + +```js +(async () => { + const { marked } = await import("marked"); + console.log(marked.parse(text)); +})(); + +class Renderer { + static parser = import("marked"); + static { + import("./plugins.js"); + } +} +``` + +The `import()` that tells Vitest which module to mock is allowed in `vi.mock()`, `vi.doMock()`, `vi.unmock()`, and `vi.doUnmock()`: + +{/* good */} + +```js +import { vi } from "vitest"; + +vi.mock(import("./database.js"), () => ({ connect: vi.fn() })); +``` + +## When not to use it + +In an ES module, a top-level `import()` is how you choose a module at run time, as in `await import(isBun ? "./bun.js" : "./node.js")`. Disable the rule on that line. diff --git a/docs/lint/rules/bun/no-eager-native-addon.mdx b/docs/lint/rules/bun/no-eager-native-addon.mdx new file mode 100644 index 000000000000..9f33637c7e1e --- /dev/null +++ b/docs/lint/rules/bun/no-eager-native-addon.mdx @@ -0,0 +1,49 @@ +--- +title: "bun/no-eager-native-addon" +description: "Disallow loading a `.node` native addon as soon as the module is loaded" +--- + +A native addon is a shared library. Loading one takes time, and it throws if the file is missing or was built for another platform. If that happens at the top level of a file, everything that imports the file fails to start, even code that never calls the addon. Load it in the function that first needs it. + +The rule looks at `import` and `export ... from` statements, `require()`, and `import()` with a path that ends in `.node`. + +## Examples + +Reported: + +{/* bad */} + +```js +const native = require("./build/Release/resize.node"); + +export function resize(image, width) { + return native.resize(image, width); +} +``` + +{/* bad */} + +```js +import native from "./build/Release/resize.node"; +``` + +Not reported: + +{/* good */} + +```js +let native; + +export function resize(image, width) { + native ??= require("./build/Release/resize.node"); + return native.resize(image, width); +} +``` + +A type-only import loads nothing: + +{/* good */} + +```ts +import type { Resizer } from "./build/Release/resize.node"; +``` diff --git a/docs/lint/rules/bun/no-env-at-module-scope.mdx b/docs/lint/rules/bun/no-env-at-module-scope.mdx new file mode 100644 index 000000000000..390de315bd67 --- /dev/null +++ b/docs/lint/rules/bun/no-env-at-module-scope.mdx @@ -0,0 +1,73 @@ +--- +title: "bun/no-env-at-module-scope" +description: "Disallow reading environment variables as soon as the module is loaded" +--- + +A variable that is read at the top level of a file is read once, when the file is first imported. A test that sets `process.env.API_URL` after the import changes nothing, and neither does code that loads the environment later. What the module does then depends on the order of the imports. Read the variable in the function that needs it. + +The rule knows `process.env`, `Bun.env`, `import.meta.env`, and `env` imported from `node:process`. Assigning to a variable or deleting one is not reported. + +## Examples + +Reported: + +{/* bad */} + +```js +const apiUrl = process.env.API_URL; + +export function fetchUser(id) { + return fetch(`${apiUrl}/users/${id}`); +} +``` + +{/* bad {"count": 3} */} + +```js +import { env } from "node:process"; + +const { API_URL, API_KEY } = Bun.env; +const settings = { ...import.meta.env }; +const port = Number(env.PORT); +``` + +Not reported: + +{/* good */} + +```js +export function fetchUser(id) { + return fetch(`${process.env.API_URL}/users/${id}`); +} +``` + +{/* good */} + +```js +process.env.TZ = "UTC"; + +const isProduction = process.env.NODE_ENV === "production"; +``` + +## Options + +| Option | Default | What it does | +| ------- | -------------- | ----------------------------------------------------------------------------------- | +| `allow` | `["NODE_ENV"]` | Names of variables that may be read at the top level. It replaces the default list. | + +{/* good {"options": [{"allow": ["NODE_ENV", "CI"]}]} */} + +```js +const isCI = process.env.CI === "true"; +const { NODE_ENV, CI } = process.env; +``` + +{/* bad {"options": [{"allow": ["CI"]}]} */} + +```js +const isProduction = process.env.NODE_ENV === "production"; +``` + +## When not to use it + +A file that exists to read and check the environment once at startup, such as `env.ts` or `config.ts`, does on purpose what this rule reports. Turn the rule off for that file. diff --git a/docs/lint/rules/bun/no-literal-temp-dir.mdx b/docs/lint/rules/bun/no-literal-temp-dir.mdx new file mode 100644 index 000000000000..d8c8051b31ab --- /dev/null +++ b/docs/lint/rules/bun/no-literal-temp-dir.mdx @@ -0,0 +1,53 @@ +--- +title: "bun/no-literal-temp-dir" +description: "Disallow hard-coded temporary directories such as `/tmp` in favor of `os.tmpdir()`" +--- + +Windows has no `/tmp`, and on other systems `TMPDIR` can point somewhere else, as it does for every user on macOS. A path that starts with `/tmp` ignores that. `os.tmpdir()` returns the directory that the system wants you to use. + +The rule reports a string or a template that is one of the directories or starts with one followed by `/`. Text and attribute strings in JSX are not checked. + +## Examples + +Reported: + +{/* bad {"count": 2} */} + +```js +import { writeFileSync } from "node:fs"; + +writeFileSync("/tmp/report.json", JSON.stringify(report)); +writeFileSync(`/var/tmp/${name}.log`, lines.join("\n")); +``` + +Not reported: + +{/* good */} + +```js +import { writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +writeFileSync(join(tmpdir(), "report.json"), JSON.stringify(report)); +``` + +A name that only begins with the same letters is fine: + +{/* good */} + +```js +const template = "/tmpl/email.html"; +``` + +## Options + +| Option | Default | What it does | +| ------------- | ---------------------- | -------------------------------------------------------- | +| `directories` | `["/tmp", "/var/tmp"]` | The directories to report. It replaces the default list. | + +{/* bad {"options": [{"directories": ["/tmp", "/var/tmp", "/private/tmp"]}]} */} + +```js +const socket = "/private/tmp/worker.sock"; +``` diff --git a/docs/lint/rules/bun/no-negative-repeat-count.mdx b/docs/lint/rules/bun/no-negative-repeat-count.mdx new file mode 100644 index 000000000000..27e9b540f45c --- /dev/null +++ b/docs/lint/rules/bun/no-negative-repeat-count.mdx @@ -0,0 +1,60 @@ +--- +title: "bun/no-negative-repeat-count" +description: "Disallow a `repeat()` count that comes from a subtraction and can be negative" +--- + +`" ".repeat(width - label.length)` works until a label is longer than `width`. Then the count is negative and `repeat()` throws a `RangeError`. Tests with short labels do not catch it. + +The rule reports a count that contains a subtraction or a negation, unless `Math.max()` with a literal of zero or more, as in `Math.max(0, n)`, keeps it from going negative. + +The rule suggests wrapping the count in `Math.max(0, ...)`. `--fix-suggestions` applies that. + +## Examples + +Reported: + +{/* bad */} + +```js +function pad(label, width) { + return label + " ".repeat(width - label.length); +} +``` + +Rounding does not help, and neither does an upper bound: + +{/* bad {"count": 2} */} + +```js +const left = " ".repeat(Math.floor((width - title.length) / 2)); +const bar = "#".repeat(Math.min(total - done, 40)); +``` + +Not reported: + +{/* good */} + +```js +function pad(label, width) { + return label + " ".repeat(Math.max(0, width - label.length)); +} +``` + +For padding, use `padEnd()` or `padStart()`. They return a string that is already long enough unchanged: + +{/* good */} + +```js +function pad(label, width) { + return label.padEnd(width); +} +``` + +Only the expression in the call is looked at. A count that is computed earlier is not followed: + +{/* good */} + +```js +const missing = width - label.length; +const padding = " ".repeat(missing); +``` diff --git a/docs/lint/rules/bun/no-redundant-catch-type.mdx b/docs/lint/rules/bun/no-redundant-catch-type.mdx new file mode 100644 index 000000000000..3ebb5feec9b5 --- /dev/null +++ b/docs/lint/rules/bun/no-redundant-catch-type.mdx @@ -0,0 +1,38 @@ +--- +title: "bun/no-redundant-catch-type" +description: "Disallow `: unknown` on the variable of a `catch` clause" +--- + +With `strict` or `useUnknownInCatchVariables` in `tsconfig.json`, the variable of a `catch` clause is already `unknown`. Writing `: unknown` adds nothing. + +`--fix` removes the annotation. + +## Examples + +Reported: + +{/* bad */} + +```ts +try { + await save(draft); +} catch (error: unknown) { + report(error); +} +``` + +Not reported: + +{/* good */} + +```ts +try { + await save(draft); +} catch (error) { + report(error); +} +``` + +## When not to use it + +The rule does not read your `tsconfig.json`. Without `strict` or `useUnknownInCatchVariables`, the variable is `any`, and `: unknown` is what makes it safe. Leave the rule off in such a project. diff --git a/docs/lint/rules/bun/no-restricted-text.mdx b/docs/lint/rules/bun/no-restricted-text.mdx new file mode 100644 index 000000000000..d3cb7c04f76d --- /dev/null +++ b/docs/lint/rules/bun/no-restricted-text.mdx @@ -0,0 +1,70 @@ +--- +title: "bun/no-restricted-text" +description: "Disallow text that matches a pattern in strings, templates, JSX text, comments, and regular expressions" +--- + +Some mistakes are in the words, not in the code: an `http://` URL, an old product name, three dots where the design asks for an ellipsis. This rule reports text that matches a regular expression you give it, with your own message. + +The rule does nothing until you list `patterns`. Every match is reported on its own. + +## Examples + +These examples use this config: + +```toml bunfig.toml +[lint.rules] +"bun/no-restricted-text" = ["error", { patterns = [{ pattern = "http://", message = "Use https.", in = ["strings", "templates"] }] }] +``` + +Reported: + +{/* bad {"count": 2, "options": [{"patterns": [{"pattern": "http://", "message": "Use https.", "in": ["strings", "templates"]}]}]} */} + +```js +const api = "http://api.example.com"; +const avatar = `http://img.example.com/${user.id}.png`; +``` + +Not reported: + +{/* good {"options": [{"patterns": [{"pattern": "http://", "message": "Use https.", "in": ["strings", "templates"]}]}]} */} + +```js +// This was http:// until the certificate arrived. +const api = "https://api.example.com"; +``` + +## Options + +`patterns` is a list of objects with these keys: + +| Key | Default | What it does | +| --------- | -------------------------------- | --------------------------------------------------------------------------------------------- | +| `pattern` | required | The source of a JavaScript regular expression. | +| `flags` | `"u"` | Its flags. | +| `message` | The matched text and the pattern | What the report says. | +| `in` | All five | Where to look: `"strings"`, `"templates"`, `"jsxText"`, `"comments"`, `"regularExpressions"`. | + +The pattern sees the text as it is written in the file, so an escape such as `\n` in a string is a backslash and an `n`. A pattern that is not a valid regular expression is an error in the config. + +Text between JSX tags: + +{/* bad {"options": [{"patterns": [{"pattern": "\\.\\.\\.", "message": "Use the ellipsis character.", "in": ["jsxText"]}]}]} */} + +```jsx +const status =

Loading...

; +``` + +{/* good {"options": [{"patterns": [{"pattern": "\\.\\.\\.", "message": "Use the ellipsis character.", "in": ["jsxText"]}]}]} */} + +```jsx +const status =

Loading…

; +``` + +With the `i` flag, upper and lower case both match: + +{/* bad {"options": [{"patterns": [{"pattern": "acme corp", "flags": "iu", "in": ["strings"]}]}]} */} + +```js +const footer = "Copyright ACME Corp"; +``` diff --git a/docs/lint/rules/bun/no-side-effects-on-import.mdx b/docs/lint/rules/bun/no-side-effects-on-import.mdx new file mode 100644 index 000000000000..fc7c014e4c83 --- /dev/null +++ b/docs/lint/rules/bun/no-side-effects-on-import.mdx @@ -0,0 +1,97 @@ +--- +title: "bun/no-side-effects-on-import" +description: "Disallow top-level statements that call something or assign to a property" +--- + +A file that does something at its top level does it for everyone who imports it. A test that imports one helper from the file also starts the server, and a bundler has to keep the file even if nothing from it is used. Export a function instead, and let the importer decide when to call it. + +The rule reports a statement that is not inside a function if it calls something, uses `new`, `import()`, or a tagged template, or assigns to, deletes, or increments a property. + +## Examples + +Reported: + +{/* bad */} + +```js +import { createServer } from "./server.js"; + +createServer().listen(3000); +``` + +{/* bad */} + +```js +globalThis.appVersion = "1.4.0"; +``` + +Not reported: + +{/* good */} + +```js +import { createServer } from "./server.js"; + +export function start() { + createServer().listen(3000); +} +``` + +Declarations are not checked, so a module can still build its constants. `require()` and assigning to `module.exports` or `exports` are allowed too: + +{/* good */} + +```js +require("./polyfills.js"); + +const cache = new Map(); + +module.exports = { cache }; +exports.size = 100; +``` + +A call in the value that is assigned is still reported: + +{/* bad */} + +```js +module.exports = createApp(); +``` + +## Options + +| Option | Default | What it does | +| ------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | +| `allow` | `after`, `afterAll`, `afterEach`, `before`, `beforeAll`, `beforeEach`, `describe`, `expect`, `it`, `jest`, `mock`, `suite`, `test`, `vi` | What may be called at the top level. It replaces the default list. | + +The default list is what declares tests, hooks, and mocks, so test files pass: + +{/* good */} + +```js +import { expect, mock, test } from "bun:test"; + +mock.module("./database.js", () => ({ connect: mock() })); + +test.each([1, 2])("is positive", value => { + expect(value).toBeGreaterThan(0); +}); +``` + +A plain name such as `"test"` allows everything that starts with it: `test()`, `test.each()()`. A name with a dot such as `"customElements.define"` allows only that call: + +{/* good {"options": [{"allow": ["customElements.define"]}]} */} + +```js +customElements.define("user-card", UserCard); +``` + +{/* bad {"options": [{"allow": ["customElements.define"]}]} */} + +```js +customElements.upgrade(document.body); +``` + +## When not to use it + +An entry point, a script, or a polyfill exists to do something when it runs. Turn the rule off for those files. diff --git a/docs/lint/rules/bun/no-unbounded-requests.mdx b/docs/lint/rules/bun/no-unbounded-requests.mdx new file mode 100644 index 000000000000..a8d008e59187 --- /dev/null +++ b/docs/lint/rules/bun/no-unbounded-requests.mdx @@ -0,0 +1,87 @@ +--- +title: "bun/no-unbounded-requests" +description: "Require an axios request to have a `timeout` or a `signal`" +--- + +By default axios has no timeout. If the server accepts the connection and never answers, the promise never settles, and whatever awaits it hangs. Set `timeout`, or pass a `signal` that ends the request. + +The rule looks at `axios()`, `axios.request()`, `axios.create()`, and the methods such as `axios.get()` and `axios.post()`. A `timeout` of `0` or `Infinity` is reported too, because it sets no limit. + +## Examples + +Reported: + +{/* bad {"count": 3} */} + +```js +import axios from "axios"; + +const user = await axios.get(`/users/${id}`); +await axios.post("/users", user, { headers }); +const api = axios.create({ baseURL: "https://api.example.com", timeout: 0 }); +``` + +Not reported: + +{/* good */} + +```js +import axios from "axios"; + +const user = await axios.get(`/users/${id}`, { timeout: 5000 }); +await axios.post("/users", user, { headers, signal: AbortSignal.timeout(5000) }); +const api = axios.create({ baseURL: "https://api.example.com", timeout: 5000 }); +``` + +A file that sets a default timeout is not checked: + +{/* good */} + +```js +import axios from "axios"; + +axios.defaults.timeout = 5000; + +const user = await axios.get(`/users/${id}`); +``` + +A config with `cancelToken` passes too. A config that is a variable, or that spreads another object, is not checked, because the rule cannot see what is in it. For an instance, the `axios.create()` call is checked and the requests made with it are not. + +{/* good */} + +```js +import axios from "axios"; + +await axios.get("/users", requestConfig); +await axios.get("/users", { ...requestConfig, params }); + +const api = axios.create({ timeout: 5000 }); +await api.get("/users"); +``` + +## Options + +| Option | Default | What it does | +| --------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `clients` | `[]` | More modules that export something you call like `axios`. Each entry is `{ module, names }`: the import specifier and the names of the exports. Without `names`, the default export is the client. | + +```toml bunfig.toml +[lint.rules] +"bun/no-unbounded-requests" = ["error", { clients = [{ module = "./http.js", names = ["api"] }] }] +``` + +{/* bad {"options": [{"clients": [{"module": "./http.js", "names": ["api"]}]}]} */} + +```js +import { api } from "./http.js"; + +const users = await api.get("/users"); +``` + +{/* bad {"options": [{"clients": [{"module": "./http.js"}]}]} */} + +```js +import api from "./http.js"; + +const users = await api.get("/users"); +``` diff --git a/docs/lint/rules/bun/no-unhandled-emitter-errors.mdx b/docs/lint/rules/bun/no-unhandled-emitter-errors.mdx new file mode 100644 index 000000000000..3b18b2022e93 --- /dev/null +++ b/docs/lint/rules/bun/no-unhandled-emitter-errors.mdx @@ -0,0 +1,97 @@ +--- +title: "bun/no-unhandled-emitter-errors" +description: "Require an `error` listener on event emitters that Node.js functions return" +--- + +When an event emitter emits `error` and nobody listens for it, the error is thrown instead. A `try` around the call does not catch it, because it happens later. So `spawn("gti")` with a typo, or `createReadStream()` on a file that is gone, ends the whole process. + +The rule looks at what these functions return: + +| Module | Functions | +| ------------------------- | ------------------------------------------------ | +| `node:child_process` | `spawn`, `fork` | +| `node:cluster` | `fork` | +| `node:fs` | `createReadStream`, `createWriteStream`, `watch` | +| `node:http`, `node:https` | `request`, `get` | +| `node:http2`, `node:tls` | `connect` | +| `node:net` | `connect`, `createConnection` | + +## Examples + +Reported: + +{/* bad */} + +```js +import { spawn } from "node:child_process"; + +const child = spawn("git", ["status", "--short"]); +child.stdout.on("data", show); +child.on("exit", done); +``` + +`pipe()` does not pass errors on, so the source still needs a listener: + +{/* bad */} + +```js +import { createReadStream } from "node:fs"; + +createReadStream(file).pipe(response); +``` + +Not reported: + +{/* good */} + +```js +import { spawn } from "node:child_process"; + +const child = spawn("git", ["status", "--short"]); +child.stdout.on("data", show); +child.on("exit", done); +child.on("error", fail); +``` + +{/* good */} + +```js +import { createReadStream } from "node:fs"; + +createReadStream(file).on("error", fail).pipe(response); +``` + +An emitter that is returned or passed to another function is not reported, because the listener may be added there. `pipeline()` handles the errors of its streams: + +{/* good */} + +```js +import { createReadStream } from "node:fs"; +import { pipeline } from "node:stream/promises"; + +await pipeline(createReadStream(file), response); + +export function open(path) { + return createReadStream(path); +} +``` + +## Options + +| Option | Default | What it does | +| ----------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `factories` | `[]` | More functions that return an emitter, checked in addition to the table above. Each entry is `{ module, names }`: the import specifier and the names of the functions. Without `names`, the default export is the function. | + +```toml bunfig.toml +[lint.rules] +"bun/no-unhandled-emitter-errors" = ["error", { factories = [{ module = "ws", names = ["createWebSocketStream"] }] }] +``` + +{/* bad {"options": [{"factories": [{"module": "ws", "names": ["createWebSocketStream"]}]}]} */} + +```js +import { createWebSocketStream } from "ws"; + +const stream = createWebSocketStream(socket); +stream.on("data", show); +``` diff --git a/docs/lint/rules/bun/no-unportable-commands.mdx b/docs/lint/rules/bun/no-unportable-commands.mdx new file mode 100644 index 000000000000..0fa381366662 --- /dev/null +++ b/docs/lint/rules/bun/no-unportable-commands.mdx @@ -0,0 +1,89 @@ +--- +title: "bun/no-unportable-commands" +description: "Disallow starting a process with a command that does not work the same on every system" +--- + +A command that works on macOS and Linux can fail on Windows: + +- `npm` is a script there, `npm.cmd`, and a script cannot be started without a shell. +- `ls`, `rm` and `grep` do not exist. +- `&&` and `|` are shell syntax, and the shell is not the same on every system. + +The rule checks `exec`, `spawn` and `execFile` from `node:child_process`, their `Sync` versions, `Bun.spawn` and `Bun.spawnSync`, when the command is a string in the call. It also reports any string that contains `ps` with options, such as `"ps -ef"`, because `ps` takes different options on each system. + +## Examples + +Reported: + +{/* bad {"count": 3} */} + +```js +import { exec, spawn } from "node:child_process"; + +spawn("npm", ["install"]); +spawn("rm", ["-rf", "dist"]); +exec("bun run build && bun run deploy"); +``` + +{/* bad */} + +```js +Bun.spawn(["cat", "notes.txt"]); +``` + +{/* bad */} + +```js +const listProcesses = "ps -ef"; +``` + +Not reported: + +{/* good */} + +```js +import { spawn } from "node:child_process"; +import { rm } from "node:fs/promises"; + +spawn("npm", ["install"], { shell: true }); +await rm("dist", { recursive: true, force: true }); +``` + +Bun Shell has its own `rm`, `ls`, `cat`, `&&` and `|`, which work the same on every system: + +{/* good */} + +```js +import { $ } from "bun"; + +await $`rm -rf dist`; +await $`bun run build && bun run deploy`; +``` + +## Options + +| Option | Default | What it does | +| ----------- | ----------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| `scripts` | `["npm", "npx", "pnpm", "pnpx", "yarn"]` | Commands that are `.cmd` scripts on Windows. Reported when they are started without a shell. | +| `posixOnly` | `awk`, `cat`, `chmod`, `cp`, `grep`, `kill`, `ls`, `mv`, `rm`, `sed`, `sh`, `touch` and 21 more | Commands that Windows does not have. Reported with or without a shell. | +| `launchers` | `[]` | Names of your own functions that start a process. Their first argument is checked like the first argument of `spawn`. | + +Setting a list replaces the default, so repeat the defaults you want to keep. + +{/* bad {"options": [{"scripts": ["npm", "tsc"]}]} */} + +```js +import { spawnSync } from "node:child_process"; + +spawnSync("tsc", ["--noEmit"]); +``` + +{/* bad {"options": [{"launchers": ["run"]}]} */} + +```js +await run("grep", ["-r", "TODO", "src"]); +``` + +## When not to use it + +Your code never runs on Windows. diff --git a/docs/lint/rules/bun/no-unseeded-random-in-property-test.mdx b/docs/lint/rules/bun/no-unseeded-random-in-property-test.mdx new file mode 100644 index 000000000000..72c28cbfab76 --- /dev/null +++ b/docs/lint/rules/bun/no-unseeded-random-in-property-test.mdx @@ -0,0 +1,65 @@ +--- +title: "bun/no-unseeded-random-in-property-test" +description: "Disallow `Math.random()` and `fc.sample()` inside a fast-check property" +--- + +fast-check draws every input of a property from one seed. When a property fails, it prints the seed so you can run the same inputs again, and it shrinks the inputs to the smallest ones that still fail. + +A value from `Math.random()` or `fc.sample()` inside the function you pass to `fc.property()` or `fc.asyncProperty()` does not come from that seed. It is different on every run, so the failure cannot be replayed or shrunk. Make the value a parameter of the property instead. + +## Examples + +Reported: + +{/* bad */} + +```ts +import fc from "fast-check"; +import { pad } from "./text"; + +fc.assert( + fc.property(fc.string(), text => { + const width = Math.floor(Math.random() * 80); + return pad(text, width).length >= width; + }), +); +``` + +{/* bad */} + +```ts +import { assert, integer, property, sample, string } from "fast-check"; +import { pad } from "./text"; + +assert( + property(string(), text => { + const [width] = sample(integer({ min: 0, max: 80 }), 1); + return pad(text, width).length >= width; + }), +); +``` + +Not reported: + +{/* good */} + +```ts +import fc from "fast-check"; +import { pad } from "./text"; + +fc.assert( + fc.property(fc.string(), fc.integer({ min: 0, max: 80 }), (text, width) => { + return pad(text, width).length >= width; + }), +); +``` + +`fc.sample()` outside a property is fine: + +{/* good */} + +```ts +import fc from "fast-check"; + +console.log(fc.sample(fc.emailAddress(), 5)); +``` diff --git a/docs/lint/rules/bun/no-useless-spread-fallback.mdx b/docs/lint/rules/bun/no-useless-spread-fallback.mdx new file mode 100644 index 000000000000..42572574e860 --- /dev/null +++ b/docs/lint/rules/bun/no-useless-spread-fallback.mdx @@ -0,0 +1,75 @@ +--- +title: "bun/no-useless-spread-fallback" +description: "Disallow an empty object as the fallback in `...(condition ? value : {})`" +--- + +Spreading `false`, `null` or `undefined` into an object does nothing. So the `{}` in `...(condition ? { a } : {})` is not needed, and `...(condition && { a })` does the same with less to read. + +The rule checks spreads in object literals and in JSX attributes. + +`--fix-suggestions` rewrites `condition ? value : {}` to `condition && value`. + +## Examples + +Reported: + +{/* bad */} + +```js +const headers = { + accept: "application/json", + ...(apiKey ? { "x-api-key": apiKey } : {}), +}; +``` + +{/* bad */} + +```jsx +const link = ( + + {label} + +); +``` + +Not reported: + +{/* good */} + +```js +const headers = { + accept: "application/json", + ...(apiKey && { "x-api-key": apiKey }), +}; +``` + +An empty object in the first branch is reported too, without a suggestion: + +{/* bad */} + +```js +const query = { page, ...(showAll ? {} : { limit: 20 }) }; +``` + +{/* good */} + +```js +const query = { page, ...(!showAll && { limit: 20 }) }; +``` + +## Options + +| Option | Default | What it does | +| -------------------------- | ------- | ------------------------------------------------------------------------------------- | +| `ignoreConditionsThatCall` | `[]` | Names of functions. A conditional is not reported if its condition calls one of them. | + +Use it for a function that a build tool only understands as the condition of `? :`, such as a feature flag: + +{/* good {"options": [{"ignoreConditionsThatCall": ["feature"]}]} */} + +```js +const routes = { + home, + ...(feature("ADMIN") ? { admin } : {}), +}; +``` diff --git a/docs/lint/rules/bun/prefer-builtin-sleep.mdx b/docs/lint/rules/bun/prefer-builtin-sleep.mdx new file mode 100644 index 000000000000..55f0bc164cec --- /dev/null +++ b/docs/lint/rules/bun/prefer-builtin-sleep.mdx @@ -0,0 +1,56 @@ +--- +title: "bun/prefer-builtin-sleep" +description: "Prefer `Bun.sleep()` to a promise that only waits for `setTimeout`" +--- + +`new Promise(resolve => setTimeout(resolve, ms))` takes a moment to read, and all it does is wait. `Bun.sleep(ms)` does the same and says so. `setTimeout(ms)` from `node:timers/promises` does too. + +`--fix-suggestions` replaces the promise with `Bun.sleep(ms)`. + +## Examples + +Reported: + +{/* bad */} + +```js +await new Promise(resolve => setTimeout(resolve, 500)); +``` + +{/* bad */} + +```js +function wait(ms) { + return new Promise(resolve => { + setTimeout(() => resolve(), ms); + }); +} +``` + +Not reported: + +{/* good */} + +```js +await Bun.sleep(500); +``` + +{/* good */} + +```js +import { setTimeout } from "node:timers/promises"; + +await setTimeout(500); +``` + +A promise that does more than wait is not reported. This one resolves with a value: + +{/* good */} + +```js +const answer = await new Promise(resolve => setTimeout(() => resolve("timed out"), 500)); +``` + +## When not to use it + +`Bun.sleep()` only exists in Bun, and `node:timers/promises` does not exist in browsers. Turn the rule off for code that also runs in a browser. diff --git a/docs/lint/rules/bun/prefer-lazy-schema.mdx b/docs/lint/rules/bun/prefer-lazy-schema.mdx new file mode 100644 index 000000000000..d2dad522ff2d --- /dev/null +++ b/docs/lint/rules/bun/prefer-lazy-schema.mdx @@ -0,0 +1,98 @@ +--- +title: "bun/prefer-lazy-schema" +description: "Disallow building a validation schema while the module loads" +--- + +A Zod schema at the top level of a module is built as soon as the module is imported, even if nothing is ever validated with it. With hundreds of schemas, that makes every start slower. + +The rule reports a chain of calls that starts with `z`, where `z` is imported or global, if it runs while the module loads. That includes static class fields and functions that are called where they are written. A schema inside another schema is not reported again. + +## Examples + +Reported: + +{/* bad {"count": 1} */} + +```ts +import { z } from "zod"; + +export const User = z.object({ + name: z.string().min(1), + email: z.string().email(), +}); +``` + +Not reported: + +{/* good */} + +```ts +import { z } from "zod"; + +let user: z.ZodType | undefined; + +export function userSchema() { + return (user ??= z.object({ + name: z.string().min(1), + email: z.string().email(), + })); +} +``` + +`z.lazy()` builds nothing until it is used, so it is allowed: + +{/* good */} + +```ts +import { z } from "zod"; + +export const Comment = z.lazy(() => z.object({ text: z.string() })); +``` + +## Options + +| Option | Default | What it does | +| --------------- | ------- | ------------------------------------------------------------------------------------------------- | +| `roots` | `["z"]` | The names that a schema starts with, such as the `z` of `z.object()`. | +| `methods` | `[]` | Methods that build a schema, whatever they are called on, such as `extend`. | +| `factorySuffix` | none | Also report calls to functions with a name that ends in this string. | +| `lazyWrapper` | none | The name of your helper that builds a schema on first use. The message then shows how to call it. | + +{/* bad {"options": [{"roots": ["v"]}]} */} + +```ts +import * as v from "valibot"; + +export const Email = v.pipe(v.string(), v.email()); +``` + +{/* bad {"options": [{"methods": ["extend"]}]} */} + +```ts +import { User } from "./user"; + +export const Admin = User.extend({ level: 1 }); +``` + +{/* bad {"options": [{"factorySuffix": "Schema"}]} */} + +```ts +import { createUserSchema } from "./user"; + +export const User = createUserSchema(); +``` + +`lazyWrapper` does not change what is reported. A schema in a function that you pass to a helper is allowed with or without it: + +{/* good {"options": [{"lazyWrapper": "once"}]} */} + +```ts +import { z } from "zod"; +import { once } from "./once"; + +export const user = once(() => z.object({ name: z.string() })); +``` + +## When not to use it + +In a small program, or a server that starts once and runs for days, the time to build the schemas does not matter. diff --git a/docs/lint/rules/bun/prefer-local-module.mdx b/docs/lint/rules/bun/prefer-local-module.mdx new file mode 100644 index 000000000000..0d9a37e98a97 --- /dev/null +++ b/docs/lint/rules/bun/prefer-local-module.mdx @@ -0,0 +1,67 @@ +--- +title: "bun/prefer-local-module" +description: "Require importing a package through the module in your project that wraps it" +--- + +A project often wraps a package in a module of its own, for example to give an HTTP client a base URL, a timeout and logging. A file that imports the package directly skips all of that, and nothing tells you. + +The rule does nothing until you list the packages in `modules`. It then checks `import`, `export .. from`, `import()` and `require()`. The name has to match exactly: with `axios` listed, `axios/lib` is not reported. + +`--fix` changes the import to your module. + +## Examples + +These examples use this configuration: + +```toml bunfig.toml +[lint.rules] +"bun/prefer-local-module" = ["error", { modules = [{ package = "axios", use = "@/http" }] }] +``` + +Reported: + +{/* bad {"options": [{"modules": [{"package": "axios", "use": "@/http"}]}]} */} + +```ts +import axios from "axios"; + +const users = await axios.get("/users"); +``` + +Not reported: + +{/* good {"options": [{"modules": [{"package": "axios", "use": "@/http"}]}]} */} + +```ts +import axios from "@/http"; + +const users = await axios.get("/users"); +``` + +## Options + +| Option | Default | What it does | +| ------------- | ------- | -------------------------------------------------------------------------- | +| `modules` | `[]` | The wrapped packages. See below. | +| `exemptFiles` | `[]` | Files that may import the packages directly. List the wrapper itself here. | + +Each entry of `modules` has: + +| Key | What it is | +| --------- | -------------------------------------------------------------------------------- | +| `package` | The package that must not be imported directly. Required. | +| `use` | The specifier to import instead. Required. | +| `default` | The name under which your module exports the default export of the package. | +| `names` | Exports that have another name in your module: `{ "isCancel": "wasCancelled" }`. | + +`default` and `names` only matter for `--fix`. With `default` set to `"http"`, `import axios from "axios"` becomes `import { http as axios } from "@/http"`. If either is set, only `import` statements are fixed, and not `import * as` or `import type`. + +An entry of `exemptFiles` is the end of a path, in whole names. `http.ts` matches `src/http.ts` but not `src/other-http.ts`. + +{/* good {"filename": "src/http.ts", "options": [{"modules": [{"package": "axios", "use": "@/http"}], "exemptFiles": ["src/http.ts"]}]} */} + +```ts +import axios from "axios"; + +export default axios.create({ baseURL: "https://api.example.com", timeout: 5000 }); +``` diff --git a/docs/lint/rules/bun/prefer-timeout-signal.mdx b/docs/lint/rules/bun/prefer-timeout-signal.mdx new file mode 100644 index 000000000000..089324fd5aad --- /dev/null +++ b/docs/lint/rules/bun/prefer-timeout-signal.mdx @@ -0,0 +1,67 @@ +--- +title: "bun/prefer-timeout-signal" +description: "Prefer `AbortSignal.timeout()` to an `AbortController` that only a timer aborts" +--- + +`AbortSignal.timeout(ms)` does in one call what an `AbortController` and a `setTimeout` that aborts it do together. Its signal also aborts with a `TimeoutError` instead of an `AbortError`, so the code that catches the error can tell a timeout from a cancellation. + +A controller is reported if one `setTimeout(() => controller.abort(), ms)` is the only thing that aborts it, and the rest of the code only reads `controller.signal`. + +## Examples + +Reported: + +{/* bad */} + +```js +const controller = new AbortController(); +setTimeout(() => controller.abort(), 5000); + +const response = await fetch(url, { signal: controller.signal }); +``` + +Not reported: + +{/* good */} + +```js +const response = await fetch(url, { signal: AbortSignal.timeout(5000) }); +``` + +A controller that something else can abort too is not reported: + +{/* good */} + +```js +const controller = new AbortController(); +setTimeout(() => controller.abort(), 5000); +cancelButton.addEventListener("click", () => controller.abort()); + +const response = await fetch(url, { signal: controller.signal }); +``` + +Neither is one that aborts with a reason of its own: + +{/* good */} + +```js +const controller = new AbortController(); +setTimeout(() => controller.abort(new Error("The upload took too long")), 5000); + +await upload(file, { signal: controller.signal }); +``` + +## Options + +| Option | Default | What it does | +| ----------- | ------- | ----------------------------------------------------------------------------------------------------------------------- | +| `factories` | `[]` | Names of your own functions that return an `AbortController`. What they return is checked like `new AbortController()`. | + +{/* bad {"options": [{"factories": ["createController"]}]} */} + +```js +const controller = createController(); +setTimeout(() => controller.abort(), 5000); + +const response = await fetch(url, { signal: controller.signal }); +``` diff --git a/docs/lint/rules/bun/prefer-url-for-sibling-files.mdx b/docs/lint/rules/bun/prefer-url-for-sibling-files.mdx new file mode 100644 index 000000000000..0a3613cae947 --- /dev/null +++ b/docs/lint/rules/bun/prefer-url-for-sibling-files.mdx @@ -0,0 +1,72 @@ +--- +title: "bun/prefer-url-for-sibling-files" +description: 'Prefer `new URL("./file", import.meta.url)` to a path joined onto the module''s directory' +--- + +Bundlers such as Vite and webpack recognize `new URL("./file", import.meta.url)`. They copy the file to the output and point the URL at the copy. A path that is joined onto `import.meta.dirname` or `__dirname` is only a string to them, so after bundling it names a file that is not there. + +The rule reports `join()` and `resolve()` from `node:path`, `+` and template literals, if the first part is the directory of the module and the rest is string literals. The directory can be `__dirname`, `import.meta.dirname`, `import.meta.dir`, `dirname(fileURLToPath(import.meta.url))`, or a `const` that holds one of them. + +## Examples + +Reported: + +{/* bad */} + +```js +import { join } from "node:path"; + +const schema = await Bun.file(join(import.meta.dirname, "schema.sql")).text(); +``` + +{/* bad {"count": 2} */} + +```js +import { readFileSync } from "node:fs"; + +const logo = readFileSync(`${import.meta.dirname}/assets/logo.svg`); +const icon = readFileSync(import.meta.dirname + "/assets/icon.svg"); +``` + +{/* bad */} + +```js +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const folder = dirname(fileURLToPath(import.meta.url)); +export const template = resolve(folder, "templates", "email.html"); +``` + +Not reported: + +{/* good */} + +```js +const schema = await Bun.file(new URL("./schema.sql", import.meta.url)).text(); +``` + +A path with a part that is only known at run time, or one that ends in `.`, `..` or `/`, is not reported: + +{/* good */} + +```js +import { join } from "node:path"; + +export const translations = language => join(import.meta.dirname, "locales", `${language}.json`); +export const root = join(import.meta.dirname, ".."); +``` + +CommonJS files are not checked, because they have no `import.meta.url`: + +{/* good {"filename": "load.cjs"} */} + +```js +const { join } = require("node:path"); + +module.exports = join(__dirname, "schema.sql"); +``` + +## When not to use it + +Your code is never bundled, or your bundler does not follow `new URL(.., import.meta.url)`. diff --git a/docs/lint/rules/bun/react-no-latest-value-ref.mdx b/docs/lint/rules/bun/react-no-latest-value-ref.mdx new file mode 100644 index 000000000000..46a104a7078a --- /dev/null +++ b/docs/lint/rules/bun/react-no-latest-value-ref.mdx @@ -0,0 +1,84 @@ +--- +title: "bun/react-no-latest-value-ref" +description: "Disallow a ref that is set to the latest value on every render" +--- + +A common trick keeps the latest props or state in a ref, so that an effect can read them without listing them as dependencies. Setting `ref.current` while the component renders is not safe. React can render a component more than once, or throw the result away, so the ref can hold a value from a render that never reached the screen. + +An effect that only copies the value into the ref is safe, but it is reported too. A function from `useEffectEvent()` sees the latest values without a ref. + +## Examples + +Reported: + +{/* bad */} + +```jsx +import { useRef } from "react"; + +function Chat({ onMessage }) { + const onMessageRef = useRef(onMessage); + onMessageRef.current = onMessage; + // ... +} +``` + +{/* bad */} + +```jsx +import { useEffect, useRef } from "react"; + +function Chat({ onMessage }) { + const onMessageRef = useRef(onMessage); + useEffect(() => { + onMessageRef.current = onMessage; + }); + // ... +} +``` + +Not reported: + +{/* good */} + +```jsx +import { useEffect, useEffectEvent } from "react"; + +function Chat({ room, onMessage }) { + const handleMessage = useEffectEvent(message => onMessage(message)); + + useEffect(() => { + const connection = connect(room); + connection.on("message", message => handleMessage(message)); + return () => connection.close(); + }, [room]); + // ... +} +``` + +A ref that is set under a condition, in an event handler, or in an effect that also does something else is not reported: + +{/* good */} + +```jsx +import { useEffect, useRef } from "react"; + +function Player({ src }) { + const playerRef = useRef(null); + if (playerRef.current === null) { + playerRef.current = createPlayer(); + } + + const startedAt = useRef(0); + useEffect(() => { + startedAt.current = Date.now(); + playerRef.current.play(src); + }, [src]); + + return ; +} +``` + +## When not to use it + +`useEffectEvent()` needs React 19.2 or later. On an older version, keep the effect that copies the value and turn the rule off. diff --git a/docs/lint/rules/bun/react-no-local-render-helpers.mdx b/docs/lint/rules/bun/react-no-local-render-helpers.mdx new file mode 100644 index 000000000000..aa362ae403e3 --- /dev/null +++ b/docs/lint/rules/bun/react-no-local-render-helpers.mdx @@ -0,0 +1,82 @@ +--- +title: "bun/react-no-local-render-helpers" +description: "Disallow `renderItem()` functions that are defined inside a component and used in its JSX" +--- + +A `renderItem()` function inside a component does the job of a component, but React does not see it as one. It is created again on every render, it does not show up in React DevTools, and React cannot skip it when its input stays the same. Move it out and make it a component. + +The rule reports a function if its name is `render` followed by a capital letter, it is defined inside another function, and it is called or passed to a call in the JSX of that function. + +## Examples + +Reported: + +{/* bad */} + +```jsx +function UserList({ users }) { + const renderUser = user =>
  • {user.name}
  • ; + + return
      {users.map(renderUser)}
    ; +} +``` + +{/* bad */} + +```jsx +function Page({ title }) { + function renderHeader() { + return

    {title}

    ; + } + + return
    {renderHeader()}
    ; +} +``` + +Not reported: + +{/* good */} + +```jsx +function User({ user }) { + return
  • {user.name}
  • ; +} + +function UserList({ users }) { + return ( +
      + {users.map(user => ( + + ))} +
    + ); +} +``` + +A function that is passed as a prop is not reported: + +{/* good */} + +```jsx +function UserList({ users }) { + const renderUser = user => ; + + return ; +} +``` + +## Options + +| Option | Default | What it does | +| --------- | ------- | ---------------------------------------------------------------------------------------- | +| `pattern` | none | A regular expression for the names to report, in place of `render` and a capital letter. | + +{/* bad {"options": [{"pattern": "^(render|draw)[A-Z]"}]} */} + +```jsx +function Chart({ points }) { + const drawPoint = point => ; + + return {points.map(drawPoint)}; +} +``` diff --git a/docs/lint/rules/bun/react-no-namespace-hooks.mdx b/docs/lint/rules/bun/react-no-namespace-hooks.mdx new file mode 100644 index 000000000000..623dd11c59d0 --- /dev/null +++ b/docs/lint/rules/bun/react-no-namespace-hooks.mdx @@ -0,0 +1,57 @@ +--- +title: "bun/react-no-namespace-hooks" +description: "Require React hooks to be imported by name: `useState()`, not `React.useState()`" +--- + +`React.useState()` and `useState()` are the same function. With both spellings in one project, the same code looks different from file to file. This rule keeps the short one. + +It checks `React` from `import React from "react"` and from `import * as React from "react"`. + +`--fix` removes `React.` and imports the hook by name from `"react"`. + +## Examples + +Reported: + +{/* bad {"count": 2} */} + +```jsx +import React from "react"; + +function Counter() { + const [count, setCount] = React.useState(0); + React.useEffect(() => { + document.title = `${count} clicks`; + }, [count]); + + return ; +} +``` + +Not reported: + +{/* good */} + +```jsx +import { useEffect, useState } from "react"; + +function Counter() { + const [count, setCount] = useState(0); + useEffect(() => { + document.title = `${count} clicks`; + }, [count]); + + return ; +} +``` + +Only hooks are reported. Other members of `React` are allowed: + +{/* good */} + +```jsx +import * as React from "react"; + +const Settings = React.lazy(() => import("./Settings")); +const Row = React.memo(({ label }) =>
  • {label}
  • ); +``` diff --git a/docs/lint/rules/bun/react-prefer-updater-function.mdx b/docs/lint/rules/bun/react-prefer-updater-function.mdx new file mode 100644 index 000000000000..a7e732376ffd --- /dev/null +++ b/docs/lint/rules/bun/react-prefer-updater-function.mdx @@ -0,0 +1,82 @@ +--- +title: "bun/react-prefer-updater-function" +description: "Prefer `setItems(previous => ..)` to a new array or object made from the state of the last render" +--- + +The `items` of `const [items, setItems] = useState([])` is the state as of the last render. If `setItems([...items, item])` runs after an `await`, in a timer, or twice before the next render, `items` is out of date and the other update is lost. A function gets the current state: `setItems(previous => [...previous, item])`. + +The rule reports a setter that is called with an array or object literal that spreads its own state. + +## Examples + +Reported: + +{/* bad */} + +```jsx +import { useState } from "react"; + +function Cart() { + const [items, setItems] = useState([]); + + async function add(id) { + const item = await loadItem(id); + setItems([...items, item]); + } + // ... +} +``` + +{/* bad */} + +```jsx +import { useState } from "react"; + +function Profile() { + const [form, setForm] = useState({ name: "", email: "" }); + + return setForm({ ...form, name: event.target.value })} />; +} +``` + +Not reported: + +{/* good */} + +```jsx +import { useState } from "react"; + +function Cart() { + const [items, setItems] = useState([]); + + async function add(id) { + const item = await loadItem(id); + setItems(previous => [...previous, item]); + } + // ... +} +``` + +If neither name comes from a `useState()` in the file, for example because both are props, the names decide. `setRows` is taken for the setter of `rows`: + +{/* bad */} + +```jsx +function AddRow({ rows, setRows }) { + return ; +} +``` + +Spreading something else is fine: + +{/* good */} + +```jsx +import { useState } from "react"; + +function Search({ defaults }) { + const [filters, setFilters] = useState({}); + + return ; +} +``` diff --git a/docs/lint/rules/eslint/accessor-pairs.mdx b/docs/lint/rules/eslint/accessor-pairs.mdx new file mode 100644 index 000000000000..82e912ec9b0d --- /dev/null +++ b/docs/lint/rules/eslint/accessor-pairs.mdx @@ -0,0 +1,90 @@ +--- +title: "accessor-pairs" +description: "Require a getter for every setter in objects and classes" +--- + +A property that has a setter and no getter can be written but never read: `user.name = "Ada"` works, and `user.name` is `undefined`. Most of the time the getter was forgotten. + +The rule looks at object literals, classes, and the descriptors passed to `Object.defineProperty()`, `Object.defineProperties()`, `Object.create()` and `Reflect.defineProperty()`. A getter without a setter is a read-only property, so it is allowed by default. + +## Examples + +Reported: + +{/* bad */} + +```js +const user = { + set name(value) { + this._name = value.trim(); + }, +}; +``` + +{/* bad */} + +```js +Object.defineProperty(user, "name", { + set(value) { + this._name = value.trim(); + }, +}); +``` + +Not reported: + +{/* good */} + +```js +const user = { + get name() { + return this._name; + }, + set name(value) { + this._name = value.trim(); + }, +}; + +const config = { + get version() { + return 3; + }, +}; +``` + +## Options + +| Option | Default | What it does | +| ------------------------ | ------- | ------------------------------------------------------------------- | +| `setWithoutGet` | `true` | Report a setter that has no getter. | +| `getWithoutSet` | `false` | Report a getter that has no setter. | +| `enforceForClassMembers` | `true` | Also check classes. A static accessor only pairs with a static one. | +| `enforceForTSTypes` | `false` | Also check TypeScript interfaces and object types. | + +{/* bad {"options": [{"getWithoutSet": true}]} */} + +```js +const config = { + get version() { + return 3; + }, +}; +``` + +{/* good {"options": [{"enforceForClassMembers": false}]} */} + +```js +class User { + set name(value) { + this._name = value.trim(); + } +} +``` + +{/* bad {"options": [{"enforceForTSTypes": true}]} */} + +```ts +interface User { + set name(value: string); +} +``` diff --git a/docs/lint/rules/eslint/array-callback-return.mdx b/docs/lint/rules/eslint/array-callback-return.mdx new file mode 100644 index 000000000000..fd190f84c671 --- /dev/null +++ b/docs/lint/rules/eslint/array-callback-return.mdx @@ -0,0 +1,81 @@ +--- +title: "array-callback-return" +description: "Require callbacks of array methods such as `map` and `filter` to return a value" +--- + +Methods like `map` and `filter` use what the callback returns. If the callback forgets `return`, they get `undefined`: `map` fills the array with `undefined`, and `filter` drops every item. A callback that returns a value on some paths only is reported too. + +The rule checks a function passed directly to `every`, `filter`, `find`, `findIndex`, `findLast`, `findLastIndex`, `flatMap`, `map`, `reduce`, `reduceRight`, `some`, `sort`, `toSorted`, `Array.from` or `Array.fromAsync`. + +## Examples + +Reported: + +{/* bad */} + +```js +const names = users.map(user => { + user.name.trim(); +}); +``` + +{/* bad */} + +```js +const adults = users.filter(user => { + if (user.age >= 18) { + return true; + } +}); +``` + +Not reported: + +{/* good */} + +```js +const names = users.map(user => { + return user.name.trim(); +}); + +const adults = users.filter(user => user.age >= 18); +``` + +## Options + +| Option | Default | What it does | +| --------------- | ------- | ----------------------------------------------------------------------------------------------------------------- | +| `allowImplicit` | `false` | Allow `return;` without a value as a way to return `undefined`. | +| `checkForEach` | `false` | Also check `forEach`, the other way around: report a callback that returns a value, because `forEach` ignores it. | +| `allowVoid` | `false` | With `checkForEach`, allow a returned value that starts with `void`. | + +{/* good {"options": [{"allowImplicit": true}]} */} + +```js +const first = users.find(user => { + if (!user.active) { + return; + } + return user.admin; +}); +``` + +{/* bad {"options": [{"checkForEach": true}]} */} + +```js +users.forEach(user => save(user)); +``` + +{/* good {"options": [{"checkForEach": true}]} */} + +```js +users.forEach(user => { + save(user); +}); +``` + +{/* good {"options": [{"checkForEach": true, "allowVoid": true}]} */} + +```js +users.forEach(user => void save(user)); +``` diff --git a/docs/lint/rules/eslint/arrow-body-style.mdx b/docs/lint/rules/eslint/arrow-body-style.mdx new file mode 100644 index 000000000000..2ae2107d2cc7 --- /dev/null +++ b/docs/lint/rules/eslint/arrow-body-style.mdx @@ -0,0 +1,77 @@ +--- +title: "arrow-body-style" +description: "Enforce one style for the braces around arrow function bodies" +--- + +`x => x * 2` and `x => { return x * 2; }` do the same thing. By default, this rule reports the braces when the body is nothing but one `return`, so short functions stay short. + +`--fix` removes or adds the braces and the `return`. + +## Examples + +Reported: + +{/* bad */} + +```js +const double = items.map(item => { + return item * 2; +}); +``` + +Not reported: + +{/* good */} + +```js +const double = items.map(item => item * 2); + +const logged = items.map(item => { + console.log(item); + return item * 2; +}); +``` + +## Options + +```toml bunfig.toml +[lint.rules] +arrow-body-style = ["error", "always"] +``` + +| Option | What it does | +| ----------------------- | --------------------------------------------------------------------------------------------------------------------- | +| `"as-needed"` (default) | Report braces around a body that is only a `return`. | +| `"always"` | Report every arrow function without braces. | +| `"never"` | Report every arrow function with braces, whatever is in them. `--fix` only rewrites a body that is a single `return`. | + +{/* bad {"options": ["always"]} */} + +```js +const double = items.map(item => item * 2); +``` + +{/* bad {"options": ["never"]} */} + +```js +const logged = items.map(item => { + console.log(item); + return item * 2; +}); +``` + +With `"as-needed"`, a second option `{ "requireReturnForObjectLiteral": true }` (default `false`) requires braces and a `return` when the function returns an object literal, instead of `=> ({ ... })`. + +{/* bad {"options": ["as-needed", {"requireReturnForObjectLiteral": true}]} */} + +```js +const toPoint = item => ({ x: item.left, y: item.top }); +``` + +{/* good {"options": ["as-needed", {"requireReturnForObjectLiteral": true}]} */} + +```js +const toPoint = item => { + return { x: item.left, y: item.top }; +}; +``` diff --git a/docs/lint/rules/eslint/block-scoped-var.mdx b/docs/lint/rules/eslint/block-scoped-var.mdx new file mode 100644 index 000000000000..d3c086c69af2 --- /dev/null +++ b/docs/lint/rules/eslint/block-scoped-var.mdx @@ -0,0 +1,50 @@ +--- +title: "block-scoped-var" +description: "Disallow using a `var` outside the block it is declared in" +--- + +A `var` declared inside an `if`, a loop or a `switch` is visible in the whole function. Code after the block can read it, and gets `undefined` when the block did not run. This rule reports every use of a `var` outside the block that declares it, as if it were a `let`. + +## Examples + +Reported: + +{/* bad */} + +```js +function label(user) { + if (user.admin) { + var prefix = "Admin: "; + } + return prefix + user.name; // "undefinedAda" for everyone else +} +``` + +{/* bad */} + +```js +function indexOfFirstEmpty(items) { + for (var i = 0; i < items.length; i++) { + if (!items[i]) break; + } + return i; +} +``` + +Not reported: + +{/* good */} + +```js +function label(user) { + var prefix = ""; + if (user.admin) { + prefix = "Admin: "; + } + return prefix + user.name; +} +``` + +## When not to use it + +If `no-var` is on, there is no `var` left to check, and `let` and `const` already behave this way. diff --git a/docs/lint/rules/eslint/callback-return.mdx b/docs/lint/rules/eslint/callback-return.mdx new file mode 100644 index 000000000000..89776977434f --- /dev/null +++ b/docs/lint/rules/eslint/callback-return.mdx @@ -0,0 +1,80 @@ +--- +title: "callback-return" +description: "Require `return` when calling a callback" +--- + +Calling a callback does not stop the function. If you call `callback(err)` in an `if` and forget to return, the code below runs too, and the callback is often called a second time. + +The rule reports a call to `callback`, `cb` or `next` inside a function, unless the call is returned, is followed by a `return` that ends its block, or is the last statement of the function. + +## Examples + +Reported: + +{/* bad */} + +```js +function load(path, callback) { + readFile(path, (err, text) => { + if (err) { + callback(err); + } + callback(null, JSON.parse(text)); + }); +} +``` + +Not reported: + +{/* good */} + +```js +function load(path, callback) { + readFile(path, (err, text) => { + if (err) { + return callback(err); + } + callback(null, JSON.parse(text)); + }); +} + +function check(request, response, next) { + if (!request.user) { + next(new Error("Not signed in")); + return; + } + next(); +} +``` + +## Options + +One option: the list of names that count as callbacks. It replaces the default, `["callback", "cb", "next"]`. A name can be a path such as `"response.send"`. + +{/* bad {"options": [["done", "response.send"]]} */} + +```js +function handle(request, response) { + if (!request.user) { + response.send(401); + } + response.send(200); +} +``` + +With the same list, `callback` is no longer checked: + +{/* good {"options": [["done", "response.send"]]} */} + +```js +function load(err, callback) { + if (err) { + callback(err); + } + finish(); +} +``` + +## When not to use it + +The rule goes by the name only, so it cannot tell a callback from another function called `next`. Code that uses promises and `async` functions has no use for it. diff --git a/docs/lint/rules/eslint/camelcase.mdx b/docs/lint/rules/eslint/camelcase.mdx new file mode 100644 index 000000000000..c42aa026759d --- /dev/null +++ b/docs/lint/rules/eslint/camelcase.mdx @@ -0,0 +1,108 @@ +--- +title: "camelcase" +description: "Require camelCase names for variables, functions and properties" +--- + +JavaScript's built-in APIs and most libraries use camelCase. If a codebase has both `user_name` and `userName`, you have to look up which spelling each variable uses. + +The rule reports a name that has an underscore in the middle. Underscores at the start or the end (`_private`, `unused_`) and names in capitals (`MAX_RETRIES`) are allowed. Reading a property (`row.first_name`), a key in quotes and a name in a call (`get_user()`) are not reported either, because those names often come from code you do not control. + +## Examples + +Reported: + +{/* bad {"count": 3} */} + +```js +const first_name = "Ada"; + +function full_name(user) { + return { display_name: user.first + " " + user.last }; +} +``` + +Not reported: + +{/* good */} + +```js +const MAX_RETRIES = 3; +const _cache = new Map(); +const firstName = row.first_name; +const user = get_user(firstName); + +function fullName(user) { + return { displayName: user.first + " " + user.last, "sort_key": user.last }; +} +``` + +## Options + +| Option | Default | What it does | +| --------------------- | ---------- | ------------------------------------------------------------------------------------------------- | +| `properties` | `"always"` | `"never"` stops checking property names: keys of object literals, class members, and `a.b_c = 1`. | +| `ignoreDestructuring` | `false` | Allow a destructured variable that keeps the name of the property. | +| `ignoreImports` | `false` | Allow a named import that keeps the name it is exported with. | +| `ignoreGlobals` | `false` | Allow the global variables that your config or a `/* global */` comment declares. | +| `allow` | `[]` | Names to allow. Each entry is a name or a regular expression. | + +{/* good {"options": [{"properties": "never"}]} */} + +```js +const body = { user_id: 7 }; +body.created_at = Date.now(); +``` + +With `ignoreDestructuring`, only the variable with the same name as the property is allowed. A new name is still checked: + +{/* good {"options": [{"ignoreDestructuring": true}]} */} + +```js +const { user_id } = await response.json(); +``` + +{/* bad {"options": [{"ignoreDestructuring": true}]} */} + +```js +const { id: user_id } = await response.json(); +``` + +{/* good {"options": [{"ignoreImports": true}]} */} + +```js +import { unstable_cache } from "next/cache"; +``` + +`ignoreDestructuring` and `ignoreImports` only cover the line that creates the variable. Using it later is still reported, except in a call: + +{/* bad {"options": [{"ignoreDestructuring": true}], "count": 1} */} + +```js +const { user_id } = await response.json(); +console.log(user_id); +const key = "user:" + user_id; +``` + +{/* bad {"options": [{"ignoreImports": true}], "count": 1} */} + +```js +import { unstable_cache } from "next/cache"; +export const cache = unstable_cache; +``` + +{/* good {"options": [{"ignoreGlobals": true}]} */} + +```js +/* global api_key */ +const key = api_key; +``` + +{/* good {"options": [{"allow": ["^UNSAFE_", "user_id"]}]} */} + +```js +const user_id = 7; + +class Form { + UNSAFE_componentWillMount() {} +} +``` diff --git a/docs/lint/rules/eslint/capitalized-comments.mdx b/docs/lint/rules/eslint/capitalized-comments.mdx new file mode 100644 index 000000000000..7ea286a88eeb --- /dev/null +++ b/docs/lint/rules/eslint/capitalized-comments.mdx @@ -0,0 +1,88 @@ +--- +title: "capitalized-comments" +description: "Require comments to start with a capital letter, or forbid it" +--- + +This is a style rule: it makes every comment start the same way. By default, a comment that starts with a lowercase letter is reported. + +Comments that start with something other than a letter, comments that start with a URL, and comments for tools such as `eslint-disable`, `global` or `istanbul ignore` are never reported. + +`--fix` changes the case of the first letter. + +## Examples + +Reported: + +{/* bad */} + +```js +// retry once, then give up +await send(message); +``` + +Not reported: + +{/* good */} + +```js +// Retry once, then give up +await send(message); + +// 3 attempts at most +// https://example.com/retries explains why +/* eslint-disable-next-line no-console */ +console.log("sent"); +``` + +## Options + +```toml bunfig.toml +[lint.rules] +capitalized-comments = ["error", "always", { ignoreConsecutiveComments = true }] +``` + +The first option is `"always"` (default), which requires a capital letter, or `"never"`, which requires a lowercase letter. + +{/* bad {"options": ["never"]} */} + +```js +// Retry once, then give up +await send(message); +``` + +The second option is an object: + +| Option | Default | What it does | +| --------------------------- | ------- | -------------------------------------------------------------------------------------------------------- | +| `ignorePattern` | none | A regular expression. A comment that starts with a match is not checked. | +| `ignoreInlineComments` | `false` | Do not check a comment that has code before it and after it on the same line. | +| `ignoreConsecutiveComments` | `false` | Do not check a comment that comes right after another comment, so only the first line of a group counts. | + +{/* good {"options": ["always", {"ignorePattern": "webpack"}]} */} + +```js +const editor = import(/* webpackChunkName: "editor" */ "./editor.js"); +``` + +{/* good {"options": ["always", {"ignoreInlineComments": true}]} */} + +```js +function resize(width, /* optional */ height) {} +``` + +{/* good {"options": ["always", {"ignoreConsecutiveComments": true}]} */} + +```js +// Retry once, then give up, +// so that a slow server does not block the queue +await send(message); +``` + +To use different settings for `//` and `/* */` comments, put the same options under `line` and `block`. Here only the `//` comment is reported: + +{/* bad {"options": ["always", {"block": {"ignorePattern": "webpack"}}], "count": 1} */} + +```js +// webpack splits the bundle here +const editor = import(/* webpackChunkName: "editor" */ "./editor.js"); +``` diff --git a/docs/lint/rules/eslint/class-methods-use-this.mdx b/docs/lint/rules/eslint/class-methods-use-this.mdx new file mode 100644 index 000000000000..f2808b3e173d --- /dev/null +++ b/docs/lint/rules/eslint/class-methods-use-this.mdx @@ -0,0 +1,97 @@ +--- +title: "class-methods-use-this" +description: "Require class methods to use `this`" +--- + +A method that never touches `this` does not need an instance. Callers still have to create one to reach it. Make it `static`, or move it out of the class as a plain function. + +The rule checks methods, getters, setters, and fields that hold a function. Constructors and static members are not checked. Using `super` counts as using `this`. + +## Examples + +Reported: + +{/* bad */} + +```js +class Cart { + formatPrice(cents) { + return "$" + (cents / 100).toFixed(2); + } +} +``` + +Not reported: + +{/* good */} + +```js +class Cart { + static formatPrice(cents) { + return "$" + (cents / 100).toFixed(2); + } + + total() { + return Cart.formatPrice(this.items.reduce((sum, item) => sum + item.cents, 0)); + } +} +``` + +## Options + +| Option | Default | What it does | +| ----------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------- | +| `exceptMethods` | `[]` | Names of methods that are not checked. Write a private method with its `#`. | +| `enforceForClassFields` | `true` | Also check functions that are the value of a field: `format = () => {}`. | +| `ignoreOverrideMethods` | `false` | Do not check members marked `override` in TypeScript. | +| `ignoreClassesWithImplements` | not set | `"all"` skips every member of a class that has an `implements` clause. `"public-fields"` skips only its public members. | + +A framework may call a method on the instance whether it uses `this` or not: + +{/* good {"options": [{"exceptMethods": ["render", "#label"]}]} */} + +```js +class Spinner extends Component { + render() { + return "Loading"; + } + + #label() { + return "Spinner"; + } +} +``` + +{/* good {"options": [{"enforceForClassFields": false}]} */} + +```js +class Cart { + formatPrice = cents => "$" + (cents / 100).toFixed(2); +} +``` + +{/* good {"options": [{"ignoreOverrideMethods": true}]} */} + +```ts +class Guest extends User { + override displayName() { + return "Guest"; + } +} +``` + +With `"public-fields"`, the interface may ask for `name()`, but it cannot ask for the private `prefix()`: + +{/* bad {"options": [{"ignoreClassesWithImplements": "public-fields"}], "count": 1} */} + +```ts +class Guest implements Named { + name() { + return "Guest"; + } + + private prefix() { + return "Mx."; + } +} +``` diff --git a/docs/lint/rules/eslint/complexity.mdx b/docs/lint/rules/eslint/complexity.mdx new file mode 100644 index 000000000000..4ecab31445aa --- /dev/null +++ b/docs/lint/rules/eslint/complexity.mdx @@ -0,0 +1,96 @@ +--- +title: "complexity" +description: "Enforce a maximum number of branches in a function" +--- + +Every branch in a function is one more path to keep in mind and to test. This rule counts them and reports a function that has more than 20. + +The count starts at 1. Each of these adds 1: `if`, `? :`, a loop, a `case`, a `catch`, `&&`, `||`, `??`, their assignment forms such as `||=`, `?.`, and a default value of a parameter or in destructuring. Nested functions are counted on their own. Class field initializers and static blocks are counted too. + +## Examples + +With a maximum of 3, this function is reported. It has a complexity of 4: + +{/* bad {"options": [3]} */} + +```js +function shipping(order) { + if (order.total > 100) { + return 0; + } else if (order.express) { + return 15; + } else if (order.country !== "US") { + return 25; + } + return 5; +} +``` + +Not reported: + +{/* good {"options": [3]} */} + +```js +function shipping(order) { + if (order.total > 100) { + return 0; + } + return order.express ? 15 : standardRate(order.country); +} + +function standardRate(country) { + return country === "US" ? 5 : 25; +} +``` + +## Options + +```toml bunfig.toml +[lint.rules] +complexity = ["error", 10] +``` + +The option is a number, which is the maximum, or an object: + +| Option | Default | What it does | +| --------- | ----------- | -------------------------------------------------------------------------- | +| `max` | `20` | The highest complexity that is allowed. `maximum` is an older name for it. | +| `variant` | `"classic"` | `"modified"` counts a whole `switch` as 1, however many `case`s it has. | + +Three `case`s give this function a complexity of 4. `default` does not count: + +{/* bad {"options": [{"max": 3}]} */} + +```js +function label(status) { + switch (status) { + case "open": + return "Open"; + case "merged": + return "Merged"; + case "closed": + return "Closed"; + default: + return "Unknown"; + } +} +``` + +With `"modified"`, the same function has a complexity of 2: + +{/* good {"options": [{"max": 2, "variant": "modified"}]} */} + +```js +function label(status) { + switch (status) { + case "open": + return "Open"; + case "merged": + return "Merged"; + case "closed": + return "Closed"; + default: + return "Unknown"; + } +} +``` diff --git a/docs/lint/rules/eslint/consistent-return.mdx b/docs/lint/rules/eslint/consistent-return.mdx new file mode 100644 index 000000000000..26a16187e94a --- /dev/null +++ b/docs/lint/rules/eslint/consistent-return.mdx @@ -0,0 +1,81 @@ +--- +title: "consistent-return" +description: "Require every `return` in a function to have a value, or none of them" +--- + +A function that returns a value on one path and nothing on another sometimes gives its caller `undefined`. That can be on purpose, but often a value was forgotten, or the function runs off its end by accident. + +The first `return` of the function sets what is expected. Every `return` that differs is reported, and so is a function that returns a value and can also run to its end. + +## Examples + +Reported: + +{/* bad */} + +```js +function findUser(id) { + if (!id) { + return; + } + return users.get(id); +} +``` + +{/* bad */} + +```js +function discount(order) { + if (order.total > 100) { + return 10; + } +} +``` + +Not reported: + +{/* good */} + +```js +function findUser(id) { + if (!id) { + return undefined; + } + return users.get(id); +} + +function discount(order) { + if (order.total > 100) { + return 10; + } + return 0; +} + +function log(message) { + if (!enabled) { + return; + } + console.log(message); +} +``` + +## Options + +| Option | Default | What it does | +| ----------------------------- | ------- | --------------------------------------------------------------------- | +| `treatUndefinedAsUnspecified` | `false` | Treat `return undefined;` and `return void 0;` the same as `return;`. | + +{/* bad {"options": [{"treatUndefinedAsUnspecified": true}]} */} + +```js +function findUser(id) { + if (!id) { + return undefined; + } + return users.get(id); +} +``` + +## When not to use it + +In TypeScript, the compiler option `noImplicitReturns` finds the same mistakes and knows the return type of the function. diff --git a/docs/lint/rules/eslint/consistent-this.mdx b/docs/lint/rules/eslint/consistent-this.mdx new file mode 100644 index 000000000000..720e02d5a7f2 --- /dev/null +++ b/docs/lint/rules/eslint/consistent-this.mdx @@ -0,0 +1,77 @@ +--- +title: "consistent-this" +description: "Enforce one name for variables that hold `this`" +--- + +Code that saves `this` for a nested function calls the variable `that`, `self`, `me` or `vm`. With one name everywhere, you know what the variable holds without looking for its declaration. + +The rule reports `this` assigned to any other name. It also reports the chosen name when it holds something other than `this`. The default name is `that`. + +## Examples + +Reported: + +{/* bad */} + +```js +function Timer() { + const self = this; + setInterval(function () { + self.tick(); + }, 1000); +} +``` + +{/* bad */} + +```js +const that = document.body; +``` + +Not reported: + +{/* good */} + +```js +function Timer() { + const that = this; + setInterval(function () { + that.tick(); + }, 1000); +} +``` + +Destructuring is not an alias, so it is allowed: + +{/* good */} + +```js +function Timer() { + const { interval, tick } = this; + setInterval(tick, interval); +} +``` + +## Options + +The options are the names you allow, one or more: + +```toml bunfig.toml +[lint.rules] +consistent-this = ["error", "self", "vm"] +``` + +{/* good {"options": ["self", "vm"]} */} + +```js +function Timer() { + const self = this; + setInterval(function () { + self.tick(); + }, 1000); +} +``` + +## When not to use it + +An arrow function uses the `this` of the code around it, so new code rarely needs an alias at all. diff --git a/docs/lint/rules/eslint/constructor-super.mdx b/docs/lint/rules/eslint/constructor-super.mdx new file mode 100644 index 000000000000..0d471566eb53 --- /dev/null +++ b/docs/lint/rules/eslint/constructor-super.mdx @@ -0,0 +1,83 @@ +--- +title: "constructor-super" +description: "Require `super()` to be called exactly once in the constructor of a derived class" +--- + +In a class with `extends`, the constructor has to call `super()` before it uses `this` or returns. Without the call, `new` throws a `ReferenceError`. A second call throws too. These errors only show up when that path of the constructor runs. + +The rule follows every path through the constructor. It reports a path that has no `super()`, a path that calls it twice, and a `super()` in a class that extends something that cannot be a constructor, such as `null`. + +This rule is on by default in JavaScript files. It is off in TypeScript files, where TypeScript reports a missing `super()` itself. + +## Examples + +Reported: + +{/* bad */} + +```js +class Admin extends User { + constructor(name) { + this.name = name; + } +} +``` + +{/* bad */} + +```js +class Admin extends User { + constructor(name) { + if (name) { + super(name); + } + } +} +``` + +{/* bad */} + +```js +class Admin extends User { + constructor(name) { + super(name); + super(name.trim()); + } +} +``` + +{/* bad */} + +```js +class Dictionary extends null { + constructor() { + super(); + } +} +``` + +Not reported: + +{/* good */} + +```js +class Admin extends User { + constructor(name) { + super(name); + this.role = "admin"; + } +} + +class Guest extends User { + constructor(name) { + if (name) { + super(name); + } else { + super("Guest"); + } + } +} + +// Without a constructor, `super()` is called for you. +class Member extends User {} +``` diff --git a/docs/lint/rules/eslint/curly.mdx b/docs/lint/rules/eslint/curly.mdx new file mode 100644 index 000000000000..94f2ffa2ed5f --- /dev/null +++ b/docs/lint/rules/eslint/curly.mdx @@ -0,0 +1,123 @@ +--- +title: "curly" +description: "Require braces around the bodies of `if`, `else` and loops" +--- + +Without braces, only the next statement belongs to the `if` or the loop. Add a second line below it with the same indentation, and that line runs every time, whatever the condition says. + +`--fix` adds the missing braces, or removes the ones your style does not want. + +## Examples + +Reported: + +{/* bad {"count": 2} */} + +```js +if (!user) redirect("/login"); + +for (const item of items) total += item.price; +``` + +Not reported: + +{/* good */} + +```js +if (!user) { + redirect("/login"); +} + +for (const item of items) { + total += item.price; +} +``` + +## Options + +```toml bunfig.toml +[lint.rules] +curly = ["error", "multi-line", "consistent"] +``` + +| Option | What it does | +| ----------------- | --------------------------------------------------------------------------------------------------------- | +| `"all"` (default) | Braces everywhere. | +| `"multi"` | Braces only around more than one statement. Braces around a single statement are reported. | +| `"multi-line"` | You can leave the braces out if the body is on the same line as the `if` or the loop. | +| `"multi-or-nest"` | No braces around a single statement that fits on one line. Braces around a body that takes several lines. | + +{/* bad {"options": ["multi"]} */} + +```js +if (!user) { + redirect("/login"); +} +``` + +Braces that the code needs are never reported, for example around a `const`: + +{/* good {"options": ["multi"]} */} + +```js +if (!user) redirect("/login"); + +if (debug) { + const started = Date.now(); +} +``` + +With `"multi-line"`, the first `if` is allowed and the second one is reported: + +{/* bad {"options": ["multi-line"], "count": 1} */} + +```js +if (!user) redirect("/login"); + +if (!user.active) redirect("/activate"); +``` + +With `"multi-or-nest"`, the body below is one statement, but it takes three lines: + +{/* bad {"options": ["multi-or-nest"]} */} + +```js +if (!user) + save({ + name: "guest", + }); +``` + +{/* good {"options": ["multi-or-nest"]} */} + +```js +if (!user) redirect("/login"); + +if (user.admin) { + save({ + role: "admin", + }); +} +``` + +With any option but `"all"`, a second option `"consistent"` makes all branches of an `if` / `else` chain look the same. If one of them needs braces, all of them get braces. + +{/* bad {"options": ["multi", "consistent"]} */} + +```js +if (user.admin) { + grant(user); + notify(user); +} else deny(user); +``` + +{/* good {"options": ["multi", "consistent"]} */} + +```js +if (user.admin) { + grant(user); + notify(user); +} else { + deny(user); +} +``` diff --git a/docs/lint/rules/eslint/default-case-last.mdx b/docs/lint/rules/eslint/default-case-last.mdx new file mode 100644 index 000000000000..7c23992b242c --- /dev/null +++ b/docs/lint/rules/eslint/default-case-last.mdx @@ -0,0 +1,38 @@ +--- +title: "default-case-last" +description: "Require `default` to be the last clause of a `switch`" +--- + +`default` runs when no `case` matches, wherever it is written. Placed first or in the middle, it reads as if it were tested in order, and without a `break` it falls through into the `case` below it. At the end, it reads like the `else` that it is. + +## Examples + +Reported: + +{/* bad */} + +```js +switch (level) { + default: + log(message); + break; + case "error": + alert(message); + break; +} +``` + +Not reported: + +{/* good */} + +```js +switch (level) { + case "error": + alert(message); + break; + default: + log(message); + break; +} +``` diff --git a/docs/lint/rules/eslint/default-case.mdx b/docs/lint/rules/eslint/default-case.mdx new file mode 100644 index 000000000000..a3f524778de5 --- /dev/null +++ b/docs/lint/rules/eslint/default-case.mdx @@ -0,0 +1,90 @@ +--- +title: "default-case" +description: "Require a `default` case in every `switch`" +--- + +A `switch` without `default` does nothing when no `case` matches. When a new value shows up, it is silently skipped. A `default` makes you decide what happens then, even if the answer is to throw. + +If you leave it out on purpose, say so with a `// no default` comment after the last `case`. + +## Examples + +Reported: + +{/* bad */} + +```js +switch (status) { + case "open": + show(); + break; + case "closed": + hide(); + break; +} +``` + +Not reported: + +{/* good */} + +```js +switch (status) { + case "open": + show(); + break; + case "closed": + hide(); + break; + default: + throw new Error("Unknown status: " + status); +} +``` + +{/* good */} + +```js +switch (status) { + case "open": + show(); + break; + case "closed": + hide(); + break; + // no default +} +``` + +## Options + +| Option | Default | What it does | +| ---------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| `commentPattern` | `"^no default$"`, in any case | A regular expression for the comment that excuses a missing `default`. It is tested on the last comment after the last `case`. | + +{/* good {"options": [{"commentPattern": "^skip default"}]} */} + +```js +switch (status) { + case "open": + show(); + break; + // skip default: other statuses are not shown +} +``` + +With your own pattern, `// no default` no longer counts: + +{/* bad {"options": [{"commentPattern": "^skip default"}]} */} + +```js +switch (status) { + case "open": + show(); + break; + // no default +} +``` + +## When not to use it + +In TypeScript, `@typescript-eslint/switch-exhaustiveness-check` reports every member of a union that has no `case`. It finds a new value when you lint. A `default` only finds it when the code runs. diff --git a/docs/lint/rules/eslint/default-param-last.mdx b/docs/lint/rules/eslint/default-param-last.mdx new file mode 100644 index 000000000000..32cfb990a152 --- /dev/null +++ b/docs/lint/rules/eslint/default-param-last.mdx @@ -0,0 +1,32 @@ +--- +title: "default-param-last" +description: "Require parameters with a default value to come after the required ones" +--- + +A default value is used when the argument is left out. You can only leave out arguments at the end of a call. If a required parameter follows, the caller has to write `createUser(undefined, "Ada")` to get the default. + +## Examples + +Reported: + +{/* bad */} + +```js +function createUser(role = "member", name) { + return { name, role }; +} +``` + +Not reported: + +{/* good */} + +```js +function createUser(name, role = "member") { + return { name, role }; +} + +function log(level = "info", ...messages) { + console[level](...messages); +} +``` diff --git a/docs/lint/rules/eslint/dot-notation.mdx b/docs/lint/rules/eslint/dot-notation.mdx new file mode 100644 index 000000000000..f835d9c9f22a --- /dev/null +++ b/docs/lint/rules/eslint/dot-notation.mdx @@ -0,0 +1,62 @@ +--- +title: "dot-notation" +description: 'Require `user.name` instead of `user["name"]`' +--- + +`user["name"]` and `user.name` read the same property. The dot form is shorter, and it leaves brackets for the cases that need them: a key in a variable, or a name that is not a valid identifier. + +`--fix` rewrites the brackets to a dot. + +## Examples + +Reported: + +{/* bad */} + +```js +const name = user["name"]; +``` + +Not reported: + +{/* good */} + +```js +const name = user.name; +const value = user[key]; +const header = headers["content-type"]; +const first = items[0]; +``` + +## Options + +| Option | Default | What it does | +| --------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| `allowKeywords` | `true` | With `false`, reserved words go in brackets: `node.class` is reported, and `node["class"]` is allowed. Only engines older than ES5 need this. | +| `allowPattern` | `""` | A regular expression. Property names that match it can be written in brackets. | + +{/* bad {"options": [{"allowKeywords": false}]} */} + +```js +const kind = node.class; +``` + +{/* good {"options": [{"allowKeywords": false}]} */} + +```js +const kind = node["class"]; +``` + +To allow brackets for names that have an underscore: + +{/* good {"options": [{"allowPattern": "_"}]} */} + +```js +const firstName = row["first_name"]; +``` + +{/* bad {"options": [{"allowPattern": "_"}]} */} + +```js +const firstName = row["name"]; +``` diff --git a/docs/lint/rules/eslint/for-direction.mdx b/docs/lint/rules/eslint/for-direction.mdx new file mode 100644 index 000000000000..73884797737e --- /dev/null +++ b/docs/lint/rules/eslint/for-direction.mdx @@ -0,0 +1,56 @@ +--- +title: "for-direction" +description: "Require the update clause of a `for` loop to move the counter toward the end of the loop" +--- + +A loop that tests `i < 10` and then runs `i--` moves away from 10, so the test stays true and the loop never ends. It is almost always a typo: `--` for `++`, or `<` for `>`. + +The rule looks at `++`, `--`, `+=` and `-=` on the counter. If it cannot tell whether the step is positive or negative, it reports nothing. + +This rule is on by default. + +## Examples + +Reported: + +{/* bad */} + +```js +for (let i = 0; i < items.length; i--) { + render(items[i]); +} +``` + +{/* bad */} + +```js +for (let i = items.length - 1; i >= 0; i += 1) { + render(items[i]); +} +``` + +Not reported: + +{/* good */} + +```js +for (let i = 0; i < items.length; i++) { + render(items[i]); +} + +for (let i = items.length - 1; i >= 0; i -= 1) { + render(items[i]); +} +``` + +The sign of `step` is not known here, so the loop is left alone: + +{/* good */} + +```js +function walk(items, step) { + for (let i = 0; i < items.length; i += step) { + render(items[i]); + } +} +``` diff --git a/docs/lint/rules/eslint/func-name-matching.mdx b/docs/lint/rules/eslint/func-name-matching.mdx new file mode 100644 index 000000000000..c8a018952de2 --- /dev/null +++ b/docs/lint/rules/eslint/func-name-matching.mdx @@ -0,0 +1,102 @@ +--- +title: "func-name-matching" +description: "Require a function expression's name to match the variable or property it is assigned to" +--- + +A function expression can have its own name, different from the variable or property that holds it. Stack traces then show one name while the rest of the code calls the other, so searching for the name from the trace finds no caller. + +Only function expressions that have a name are checked. Arrow functions and `function () {}` are never reported. + +## Examples + +Reported: + +{/* bad {"count": 3} */} + +```js +const loadUser = function fetchUser(id) { + return db.users.get(id); +}; + +api.remove = function destroy(id) {}; + +const handlers = { + onSave: function save() {}, +}; +``` + +Not reported: + +{/* good */} + +```js +const loadUser = function loadUser(id) { + return db.users.get(id); +}; + +api.remove = function remove(id) {}; + +const handlers = { + onSave: function onSave() {}, + onClose: function () {}, +}; +``` + +## Options + +```toml bunfig.toml +[lint.rules] +func-name-matching = ["error", "always", { considerPropertyDescriptor = true }] +``` + +The first option is a string. You can leave it out and pass only the object. + +| Option | What it does | +| -------------------- | -------------------------------- | +| `"always"` (default) | The two names must be the same. | +| `"never"` | The two names must be different. | + +| Option | Default | What it does | +| ------------------------------ | ------- | --------------------------------------------------------------------------------------------------------------------- | +| `considerPropertyDescriptor` | `false` | In a property descriptor, compare the name of the `value` function with the property being defined. | +| `includeCommonJSModuleExports` | `false` | Also check `module.exports = function name() {}`. The property is `exports`, so that is the name it is compared with. | + +{/* bad {"options": ["never"]} */} + +```js +const loadUser = function loadUser(id) {}; +``` + +### `considerPropertyDescriptor` + +This applies to `Object.defineProperty`, `Reflect.defineProperty`, `Object.defineProperties` and `Object.create`. Without the option, the function below is compared with its key, `value`, and reported: + +{/* bad */} + +```js +Object.defineProperty(user, "save", { + value: function save() {}, +}); +``` + +{/* good {"options": ["always", {"considerPropertyDescriptor": true}]} */} + +```js +Object.defineProperty(user, "save", { + value: function save() {}, +}); +``` + +### `includeCommonJSModuleExports` + +{/* good */} + +```js +module.exports = function createServer() {}; +``` + +{/* bad {"options": [{"includeCommonJSModuleExports": true}]} */} + +```js +module.exports = function createServer() {}; +``` diff --git a/docs/lint/rules/eslint/func-names.mdx b/docs/lint/rules/eslint/func-names.mdx new file mode 100644 index 000000000000..eec788219902 --- /dev/null +++ b/docs/lint/rules/eslint/func-names.mdx @@ -0,0 +1,125 @@ +--- +title: "func-names" +description: "Require or disallow names for `function` expressions" +--- + +A callback written as `function () {}` shows up as `` in stack traces and profiles. When several callbacks are involved, you cannot tell from the trace which one failed. + +Arrow functions and methods are not checked. + +## Examples + +Reported: + +{/* bad */} + +```js +button.addEventListener("click", function () { + save(); +}); +``` + +{/* bad */} + +```js +export default function () { + return "ready"; +} +``` + +Not reported: + +{/* good */} + +```js +button.addEventListener("click", function onClick() { + save(); +}); + +button.addEventListener("blur", () => save()); +``` + +## Options + +```toml bunfig.toml +[lint.rules] +func-names = ["error", "as-needed"] +``` + +| Option | What it does | +| -------------------- | ------------------------------------------------------------------------------------- | +| `"always"` (default) | Every function expression needs a name. | +| `"as-needed"` | A name is needed only where JavaScript does not give the function one by itself. | +| `"never"` | No function expression has a name, unless the function refers to itself by that name. | + +A second option, `{ "generators": "always" | "as-needed" | "never" }`, sets the same choice for generator functions only. Without it, generators follow the first option. + +### `"as-needed"` + +A function assigned to a variable, or written as the value of an object key, takes that name, so it does not need its own: + +{/* good {"options": ["as-needed"]} */} + +```js +const save = function () {}; + +const handlers = { + close: function () {}, +}; +``` + +With `"always"`, both are reported: + +{/* bad {"count": 2} */} + +```js +const save = function () {}; + +const handlers = { + close: function () {}, +}; +``` + +A callback gets no name that way, and neither does a function assigned to `object.property`. Both are still reported: + +{/* bad {"options": ["as-needed"], "count": 2} */} + +```js +items.forEach(function (item) { + render(item); +}); + +handlers.open = function () {}; +``` + +### `"never"` + +{/* bad {"options": ["never"]} */} + +```js +items.forEach(function renderItem(item) { + render(item); +}); +``` + +{/* good {"options": ["never"]} */} + +```js +items.forEach(function (item) { + render(item); +}); + +const factorial = function fact(n) { + return n <= 1 ? 1 : n * fact(n - 1); +}; +``` + +### `generators` + +{/* good {"options": ["always", {"generators": "never"}]} */} + +```js +run(function* () { + yield 1; +}); +``` diff --git a/docs/lint/rules/eslint/func-style.mdx b/docs/lint/rules/eslint/func-style.mdx new file mode 100644 index 000000000000..63472ca245b2 --- /dev/null +++ b/docs/lint/rules/eslint/func-style.mdx @@ -0,0 +1,141 @@ +--- +title: "func-style" +description: "Enforce one way to define functions: declarations, or expressions assigned to variables" +--- + +A function declaration can be called above the line that defines it. A function assigned to a variable exists only after that line has run. If a project mixes both, you have to check how each function is written to know where it can be called. + +This rule makes you pick one. By default it asks for expressions. + +## Examples + +Reported: + +{/* bad */} + +```js +function formatName(user) { + return `${user.first} ${user.last}`; +} +``` + +Not reported: + +{/* good */} + +```js +const formatName = function (user) { + return `${user.first} ${user.last}`; +}; + +const initials = user => user.first[0] + user.last[0]; +``` + +`export default function` is never reported, and neither is a TypeScript function that has overload signatures: + +{/* good */} + +```ts +export default function main() {} + +function parse(input: string): string; +function parse(input: number): number; +function parse(input: string | number) { + return input; +} +``` + +## Options + +```toml bunfig.toml +[lint.rules] +func-style = ["error", "declaration", { allowArrowFunctions = true }] +``` + +| Option | What it does | +| ------------------------ | ------------------------------------------------------------- | +| `"expression"` (default) | Report function declarations. | +| `"declaration"` | Report functions assigned to a variable where it is declared. | + +| Option | Default | What it does | +| ------------------------ | ------- | -------------------------------------------------------------------------------------------------------------- | +| `allowArrowFunctions` | `false` | With `"declaration"`, allow arrow functions. | +| `allowTypeAnnotation` | `false` | With `"declaration"`, allow a function if its variable has a type annotation. | +| `overrides.namedExports` | not set | The style for `export function` and `export const`: `"declaration"`, `"expression"`, or `"ignore"` for either. | + +### `"declaration"` + +{/* bad {"options": ["declaration"], "count": 2} */} + +```js +const formatName = function (user) { + return `${user.first} ${user.last}`; +}; + +const initials = user => user.first[0] + user.last[0]; +``` + +{/* good {"options": ["declaration"]} */} + +```js +function formatName(user) { + return `${user.first} ${user.last}`; +} +``` + +An arrow function that uses `this` is not reported, because `this` would mean something else in a declaration: + +{/* good {"options": ["declaration"]} */} + +```js +function Timer() { + const tick = () => { + this.seconds++; + }; + setInterval(tick, 1000); +} +``` + +### `allowArrowFunctions` + +{/* good {"options": ["declaration", {"allowArrowFunctions": true}]} */} + +```js +const initials = user => user.first[0] + user.last[0]; +``` + +### `allowTypeAnnotation` + +A declaration cannot be given a function type as a whole, so this option lets such functions stay expressions: + +{/* good {"options": ["declaration", {"allowTypeAnnotation": true}]} */} + +```ts +type Handler = (event: Event) => void; + +const onClick: Handler = event => { + event.preventDefault(); +}; +``` + +### `overrides.namedExports` + +{/* good {"options": ["expression", {"overrides": {"namedExports": "declaration"}}]} */} + +```js +export function formatName(user) { + return user.name; +} + +const trim = function (text) { + return text.trim(); +}; +``` + +{/* bad {"options": ["expression", {"overrides": {"namedExports": "declaration"}}]} */} + +```js +export const formatName = function (user) { + return user.name; +}; +``` diff --git a/docs/lint/rules/eslint/getter-return.mdx b/docs/lint/rules/eslint/getter-return.mdx new file mode 100644 index 000000000000..5d7e4e203d35 --- /dev/null +++ b/docs/lint/rules/eslint/getter-return.mdx @@ -0,0 +1,93 @@ +--- +title: "getter-return" +description: "Require getters to return a value" +--- + +A getter that does not return gives `undefined` to whoever reads the property. Usually the `return` was forgotten, or one branch of the getter has none. + +The rule checks `get` in classes and object literals, and the `get` function of a property descriptor passed to `Object.defineProperty`, `Reflect.defineProperty`, `Object.defineProperties` or `Object.create`. + +This rule is on by default in JavaScript files. It is off in TypeScript files, where TypeScript reports the same mistake. + +## Examples + +Reported: + +{/* bad */} + +```js +const user = { + get fullName() { + `${this.first} ${this.last}`; + }, +}; +``` + +{/* bad */} + +```js +class Cart { + get total() { + if (this.items.length > 0) { + return sum(this.items); + } + } +} +``` + +{/* bad */} + +```js +Object.defineProperty(user, "age", { + get: function () { + now() - this.born; + }, +}); +``` + +Not reported: + +{/* good */} + +```js +const user = { + get fullName() { + return `${this.first} ${this.last}`; + }, +}; + +class Cart { + get total() { + if (this.items.length > 0) { + return sum(this.items); + } + return 0; + } +} +``` + +## Options + +| Option | Default | What it does | +| --------------- | ------- | ------------------------------------------------------------------------------------------- | +| `allowImplicit` | `false` | Allow `return;` without a value. The getter still has to end with a `return` on every path. | + +{/* bad */} + +```js +class Session { + get token() { + return; + } +} +``` + +{/* good {"options": [{"allowImplicit": true}]} */} + +```js +class Session { + get token() { + return; + } +} +``` diff --git a/docs/lint/rules/eslint/global-require.mdx b/docs/lint/rules/eslint/global-require.mdx new file mode 100644 index 000000000000..c03408abd2aa --- /dev/null +++ b/docs/lint/rules/eslint/global-require.mdx @@ -0,0 +1,55 @@ +--- +title: "global-require" +description: "Require `require()` calls to be at the top level of the module" +--- + +`require()` loads a module synchronously. Inside a function, that work happens in the middle of the first call instead of at startup, and a missing module throws only when that call is made. The file's dependencies are also no longer listed in one place. + +## Examples + +Reported: + +{/* bad */} + +```js +function readConfig(path) { + const fs = require("fs"); + return fs.readFileSync(path, "utf8"); +} +``` + +{/* bad */} + +```js +if (process.env.TRACE) { + require("./trace"); +} +``` + +Not reported: + +{/* good */} + +```js +const fs = require("fs"); +const { join } = require("path"); +const logger = process.env.TRACE ? require("./trace") : require("./quiet"); + +function readConfig(path) { + return fs.readFileSync(join(path, "config.json"), "utf8"); +} +``` + +A function of your own that is named `require` is not reported: + +{/* good */} + +```js +function load(require) { + return require("settings"); +} +``` + +## When not to use it + +Loading a heavy module only when it is needed makes startup faster, and an optional dependency has to be required inside `try`. If you do either often, leave this rule off. diff --git a/docs/lint/rules/eslint/grouped-accessor-pairs.mdx b/docs/lint/rules/eslint/grouped-accessor-pairs.mdx new file mode 100644 index 000000000000..ce5de1dd6bd3 --- /dev/null +++ b/docs/lint/rules/eslint/grouped-accessor-pairs.mdx @@ -0,0 +1,107 @@ +--- +title: "grouped-accessor-pairs" +description: "Require the getter and setter of a property to be next to each other" +--- + +When other members sit between the getter and the setter of a property, a reader who finds the getter can miss that there is a setter further down. + +The rule checks object literals and classes. In a class, a static pair and an instance pair with the same name are separate. + +## Examples + +Reported: + +{/* bad */} + +```js +class User { + get name() { + return this.first; + } + save() {} + set name(value) { + this.first = value; + } +} +``` + +Not reported: + +{/* good */} + +```js +class User { + get name() { + return this.first; + } + set name(value) { + this.first = value; + } + save() {} +} +``` + +## Options + +```toml bunfig.toml +[lint.rules] +grouped-accessor-pairs = ["error", "getBeforeSet"] +``` + +| Option | What it does | +| ---------------------- | ------------------------------------------- | +| `"anyOrder"` (default) | The pair must be adjacent, in either order. | +| `"getBeforeSet"` | The getter also has to come first. | +| `"setBeforeGet"` | The setter also has to come first. | + +| Option | Default | What it does | +| ------------------- | ------- | ---------------------------------------------------------------------- | +| `enforceForTSTypes` | `false` | Also check `get` and `set` signatures in interfaces and type literals. | + +{/* bad {"options": ["getBeforeSet"]} */} + +```js +const volume = { + set level(value) { + this.raw = value; + }, + get level() { + return this.raw; + }, +}; +``` + +{/* good {"options": ["getBeforeSet"]} */} + +```js +const volume = { + get level() { + return this.raw; + }, + set level(value) { + this.raw = value; + }, +}; +``` + +### `enforceForTSTypes` + +{/* good */} + +```ts +interface Volume { + get level(): number; + mute(): void; + set level(value: number); +} +``` + +{/* bad {"options": ["anyOrder", {"enforceForTSTypes": true}]} */} + +```ts +interface Volume { + get level(): number; + mute(): void; + set level(value: number); +} +``` diff --git a/docs/lint/rules/eslint/guard-for-in.mdx b/docs/lint/rules/eslint/guard-for-in.mdx new file mode 100644 index 000000000000..11694842c860 --- /dev/null +++ b/docs/lint/rules/eslint/guard-for-in.mdx @@ -0,0 +1,59 @@ +--- +title: "guard-for-in" +description: "Require the body of a `for...in` loop to be guarded by an `if`" +--- + +`for...in` visits the enumerable properties an object inherits from its prototype chain as well as its own. If anything was added to a prototype, the loop body runs for keys the object does not have itself. + +The rule looks only at the shape of the loop. The body has to be a single `if`, or start with an `if` that does nothing but `continue`. It does not check what the condition tests. + +## Examples + +Reported: + +{/* bad */} + +```js +for (const key in settings) { + apply(key, settings[key]); +} +``` + +{/* bad */} + +```js +for (const key in settings) { + if (Object.hasOwn(settings, key)) { + apply(key, settings[key]); + } + count++; +} +``` + +Not reported: + +{/* good */} + +```js +for (const key in settings) { + if (Object.hasOwn(settings, key)) { + apply(key, settings[key]); + } +} + +for (const key in settings) { + if (!Object.hasOwn(settings, key)) continue; + apply(key, settings[key]); + count++; +} +``` + +`Object.keys()` returns only the object's own keys, so a `for...of` loop over it needs no guard: + +{/* good */} + +```js +for (const key of Object.keys(settings)) { + apply(key, settings[key]); +} +``` diff --git a/docs/lint/rules/eslint/handle-callback-err.mdx b/docs/lint/rules/eslint/handle-callback-err.mdx new file mode 100644 index 000000000000..60f737584a7a --- /dev/null +++ b/docs/lint/rules/eslint/handle-callback-err.mdx @@ -0,0 +1,73 @@ +--- +title: "handle-callback-err" +description: "Require callbacks to use their error parameter" +--- + +A Node.js style callback gets the error as its first argument. If the function never looks at it, a failure goes unnoticed and the code carries on with a result that is `undefined`. + +The rule reports a function if its first parameter is named `err` and is never used. Any use counts as handling it. + +## Examples + +Reported: + +{/* bad */} + +```js +fs.readFile(path, "utf8", function (err, text) { + render(text); +}); +``` + +Not reported: + +{/* good */} + +```js +fs.readFile(path, "utf8", function (err, text) { + if (err) { + showError(err); + return; + } + render(text); +}); +``` + +## Options + +```toml bunfig.toml +[lint.rules] +handle-callback-err = ["error", "^(err|error)$"] +``` + +The one option is a string: the name of the error parameter. The default is `"err"`. A string that starts with `^` is a regular expression that the name has to match. + +{/* bad {"options": ["error"]} */} + +```js +fs.readFile(path, "utf8", (error, text) => { + render(text); +}); +``` + +{/* bad {"options": ["^(err|error)$"], "count": 2} */} + +```js +fs.readFile(path, "utf8", (error, text) => { + render(text); +}); + +fs.unlink(path, err => { + done(); +}); +``` + +With the default, a parameter named `error` is not checked: + +{/* good */} + +```js +fs.readFile(path, "utf8", (error, text) => { + render(text); +}); +``` diff --git a/docs/lint/rules/eslint/id-blacklist.mdx b/docs/lint/rules/eslint/id-blacklist.mdx new file mode 100644 index 000000000000..d121fa833c00 --- /dev/null +++ b/docs/lint/rules/eslint/id-blacklist.mdx @@ -0,0 +1,54 @@ +--- +title: "id-blacklist" +description: "Disallow the identifier names you list (old name of `id-denylist`)" +--- + +This is the old name of `id-denylist`. It takes the same options. Use `id-denylist` in a new config. + +Names like `data`, `tmp` or `cb` say little about what they hold. The rule reports the names you list when they name a variable, parameter, function, class, import, object key or class member, or a property you assign to. Without a list, it reports nothing. + +## Examples + +```toml bunfig.toml +[lint.rules] +id-blacklist = ["error", "data", "callback"] +``` + +Reported: + +{/* bad {"options": ["data", "callback"], "count": 4} */} + +```js +const data = loadUser(id); + +function save(user, callback) {} + +const response = { data: 1 }; +response.data = 2; +``` + +Not reported: + +{/* good {"options": ["data", "callback"]} */} + +```js +const user = loadUser(id); + +function save(user, onSaved) {} + +console.log(response.data); +``` + +Unlike `id-denylist`, this rule does not look at private names: + +{/* good {"options": ["data"]} */} + +```js +class Store { + #data = new Map(); +} +``` + +## Options + +Each option is a name to disallow. Names are compared exactly, so `"data"` does not match `userData`. diff --git a/docs/lint/rules/eslint/id-denylist.mdx b/docs/lint/rules/eslint/id-denylist.mdx new file mode 100644 index 000000000000..00feb75d282e --- /dev/null +++ b/docs/lint/rules/eslint/id-denylist.mdx @@ -0,0 +1,75 @@ +--- +title: "id-denylist" +description: "Disallow the identifier names you list" +--- + +Names like `data`, `tmp` or `cb` say little about what they hold. This rule reports the names you list when they name a variable, parameter, function, class, import, object key or class member, or a property you assign to. + +Without a list, the rule reports nothing. + +## Examples + +```toml bunfig.toml +[lint.rules] +id-denylist = ["error", "data", "callback"] +``` + +Reported: + +{/* bad {"options": ["data", "callback"], "count": 4} */} + +```js +const data = loadUser(id); + +function save(user, callback) {} + +const response = { data: 1 }; +response.data = 2; +``` + +Not reported: + +{/* good {"options": ["data", "callback"]} */} + +```js +const user = loadUser(id); + +function save(user, onSaved) {} +``` + +A variable is reported where it is declared and again where it is used, except where it is called or passed as an argument. This has two reports, on lines 1 and 3: + +{/* bad {"options": ["data"], "count": 2} */} + +```js +function render(data) { + log(data); + return data.title; +} +``` + +Reading a property is allowed, because the object may come from code you do not control. So is an import or a destructured property that you rename: + +{/* good {"options": ["data", "callback"]} */} + +```js +import { data as users } from "./api"; + +const { data: orders } = await fetchOrders(); + +console.log(response.data, users, orders); +``` + +Private names are compared without the `#`: + +{/* bad {"options": ["data"]} */} + +```js +class Store { + #data = new Map(); +} +``` + +## Options + +Each option is a name to disallow. Names are compared exactly, so `"data"` does not match `userData`. diff --git a/docs/lint/rules/eslint/id-length.mdx b/docs/lint/rules/eslint/id-length.mdx new file mode 100644 index 000000000000..61b0aa685430 --- /dev/null +++ b/docs/lint/rules/eslint/id-length.mdx @@ -0,0 +1,113 @@ +--- +title: "id-length" +description: "Enforce a minimum and a maximum length for identifier names" +--- + +A one-letter name such as `e`, `n` or `x` makes the reader go back to the declaration to learn what it holds. A very long name makes every line that uses it hard to scan. + +The rule checks a name where your code introduces it: variables, parameters, functions, classes, imports, object keys and class members. By default a name needs at least 2 characters, and there is no maximum. + +## Examples + +Reported: + +{/* bad {"count": 4} */} + +```js +const n = items.length; + +const prices = items.map(x => x.price); + +try { + save(); +} catch (e) { + report(e); +} + +const point = { y: 0 }; +``` + +Not reported: + +{/* good */} + +```js +const count = items.length; + +const prices = items.map(item => item.price); + +try { + save(); +} catch (error) { + report(error); +} +``` + +Type parameters are not checked: + +{/* good */} + +```ts +function first(items: T[]): T { + return items[0]; +} +``` + +## Options + +```toml bunfig.toml +[lint.rules] +id-length = ["error", { min = 3, max = 30, exceptions = ["i", "id"] }] +``` + +| Option | Default | What it does | +| ------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------- | +| `min` | `2` | The shortest name allowed. | +| `max` | none | The longest name allowed. | +| `properties` | `"always"` | `"never"` stops checking object keys and the properties you assign to. | +| `exceptions` | `[]` | Names that are always allowed. | +| `exceptionPatterns` | `[]` | Regular expressions. A name that matches one is always allowed. | +| `checkGeneric` | `true` | Only with an oxlint config, which also checks type parameters such as the `T` in `Box`. `false` stops checking them. | + +{/* bad {"options": [{"max": 20}]} */} + +```js +const numberOfItemsInTheShoppingCart = cart.items.length; +``` + +{/* good {"options": [{"exceptions": ["i"]}]} */} + +```js +for (let i = 0; i < items.length; i++) { + render(items[i]); +} +``` + +{/* good {"options": [{"exceptionPatterns": ["^[xyz]$"]}]} */} + +```js +function move(x, y, z) { + return [x + 1, y + 1, z + 1]; +} +``` + +### `properties` + +{/* good {"options": [{"properties": "never"}]} */} + +```js +const point = { x: 0, y: 0 }; +point.x = 10; +``` + +Variables and class members are still checked: + +{/* bad {"options": [{"properties": "never"}], "count": 2} */} + +```js +const p = { x: 0, y: 0 }; + +class Point { + x = 0; +} +``` diff --git a/docs/lint/rules/eslint/id-match.mdx b/docs/lint/rules/eslint/id-match.mdx new file mode 100644 index 000000000000..9a44e7eb467d --- /dev/null +++ b/docs/lint/rules/eslint/id-match.mdx @@ -0,0 +1,138 @@ +--- +title: "id-match" +description: "Require identifier names to match a regular expression" +--- + +Use this rule to hold a project to one naming convention, for example no underscores or only camelCase. You give it a regular expression, and it reports the names that do not match. + +The default pattern, `^.+$`, matches every name, so the rule reports nothing until you set one. + +## Examples + +```toml bunfig.toml +[lint.rules] +id-match = ["error", "^[^_]+$"] +``` + +The examples on this page use `^[^_]+$`, which means no underscores. + +Reported: + +{/* bad {"options": ["^[^_]+$"], "count": 3} */} + +```js +const user_name = "Ada"; + +function load_user(user_id) {} +``` + +Not reported: + +{/* good {"options": ["^[^_]+$"]} */} + +```js +const userName = "Ada"; + +function loadUser(userId) {} +``` + +A function you call and a property you read may come from code you do not control, so they are not checked: + +{/* good {"options": ["^[^_]+$"]} */} + +```js +const total = get_total(order.line_items); +``` + +## Options + +The first option is the pattern. The second is an object: + +| Option | Default | What it does | +| --------------------- | ------- | ------------------------------------------------------------------------- | +| `properties` | `false` | Also check the keys of object literals and the properties you assign to. | +| `classFields` | `false` | Also check class fields. Methods are checked either way. | +| `onlyDeclarations` | `false` | Check a name only where it is declared, not where it is used. | +| `ignoreDestructuring` | `false` | Do not check a destructured variable that keeps the name of the property. | + +### `properties` + +{/* good {"options": ["^[^_]+$"]} */} + +```js +const user = { first_name: "Ada" }; +user.last_name = "Lovelace"; +``` + +{/* bad {"options": ["^[^_]+$", {"properties": true}], "count": 2} */} + +```js +const user = { first_name: "Ada" }; +user.last_name = "Lovelace"; +``` + +### `classFields` + +{/* bad {"options": ["^[^_]+$"], "count": 1} */} + +```js +class User { + first_name = "Ada"; + full_name() {} +} +``` + +{/* bad {"options": ["^[^_]+$", {"classFields": true}], "count": 2} */} + +```js +class User { + first_name = "Ada"; + full_name() {} +} +``` + +### `onlyDeclarations` + +{/* bad {"options": ["^[^_]+$"]} */} + +```js +if (legacy_mode) { + start(); +} +``` + +{/* good {"options": ["^[^_]+$", {"onlyDeclarations": true}]} */} + +```js +if (legacy_mode) { + start(); +} +``` + +{/* bad {"options": ["^[^_]+$", {"onlyDeclarations": true}]} */} + +```js +const legacy_mode = true; +``` + +### `ignoreDestructuring` + +{/* bad {"options": ["^[^_]+$"]} */} + +```js +const { user_id } = row; +``` + +{/* good {"options": ["^[^_]+$", {"ignoreDestructuring": true}]} */} + +```js +const { user_id } = row; +``` + +A new name that you choose is still checked: + +{/* bad {"options": ["^[^_]+$", {"ignoreDestructuring": true}]} */} + +```js +const { user_id: owner_id } = row; +``` diff --git a/docs/lint/rules/eslint/init-declarations.mdx b/docs/lint/rules/eslint/init-declarations.mdx new file mode 100644 index 000000000000..a112faf1c321 --- /dev/null +++ b/docs/lint/rules/eslint/init-declarations.mdx @@ -0,0 +1,105 @@ +--- +title: "init-declarations" +description: "Require or disallow a value in variable declarations" +--- + +A variable declared without a value is `undefined` until something assigns to it. If one path through the code skips the assignment, later code reads `undefined` without any error. Giving every variable a value where it is declared closes that gap. + +## Examples + +Reported: + +{/* bad */} + +```js +let total; +if (cart.items.length > 0) { + total = sum(cart.items); +} +``` + +Not reported: + +{/* good */} + +```js +let total = 0; +if (cart.items.length > 0) { + total = sum(cart.items); +} +``` + +The variable of a `for...in` or `for...of` loop gets its value from the loop, so it counts as initialized: + +{/* good */} + +```js +for (let item of cart.items) { + render(item); +} +``` + +In TypeScript, a `declare` statement cannot have a value and is not reported: + +{/* good */} + +```ts +declare let version: string; +``` + +## Options + +```toml bunfig.toml +[lint.rules] +init-declarations = ["error", "never", { ignoreForLoopInit = true }] +``` + +| Option | What it does | +| -------------------- | --------------------------------------------------------------------------------------- | +| `"always"` (default) | Every `var` and `let` needs a value. | +| `"never"` | No `var` or `let` has a value. `const` is left alone, because it cannot do without one. | + +| Option | Default | What it does | +| ------------------- | ------- | --------------------------------------------------------------------------------------------------- | +| `ignoreForLoopInit` | `false` | With `"never"`, allow the variables declared in the head of a `for`, `for...in` or `for...of` loop. | + +{/* bad {"options": ["never"]} */} + +```js +let count = 0; +``` + +{/* good {"options": ["never"]} */} + +```js +let count; +count = 0; + +const limit = 10; +``` + +### `ignoreForLoopInit` + +{/* bad {"options": ["never"], "count": 2} */} + +```js +for (let i = 0; i < 3; i++) { + retry(); +} + +for (let item of cart.items) { + render(item); +} +``` + +{/* good {"options": ["never", {"ignoreForLoopInit": true}]} */} + +```js +for (let i = 0; i < 3; i++) { + retry(); +} + +for (let item of cart.items) { + render(item); +} +``` diff --git a/docs/lint/rules/eslint/logical-assignment-operators.mdx b/docs/lint/rules/eslint/logical-assignment-operators.mdx new file mode 100644 index 000000000000..9c148e55570c --- /dev/null +++ b/docs/lint/rules/eslint/logical-assignment-operators.mdx @@ -0,0 +1,84 @@ +--- +title: "logical-assignment-operators" +description: "Require or disallow the logical assignment operators `||=`, `&&=` and `??=`" +--- + +`options = options || {}` names the variable twice, and it assigns even when `options` keeps its value. `options ||= {}` says the same with one name and assigns only when it has to. + +`--fix` rewrites the code when the target is a plain variable. A property can have a getter or a setter, and the rewrite can change how often they run, so `user.name = user.name || "guest"` gets a suggestion instead of a fix. + +## Examples + +Reported: + +{/* bad {"count": 5} */} + +```js +options = options || {}; +cache = cache ?? new Map(); +enabled = enabled && isReady(); +title || (title = "Untitled"); +user.name = user.name || "guest"; +``` + +Not reported: + +{/* good */} + +```js +options ||= {}; +cache ??= new Map(); +enabled &&= isReady(); +title ||= "Untitled"; +user.name ||= "guest"; +``` + +## Options + +```toml bunfig.toml +[lint.rules] +logical-assignment-operators = ["error", "always", { enforceForIfStatements = true }] +``` + +| Option | What it does | +| -------------------- | --------------------------------------------------------- | +| `"always"` (default) | Report code that could use a logical assignment operator. | +| `"never"` | Report every logical assignment operator. | + +| Option | Default | What it does | +| ------------------------ | ------- | ----------------------------------------------------------------------------- | +| `enforceForIfStatements` | `false` | With `"always"`, also report an `if` that only assigns to the value it tests. | + +{/* bad {"options": ["never"]} */} + +```js +options ||= {}; +``` + +{/* good {"options": ["never"]} */} + +```js +options = options || {}; +``` + +### `enforceForIfStatements` + +{/* good */} + +```js +if (!title) { + title = "Untitled"; +} + +if (cache == null) cache = new Map(); +``` + +{/* bad {"options": ["always", {"enforceForIfStatements": true}], "count": 2} */} + +```js +if (!title) { + title = "Untitled"; +} + +if (cache == null) cache = new Map(); +``` diff --git a/docs/lint/rules/eslint/max-classes-per-file.mdx b/docs/lint/rules/eslint/max-classes-per-file.mdx new file mode 100644 index 000000000000..a106fa5e0044 --- /dev/null +++ b/docs/lint/rules/eslint/max-classes-per-file.mdx @@ -0,0 +1,78 @@ +--- +title: "max-classes-per-file" +description: "Enforce a maximum number of classes per file" +--- + +When a file holds several classes, its name can describe only one of them, and the others are hard to find. + +Class declarations and class expressions both count. The default maximum is 1. + +## Examples + +Reported: + +{/* bad */} + +```js +class User {} + +class Session {} +``` + +Not reported: + +{/* good */} + +```js +class User {} +``` + +## Options + +```toml bunfig.toml +[lint.rules] +max-classes-per-file = ["error", { max = 2, ignoreExpressions = true }] +``` + +The option is either a number, the maximum, or an object: + +| Option | Default | What it does | +| ------------------- | ------- | -------------------------------------- | +| `max` | `1` | The number of classes a file can have. | +| `ignoreExpressions` | `false` | Do not count class expressions. | + +{/* good {"options": [2]} */} + +```js +class User {} + +class Session {} +``` + +{/* bad {"options": [{"max": 2}]} */} + +```js +class User {} + +class Session {} + +class Token {} +``` + +### `ignoreExpressions` + +{/* bad */} + +```js +class User {} + +const Guest = class extends User {}; +``` + +{/* good {"options": [{"ignoreExpressions": true}]} */} + +```js +class User {} + +const Guest = class extends User {}; +``` diff --git a/docs/lint/rules/eslint/max-depth.mdx b/docs/lint/rules/eslint/max-depth.mdx new file mode 100644 index 000000000000..8518a0ff637a --- /dev/null +++ b/docs/lint/rules/eslint/max-depth.mdx @@ -0,0 +1,81 @@ +--- +title: "max-depth" +description: "Enforce a maximum depth that blocks can be nested" +--- + +To understand a line that sits five blocks deep, you have to keep every condition and loop around it in your head. This rule reports a statement that is nested deeper than the maximum, which is 4 by default. + +`if`, `switch`, `try`, `for`, `while`, `do` and `with` each add one level. Every function starts again at zero. + +## Examples + +With a maximum of 2, this is reported: + +{/* bad {"options": [2]} */} + +```js +function notify(users) { + for (const user of users) { + if (user.active) { + if (user.email) { + send(user.email); + } + } + } +} +``` + +Not reported: + +{/* good {"options": [2]} */} + +```js +function notify(users) { + for (const user of users) { + if (user.active && user.email) { + send(user.email); + } + } +} +``` + +An `else if` is as deep as the `if` it continues. This passes with a maximum of 1: + +{/* good {"options": [1]} */} + +```js +if (size < 10) { + small(); +} else if (size < 100) { + medium(); +} else { + large(); +} +``` + +So does this, because the inner `if` is the first level of the callback: + +{/* good {"options": [1]} */} + +```js +if (ready) { + items.forEach(item => { + if (item.done) { + archive(item); + } + }); +} +``` + +## Options + +```toml bunfig.toml +[lint.rules] +max-depth = ["error", 3] +``` + +| Option | Default | What it does | +| ------ | ------- | --------------------------------------------------------------------- | +| `max` | `4` | The deepest level that is allowed. `maximum` is an older name for it. | + +A number alone is short for `{ "max": number }`. diff --git a/docs/lint/rules/eslint/max-lines-per-function.mdx b/docs/lint/rules/eslint/max-lines-per-function.mdx new file mode 100644 index 000000000000..af6aa5d07da7 --- /dev/null +++ b/docs/lint/rules/eslint/max-lines-per-function.mdx @@ -0,0 +1,102 @@ +--- +title: "max-lines-per-function" +description: "Enforce a maximum number of lines in a function" +--- + +A function that does not fit on the screen is hard to read in one go, and it usually does several jobs that could have a name each. This rule reports a function that has more lines than the maximum, which is 50 by default. + +The count runs from the line where the function starts to the line where it ends. + +## Examples + +With a maximum of 5, this function of 7 lines is reported: + +{/* bad {"options": [5]} */} + +```js +function checkout(cart) { + let total = 0; + for (const item of cart.items) { + total += item.price * item.count; + } + charge(cart.user, total); +} +``` + +Not reported: + +{/* good {"options": [5]} */} + +```js +function totalOf(cart) { + let total = 0; + for (const item of cart.items) total += item.price * item.count; + return total; +} + +function checkout(cart) { + charge(cart.user, totalOf(cart)); +} +``` + +## Options + +```toml bunfig.toml +[lint.rules] +max-lines-per-function = ["error", { max = 80, skipBlankLines = true, skipComments = true }] +``` + +| Option | Default | What it does | +| ---------------- | ------- | ------------------------------------------------------------------------------------ | +| `max` | `50` | The number of lines a function can have. | +| `skipBlankLines` | `false` | Do not count lines that are empty or only have whitespace. | +| `skipComments` | `false` | Do not count lines that only have a comment. | +| `IIFEs` | `false` | Also check a function that is called where it is written: `(function () { ... })()`. | + +A number alone is short for `{ "max": number }`. + +This function has 5 lines, so a maximum of 3 reports it: + +{/* bad {"options": [3]} */} + +```js +function greet(user) { + // Fall back to the login name. + + return `Hello, ${user.name ?? user.login}`; +} +``` + +Without the comment and the blank line it has 3: + +{/* good {"options": [{"max": 3, "skipBlankLines": true, "skipComments": true}]} */} + +```js +function greet(user) { + // Fall back to the login name. + + return `Hello, ${user.name ?? user.login}`; +} +``` + +A function that is called where it is written is skipped by default. This passes with a maximum of 2: + +{/* good {"options": [2]} */} + +```js +(function () { + setup(); + start(); +})(); +``` + +With `IIFEs` it is reported: + +{/* bad {"options": [{"max": 2, "IIFEs": true}]} */} + +```js +(function () { + setup(); + start(); +})(); +``` diff --git a/docs/lint/rules/eslint/max-lines.mdx b/docs/lint/rules/eslint/max-lines.mdx new file mode 100644 index 000000000000..a7f4032a104c --- /dev/null +++ b/docs/lint/rules/eslint/max-lines.mdx @@ -0,0 +1,72 @@ +--- +title: "max-lines" +description: "Enforce a maximum number of lines per file" +--- + +A file that keeps growing ends up doing many unrelated things, and finding anything in it means scrolling. This rule reports a file that has more lines than the maximum, which is 300 by default. + +## Examples + +With a maximum of 4, this file of 5 lines is reported: + +{/* bad {"options": [4]} */} + +```js +import { readFile } from "node:fs/promises"; + +const text = await readFile("notes.txt", "utf8"); + +console.log(text.split("\n").length); +``` + +Not reported, with the work moved to another file: + +{/* good {"options": [4]} */} + +```js +import { countLines } from "./count-lines.js"; +console.log(await countLines("notes.txt")); +``` + +## Options + +```toml bunfig.toml +[lint.rules] +max-lines = ["error", { max = 500, skipBlankLines = true, skipComments = true }] +``` + +| Option | Default | What it does | +| ---------------- | ------- | ----------------------------------------------------------------------------------------- | +| `max` | `300` | The number of lines a file can have. | +| `skipBlankLines` | `false` | Do not count lines that are empty or only have whitespace. | +| `skipComments` | `false` | Do not count lines that only have a comment. A line with code and a comment still counts. | + +A number alone is short for `{ "max": number }`. + +With `skipBlankLines`, the first example has 3 lines: + +{/* good {"options": [{"max": 4, "skipBlankLines": true}]} */} + +```js +import { readFile } from "node:fs/promises"; + +const text = await readFile("notes.txt", "utf8"); + +console.log(text.split("\n").length); +``` + +With `skipComments`, so does this one: + +{/* good {"options": [{"max": 4, "skipComments": true}]} */} + +```js +// Prints the number of lines in notes.txt. +// Run it with: bun count.js +import { readFile } from "node:fs/promises"; +const text = await readFile("notes.txt", "utf8"); +console.log(text.split("\n").length); +``` + +## When not to use it + +Generated files, fixtures and long tables of data are long for a good reason. Turn the rule off for them. diff --git a/docs/lint/rules/eslint/max-nested-callbacks.mdx b/docs/lint/rules/eslint/max-nested-callbacks.mdx new file mode 100644 index 000000000000..8ad082d73d27 --- /dev/null +++ b/docs/lint/rules/eslint/max-nested-callbacks.mdx @@ -0,0 +1,73 @@ +--- +title: "max-nested-callbacks" +description: "Enforce a maximum depth that callbacks can be nested" +--- + +Callbacks inside callbacks push the code to the right, and each level needs its own error handling. This rule reports a callback that is nested deeper than the maximum, which is 10 by default. + +A callback is a function expression or an arrow function that is passed as an argument to a call. + +## Examples + +With a maximum of 2, the third callback is reported: + +{/* bad {"options": [2]} */} + +```js +readConfig(config => { + connect(config, db => { + db.query("select 1", rows => { + console.log(rows); + }); + }); +}); +``` + +Not reported: + +{/* good {"options": [2]} */} + +```js +const config = await readConfig(); +const db = await connect(config); +const rows = await db.query("select 1"); +console.log(rows); +``` + +## Options + +```toml bunfig.toml +[lint.rules] +max-nested-callbacks = ["error", 3] +``` + +| Option | Default | What it does | +| ------------------------------- | ------- | ----------------------------------------------------------------------------- | +| `max` | `10` | The deepest level that is allowed. `maximum` is an older name for it. | +| `checkConstructorCallCallbacks` | `false` | Also count a function that is passed to `new`, as in `new Promise(() => {})`. | + +A number alone is short for `{ "max": number }`. + +By default the function passed to `new Promise` is not a level: + +{/* good {"options": [1]} */} + +```js +button.addEventListener("click", () => { + return new Promise(resolve => { + setTimeout(resolve, 100); + }); +}); +``` + +With `checkConstructorCallCallbacks` it is the second level: + +{/* bad {"options": [{"max": 1, "checkConstructorCallCallbacks": true}]} */} + +```js +button.addEventListener("click", () => { + return new Promise(resolve => { + setTimeout(resolve, 100); + }); +}); +``` diff --git a/docs/lint/rules/eslint/max-params.mdx b/docs/lint/rules/eslint/max-params.mdx new file mode 100644 index 000000000000..9adc70b47d6f --- /dev/null +++ b/docs/lint/rules/eslint/max-params.mdx @@ -0,0 +1,75 @@ +--- +title: "max-params" +description: "Enforce a maximum number of parameters in function definitions" +--- + +At a call like `createUser("Ada", "ada@example.com", "admin", "core")` nothing says which argument is which, and swapping two of them goes unnoticed. This rule reports a function that has more parameters than the maximum, which is 3 by default. + +## Examples + +Reported: + +{/* bad */} + +```js +function createUser(name, email, role, team) { + return { name, email, role, team }; +} +``` + +Not reported: + +{/* good */} + +```js +function createUser({ name, email, role, team }) { + return { name, email, role, team }; +} +``` + +## Options + +```toml bunfig.toml +[lint.rules] +max-params = ["error", 4] +``` + +| Option | Default | What it does | +| --------------- | --------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| `max` | `3` | The number of parameters a function can have. `maximum` is an older name for it. | +| `countThis` | `"except-void"` | Whether a TypeScript `this` parameter counts: `"always"`, `"never"`, or `"except-void"`, which counts it unless it is `this: void`. | +| `countVoidThis` | `false` | An older spelling. `true` is the same as `countThis: "always"`. | + +A number alone is short for `{ "max": number }`. + +By default a typed `this` counts. This function has 3 parameters, so a maximum of 2 reports it: + +{/* bad {"options": [2]} */} + +```ts +function onClick(this: HTMLButtonElement, event: Event, target: Element) {} +``` + +With `countThis: "never"` it has 2: + +{/* good {"options": [{"max": 2, "countThis": "never"}]} */} + +```ts +function onClick(this: HTMLButtonElement, event: Event, target: Element) {} +``` + +`this: void` does not count by default, so this passes with a maximum of 2: + +{/* good {"options": [2]} */} + +```ts +function onClick(this: void, event: Event, target: Element) {} +``` + +With `countThis: "always"` it is reported: + +{/* bad {"options": [{"max": 2, "countThis": "always"}]} */} + +```ts +function onClick(this: void, event: Event, target: Element) {} +``` diff --git a/docs/lint/rules/eslint/max-statements.mdx b/docs/lint/rules/eslint/max-statements.mdx new file mode 100644 index 000000000000..9373afc8cfec --- /dev/null +++ b/docs/lint/rules/eslint/max-statements.mdx @@ -0,0 +1,87 @@ +--- +title: "max-statements" +description: "Enforce a maximum number of statements in a function" +--- + +A function with a long list of statements usually does several jobs, and you have to read all of it to change any part. This rule reports a function that has more statements than the maximum, which is 10 by default. + +Statements in nested blocks count too. The statements of an inner function count for the inner function only. + +## Examples + +With a maximum of 3, this function of 4 statements is reported: + +{/* bad {"options": [3]} */} + +```js +function register(form) { + const user = parse(form); + validate(user); + save(user); + sendWelcomeMail(user); +} +``` + +Not reported: + +{/* good {"options": [3]} */} + +```js +function register(form) { + const user = parse(form); + validate(user); + return saveAndWelcome(user); +} +``` + +This function has 3 statements, the `if` and the two in its block, so a maximum of 2 reports it: + +{/* bad {"options": [2]} */} + +```js +function log(message) { + if (verbose) { + console.log(message); + count++; + } +} +``` + +## Options + +```toml bunfig.toml +[lint.rules] +max-statements = ["error", 15, { ignoreTopLevelFunctions = true }] +``` + +The first option is the maximum, as a number or as `{ "max": number }`. `maximum` is an older name for `max`. The second option is an object: + +| Option | Default | What it does | +| ------------------------- | ------- | --------------------------------------------------------------------------------------------- | +| `ignoreTopLevelFunctions` | `false` | Do not report the only top-level function of a file, such as a wrapper around a whole script. | + +With it, this passes with a maximum of 2: + +{/* good {"options": [2, {"ignoreTopLevelFunctions": true}]} */} + +```js +(function () { + const app = createApp(); + app.use(router); + app.listen(3000); +})(); +``` + +In a file with two or more top-level functions the option changes nothing: + +{/* bad {"options": [2, {"ignoreTopLevelFunctions": true}]} */} + +```js +function start() { + const app = createApp(); + app.use(router); + app.listen(3000); +} + +function stop() {} +``` diff --git a/docs/lint/rules/eslint/new-cap.mdx b/docs/lint/rules/eslint/new-cap.mdx new file mode 100644 index 000000000000..cd2df572fba5 --- /dev/null +++ b/docs/lint/rules/eslint/new-cap.mdx @@ -0,0 +1,94 @@ +--- +title: "new-cap" +description: "Require constructor names to begin with a capital letter" +--- + +A capital first letter is how a reader tells a constructor from an ordinary function. If the names do not follow that, it is easy to forget `new`: a class then throws, and an older constructor function runs with the wrong `this`. + +The rule reports two things: `new` with a name that starts with a lowercase letter, and a call without `new` of a name that starts with a capital letter. + +The built-ins that work without `new` are allowed: `Array`, `BigInt`, `Boolean`, `Date`, `Error`, `Function`, `Number`, `Object`, `RegExp`, `String`, `Symbol`, and `Date.UTC()`. + +## Examples + +Reported: + +{/* bad {"count": 2} */} + +```js +const user = new person("Ada"); +const team = Team("core"); +``` + +Not reported: + +{/* good */} + +```js +const user = new Person("Ada"); +const team = createTeam("core"); +const id = String(42); +const start = Date.UTC(2026, 0, 1); +``` + +## Options + +```toml bunfig.toml +[lint.rules] +new-cap = ["error", { capIsNewExceptions = ["Router"] }] +``` + +| Option | Default | What it does | +| -------------------------- | ------- | ------------------------------------------------------------------------------ | +| `newIsCap` | `true` | Report `new` with a lowercase name. | +| `capIsNew` | `true` | Report a call of a capitalized name without `new`. | +| `properties` | `true` | Also check names after a dot, as in `new net.socket()` and `express.Router()`. | +| `newIsCapExceptions` | `[]` | Lowercase names that are allowed after `new`. | +| `newIsCapExceptionPattern` | none | A regular expression for such names. | +| `capIsNewExceptions` | `[]` | Capitalized names that can be called without `new`. | +| `capIsNewExceptionPattern` | none | A regular expression for such names. | + +Reported by default: + +{/* bad */} + +```js +const router = express.Router(); +``` + +Not reported with `capIsNewExceptions: ["Router"]`. `"express.Router"` works too: + +{/* good {"options": [{"capIsNewExceptions": ["Router"]}]} */} + +```js +const router = express.Router(); +``` + +A pattern is tested against the whole `express.Router`. Not reported with `capIsNewExceptionPattern: "^express\\."`: + +{/* good {"options": [{"capIsNewExceptionPattern": "^express\\."}]} */} + +```js +const router = express.Router(); +``` + +Not reported with `newIsCapExceptions: ["moment"]`: + +{/* good {"options": [{"newIsCapExceptions": ["moment"]}]} */} + +```js +const now = new moment(); +``` + +Not reported with `properties: false`: + +{/* good {"options": [{"properties": false}]} */} + +```js +const router = express.Router(); +const socket = new net.socket(); +``` + +## When not to use it + +Some libraries export capitalized functions that are meant to be called without `new`. If you use many of them, set `capIsNew` to `false`. diff --git a/docs/lint/rules/eslint/no-alert.mdx b/docs/lint/rules/eslint/no-alert.mdx new file mode 100644 index 000000000000..dcd3fd477089 --- /dev/null +++ b/docs/lint/rules/eslint/no-alert.mdx @@ -0,0 +1,46 @@ +--- +title: "no-alert" +description: "Disallow `alert`, `confirm`, and `prompt`" +--- + +`alert`, `confirm` and `prompt` stop all JavaScript on the page until the user answers, and the box they show cannot be styled. They are often left over from debugging. + +## Examples + +Reported: + +{/* bad {"count": 2} */} + +```js +alert("Saved"); + +if (window.confirm("Delete this file?")) { + remove(file); +} +``` + +Not reported: + +{/* good */} + +```js +showToast("Saved"); + +if (await dialog.confirm("Delete this file?")) { + remove(file); +} +``` + +A function of your own with one of these names is fine: + +{/* good */} + +```js +import { alert } from "./ui/dialogs.js"; + +alert("Saved"); +``` + +## When not to use it + +In Bun, `alert`, `confirm` and `prompt` work in the terminal, which makes them a quick way to ask a question in a command-line script. Turn the rule off for those scripts. diff --git a/docs/lint/rules/eslint/no-array-constructor.mdx b/docs/lint/rules/eslint/no-array-constructor.mdx new file mode 100644 index 000000000000..0596b532867a --- /dev/null +++ b/docs/lint/rules/eslint/no-array-constructor.mdx @@ -0,0 +1,39 @@ +--- +title: "no-array-constructor" +description: "Disallow `Array` constructors in favor of array literals" +--- + +What `Array()` does depends on how many arguments it gets. `Array(10, 20)` is an array with two items, but `Array(10)` is an array with 10 empty slots. An array literal always holds what you wrote. + +The rule reports `Array()` and `new Array()` with no argument or with more than one. A call with one argument is allowed, because that is how you make an array of a given length. + +`--fix` rewrites the call to an array literal. + +## Examples + +Reported: + +{/* bad {"count": 2} */} + +```js +const empty = new Array(); +const sizes = Array(10, 20, 30); +``` + +Not reported: + +{/* good */} + +```js +const empty = []; +const sizes = [10, 20, 30]; +const slots = new Array(5); +``` + +In TypeScript, a call with a type argument is not reported either: + +{/* good */} + +```ts +const names = new Array(); +``` diff --git a/docs/lint/rules/eslint/no-async-promise-executor.mdx b/docs/lint/rules/eslint/no-async-promise-executor.mdx new file mode 100644 index 000000000000..2352e2c05e0d --- /dev/null +++ b/docs/lint/rules/eslint/no-async-promise-executor.mdx @@ -0,0 +1,36 @@ +--- +title: "no-async-promise-executor" +description: "Disallow an async function as a Promise executor" +--- + +If the function you pass to `new Promise` is async and it throws, or something it awaits rejects, the new promise never hears about it. It stays pending forever, and a `.catch()` on it or a `try` around its `await` does not see the error. + +An `await` in the executor also means that you already have a promise, so you rarely need `new Promise` at all. + +This rule is on by default. + +## Examples + +Reported: + +{/* bad */} + +```js +const user = new Promise(async resolve => { + const response = await fetch("/api/user"); + resolve(await response.json()); +}); +``` + +Not reported: + +{/* good */} + +```js +async function loadUser() { + const response = await fetch("/api/user"); + return response.json(); +} + +const user = loadUser(); +``` diff --git a/docs/lint/rules/eslint/no-await-in-loop.mdx b/docs/lint/rules/eslint/no-await-in-loop.mdx new file mode 100644 index 000000000000..3fa834352bd6 --- /dev/null +++ b/docs/lint/rules/eslint/no-await-in-loop.mdx @@ -0,0 +1,60 @@ +--- +title: "no-await-in-loop" +description: "Disallow `await` inside of loops" +--- + +An `await` in a loop makes every round wait for the one before it. Ten requests of 100 ms each take a second, where starting them together and awaiting them with `Promise.all` takes about 100 ms. + +## Examples + +Reported: + +{/* bad */} + +```js +async function loadAll(urls) { + const pages = []; + for (const url of urls) { + pages.push(await fetch(url)); + } + return pages; +} +``` + +Not reported: + +{/* good */} + +```js +async function loadAll(urls) { + return Promise.all(urls.map(url => fetch(url))); +} +``` + +A `for await` loop is meant to wait, so an `await` in its body is not reported: + +{/* good */} + +```js +async function copy(stream, file) { + for await (const chunk of stream) { + await file.write(chunk); + } +} +``` + +The list a `for ... of` loop runs over is only evaluated once: + +{/* good */} + +```js +async function printUsers() { + for (const user of await loadUsers()) { + console.log(user.name); + } +} +``` + +## When not to use it + +Some loops have to run one step at a time: each page of results gives the cursor for the next, a retry waits for the attempt before it, or you do not want to send a server 500 requests at once. Disable the rule for those loops with a comment. diff --git a/docs/lint/rules/eslint/no-bitwise.mdx b/docs/lint/rules/eslint/no-bitwise.mdx new file mode 100644 index 000000000000..648c77f8928b --- /dev/null +++ b/docs/lint/rules/eslint/no-bitwise.mdx @@ -0,0 +1,71 @@ +--- +title: "no-bitwise" +description: "Disallow bitwise operators" +--- + +Most application code never needs `&`, `|`, `^`, `~`, `<<`, `>>` or `>>>`, so a single `&` or `|` is usually a typo for `&&` or `||`. The code still runs, but it evaluates both sides every time and gives a number instead of one of the operands. + +The assignment forms such as `|=` and `<<=` are reported too. + +## Examples + +Reported: + +{/* bad */} + +```js +if (user.active & user.verified) { + grant(user); +} +``` + +Not reported: + +{/* good */} + +```js +if (user.active && user.verified) { + grant(user); +} +``` + +## Options + +```toml bunfig.toml +[lint.rules] +no-bitwise = ["error", { allow = ["&", "|"] }] +``` + +| Option | Default | What it does | +| ----------- | ------- | ------------------------------------------------------------------- | +| `allow` | `[]` | Operators that are not reported. | +| `int32Hint` | `false` | Allow `value \| 0`, a way to cut a number down to a 32-bit integer. | + +With `allow: ["&", "|"]`, this is not reported: + +{/* good {"options": [{"allow": ["&", "|"]}]} */} + +```js +const mode = READ | WRITE; +const canWrite = (mode & WRITE) !== 0; +``` + +With `int32Hint`, neither is this: + +{/* good {"options": [{"int32Hint": true}]} */} + +```js +const whole = price | 0; +``` + +Any other number is still reported: + +{/* bad {"options": [{"int32Hint": true}]} */} + +```js +const odd = price | 1; +``` + +## When not to use it + +Code that works with flags, binary formats, hashes or colors needs these operators. Turn the rule off for those files, or list the operators you use in `allow`. diff --git a/docs/lint/rules/eslint/no-buffer-constructor.mdx b/docs/lint/rules/eslint/no-buffer-constructor.mdx new file mode 100644 index 000000000000..62f71122a806 --- /dev/null +++ b/docs/lint/rules/eslint/no-buffer-constructor.mdx @@ -0,0 +1,28 @@ +--- +title: "no-buffer-constructor" +description: "Disallow the deprecated `Buffer()` constructor" +--- + +`Buffer(value)` does different things depending on the type of `value`: a string is copied, a number allocates that many bytes. If `value` comes from a request, a caller who sends a number where you expect a string decides how much memory your server allocates. + +Node.js has deprecated the constructor. `Buffer.from()`, `Buffer.alloc()` and `Buffer.allocUnsafe()` each do one thing, and `Buffer.from()` throws if it gets a number. + +## Examples + +Reported: + +{/* bad {"count": 2} */} + +```js +const header = new Buffer(16); +const name = Buffer("Ada", "utf8"); +``` + +Not reported: + +{/* good */} + +```js +const header = Buffer.alloc(16); +const name = Buffer.from("Ada", "utf8"); +``` diff --git a/docs/lint/rules/eslint/no-caller.mdx b/docs/lint/rules/eslint/no-caller.mdx new file mode 100644 index 000000000000..c5f745166405 --- /dev/null +++ b/docs/lint/rules/eslint/no-caller.mdx @@ -0,0 +1,30 @@ +--- +title: "no-caller" +description: "Disallow `arguments.caller` and `arguments.callee`" +--- + +Reading `arguments.callee` throws a `TypeError` in strict mode, and every ES module and every class body is in strict mode. `arguments.caller` is no longer part of the language. + +To let a function call itself, give it a name. + +## Examples + +Reported: + +{/* bad */} + +```js +const factorial = function (n) { + return n <= 1 ? 1 : n * arguments.callee(n - 1); +}; +``` + +Not reported: + +{/* good */} + +```js +const factorial = function fact(n) { + return n <= 1 ? 1 : n * fact(n - 1); +}; +``` diff --git a/docs/lint/rules/eslint/no-case-declarations.mdx b/docs/lint/rules/eslint/no-case-declarations.mdx new file mode 100644 index 000000000000..63826c2ee597 --- /dev/null +++ b/docs/lint/rules/eslint/no-case-declarations.mdx @@ -0,0 +1,43 @@ +--- +title: "no-case-declarations" +description: "Disallow `let`, `const`, `function` and `class` declarations directly in a `case` clause" +--- + +All the `case` clauses of a `switch` share one scope. A `let`, `const` or `class` that you declare in one clause is visible in the others, but it only gets its value if its own clause runs. Reading it from another clause throws a `ReferenceError`, and declaring the same name in two clauses is a syntax error. + +Put braces around the clause to give it a scope of its own. A `function` declaration in a clause is reported too. `var` is not. + +This rule is on by default. + +## Examples + +Reported: + +{/* bad */} + +```js +switch (action.type) { + case "add": + const item = action.item; + list.push(item); + break; + default: + list.length = 0; +} +``` + +Not reported: + +{/* good */} + +```js +switch (action.type) { + case "add": { + const item = action.item; + list.push(item); + break; + } + default: + list.length = 0; +} +``` diff --git a/docs/lint/rules/eslint/no-catch-shadow.mdx b/docs/lint/rules/eslint/no-catch-shadow.mdx new file mode 100644 index 000000000000..ab9da1dcc063 --- /dev/null +++ b/docs/lint/rules/eslint/no-catch-shadow.mdx @@ -0,0 +1,42 @@ +--- +title: "no-catch-shadow" +description: "Disallow a `catch` parameter with the name of a variable in the scope around it" +--- + +In Internet Explorer 8 and earlier, the parameter of a `catch` clause leaks into the scope around it. If a variable there has the same name, the caught error overwrites it. + +The rule reports a `catch` parameter that has the name of a variable, a function, a class or a global that is visible at the `try` statement. + +## Examples + +Reported: + +{/* bad */} + +```js +const error = loadLastError(); + +try { + save(); +} catch (error) { + console.log(error); +} +``` + +Not reported: + +{/* good */} + +```js +const error = loadLastError(); + +try { + save(); +} catch (saveError) { + console.log(saveError); +} +``` + +## When not to use it + +Every current browser and runtime keeps the parameter inside the `catch` clause, so this rule only matters for Internet Explorer 8. It is deprecated. To stop names from hiding each other, use `no-shadow`. diff --git a/docs/lint/rules/eslint/no-class-assign.mdx b/docs/lint/rules/eslint/no-class-assign.mdx new file mode 100644 index 000000000000..de27198eefa3 --- /dev/null +++ b/docs/lint/rules/eslint/no-class-assign.mdx @@ -0,0 +1,40 @@ +--- +title: "no-class-assign" +description: "Disallow assigning to the name of a class" +--- + +The name of a class declaration can be assigned to like a `let` variable. After that the class is gone, and every `new Logger()` and `instanceof Logger` in the file works with the new value. That is almost always a mistake. + +This rule is on by default in JavaScript files. It is off in TypeScript files, where TypeScript reports the same mistake. + +## Examples + +Reported: + +{/* bad */} + +```js +class Logger {} + +Logger = createLogger(); +``` + +Not reported: + +{/* good */} + +```js +class Logger {} + +const logger = createLogger(); +``` + +A variable that holds a class expression is an ordinary variable: + +{/* good */} + +```js +let Store = class {}; + +Store = class extends Store {}; +``` diff --git a/docs/lint/rules/eslint/no-compare-neg-zero.mdx b/docs/lint/rules/eslint/no-compare-neg-zero.mdx new file mode 100644 index 000000000000..a4112dd3f10b --- /dev/null +++ b/docs/lint/rules/eslint/no-compare-neg-zero.mdx @@ -0,0 +1,44 @@ +--- +title: "no-compare-neg-zero" +description: "Disallow comparing against `-0`" +--- + +`0` and `-0` compare as equal, so `x === -0` is also `true` when `x` is `0`. The minus sign has no effect. + +To tell the two apart, use `Object.is(x, -0)`. Otherwise compare with `0`. + +This rule is on by default. + +## Examples + +Reported: + +{/* bad */} + +```js +if (offset === -0) { + flipDirection(); +} +``` + +Not reported: + +{/* good */} + +```js +if (Object.is(offset, -0)) { + flipDirection(); +} + +if (offset === 0) { + stop(); +} +``` + +`<`, `<=`, `>` and `>=` are reported too: + +{/* bad */} + +```js +const isForward = speed >= -0; +``` diff --git a/docs/lint/rules/eslint/no-cond-assign.mdx b/docs/lint/rules/eslint/no-cond-assign.mdx new file mode 100644 index 000000000000..60ce26ae12d1 --- /dev/null +++ b/docs/lint/rules/eslint/no-cond-assign.mdx @@ -0,0 +1,80 @@ +--- +title: "no-cond-assign" +description: "Disallow assignments in the test of `if`, loops and `? :`" +--- + +`if (user.role = "admin")` is usually a typo for `===`. It overwrites `user.role`, and the branch always runs because `"admin"` is truthy. + +The rule checks the test of `if`, `while`, `do...while`, `for` and `? :`. By default, an extra pair of parentheses around the assignment marks it as intentional. + +This rule is on by default. + +## Examples + +Reported: + +{/* bad */} + +```js +if ((user.role = "admin")) { + showSettings(); +} +``` + +Not reported: + +{/* good */} + +```js +if (user.role === "admin") { + showSettings(); +} + +let node = list.head; +while ((node = node.next)) { + visit(node); +} +``` + +## Options + +| Option | What it does | +| --------------------------- | ----------------------------------------------------------------------------------- | +| `"except-parens"` (default) | Report a test that is an assignment, unless the assignment has its own parentheses. | +| `"always"` | Report every assignment inside a test, with or without parentheses. | + +With `"always"`, an assignment that is only part of the test is reported too: + +{/* bad {"options": ["always"]} */} + +```js +let match; +while ((match = pattern.exec(text)) !== null) { + results.push(match[0]); +} +``` + +The default allows it: + +{/* good */} + +```js +let match; +while ((match = pattern.exec(text)) !== null) { + results.push(match[0]); +} +``` + +With the default, the test of `? :` needs two pairs of parentheses. One pair is still reported: + +{/* bad */} + +```js +const label = (current = next()) ? current.name : "none"; +``` + +{/* good */} + +```js +const label = (current = next()) ? current.name : "none"; +``` diff --git a/docs/lint/rules/eslint/no-console.mdx b/docs/lint/rules/eslint/no-console.mdx new file mode 100644 index 000000000000..f587f29c1ac0 --- /dev/null +++ b/docs/lint/rules/eslint/no-console.mdx @@ -0,0 +1,65 @@ +--- +title: "no-console" +description: "Disallow the use of `console`" +--- + +`console.log()` calls added while debugging are easy to forget. In a web page they show up in every visitor's console, and in a library they write to the output of someone else's program. + +Any property of the global `console` is reported, whether it is called or not. A local variable named `console` is not. + +## Examples + +Reported: + +{/* bad {"count": 2} */} + +```js +console.log("loaded", items.length); + +items.forEach(console.warn); +``` + +Not reported: + +{/* good */} + +```js +import { logger } from "./logger.js"; + +logger.info("loaded", items.length); +``` + +{/* good */} + +```js +function report(console) { + console.log("done"); +} +``` + +## Options + +| Option | Default | What it does | +| ------- | ------- | --------------------------------------- | +| `allow` | none | The `console` methods that are allowed. | + +```toml bunfig.toml +[lint.rules] +no-console = ["error", { allow = ["warn", "error"] }] +``` + +{/* good {"options": [{"allow": ["warn", "error"]}]} */} + +```js +console.error("could not save", reason); +``` + +{/* bad {"options": [{"allow": ["warn", "error"]}]} */} + +```js +console.log("saved"); +``` + +## When not to use it + +In a CLI tool or a script, `console` is how the program talks to its user. Turn the rule off there. diff --git a/docs/lint/rules/eslint/no-const-assign.mdx b/docs/lint/rules/eslint/no-const-assign.mdx new file mode 100644 index 000000000000..47819861737a --- /dev/null +++ b/docs/lint/rules/eslint/no-const-assign.mdx @@ -0,0 +1,49 @@ +--- +title: "no-const-assign" +description: "Disallow reassigning `const`, `using` and `await using` variables" +--- + +Assigning to a variable declared with `const`, `using` or `await using` throws a `TypeError` when that line runs. The rule catches it earlier, including in branches your tests never reach. + +`=`, compound assignments such as `+=`, `++` and `--` all count. + +This rule is on by default in JavaScript files. It is off in TypeScript files, where TypeScript reports the same mistake. + +## Examples + +Reported: + +{/* bad {"count": 2} */} + +```js +const retries = 0; + +function onFailure() { + retries += 1; +} + +function reset() { + retries = 0; +} +``` + +Not reported: + +{/* good */} + +```js +let retries = 0; + +function onFailure() { + retries += 1; +} +``` + +`const` protects the variable, not the value it holds. Changing a property is fine: + +{/* good */} + +```js +const settings = { theme: "light" }; +settings.theme = "dark"; +``` diff --git a/docs/lint/rules/eslint/no-constant-binary-expression.mdx b/docs/lint/rules/eslint/no-constant-binary-expression.mdx new file mode 100644 index 000000000000..f7ad6ee68e93 --- /dev/null +++ b/docs/lint/rules/eslint/no-constant-binary-expression.mdx @@ -0,0 +1,62 @@ +--- +title: "no-constant-binary-expression" +description: "Disallow comparisons and `&&`, `||`, `??` that always give the same result" +--- + +Some expressions look like they depend on a value but always give the same result. `a + b ?? c` means `(a + b) ?? c`, and a sum is never `null`, so `c` is never used. `items === []` is always `false`, because `[]` is a new array that is equal to nothing else. + +The rule reports: + +- `&&` and `||` where the left side is always truthy or always falsy +- `??` where the left side is never `null` or `undefined`, or always is +- `==`, `!=`, `===` and `!==` where the result is always the same + +This rule is on by default. + +## Examples + +Reported: + +{/* bad {"count": 3} */} + +```js +const label = "Hello, " + user.name ?? "guest"; + +if (items === []) { + showEmptyState(); +} + +const isMissing = !user.email === undefined; +``` + +Not reported: + +{/* good */} + +```js +const label = "Hello, " + (user.name ?? "guest"); + +if (items.length === 0) { + showEmptyState(); +} + +const isMissing = user.email === undefined; +``` + +## Options + +| Option | Default | What it does | +| ---------------------------- | ------- | ------------------------------------------------------------------------------------------------------------- | +| `checkRelationalComparisons` | `false` | Also report `<`, `<=`, `>` and `>=` between two literal values. With an oxlint config, the default is `true`. | + +{/* bad {"options": [{"checkRelationalComparisons": true}]} */} + +```js +const isLarge = 1024 > 4096; +``` + +{/* good */} + +```js +const isLarge = 1024 > 4096; +``` diff --git a/docs/lint/rules/eslint/no-constant-condition.mdx b/docs/lint/rules/eslint/no-constant-condition.mdx new file mode 100644 index 000000000000..b7aabc6b8cfe --- /dev/null +++ b/docs/lint/rules/eslint/no-constant-condition.mdx @@ -0,0 +1,93 @@ +--- +title: "no-constant-condition" +description: "Disallow conditions that are always truthy or always falsy" +--- + +If a condition never changes, one branch always runs and the other is dead code. It is often a mistake: `role === "admin" || "owner"` is always truthy, because `"owner"` is a non-empty string. + +The rule checks the test of `if`, `? :`, `while`, `do...while` and `for`. `while (true)` is allowed by default. + +This rule is on by default. + +## Examples + +Reported: + +{/* bad {"count": 2} */} + +```js +if (role === "admin" || "owner") { + showSettings(); +} + +const title = "" ? "Draft" : "Published"; +``` + +Not reported: + +{/* good */} + +```js +if (role === "admin" || role === "owner") { + showSettings(); +} + +while (true) { + const job = queue.shift(); + if (!job) break; + run(job); +} +``` + +## Options + +`checkLoops` controls how loops are checked: + +| Value | What it does | +| -------------------------------- | -------------------------------------- | +| `"allExceptWhileTrue"` (default) | Check loops, but allow `while (true)`. | +| `"all"` or `true` | Check loops, including `while (true)`. | +| `"none"` or `false` | Do not check loops. | + +{/* bad {"options": [{"checkLoops": "all"}]} */} + +```js +while (true) { + poll(); +} +``` + +The default only allows the literal `true` in a `while`: + +{/* bad {"count": 2} */} + +```js +while (1) { + poll(); +} + +do { + poll(); +} while (true); +``` + +{/* good {"options": [{"checkLoops": "none"}]} */} + +```js +while (1) { + poll(); +} +``` + +A loop in a generator function is not reported if it contains a `yield`, because the caller decides when to stop: + +{/* good {"options": [{"checkLoops": "all"}]} */} + +```js +function* ids() { + let next = 0; + while (true) { + yield next++; + } +} +``` diff --git a/docs/lint/rules/eslint/no-constructor-return.mdx b/docs/lint/rules/eslint/no-constructor-return.mdx new file mode 100644 index 000000000000..3ce0cbb1752b --- /dev/null +++ b/docs/lint/rules/eslint/no-constructor-return.mdx @@ -0,0 +1,58 @@ +--- +title: "no-constructor-return" +description: "Disallow returning a value from a constructor" +--- + +If a constructor returns an object, `new` gives the caller that object instead of the new instance, so the result may not be an instance of the class. Returning a primitive such as a number is ignored, or throws a `TypeError` if the class extends another one. + +A bare `return;` is allowed, to leave the constructor early. + +## Examples + +Reported: + +{/* bad */} + +```js +class Connection { + constructor(url) { + if (cache.has(url)) { + return cache.get(url); + } + this.url = url; + } +} +``` + +Not reported: + +{/* good */} + +```js +class Connection { + constructor(url) { + this.url = url; + } + + static open(url) { + if (!cache.has(url)) { + cache.set(url, new Connection(url)); + } + return cache.get(url); + } +} +``` + +{/* good */} + +```js +class Timer { + constructor(delay) { + this.delay = delay; + if (delay === 0) { + return; + } + this.start(); + } +} +``` diff --git a/docs/lint/rules/eslint/no-continue.mdx b/docs/lint/rules/eslint/no-continue.mdx new file mode 100644 index 000000000000..eb106be2d470 --- /dev/null +++ b/docs/lint/rules/eslint/no-continue.mdx @@ -0,0 +1,39 @@ +--- +title: "no-continue" +description: "Disallow `continue` statements" +--- + +`continue` jumps to the next iteration from the middle of the loop body. In a long loop, you have to find every `continue` to know which lines run for which items. An `if` around the rest of the body shows that in the structure of the code. + +`continue` with a label is reported too. + +## Examples + +Reported: + +{/* bad */} + +```js +for (const user of users) { + if (!user.active) { + continue; + } + notify(user); +} +``` + +Not reported: + +{/* good */} + +```js +for (const user of users) { + if (user.active) { + notify(user); + } +} +``` + +## When not to use it + +A `continue` at the top of a loop works like an early return and saves a level of indentation. If you prefer that style, leave this rule off. diff --git a/docs/lint/rules/eslint/no-control-regex.mdx b/docs/lint/rules/eslint/no-control-regex.mdx new file mode 100644 index 000000000000..7e898cd2599e --- /dev/null +++ b/docs/lint/rules/eslint/no-control-regex.mdx @@ -0,0 +1,57 @@ +--- +title: "no-control-regex" +description: "Disallow control characters in regular expressions" +--- + +The characters U+0000 to U+001F are invisible and rarely appear in text you want to match. In a regular expression, one is often a typo in an escape, such as `\x0C` where `\xC0` was meant. + +The rule reports a raw control character and the escapes `\x0A`, `\u000A` and, with the `u` or `v` flag, `\u{A}`. It checks regular expression literals and strings passed to `RegExp`. Named escapes such as `\t`, `\n` and `\cJ` are allowed. + +This rule is on by default. + +## Examples + +Reported: + +{/* bad {"count": 2} */} + +```js +const lineBreak = /\x0A/; + +const separator = new RegExp("\x1F"); +``` + +Not reported: + +{/* good */} + +```js +const lineBreak = /\n/; + +const space = /\x20/; +``` + +In a string, `"\n"` is the raw character by the time `RegExp` sees it. Escape the backslash: + +{/* bad */} + +```js +const lineBreak = new RegExp("\n"); +``` + +{/* good */} + +```js +const lineBreak = new RegExp("\\n"); +``` + +## When not to use it + +Some formats are built on control characters. Terminal color codes start with `\x1B`, so a pattern that strips them has to contain it. Disable the rule for that line: + +{/* good */} + +```js +// eslint-disable-next-line no-control-regex +const colorCodes = /\x1B\[[0-9;]*m/g; +``` diff --git a/docs/lint/rules/eslint/no-debugger.mdx b/docs/lint/rules/eslint/no-debugger.mdx new file mode 100644 index 000000000000..2c827f59e9c4 --- /dev/null +++ b/docs/lint/rules/eslint/no-debugger.mdx @@ -0,0 +1,31 @@ +--- +title: "no-debugger" +description: "Disallow `debugger` statements" +--- + +`debugger` pauses the program when a debugger is attached, for example when the browser's developer tools are open. It is useful while you track down a bug. Committed by accident, it freezes the page for anyone who has the tools open. + +This rule is on by default. + +## Examples + +Reported: + +{/* bad */} + +```js +function total(items) { + debugger; + return items.reduce((sum, item) => sum + item.price, 0); +} +``` + +Not reported: + +{/* good */} + +```js +function total(items) { + return items.reduce((sum, item) => sum + item.price, 0); +} +``` diff --git a/docs/lint/rules/eslint/no-delete-var.mdx b/docs/lint/rules/eslint/no-delete-var.mdx new file mode 100644 index 000000000000..831e2acfa5de --- /dev/null +++ b/docs/lint/rules/eslint/no-delete-var.mdx @@ -0,0 +1,32 @@ +--- +title: "no-delete-var" +description: "Disallow `delete` on variables" +--- + +`delete` removes a property from an object. It does not remove a variable: on one declared with `var`, `let` or `const` it does nothing and returns `false`. + +In strict mode and in modules this is already a syntax error. The rule catches it in the scripts where it is not, such as a `.cjs` file without `"use strict"`. + +This rule is on by default. + +## Examples + +Reported, in a `.cjs` file: + +{/* bad {"filename": "legacy.cjs"} */} + +```js +var session = createSession(); +delete session; +``` + +Not reported: + +{/* good */} + +```js +var session = createSession(); +session = undefined; + +delete cache.session; +``` diff --git a/docs/lint/rules/eslint/no-div-regex.mdx b/docs/lint/rules/eslint/no-div-regex.mdx new file mode 100644 index 000000000000..c220fefe4e49 --- /dev/null +++ b/docs/lint/rules/eslint/no-div-regex.mdx @@ -0,0 +1,26 @@ +--- +title: "no-div-regex" +description: "Disallow regular expressions that start with `=`" +--- + +A regular expression literal that starts with `=` begins with `/=`, which is also the division assignment operator. You have to look twice to see which one it is. + +`--fix` rewrites the `=` to `[=]`, which matches the same text. + +## Examples + +Reported: + +{/* bad */} + +```js +const value = line.replace(/=\s*/, ""); +``` + +Not reported: + +{/* good */} + +```js +const value = line.replace(/[=]\s*/, ""); +``` diff --git a/docs/lint/rules/eslint/no-dupe-args.mdx b/docs/lint/rules/eslint/no-dupe-args.mdx new file mode 100644 index 000000000000..1193d7056caa --- /dev/null +++ b/docs/lint/rules/eslint/no-dupe-args.mdx @@ -0,0 +1,32 @@ +--- +title: "no-dupe-args" +description: "Disallow two parameters with the same name in a function" +--- + +When two parameters have the same name, the name refers to the last one. The argument passed for the first one can only be reached through `arguments`. It is nearly always a typo or a copy-paste mistake. + +In strict mode, in modules and in arrow functions this is already a syntax error. The rule catches it in the scripts where it is not, such as a `.cjs` file without `"use strict"`. + +This rule is on by default in JavaScript files. It is off in TypeScript files, where TypeScript reports the same mistake. + +## Examples + +Reported, in a `.cjs` file: + +{/* bad {"filename": "legacy.cjs"} */} + +```js +function resize(width, height, width) { + return width * height; +} +``` + +Not reported: + +{/* good */} + +```js +function resize(width, height, depth) { + return width * height * depth; +} +``` diff --git a/docs/lint/rules/eslint/no-dupe-class-members.mdx b/docs/lint/rules/eslint/no-dupe-class-members.mdx new file mode 100644 index 000000000000..1a63ba6d4c3d --- /dev/null +++ b/docs/lint/rules/eslint/no-dupe-class-members.mdx @@ -0,0 +1,92 @@ +--- +title: "no-dupe-class-members" +description: "Disallow two class members with the same name" +--- + +When a class declares two methods with the same name, the last one silently replaces the first. Calls run the second method, and edits to the first one have no effect. + +A getter and a setter with the same name are a pair and are allowed. So are a static member and an instance member with the same name, and `name` next to the private `#name`. + +This rule is on by default in JavaScript files. It is off in TypeScript files, where TypeScript reports the same mistake. + +## Examples + +Reported: + +{/* bad */} + +```js +class Cart { + total() { + return this.items.length; + } + + total() { + return this.items.reduce((sum, item) => sum + item.price, 0); + } +} +``` + +Not reported: + +{/* good */} + +```js +class Cart { + count() { + return this.items.length; + } + + total() { + return this.items.reduce((sum, item) => sum + item.price, 0); + } +} +``` + +{/* good */} + +```js +class Cart { + #discount = 0; + + get discount() { + return this.#discount; + } + + set discount(value) { + this.#discount = value; + } + + static discount() { + return new Cart(); + } +} +``` + +A field and a method with the same name are reported too: + +{/* bad */} + +```js +class Cart { + total = 0; + + total() { + return this.items.length; + } +} +``` + +In TypeScript, overload signatures do not count: + +{/* good */} + +```ts +class Cart { + add(id: string): void; + add(id: string, quantity: number): void; + add(id: string, quantity = 1) { + this.items.push({ id, quantity }); + } +} +``` diff --git a/docs/lint/rules/eslint/no-dupe-else-if.mdx b/docs/lint/rules/eslint/no-dupe-else-if.mdx new file mode 100644 index 000000000000..74b8032de8f6 --- /dev/null +++ b/docs/lint/rules/eslint/no-dupe-else-if.mdx @@ -0,0 +1,66 @@ +--- +title: "no-dupe-else-if" +description: "Disallow an `else if` with a condition that an earlier branch already covers" +--- + +In an `if ... else if` chain, a branch only runs if every condition before it was false. If a condition repeats an earlier one, its branch can never run. It is usually a copied line where the condition was not changed. + +The rule also understands `||` and `&&`. After `if (a || b)`, both `else if (a)` and `else if (b && c)` are dead. The order of the operands does not matter. + +This rule is on by default. + +## Examples + +Reported: + +{/* bad */} + +```js +if (status === "active") { + enable(); +} else if (status === "paused") { + pause(); +} else if (status === "active") { + archive(); +} +``` + +Not reported: + +{/* good */} + +```js +if (status === "active") { + enable(); +} else if (status === "paused") { + pause(); +} else if (status === "archived") { + archive(); +} +``` + +A condition that is narrower than an earlier one is reported too: + +{/* bad {"count": 2} */} + +```js +if (user.isAdmin || user.isOwner) { + showAll(); +} else if (user.isOwner && user.isVerified) { + showMost(); +} else if (user.isAdmin) { + showSome(); +} +``` + +Put the narrower condition first: + +{/* good */} + +```js +if (user.isOwner && user.isVerified) { + showMost(); +} else if (user.isAdmin || user.isOwner) { + showAll(); +} +``` diff --git a/docs/lint/rules/eslint/no-dupe-keys.mdx b/docs/lint/rules/eslint/no-dupe-keys.mdx new file mode 100644 index 000000000000..8323df2ef275 --- /dev/null +++ b/docs/lint/rules/eslint/no-dupe-keys.mdx @@ -0,0 +1,63 @@ +--- +title: "no-dupe-keys" +description: "Disallow two keys with the same name in an object literal" +--- + +When an object literal has the same key twice, the last value silently replaces the first. In a long object it is easy to add a key that is already there, and then one of the two has no effect. + +A getter and a setter with the same name are a pair and are allowed. + +This rule is on by default in JavaScript files. It is off in TypeScript files, where TypeScript reports the same mistake. + +## Examples + +Reported: + +{/* bad */} + +```js +const options = { + timeout: 1000, + retries: 3, + timeout: 5000, +}; +``` + +Not reported: + +{/* good */} + +```js +const options = { + timeout: 5000, + retries: 3, +}; +``` + +Keys are compared by value, so `"timeout"` is the same key as `timeout`, and `0x1` is the same key as `1`: + +{/* bad {"count": 2} */} + +```js +const options = { + timeout: 1000, + "timeout": 5000, +}; + +const labels = { + 1: "one", + 0x1: "uno", +}; +``` + +A computed key such as `[name]` and a spread are not compared, because their keys are only known at runtime: + +{/* good */} + +```js +const options = { + timeout: 1000, + [name]: 5000, + ...overrides, +}; +``` diff --git a/docs/lint/rules/eslint/no-duplicate-case.mdx b/docs/lint/rules/eslint/no-duplicate-case.mdx new file mode 100644 index 000000000000..b1341b64cd9e --- /dev/null +++ b/docs/lint/rules/eslint/no-duplicate-case.mdx @@ -0,0 +1,63 @@ +--- +title: "no-duplicate-case" +description: "Disallow two `case` labels with the same test in a `switch`" +--- + +A `switch` runs the first `case` that matches. A second `case` with the same test is never chosen, so its code only runs if the case above it falls through. It is usually a copied `case` where the label was not changed. + +Two labels are the same if their source text is the same, ignoring whitespace and comments. + +This rule is on by default. + +## Examples + +Reported: + +{/* bad */} + +```js +switch (event.key) { + case "ArrowUp": + moveUp(); + break; + case "ArrowDown": + moveDown(); + break; + case "ArrowUp": + selectPrevious(); + break; +} +``` + +Not reported: + +{/* good */} + +```js +switch (event.key) { + case "ArrowUp": + moveUp(); + break; + case "ArrowDown": + moveDown(); + break; + case "PageUp": + selectPrevious(); + break; +} +``` + +Labels do not have to be literals: + +{/* bad */} + +```js +switch (true) { + case size > limit: + reject(); + break; + case size > limit: + warn(); + break; +} +``` diff --git a/docs/lint/rules/eslint/no-duplicate-imports.mdx b/docs/lint/rules/eslint/no-duplicate-imports.mdx new file mode 100644 index 000000000000..51d949bfdc59 --- /dev/null +++ b/docs/lint/rules/eslint/no-duplicate-imports.mdx @@ -0,0 +1,85 @@ +--- +title: "no-duplicate-imports" +description: "Disallow importing from the same module more than once in a file" +--- + +Two `import` statements for the same module split one list of names over two places. A reader has to find both to know what the file takes from that module, and the next person to add a name has to pick one. + +Only statements that can be merged are reported. `import * as path` and `import { join }` cannot be written as one statement, so they can stand side by side. + +## Examples + +Reported: + +{/* bad */} + +```js +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { writeFile } from "node:fs/promises"; +``` + +Not reported: + +{/* good */} + +```js +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +``` + +{/* good */} + +```js +import * as path from "node:path"; +import { join } from "node:path"; +``` + +## Options + +| Option | Default | What it does | +| -------------------------- | ------- | ------------------------------------------------------------------------------ | +| `includeExports` | `false` | Also look at `export ... from` statements. | +| `allowSeparateTypeImports` | `false` | Allow an `import type` statement next to a normal import from the same module. | + +With `includeExports`, an `export ... from` a module that the file already imports or exports from is reported: + +{/* bad {"options": [{"includeExports": true}]} */} + +```js +import { parse } from "./parser.js"; +export { tokenize } from "./parser.js"; +``` + +`export * from` cannot be merged with an import of names, so it is still allowed: + +{/* good {"options": [{"includeExports": true}]} */} + +```js +import { parse } from "./parser.js"; +export * from "./parser.js"; +``` + +Without `allowSeparateTypeImports`, types and values from one module belong in one statement: + +{/* bad */} + +```ts +import type { User } from "./user"; +import { loadUser } from "./user"; +``` + +{/* good */} + +```ts +import { loadUser, type User } from "./user"; +``` + +With it, the two statements are allowed: + +{/* good {"options": [{"allowSeparateTypeImports": true}]} */} + +```ts +import type { User } from "./user"; +import { loadUser } from "./user"; +``` diff --git a/docs/lint/rules/eslint/no-else-return.mdx b/docs/lint/rules/eslint/no-else-return.mdx new file mode 100644 index 000000000000..f1772fcbf838 --- /dev/null +++ b/docs/lint/rules/eslint/no-else-return.mdx @@ -0,0 +1,89 @@ +--- +title: "no-else-return" +description: "Disallow `else` after an `if` branch that always returns" +--- + +When the `if` branch ends with `return`, the code after the `if` only runs if the condition was false. An `else` around that code adds a level of indentation and says nothing new. + +`--fix` removes the `else` and moves its content out of the block, unless a name declared in the block would clash with one outside it. + +## Examples + +Reported: + +{/* bad */} + +```js +function label(user) { + if (user.admin) { + return "Admin"; + } else { + return "Member"; + } +} +``` + +Not reported: + +{/* good */} + +```js +function label(user) { + if (user.admin) { + return "Admin"; + } + return "Member"; +} +``` + +## Options + +| Option | Default | What it does | +| ------------- | ------- | -------------------------------------------- | +| `allowElseIf` | `true` | Allow `else if` after a branch that returns. | + +By default an `else if` is fine: + +{/* good */} + +```js +function label(user) { + if (user.admin) { + return "Admin"; + } else if (user.guest) { + return "Guest"; + } + return "Member"; +} +``` + +The last `else` of a chain is still reported if every branch before it returns: + +{/* bad */} + +```js +function label(user) { + if (user.admin) { + return "Admin"; + } else if (user.guest) { + return "Guest"; + } else { + return "Member"; + } +} +``` + +With `allowElseIf: false`, the `else if` is reported too: + +{/* bad {"options": [{"allowElseIf": false}]} */} + +```js +function label(user) { + if (user.admin) { + return "Admin"; + } else if (user.guest) { + return "Guest"; + } + return "Member"; +} +``` diff --git a/docs/lint/rules/eslint/no-empty-character-class.mdx b/docs/lint/rules/eslint/no-empty-character-class.mdx new file mode 100644 index 000000000000..aa53e5428f57 --- /dev/null +++ b/docs/lint/rules/eslint/no-empty-character-class.mdx @@ -0,0 +1,36 @@ +--- +title: "no-empty-character-class" +description: "Disallow empty character classes `[]` in regular expressions" +--- + +`[]` in a regular expression is a character class with no characters in it. It matches nothing, so `/^#[]{6}$/` never matches any string. Most often someone forgot to fill in the class, or meant the text `[]`, which is written `\[]`. + +`[^]`, which matches any character, is allowed. Only regular expression literals are checked, not strings passed to `new RegExp()`. + +This rule is on by default. + +## Examples + +Reported: + +{/* bad */} + +```js +const isHexColor = /^#[]{6}$/.test(color); +``` + +Not reported: + +{/* good */} + +```js +const isHexColor = /^#[0-9a-f]{6}$/i.test(color); +``` + +{/* good */} + +```js +const anyCharacter = /[^]/; +const emptyArrayText = /\[]/; +const notChecked = new RegExp("[]"); +``` diff --git a/docs/lint/rules/eslint/no-empty-function.mdx b/docs/lint/rules/eslint/no-empty-function.mdx new file mode 100644 index 000000000000..8a98616d9f38 --- /dev/null +++ b/docs/lint/rules/eslint/no-empty-function.mdx @@ -0,0 +1,97 @@ +--- +title: "no-empty-function" +description: "Disallow functions with an empty body" +--- + +An empty function reads like something that was never finished. A reader cannot tell whether it does nothing on purpose, as a default callback does, or whether the body is missing. + +A comment in the body answers that question, and the rule accepts it. + +## Examples + +Reported: + +{/* bad {"count": 2} */} + +```js +function onClose() {} + +const handlers = { + onError: () => {}, +}; +``` + +Not reported: + +{/* good */} + +```js +function onClose() { + // Nothing to clean up. +} + +const handlers = { + onError: () => { + // Errors are reported by the caller. + }, +}; +``` + +In TypeScript, a constructor that declares fields in its parameters does its work without a body: + +{/* good */} + +```ts +class User { + constructor(private name: string) {} +} +``` + +## Options + +| Option | Default | What it does | +| ------- | ------- | ----------------------------------------- | +| `allow` | `[]` | The kinds of functions that may be empty. | + +| Kind | Allows | +| ------------------------- | ----------------------------------------- | +| `"functions"` | `function noop() {}` and `function () {}` | +| `"arrowFunctions"` | `() => {}`, also if it is `async` | +| `"generatorFunctions"` | `function* items() {}` | +| `"asyncFunctions"` | `async function load() {}` | +| `"methods"` | `render() {}` in a class or an object | +| `"generatorMethods"` | `*items() {}` | +| `"asyncMethods"` | `async load() {}` | +| `"getters"` | `get name() {}` | +| `"setters"` | `set name(value) {}` | +| `"constructors"` | `constructor() {}` | +| `"privateConstructors"` | `private constructor() {}` | +| `"protectedConstructors"` | `protected constructor() {}` | +| `"decoratedFunctions"` | a method with a decorator | +| `"overrideMethods"` | `override render() {}` | + +Each kind allows only what its row shows. `"methods"` does not cover `async load() {}`, and `"arrowFunctions"` does not cover an empty `function`: + +{/* good {"options": [{"allow": ["arrowFunctions"]}]} */} + +```js +const noop = () => {}; +const wait = async () => {}; +``` + +{/* bad {"options": [{"allow": ["arrowFunctions"]}]} */} + +```js +function noop() {} +``` + +A private constructor is a common way to stop `new Registry()` from outside the class: + +{/* good {"options": [{"allow": ["privateConstructors"]}]} */} + +```ts +class Registry { + static shared = new Registry(); + private constructor() {} +} +``` diff --git a/docs/lint/rules/eslint/no-empty-pattern.mdx b/docs/lint/rules/eslint/no-empty-pattern.mdx new file mode 100644 index 000000000000..2072491e1bc9 --- /dev/null +++ b/docs/lint/rules/eslint/no-empty-pattern.mdx @@ -0,0 +1,68 @@ +--- +title: "no-empty-pattern" +description: "Disallow destructuring patterns that bind nothing" +--- + +`const {} = user` looks like a declaration but creates no variable. The usual cause is one wrong character: `{ settings: {} }` destructures `settings` into nothing, where `{ settings = {} }` would give it a default value. + +This rule is on by default. + +## Examples + +Reported: + +{/* bad {"count": 3} */} + +```js +const { + settings: {}, +} = user; +const [] = items; + +function render({}) { + return "
    "; +} +``` + +Not reported: + +{/* good */} + +```js +const { settings = {} } = user; +const [first] = items; + +function render({ title }) { + return `
    ${title}
    `; +} +``` + +## Options + +| Option | Default | What it does | +| --------------------------------- | ------- | ---------------------------------------------------------------------------------------- | +| `allowObjectPatternsAsParameters` | `false` | Allow `{}` as a parameter of a function, with no default value or with the default `{}`. | + +This is for a function that is called with an object it does not need, and that uses a later parameter: + +{/* good {"options": [{"allowObjectPatternsAsParameters": true}]} */} + +```js +function withServer({}, use) { + return use(startServer()); +} + +function withDatabase({} = {}, use) { + return use(openDatabase()); +} +``` + +An empty array pattern as a parameter is still reported: + +{/* bad {"options": [{"allowObjectPatternsAsParameters": true}]} */} + +```js +function withServer([], use) { + return use(startServer()); +} +``` diff --git a/docs/lint/rules/eslint/no-empty-static-block.mdx b/docs/lint/rules/eslint/no-empty-static-block.mdx new file mode 100644 index 000000000000..303e1c43617d --- /dev/null +++ b/docs/lint/rules/eslint/no-empty-static-block.mdx @@ -0,0 +1,48 @@ +--- +title: "no-empty-static-block" +description: "Disallow empty `static {}` blocks in classes" +--- + +A `static` block runs once, when the class is defined. An empty one does nothing. It is usually what a refactor left behind, or setup code that was never written. + +A block with a comment in it is not reported. + +This rule is on by default. + +## Examples + +Reported: + +{/* bad */} + +```js +class Config { + static values = new Map(); + + static {} +} +``` + +Not reported: + +{/* good */} + +```js +class Config { + static values = new Map(); + + static { + Config.values.set("mode", process.env.NODE_ENV); + } +} +``` + +{/* good */} + +```js +class Config { + static { + // Values are loaded on first use. + } +} +``` diff --git a/docs/lint/rules/eslint/no-empty.mdx b/docs/lint/rules/eslint/no-empty.mdx new file mode 100644 index 000000000000..3c13e79b4b3c --- /dev/null +++ b/docs/lint/rules/eslint/no-empty.mdx @@ -0,0 +1,72 @@ +--- +title: "no-empty" +description: "Disallow empty block statements" +--- + +An empty block is usually code that was never finished, or what a refactor left behind. An empty `catch` is worse: the error disappears and nothing tells you that the call failed. + +A block with a comment in it is not reported, so you can say why it is empty. Empty function bodies are not reported either. `no-empty-function` is the rule for those. + +This rule is on by default. + +## Examples + +Reported: + +{/* bad */} + +```js +try { + cache.clear(); +} catch (error) {} +``` + +{/* bad {"count": 2} */} + +```js +if (user.active) { +} + +switch (user.role) { +} +``` + +Not reported: + +{/* good */} + +```js +try { + cache.clear(); +} catch (error) { + // The cache is optional. A failure here changes nothing. +} + +function noop() {} +``` + +## Options + +| Option | Default | What it does | +| ----------------- | ------- | ----------------------------- | +| `allowEmptyCatch` | `false` | Allow an empty `catch` block. | + +{/* good {"options": [{"allowEmptyCatch": true}]} */} + +```js +try { + cache.clear(); +} catch (error) {} +``` + +Other empty blocks are still reported, including the `finally` of the same statement: + +{/* bad {"options": [{"allowEmptyCatch": true}]} */} + +```js +try { + cache.clear(); +} catch (error) { +} finally { +} +``` diff --git a/docs/lint/rules/eslint/no-eq-null.mdx b/docs/lint/rules/eslint/no-eq-null.mdx new file mode 100644 index 000000000000..86a0f7fdec8c --- /dev/null +++ b/docs/lint/rules/eslint/no-eq-null.mdx @@ -0,0 +1,40 @@ +--- +title: "no-eq-null" +description: "Disallow comparing with `null` using `==` or `!=`" +--- + +`value == null` is `true` for `null` and also for `undefined`. If you meant only `null`, the check lets `undefined` through. If you meant both, the next reader cannot tell that from the code. + +## Examples + +Reported: + +{/* bad {"count": 2} */} + +```js +if (user == null) { + redirect("/login"); +} + +while (node != null) { + node = node.next; +} +``` + +Not reported: + +{/* good */} + +```js +if (user === null) { + redirect("/login"); +} + +if (session === null || session === undefined) { + redirect("/login"); +} +``` + +## When not to use it + +`== null` is a common short way to test for `null` and `undefined` at once. If your code does that on purpose, leave this rule off. `eqeqeq` with the `"smart"` option allows `== null` and still reports `value == other`. diff --git a/docs/lint/rules/eslint/no-eval.mdx b/docs/lint/rules/eslint/no-eval.mdx new file mode 100644 index 000000000000..019346354fcc --- /dev/null +++ b/docs/lint/rules/eslint/no-eval.mdx @@ -0,0 +1,69 @@ +--- +title: "no-eval" +description: "Disallow `eval()`" +--- + +`eval()` runs a string as code, with access to the variables around the call. If any part of that string comes from outside your program, the person who controls it can run anything your program can. + +Most uses have a direct replacement: `JSON.parse()` for data, `object[key]` for a property chosen by name. + +## Examples + +Reported: + +{/* bad */} + +```js +const settings = eval("(" + text + ")"); +``` + +Reaching `eval` another way is reported too: + +{/* bad {"count": 3} */} + +```js +const run = eval; +globalThis.eval(code); +(0, eval)(code); +``` + +Not reported: + +{/* good */} + +```js +const settings = JSON.parse(text); +const value = user[field]; +``` + +A method named `eval` on your own object is fine: + +{/* good */} + +```js +const result = sandbox.eval(code); +``` + +## Options + +| Option | Default | What it does | +| --------------- | ------- | ------------------------------- | +| `allowIndirect` | `false` | Allow indirect calls to `eval`. | + +An indirect call runs the code in the global scope, so it cannot read or change the local variables around the call: + +{/* good {"options": [{"allowIndirect": true}]} */} + +```js +(0, eval)(code); +globalThis.eval(code); +eval?.(code); +``` + +A direct call is still reported: + +{/* bad {"options": [{"allowIndirect": true}]} */} + +```js +eval(code); +``` diff --git a/docs/lint/rules/eslint/no-ex-assign.mdx b/docs/lint/rules/eslint/no-ex-assign.mdx new file mode 100644 index 000000000000..768c6a604b8f --- /dev/null +++ b/docs/lint/rules/eslint/no-ex-assign.mdx @@ -0,0 +1,62 @@ +--- +title: "no-ex-assign" +description: "Disallow assigning to the error parameter of a `catch` clause" +--- + +Assigning to the parameter of a `catch` clause overwrites the error that was caught. From that line on, nothing in the block can read the original message or stack. + +This rule is on by default. + +## Examples + +Reported: + +{/* bad */} + +```js +try { + upload(file); +} catch (error) { + error = new Error("Upload failed"); + report(error); +} +``` + +Names that the parameter destructures count as well: + +{/* bad */} + +```js +try { + upload(file); +} catch ({ message }) { + message = "Upload failed"; + report(message); +} +``` + +Not reported: + +{/* good */} + +```js +try { + upload(file); +} catch (error) { + const wrapped = new Error("Upload failed", { cause: error }); + report(wrapped); +} +``` + +Setting a property keeps the error, so it is allowed: + +{/* good */} + +```js +try { + upload(file); +} catch (error) { + error.file = file; + report(error); +} +``` diff --git a/docs/lint/rules/eslint/no-extend-native.mdx b/docs/lint/rules/eslint/no-extend-native.mdx new file mode 100644 index 000000000000..9bcb8276d5a3 --- /dev/null +++ b/docs/lint/rules/eslint/no-extend-native.mdx @@ -0,0 +1,70 @@ +--- +title: "no-extend-native" +description: "Disallow adding properties to the prototypes of built-in objects" +--- + +A property added to `Array.prototype` or `Object.prototype` shows up on every array or object in the program, including those inside libraries. It appears in their `for...in` loops, and it breaks when a library or a later version of JavaScript defines the same name with a different meaning. + +Both plain assignment and `Object.defineProperty()` / `Object.defineProperties()` are reported. + +## Examples + +Reported: + +{/* bad {"count": 2} */} + +```js +Array.prototype.last = function () { + return this[this.length - 1]; +}; + +Object.defineProperty(String.prototype, "shout", { + value() { + return this.toUpperCase() + "!"; + }, +}); +``` + +Not reported: + +{/* good */} + +```js +function last(items) { + return items[items.length - 1]; +} + +class Stack extends Array { + last() { + return this[this.length - 1]; + } +} + +Stack.prototype.peek = Stack.prototype.last; +``` + +## Options + +| Option | Default | What it does | +| ------------ | ------- | ---------------------------------------------- | +| `exceptions` | `[]` | Names of built-in objects that you may extend. | + +{/* good {"options": [{"exceptions": ["Array"]}]} */} + +```js +Array.prototype.last = function () { + return this[this.length - 1]; +}; +``` + +{/* bad {"options": [{"exceptions": ["Array"]}]} */} + +```js +String.prototype.shout = function () { + return this.toUpperCase() + "!"; +}; +``` + +## When not to use it + +A polyfill has to add to built-in prototypes. List what it extends in `exceptions`, or turn the rule off for that file. diff --git a/docs/lint/rules/eslint/no-extra-bind.mdx b/docs/lint/rules/eslint/no-extra-bind.mdx new file mode 100644 index 000000000000..902b93b25597 --- /dev/null +++ b/docs/lint/rules/eslint/no-extra-bind.mdx @@ -0,0 +1,60 @@ +--- +title: "no-extra-bind" +description: "Disallow `.bind()` on functions that do not use `this`" +--- + +`.bind(user)` sets what `this` is inside a function. On a function that never reads `this` it changes nothing, and on an arrow function it has no effect, because an arrow function takes `this` from where it is written. The call is usually left from a time when the function did use `this`. + +Only a function written right before the `.bind()` is checked, and only a call with one argument. + +`--fix` removes the `.bind()` call if its argument is a name, a literal or `this`. It leaves `.bind(getUser())` alone, because removing it would also remove the call to `getUser()`. + +## Examples + +Reported: + +{/* bad {"count": 2} */} + +```js +const greet = function () { + return "Hello"; +}.bind(user); + +const log = (() => { + console.log(this.name); +}).bind(user); +``` + +A `this` inside a nested `function` belongs to that function, not to the outer one: + +{/* bad */} + +```js +const setup = function () { + button.onclick = function () { + this.disabled = true; + }; +}.bind(form); +``` + +Not reported: + +{/* good */} + +```js +const greet = function () { + return `Hello ${this.name}`; +}.bind(user); + +const save = user.save.bind(user); +``` + +More arguments fill in parameters, which is useful without `this`: + +{/* good */} + +```js +const addFive = function (a, b) { + return a + b; +}.bind(null, 5); +``` diff --git a/docs/lint/rules/eslint/no-extra-boolean-cast.mdx b/docs/lint/rules/eslint/no-extra-boolean-cast.mdx new file mode 100644 index 000000000000..253cc8cd9530 --- /dev/null +++ b/docs/lint/rules/eslint/no-extra-boolean-cast.mdx @@ -0,0 +1,92 @@ +--- +title: "no-extra-boolean-cast" +description: "Disallow `!!` and `Boolean()` where the value is converted to a boolean anyway" +--- + +The test of an `if`, a loop or a `? :`, the operand of `!`, and the argument of `Boolean()` are already converted to a boolean. Writing `!!value` or `Boolean(value)` there converts twice and gives the same result. + +`--fix` removes the cast. + +This rule is on by default. + +## Examples + +Reported: + +{/* bad {"count": 3} */} + +```js +if (!!user) { + greet(user); +} + +const label = Boolean(count) ? "some" : "none"; +const isEmpty = !Boolean(items.length); +``` + +Not reported: + +{/* good */} + +```js +if (user) { + greet(user); +} + +const label = count ? "some" : "none"; +const isEmpty = !items.length; +``` + +Where you store or return the value, the cast does something, so it is allowed: + +{/* good */} + +```js +const isActive = !!user.active; +const hasItems = Boolean(items.length); +``` + +## Options + +| Option | Default | What it does | +| ---------------------------- | ------- | ----------------------------------------------------------------------------------------- | +| `enforceForInnerExpressions` | `false` | Also look inside `\|\|`, `&&`, `??`, `? :` and comma expressions that are used as a test. | +| `enforceForLogicalOperands` | `false` | The same, for `\|\|` and `&&` only. | + +Set one or the other. A config with both is rejected. + +By default only the outermost expression of a test is checked: + +{/* good */} + +```js +if (!!user || guest) { + greet(); +} +``` + +{/* bad {"options": [{"enforceForInnerExpressions": true}]} */} + +```js +if (!!user || guest) { + greet(); +} +``` + +`enforceForLogicalOperands` reports that one too. The two differ on the other operators: + +{/* bad {"options": [{"enforceForInnerExpressions": true}]} */} + +```js +if (loggedIn ? !!user : guest) { + greet(); +} +``` + +{/* good {"options": [{"enforceForLogicalOperands": true}]} */} + +```js +if (loggedIn ? !!user : guest) { + greet(); +} +``` diff --git a/docs/lint/rules/eslint/no-extra-label.mdx b/docs/lint/rules/eslint/no-extra-label.mdx new file mode 100644 index 000000000000..5bf8cd90605a --- /dev/null +++ b/docs/lint/rules/eslint/no-extra-label.mdx @@ -0,0 +1,50 @@ +--- +title: "no-extra-label" +description: "Disallow labels on `break` and `continue` that name the innermost loop or `switch`" +--- + +`break` and `continue` without a label already apply to the innermost loop or `switch`. Naming the label of that same statement changes nothing, and it sends the reader looking for an outer loop that is not there. + +`--fix` removes the label after `break` or `continue`. + +## Examples + +Reported: + +{/* bad */} + +```js +search: for (const item of items) { + if (item.id === id) { + select(item); + break search; + } +} +``` + +Not reported: + +{/* good */} + +```js +for (const item of items) { + if (item.id === id) { + select(item); + break; + } +} +``` + +A label is needed to leave an outer loop from an inner one: + +{/* good */} + +```js +rows: for (const row of table) { + for (const cell of row) { + if (cell === target) { + break rows; + } + } +} +``` diff --git a/docs/lint/rules/eslint/no-fallthrough.mdx b/docs/lint/rules/eslint/no-fallthrough.mdx new file mode 100644 index 000000000000..06599de2a9d8 --- /dev/null +++ b/docs/lint/rules/eslint/no-fallthrough.mdx @@ -0,0 +1,114 @@ +--- +title: "no-fallthrough" +description: "Disallow `case` clauses that run on into the next one" +--- + +A `case` that does not end with `break`, `return`, `throw` or `continue` keeps going into the code of the next `case`. A forgotten `break` runs code for a value it was not written for, and nothing fails at that line. + +If you want the fallthrough, say so in a comment that contains `falls through` right before the next `case`. Several `case` labels in a row that share one body are allowed. + +This rule is on by default. + +## Examples + +Reported: + +{/* bad */} + +```js +switch (post.status) { + case "draft": + publish(post); + case "published": + notify(post); + break; +} +``` + +Not reported: + +{/* good */} + +```js +switch (post.status) { + case "draft": + publish(post); + break; + case "published": + notify(post); + break; +} +``` + +{/* good */} + +```js +switch (post.status) { + case "draft": + publish(post); + // falls through + case "published": + case "pinned": + notify(post); + break; +} +``` + +## Options + +| Option | Default | What it does | +| -------------------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------- | +| `commentPattern` | `"falls?\\s?through"`, ignoring case | A regular expression for the comment that allows a fallthrough. It replaces the default. | +| `allowEmptyCase` | `false` | Allow an empty `case` with a blank line before the next one. | +| `reportUnusedFallthroughComment` | `false` | Report a fallthrough comment after a `case` that cannot fall through. | + +{/* good {"options": [{"commentPattern": "break omitted"}]} */} + +```js +switch (post.status) { + case "draft": + publish(post); + // break omitted on purpose + case "published": + notify(post); +} +``` + +An empty `case` followed by a blank line looks like its body was deleted, so it is reported unless you set `allowEmptyCase`: + +{/* bad */} + +```js +switch (post.status) { + case "draft": + + case "published": + notify(post); +} +``` + +{/* good {"options": [{"allowEmptyCase": true}]} */} + +```js +switch (post.status) { + case "draft": + + case "published": + notify(post); +} +``` + +With `reportUnusedFallthroughComment`, a comment that is no longer true is reported: + +{/* bad {"options": [{"reportUnusedFallthroughComment": true}]} */} + +```js +switch (post.status) { + case "draft": + publish(post); + break; + // falls through + case "published": + notify(post); +} +``` diff --git a/docs/lint/rules/eslint/no-func-assign.mdx b/docs/lint/rules/eslint/no-func-assign.mdx new file mode 100644 index 000000000000..a2c1f77f9520 --- /dev/null +++ b/docs/lint/rules/eslint/no-func-assign.mdx @@ -0,0 +1,58 @@ +--- +title: "no-func-assign" +description: "Disallow assigning to the name of a function declaration" +--- + +Assigning to the name of a `function` declaration replaces the function for every caller from then on. It is almost always a mistake: a typo, or a variable that happens to have the same name as a function in the file. + +The name of a function expression, such as `retry` in `const run = function retry() {}`, is checked too. + +This rule is on by default in JavaScript files. In TypeScript files it is off by default, because TypeScript reports the same mistake. + +## Examples + +Reported: + +{/* bad */} + +```js +function format(value) { + return String(value); +} + +format = value => value.toFixed(2); +``` + +{/* bad */} + +```js +const run = function retry() { + retry = null; +}; +``` + +Not reported: + +{/* good */} + +```js +let format = function (value) { + return String(value); +}; + +format = value => value.toFixed(2); +``` + +A parameter or a local variable with the same name is a different variable: + +{/* good */} + +```js +function total(items) { + let total = 0; + for (const item of items) { + total += item.price; + } + return total; +} +``` diff --git a/docs/lint/rules/eslint/no-global-assign.mdx b/docs/lint/rules/eslint/no-global-assign.mdx new file mode 100644 index 000000000000..d32c177eb6a8 --- /dev/null +++ b/docs/lint/rules/eslint/no-global-assign.mdx @@ -0,0 +1,58 @@ +--- +title: "no-global-assign" +description: "Disallow assigning to built-in and other read-only global variables" +--- + +Assigning to a global such as `Promise` or `window` replaces it for all the code that runs afterwards, including libraries. More often it is an accident: a `const` or `let` is missing, and the name you picked is already a global of the browser. + +This rule is on by default. + +## Examples + +Reported: + +{/* bad {"count": 3} */} + +```js +Promise = loadPromiseLibrary(); +window = createFakeWindow(); +status = "ready"; +``` + +Not reported: + +{/* good */} + +```js +const Promise = loadPromiseLibrary(); +let status = "ready"; +status = "done"; +``` + +A global that is declared as writable can be assigned: + +{/* good */} + +```js +/* global analytics:writable */ +analytics = createAnalytics(); +``` + +{/* bad */} + +```js +/* global analytics:readonly */ +analytics = createAnalytics(); +``` + +## Options + +| Option | Default | What it does | +| ------------ | ------- | ------------------------------------------------ | +| `exceptions` | `[]` | Names of read-only globals that may be assigned. | + +{/* good {"options": [{"exceptions": ["Promise"]}]} */} + +```js +Promise = loadPromiseLibrary(); +``` diff --git a/docs/lint/rules/eslint/no-implicit-coercion.mdx b/docs/lint/rules/eslint/no-implicit-coercion.mdx new file mode 100644 index 000000000000..868c5a0359fb --- /dev/null +++ b/docs/lint/rules/eslint/no-implicit-coercion.mdx @@ -0,0 +1,75 @@ +--- +title: "no-implicit-coercion" +description: "Require `Boolean()`, `Number()` and `String()` instead of operator tricks like `!!x` and `+x`" +--- + +`!!value`, `+value` and `"" + value` convert a value as a side effect of an operator. You have to know the trick to see that a conversion happens. `Boolean(value)`, `Number(value)` and `String(value)` say it by name. + +`--fix` rewrites `!!value` to `Boolean(value)`. For the conversions to a number or a string, the rewrite is offered as a suggestion. + +## Examples + +Reported: + +{/* bad {"count": 6} */} + +```js +const hasItems = !!items.length; +const isMember = ~names.indexOf(user.name); +const quantity = +input.value; +const price = 1 * row.price; +const id = "" + user.id; +label += ""; +``` + +Not reported: + +{/* good */} + +```js +const hasItems = Boolean(items.length); +const isMember = names.indexOf(user.name) !== -1; +const quantity = Number(input.value); +const price = Number(row.price); +const id = String(user.id); +label = String(label); +``` + +## Options + +| Option | Default | What it does | +| --------------------------- | ------- | ----------------------------------------------------------------------------- | +| `boolean` | `true` | Report `!!value` and `~list.indexOf(value)`. | +| `number` | `true` | Report `+value`, `- -value`, `1 * value` and `value - 0`. | +| `string` | `true` | Report `"" + value` and `value += ""`. | +| `disallowTemplateShorthand` | `false` | Also report a template that holds one expression and nothing else. | +| `allow` | `[]` | Operators to leave alone: any of `"~"`, `"!!"`, `"+"`, `"- -"`, `"-"`, `"*"`. | + +{/* bad {"options": [{"disallowTemplateShorthand": true}]} */} + +```js +const id = `${user.id}`; +``` + +`allow` keeps one trick while the rest of its group is still reported: + +{/* good {"options": [{"allow": ["!!"]}]} */} + +```js +const hasItems = !!items.length; +``` + +{/* bad {"options": [{"allow": ["!!"]}]} */} + +```js +const isMember = ~names.indexOf(user.name); +``` + +`"+"` stands for both `+value` and `"" + value`: + +{/* good {"options": [{"allow": ["+"]}]} */} + +```js +const quantity = +input.value; +const id = "" + user.id; +``` diff --git a/docs/lint/rules/eslint/no-implicit-globals.mdx b/docs/lint/rules/eslint/no-implicit-globals.mdx new file mode 100644 index 000000000000..9728686d4fc0 --- /dev/null +++ b/docs/lint/rules/eslint/no-implicit-globals.mdx @@ -0,0 +1,97 @@ +--- +title: "no-implicit-globals" +description: "Disallow declarations and assignments that create global variables by accident" +--- + +In a classic script, one loaded with `; +} +``` + +{/* bad */} + +```jsx +import Script from "next/script"; + +export default function Page() { + return + ; +} +``` diff --git a/docs/lint/rules/nextjs/next-script-for-ga.mdx b/docs/lint/rules/nextjs/next-script-for-ga.mdx new file mode 100644 index 000000000000..60d0a2708989 --- /dev/null +++ b/docs/lint/rules/nextjs/next-script-for-ga.mdx @@ -0,0 +1,53 @@ +--- +title: "@next/next/next-script-for-ga" +description: "Require `next/script` instead of `\n" }; const quiet = { categories: { correctness: "off" }, rules: { "no-debugger": "warn" } }; const named = (plugin: string, ...rules: string[]) => - `${rules.length} rule${rules.length > 1 ? "s" : ""} of the plugin "${plugin}" did not run: ${rules.join(", ")}. None is built in: turn them off, or take them out of the configuration`; + `${rules.length} ${rules.length > 1 ? "rules" : "rule"} in .oxlintrc.json ${rules.length > 1 ? "are" : "is"} not supported: ${rules.join(", ")}`; const ofCategories = (...plugins: string[]) => - `note: The rules of the plugin${plugins.length > 1 ? "s" : ""} "${plugins.join('" and "')}" that the categories turn on did not run. None is built in: take ${plugins.length > 1 ? "them" : "it"} out of "plugins"`; + `note: bun lint has no "${plugins.join('" or "')}" rules, so none of them ran`; const silent = { problems: found, exitCode: 0, said: [], notes: [] }; /** What is reported, the exit code, what is said about what cannot be done, and the notes. */ async function run(config: object, flags: string[] = [], more: Record = {}) { const all = { ...files, ...more, ".oxlintrc.json": JSON.stringify(config) }; const { problems, stderr, exitCode } = await lint(all, [...flags, "."]); const lines = stderr.split("\n"); - const said = lines.slice(lines.findIndex(it => it.includes("cannot be done yet")) + 1 || lines.length, -1); + const said = lines.filter(it => it.startsWith("error: ")).map(it => it.slice("error: ".length)); const notes = lines.filter(it => it.startsWith("note: ")); return { problems, exitCode, said: said.map(it => it.trim()), notes }; } @@ -4320,7 +4250,9 @@ describe.concurrent("the plugins jsdoc and vue of oxlint, of which no rule is bu { ...files, ".oxlintrc.json": JSON.stringify({ ...quiet, plugins: ["jsdoc", "vue"], rules }) }, ["--allow-unsupported", "."], ); - expect(allowed.stderr).toContain(`warn: ${named("jsdoc", "jsdoc/require-param")}`); + expect(allowed.stderr).toContain( + `warn: ${named("jsdoc", "jsdoc/require-param")}\n bun lint has no "jsdoc" rules. Turn it off or remove it.\n`, + ); expect(allowed.exitCode).toBe(0); }); diff --git a/test/cli/lint/doctor.test.ts b/test/cli/lint/doctor.test.ts new file mode 100644 index 000000000000..e146c4631e55 --- /dev/null +++ b/test/cli/lint/doctor.test.ts @@ -0,0 +1,442 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, isASAN, isDebug, tempDir } from "harness"; +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { endChildren, spawn } from "../children"; + +afterAll(endChildren); + +// Disable AI agent and CI detection regardless of the environment the tests run in. +const env = { + ...bunEnv, + AGENT: "0", + CLAUDECODE: undefined, + REPL_ID: undefined, + GITHUB_ACTIONS: undefined, + GITHUB_WORKSPACE: undefined, + NO_COLOR: undefined, + FORCE_COLOR: undefined, +}; + +async function doctor(files: Record, args: string[] = []) { + using dir = tempDir("bun-lint-doctor", files); + const list = () => (readdirSync(String(dir), { recursive: true }) as string[]).sort(); + const before = list(); + await using proc = spawn({ + cmd: [bunExe(), "lint", "--threads", "2", "--doctor", ...args], + env, + cwd: String(dir), + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const isUntouched = + Bun.deepEquals(list(), before) && + Object.entries(files).every(([name, text]) => readFileSync(join(String(dir), name), "utf8") === text); + return { stdout, stderr, exitCode, isUntouched }; +} + +/** A package with a plugin for ESLint that has `rules`, none of which reports anything. */ +function plugin(name: string, rules: string[], { isModule = false, version = "99.0.0" } = {}) { + const object = `{ meta: { name: ${JSON.stringify(name)}, version: ${JSON.stringify(version)} }, rules: { ${rules + .map(rule => `${JSON.stringify(rule)}: { create: () => ({}) }`) + .join(", ")} } }`; + return { + [`node_modules/${name}/package.json`]: JSON.stringify({ + name, + version, + main: "index.js", + type: isModule ? "module" : "commonjs", + }), + [`node_modules/${name}/index.js`]: isModule ? `export default ${object};` : `module.exports = ${object};`, + }; +} + +const dependencies = (...names: string[]) => + JSON.stringify({ name: "app", type: "module", devDependencies: Object.fromEntries(names.map(name => [name, "*"])) }); + +// A VM is started for a plugin that only a VM can describe, which takes seconds in a debug build. +const timeout = isDebug || isASAN ? 120_000 : 10_000; + +const flat = { + ...plugin("eslint-plugin-first", ["a", "b"]), + ...plugin("eslint-plugin-second", ["c"], { isModule: true }), + ...plugin("eslint-plugin-react-hooks", ["rules-of-hooks", "exhaustive-deps"]), + "package.json": dependencies( + "eslint", + "eslint-plugin-first", + "eslint-plugin-second", + "eslint-plugin-react-hooks", + "eslint-plugin-never-imported", + "typescript", + ), + "eslint.config.js": ` + import first from "eslint-plugin-first"; + import second from "eslint-plugin-second"; + import hooks from "eslint-plugin-react-hooks"; + const local = { rules: { x: { create: () => ({}) } } }; + export default [ + { + plugins: { first, second, "react-hooks": hooks, local }, + rules: { + "no-var": "error", + eqeqeq: "warn", + "no-console": "off", + "first/a": "error", + "second/c": "error", + "local/x": "warn", + "react-hooks/rules-of-hooks": "error", + indent: "error", + "no-such-rule": "error", + }, + }, + { files: ["**/*.ts"], rules: { "first/b": "error", "no-undef": "error", eqeqeq: "off" } }, + ];`, + "src/a.js": "var a = 1;\n", + "src/b.ts": "var b = 1;\n", +}; + +describe.concurrent("bun lint --doctor", () => { + test( + "with an eslint.config.js", + async () => { + const { stdout, stderr, exitCode, isUntouched } = await doctor(flat); + expect(stderr).toBe(""); + expect(stdout).toBe(`Using eslint.config.js (ESLint flat config) + + 3 files to lint + + 4 rules run natively + 4 rules run in JavaScript + eslint-plugin-first 2 + eslint-plugin-second 1 + eslint.config.js 1 + 2 rules do not run + 1 is not supported: no-such-rule + 1 is a formatting rule, use bun format: indent + +You can remove: eslint, eslint-plugin-never-imported +Still needed: eslint-plugin-first, eslint-plugin-second (their rules run in JavaScript) + eslint-plugin-react-hooks (eslint.config.js loads it) +`); + expect(isUntouched).toBe(true); + expect(exitCode).toBe(0); + }, + timeout, + ); + + test( + "-f json has the same as data", + async () => { + const { stdout, exitCode } = await doctor(flat, ["-f", "json"]); + expect(JSON.parse(stdout)).toEqual({ + config: { path: "eslint.config.js", kind: "eslint", nested: [] }, + files: { linted: 3, by_eslint: {}, not_linted: {} }, + rules: { + native: ["eqeqeq", "no-undef", "no-var", "react-hooks/rules-of-hooks"], + javascript: { + "eslint-plugin-first": ["first/a", "first/b"], + "eslint-plugin-second": ["second/c"], + "eslint.config.js": ["local/x"], + }, + not_run: [ + { rule: "no-such-rule", reason: "not_supported" }, + { rule: "indent", reason: "formatting" }, + ], + }, + packages: { + removable: ["eslint", "eslint-plugin-never-imported"], + needed: [ + { name: "eslint-plugin-first", reason: "rules" }, + { name: "eslint-plugin-react-hooks", reason: "config" }, + { name: "eslint-plugin-second", reason: "rules" }, + ], + }, + notes: [], + }); + expect(exitCode).toBe(0); + }, + timeout, + ); + + test( + "counts the rules of the files that the arguments select", + async () => { + const { stdout, exitCode } = await doctor(flat, ["-f", "json", "src/a.js"]); + const { files, rules } = JSON.parse(stdout); + expect(files.linted).toBe(1); + expect(rules.native).toEqual(["eqeqeq", "no-var", "react-hooks/rules-of-hooks"]); + expect(rules.javascript["eslint-plugin-first"]).toEqual(["first/a"]); + expect(exitCode).toBe(0); + }, + timeout, + ); + + test( + "--no-native-plugin-rules sends the rules of a plugin to its package", + async () => { + const { stdout, exitCode } = await doctor(flat, ["-f", "json", "--no-native-plugin-rules"]); + const { rules, packages } = JSON.parse(stdout); + expect(rules.native).toEqual(["eqeqeq", "no-undef", "no-var"]); + expect(rules.javascript["eslint-plugin-react-hooks"]).toEqual(["react-hooks/rules-of-hooks"]); + expect(packages.needed).toContainEqual({ name: "eslint-plugin-react-hooks", reason: "rules" }); + expect(exitCode).toBe(0); + }, + timeout, + ); + + test( + "with an .oxlintrc.json", + async () => { + const { stdout, stderr, exitCode, isUntouched } = await doctor({ + ...plugin("eslint-plugin-first", ["a", "b"]), + "package.json": dependencies("oxlint", "oxlint-tsgolint", "eslint-plugin-first"), + ".oxlintrc.json": JSON.stringify({ + categories: { correctness: "off" }, + ignorePatterns: ["node_modules"], + plugins: ["typescript", "unicorn", "vue"], + jsPlugins: ["eslint-plugin-first"], + rules: { + "no-var": "error", + "unicorn/no-null": "warn", + "first/a": "error", + "typescript/no-floating-promises": "error", + "vue/no-arrow-functions-in-watch": "error", + }, + }), + "a.ts": "var a = null;\n", + }); + expect(stderr).toBe(""); + expect(stdout).toBe(`Using .oxlintrc.json (oxlint config) + + 1 file to lint + + 2 rules run natively + 1 rule runs in JavaScript + eslint-plugin-first 1 + 2 rules do not run + 1 is not supported: vue/no-arrow-functions-in-watch + 1 needs types, use --type-aware: @typescript-eslint/no-floating-promises + +You can remove: oxlint, oxlint-tsgolint +Still needed: eslint-plugin-first (its rules run in JavaScript) +`); + expect(isUntouched).toBe(true); + expect(exitCode).toBe(0); + }, + timeout, + ); + + test("of the rules that lose nothing it names a few", async () => { + const files = { + "eslint.config.js": `export default [{ rules: { indent: 2, semi: 2, quotes: 2, "comma-dangle": 2, "no-such-rule": 2, "nor-this": 2, "nor-that": 2, "nor-any": 2 } }];`, + }; + const [text, json] = await Promise.all([doctor(files), doctor(files, ["-f", "json"])]); + expect(text.stdout).toContain(` + 8 rules do not run + 4 are not supported: no-such-rule, nor-any, nor-that, nor-this + 4 are formatting rules, use bun format: comma-dangle, indent, quotes, .. +`); + expect(JSON.parse(json.stdout).rules.not_run).toContainEqual({ rule: "semi", reason: "formatting" }); + expect(text.exitCode).toBe(0); + }); + + test("the rules for React are on in a project that depends on it", async () => { + const native = async (dependency: string) => { + const files = { "package.json": dependencies(dependency), "a.jsx": "" }; + return JSON.parse((await doctor(files, ["-f", "json"])).stdout).rules.native; + }; + const [withReact, without] = await Promise.all([native("react"), native("preact")]); + expect(withReact).toContain("react-hooks/rules-of-hooks"); + expect(without).not.toContain("react-hooks/rules-of-hooks"); + expect(withReact.length).toBeGreaterThan(without.length); + }); + + test("--type-aware runs the rules that need types", async () => { + const { stdout, exitCode } = await doctor( + { + ".oxlintrc.json": JSON.stringify({ + categories: { correctness: "off" }, + rules: { "typescript/no-floating-promises": "error" }, + }), + "tsconfig.json": "{}", + "a.ts": "export {};\n", + }, + ["-f", "json", "--type-aware"], + ); + const { rules } = JSON.parse(stdout); + expect(rules).toEqual({ native: ["@typescript-eslint/no-floating-promises"], javascript: {}, not_run: [] }); + expect(exitCode).toBe(0); + }); + + test( + "with an .eslintrc.json", + async () => { + const { stdout, exitCode } = await doctor( + { + ...plugin("eslint-plugin-first", ["a", "b"]), + "package.json": dependencies("eslint", "eslint-plugin-first"), + ".eslintrc.json": JSON.stringify({ + root: true, + plugins: ["first"], + rules: { "no-var": "error", "first/a": "error", semi: "error" }, + }), + "a.js": "var a = 1;\n", + }, + ["-f", "json"], + ); + const { config, rules, packages } = JSON.parse(stdout); + expect(config).toEqual({ path: ".eslintrc.json", kind: "eslintrc", nested: [] }); + expect(rules).toEqual({ + native: ["no-var"], + javascript: { "eslint-plugin-first": ["first/a"] }, + not_run: [{ rule: "semi", reason: "formatting" }], + }); + expect(packages).toEqual({ removable: ["eslint"], needed: [{ name: "eslint-plugin-first", reason: "rules" }] }); + expect(exitCode).toBe(0); + }, + timeout, + ); + + test( + "says what a run says about the versions of plugins", + async () => { + const files = { ...flat, ...plugin("eslint-plugin-react-hooks", ["rules-of-hooks"], { version: "1.0.0" }) }; + const [text, json] = await Promise.all([doctor(files), doctor(files, ["-f", "json"])]); + const note = /^eslint-plugin-react-hooks 1\.0\.0 is installed; bun lint follows [\d.]+\.$/; + expect(JSON.parse(json.stdout).notes).toEqual([expect.stringMatching(note)]); + expect(text.stdout).toEndWith(`\n\nnote: ${JSON.parse(json.stdout).notes[0]}\n`); + expect(text.exitCode).toBe(0); + }, + timeout, + ); + + test("files that only the parser of the config reads go to the installed ESLint", async () => { + const files = { + "package.json": dependencies("eslint"), + "eslint.config.js": ` + const parser = { meta: { name: "my-parser" }, parse() {} }; + export default [{ rules: { "no-var": "error" } }, { files: ["**/*.foo"], languageOptions: { parser } }];`, + "a.js": "", + "b.foo": "", + "c.foo": "", + }; + const eslint = { "node_modules/eslint/package.json": JSON.stringify({ name: "eslint", version: "10.0.0" }) }; + const [installed, missing] = await Promise.all([doctor({ ...files, ...eslint }), doctor(files)]); + expect(installed.stdout).toBe(`Using eslint.config.js (ESLint flat config) + + 4 files to lint + 2 of them go to your installed ESLint: 2 *.foo + + 1 rule runs natively + 0 rules run in JavaScript + +Still needed: eslint (some files go to it) +`); + expect(missing.stdout).toBe(`Using eslint.config.js (ESLint flat config) + + 2 files to lint + 2 files cannot be linted: 2 *.foo + + 1 rule runs natively + 0 rules run in JavaScript + +You can remove: eslint +`); + expect(installed.exitCode).toBe(0); + expect(missing.exitCode).toBe(0); + }); + + test("names the config files further down", async () => { + const { stdout, exitCode } = await doctor({ + "eslint.config.js": `export default [{ rules: { "no-var": "error" } }];`, + "packages/a/eslint.config.js": `export default [{ rules: { eqeqeq: "error" } }];`, + "a.js": "", + "packages/a/b.js": "", + }); + expect(stdout).toStartWith(`Using eslint.config.js (ESLint flat config) +Also packages/a/eslint.config.js + + 4 files to lint + + 2 rules run natively + 0 rules run in JavaScript +`); + expect(exitCode).toBe(0); + }); + + test("without a config file", async () => { + const { stdout, stderr, exitCode, isUntouched } = await doctor({ + "package.json": dependencies("typescript"), + "a.ts": "var a = 1;\n", + }); + expect(stderr).toBe(""); + expect(stdout).toStartWith("No ESLint or oxlint config found. Using Bun's defaults.\n\n 1 file to lint\n\n"); + expect(stdout).toMatch(/^ +\d+ rules are on\n( {8}\S+ +\d+\n)*? {8}eslint +\d+\n/m); + expect(stdout).toMatch(/^ {8}@typescript-eslint +\d+$/m); + expect(stdout).toMatch(/^ {8}bun:test +\d+$/m); + expect(stdout).not.toContain("JavaScript"); + expect(stdout).toEndWith("\nRun bun lint --init to add a [lint] section to bunfig.toml\n"); + expect(isUntouched).toBe(true); + expect(exitCode).toBe(0); + }); + + test("without a config file, [lint.rules] of bunfig.toml count", async () => { + const files = { "a.js": "" }; + const [plain, configured] = await Promise.all([ + doctor(files, ["-f", "json"]), + doctor({ ...files, "bunfig.toml": `[lint.rules]\n"no-console" = "warn"\n"no-debugger" = "off"\n` }, [ + "-f", + "json", + ]), + ]); + const [before, after] = [JSON.parse(plain.stdout), JSON.parse(configured.stdout)]; + expect(before.config).toEqual({ path: null, kind: "none", nested: [] }); + expect(after.config).toEqual(before.config); + expect(before.rules.native).toContain("no-debugger"); + expect(before.rules.native).not.toContain("no-console"); + expect(after.rules.native).toEqual( + [...before.rules.native.filter((it: string) => it !== "no-debugger"), "no-console"].sort(), + ); + expect(configured.exitCode).toBe(0); + }); + + test("without any file to lint it says what a file would have", async () => { + const { stdout, exitCode } = await doctor({ "README.md": "" }, ["-f", "json"]); + const { files, rules } = JSON.parse(stdout); + expect(files.linted).toBe(0); + expect(rules.native).toContain("no-debugger"); + expect(rules.native).toContain("@typescript-eslint/no-explicit-any"); + expect(exitCode).toBe(0); + }); + + test("a config file that cannot be read is an error", async () => { + const { stdout, stderr, exitCode } = await doctor({ + "eslint.config.js": `throw new Error("broken on purpose");`, + "a.js": "", + }); + expect(stdout).toBe(""); + expect(stderr).toContain("broken on purpose"); + expect(exitCode).toBe(2); + }); + + test("a config that ESLint refuses is an error", async () => { + const { stdout, stderr, exitCode } = await doctor({ + "eslint.config.js": `export default [{ rules: { eqeqeq: ["error", "sometimes"] } }];`, + "a.js": "", + }); + expect(stdout).toBe(""); + expect(stderr).toContain(`Key "rules": Key "eqeqeq"`); + expect(exitCode).toBe(2); + }); + + test("the lint script of package.json does not take its place", async () => { + const { stdout, exitCode } = await doctor({ + "package.json": JSON.stringify({ scripts: { lint: "echo the script" } }), + "a.js": "", + }); + expect(stdout).toStartWith("No ESLint or oxlint config found."); + expect(exitCode).toBe(0); + }); +}); diff --git a/test/cli/lint/git.test.ts b/test/cli/lint/git.test.ts new file mode 100644 index 000000000000..1fddae084865 --- /dev/null +++ b/test/cli/lint/git.test.ts @@ -0,0 +1,411 @@ +// `--staged` and `--changed` of `bun lint` and `bun format`. +import { afterAll, describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + realpathSync, + renameSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { join, relative, resolve } from "node:path"; +import { endChildren, spawn } from "../children"; + +afterAll(endChildren); + +// In place of the machine's own configuration. +const machine = tempDir("bun-lint-git-config", { "gitconfig": "" }); +afterAll(() => machine[Symbol.dispose]()); + +const env = { + ...bunEnv, + AGENT: "0", + CLAUDECODE: undefined, + REPL_ID: undefined, + GITHUB_ACTIONS: undefined, + NO_COLOR: "1", + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: join(String(machine), "gitconfig"), + GIT_AUTHOR_NAME: "Test", + GIT_AUTHOR_EMAIL: "test@example.com", + GIT_COMMITTER_NAME: "Test", + GIT_COMMITTER_EMAIL: "test@example.com", +}; + +type Env = Record; + +async function run(cmd: string[], cwd: string, more: Env = {}) { + await using proc = spawn({ cmd, cwd, env: { ...env, ...more }, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; +} + +const bun = (cwd: string, args: string[], more?: Env) => run([bunExe(), ...args], cwd, more); + +async function git(cwd: string, ...args: string[]) { + const { stdout, stderr, exitCode } = await run(["git", ...args], cwd); + if (exitCode !== 0) throw new Error(`git ${args.join(" ")}: ${stderr}`); + return stdout; +} + +/** A repository on `main`, with `files` in its first commit if there are any. */ +async function repository(files: Record) { + const dir = tempDir("bun-lint-git", files); + const root = realpathSync(String(dir)); + await git(root, "init", "-q", "-b", "main"); + await git(root, "config", "commit.gpgsign", "false"); + await git(root, "config", "core.autocrlf", "false"); + if (Object.keys(files).length > 0) await commit(root); + return { root, [Symbol.dispose]: () => dir[Symbol.dispose]() }; +} + +async function commit(root: string) { + await git(root, "add", "-A"); + await git(root, "commit", "-q", "-m", "a commit"); +} + +const write = (root: string, name: string, text: string) => { + mkdirSync(join(root, name, ".."), { recursive: true }); + writeFileSync(join(root, name), text); +}; +const read = (root: string, name: string) => readFileSync(join(root, name), "utf8"); + +/** The files of a run, from `root`. */ +async function selected(root: string, cwd: string, args: string[], more?: Env) { + const { stdout, stderr, exitCode } = await bun(cwd, [...args, "--list-files"], more); + expect({ exitCode, stderr: exitCode === 0 ? "" : stderr }).toEqual({ exitCode: 0, stderr: "" }); + const names = stdout.split("\n").filter(it => it !== ""); + return names.map(it => relative(root, resolve(cwd, it)).replaceAll("\\", "/")).sort(); +} + +const commands = ["lint", "format"] as const; +// What `--fix` and `bun format` change, and what they make of it. +const [before, after] = ["export var a = 1;\n", { lint: "export let a = 1;\n", format: "export var a = 1;\n" }]; +const writing = { lint: ["lint", "--fix", "--rule", "no-var: error"], format: ["format"] }; +const tidy = "export const a = 1;\n"; + +describe.concurrent.each(commands)("bun %s", command => { + test("--staged takes what is in the index and differs from HEAD", async () => { + using repo = await repository({ + "modified.js": tidy, + "unstaged.js": tidy, + "same.js": tidy, + "deleted.js": tidy, + "old-name.js": "export const renamed = 1;\n", + "sub/modified.js": tidy, + }); + const { root } = repo; + const odd = isWindows ? "ünï côde.js" : 'ünï "cô\tde".js'; + for (const name of ["modified.js", "sub/modified.js", "added.js", odd]) write(root, name, before); + rmSync(join(root, "deleted.js")); + renameSync(join(root, "old-name.js"), join(root, "new-name.js")); + await git(root, "add", "-A"); + write(root, "unstaged.js", before); + write(root, "untracked.js", before); + + const all = ["added.js", "modified.js", "new-name.js", "sub/modified.js", odd].sort(); + expect(await selected(root, root, [command, "--staged"])).toEqual(all); + // Below the top: the arguments are from there, the names of git are not. + expect(await selected(root, join(root, "sub"), [command, "--staged"])).toEqual(["sub/modified.js"]); + expect(await selected(root, join(root, "sub"), [command, "--staged", ".."])).toEqual(all); + // Whatever the repository says about how names are printed. + await git(root, "config", "diff.relative", "true"); + await git(root, "config", "diff.renames", "copies"); + expect(await selected(root, join(root, "sub"), [command, "--staged", ".."])).toEqual(all); + }); + + test("--changed takes what differs from where the branch left the default branch, and what is new", async () => { + using repo = await repository({ + "a.js": tidy, + "b.js": tidy, + "c.js": tidy, + "d.js": tidy, + ".gitignore": "ignored.js\n", + }); + const { root } = repo; + await git(root, "checkout", "-q", "-b", "feature"); + write(root, "a.js", before); + await commit(root); + // The default branch goes on. + await git(root, "checkout", "-q", "main"); + write(root, "d.js", before); + await commit(root); + await git(root, "checkout", "-q", "feature"); + write(root, "b.js", before); + await git(root, "add", "b.js"); + write(root, "c.js", before); + write(root, "sub/untracked.js", before); + write(root, "ignored.js", before); + + const all = ["a.js", "b.js", "c.js", "sub/untracked.js"]; + expect(await selected(root, root, [command, "--changed"])).toEqual(all); + expect(await selected(root, join(root, "sub"), [command, "--changed", ".."])).toEqual(all); + expect(await selected(root, root, [command, "--changed=HEAD"])).toEqual(["b.js", "c.js", "sub/untracked.js"]); + expect(await selected(root, root, [command, "--changed=main"])).toEqual([...all, "d.js"].sort()); + await git(root, "tag", "-a", "-m", "a tag", "v1", "main~1"); + expect(await selected(root, root, [command, "--changed=v1"])).toEqual(all); + // What follows is a file. + expect(await selected(root, root, [command, "--changed", "a.js", "d.js"])).toEqual(["a.js"]); + }); + + test("--changed prefers origin/HEAD, then main, then master, and says what it tried", async () => { + using repo = await repository({ "a.js": tidy, "b.js": tidy, "c.js": tidy }); + const { root } = repo; + const first = (await git(root, "rev-parse", "HEAD")).trim(); + write(root, "a.js", before); + await commit(root); + await git(root, "branch", "master"); + write(root, "b.js", before); + await commit(root); + await git(root, "checkout", "-q", "-b", "feature"); + write(root, "c.js", before); + await commit(root); + + expect(await selected(root, root, [command, "--changed"])).toEqual(["c.js"]); + await git(root, "branch", "-q", "-D", "main"); + expect(await selected(root, root, [command, "--changed"])).toEqual(["b.js", "c.js"]); + await git(root, "update-ref", "refs/remotes/origin/trunk", first); + await git(root, "symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/trunk"); + expect(await selected(root, root, [command, "--changed"])).toEqual(["a.js", "b.js", "c.js"]); + + await git(root, "symbolic-ref", "-d", "refs/remotes/origin/HEAD"); + await git(root, "branch", "-q", "-D", "master"); + const { stdout, stderr, exitCode } = await bun(root, [command, "--changed"]); + expect(stderr).toContain("--changed cannot find the default branch"); + expect(stderr).toContain("It tried origin/HEAD, main and master. Name a branch or a commit with --changed="); + expect({ stdout, exitCode }).toEqual({ stdout: "", exitCode: 2 }); + }); + + test("--changed= takes one commit, and nothing that git could read as something else", async () => { + using repo = await repository({ "a.js": tidy }); + const { root } = repo; + write(root, "a.js", before); + for (const written of ["nothing", "--output=written.txt", "-O", "HEAD..HEAD", "HEAD HEAD", "HEAD:a.js"]) { + const { stdout, stderr, exitCode } = await bun(root, [command, `--changed=${written}`]); + expect(stderr).toContain(`"${written}" is not a branch, a tag or a commit of this repository`); + expect({ stdout, exitCode }).toEqual({ stdout: "", exitCode: 2 }); + } + expect(existsSync(join(root, "written.txt"))).toBe(false); + expect(read(root, "a.js")).toBe(before); + }); + + test("they only narrow what the arguments and the ignore files select", async () => { + using repo = await repository({ + "src/a.js": tidy, + "lib/a.js": tidy, + "lib/tracked-and-ignored.js": tidy, + "a.txt": "a\n", + }); + const { root } = repo; + for (const name of ["src/a.js", "lib/a.js", "lib/tracked-and-ignored.js", "a.txt", "node_modules/a/a.js"]) { + write(root, name, before); + } + write(root, ".gitignore", "tracked-and-ignored.js\n"); + await git(root, "add", "-A", "-f"); + + const without = await selected(root, root, [command]); + for (const flag of ["--staged", "--changed"]) { + expect(await selected(root, root, [command, flag])).toEqual(without); + expect(await selected(root, root, [command, flag, "src"])).toEqual(["src/a.js"]); + expect(await selected(root, root, [command, flag, "lib/*.js"])).toEqual(["lib/a.js"]); + expect(await selected(root, root, [command, flag, "--ignore-pattern", "lib"])).toEqual(["src/a.js"]); + // A name that is mistyped is still one. + const { exitCode } = await bun(root, [command, flag, "scr"]); + expect(exitCode).toBe(2); + } + expect(without).toEqual(["lib/a.js", "src/a.js"]); + }); + + test("a run that is left without a file says so, and succeeds", async () => { + using repo = await repository({ "a.js": before, "b.js": tidy }); + const { root } = repo; + expect(await bun(root, [command, "--staged"])).toEqual({ + stdout: "", + stderr: `No staged files to ${command}\n`, + exitCode: 0, + }); + expect(await bun(root, [command, "--changed"])).toEqual({ + stdout: "", + stderr: `No changed files to ${command}\n`, + exitCode: 0, + }); + // There are some, and the argument is another file. + write(root, "b.js", before); + await git(root, "add", "b.js"); + const { stderr, exitCode } = await bun(root, [command, "--staged", "a.js"]); + expect({ stderr, exitCode }).toEqual({ stderr: `No staged files to ${command}\n`, exitCode: 0 }); + expect(read(root, "a.js")).toBe(before); + }); + + test("a repository without a commit", async () => { + using repo = await repository({}); + const { root } = repo; + write(root, "staged.js", before); + await git(root, "add", "-A"); + write(root, "untracked.js", before); + expect(await selected(root, root, [command, "--staged"])).toEqual(["staged.js"]); + const { stdout, stderr, exitCode } = await bun(root, [command, "--changed"]); + expect(stderr).toContain("--changed has nothing to compare with, because this repository has no commits"); + expect(stderr).toContain("Use --staged for the files that you have added"); + expect({ stdout, exitCode }).toEqual({ stdout: "", exitCode: 2 }); + }); + + test.each(["--staged", "--changed"])("%s where there is no repository, or no git", async flag => { + using dir = tempDir("bun-lint-git", { "a.js": before, "empty/.keep": "" }); + const root = realpathSync(String(dir)); + const outside = await bun(root, [command, flag], { GIT_CEILING_DIRECTORIES: join(root, "..") }); + expect(outside.stderr).toContain(`${flag} needs a git repository`); + expect(outside.stderr).toMatch(/\n.* fatal: not a git repository/); + expect({ stdout: outside.stdout, exitCode: outside.exitCode }).toEqual({ stdout: "", exitCode: 2 }); + + const without = await bun(root, [command, flag], { PATH: join(root, "empty"), Path: undefined }); + expect(without.stderr).toContain("Cannot find git in PATH"); + expect({ stdout: without.stdout, exitCode: without.exitCode }).toEqual({ stdout: "", exitCode: 2 }); + expect(read(root, "a.js")).toBe(before); + }); + + test("flags that do not go together", async () => { + using repo = await repository({ "a.js": tidy }); + const together = await bun(repo.root, [command, "--staged", "--changed"]); + expect(together.stderr).toContain("--staged and --changed cannot be used together"); + expect(together.exitCode).toBe(2); + const stdin = command === "lint" ? ["--stdin"] : ["--stdin-filepath", "a.js"]; + const piped = await bun(repo.root, [command, "--staged", ...stdin]); + expect(piped.stderr).toContain(`--staged and ${stdin[0]} cannot be used together`); + expect(piped.exitCode).toBe(2); + }); + + test("a link is not the file that it leads to, and a repository in the repository is another one", async () => { + using repo = await repository({ "a.js": tidy }); + const { root } = repo; + write(root, "inner/a.js", tidy); + await git(join(root, "inner"), "init", "-q", "-b", "main"); + await commit(join(root, "inner")); + write(root, "inner/a.js", before); + await git(join(root, "inner"), "add", "-A"); + if (!isWindows) symlinkSync("a.js", join(root, "link.js")); + write(root, "b.js", before); + await git(root, "add", "-A"); + + for (const flag of ["--staged", "--changed"]) { + expect(await selected(root, root, [command, flag])).toEqual(["b.js"]); + expect(await selected(root, join(root, "inner"), [command, flag])).toEqual(["inner/a.js"]); + } + }); + + test("a file that is written and was staged as a whole is staged again", async () => { + using repo = await repository({ "a.js": tidy, "sub/b.js": tidy, "c.js": tidy }); + const { root } = repo; + // A name is no pattern: that one would stage c.js too. + const names = [...(isWindows ? [] : ["*.js"]), "a.js", "new.js", "sub/b.js"]; + for (const name of names) write(root, name, before); + await git(root, "add", "-A"); + write(root, "c.js", before); + + const { stderr, exitCode } = await bun(join(root, "sub"), [...writing[command], "--staged", ".."]); + expect({ exitCode, stderr: exitCode === 0 ? "" : stderr }).toEqual({ exitCode: 0, stderr: "" }); + expect(await git(root, "diff", "--name-only")).toBe("c.js\n"); + expect((await git(root, "diff", "--name-only", "--cached")).split("\n").filter(Boolean)).toEqual(names); + for (const name of names) expect(await git(root, "show", `:${name}`)).toBe(after[command]); + expect(read(root, "c.js")).toBe(before); + }); + + test("--changed stages nothing", async () => { + using repo = await repository({ "a.js": tidy }); + const { root } = repo; + write(root, "a.js", before); + await git(root, "add", "-A"); + const { exitCode } = await bun(root, [...writing[command], "--changed"]); + expect(read(root, "a.js")).toBe(after[command]); + expect(await git(root, "status", "--porcelain")).toBe("MM a.js\n"); + expect(exitCode).toBe(0); + }); + + test("a file with staged and unstaged changes is not written", async () => { + using repo = await repository({ "partly.js": tidy, "sub/partly.js": tidy, "whole.js": tidy, "tidy.js": tidy }); + const { root } = repo; + for (const name of ["partly.js", "whole.js"]) write(root, name, before); + write(root, "tidy.js", tidy + tidy.replace("a", "b")); + await git(root, "add", "-A"); + const more = before + "export const unstaged = 1;\n"; + write(root, "partly.js", more); + // It has nothing to write, so there is nothing to refuse. + write(root, "tidy.js", tidy); + + const one = await bun(root, [...writing[command], "--staged"]); + expect(one.stderr).toContain("partly.js has both staged and unstaged changes, so it was not written"); + expect(one.stderr).toContain( + "Stage the rest with git add, or put it away with git stash --keep-index. Then run this again.", + ); + expect(one.stderr).not.toContain("tidy.js"); + expect(one.exitCode).toBe(2); + expect(read(root, "partly.js")).toBe(more); + expect(await git(root, "status", "--porcelain")).toBe("MM partly.js\nMM tidy.js\nM whole.js\n"); + expect(await git(root, "show", ":whole.js")).toBe(after[command]); + + write(root, "sub/partly.js", before); + await git(root, "add", "sub"); + write(root, "sub/partly.js", more); + const two = await bun(join(root, "sub"), [...writing[command], "--staged", ".."]); + expect(two.stderr).toMatch( + /2 files have both staged and unstaged changes, so they were not written\n.* \.\.\/partly\.js\n.* partly\.js\n/, + ); + expect(two.exitCode).toBe(2); + expect(read(root, "sub/partly.js")).toBe(more); + + // A run that writes nothing has nothing to refuse. + const looking = command === "lint" ? ["lint", "--rule", "no-var: error"] : ["format", "--check"]; + const three = await bun(root, [...looking, "--staged"]); + expect(three.stderr).not.toContain("unstaged"); + expect(three.exitCode).toBe(1); + }); + + test.skipIf(isWindows)("in a hook, for the index of the commit", async () => { + using repo = await repository({ "a.js": tidy, "b.js": tidy }); + const { root } = repo; + const hook = join(root, ".git/hooks/pre-commit"); + const words = [bunExe(), ...writing[command], "--staged"].map(it => `'${it}'`).join(" "); + writeFileSync(hook, `#!/bin/sh\nexec ${words}\n`); + chmodSync(hook, 0o755); + + write(root, "a.js", before); + await git(root, "add", "a.js"); + await git(root, "commit", "-q", "-m", "what is staged"); + expect(await git(root, "show", "HEAD:a.js")).toBe(after[command]); + // The index of such a commit is a file of its own. + write(root, "b.js", before); + await git(root, "commit", "-q", "-a", "-m", "all"); + expect(await git(root, "show", "HEAD:b.js")).toBe(after[command]); + expect(await git(root, "status", "--porcelain")).toBe(""); + }); + + test.skipIf(isWindows)("git starts no program of the repository's configuration", async () => { + using repo = await repository({ "a.js": tidy, "b.js": tidy, ".gitattributes": "*.js diff=mine\n" }); + const { root } = repo; + const program = join(root, "program.sh"); + writeFileSync(program, `#!/bin/sh\necho ran >> '${join(root, "ran.txt")}'\n`); + chmodSync(program, 0o755); + for (const key of ["diff.external", "diff.mine.textconv", "diff.mine.command", "core.pager", "pager.diff"]) { + await git(root, "config", key, program); + } + write(root, "a.js", before); + await git(root, "add", "a.js"); + write(root, "b.js", before); + const more = { GIT_EXTERNAL_DIFF: program, GIT_PAGER: program, PAGER: program }; + expect(await selected(root, root, [command, "--staged"], more)).toEqual(["a.js"]); + expect(await selected(root, root, [command, "--changed"], more)).toEqual(["a.js", "b.js"]); + const { exitCode } = await bun(root, [...writing[command], "--staged"], more); + expect(existsSync(join(root, "ran.txt"))).toBe(false); + expect(exitCode).toBe(0); + }); +}); + +test.concurrent("bun lint --staged prints what its format prints for no file", async () => { + using repo = await repository({ "a.js": before }); + const { stdout, stderr, exitCode } = await bun(repo.root, ["lint", "--staged", "-f", "json"]); + expect({ stdout, stderr, exitCode }).toEqual({ stdout: "[]\n", stderr: "No staged files to lint\n", exitCode: 0 }); +}); diff --git a/test/cli/lint/help.test.ts b/test/cli/lint/help.test.ts new file mode 100644 index 000000000000..afe74db823f1 --- /dev/null +++ b/test/cli/lint/help.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "fs"; +import { bunEnv, bunExe, tempDir } from "harness"; +import { join } from "path"; + +const root = join(import.meta.dir, "../../.."); + +async function runIn(files: Record, ...args: string[]) { + using dir = tempDir("bun-lint-help", files); + await using proc = Bun.spawn({ cmd: [bunExe(), ...args], env: bunEnv, cwd: String(dir), stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; +} + +const run = (...args: string[]) => runIn({}, ...args); + +/** The flags that a help lists, by their long names, and what it says about each. */ +const listed = (help: string): Record => + Object.fromEntries([...help.matchAll(/^ {2}(?:-\w,| {3}) --([\w-]+)(?:=)? +(.+)$/gm)].map(it => [it[1], it[2]])); + +describe.concurrent.each([ + { + command: "lint", + source: "src/lint/driver/cli.rs", + headings: [ + "Rules", + "Files", + "Output", + "Fixing", + "Types", + "Other", + "Compatibility with ESLint", + "Compatibility with oxlint", + ], + }, + { + command: "format", + source: "src/lint/driver/fmt/cli.rs", + headings: ["Options", "Files", "Output", "Other", "Compatibility with Prettier", "Compatibility with oxfmt"], + }, +])("bun $command", ({ command, source, headings }) => { + test("--help is short", async () => { + const { stdout, stderr, exitCode } = await run(command, "--help"); + const lines = stdout.trimEnd().split("\n"); + expect(lines[0]).toBe(`Usage: bun ${command} [flags] [...files, directories or patterns]`); + expect(stdout.match(/^\S.*:$/gm)).toEqual(["Flags:", "Examples:"]); + expect(lines.filter(line => line.startsWith(` bun ${command}`))).toHaveLength(3); + expect(lines.slice(-2)).toEqual([ + `Full documentation is available at https://bun.com/docs/runtime/${command}`, + `See every flag with bun ${command} --help --all`, + ]); + expect(lines.length).toBeLessThanOrEqual(25); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); + + test("--help --all has every flag that the command takes", async () => { + const { stdout, stderr, exitCode } = await run(command, "--help", "--all"); + expect(stdout.match(/^\S.*:$/gm)).toEqual([...headings.map(heading => `${heading}:`), "Examples:"]); + const params = readFileSync(join(root, source), "utf8").matchAll(/clap::param!\(\s*"(?:-\w, )?(--[^\s"]+)/g); + const taken = [...params].flatMap(it => it[1].split("/")); + expect(taken.length).toBeGreaterThan(70); + const mentioned = new Set(stdout.match(/--[\w-]+/g)); + // `--no-semi` stands for `--semi` too. + const missing = taken.filter(flag => !mentioned.has(flag) && !mentioned.has(flag.replace("--", "--no-"))); + expect(missing).toEqual([]); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); + + test("--help says about a flag what --help --all says", async () => { + const [short, all] = await Promise.all([run(command, "--help"), run(command, "--help", "--all")]); + expect(Object.keys(listed(short.stdout)).length).toBeGreaterThan(5); + expect(listed(all.stdout)).toMatchObject(listed(short.stdout)); + }); + + test.each([["--help-all"], ["--all", "--help"], ["-h", "--all"]])("%s %s is --help --all", async (...args) => { + const [one, other] = await Promise.all([run(command, ...args), run(command, "--help", "--all")]); + expect(one).toEqual(other); + }); + + test.each(["--help", "-h", "--help-all"])("%s is not for a script of that name in package.json", async flag => { + const files = { "package.json": JSON.stringify({ scripts: { [command]: "echo the script" } }) }; + const [here, elsewhere] = await Promise.all([runIn(files, command, flag), run(command, flag)]); + expect(here.stdout).toBe( + `${elsewhere.stdout} +note: package.json has a "${command}" script, so bun ${command} runs that script + Change the script to "bun ${command}" to use the built-in command +`, + ); + expect(here.exitCode).toBe(0); + }); + + test("-- --help is for the script", async () => { + const files = { "package.json": JSON.stringify({ scripts: { [command]: "echo the script" } }) }; + const { stdout, exitCode } = await runIn(files, command, "--", "--help"); + expect(stdout).toBe("the script --help\n"); + expect(exitCode).toBe(0); + }); + + test("the shell completions have the flags of --help --all", async () => { + const { stdout } = await run(command, "--help", "--all"); + const flags = Object.entries(listed(stdout)) + .filter(([, description]) => !/^(No effect|Not supported)/.test(description)) + .map(([flag]) => flag) + .sort(); + const read = (shell: string) => readFileSync(join(root, "completions", `bun.${shell}`), "utf8"); + const bash = read("bash").match(new RegExp(`^ +${command}\\)\\n.*-W "(.+)" --`, "m"))![1]; + const zsh = read("zsh").match(new RegExp(`^_bun_${command}_completion\\(\\) \\{\\n(.+?)^\\}`, "ms"))![1]; + const fish = read("fish").matchAll(new RegExp(`__fish_seen_subcommand_from ${command}".* -l "([\\w-]+)"`, "g")); + expect({ + bash: [...bash.matchAll(/--([\w-]+)/g)].map(it => it[1]).sort(), + zsh: [...zsh.matchAll(/^ +'--([\w-]+)\[/gm)].map(it => it[1]).sort(), + // It has these two for every command. + fish: [...fish, ["", "help"], ["", "version"]].map(it => it[1]).sort(), + }).toEqual({ bash: flags, zsh: flags, fish: flags }); + }); +}); + +test("bun format --all is refused without --help", async () => { + const { stdout, stderr, exitCode } = await run("format", "--all"); + expect(stderr).toContain("error: --all has no effect without --help"); + expect(stdout).toBe(""); + expect(exitCode).toBe(2); +}); diff --git a/test/cli/lint/hints.test.ts b/test/cli/lint/hints.test.ts new file mode 100644 index 000000000000..b089adcc75c9 --- /dev/null +++ b/test/cli/lint/hints.test.ts @@ -0,0 +1,220 @@ +// The line that `pretty` and `agent` print under a report whose message alone does not tell what is wrong and what to do. +import { afterAll, describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; +import { endChildren, spawn } from "../children"; + +afterAll(endChildren); + +async function lint(files: Record, ...args: string[]) { + using dir = tempDir("bun-lint-hints", files); + await using proc = spawn({ cmd: [bunExe(), "lint", ...args], env: bunEnv, cwd: String(dir), stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; +} + +/** What `pretty` says about `code`: a line for each report, with the rule, the line, and the hint if there is one. */ +async function hints(code: string, file = "a.js", others: Record = {}) { + const { stdout } = await lint({ ...others, [file]: code }, "-f", "pretty"); + const reports = stdout.matchAll(/^(?:error|warn): .* (\S+)\n +at [^\n]*?:(\d+):\d+\n(?:\nnote: (.*)\n)?/gm); + return Array.from(reports, ([, rule, line, hint]) => `${rule} ${line}${hint === undefined ? "" : ": " + hint}`); +} + +describe.concurrent("no-useless-assignment", () => { + test("every way leads to another assignment", async () => { + const code = ` +export function rename(kind) { + let name = "honeypot"; + if (kind === "a") { + name = "x"; + } else { + name = "y"; + } + return name; +} +`; + expect(await hints(code)).toEqual([ + "no-useless-assignment 3: It is overwritten on line 5 and line 7 before it is read.", + ]); + }); + + test("the next statement assigns", async () => { + expect(await hints(`export function f(a) {\n let b = a;\n b = 2;\n return b;\n}\n`)).toEqual([ + "no-useless-assignment 2: It is overwritten on line 3 before it is read.", + ]); + }); + + test("three ways and more", async () => { + const cases = (count: number) => Array.from({ length: count }, (_, i) => ` case ${i}: b = ${i}; break;\n`); + const code = (count: number) => + `export function f(a) {\n let b = -1;\n switch (a) {\n${cases(count).join("")} default: b = 9;\n }\n return b;\n}\n`; + expect(await Promise.all([hints(code(2)), hints(code(5))])).toEqual([ + ["no-useless-assignment 2: It is overwritten on lines 4, 5 and 6 before it is read."], + ["no-useless-assignment 2: It is overwritten on lines 4, 5, 6 and 3 more before it is read."], + ]); + }); + + test("nothing reads the variable again", async () => { + expect(await hints(`export function f(a) {\n let b = a;\n use(b);\n b = 2;\n}\n`, "a.ts")).toEqual([ + "no-useless-assignment 4: Nothing reads it after this.", + ]); + }); +}); + +/** The code, and what is said about it. The code uses what it declares, so that nothing else is reported. */ +const cases: [code: string, ...reports: string[]][] = [ + [ + `let a;\nif (a = f()) use(a);`, + "no-cond-assign 2: Did you mean ===? To keep the assignment, wrap it in another pair of parentheses.", + ], + [ + `let a = 1;\nwhile (a -= 1) use(a);`, + "no-cond-assign 2: To keep the assignment, wrap it in another pair of parentheses.", + ], + [`let a;\nuse(a = f() ? 1 : 2, a);`], + [`let a;\nuse((a = f()) ? 1 : 2, a);`, "no-cond-assign 2: Did you mean ===?"], + [ + `switch (f()) {\n case 1:\n const a = 1;\n use(a);\n}`, + "no-case-declarations 3: Every case of the switch can see it. Wrap this case in { }.", + ], + [ + `switch (f()) {\n case 1:\n use(1);\n case 2:\n use(2);\n}`, + "no-fallthrough 4: Add break, or the comment // falls through if this is meant.", + ], + [`use(f() === -0);`, "no-compare-neg-zero 1: -0 and 0 compare as equal. Use Object.is(x, -0) to tell them apart."], + [`if (f()) {\n}`, "no-empty 1: Remove it, or put a comment in it that says why it is empty."], + [`try {\n f();\n} catch {}`, "no-empty 3: Remove it, or put a comment in it that says why it is empty."], + [`use(/a[]/);`, "no-empty-character-class 1: [] in a regular expression matches nothing."], + [ + `export function g() {\n try {\n return f();\n } finally {\n return 2;\n }\n}`, + "no-unsafe-finally 5: It replaces whatever try and catch return or throw.", + ], + [`use(!f() in g());`, "no-unsafe-negation 1: !a in b means (!a) in b. Did you mean !(a in b)?"], + [ + `use(!f() instanceof Map);`, + "no-unsafe-negation 1: !a instanceof b means (!a) instanceof b. Did you mean !(a instanceof b)?", + ], + [ + `const a = f\n(g || h).call();\nuse(a);`, + "no-unexpected-multiline 2: This line continues the one before. End that one with a semicolon.", + ], + [`use(f().hasOwnProperty("a"));`, "no-prototype-builtins 1: Use Object.hasOwn(object, key) instead."], + [ + `use(f().isPrototypeOf(g()));`, + "no-prototype-builtins 1: Use Object.prototype.isPrototypeOf.call(object, value) instead.", + ], + [ + `use(new Promise(async resolve => resolve(await f())));`, + "no-async-promise-executor 1: If it throws, the promise never settles. Await outside of new Promise().", + ], + [ + `use("a" + f() || "b");`, + "no-constant-binary-expression 1: The left side is always truthy or always falsy. Are parentheses missing?", + ], + [ + `use(!f() ?? "b");`, + "no-constant-binary-expression 1: The left side is never null or undefined, or always is. Are parentheses missing?", + ], + [`use(!f() == null);`, "no-constant-binary-expression 1: The result is always the same. Are parentheses missing?"], + [`use({} === f());`, "no-constant-binary-expression 1"], + [ + `try {\n f();\n} catch (e) {\n throw e;\n}`, + "no-useless-catch 1: It only throws the error again. The code does the same without try and catch.", + ], + [ + `try {\n f();\n} catch (e) {\n throw e;\n} finally {\n g();\n}`, + "no-useless-catch 3: It only throws the error again. try and finally do the same without it.", + ], + [`use([1, , 2]);`, "no-sparse-arrays 1: It leaves a hole in the array. Remove the comma, or write undefined."], + [`const {} = f();`, "no-empty-pattern 1: It declares no variables."], + [ + `const { a: {} } = f();`, + "no-empty-pattern 1: It declares no variables. For a default value, write = {} instead of : {}.", + ], + [ + `const { a: [] } = f();`, + "no-empty-pattern 1: It declares no variables. For a default value, write = [] instead of : [].", + ], + [`class A {}\nA = f();\nuse(A);`, "no-class-assign 2: This assignment replaces the class declared on line 1."], + [ + `function a() {}\n\na = f();\nuse(a);`, + "no-func-assign 3: This assignment replaces the function declared on line 1.", + ], + [ + `const a = 1;\nif (f()) a = 2;\nuse(a);`, + "no-const-assign 2: It is declared with const on line 1. Use let if it has to change.", + ], + [`var a = 1;\nuse(a);\nvar a = 2;\nuse(a);`, "no-redeclare 3: It is first declared on line 1."], + [ + `switch (f()) {\n case 1:\n break;\n case 2:\n break;\n case 1:\n break;\n}`, + "no-duplicate-case 6: Only the first case with this label runs. It is on line 2.", + ], + [`use({\n a: 1,\n b: 2,\n a: 3,\n});`, "no-dupe-keys 4: Only the last one counts. The first is on line 2."], + [`use(class {\n a() {}\n a() {}\n});`, "no-dupe-class-members 3: Only the last one counts."], + [`use(9007199254740993);`, "no-loss-of-precision 1: At runtime it is 9007199254740992."], + [`use(typeof f() === "strnig");`, 'valid-typeof 1: Did you mean "string"?'], + [`use(typeof f() === "array");`, "valid-typeof 1: typeof never returns this string."], + [`use(typeof f() === null);`, "valid-typeof 1: typeof always returns a string."], + [`use(typeof f() === undefined);`, 'valid-typeof 1: typeof returns the string "undefined".'], + [ + `try {\n f();\n} catch (err) {\n throw new Error("no");\n}`, + "no-unused-vars 3", + "preserve-caught-error 4: Pass { cause: err } as the last argument, so that the caught error is not lost.", + ], + [ + String.raw`use("\d+");`, + String.raw`no-useless-escape 1: \d is the same as d. Remove the backslash, or write \\ for a real one.`, + ], + [String.raw`use(/a\:b/);`, String.raw`no-useless-escape 1: \: is the same as : here. Remove the backslash.`], + [ + `export function a() {\n return 1;\n function b() {}\n use(b);\n}`, + "no-unreachable 4: Nothing runs after the return on line 2.", + ], + [ + `while (true) {\n f();\n}\nuse(1);`, + "no-unreachable 4: The loop on line 1 runs forever, or leaves by return or throw.", + ], + [ + `export function a() {\n if (f()) {\n return 1;\n } else {\n throw g();\n }\n use(1);\n}`, + "no-unreachable 7: Every path through the if on line 2 ends in return, throw, break or continue.", + ], +]; + +test.concurrent.each(cases)("%s", async (code, ...reports) => { + expect(await hints(code + "\n/* global f, g, h, use */\n")).toEqual(reports); +}); + +test.concurrent("no-octal", async () => { + expect(await hints(`module.exports = [071, 09];\n`, "a.cjs")).toEqual([ + "no-octal 1: It is octal: 071 is 57. Write 0o71 to make that clear.", + "no-octal 1: The leading zero does nothing here. Remove it.", + ]); +}); + +describe.concurrent("where a hint is shown", () => { + const files = { "a.js": `export function f(a) {\n let b = a;\n b = 2;\n return b;\n}\n` }; + const hint = "It is overwritten on line 3 before it is read."; + const message = "The value assigned to 'b' is not used in subsequent statements."; + + test("agent", async () => { + const { stdout } = await lint(files, "-f", "agent"); + expect(stdout).toContain(`${hint}`); + }); + + test("pretty with --all", async () => { + const { stdout } = await lint(files, "-f", "pretty", "--all"); + expect(stdout).toContain(`note: ${hint}`); + }); + + test.each(["stylish", "json", "unix", "github", "junit", "checkstyle", "gitlab", "sarif"])("not %s", async format => { + const { stdout } = await lint(files, "-f", format); + expect(stdout).toContain(message.slice(0, -1)); + expect(stdout).not.toContain("overwritten"); + }); + + test("not with an oxlint config", async () => { + const config = JSON.stringify({ rules: { "no-useless-assignment": "error" } }); + const { stdout } = await lint({ ...files, ".oxlintrc.json": config }, "-f", "default"); + expect(stdout).toContain("no-useless-assignment"); + expect(stdout).not.toContain("overwritten"); + }); +}); diff --git a/test/cli/lint/init.test.ts b/test/cli/lint/init.test.ts new file mode 100644 index 000000000000..96bdaf531a3e --- /dev/null +++ b/test/cli/lint/init.test.ts @@ -0,0 +1,325 @@ +// `bun lint --init` and `bun format --init`. +import { afterAll, describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; +import { chmodSync, existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { endChildren, spawn } from "../children"; + +afterAll(endChildren); + +// Disable AI agent and CI detection regardless of the environment the tests run in. +const env = { + ...bunEnv, + AGENT: "0", + CLAUDECODE: undefined, + REPL_ID: undefined, + GITHUB_ACTIONS: undefined, + NO_COLOR: "1", + FORCE_COLOR: undefined, + ESLINT_USE_FLAT_CONFIG: undefined, +}; + +type Command = "lint" | "format"; + +async function run(cwd: string, command: Command, ...args: string[]) { + await using proc = spawn({ cmd: [bunExe(), command, ...args], env, cwd, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; +} + +const sections = { + lint: `[lint] +# typeAware = true # also run the rules that need types + +[lint.rules] +# "no-console" = "warn" +# "eqeqeq" = ["error", "smart"] +`, + format: `[format] +# printWidth = 100 +# semi = false +`, +}; + +const utf16 = (text: string) => Buffer.from("" + text, "utf16le").toString("latin1"); + +describe.concurrent.each(["lint", "format"] as const)("bun %s --init", command => { + const section = sections[command]; + const other = command === "lint" ? "format" : "lint"; + + test("creates bunfig.toml", async () => { + using dir = tempDir("bun-init-section", { "a.js": "a;\n" }); + expect(await run(String(dir), command, "--init")).toEqual({ + stdout: `Created bunfig.toml with a [${command}] section\n`, + stderr: "", + exitCode: 0, + }); + expect(readFileSync(join(String(dir), "bunfig.toml"), "utf8")).toBe(section); + expect(readdirSync(String(dir)).sort()).toEqual(["a.js", "bunfig.toml"]); + expect(await run(String(dir), command, "--init")).toEqual({ + stdout: `bunfig.toml already has a [${command}] section\n`, + stderr: "", + exitCode: 0, + }); + expect(readFileSync(join(String(dir), "bunfig.toml"), "utf8")).toBe(section); + }); + + // The bytes, one character for each. + test.each<[string, string, string]>([ + ["a line break at the end", '[install]\nregistry = "a"\n', "\n" + section], + ["none", '[install]\nregistry = "a"', "\n\n" + section], + ["an empty line at the end", "[install]\n\n", section], + ["two", "[install]\n\n\n", section], + ["spaces at the end", "[install]\n ", "\n" + section], + ["a comment at the end", "# [install]", "\n\n" + section], + ["nothing", "", section], + ["an empty line", "\n", section], + ["a space", " ", "\n" + section], + ["CRLF", "[install]\r\nauto = 'auto'\r\n", ("\n" + section).replaceAll("\n", "\r\n")], + ["CRLF, and none at the end", "a = 1\r\nb = 2", ("\n\n" + section).replaceAll("\n", "\r\n")], + ["CRLF after LF", "a = 1\nb = 2\r\n", "\n" + section], + ["a byte order mark", "a = 1\n", "\n" + section], + ["UTF-16", utf16("a = 'é'\r\n"), utf16(("\n" + section).replaceAll("\n", "\r\n")).slice(2)], + ["more than ASCII", Buffer.from("a = 'é→𝒳'\n").toString("latin1"), "\n" + section], + ["the name in a comment", `# [${command}]\n`, "\n" + section], + ["the name in a string", `a = """\n[${command}]\n"""\n`, "\n" + section], + ["the name in another table", `[install.${command}]\n`, "\n" + section], + ["the section of the other command", sections[other], "\n" + section], + ])("adds the section to a bunfig.toml with %s", async (_, before, added) => { + using dir = tempDir("bun-init-section", { "a.js": "a;\n" }); + const path = join(String(dir), "bunfig.toml"); + writeFileSync(path, before, "latin1"); + expect(await run(String(dir), command, "--init")).toEqual({ + stdout: `Added [${command}] to bunfig.toml\n`, + stderr: "", + exitCode: 0, + }); + expect(readFileSync(path, "latin1")).toBe(before + added); + // It can be read, and the section is seen. + expect(await run(String(dir), command, "--init")).toEqual({ + stdout: `bunfig.toml already has a [${command}] section\n`, + stderr: "", + exitCode: 0, + }); + }); + + const key = command === "lint" ? "typeAware" : "useTabs"; + test.each([ + `[${command}]\n`, + `[ ${command} ]`, + `["${command}"]\n`, + `${command}.${key} = true\n`, + `${command} = { ${key} = true }\n`, + `a = 1\r\n\r\n[${command}] # here\r\n${key} = false`, + ...(command === "lint" ? ['[lint.rules]\n"no-var" = "error"\n', "[lint.globals]\n"] : []), + ])("leaves a bunfig.toml with %j as it is", async before => { + using dir = tempDir("bun-init-section", { "bunfig.toml": before }); + expect(await run(String(dir), command, "--init")).toEqual({ + stdout: `bunfig.toml already has a [${command}] section\n`, + stderr: "", + exitCode: 0, + }); + expect(readFileSync(join(String(dir), "bunfig.toml"), "utf8")).toBe(before); + }); + + test.each([ + [`[${command}\n`, `bunfig.toml:1:${command.length + 2}`], + ["a = 1\nb = \n", "bunfig.toml:2:5"], + [`[${command}]\nnothing = 1\n`, "bunfig.toml:2:1"], + [`${command} = 1\n`, `bunfig.toml:1:${command.length + 4}`], + ])("a bunfig.toml that cannot be used is an error, and stays as it is: %j", async (before, place) => { + using dir = tempDir("bun-init-section", { "bunfig.toml": before }); + const result = await run(String(dir), command, "--init"); + expect(result.stderr).toContain("error: "); + expect(result.stderr).toContain(place); + expect({ ...result, stderr: "" }).toEqual({ stdout: "", stderr: "", exitCode: 2 }); + expect(readFileSync(join(String(dir), "bunfig.toml"), "utf8")).toBe(before); + }); + + // The one that is read is another: `--init` is about the one in the working directory. + test("the same with `bun --config=`", async () => { + using dir = tempDir("bun-init-section", { "bunfig.toml": "a = 1\nb = \n", "other.toml": "" }); + await using proc = spawn({ + cmd: [bunExe(), "--config=other.toml", command, "--init"], + env, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toStartWith("error: Cannot read bunfig.toml: "); + expect(stderr).toEndWith("\n at bunfig.toml:2:5\n"); + expect({ stdout, exitCode }).toEqual({ stdout: "", exitCode: 2 }); + expect(readFileSync(join(String(dir), "bunfig.toml"), "utf8")).toBe("a = 1\nb = \n"); + }); + + const cannotBeKeptFromWriting = isWindows || process.getuid?.() === 0; + + test.skipIf(cannotBeKeptFromWriting)("a bunfig.toml that cannot be written is an error", async () => { + using dir = tempDir("bun-init-section", { "bunfig.toml": "a = 1\n" }); + chmodSync(join(String(dir), "bunfig.toml"), 0o444); + expect(await run(String(dir), command, "--init")).toEqual({ + stdout: "", + stderr: expect.stringMatching(/^error: Cannot write bunfig\.toml: EACCES: .+\n$/), + exitCode: 2, + }); + expect(readFileSync(join(String(dir), "bunfig.toml"), "utf8")).toBe("a = 1\n"); + expect(readdirSync(String(dir))).toEqual(["bunfig.toml"]); + }); + + test.skipIf(cannotBeKeptFromWriting)("a bunfig.toml that cannot be read is an error", async () => { + using dir = tempDir("bun-init-section", { "bunfig.toml": "a = 1\n" }); + chmodSync(join(String(dir), "bunfig.toml"), 0o200); + expect(await run(String(dir), command, "--init")).toEqual({ + stdout: "", + stderr: expect.stringMatching(/^error: Cannot read bunfig\.toml: EACCES: .+\n$/), + exitCode: 2, + }); + chmodSync(join(String(dir), "bunfig.toml"), 0o644); + expect(readFileSync(join(String(dir), "bunfig.toml"), "utf8")).toBe("a = 1\n"); + }); + + test.skipIf(cannotBeKeptFromWriting)("a directory that nothing can be added to", async () => { + using dir = tempDir("bun-init-section", { "none/a.js": "a;\n", "one/bunfig.toml": "a = 1\n" }); + const [none, one] = [join(String(dir), "none"), join(String(dir), "one")]; + chmodSync(none, 0o555); + chmodSync(one, 0o555); + try { + expect(await run(none, command, "--init")).toEqual({ + stdout: "", + stderr: expect.stringMatching(/^error: Cannot write bunfig\.toml: EACCES: .+\n$/), + exitCode: 2, + }); + // The file can be written. + expect(await run(one, command, "--init")).toEqual({ + stdout: `Added [${command}] to bunfig.toml\n`, + stderr: "", + exitCode: 0, + }); + expect(readFileSync(join(one, "bunfig.toml"), "utf8")).toBe("a = 1\n\n" + section); + } finally { + chmodSync(none, 0o755); + chmodSync(one, 0o755); + } + }); + + test("a directory called bunfig.toml is an error", async () => { + using dir = tempDir("bun-init-section", { "bunfig.toml/a.js": "a;\n" }); + expect(await run(String(dir), command, "--init")).toEqual({ + stdout: "", + stderr: expect.stringMatching(/^error: Cannot read bunfig\.toml: E[A-Z]+: .+\n$/), + exitCode: 2, + }); + }); + + const flavor = command === "lint" ? "oxlint" : "oxfmt"; + test(`--flavor=${flavor} is kept, in bunfig.toml`, async () => { + using dir = tempDir("bun-init-section", { "a.js": "a;\n" }); + expect(await run(String(dir), command, "--init", `--flavor=${flavor}`)).toEqual({ + stdout: `Created bunfig.toml with a [${command}] section\n`, + stderr: "", + exitCode: 0, + }); + expect(readFileSync(join(String(dir), "bunfig.toml"), "utf8")).toBe( + section.replace("\n", `\nflavor = "${flavor}"\n`), + ); + expect(readdirSync(String(dir)).sort()).toEqual(["a.js", "bunfig.toml"]); + }); +}); + +describe.concurrent("with a configuration file of another tool nothing is written", () => { + const flat = "export default [];\n"; + test.each<[Command, Record, string[], string]>([ + ["lint", { "eslint.config.js": flat }, [], "eslint.config.js"], + ["lint", { "eslint.config.mjs": flat, "sub/a.js": "" }, ["--cwd", "sub"], "../eslint.config.mjs"], + ["lint", { ".eslintrc.json": "{}" }, [], ".eslintrc.json"], + ["lint", { "package.json": '{ "eslintConfig": {} }' }, [], "eslintConfig in package.json"], + ["lint", { ".oxlintrc.json": "{}" }, [], ".oxlintrc.json"], + ["lint", { ".oxlintrc.json": "{}", "sub/a.js": "" }, ["--cwd", "sub"], "../.oxlintrc.json"], + ["lint", { "config/mine.mjs": flat }, ["-c", "config/mine.mjs"], "config/mine.mjs"], + ["lint", { "eslint.config.js": flat, ".oxlintrc.json": "{}" }, ["--flavor=oxlint"], ".oxlintrc.json"], + ["lint", { "eslint.config.js": flat, ".oxlintrc.json": "{}" }, ["--flavor=eslint"], "eslint.config.js"], + ["format", { ".prettierrc": "{}" }, [], ".prettierrc"], + ["format", { ".prettierrc.toml": "semi = false\n", "sub/a.js": "" }, ["--cwd", "sub"], "../.prettierrc.toml"], + ["format", { "package.json": '{ "prettier": {} }' }, [], "prettier in package.json"], + ["format", { ".oxfmtrc.json": "{}" }, [], ".oxfmtrc.json"], + ["format", { ".oxfmtrc.jsonc": "{}", "sub/a.js": "" }, ["--cwd", "sub"], "../.oxfmtrc.jsonc"], + ["format", { "config/mine.json": "{}" }, ["--config", "config/mine.json"], "config/mine.json"], + ])("bun %s: %j %j", async (command, files, args, shown) => { + using dir = tempDir("bun-init-section", files); + expect(await run(String(dir), command, "--init", ...args)).toEqual({ + stdout: `Using ${shown}. bunfig.toml is not needed.\n`, + stderr: "", + exitCode: 0, + }); + const found = new Bun.Glob("**").scanSync({ cwd: String(dir), dot: true }); + expect([...found].map(it => it.replaceAll("\\", "/")).sort()).toEqual(Object.keys(files).sort()); + }); + + test.each<[Command, Record]>([ + ["lint", { "package.json": '{ "devDependencies": { "eslint": "10.0.0" } }' }], + ["lint", { "package.json": '{ "devDependencies": { "oxlint": "1.0.0" } }' }], + ["lint", { "eslint.config.js": flat, "args": "--flavor=oxlint" }], + ["lint", { "eslint.config.js": flat, "args": "--no-config-lookup" }], + ["format", { "package.json": '{ "devDependencies": { "prettier": "3.0.0" } }' }], + ["format", { ".editorconfig": "root = true\n" }], + ["format", { ".prettierrc": "{}", "args": "--no-config" }], + ])("not these, bun %s: %j", async (command, { args, ...files }) => { + using dir = tempDir("bun-init-section", files); + const result = await run(String(dir), command, "--init", ...(args ? [args] : [])); + expect(result.stdout).toBe(`Created bunfig.toml with a [${command}] section\n`); + expect(result.exitCode).toBe(0); + }); + + test("one that cannot be read is an error", async () => { + using dir = tempDir("bun-init-section", { "eslint.config.js": "throw new Error('no');\n", ".prettierrc": "{" }); + for (const command of ["lint", "format"] as const) { + const result = await run(String(dir), command, "--init"); + expect(result.stderr).toContain(command === "lint" ? "eslint.config.js" : ".prettierrc"); + expect({ stdout: result.stdout, exitCode: result.exitCode }).toEqual({ stdout: "", exitCode: 2 }); + } + expect(existsSync(join(String(dir), "bunfig.toml"))).toBe(false); + }); +}); + +// Each line that is a comment, without the `# `, alone and with the others. +describe.concurrent("what is commented out can be used", () => { + const commented = (text: string) => text.split("\n").flatMap((line, at) => (line.startsWith("# ") ? [at] : [])); + const variants = (text: string) => + [[], ...commented(text).map(at => [at]), commented(text)].map(lines => { + const all = text.split("\n"); + const keys = lines.map(at => all[at].slice(2).split(" ")[0]); + return [keys, all.map((line, at) => (lines.includes(at) ? line.slice(2) : line)).join("\n")] as const; + }); + + const rules: Record = { + typeAware: { id: "@typescript-eslint/no-floating-promises", exitCode: 1 }, + '"no-console"': { id: "no-console", exitCode: 0 }, + '"eqeqeq"': { id: "eqeqeq", exitCode: 1 }, + }; + test.each(variants(sections.lint))("bun lint: %j", async (keys, bunfig) => { + using dir = tempDir("bun-init-section", { + "a.ts": "export function f(a: number) {\n console.log(a == 1);\n return Promise.resolve();\n}\nf(1);\n", + "tsconfig.json": "{}", + "bunfig.toml": bunfig, + }); + const result = await run(String(dir), "lint", "-f", "json"); + const found = JSON.parse(result.stdout).flatMap((it: { messages: { ruleId: string }[] }) => + it.messages.map(it => it.ruleId), + ); + expect(found.sort()).toEqual(keys.map(key => rules[key].id).sort()); + expect(result.exitCode).toBe(Math.max(0, ...keys.map(key => rules[key].exitCode))); + }); + + const names = Array.from({ length: 8 }, (_, at) => `argument${at}`); + test.each(variants(sections.format))("bun format: %j", async (keys, bunfig) => { + expect(["printWidth", "semi"]).toEqual(expect.arrayContaining([...keys])); + using dir = tempDir("bun-init-section", { "a.js": `call(${names.join(",")})\n`, "bunfig.toml": bunfig }); + const result = await run(String(dir), "format", "a.js"); + const call = keys.includes("printWidth") + ? `call(${names.join(", ")})` + : `call(\n${names.map(name => ` ${name},\n`).join("")})`; + expect(readFileSync(join(String(dir), "a.js"), "utf8")).toBe(call + (keys.includes("semi") ? "\n" : ";\n")); + expect(result.exitCode).toBe(0); + }); +}); diff --git a/test/cli/lint/js-parsers.test.ts b/test/cli/lint/js-parsers.test.ts index fe2648ab3cff..bff83c9a2bd1 100644 --- a/test/cli/lint/js-parsers.test.ts +++ b/test/cli/lint/js-parsers.test.ts @@ -432,7 +432,7 @@ describe.concurrent("bun lint with languages", () => { ["-f", "json", "--timing", "c.vue"], ); expect(summary(result.raw)).toMatchInlineSnapshot(`"c.vue: 2:1 lines/no-tabs tab p 8 v c.vue [fix 11,12 " "]"`); - expect(result.stderr).toContain("the package eslint has linted 1 texts"); + expect(result.stderr).toContain("the eslint package linted 1 files"); expect(result.exitCode).toBe(1); }, timeout, @@ -517,7 +517,7 @@ describe.concurrent("bun lint with languages", () => { "phase.js: 2:1 lines/no-tabs tab q 2 b phase.js [fix 45,46 " "] plain.js: 1:1 members/objects Identifier" `); - expect(result.stderr).toContain("the package eslint has linted 1 texts"); + expect(result.stderr).toContain("the eslint package linted 1 files"); expect(result.exitCode).toBe(1); }, timeout, @@ -670,7 +670,7 @@ describe.concurrent("bun lint with languages", () => { const result = await lint(lines, ["-f", "unix", "a.js", "b.txt"]); expect(result.stdout).toContain("a.js:1:1: Unexpected var, use let or const instead. [Error/no-var]"); expect(result.stderr).toContain( - `1 file was not linted, only JavaScript and TypeScript can be (1 *.txt): b.txt. The package "eslint" lints other languages, if it is installed`, + "1 file was not linted because bun lint only supports JavaScript and TypeScript (1 *.txt): b.txt\n To lint other languages, install eslint: bun add -d eslint", ); expect(result.exitCode).toBe(2); }, diff --git a/test/cli/lint/js-plugins.test.ts b/test/cli/lint/js-plugins.test.ts index ad61394dee51..6cc230d3cd54 100644 --- a/test/cli/lint/js-plugins.test.ts +++ b/test/cli/lint/js-plugins.test.ts @@ -607,18 +607,19 @@ describe.concurrent("bun lint with plugins in JavaScript", () => { const notes = async (files: Record, more: object, ...flags: string[]) => (await lint({ ...files, ".oxlintrc.json": besideNode(more) }, [...flags, "a.js"])).stderr .split("\n") - .filter(it => it.startsWith("note: ")); - const advice = `remove it from "jsPlugins" and add "node" to "plugins".`; + .filter(it => it.startsWith("note: ") || it.startsWith(" To use ")); test( "a package, with its version", async () => { const jsPlugins = ["eslint-plugin-n"]; expect(await notes(packaged("17.16.2"), { jsPlugins, rules })).toEqual([ - `note: 1 rule of "n" ran in JavaScript (eslint-plugin-n 17.16.2, from "jsPlugins"). bun lint has it built in, as of 18.4.1: ${advice} Reports may differ.`, + `note: 1 "n" rule ran in JavaScript from eslint-plugin-n 17.16.2, but bun lint has it built in (version 18.4.1)`, + ` To use the built-in rule, remove eslint-plugin-n from "jsPlugins" and add "node" to "plugins". Reports may differ.`, ]); expect(await notes(packaged("18.4.9"), { jsPlugins, rules }, "-f", "stylish")).toEqual([ - `note: 1 rule of "n" ran in JavaScript (eslint-plugin-n 18.4.9, from "jsPlugins"). bun lint has it built in, as of 18.4.1: ${advice}`, + `note: 1 "n" rule ran in JavaScript from eslint-plugin-n 18.4.9, but bun lint has it built in (version 18.4.1)`, + ` To use the built-in rule, remove eslint-plugin-n from "jsPlugins" and add "node" to "plugins".`, ]); }, timeout, @@ -633,7 +634,8 @@ describe.concurrent("bun lint with plugins in JavaScript", () => { `"no-new-require": rule("new"), "no-sync": rule("sync"), "only-there"`, ); expect(await notes({ ...calledN, "n.js": plugin }, { rules: { ...rules, ...more } })).toEqual([ - `note: 2 rules of "n" ran in JavaScript (./n.js, from "jsPlugins"). bun lint has them built in, as of 18.4.1: write them "node/.." and add "node" to "plugins". Keep it for the other 1. Reports may differ.`, + `note: 2 "n" rules ran in JavaScript from ./n.js, but bun lint has them built in (version 18.4.1)`, + ` To use the built-in rules, rename them to "node/.." and add "node" to "plugins". Keep ./n.js for the other 1 rule. Reports may differ.`, ]); }, timeout, @@ -1211,7 +1213,7 @@ describe.concurrent("bun lint with plugins in JavaScript", () => { `); // What the process prints that evaluates the file is not shown. expect(stderr).not.toContain("The configuration file runs."); - expect(stderr).toMatch(/JavaScript: [12] engines, which have loaded [12] modules, /); + expect(stderr).toMatch(/JavaScript: [12] engines loaded [12] modules, /); expect(exitCode).toBe(1); }, timeout, @@ -1244,7 +1246,7 @@ describe.concurrent("bun lint with plugins in JavaScript", () => { const counts = JSON.parse(raw).map((it: any) => it.messages.length); expect(counts).toEqual([2, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1]); expect(stderr.split("The configuration file runs.").length - 1).toBe(1); - expect(stderr).toContain(`with the whole configuration file, which alone has the plugin "inline"`); + expect(stderr).toContain(`; loaded the whole config file for the plugin "inline"`); expect(exitCode).toBe(1); }, timeout, @@ -1357,7 +1359,7 @@ describe.concurrent("bun lint with plugins in JavaScript", () => { 3 problems" `); - expect(stderr).toContain(`with the whole configuration file, which alone has what is in "settings"`); + expect(stderr).toContain(`; loaded the whole config file for "settings"`); expect(exitCode).toBe(1); }, timeout, @@ -1641,7 +1643,7 @@ describe.concurrent("bun lint with plugins in JavaScript", () => { 4 problems" `); expect(stderr).toContain( - "2 rules in JavaScript did not run, only the built-in rules have types: typed/at-once, typed/later", + "2 rules did not run because rules that run in JavaScript cannot use type information: typed/at-once, typed/later", ); expect(stderr).not.toContain("You have used a rule"); expect(exitCode).toBe(2); @@ -1812,7 +1814,7 @@ describe.concurrent("bun lint with plugins in JavaScript", () => { ["-f", "unix", "--timing", "a.js"], ); expect(stdout).toContain("/a.js:1:1: one [Error/own/one]"); - expect(stderr).toContain("JavaScript: 1 engines, which have loaded 1 modules"); + expect(stderr).toContain("JavaScript: 1 engines loaded 1 modules"); expect(exitCode).toBe(1); }, timeout, @@ -2211,7 +2213,7 @@ export default { { "eslint.config.mjs": `export default [{ rules: { "no-debugger": "error" } }];`, "a.js": "debugger;\n" }, ["--timing", "a.js"], ); - expect(stderr).toContain("JavaScript: 0 engines, which have loaded 0 modules, 0.0 MB of source, in 0.0ms"); + expect(stderr).toContain("JavaScript: 0 engines loaded 0 modules, 0.0 MB of source, in 0.0ms"); expect(exitCode).toBe(1); }, timeout, @@ -2371,7 +2373,7 @@ export default { ); expect(exitCode).toBe(1); const seen = raw.split(":1:1: seen [Error/own/grows]").length - 1; - return { seen, freed: Number(/, freed to stay in the memory: (\d+)/.exec(stderr)?.[1] ?? 0) }; + return { seen, freed: Number(/, engines freed to save memory: (\d+)/.exec(stderr)?.[1] ?? 0) }; }; const more = Math.max(40, availableParallelism() + 1); const [one, some, many] = await Promise.all([run(1, "8"), run(96, "8"), run(more, "0")]); diff --git a/test/cli/lint/lint.test.ts b/test/cli/lint/lint.test.ts index 2872378257ad..feec75755c2b 100644 --- a/test/cli/lint/lint.test.ts +++ b/test/cli/lint/lint.test.ts @@ -199,7 +199,7 @@ describe.concurrent("bun lint", () => { const reports = (stdout: string) => stdout .split("\n") - .flatMap(line => /^\/(\S+?:\d+:\d+): .*?'(\w+)'.* \[Error\/(.*)\]$/.exec(line)?.slice(1).join(" ") ?? []); + .flatMap(line => /^\/(\S+?:\d+:\d+): .*?'(\w+)'.* \[\w+\/(.*)\]$/.exec(line)?.slice(1).join(" ") ?? []); const union = ["a/a.js:1:35 nothing no-undef", "d/build.js:1:39 nothing no-undef"]; test("without a configuration file, with --infer-globals", async () => { @@ -300,7 +300,9 @@ describe.concurrent("bun lint", () => { }), ]); expect(read.stdout).toContain("/a.mjs:1:1: Unexpected var, use let or const instead. [Error/no-var]"); - expect(unread.stderr).toContain("1 file was not linted, only the parser of the configuration can read it: b.mjs."); + expect(unread.stderr).toContain( + "1 file was not linted because only the parser in your config can read it: b.mjs\n", + ); expect([read.exitCode, unread.exitCode]).toEqual([1, 2]); }); @@ -330,17 +332,17 @@ describe.concurrent("bun lint", () => { "vendor.min.js": "debugger;\n", ".gitignore": "dist\n", }, - [], + ["-f", "stylish"], ); expect(stdout).toMatchInlineSnapshot(` "/a.js - 2:5 error 'unused' is defined but never used no-unused-vars - 3:1 error 'undefinedName' is not defined no-undef + 2:5 warning 'unused' is defined but never used no-unused-vars + 3:1 error 'undefinedName' is not defined no-undef /b.ts - 1:17 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any + 1:17 warning Unexpected any. Specify a different type @typescript-eslint/no-explicit-any - ✖ 3 problems (3 errors, 0 warnings)" + ✖ 3 problems (1 error, 2 warnings)" `); expect(exitCode).toBe(1); }); @@ -2931,7 +2933,7 @@ describe.concurrent("bun lint", () => { const result = await lint(files, ["--fix", "-f", format, "."], { reads: ["a.js", "b.js"] }); expect(result.files).toEqual({ "a.js": files["a.js"].replace("var", "let"), "b.js": files["b.js"] }); expect(result.stderr).toContain( - "Fixes of mine/breaks would leave a.js with a syntax error. They are not applied.", + "Fixes from mine/breaks were not applied to a.js because they would cause a syntax error", ); expect(result.exitCode).toBe(1); }, @@ -3436,7 +3438,7 @@ describe.concurrent("bun lint", () => { describe("fixes after which the text cannot be parsed are not written", () => { const warning = (rules: string, file: string) => - `warn: Fixes of ${rules} would leave ${file} with a syntax error. They are not applied.`; + `warn: Fixes from ${rules} were not applied to ${file} because they would cause a syntax error`; // oxlint 1.87 writes `foo(,);`, `await x ^= y;` and "declare module String.raw`a\\b` {}". test.each([ @@ -4576,7 +4578,7 @@ describe.concurrent("bun lint", () => { return stderr.split("\n").filter(it => it.startsWith("note: ")); }; expect(await notes("5.8.3")).toEqual([ - "note: typescript 5.8.3 is installed; bun lint checks types as TypeScript 7.", + "note: typescript 5.8.3 is installed, but bun lint checks types like TypeScript 7", ]); expect(await notes("7.0.2")).toEqual([]); expect(await notes("5.8.3", {}, "--quiet")).toEqual([]); @@ -4645,7 +4647,7 @@ describe.concurrent("bun lint", () => { const reported = (stdout: string, rule: string) => stdout .split("\n") - .filter(it => it.endsWith(`[Error/${rule}]`)) + .filter(it => it.endsWith(`/${rule}]`)) .map(it => `${it.split(":")[0].replace("/", "")} ${/'([^']+)'/.exec(it)![1]}`); test.concurrent.each([ @@ -4686,7 +4688,7 @@ describe.concurrent("bun lint", () => { ["fromTypes", "mineConst", "mineFn", "MineClass", "Array"].map(it => `a.js ${it}`), ); expect(reported(stdout, "no-undef")).toEqual([]); - expect(exitCode).toBe(1); + expect(exitCode).toBe(0); }); test("a project in which no file can declare a global has its libraries and its types", async () => { @@ -5879,7 +5881,7 @@ describe.concurrent("bun lint", () => { "c.tsx": ";\n", }; const { stderr, exitCode } = await lint(files, ["--timing"]); - expect(stderr).toContain("without a filter of the names they mention: 0 files"); + expect(stderr).toContain("linted without the name filter: 0 files"); expect(exitCode).toBe(0); }); @@ -6604,7 +6606,9 @@ describe.concurrent("regular expressions in a configuration", () => { // JavaScriptCore, which has the regular expressions, prints its options when it is started. const env = { BUN_JSC_dumpOptions: "1", JSC_dumpOptions: "1" }; // Without a configuration file: eslint:recommended. - const { stdout, stderr, exitCode } = await lint(files, ["--rule", "no-warning-comments: error"], { env }); + const { stdout, stderr, exitCode } = await lint(files, ["-f", "stylish", "--rule", "no-warning-comments: error"], { + env, + }); expect(stdout).toMatchInlineSnapshot(` "/a.js 4:11 error Irregular whitespace not allowed no-irregular-whitespace @@ -8036,7 +8040,7 @@ describe.concurrent("[lint] in bunfig.toml", () => { // A flag that only one of the two tools has says whom a command line was written for. A key was written for Bun. test("a key does not say which tool the run stands in for", async () => { const both = { ...eslint, ...oxlint }; - const asks = "Say which one this run is for: --flavor=oxlint or --flavor=eslint."; + const asks = "Choose one with --flavor=eslint or --flavor=oxlint"; const results = await Promise.all( [ `denyWarnings = true`, @@ -8202,9 +8206,9 @@ describe.concurrent("[lint] in bunfig.toml", () => { }); test("every flag in the help is a key, or is refused as a key with its name", async () => { - const help = await lint({}, ["--help"]); - const flags = [...help.stdout.matchAll(/^\s+(?:-\w, )?--([a-z-]+)/gm)].map(it => it[1]); - expect(flags.length).toBeGreaterThan(50); + const help = await lint({}, ["--help", "--all"]); + const flags = [...help.stdout.matchAll(/^\s+(?:-\w, )?--([a-z0-9-]+)/gm)].map(it => it[1]); + expect(flags.length).toBeGreaterThan(90); const renamed: Record = { "ignore-pattern": "ignorePatterns", "report-unused-disable-directives-severity": "reportUnusedDisableDirectives", @@ -8223,7 +8227,37 @@ describe.concurrent("[lint] in bunfig.toml", () => { return [flag, /expected .+ but received|cannot be set in bunfig\.toml/.test(stderr)]; }), ); - expect(answers.filter(it => !it[1])).toEqual([]); + // Flags of ESLint and oxlint that say nothing about a project, or have no effect. + expect(answers.filter(it => !it[1]).map(it => it[0])).toEqual([ + "parser", + "plugin", + "concurrency", + "flag", + "cache", + "cache-file", + "cache-location", + "cache-strategy", + "mcp", + "no-eslintrc", + "env", + "rulesdir", + "resolve-plugins-relative-to", + "lsp", + "import-plugin", + "react-plugin", + "jsdoc-plugin", + "jest-plugin", + "vitest-plugin", + "jsx-a11y-plugin", + "nextjs-plugin", + "react-perf-plugin", + "promise-plugin", + "node-plugin", + "vue-plugin", + "disable-unicorn-plugin", + "disable-oxc-plugin", + "disable-typescript-plugin", + ]); }); }); @@ -8672,7 +8706,9 @@ describe.concurrent("nativePluginRules", () => { lint({ ...flat, ...bunfig(`["import"]`) }, ["--print-config", "a.js"]), ]); expect(builtIn.stderr).toBe(""); - expect(mixed.stderr).toBe("note: in JavaScript: react-hooks/rules-of-hooks. All other rules are built in."); + expect(mixed.stderr).toBe( + "note: these rules run in JavaScript: react-hooks/rules-of-hooks. All other rules are built in.", + ); expect(JSON.parse(mixed.raw)).toEqual(JSON.parse(builtIn.raw)); }, timeout, @@ -8690,7 +8726,7 @@ describe.concurrent("nativePluginRules", () => { }; const result = await lint({ ...files, ...bunfig("false") }, ["a.js"], { mayFail: true }); expect(result.stderr).toContain( - "1 rule in JavaScript did not run, only the built-in rules have types: @typescript-eslint/no-floating-promises. With --native-plugin-rules=@typescript-eslint the built-in ones run", + "1 rule did not run because rules that run in JavaScript cannot use type information: @typescript-eslint/no-floating-promises\n Use --native-plugin-rules=@typescript-eslint to run the built-in versions.", ); expect(result.exitCode).toBe(2); }, @@ -8703,7 +8739,7 @@ describe.concurrent("nativePluginRules", () => { lint(files, ["-f", "unix", "a.ts"]), lint({ ...files, ...bunfig("false") }, ["-f", "unix", "a.ts"]), ]); - expect(plain.stdout).toContain("[Error/@typescript-eslint/no-explicit-any]"); + expect(plain.stdout).toContain("[Warning/@typescript-eslint/no-explicit-any]"); expect(off.stdout).toBe(plain.stdout); }); diff --git a/test/cli/lint/oracle/driver/oxfmt-fixtures.mjs b/test/cli/lint/oracle/driver/oxfmt-fixtures.mjs index 3f4a85120d50..b359a57487a9 100644 --- a/test/cli/lint/oracle/driver/oxfmt-fixtures.mjs +++ b/test/cli/lint/oracle/driver/oxfmt-fixtures.mjs @@ -19,8 +19,9 @@ const bin = (flag("bin") ?? "bun format").split(" "); const only = flag("only"); const show = process.argv.includes("--show"); -// Commands that `bun format` does not have, what `vp` tells oxfmt through the environment, and Svelte where the configuration has it. -const leftOut = /^(migrate_|vite_plus|(stdin_)?svelte\/(?!.*fmt-(no|without)-svelte))/; +// Commands that `bun format` does not have, `--init`, which is about bunfig.toml, what `vp` tells oxfmt through the environment, and +// Svelte where the configuration has it. +const leftOut = /^(migrate_|init|vite_plus|(stdin_)?svelte\/(?!.*fmt-(no|without)-svelte))/; const compared = /(\.([cm]?[jt]sx?|jsonc?|json5|css|scss|less|graphql|gql|ya?ml|mdx?|markdown|hbs|handlebars|html?|vue|mjml|toml|toml\.example|toml\.orig)|(^|\/)Pipfile)$/; diff --git a/test/cli/lint/output.test.ts b/test/cli/lint/output.test.ts new file mode 100644 index 000000000000..aefe3f9349b1 --- /dev/null +++ b/test/cli/lint/output.test.ts @@ -0,0 +1,342 @@ +// What `bun lint` prints where no configuration file of ESLint or oxlint applies, and what is the same with one. +import { afterAll, describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; +import { sep } from "node:path"; +import { endChildren, spawn } from "../children"; + +afterAll(endChildren); + +// Disable AI agent and CI detection regardless of the environment the tests run in. +const env = { + ...bunEnv, + AGENT: "0", + CLAUDECODE: undefined, + REPL_ID: undefined, + GITHUB_ACTIONS: undefined, + GITHUB_WORKSPACE: undefined, + NO_COLOR: undefined, + FORCE_COLOR: undefined, +}; + +type Files = Record; + +const withoutDirectory = (text: string, dir: { toString(): string }) => + text.replaceAll(String(dir) + sep, "/").replaceAll(String(dir), ""); +const withoutTime = (text: string) => text.replace(/ \[\d+(\.\d+)?m?s\]/g, ""); + +async function lint(files: Files, args: string[] = [], more: Record = {}) { + using dir = tempDir("bun-lint-output", files); + await using proc = spawn({ + cmd: [bunExe(), "lint", ...args], + env: { ...env, ...more }, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout: withoutDirectory(stdout, dir), stderr: withoutTime(stderr), exitCode }; +} + +// Some sandboxes have no pseudo-terminals. +const hasTerminal = (() => { + try { + new Bun.Terminal({}).close(); + return true; + } catch { + return false; + } +})(); + +/** As a person runs it. Both streams are the terminal. */ +async function inTerminal(files: Files, args: string[] = [], more: Record = {}) { + using dir = tempDir("bun-lint-output", files); + const decoder = new TextDecoder(); + let output = ""; + await using proc = spawn({ + cmd: [bunExe(), "lint", ...args], + env: { ...env, TERM: "xterm-256color", ...more }, + cwd: String(dir), + terminal: { + cols: 100, + rows: 40, + data(_terminal, chunk) { + output += decoder.decode(chunk, { stream: true }); + }, + }, + }); + const exitCode = await proc.exited; + return { output: withoutDirectory(output.replaceAll("\r\n", "\n"), dir), exitCode }; +} + +const link = (page: string, text: string) => + `\x1b]8;;https://bun.com/docs/lint/rules/${page}\x1b\\${text}\x1b]8;;\x1b\\`; +const withoutLinks = (text: string) => text.replace(/\x1b\]8;;[^\x1b]*\x1b\\/g, ""); + +const bad = "var a = 1;\ndebugger;\nconsole.log(a);\n"; +const warnsAboutVar = ["--rule", "no-var: warn"]; +const eslint = { "eslint.config.js": `export default [{ rules: { "no-debugger": "error", "no-var": "warn" } }];\n` }; +const oxlint = { ".oxlintrc.json": `{ "rules": { "no-debugger": "error", "no-var": "warn" } }` }; + +describe.concurrent("without a configuration file", () => { + test("the default format is pretty, which ends with a summary", async () => { + const [byDefault, named] = await Promise.all([ + lint({ "a.js": bad }, warnsAboutVar), + lint({ "a.js": bad }, [...warnsAboutVar, "-f", "pretty"]), + ]); + expect(byDefault.stdout).toBe( + [ + "1 | var a = 1;", + " ^", + "warn: Unexpected var, use let or const instead. no-var", + " at a.js:1:1", + "", + "1 | var a = 1;", + "2 | debugger;", + " ^", + "error: Unexpected 'debugger' statement. no-debugger", + " at a.js:2:1", + "", + "2 problems (1 error, 1 warning)", + "1 can be fixed with --fix", + "", + ].join("\n"), + ); + expect(byDefault.stderr).toBe("Linted 1 file\n"); + expect(byDefault.exitCode).toBe(1); + expect(named).toEqual(byDefault); + }); + + test("a [lint] section in bunfig.toml changes nothing about that", async () => { + const { stdout, exitCode } = await lint({ "a.js": bad, "bunfig.toml": `[lint.rules]\n"no-var" = "warn"\n` }); + expect(stdout).toEndWith("\n\n2 problems (1 error, 1 warning)\n1 can be fixed with --fix\n"); + expect(exitCode).toBe(1); + }); + + test("the summary counts with the noun, and says nothing about --fix if it fixes nothing", async () => { + const [one, two] = await Promise.all([ + lint({ "a.js": "debugger;\n" }), + lint({ "a.js": "var a = 1, b = 2;\nvar c = a + b;\nconsole.log(c);\n" }, warnsAboutVar), + ]); + expect(one.stdout).toEndWith(" at a.js:1:1\n\n1 problem (1 error, 0 warnings)\n"); + expect(one.exitCode).toBe(1); + expect(two.stdout).toEndWith(" at a.js:2:1\n\n2 problems (0 errors, 2 warnings)\n2 can be fixed with --fix\n"); + expect(two.exitCode).toBe(0); + }); + + test("nothing is printed on stdout if there is no problem", async () => { + const { stdout, stderr, exitCode } = await lint({ "a.js": "console.log(1);\n" }); + expect(stdout).toBe(""); + expect(stderr).toBe("✓ No problems in 1 file\n"); + expect(exitCode).toBe(0); + }); + + test("--quiet: the summary is about what is shown", async () => { + const { stdout, exitCode } = await lint({ "a.js": bad }, [...warnsAboutVar, "--quiet"]); + expect(stdout).not.toContain("no-var"); + expect(stdout).toEndWith("\n\n1 problem (1 error, 0 warnings)\n"); + expect(exitCode).toBe(1); + }); + + test("above 50 problems identical ones are grouped, and the summary counts them all", async () => { + const many = { "a.js": Buffer.alloc(10 * 60, "debugger;\n").toString(), "b.js": bad }; + const [grouped, all] = await Promise.all([lint(many, warnsAboutVar), lint(many, [...warnsAboutVar, "--all"])]); + expect(grouped.stdout).toBe( + [ + "1 | debugger;", + " ^", + "error: Unexpected 'debugger' statement. no-debugger", + " at a.js:1:1", + " 61 times in 2 files", + " 60 a.js:1", + " 1 b.js:2", + "", + "1 | var a = 1;", + " ^", + "warn: Unexpected var, use let or const instead. no-var", + " at b.js:1:1", + "", + "bun lint --all show every problem", + "", + "62 problems (61 errors, 1 warning)", + "1 can be fixed with --fix", + "", + ].join("\n"), + ); + expect(all.stdout.match(/Unexpected 'debugger' statement/g)).toHaveLength(61); + expect(all.stdout).not.toContain("bun lint --all"); + expect(all.stdout).toEndWith("\n\n62 problems (61 errors, 1 warning)\n1 can be fixed with --fix\n"); + }); + + test("-f stylish is ESLint's", async () => { + const { stdout, exitCode } = await lint({ "a.js": bad }, [...warnsAboutVar, "-f", "stylish"]); + expect(stdout).toBe( + [ + "", + "/a.js", + " 1:1 warning Unexpected var, use let or const instead no-var", + " 2:1 error Unexpected 'debugger' statement no-debugger", + "", + "✖ 2 problems (1 error, 1 warning)", + " 0 errors and 1 warning potentially fixable with the `--fix` option.", + "", + "", + ].join("\n"), + ); + expect(exitCode).toBe(1); + }); + + test("an agent gets agent, and GitHub Actions the annotations after the report", async () => { + const [agent, actions] = await Promise.all([ + lint({ "a.js": "debugger;\n" }, [], { AGENT: "1" }), + lint({ "a.js": "debugger;\n" }, [], { GITHUB_ACTIONS: "true" }), + ]); + expect(agent.stdout).toStartWith(``); + expect(agent.stdout).not.toContain("1 problem"); + expect(actions.stdout).toEndWith( + "\n\n1 problem (1 error, 0 warnings)\n" + + "::error file=a.js,line=1,col=1,endLine=1,endColumn=10,title=no-debugger::Unexpected 'debugger' statement.\n", + ); + }); +}); + +describe.concurrent("with a configuration file", () => { + test("ESLint's: the default format is stylish, and pretty has no summary", async () => { + const [byDefault, stylish, pretty] = await Promise.all([ + lint({ ...eslint, "a.js": bad }), + lint({ ...eslint, "a.js": bad }, ["-f", "stylish"]), + lint({ ...eslint, "a.js": bad }, ["-f", "pretty"]), + ]); + expect(byDefault.stdout).toContain("\n✖ 2 problems (1 error, 1 warning)\n"); + expect(byDefault).toEqual(stylish); + expect(pretty.stdout).toEndWith("error: Unexpected 'debugger' statement. no-debugger\n at a.js:2:1\n\n"); + }); + + test("-c counts as one", async () => { + const { stdout } = await lint({ "my.config.js": eslint["eslint.config.js"], "a.js": bad }, ["-c", "my.config.js"]); + expect(stdout).toContain("\n✖ 2 problems (1 error, 1 warning)\n"); + }); + + test("oxlint's: no summary but its own last line", async () => { + const { stdout } = await lint({ ...oxlint, "a.js": bad }); + expect(stdout).not.toContain("2 problems"); + expect(withoutTime(stdout)).toEndWith("\n\nLinted 1 file\n"); + }); +}); + +describe.concurrent("the name of a rule leads to its page", () => { + const files = { "a.js": "debugger;\n" }; + const configs = { none: {}, eslint, oxlint }; + // The last line has the time that the run took. + const lines = (text: string) => text.split("\n").filter(line => !line.includes("Linted 1 file")); + + test.skipIf(!hasTerminal).each([ + ["none", "pretty", "no-debugger"], + ["none", "stylish", "no-debugger"], + ["eslint", "pretty", "no-debugger"], + ["eslint", "stylish", "no-debugger"], + ["oxlint", "pretty", "no-debugger"], + ["oxlint", "stylish", "eslint(no-debugger)"], + ] as const)("in a terminal: %s, %s", async (config, format, name) => { + const project = { ...configs[config], ...files }; + const [shown, piped] = await Promise.all([ + inTerminal(project, ["-f", format]), + lint(project, ["-f", format], { FORCE_COLOR: "1" }), + ]); + expect(shown.output).toContain(link("eslint/no-debugger", name)); + // Nothing else is different. + expect(piped.stdout).toContain("\x1b["); + expect(piped.stdout).not.toContain("\x1b]8"); + expect(lines(withoutLinks(shown.output))).toEqual(lines(piped.stdout + piped.stderr)); + }); + + test.skipIf(!hasTerminal)("by the directory of the plugin, not by its prefix", async () => { + const { output } = await inTerminal({ "a.ts": "export const a: any = 1;\n" }); + expect(output).toContain(link("typescript/no-explicit-any", "@typescript-eslint/no-explicit-any")); + }); + + test.skipIf(!hasTerminal)("not without colors, and not in a file", async () => { + const [plain, refused, toFile] = await Promise.all([ + inTerminal(files, [], { NO_COLOR: "1" }), + inTerminal(files, ["--no-color"]), + inTerminal(files, ["-o", "report.txt"]), + ]); + expect(plain.output).toContain("no-debugger"); + expect(refused.output).toContain("no-debugger"); + for (const { output } of [plain, refused, toFile]) expect(output).not.toContain("\x1b]8"); + }); + + test.each(["pretty", "stylish"])("not in a pipe: %s", async format => { + const [plain, colored, asked] = await Promise.all([ + lint(files, ["-f", format]), + lint(files, ["-f", format], { FORCE_COLOR: "1" }), + lint(files, ["-f", format, "--color"]), + ]); + expect(plain.stdout).not.toContain("\x1b"); + for (const { stdout } of [colored, asked]) { + expect(stdout).toContain("\x1b["); + expect(stdout).not.toContain("\x1b]8"); + expect(Bun.stripANSI(stdout)).toBe(plain.stdout); + } + }); +}); + +describe.concurrent("--fix says what it has fixed", () => { + const twice = "var a = 1;\nvar b = 2;\nconsole.log(a, b);\n"; + const noVar = ["--rule", "no-var: error"]; + + test("how many problems, in how many files", async () => { + const [two, one, three] = await Promise.all([ + lint({ "a.js": twice, "b.js": "console.log(1);\n" }, ["--fix", ...noVar]), + lint({ "a.js": "var a = 1;\nconsole.log(a);\n" }, ["--fix", ...noVar]), + lint({ "a.js": twice, "b.js": "var a = 1;\nconsole.log(a);\n" }, ["--fix", ...noVar]), + ]); + expect(two).toEqual({ stdout: "", stderr: "Fixed 2 problems in 1 file\n✓ No problems in 2 files\n", exitCode: 0 }); + expect(one).toEqual({ stdout: "", stderr: "Fixed 1 problem in 1 file\n✓ No problems in 1 file\n", exitCode: 0 }); + expect(three.stderr).toBe("Fixed 3 problems in 2 files\n✓ No problems in 2 files\n"); + }); + + test("before what is said about the rest", async () => { + const { stdout, stderr, exitCode } = await lint({ "a.js": "debugger;\n" + twice }, ["--fix", ...noVar]); + expect(stdout).toEndWith("\n\n1 problem (1 error, 0 warnings)\n"); + expect(stderr).toBe("Fixed 2 problems in 1 file\nLinted 1 file\n"); + expect(exitCode).toBe(1); + }); + + test("what a fix leaves for the next pass counts too", async () => { + const { stderr } = await lint({ "a.js": "var a = 1;\nconsole.log(a);\n" }, [ + "--fix", + ...noVar, + "--rule", + "prefer-const: error", + ]); + expect(stderr).toBe("Fixed 2 problems in 1 file\n✓ No problems in 1 file\n"); + }); + + test("nothing if nothing is fixed, or nothing is written", async () => { + const [clean, unfixable, dry] = await Promise.all([ + lint({ "a.js": "console.log(1);\n" }, ["--fix"]), + lint({ "a.js": "debugger;\n" }, ["--fix"]), + lint({ "a.js": twice }, ["--fix-dry-run", ...noVar]), + ]); + expect(clean.stderr).toBe("✓ No problems in 1 file\n"); + expect(unfixable.stderr).toBe("Linted 1 file\n"); + expect(dry.stderr).toBe("✓ No problems in 1 file\n"); + }); + + test("with a configuration file of ESLint, and of oxlint, whose stdout is as it was", async () => { + const [ofEslint, ofOxlint] = await Promise.all([ + lint({ ...eslint, "a.js": twice }, ["--fix"]), + lint({ ...oxlint, "a.js": twice }, ["--fix"]), + ]); + expect(ofEslint).toEqual({ + stdout: "", + stderr: "Fixed 2 problems in 1 file\n✓ No problems in 2 files\n", + exitCode: 0, + }); + expect({ ...ofOxlint, stdout: withoutTime(ofOxlint.stdout) }).toEqual({ + stdout: "✓ No problems in 1 file, fixed 1\n", + stderr: "Fixed 2 problems in 1 file\n", + exitCode: 0, + }); + }); +}); diff --git a/test/cli/lint/prettier-rule.test.ts b/test/cli/lint/prettier-rule.test.ts index 3ae94a209e85..b5123eb18a43 100644 --- a/test/cli/lint/prettier-rule.test.ts +++ b/test/cli/lint/prettier-rule.test.ts @@ -179,13 +179,13 @@ describe.concurrent("prettier/prettier", () => { "another minor version of prettier", installed("3.3.3"), {}, - "the prettier that is installed is not 3.9, which bun format follows", + "the installed prettier is not version 3.9, which bun format matches", ], [ "another major version of prettier", installed("2.8.8"), {}, - "the prettier that is installed is not 3.9, which bun format follows", + "the installed prettier is not version 3.9, which bun format matches", ], [ "no prettier", @@ -194,26 +194,26 @@ describe.concurrent("prettier/prettier", () => { installed()["node_modules/eslint-plugin-prettier/package.json"], }, {}, - "the prettier that is installed is not 3.9, which bun format follows", + "the installed prettier is not version 3.9, which bun format matches", ], [ "another major version of the plugin", installed("3.9.9", "4.2.1"), {}, - "the eslint-plugin-prettier that is installed is not 5", + "the installed eslint-plugin-prettier is not version 5", ], [ "a plugin of Prettier that is not built in", installed(), { ".prettierrc": `{ "plugins": ["prettier-plugin-of-theirs"] }` }, - "a plugin of Prettier that bun format does not have may print them", + "the Prettier config uses a plugin that bun format does not include", ], ])("the rule of the package is asked: %s", async (_, packages, more, why) => { const files = { "a.js": "const a = {b:1}\n", ...packages, ...more, "eslint.config.mjs": config() }; // A note is for people. const [{ reports }, { stderr }] = await Promise.all([lint(files), run(files, "-f", "stylish")]); expect(reports).toEqual({ "a.js": ["theirs"] }); - expect(stderr.split("\n")).toContain(`note: prettier/prettier ran in JavaScript for 1 file: ${why}.`); + expect(stderr.split("\n")).toContain(`note: prettier/prettier ran in JavaScript for 1 file because ${why}`); }); // The parser of ESLint takes it, that of Prettier, which goes by the name of the file, does not. @@ -228,7 +228,7 @@ describe.concurrent("prettier/prettier", () => { const [{ reports }, { stderr }] = await Promise.all([lint(files), run(files, "-f", "stylish")]); expect(reports).toEqual({ "a.js": ["theirs"], "b.js": expected["git.js"] }); expect(stderr.split("\n")).toContain( - "note: prettier/prettier ran in JavaScript for 1 file: bun format finds a syntax error in them.", + "note: prettier/prettier ran in JavaScript for 1 file because bun format found a syntax error", ); }); @@ -292,7 +292,7 @@ describe.concurrent("prettier/prettier", () => { const [{ reports }, { stderr }] = await Promise.all([lint(files), run(files, "-f", "stylish")]); expect(reports).toEqual({ "x/a.js": expected["git.js"], "y/a.js": ["theirs"] }); expect(stderr.split("\n")).toContain( - "note: prettier/prettier ran in JavaScript for 1 file: the eslint-plugin-prettier that is installed is not 5.", + "note: prettier/prettier ran in JavaScript for 1 file because the installed eslint-plugin-prettier is not version 5", ); }); @@ -310,14 +310,14 @@ describe.concurrent("prettier/prettier", () => { // Nobody else looks at them. test("bun/format reports the files that it cannot check, and why", async () => { const rule = { "eslint.config.mjs": `export default [{ rules: { "bun/format": "error" } }];` }; - const unchecked = (why: string) => [`Files like this are not checked: ${why}.`]; + const unchecked = (why: string) => [`This file was not checked because ${why}.`]; const plugin = await lint({ ...rule, ".prettierrc": '{ "plugins": ["prettier-plugin-brace-style"] }', "a.js": "a ;\n", }); expect(plugin.reports["a.js"]).toEqual( - unchecked("a plugin of Prettier that bun format does not have may print them"), + unchecked("the Prettier config uses a plugin that bun format does not include"), ); expect(plugin.exitCode).toBe(1); const classes = await lint({ @@ -327,7 +327,7 @@ describe.concurrent("prettier/prettier", () => { "b.js": "b ;\n", "off.js": '/* eslint-disable bun/format */\nhtml``;\n', }); - expect(classes.reports["a.js"]).toEqual(unchecked("only bun format asks Tailwind CSS where their classes go")); + expect(classes.reports["a.js"]).toEqual(unchecked("only bun format can sort Tailwind CSS classes")); expect(classes.reports["off.js"]).toEqual([]); expect(classes.reports["b.js"]).toEqual(['1:2-1:4 delete [1,3] "" Delete `··`']); expect(classes.exitCode).toBe(1); @@ -361,7 +361,7 @@ describe.concurrent("prettier/prettier", () => { "--threads=1", ); expect(stdout.split("\n").filter(it => it.includes("bun(format)"))).toEqual([ - "a/a.js:1:1: Files like this are not checked: bun format does not know one of the options. [Error/bun(format)]", + "a/a.js:1:1: This file was not checked because bun format does not support one of the options. [Error/bun(format)]", "b/b.js:1:2: Delete `··` [Error/bun(format)]", ]); }); @@ -390,7 +390,7 @@ describe.concurrent("prettier/prettier", () => { const reportsOf = async (files: Record) => (await run(files, "-f", "unix")).stdout.split("\n").filter(it => it.includes("bun(format)")); expect(await reportsOf(classes)).toEqual(["a.js:1:31: Delete `··` [Error/bun(format)]"]); - const unchecked = "Files like this are not checked: only bun format asks Tailwind CSS where their classes go."; + const unchecked = "This file was not checked because only bun format can sort Tailwind CSS classes."; expect(await reportsOf({ ...classes, "node_modules/tailwindcss/package.json": "{}" })).toEqual([ `a.js:1:1: ${unchecked} [Error/bun(format)]`, `b.js:1:1: ${unchecked} [Error/bun(format)]`, @@ -413,7 +413,7 @@ describe.concurrent("prettier/prettier", () => { const [{ reports }, { stderr }] = await Promise.all([lint(files), run(files, "-f", "stylish")]); expect(reports).toEqual({ "a.js": ["theirs"] }); expect(stderr.split("\n")).toContain( - "note: prettier/prettier ran in JavaScript for 1 file: bun format does not know one of the options.", + "note: prettier/prettier ran in JavaScript for 1 file because bun format does not support one of the options", ); }); }); From 1cf0d61945dcc252f770564b0f6e0ee6a93b6a5b Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Mon, 12 Oct 2026 06:37:19 +0000 Subject: [PATCH 07/49] lint, format: 1135 files --- .prettierignore | 2 + Cargo.lock | 2 + completions/bun.bash | 2 +- completions/bun.fish | 1 + completions/bun.zsh | 1 + docs/guides/format/migrate-from-oxfmt.mdx | 39 +- docs/guides/format/migrate-from-prettier.mdx | 43 +- docs/guides/lint/migrate-from-eslint.mdx | 14 +- docs/guides/lint/migrate-from-oxlint.mdx | 34 +- docs/lint/plugins.mdx | 6 +- .../bun-test/consistent-test-filename.mdx | 49 +- .../rules/bun-test/consistent-test-it.mdx | 35 +- docs/lint/rules/bun-test/expect-expect.mdx | 38 +- docs/lint/rules/bun-test/max-expects.mdx | 54 +- .../rules/bun-test/max-nested-describe.mdx | 45 +- docs/lint/rules/bun-test/no-alias-methods.mdx | 37 +- .../rules/bun-test/no-commented-out-tests.mdx | 12 +- .../rules/bun-test/no-conditional-expect.mdx | 13 +- .../rules/bun-test/no-conditional-in-test.mdx | 14 +- .../rules/bun-test/no-conditional-tests.mdx | 13 +- .../lint/rules/bun-test/no-disabled-tests.mdx | 12 +- docs/lint/rules/bun-test/no-done-callback.mdx | 13 +- .../rules/bun-test/no-duplicate-hooks.mdx | 11 +- docs/lint/rules/bun-test/no-export.mdx | 12 +- docs/lint/rules/bun-test/no-focused-tests.mdx | 11 +- docs/lint/rules/bun-test/no-hooks.mdx | 18 +- .../rules/bun-test/no-identical-title.mdx | 14 +- .../no-interpolation-in-snapshots.mdx | 21 +- .../rules/bun-test/no-large-snapshots.mdx | 22 +- .../bun-test/no-restricted-jest-methods.mdx | 15 +- .../rules/bun-test/no-restricted-matchers.mdx | 49 +- .../rules/bun-test/no-standalone-expect.mdx | 22 +- docs/lint/rules/bun-test/no-test-prefixes.mdx | 16 +- .../bun-test/no-test-return-statement.mdx | 15 +- .../no-unneeded-async-expect-function.mdx | 14 +- .../padding-around-after-all-blocks.mdx | 14 +- .../bun-test/padding-around-test-blocks.mdx | 13 +- .../rules/bun-test/prefer-called-once.mdx | 15 +- .../rules/bun-test/prefer-called-times.mdx | 13 +- .../rules/bun-test/prefer-called-with.mdx | 13 +- .../bun-test/prefer-comparison-matcher.mdx | 25 +- .../prefer-describe-function-title.mdx | 18 +- docs/lint/rules/bun-test/prefer-each.mdx | 15 +- .../bun-test/prefer-ending-with-an-expect.mdx | 34 +- .../bun-test/prefer-equality-matcher.mdx | 16 +- .../bun-test/prefer-expect-assertions.mdx | 45 +- .../rules/bun-test/prefer-expect-resolves.mdx | 15 +- .../rules/bun-test/prefer-expect-type-of.mdx | 15 +- .../rules/bun-test/prefer-hooks-in-order.mdx | 18 +- .../rules/bun-test/prefer-hooks-on-top.mdx | 14 +- .../rules/bun-test/prefer-lowercase-title.mdx | 56 +- .../prefer-mock-promise-shorthand.mdx | 20 +- .../bun-test/prefer-mock-return-shorthand.mdx | 29 +- .../rules/bun-test/prefer-snapshot-hint.mdx | 35 +- docs/lint/rules/bun-test/prefer-spy-on.mdx | 21 +- .../prefer-strict-boolean-matchers.mdx | 26 +- .../rules/bun-test/prefer-strict-equal.mdx | 13 +- .../rules/bun-test/prefer-to-be-falsy.mdx | 17 +- .../rules/bun-test/prefer-to-be-truthy.mdx | 17 +- docs/lint/rules/bun-test/prefer-to-be.mdx | 37 +- .../lint/rules/bun-test/prefer-to-contain.mdx | 16 +- .../prefer-to-have-been-called-times.mdx | 20 +- .../bun-test/prefer-to-have-been-called.mdx | 17 +- .../rules/bun-test/prefer-to-have-length.mdx | 11 +- docs/lint/rules/bun-test/prefer-todo.mdx | 15 +- docs/lint/rules/bun-test/require-hook.mdx | 27 +- .../bun-test/require-mock-type-parameters.mdx | 17 +- .../rules/bun-test/require-test-timeout.mdx | 49 +- .../bun-test/require-to-throw-message.mdx | 11 +- .../bun-test/require-top-level-describe.mdx | 17 +- .../bun-test/valid-describe-callback.mdx | 46 +- .../bun-test/valid-expect-in-promise.mdx | 37 +- docs/lint/rules/bun-test/valid-expect.mdx | 51 +- docs/lint/rules/bun-test/valid-title.mdx | 46 +- docs/lint/rules/bun-test/warn-todo.mdx | 13 +- .../rules/bun/consistent-directive-prefix.mdx | 17 +- docs/lint/rules/bun/format.mdx | 25 +- .../rules/bun/jsx-no-forbidden-nesting.mdx | 29 +- .../lint/rules/bun/no-array-just-to-count.mdx | 19 +- docs/lint/rules/bun/no-cast-to-brand.mdx | 27 +- .../rules/bun/no-console-window-flash.mdx | 13 +- docs/lint/rules/bun/no-cwd-dependent-path.mdx | 21 +- .../rules/bun/no-eager-dynamic-import.mdx | 14 +- docs/lint/rules/bun/no-eager-native-addon.mdx | 13 +- .../lint/rules/bun/no-env-at-module-scope.mdx | 19 +- docs/lint/rules/bun/no-literal-temp-dir.mdx | 17 +- .../rules/bun/no-negative-repeat-count.mdx | 14 +- .../rules/bun/no-redundant-catch-type.mdx | 11 +- docs/lint/rules/bun/no-restricted-text.mdx | 26 +- .../rules/bun/no-side-effects-on-import.mdx | 21 +- docs/lint/rules/bun/no-unbounded-requests.mdx | 21 +- .../rules/bun/no-unhandled-emitter-errors.mdx | 37 +- .../lint/rules/bun/no-unportable-commands.mdx | 26 +- .../no-unseeded-random-in-property-test.mdx | 13 +- .../rules/bun/no-useless-spread-fallback.mdx | 27 +- docs/lint/rules/bun/prefer-builtin-sleep.mdx | 14 +- docs/lint/rules/bun/prefer-lazy-schema.mdx | 28 +- docs/lint/rules/bun/prefer-local-module.mdx | 32 +- docs/lint/rules/bun/prefer-timeout-signal.mdx | 20 +- .../bun/prefer-url-for-sibling-files.mdx | 17 +- .../rules/bun/react-no-latest-value-ref.mdx | 13 +- .../bun/react-no-local-render-helpers.mdx | 20 +- .../rules/bun/react-no-namespace-hooks.mdx | 12 +- .../bun/react-prefer-updater-function.mdx | 14 +- docs/lint/rules/eslint/accessor-pairs.mdx | 27 +- .../rules/eslint/array-callback-return.mdx | 26 +- docs/lint/rules/eslint/arrow-body-style.mdx | 25 +- docs/lint/rules/eslint/block-scoped-var.mdx | 12 +- docs/lint/rules/eslint/callback-return.mdx | 13 +- docs/lint/rules/eslint/camelcase.mdx | 33 +- .../rules/eslint/capitalized-comments.mdx | 24 +- .../rules/eslint/class-methods-use-this.mdx | 25 +- docs/lint/rules/eslint/complexity.mdx | 21 +- docs/lint/rules/eslint/consistent-return.mdx | 17 +- docs/lint/rules/eslint/consistent-this.mdx | 14 +- docs/lint/rules/eslint/constructor-super.mdx | 14 +- docs/lint/rules/eslint/curly.mdx | 37 +- docs/lint/rules/eslint/default-case-last.mdx | 11 +- docs/lint/rules/eslint/default-case.mdx | 18 +- docs/lint/rules/eslint/default-param-last.mdx | 11 +- docs/lint/rules/eslint/dot-notation.mdx | 25 +- docs/lint/rules/eslint/eqeqeq.mdx | 21 +- docs/lint/rules/eslint/for-direction.mdx | 13 +- docs/lint/rules/eslint/func-name-matching.mdx | 30 +- docs/lint/rules/eslint/func-names.mdx | 28 +- docs/lint/rules/eslint/func-style.mdx | 43 +- docs/lint/rules/eslint/getter-return.mdx | 19 +- docs/lint/rules/eslint/global-require.mdx | 13 +- .../rules/eslint/grouped-accessor-pairs.mdx | 27 +- docs/lint/rules/eslint/guard-for-in.mdx | 13 +- .../lint/rules/eslint/handle-callback-err.mdx | 16 +- docs/lint/rules/eslint/id-blacklist.mdx | 12 +- docs/lint/rules/eslint/id-denylist.mdx | 14 +- docs/lint/rules/eslint/id-length.mdx | 37 +- docs/lint/rules/eslint/id-match.mdx | 32 +- docs/lint/rules/eslint/init-declarations.mdx | 29 +- .../eslint/logical-assignment-operators.mdx | 25 +- .../rules/eslint/max-classes-per-file.mdx | 23 +- docs/lint/rules/eslint/max-depth.mdx | 21 +- .../rules/eslint/max-lines-per-function.mdx | 27 +- docs/lint/rules/eslint/max-lines.mdx | 23 +- .../rules/eslint/max-nested-callbacks.mdx | 29 +- docs/lint/rules/eslint/max-params.mdx | 25 +- docs/lint/rules/eslint/max-statements.mdx | 18 +- docs/lint/rules/eslint/new-cap.mdx | 34 +- docs/lint/rules/eslint/no-alert.mdx | 12 +- .../rules/eslint/no-array-constructor.mdx | 12 +- .../eslint/no-async-promise-executor.mdx | 13 +- docs/lint/rules/eslint/no-await-in-loop.mdx | 15 +- docs/lint/rules/eslint/no-bitwise.mdx | 20 +- .../rules/eslint/no-buffer-constructor.mdx | 11 +- docs/lint/rules/eslint/no-caller.mdx | 11 +- .../rules/eslint/no-case-declarations.mdx | 11 +- docs/lint/rules/eslint/no-catch-shadow.mdx | 11 +- docs/lint/rules/eslint/no-class-assign.mdx | 12 +- .../lint/rules/eslint/no-compare-neg-zero.mdx | 12 +- docs/lint/rules/eslint/no-cond-assign.mdx | 25 +- docs/lint/rules/eslint/no-console.mdx | 20 +- docs/lint/rules/eslint/no-const-assign.mdx | 12 +- .../eslint/no-constant-binary-expression.mdx | 17 +- .../rules/eslint/no-constant-condition.mdx | 23 +- .../rules/eslint/no-constructor-return.mdx | 12 +- docs/lint/rules/eslint/no-continue.mdx | 11 +- docs/lint/rules/eslint/no-control-regex.mdx | 14 +- docs/lint/rules/eslint/no-debugger.mdx | 11 +- docs/lint/rules/eslint/no-delete-var.mdx | 11 +- docs/lint/rules/eslint/no-div-regex.mdx | 11 +- docs/lint/rules/eslint/no-dupe-args.mdx | 11 +- .../rules/eslint/no-dupe-class-members.mdx | 14 +- docs/lint/rules/eslint/no-dupe-else-if.mdx | 13 +- docs/lint/rules/eslint/no-dupe-keys.mdx | 13 +- docs/lint/rules/eslint/no-duplicate-case.mdx | 12 +- .../rules/eslint/no-duplicate-imports.mdx | 23 +- docs/lint/rules/eslint/no-else-return.mdx | 20 +- .../rules/eslint/no-empty-character-class.mdx | 12 +- docs/lint/rules/eslint/no-empty-function.mdx | 55 +- docs/lint/rules/eslint/no-empty-pattern.mdx | 21 +- .../rules/eslint/no-empty-static-block.mdx | 12 +- docs/lint/rules/eslint/no-empty.mdx | 18 +- docs/lint/rules/eslint/no-eq-null.mdx | 11 +- docs/lint/rules/eslint/no-eval.mdx | 19 +- docs/lint/rules/eslint/no-ex-assign.mdx | 13 +- docs/lint/rules/eslint/no-extend-native.mdx | 19 +- docs/lint/rules/eslint/no-extra-bind.mdx | 13 +- .../rules/eslint/no-extra-boolean-cast.mdx | 22 +- docs/lint/rules/eslint/no-extra-label.mdx | 12 +- docs/lint/rules/eslint/no-fallthrough.mdx | 26 +- docs/lint/rules/eslint/no-func-assign.mdx | 13 +- docs/lint/rules/eslint/no-global-assign.mdx | 20 +- .../rules/eslint/no-implicit-coercion.mdx | 29 +- .../lint/rules/eslint/no-implicit-globals.mdx | 21 +- docs/lint/rules/eslint/no-implied-eval.mdx | 13 +- docs/lint/rules/eslint/no-import-assign.mdx | 11 +- docs/lint/rules/eslint/no-inline-comments.mdx | 21 +- .../rules/eslint/no-inner-declarations.mdx | 29 +- docs/lint/rules/eslint/no-invalid-regexp.mdx | 19 +- docs/lint/rules/eslint/no-invalid-this.mdx | 24 +- .../rules/eslint/no-irregular-whitespace.mdx | 33 +- docs/lint/rules/eslint/no-iterator.mdx | 11 +- docs/lint/rules/eslint/no-label-var.mdx | 12 +- docs/lint/rules/eslint/no-labels.mdx | 20 +- docs/lint/rules/eslint/no-lone-blocks.mdx | 14 +- docs/lint/rules/eslint/no-lonely-if.mdx | 12 +- docs/lint/rules/eslint/no-loop-func.mdx | 14 +- .../rules/eslint/no-loss-of-precision.mdx | 11 +- docs/lint/rules/eslint/no-magic-numbers.mdx | 67 +- .../eslint/no-misleading-character-class.mdx | 20 +- docs/lint/rules/eslint/no-mixed-requires.mdx | 24 +- docs/lint/rules/eslint/no-multi-assign.mdx | 25 +- docs/lint/rules/eslint/no-multi-str.mdx | 26 +- docs/lint/rules/eslint/no-native-reassign.mdx | 19 +- .../rules/eslint/no-negated-condition.mdx | 13 +- docs/lint/rules/eslint/no-negated-in-lhs.mdx | 13 +- docs/lint/rules/eslint/no-nested-ternary.mdx | 12 +- docs/lint/rules/eslint/no-new-func.mdx | 12 +- .../eslint/no-new-native-nonconstructor.mdx | 12 +- docs/lint/rules/eslint/no-new-object.mdx | 11 +- docs/lint/rules/eslint/no-new-require.mdx | 11 +- docs/lint/rules/eslint/no-new-symbol.mdx | 11 +- docs/lint/rules/eslint/no-new-wrappers.mdx | 12 +- docs/lint/rules/eslint/no-new.mdx | 11 +- .../eslint/no-nonoctal-decimal-escape.mdx | 11 +- docs/lint/rules/eslint/no-obj-calls.mdx | 13 +- .../rules/eslint/no-object-constructor.mdx | 12 +- docs/lint/rules/eslint/no-octal-escape.mdx | 11 +- docs/lint/rules/eslint/no-octal.mdx | 11 +- docs/lint/rules/eslint/no-param-reassign.mdx | 24 +- docs/lint/rules/eslint/no-path-concat.mdx | 12 +- docs/lint/rules/eslint/no-plusplus.mdx | 17 +- docs/lint/rules/eslint/no-process-env.mdx | 11 +- docs/lint/rules/eslint/no-process-exit.mdx | 11 +- .../eslint/no-promise-executor-return.mdx | 17 +- docs/lint/rules/eslint/no-proto.mdx | 11 +- .../rules/eslint/no-prototype-builtins.mdx | 11 +- docs/lint/rules/eslint/no-redeclare.mdx | 22 +- docs/lint/rules/eslint/no-regex-spaces.mdx | 11 +- .../rules/eslint/no-restricted-exports.mdx | 39 +- .../rules/eslint/no-restricted-globals.mdx | 24 +- .../rules/eslint/no-restricted-imports.mdx | 49 +- .../rules/eslint/no-restricted-modules.mdx | 21 +- .../rules/eslint/no-restricted-properties.mdx | 30 +- .../rules/eslint/no-restricted-syntax.mdx | 23 +- docs/lint/rules/eslint/no-return-assign.mdx | 25 +- docs/lint/rules/eslint/no-return-await.mdx | 11 +- docs/lint/rules/eslint/no-script-url.mdx | 13 +- docs/lint/rules/eslint/no-self-assign.mdx | 18 +- docs/lint/rules/eslint/no-self-compare.mdx | 13 +- docs/lint/rules/eslint/no-sequences.mdx | 30 +- docs/lint/rules/eslint/no-setter-return.mdx | 11 +- .../eslint/no-shadow-restricted-names.mdx | 20 +- docs/lint/rules/eslint/no-shadow.mdx | 50 +- docs/lint/rules/eslint/no-sparse-arrays.mdx | 18 +- docs/lint/rules/eslint/no-sync.mdx | 18 +- .../eslint/no-template-curly-in-string.mdx | 11 +- docs/lint/rules/eslint/no-ternary.mdx | 11 +- .../rules/eslint/no-this-before-super.mdx | 13 +- docs/lint/rules/eslint/no-throw-literal.mdx | 11 +- docs/lint/rules/eslint/no-unassigned-vars.mdx | 12 +- docs/lint/rules/eslint/no-undef-init.mdx | 11 +- docs/lint/rules/eslint/no-undef.mdx | 19 +- docs/lint/rules/eslint/no-undefined.mdx | 12 +- .../rules/eslint/no-underscore-dangle.mdx | 36 +- .../rules/eslint/no-unexpected-multiline.mdx | 35 +- .../eslint/no-unmodified-loop-condition.mdx | 20 +- .../lint/rules/eslint/no-unneeded-ternary.mdx | 20 +- .../lint/rules/eslint/no-unreachable-loop.mdx | 19 +- docs/lint/rules/eslint/no-unreachable.mdx | 14 +- docs/lint/rules/eslint/no-unsafe-finally.mdx | 20 +- docs/lint/rules/eslint/no-unsafe-negation.mdx | 22 +- .../eslint/no-unsafe-optional-chaining.mdx | 19 +- .../rules/eslint/no-unused-expressions.mdx | 33 +- docs/lint/rules/eslint/no-unused-labels.mdx | 17 +- .../no-unused-private-class-members.mdx | 12 +- docs/lint/rules/eslint/no-unused-vars.mdx | 45 +- .../rules/eslint/no-use-before-define.mdx | 36 +- .../rules/eslint/no-useless-assignment.mdx | 14 +- .../rules/eslint/no-useless-backreference.mdx | 11 +- docs/lint/rules/eslint/no-useless-call.mdx | 11 +- docs/lint/rules/eslint/no-useless-catch.mdx | 14 +- .../rules/eslint/no-useless-computed-key.mdx | 19 +- docs/lint/rules/eslint/no-useless-concat.mdx | 14 +- .../rules/eslint/no-useless-constructor.mdx | 12 +- docs/lint/rules/eslint/no-useless-escape.mdx | 20 +- docs/lint/rules/eslint/no-useless-rename.mdx | 20 +- docs/lint/rules/eslint/no-useless-return.mdx | 11 +- docs/lint/rules/eslint/no-var.mdx | 11 +- docs/lint/rules/eslint/no-void.mdx | 17 +- .../lint/rules/eslint/no-warning-comments.mdx | 26 +- docs/lint/rules/eslint/no-with.mdx | 11 +- docs/lint/rules/eslint/object-shorthand.mdx | 46 +- docs/lint/rules/eslint/one-var.mdx | 53 +- .../lint/rules/eslint/operator-assignment.mdx | 19 +- .../rules/eslint/prefer-arrow-callback.mdx | 32 +- docs/lint/rules/eslint/prefer-const.mdx | 23 +- .../rules/eslint/prefer-destructuring.mdx | 23 +- .../eslint/prefer-exponentiation-operator.mdx | 11 +- .../eslint/prefer-named-capture-group.mdx | 11 +- .../rules/eslint/prefer-numeric-literals.mdx | 11 +- .../rules/eslint/prefer-object-has-own.mdx | 11 +- .../rules/eslint/prefer-object-spread.mdx | 20 +- .../eslint/prefer-promise-reject-errors.mdx | 17 +- docs/lint/rules/eslint/prefer-reflect.mdx | 19 +- .../rules/eslint/prefer-regex-literals.mdx | 17 +- docs/lint/rules/eslint/prefer-rest-params.mdx | 11 +- docs/lint/rules/eslint/prefer-spread.mdx | 11 +- docs/lint/rules/eslint/prefer-template.mdx | 14 +- .../rules/eslint/preserve-caught-error.mdx | 23 +- docs/lint/rules/eslint/radix.mdx | 20 +- .../rules/eslint/require-atomic-updates.mdx | 18 +- docs/lint/rules/eslint/require-await.mdx | 14 +- .../rules/eslint/require-unicode-regexp.mdx | 16 +- docs/lint/rules/eslint/require-yield.mdx | 11 +- docs/lint/rules/eslint/sort-imports.mdx | 31 +- docs/lint/rules/eslint/sort-keys.mdx | 29 +- docs/lint/rules/eslint/sort-vars.mdx | 17 +- docs/lint/rules/eslint/strict.mdx | 29 +- docs/lint/rules/eslint/symbol-description.mdx | 11 +- docs/lint/rules/eslint/unicode-bom.mdx | 17 +- docs/lint/rules/eslint/use-isnan.mdx | 22 +- docs/lint/rules/eslint/valid-typeof.mdx | 17 +- docs/lint/rules/eslint/vars-on-top.mdx | 11 +- docs/lint/rules/eslint/yoda.mdx | 22 +- .../consistent-type-specifier-style.mdx | 29 +- docs/lint/rules/import/default.mdx | 21 +- .../rules/import/dynamic-import-chunkname.mdx | 37 +- .../import/enforce-node-protocol-usage.mdx | 24 +- docs/lint/rules/import/export.mdx | 60 +- docs/lint/rules/import/exports-last.mdx | 14 +- docs/lint/rules/import/extensions.mdx | 68 +- docs/lint/rules/import/first.mdx | 25 +- docs/lint/rules/import/group-exports.mdx | 18 +- docs/lint/rules/import/imports-first.mdx | 24 +- docs/lint/rules/import/max-dependencies.mdx | 30 +- docs/lint/rules/import/named.mdx | 38 +- docs/lint/rules/import/namespace.mdx | 23 +- .../rules/import/newline-after-import.mdx | 34 +- docs/lint/rules/import/no-absolute-path.mdx | 30 +- docs/lint/rules/import/no-amd.mdx | 15 +- .../import/no-anonymous-default-export.mdx | 45 +- docs/lint/rules/import/no-commonjs.mdx | 30 +- docs/lint/rules/import/no-cycle.mdx | 73 +- docs/lint/rules/import/no-default-export.mdx | 12 +- docs/lint/rules/import/no-deprecated.mdx | 15 +- docs/lint/rules/import/no-duplicates.mdx | 57 +- docs/lint/rules/import/no-dynamic-require.mdx | 17 +- .../rules/import/no-empty-named-blocks.mdx | 15 +- .../import/no-extraneous-dependencies.mdx | 74 +- .../rules/import/no-import-module-exports.mdx | 34 +- .../lint/rules/import/no-internal-modules.mdx | 30 +- docs/lint/rules/import/no-mutable-exports.mdx | 16 +- .../import/no-named-as-default-member.mdx | 13 +- .../lint/rules/import/no-named-as-default.mdx | 13 +- docs/lint/rules/import/no-named-default.mdx | 14 +- docs/lint/rules/import/no-named-export.mdx | 21 +- docs/lint/rules/import/no-namespace.mdx | 19 +- docs/lint/rules/import/no-nodejs-modules.mdx | 19 +- .../rules/import/no-relative-packages.mdx | 35 +- .../import/no-relative-parent-imports.mdx | 48 +- .../lint/rules/import/no-restricted-paths.mdx | 53 +- docs/lint/rules/import/no-self-import.mdx | 32 +- .../rules/import/no-unassigned-import.mdx | 25 +- docs/lint/rules/import/no-unresolved.mdx | 42 +- docs/lint/rules/import/no-unused-modules.mdx | 44 +- .../rules/import/no-useless-path-segments.mdx | 50 +- .../rules/import/no-webpack-loader-syntax.mdx | 13 +- docs/lint/rules/import/order.mdx | 62 +- .../rules/import/prefer-default-export.mdx | 29 +- docs/lint/rules/import/unambiguous.mdx | 26 +- docs/lint/rules/index.mdx | 1141 +++++++++++++++++ docs/lint/rules/jest/consistent-test-it.mdx | 35 +- docs/lint/rules/jest/expect-expect.mdx | 38 +- docs/lint/rules/jest/max-expects.mdx | 54 +- docs/lint/rules/jest/max-nested-describe.mdx | 45 +- docs/lint/rules/jest/no-alias-methods.mdx | 37 +- .../rules/jest/no-commented-out-tests.mdx | 12 +- .../lint/rules/jest/no-conditional-expect.mdx | 13 +- .../rules/jest/no-conditional-in-test.mdx | 13 +- .../rules/jest/no-confusing-set-timeout.mdx | 13 +- .../rules/jest/no-deprecated-functions.mdx | 34 +- docs/lint/rules/jest/no-disabled-tests.mdx | 12 +- docs/lint/rules/jest/no-done-callback.mdx | 13 +- docs/lint/rules/jest/no-duplicate-hooks.mdx | 11 +- docs/lint/rules/jest/no-export.mdx | 13 +- docs/lint/rules/jest/no-focused-tests.mdx | 11 +- docs/lint/rules/jest/no-hooks.mdx | 18 +- docs/lint/rules/jest/no-identical-title.mdx | 13 +- .../jest/no-interpolation-in-snapshots.mdx | 21 +- docs/lint/rules/jest/no-jasmine-globals.mdx | 34 +- docs/lint/rules/jest/no-large-snapshots.mdx | 43 +- docs/lint/rules/jest/no-mocks-import.mdx | 18 +- .../rules/jest/no-restricted-jest-methods.mdx | 16 +- .../rules/jest/no-restricted-matchers.mdx | 43 +- docs/lint/rules/jest/no-standalone-expect.mdx | 22 +- docs/lint/rules/jest/no-test-prefixes.mdx | 16 +- .../rules/jest/no-test-return-statement.mdx | 15 +- .../no-unneeded-async-expect-function.mdx | 14 +- .../rules/jest/no-untyped-mock-factory.mdx | 13 +- .../jest/padding-around-after-all-blocks.mdx | 14 +- .../rules/jest/padding-around-test-blocks.mdx | 13 +- docs/lint/rules/jest/prefer-called-with.mdx | 11 +- .../rules/jest/prefer-comparison-matcher.mdx | 25 +- docs/lint/rules/jest/prefer-each.mdx | 15 +- .../jest/prefer-ending-with-an-expect.mdx | 31 +- .../rules/jest/prefer-equality-matcher.mdx | 16 +- .../rules/jest/prefer-expect-assertions.mdx | 45 +- .../rules/jest/prefer-expect-resolves.mdx | 13 +- .../lint/rules/jest/prefer-hooks-in-order.mdx | 18 +- docs/lint/rules/jest/prefer-hooks-on-top.mdx | 14 +- .../jest/prefer-importing-jest-globals.mdx | 30 +- docs/lint/rules/jest/prefer-jest-mocked.mdx | 28 +- .../rules/jest/prefer-lowercase-title.mdx | 56 +- .../jest/prefer-mock-promise-shorthand.mdx | 20 +- .../jest/prefer-mock-return-shorthand.mdx | 29 +- docs/lint/rules/jest/prefer-snapshot-hint.mdx | 35 +- docs/lint/rules/jest/prefer-spy-on.mdx | 18 +- docs/lint/rules/jest/prefer-strict-equal.mdx | 13 +- docs/lint/rules/jest/prefer-to-be.mdx | 37 +- docs/lint/rules/jest/prefer-to-contain.mdx | 16 +- .../jest/prefer-to-have-been-called-times.mdx | 20 +- .../rules/jest/prefer-to-have-been-called.mdx | 17 +- .../lint/rules/jest/prefer-to-have-length.mdx | 11 +- docs/lint/rules/jest/prefer-todo.mdx | 13 +- docs/lint/rules/jest/require-hook.mdx | 49 +- .../rules/jest/require-to-throw-message.mdx | 11 +- .../rules/jest/require-top-level-describe.mdx | 17 +- .../rules/jest/valid-describe-callback.mdx | 56 +- .../rules/jest/valid-expect-in-promise.mdx | 37 +- docs/lint/rules/jest/valid-expect.mdx | 46 +- docs/lint/rules/jest/valid-title.mdx | 43 +- docs/lint/rules/jsx-a11y/alt-text.mdx | 30 +- .../rules/jsx-a11y/anchor-ambiguous-text.mdx | 43 +- .../rules/jsx-a11y/anchor-has-content.mdx | 36 +- docs/lint/rules/jsx-a11y/anchor-is-valid.mdx | 36 +- .../aria-activedescendant-has-tabindex.mdx | 11 +- docs/lint/rules/jsx-a11y/aria-props.mdx | 11 +- docs/lint/rules/jsx-a11y/aria-proptypes.mdx | 39 +- docs/lint/rules/jsx-a11y/aria-role.mdx | 22 +- .../jsx-a11y/aria-unsupported-elements.mdx | 11 +- .../rules/jsx-a11y/autocomplete-valid.mdx | 18 +- .../jsx-a11y/click-events-have-key-events.mdx | 11 +- .../jsx-a11y/control-has-associated-label.mdx | 71 +- .../rules/jsx-a11y/heading-has-content.mdx | 30 +- docs/lint/rules/jsx-a11y/html-has-lang.mdx | 35 +- docs/lint/rules/jsx-a11y/iframe-has-title.mdx | 13 +- .../lint/rules/jsx-a11y/img-redundant-alt.mdx | 22 +- .../jsx-a11y/interactive-supports-focus.mdx | 52 +- .../jsx-a11y/label-has-associated-control.mdx | 81 +- docs/lint/rules/jsx-a11y/lang.mdx | 31 +- .../lint/rules/jsx-a11y/media-has-caption.mdx | 24 +- .../jsx-a11y/mouse-events-have-key-events.mdx | 34 +- docs/lint/rules/jsx-a11y/no-access-key.mdx | 19 +- .../jsx-a11y/no-aria-hidden-on-focusable.mdx | 23 +- docs/lint/rules/jsx-a11y/no-autofocus.mdx | 21 +- .../jsx-a11y/no-distracting-elements.mdx | 24 +- ...ractive-element-to-noninteractive-role.mdx | 36 +- ...no-noninteractive-element-interactions.mdx | 50 +- ...nteractive-element-to-interactive-role.mdx | 31 +- .../jsx-a11y/no-noninteractive-tabindex.mdx | 50 +- .../rules/jsx-a11y/no-redundant-roles.mdx | 18 +- .../no-static-element-interactions.mdx | 51 +- .../rules/jsx-a11y/prefer-tag-over-role.mdx | 28 +- .../jsx-a11y/role-has-required-aria-props.mdx | 52 +- .../jsx-a11y/role-supports-aria-props.mdx | 31 +- docs/lint/rules/jsx-a11y/scope.mdx | 13 +- .../rules/jsx-a11y/tabindex-no-positive.mdx | 15 +- .../lint/rules/nextjs/google-font-display.mdx | 13 +- .../rules/nextjs/google-font-preconnect.mdx | 14 +- docs/lint/rules/nextjs/inline-script-id.mdx | 13 +- docs/lint/rules/nextjs/next-script-for-ga.mdx | 12 +- .../nextjs/no-assign-module-variable.mdx | 12 +- .../nextjs/no-async-client-component.mdx | 15 +- ...re-interactive-script-outside-document.mdx | 12 +- docs/lint/rules/nextjs/no-css-tags.mdx | 14 +- .../nextjs/no-document-import-in-page.mdx | 12 +- docs/lint/rules/nextjs/no-duplicate-head.mdx | 12 +- docs/lint/rules/nextjs/no-head-element.mdx | 12 +- .../nextjs/no-head-import-in-document.mdx | 11 +- .../rules/nextjs/no-html-link-for-pages.mdx | 21 +- docs/lint/rules/nextjs/no-img-element.mdx | 12 +- .../lint/rules/nextjs/no-page-custom-font.mdx | 13 +- .../nextjs/no-script-component-in-head.mdx | 11 +- .../nextjs/no-styled-jsx-in-document.mdx | 11 +- docs/lint/rules/nextjs/no-sync-scripts.mdx | 12 +- .../nextjs/no-title-in-document-head.mdx | 12 +- docs/lint/rules/nextjs/no-typos.mdx | 13 +- .../rules/nextjs/no-unwanted-polyfillio.mdx | 12 +- docs/lint/rules/node/callback-return.mdx | 28 +- docs/lint/rules/node/exports-style.mdx | 27 +- docs/lint/rules/node/global-require.mdx | 22 +- docs/lint/rules/node/handle-callback-err.mdx | 25 +- docs/lint/rules/node/no-exports-assign.mdx | 13 +- docs/lint/rules/node/no-mixed-requires.mdx | 26 +- docs/lint/rules/node/no-new-require.mdx | 12 +- docs/lint/rules/node/no-path-concat.mdx | 12 +- docs/lint/rules/node/no-process-env.mdx | 21 +- docs/lint/rules/node/no-process-exit.mdx | 12 +- docs/lint/rules/node/no-sync.mdx | 20 +- docs/lint/rules/node/no-top-level-await.mdx | 17 +- .../no-unsupported-features/es-builtins.mdx | 34 +- .../no-unsupported-features/es-syntax.mdx | 30 +- .../no-unsupported-features/node-builtins.mdx | 58 +- docs/lint/rules/oxc/approx-constant.mdx | 11 +- .../oxc/bad-array-method-on-arguments.mdx | 11 +- docs/lint/rules/oxc/bad-bitwise-operator.mdx | 12 +- .../lint/rules/oxc/bad-char-at-comparison.mdx | 11 +- .../rules/oxc/bad-comparison-sequence.mdx | 13 +- docs/lint/rules/oxc/bad-match-all-arg.mdx | 11 +- docs/lint/rules/oxc/bad-min-max-func.mdx | 11 +- .../oxc/bad-object-literal-comparison.mdx | 11 +- docs/lint/rules/oxc/bad-replace-all-arg.mdx | 11 +- docs/lint/rules/oxc/branches-sharing-code.mdx | 13 +- docs/lint/rules/oxc/const-comparisons.mdx | 12 +- docs/lint/rules/oxc/double-comparisons.mdx | 19 +- docs/lint/rules/oxc/erasing-op.mdx | 11 +- .../rules/oxc/misrefactored-assign-op.mdx | 11 +- docs/lint/rules/oxc/missing-throw.mdx | 11 +- .../lint/rules/oxc/no-accumulating-spread.mdx | 14 +- docs/lint/rules/oxc/no-async-await.mdx | 14 +- .../rules/oxc/no-async-endpoint-handlers.mdx | 21 +- docs/lint/rules/oxc/no-barrel-file.mdx | 20 +- docs/lint/rules/oxc/no-const-enum.mdx | 12 +- docs/lint/rules/oxc/no-map-spread.mdx | 26 +- docs/lint/rules/oxc/no-optional-chaining.mdx | 19 +- .../rules/oxc/no-rest-spread-properties.mdx | 22 +- .../oxc/no-this-in-exported-function.mdx | 14 +- .../rules/oxc/number-arg-out-of-range.mdx | 24 +- .../lint/rules/oxc/only-used-in-recursion.mdx | 14 +- .../rules/oxc/uninvoked-array-callback.mdx | 14 +- docs/lint/rules/prettier/prettier.mdx | 73 +- docs/lint/rules/promise/always-return.mdx | 21 +- docs/lint/rules/promise/avoid-new.mdx | 11 +- docs/lint/rules/promise/catch-or-return.mdx | 28 +- .../rules/promise/no-callback-in-promise.mdx | 20 +- .../rules/promise/no-multiple-resolved.mdx | 13 +- docs/lint/rules/promise/no-nesting.mdx | 12 +- docs/lint/rules/promise/no-new-statics.mdx | 11 +- .../rules/promise/no-promise-in-callback.mdx | 17 +- .../rules/promise/no-return-in-finally.mdx | 13 +- docs/lint/rules/promise/no-return-wrap.mdx | 18 +- docs/lint/rules/promise/param-names.mdx | 21 +- .../promise/prefer-await-to-callbacks.mdx | 11 +- .../rules/promise/prefer-await-to-then.mdx | 18 +- docs/lint/rules/promise/prefer-catch.mdx | 13 +- docs/lint/rules/promise/spec-only.mdx | 20 +- docs/lint/rules/promise/valid-params.mdx | 24 +- .../rules/react-perf/jsx-no-jsx-as-prop.mdx | 22 +- .../react-perf/jsx-no-new-array-as-prop.mdx | 27 +- .../jsx-no-new-function-as-prop.mdx | 24 +- .../react-perf/jsx-no-new-object-as-prop.mdx | 23 +- docs/lint/rules/react/boolean-prop-naming.mdx | 30 +- docs/lint/rules/react/button-has-type.mdx | 34 +- docs/lint/rules/react/capitalized-calls.mdx | 26 +- .../checked-requires-onchange-or-readonly.mdx | 30 +- .../rules/react/component-hook-factories.mdx | 32 + docs/lint/rules/react/config.mdx | 33 + .../react/default-props-match-prop-types.mdx | 35 +- .../rules/react/destructuring-assignment.mdx | 36 +- docs/lint/rules/react/display-name.mdx | 23 +- docs/lint/rules/react/error-boundaries.mdx | 16 +- docs/lint/rules/react/exhaustive-deps.mdx | 45 +- .../react/exhaustive-effect-dependencies.mdx | 58 + docs/lint/rules/react/fbt.mdx | 37 + .../rules/react/forbid-component-props.mdx | 46 +- docs/lint/rules/react/forbid-dom-props.mdx | 28 +- docs/lint/rules/react/forbid-elements.mdx | 25 +- .../rules/react/forbid-foreign-prop-types.mdx | 22 +- docs/lint/rules/react/forbid-prop-types.mdx | 60 +- .../lint/rules/react/forward-ref-uses-ref.mdx | 11 +- .../react/function-component-definition.mdx | 26 +- docs/lint/rules/react/gating.mdx | 31 + docs/lint/rules/react/globals.mdx | 26 +- docs/lint/rules/react/hook-use-state.mdx | 23 +- docs/lint/rules/react/hooks.mdx | 15 +- .../rules/react/iframe-missing-sandbox.mdx | 18 +- docs/lint/rules/react/immutability.mdx | 39 +- .../lint/rules/react/incompatible-library.mdx | 30 +- docs/lint/rules/react/invariant.mdx | 13 +- docs/lint/rules/react/jsx-boolean-value.mdx | 32 +- .../rules/react/jsx-child-element-spacing.mdx | 42 +- .../react/jsx-closing-bracket-location.mdx | 95 +- .../rules/react/jsx-closing-tag-location.mdx | 53 +- .../rules/react/jsx-curly-brace-presence.mdx | 57 +- docs/lint/rules/react/jsx-curly-newline.mdx | 69 +- docs/lint/rules/react/jsx-curly-spacing.mdx | 52 +- docs/lint/rules/react/jsx-equals-spacing.mdx | 31 +- .../rules/react/jsx-filename-extension.mdx | 30 +- .../rules/react/jsx-first-prop-new-line.mdx | 75 +- docs/lint/rules/react/jsx-fragments.mdx | 23 +- docs/lint/rules/react/jsx-handler-names.mdx | 44 +- docs/lint/rules/react/jsx-indent-props.mdx | 76 +- docs/lint/rules/react/jsx-indent.mdx | 98 +- docs/lint/rules/react/jsx-key.mdx | 42 +- docs/lint/rules/react/jsx-max-depth.mdx | 31 +- .../rules/react/jsx-max-props-per-line.mdx | 64 +- docs/lint/rules/react/jsx-newline.mdx | 27 +- docs/lint/rules/react/jsx-no-bind.mdx | 37 +- .../rules/react/jsx-no-comment-textnodes.mdx | 12 +- .../jsx-no-constructed-context-values.mdx | 30 +- .../rules/react/jsx-no-duplicate-props.mdx | 22 +- .../lint/rules/react/jsx-no-leaked-render.mdx | 37 +- docs/lint/rules/react/jsx-no-literals.mdx | 55 +- docs/lint/rules/react/jsx-no-script-url.mdx | 26 +- docs/lint/rules/react/jsx-no-target-blank.mdx | 81 +- docs/lint/rules/react/jsx-no-undef.mdx | 17 +- .../rules/react/jsx-no-useless-fragment.mdx | 25 +- .../react/jsx-one-expression-per-line.mdx | 52 +- docs/lint/rules/react/jsx-pascal-case.mdx | 29 +- .../rules/react/jsx-props-no-multi-spaces.mdx | 31 +- .../rules/react/jsx-props-no-spread-multi.mdx | 13 +- .../rules/react/jsx-props-no-spreading.mdx | 27 +- .../rules/react/jsx-sort-default-props.mdx | 18 +- docs/lint/rules/react/jsx-sort-props.mdx | 41 +- docs/lint/rules/react/jsx-tag-spacing.mdx | 65 +- docs/lint/rules/react/jsx-uses-react.mdx | 50 + docs/lint/rules/react/jsx-uses-vars.mdx | 41 + docs/lint/rules/react/jsx-wrap-multilines.mdx | 90 +- docs/lint/rules/react/memo-dependencies.mdx | 29 +- .../react/memoized-effect-dependencies.mdx | 35 + .../react/no-access-state-in-setstate.mdx | 20 +- .../react/no-adjacent-inline-elements.mdx | 23 +- docs/lint/rules/react/no-array-index-key.mdx | 16 +- .../react/no-arrow-function-lifecycle.mdx | 11 +- docs/lint/rules/react/no-children-prop.mdx | 24 +- docs/lint/rules/react/no-clone-element.mdx | 23 +- .../rules/react/no-danger-with-children.mdx | 22 +- docs/lint/rules/react/no-danger.mdx | 18 +- docs/lint/rules/react/no-deprecated.mdx | 32 +- .../react/no-deriving-state-in-effects.mdx | 12 +- .../rules/react/no-did-mount-set-state.mdx | 20 +- .../rules/react/no-did-update-set-state.mdx | 26 +- .../rules/react/no-direct-mutation-state.mdx | 12 +- docs/lint/rules/react/no-find-dom-node.mdx | 11 +- .../rules/react/no-invalid-html-attribute.mdx | 46 +- docs/lint/rules/react/no-is-mounted.mdx | 11 +- docs/lint/rules/react/no-multi-comp.mdx | 38 +- docs/lint/rules/react/no-namespace.mdx | 11 +- .../react/no-object-type-as-default-prop.mdx | 36 +- docs/lint/rules/react/no-react-children.mdx | 14 +- .../no-redundant-should-component-update.mdx | 11 +- .../rules/react/no-render-return-value.mdx | 12 +- docs/lint/rules/react/no-set-state.mdx | 11 +- docs/lint/rules/react/no-string-refs.mdx | 26 +- docs/lint/rules/react/no-this-in-sfc.mdx | 22 +- docs/lint/rules/react/no-typos.mdx | 32 +- .../rules/react/no-unescaped-entities.mdx | 31 +- docs/lint/rules/react/no-unknown-property.mdx | 24 +- docs/lint/rules/react/no-unsafe.mdx | 34 +- .../react/no-unstable-nested-components.mdx | 26 +- .../no-unused-class-component-methods.mdx | 15 +- .../lint/rules/react/no-unused-prop-types.mdx | 54 +- docs/lint/rules/react/no-unused-state.mdx | 17 +- .../rules/react/no-will-update-set-state.mdx | 78 ++ .../rules/react/only-export-components.mdx | 140 ++ docs/lint/rules/react/prefer-es6-class.mdx | 34 +- docs/lint/rules/react/prefer-exact-props.mdx | 72 ++ .../rules/react/prefer-function-component.mdx | 94 ++ .../rules/react/prefer-read-only-props.mdx | 35 +- .../rules/react/prefer-stateless-function.mdx | 29 +- .../react/preserve-manual-memoization.mdx | 15 +- docs/lint/rules/react/prop-types.mdx | 43 +- docs/lint/rules/react/purity.mdx | 19 +- docs/lint/rules/react/react-in-jsx-scope.mdx | 14 +- docs/lint/rules/react/refs.mdx | 12 +- .../rules/react/require-default-props.mdx | 58 +- .../lint/rules/react/require-optimization.mdx | 20 +- .../rules/react/require-render-return.mdx | 13 +- docs/lint/rules/react/rule-suppression.mdx | 61 + docs/lint/rules/react/rules-of-hooks.mdx | 24 +- docs/lint/rules/react/self-closing-comp.mdx | 33 +- docs/lint/rules/react/set-state-in-effect.mdx | 25 +- docs/lint/rules/react/set-state-in-render.mdx | 22 +- docs/lint/rules/react/sort-comp.mdx | 41 +- docs/lint/rules/react/sort-default-props.mdx | 24 +- docs/lint/rules/react/sort-prop-types.mdx | 39 +- .../lint/rules/react/state-in-constructor.mdx | 25 +- docs/lint/rules/react/static-components.mdx | 13 +- .../rules/react/static-property-placement.mdx | 31 +- docs/lint/rules/react/style-prop-object.mdx | 23 +- docs/lint/rules/react/syntax.mdx | 17 +- docs/lint/rules/react/todo.mdx | 15 +- docs/lint/rules/react/unsupported-syntax.mdx | 32 +- docs/lint/rules/react/use-memo.mdx | 13 +- .../react/void-dom-elements-no-children.mdx | 11 +- docs/lint/rules/react/void-use-memo.mdx | 15 +- .../adjacent-overload-signatures.mdx | 13 +- docs/lint/rules/typescript/array-type.mdx | 31 +- docs/lint/rules/typescript/await-thenable.mdx | 15 +- docs/lint/rules/typescript/ban-ts-comment.mdx | 41 +- .../rules/typescript/ban-tslint-comment.mdx | 11 +- docs/lint/rules/typescript/ban-types.mdx | 12 +- .../class-literal-property-style.mdx | 23 +- .../typescript/class-methods-use-this.mdx | 23 +- .../consistent-generic-constructors.mdx | 22 +- .../consistent-indexed-object-style.mdx | 23 +- .../rules/typescript/consistent-return.mdx | 18 +- .../typescript/consistent-type-assertions.mdx | 28 +- .../consistent-type-definitions.mdx | 19 +- .../typescript/consistent-type-exports.mdx | 19 +- .../typescript/consistent-type-imports.mdx | 27 +- .../rules/typescript/default-param-last.mdx | 19 +- docs/lint/rules/typescript/dot-notation.mdx | 32 +- .../explicit-function-return-type.mdx | 49 +- .../explicit-member-accessibility.mdx | 26 +- .../explicit-module-boundary-types.mdx | 38 +- .../rules/typescript/init-declarations.mdx | 24 +- docs/lint/rules/typescript/max-params.mdx | 22 +- .../lint/rules/typescript/member-ordering.mdx | 41 +- .../typescript/method-signature-style.mdx | 21 +- .../rules/typescript/naming-convention.mdx | 39 +- .../rules/typescript/no-array-constructor.mdx | 11 +- .../lint/rules/typescript/no-array-delete.mdx | 11 +- .../rules/typescript/no-base-to-string.mdx | 24 +- .../no-confusing-non-null-assertion.mdx | 13 +- .../no-confusing-void-expression.mdx | 26 +- docs/lint/rules/typescript/no-deprecated.mdx | 33 +- .../typescript/no-dupe-class-members.mdx | 11 +- .../typescript/no-duplicate-enum-values.mdx | 12 +- .../no-duplicate-type-constituents.mdx | 20 +- .../rules/typescript/no-dynamic-delete.mdx | 12 +- .../rules/typescript/no-empty-function.mdx | 54 +- .../rules/typescript/no-empty-interface.mdx | 16 +- .../rules/typescript/no-empty-object-type.mdx | 24 +- .../lint/rules/typescript/no-explicit-any.mdx | 21 +- .../no-extra-non-null-assertion.mdx | 11 +- .../rules/typescript/no-extraneous-class.mdx | 32 +- .../rules/typescript/no-floating-promises.mdx | 26 +- .../lint/rules/typescript/no-for-in-array.mdx | 13 +- .../no-generated-empty-object-type.mdx | 13 +- .../lint/rules/typescript/no-implied-eval.mdx | 12 +- .../no-import-type-side-effects.mdx | 12 +- .../rules/typescript/no-inferrable-types.mdx | 20 +- .../lint/rules/typescript/no-invalid-this.mdx | 20 +- .../rules/typescript/no-invalid-void-type.mdx | 24 +- docs/lint/rules/typescript/no-loop-func.mdx | 13 +- .../rules/typescript/no-loss-of-precision.mdx | 11 +- .../rules/typescript/no-magic-numbers.mdx | 42 +- .../no-meaningless-void-operator.mdx | 16 +- docs/lint/rules/typescript/no-misused-new.mdx | 11 +- .../rules/typescript/no-misused-promises.mdx | 39 +- .../rules/typescript/no-misused-spread.mdx | 39 +- docs/lint/rules/typescript/no-mixed-enums.mdx | 12 +- docs/lint/rules/typescript/no-namespace.mdx | 25 +- ...o-non-null-asserted-nullish-coalescing.mdx | 12 +- .../no-non-null-asserted-optional-chain.mdx | 14 +- .../typescript/no-non-null-assertion.mdx | 11 +- docs/lint/rules/typescript/no-redeclare.mdx | 24 +- .../no-redundant-type-constituents.mdx | 29 +- .../rules/typescript/no-require-imports.mdx | 22 +- .../typescript/no-restricted-imports.mdx | 41 +- .../rules/typescript/no-restricted-types.mdx | 22 +- docs/lint/rules/typescript/no-shadow.mdx | 48 +- docs/lint/rules/typescript/no-this-alias.mdx | 21 +- docs/lint/rules/typescript/no-type-alias.mdx | 33 +- ...no-unnecessary-boolean-literal-compare.mdx | 21 +- .../typescript/no-unnecessary-condition.mdx | 33 +- ...ecessary-parameter-property-assignment.mdx | 12 +- .../typescript/no-unnecessary-qualifier.mdx | 12 +- .../no-unnecessary-template-expression.mdx | 13 +- .../no-unnecessary-type-arguments.mdx | 12 +- .../no-unnecessary-type-assertion.mdx | 20 +- .../no-unnecessary-type-constraint.mdx | 12 +- .../no-unnecessary-type-conversion.mdx | 26 +- .../no-unnecessary-type-parameters.mdx | 12 +- .../rules/typescript/no-unsafe-argument.mdx | 12 +- .../rules/typescript/no-unsafe-assignment.mdx | 12 +- docs/lint/rules/typescript/no-unsafe-call.mdx | 12 +- .../no-unsafe-declaration-merging.mdx | 12 +- .../typescript/no-unsafe-enum-assignment.mdx | 12 +- .../typescript/no-unsafe-enum-comparison.mdx | 12 +- .../typescript/no-unsafe-function-type.mdx | 14 +- .../typescript/no-unsafe-member-access.mdx | 18 +- .../rules/typescript/no-unsafe-return.mdx | 14 +- .../typescript/no-unsafe-type-assertion.mdx | 12 +- .../typescript/no-unsafe-unary-minus.mdx | 12 +- .../typescript/no-unused-expressions.mdx | 32 +- .../no-unused-private-class-members.mdx | 12 +- docs/lint/rules/typescript/no-unused-vars.mdx | 46 +- .../rules/typescript/no-use-before-define.mdx | 37 +- .../typescript/no-useless-constructor.mdx | 13 +- .../no-useless-default-assignment.mdx | 22 +- .../typescript/no-useless-empty-export.mdx | 13 +- .../lint/rules/typescript/no-var-requires.mdx | 19 +- .../typescript/no-wrapper-object-types.mdx | 13 +- .../non-nullable-type-assertion-style.mdx | 12 +- .../rules/typescript/only-throw-error.mdx | 27 +- .../rules/typescript/parameter-properties.mdx | 23 +- .../lint/rules/typescript/prefer-as-const.mdx | 11 +- .../rules/typescript/prefer-destructuring.mdx | 27 +- .../typescript/prefer-enum-initializers.mdx | 11 +- docs/lint/rules/typescript/prefer-find.mdx | 12 +- docs/lint/rules/typescript/prefer-for-of.mdx | 12 +- .../rules/typescript/prefer-function-type.mdx | 13 +- .../lint/rules/typescript/prefer-includes.mdx | 13 +- .../typescript/prefer-literal-enum-member.mdx | 18 +- .../typescript/prefer-namespace-keyword.mdx | 11 +- .../typescript/prefer-nullish-coalescing.mdx | 34 +- .../typescript/prefer-optional-chain.mdx | 33 +- .../prefer-promise-reject-errors.mdx | 26 +- .../prefer-readonly-parameter-types.mdx | 29 +- .../lint/rules/typescript/prefer-readonly.mdx | 22 +- .../prefer-reduce-type-parameter.mdx | 23 +- .../rules/typescript/prefer-regexp-exec.mdx | 11 +- .../typescript/prefer-return-this-type.mdx | 11 +- .../prefer-string-starts-ends-with.mdx | 17 +- .../typescript/prefer-ts-expect-error.mdx | 11 +- .../typescript/promise-function-async.mdx | 30 +- .../related-getter-setter-pairs.mdx | 11 +- .../typescript/require-array-sort-compare.mdx | 18 +- docs/lint/rules/typescript/require-await.mdx | 11 +- .../typescript/restrict-plus-operands.mdx | 29 +- .../restrict-template-expressions.mdx | 34 +- docs/lint/rules/typescript/return-await.mdx | 26 +- .../typescript/sort-type-constituents.mdx | 25 +- .../typescript/strict-boolean-expressions.mdx | 35 +- .../rules/typescript/strict-void-return.mdx | 17 +- .../switch-exhaustiveness-check.mdx | 27 +- .../typescript/triple-slash-reference.mdx | 20 +- docs/lint/rules/typescript/typedef.mdx | 34 +- docs/lint/rules/typescript/unbound-method.mdx | 16 +- .../rules/typescript/unified-signatures.mdx | 21 +- ...use-unknown-in-catch-callback-variable.mdx | 11 +- docs/lint/rules/unicorn/catch-error-name.mdx | 22 +- docs/lint/rules/unicorn/consistent-assert.mdx | 13 +- .../rules/unicorn/consistent-date-clone.mdx | 12 +- .../unicorn/consistent-empty-array-spread.mdx | 12 +- .../consistent-existence-index-check.mdx | 12 +- .../unicorn/consistent-function-scoping.mdx | 22 +- .../consistent-template-literal-escape.mdx | 12 +- .../rules/unicorn/custom-error-definition.mdx | 14 +- .../lint/rules/unicorn/empty-brace-spaces.mdx | 18 +- docs/lint/rules/unicorn/error-message.mdx | 14 +- docs/lint/rules/unicorn/escape-case.mdx | 12 +- .../rules/unicorn/explicit-length-check.mdx | 18 +- .../rules/unicorn/explicit-timer-delay.mdx | 21 +- docs/lint/rules/unicorn/filename-case.mdx | 30 +- docs/lint/rules/unicorn/import-style.mdx | 46 +- docs/lint/rules/unicorn/max-nested-calls.mdx | 21 +- docs/lint/rules/unicorn/new-for-builtins.mdx | 15 +- .../unicorn/no-abusive-eslint-disable.mdx | 12 +- .../rules/unicorn/no-accessor-recursion.mdx | 12 +- .../unicorn/no-anonymous-default-export.mdx | 19 +- .../unicorn/no-array-callback-reference.mdx | 19 +- .../no-array-fill-with-reference-type.mdx | 13 +- docs/lint/rules/unicorn/no-array-for-each.mdx | 18 +- .../unicorn/no-array-method-this-argument.mdx | 30 +- docs/lint/rules/unicorn/no-array-reduce.mdx | 19 +- docs/lint/rules/unicorn/no-array-reverse.mdx | 20 +- docs/lint/rules/unicorn/no-array-sort.mdx | 24 +- .../unicorn/no-await-expression-member.mdx | 13 +- .../unicorn/no-await-in-promise-methods.mdx | 11 +- .../rules/unicorn/no-confusing-array-with.mdx | 14 +- docs/lint/rules/unicorn/no-console-spaces.mdx | 12 +- .../lint/rules/unicorn/no-document-cookie.mdx | 12 +- docs/lint/rules/unicorn/no-empty-file.mdx | 14 +- docs/lint/rules/unicorn/no-hex-escape.mdx | 12 +- .../rules/unicorn/no-immediate-mutation.mdx | 26 +- .../rules/unicorn/no-instanceof-array.mdx | 11 +- .../rules/unicorn/no-instanceof-builtins.mdx | 26 +- .../unicorn/no-invalid-fetch-options.mdx | 13 +- .../no-invalid-remove-event-listener.mdx | 11 +- .../rules/unicorn/no-length-as-slice-end.mdx | 11 +- docs/lint/rules/unicorn/no-lonely-if.mdx | 12 +- .../unicorn/no-magic-array-flat-depth.mdx | 11 +- .../rules/unicorn/no-negated-condition.mdx | 12 +- .../unicorn/no-negation-in-equality-check.mdx | 14 +- docs/lint/rules/unicorn/no-nested-ternary.mdx | 17 +- docs/lint/rules/unicorn/no-new-array.mdx | 12 +- docs/lint/rules/unicorn/no-new-buffer.mdx | 11 +- docs/lint/rules/unicorn/no-null.mdx | 16 +- .../no-object-as-default-parameter.mdx | 11 +- docs/lint/rules/unicorn/no-process-exit.mdx | 14 +- .../no-single-promise-in-promise-methods.mdx | 12 +- .../rules/unicorn/no-static-only-class.mdx | 13 +- docs/lint/rules/unicorn/no-thenable.mdx | 13 +- .../lint/rules/unicorn/no-this-assignment.mdx | 12 +- .../rules/unicorn/no-typeof-undefined.mdx | 18 +- .../no-unnecessary-array-flat-depth.mdx | 11 +- .../no-unnecessary-array-splice-count.mdx | 11 +- .../rules/unicorn/no-unnecessary-await.mdx | 11 +- .../unicorn/no-unnecessary-slice-end.mdx | 11 +- .../no-unreadable-array-destructuring.mdx | 11 +- .../lint/rules/unicorn/no-unreadable-iife.mdx | 11 +- .../no-useless-collection-argument.mdx | 11 +- .../no-useless-error-capture-stack-trace.mdx | 12 +- .../unicorn/no-useless-fallback-in-spread.mdx | 11 +- .../unicorn/no-useless-iterator-to-array.mdx | 29 +- .../rules/unicorn/no-useless-length-check.mdx | 12 +- .../no-useless-promise-resolve-reject.mdx | 17 +- docs/lint/rules/unicorn/no-useless-spread.mdx | 14 +- .../rules/unicorn/no-useless-switch-case.mdx | 12 +- .../rules/unicorn/no-useless-undefined.mdx | 22 +- docs/lint/rules/unicorn/no-zero-fractions.mdx | 15 +- .../rules/unicorn/number-literal-case.mdx | 26 +- .../unicorn/numeric-separators-style.mdx | 50 +- .../unicorn/prefer-add-event-listener.mdx | 12 +- docs/lint/rules/unicorn/prefer-array-find.mdx | 13 +- .../rules/unicorn/prefer-array-flat-map.mdx | 17 +- docs/lint/rules/unicorn/prefer-array-flat.mdx | 12 +- .../rules/unicorn/prefer-array-index-of.mdx | 12 +- docs/lint/rules/unicorn/prefer-array-some.mdx | 12 +- docs/lint/rules/unicorn/prefer-at.mdx | 23 +- .../rules/unicorn/prefer-bigint-literals.mdx | 16 +- .../unicorn/prefer-blob-reading-methods.mdx | 13 +- .../rules/unicorn/prefer-class-fields.mdx | 14 +- .../rules/unicorn/prefer-classlist-toggle.mdx | 12 +- docs/lint/rules/unicorn/prefer-code-point.mdx | 11 +- docs/lint/rules/unicorn/prefer-date-now.mdx | 11 +- .../unicorn/prefer-default-parameters.mdx | 13 +- .../rules/unicorn/prefer-dom-node-append.mdx | 11 +- .../rules/unicorn/prefer-dom-node-dataset.mdx | 12 +- .../rules/unicorn/prefer-dom-node-remove.mdx | 11 +- .../unicorn/prefer-dom-node-text-content.mdx | 11 +- .../rules/unicorn/prefer-event-target.mdx | 12 +- .../lint/rules/unicorn/prefer-export-from.mdx | 21 +- .../lint/rules/unicorn/prefer-global-this.mdx | 13 +- .../unicorn/prefer-import-meta-properties.mdx | 13 +- docs/lint/rules/unicorn/prefer-includes.mdx | 12 +- .../unicorn/prefer-keyboard-event-key.mdx | 12 +- .../prefer-logical-operator-over-ternary.mdx | 12 +- .../rules/unicorn/prefer-math-min-max.mdx | 12 +- docs/lint/rules/unicorn/prefer-math-trunc.mdx | 12 +- .../rules/unicorn/prefer-modern-dom-apis.mdx | 28 +- .../rules/unicorn/prefer-modern-math-apis.mdx | 11 +- docs/lint/rules/unicorn/prefer-module.mdx | 13 +- .../prefer-native-coercion-functions.mdx | 12 +- .../rules/unicorn/prefer-negative-index.mdx | 12 +- .../rules/unicorn/prefer-node-protocol.mdx | 12 +- .../rules/unicorn/prefer-number-coercion.mdx | 11 +- .../unicorn/prefer-number-properties.mdx | 20 +- .../unicorn/prefer-object-from-entries.mdx | 18 +- .../unicorn/prefer-optional-catch-binding.mdx | 11 +- .../unicorn/prefer-prototype-methods.mdx | 12 +- .../rules/unicorn/prefer-query-selector.mdx | 13 +- .../rules/unicorn/prefer-reflect-apply.mdx | 12 +- .../lint/rules/unicorn/prefer-regexp-test.mdx | 12 +- .../unicorn/prefer-response-static-json.mdx | 12 +- docs/lint/rules/unicorn/prefer-set-has.mdx | 12 +- docs/lint/rules/unicorn/prefer-set-size.mdx | 12 +- .../lint/rules/unicorn/prefer-single-call.mdx | 20 +- docs/lint/rules/unicorn/prefer-spread.mdx | 12 +- docs/lint/rules/unicorn/prefer-string-raw.mdx | 11 +- .../unicorn/prefer-string-replace-all.mdx | 11 +- .../rules/unicorn/prefer-string-slice.mdx | 11 +- .../prefer-string-starts-ends-with.mdx | 11 +- .../unicorn/prefer-string-trim-start-end.mdx | 11 +- .../rules/unicorn/prefer-structured-clone.mdx | 17 +- docs/lint/rules/unicorn/prefer-ternary.mdx | 19 +- .../rules/unicorn/prefer-top-level-await.mdx | 12 +- docs/lint/rules/unicorn/prefer-type-error.mdx | 11 +- .../lint/rules/unicorn/relative-url-style.mdx | 21 +- .../unicorn/require-array-join-separator.mdx | 11 +- .../unicorn/require-module-attributes.mdx | 11 +- .../unicorn/require-module-specifiers.mdx | 13 +- ...equire-number-to-fixed-digits-argument.mdx | 11 +- .../require-post-message-target-origin.mdx | 12 +- .../lint/rules/unicorn/switch-case-braces.mdx | 21 +- .../unicorn/switch-case-break-position.mdx | 20 +- .../unicorn/text-encoding-identifier-case.mdx | 19 +- docs/lint/rules/unicorn/throw-new-error.mdx | 11 +- .../lint/rules/vitest/consistent-each-for.mdx | 36 +- .../rules/vitest/consistent-test-filename.mdx | 49 +- docs/lint/rules/vitest/consistent-test-it.mdx | 35 +- .../rules/vitest/consistent-vitest-vi.mdx | 28 +- docs/lint/rules/vitest/expect-expect.mdx | 38 +- .../lint/rules/vitest/hoisted-apis-on-top.mdx | 19 +- docs/lint/rules/vitest/max-expects.mdx | 54 +- .../lint/rules/vitest/max-nested-describe.mdx | 45 +- docs/lint/rules/vitest/no-alias-methods.mdx | 37 +- .../rules/vitest/no-commented-out-tests.mdx | 12 +- .../rules/vitest/no-conditional-expect.mdx | 13 +- .../rules/vitest/no-conditional-in-test.mdx | 14 +- .../rules/vitest/no-conditional-tests.mdx | 13 +- docs/lint/rules/vitest/no-disabled-tests.mdx | 12 +- docs/lint/rules/vitest/no-duplicate-hooks.mdx | 11 +- docs/lint/rules/vitest/no-focused-tests.mdx | 11 +- docs/lint/rules/vitest/no-hooks.mdx | 18 +- docs/lint/rules/vitest/no-identical-title.mdx | 13 +- .../lint/rules/vitest/no-import-node-test.mdx | 20 +- .../vitest/no-importing-vitest-globals.mdx | 13 +- .../vitest/no-interpolation-in-snapshots.mdx | 21 +- docs/lint/rules/vitest/no-large-snapshots.mdx | 43 +- docs/lint/rules/vitest/no-mocks-import.mdx | 20 +- .../rules/vitest/no-restricted-matchers.mdx | 43 +- .../rules/vitest/no-restricted-vi-methods.mdx | 36 +- .../rules/vitest/no-standalone-expect.mdx | 22 +- docs/lint/rules/vitest/no-test-prefixes.mdx | 16 +- .../rules/vitest/no-test-return-statement.mdx | 15 +- .../no-unneeded-async-expect-function.mdx | 14 +- .../padding-around-after-all-blocks.mdx | 14 +- .../vitest/padding-around-test-blocks.mdx | 13 +- .../prefer-called-exactly-once-with.mdx | 16 +- docs/lint/rules/vitest/prefer-called-once.mdx | 13 +- .../lint/rules/vitest/prefer-called-times.mdx | 13 +- docs/lint/rules/vitest/prefer-called-with.mdx | 11 +- .../vitest/prefer-comparison-matcher.mdx | 25 +- .../vitest/prefer-describe-function-title.mdx | 16 +- docs/lint/rules/vitest/prefer-each.mdx | 15 +- .../rules/vitest/prefer-equality-matcher.mdx | 16 +- .../rules/vitest/prefer-expect-assertions.mdx | 45 +- .../rules/vitest/prefer-expect-resolves.mdx | 13 +- .../rules/vitest/prefer-expect-type-of.mdx | 15 +- .../rules/vitest/prefer-hooks-in-order.mdx | 18 +- .../lint/rules/vitest/prefer-hooks-on-top.mdx | 14 +- .../rules/vitest/prefer-import-in-mock.mdx | 20 +- .../prefer-importing-vitest-globals.mdx | 14 +- .../rules/vitest/prefer-lowercase-title.mdx | 57 +- .../vitest/prefer-mock-promise-shorthand.mdx | 20 +- .../vitest/prefer-mock-return-shorthand.mdx | 29 +- .../rules/vitest/prefer-snapshot-hint.mdx | 35 +- docs/lint/rules/vitest/prefer-spy-on.mdx | 18 +- .../vitest/prefer-strict-boolean-matchers.mdx | 26 +- .../lint/rules/vitest/prefer-strict-equal.mdx | 13 +- docs/lint/rules/vitest/prefer-to-be-falsy.mdx | 17 +- .../lint/rules/vitest/prefer-to-be-object.mdx | 15 +- .../lint/rules/vitest/prefer-to-be-truthy.mdx | 17 +- docs/lint/rules/vitest/prefer-to-be.mdx | 37 +- docs/lint/rules/vitest/prefer-to-contain.mdx | 16 +- .../prefer-to-have-been-called-times.mdx | 20 +- .../rules/vitest/prefer-to-have-length.mdx | 11 +- docs/lint/rules/vitest/prefer-todo.mdx | 13 +- .../vitest/require-awaited-expect-poll.mdx | 36 +- docs/lint/rules/vitest/require-hook.mdx | 45 +- ...-test-context-for-concurrent-snapshots.mdx | 17 +- .../vitest/require-mock-type-parameters.mdx | 23 +- .../rules/vitest/require-test-timeout.mdx | 27 +- .../rules/vitest/require-to-throw-message.mdx | 11 +- .../vitest/require-top-level-describe.mdx | 17 +- .../rules/vitest/valid-describe-callback.mdx | 70 +- .../rules/vitest/valid-expect-in-promise.mdx | 37 +- docs/lint/rules/vitest/valid-expect.mdx | 37 +- docs/lint/rules/vitest/valid-title.mdx | 47 +- docs/lint/rules/vitest/warn-todo.mdx | 13 +- docs/runtime/bunfig.mdx | 90 +- docs/runtime/format.mdx | 25 +- docs/snippets/cli/format.mdx | 2 +- src/codegen/lint-rule-docs.ts | 263 ++++ src/format/html/error.rs | 2 +- src/format/js/sort_imports/oxfmt/options.rs | 16 +- src/lint/CLAUDE.md | 1 + src/lint/context.rs | 3 +- src/lint/driver/Cargo.toml | 2 + src/lint/driver/bunfig.rs | 24 +- src/lint/driver/cli.rs | 22 +- src/lint/driver/configs.rs | 36 +- src/lint/driver/discover.rs | 9 +- src/lint/driver/doctor.rs | 21 +- src/lint/driver/eslintrc.rs | 4 +- src/lint/driver/evaluate.rs | 3 +- src/lint/driver/explain.rs | 356 +++++ src/lint/driver/fmt/cli.rs | 8 +- src/lint/driver/fmt/config.rs | 47 +- src/lint/driver/fmt/doctor.rs | 4 +- src/lint/driver/fmt/editorconfig.rs | 130 +- src/lint/driver/fmt/files.rs | 7 +- src/lint/driver/fmt/for_rules.rs | 2 +- src/lint/driver/fmt/mod.rs | 79 +- src/lint/driver/for_tests.rs | 12 +- src/lint/driver/format/mod.rs | 5 +- src/lint/driver/format/oxlint.rs | 65 +- src/lint/driver/fs.rs | 2 +- src/lint/driver/git.rs | 10 +- src/lint/driver/lib.rs | 1 + src/lint/driver/processor.rs | 10 +- src/lint/driver/rules.rs | 13 + src/lint/driver/rulesdir.rs | 2 +- src/lint/driver/run.rs | 157 ++- src/lint/driver/typed.rs | 15 +- .../rules/no_constant_binary_expression.rs | 21 +- src/lint/eslint/rules/no_empty.rs | 32 +- .../eslint/rules/no_irregular_whitespace.rs | 7 +- .../rules/no_misleading_character_class.rs | 8 +- src/lint/eslint/rules/no_octal.rs | 4 +- src/lint/eslint/rules/no_undef.rs | 35 +- .../eslint/rules/no_unused_expressions.rs | 10 +- src/lint/eslint/rules/no_unused_vars.rs | 21 +- src/lint/eslint/rules/no_useless_catch.rs | 4 +- src/lint/eslint/rules/no_useless_escape.rs | 6 +- src/lint/eslint/rules/use_isnan.rs | 4 +- src/lint/formats.rs | 3 + src/lint/jest/jest_expect.rs | 5 +- src/lint/jest/rules/jest_valid_expect.rs | 2 +- src/lint/js_plugin/eslint.rs | 3 +- src/lint/js_plugin/host.rs | 3 +- src/lint/js_plugin/processor.rs | 3 +- src/lint/json.rs | 2 +- src/lint/language.rs | 4 + src/lint/linter/config/mod.rs | 1 + src/lint/linter/mod.rs | 7 +- src/lint/linter/registry.rs | 21 +- src/lint/linter/schema/mod.rs | 87 +- src/lint/linter/schema/plain.rs | 181 +++ src/lint/linter/schema/validate.rs | 124 +- src/lint/plugins/prettier.rs | 7 +- .../plugins/rules/bun_no_restricted_text.rs | 2 +- .../rules/react_hooks_rules_of_hooks.rs | 33 +- src/lint/react_compiler/compile.rs | 2 +- src/lint/typescript/rules/no_explicit_any.rs | 5 +- .../typescript/rules/no_require_imports.rs | 6 +- src/lint/typescript/rules/no_this_alias.rs | 5 +- .../rules/no_unsafe_declaration_merging.rs | 5 +- .../typescript/rules/no_unused_expressions.rs | 3 +- src/lint/typescript/rules/no_unused_vars.rs | 9 +- .../unicorn_no_abusive_eslint_disable.rs | 58 +- src/lint/utils/text.rs | 8 + src/md/ansi_renderer.rs | 60 +- src/react_compiler/lowering/mod.rs | 2 +- src/runtime/cli/init/react-app/src/index.ts | 4 +- .../cli/init/react-shadcn/src/index.ts | 4 +- .../cli/init/react-tailwind/src/index.ts | 4 +- src/runtime/cli/init_command.rs | 4 +- src/runtime/cli/lint_command.rs | 15 +- src/runtime/cli/lint_js.rs | 2 +- test/cli/check/check.test.ts | 10 + test/cli/format/bunfig.test.ts | 577 +++++++++ .../{doctor.test.ts => format-doctor.test.ts} | 36 +- test/cli/format/format.test.ts | 25 +- test/cli/format/glob.test.ts | 102 ++ test/cli/format/words.test.ts | 150 +++ test/cli/init/init.test.ts | 54 + test/cli/lint/bun-rules.test.ts | 18 +- test/cli/lint/config-files.test.ts | 18 +- test/cli/lint/defaults.test.ts | 383 ++++++ test/cli/lint/doctor.test.ts | 5 +- test/cli/lint/explain.test.ts | 309 +++++ test/cli/lint/git.test.ts | 31 +- test/cli/lint/glob.test.ts | 183 +++ test/cli/lint/help.test.ts | 29 +- test/cli/lint/hints.test.ts | 152 ++- test/cli/lint/js-parsers.test.ts | 2 +- test/cli/lint/js-plugins.test.ts | 4 +- test/cli/lint/lint.test.ts | 37 +- test/cli/lint/oracle/robustness/cases.ts | 2 +- .../robustness/shapes-react-compiler.ts | 2 +- test/cli/lint/output.test.ts | 2 +- test/cli/lint/rule-docs.test.ts | 195 +++ 1135 files changed, 19499 insertions(+), 9374 deletions(-) create mode 100644 docs/lint/rules/index.mdx create mode 100644 docs/lint/rules/react/component-hook-factories.mdx create mode 100644 docs/lint/rules/react/config.mdx create mode 100644 docs/lint/rules/react/exhaustive-effect-dependencies.mdx create mode 100644 docs/lint/rules/react/fbt.mdx create mode 100644 docs/lint/rules/react/gating.mdx create mode 100644 docs/lint/rules/react/jsx-uses-react.mdx create mode 100644 docs/lint/rules/react/jsx-uses-vars.mdx create mode 100644 docs/lint/rules/react/memoized-effect-dependencies.mdx create mode 100644 docs/lint/rules/react/no-will-update-set-state.mdx create mode 100644 docs/lint/rules/react/only-export-components.mdx create mode 100644 docs/lint/rules/react/prefer-exact-props.mdx create mode 100644 docs/lint/rules/react/prefer-function-component.mdx create mode 100644 docs/lint/rules/react/rule-suppression.mdx create mode 100644 src/codegen/lint-rule-docs.ts create mode 100644 src/lint/driver/explain.rs create mode 100644 src/lint/linter/schema/plain.rs create mode 100644 test/cli/format/bunfig.test.ts rename test/cli/format/{doctor.test.ts => format-doctor.test.ts} (87%) create mode 100644 test/cli/format/glob.test.ts create mode 100644 test/cli/format/words.test.ts create mode 100644 test/cli/lint/defaults.test.ts create mode 100644 test/cli/lint/explain.test.ts create mode 100644 test/cli/lint/glob.test.ts create mode 100644 test/cli/lint/rule-docs.test.ts diff --git a/.prettierignore b/.prettierignore index 39946967ea7c..375be0a27756 100644 --- a/.prettierignore +++ b/.prettierignore @@ -16,6 +16,8 @@ scripts/build/xmac.mjs # the output codeblocks need to stay minified docs/bundler/minifier.mdx +# The examples are run as they are written: test/cli/lint/rule-docs.test.ts +docs/lint/rules # `declare module "*" with { type: "text" }` is TypeScript 7.1 syntax that # prettier's parser does not accept yet diff --git a/Cargo.lock b/Cargo.lock index 3bef19fdeab0..9925ff1316b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1054,6 +1054,7 @@ dependencies = [ "bun_lint_react", "bun_lint_typescript", "bun_lint_unicorn", + "bun_md", "bun_paths", "bun_sema", "bun_sema_driver", @@ -1061,6 +1062,7 @@ dependencies = [ "bun_semver", "bun_sys", "bun_threading", + "bun_zstd", ] [[package]] diff --git a/completions/bun.bash b/completions/bun.bash index aa9a41752fb8..e8829fa5346a 100644 --- a/completions/bun.bash +++ b/completions/bun.bash @@ -195,7 +195,7 @@ _bun_completions() { COMPREPLY=( $(compgen -f -W "--project --pretty --no-pretty --all --threads --timing --cwd --help -p -h" -- "${cur_word}") ); return;; lint) - COMPREPLY=( $(compgen -f -W "--config --no-config-lookup --rule --rules --init --global --no-inline-config --report-unused-disable-directives --print-config --doctor --flavor --allow-unsupported --staged --changed --ignore-pattern --ignore-path --no-ignore --ext --stdin --stdin-filename --list-files --no-error-on-unmatched-pattern --cwd --format --all --output-file --quiet --max-warnings --color --no-color --timing --fix --fix-dry-run --fix-type --fix-suggestions --fix-dangerously --type-aware --no-type-aware --project --type-check --infer-globals --no-infer-globals --threads --version --help --help-all --parser-options --parser --plugin --native-plugin-rules --no-native-plugin-rules --report-unused-disable-directives-severity --report-unused-inline-configs --no-warn-ignored --suppress-all --suppress-rule --suppressions-location --prune-suppressions --pass-on-unpruned-suppressions --pass-on-no-patterns --exit-on-fatal-error --concurrency --env-info --no-eslintrc --env --rulesdir --resolve-plugins-relative-to --allow --warn --deny --deny-warnings --silent --disable-nested-config --import-plugin --react-plugin --jsdoc-plugin --jest-plugin --vitest-plugin --jsx-a11y-plugin --nextjs-plugin --react-perf-plugin --promise-plugin --node-plugin --vue-plugin --disable-unicorn-plugin --disable-oxc-plugin --disable-typescript-plugin -c -f -o -p -v -h -A -W -D" -- "${cur_word}") ); + COMPREPLY=( $(compgen -f -W "--config --no-config-lookup --rule --rules --explain --init --global --no-inline-config --report-unused-disable-directives --print-config --doctor --flavor --allow-unsupported --staged --changed --ignore-pattern --ignore-path --no-ignore --ext --stdin --stdin-filename --list-files --no-error-on-unmatched-pattern --cwd --format --all --output-file --quiet --max-warnings --color --no-color --timing --fix --fix-dry-run --fix-type --fix-suggestions --fix-dangerously --type-aware --no-type-aware --project --type-check --infer-globals --no-infer-globals --threads --version --help --help-all --parser-options --parser --plugin --native-plugin-rules --no-native-plugin-rules --report-unused-disable-directives-severity --report-unused-inline-configs --no-warn-ignored --suppress-all --suppress-rule --suppressions-location --prune-suppressions --pass-on-unpruned-suppressions --pass-on-no-patterns --exit-on-fatal-error --concurrency --env-info --no-eslintrc --env --rulesdir --resolve-plugins-relative-to --allow --warn --deny --deny-warnings --silent --disable-nested-config --import-plugin --react-plugin --jsdoc-plugin --jest-plugin --vitest-plugin --jsx-a11y-plugin --nextjs-plugin --react-perf-plugin --promise-plugin --node-plugin --vue-plugin --disable-unicorn-plugin --disable-oxc-plugin --disable-typescript-plugin -c -f -o -p -v -h -A -W -D" -- "${cur_word}") ); return;; format) COMPREPLY=( $(compgen -f -W "--config --no-config --no-editorconfig --init --doctor --print-width --tab-width --use-tabs --no-semi --single-quote --jsx-single-quote --quote-props --trailing-comma --no-bracket-spacing --bracket-same-line --arrow-parens --object-wrap --single-attribute-per-line --html-whitespace-sensitivity --vue-indent-script-and-style --prose-wrap --embedded-language-formatting --end-of-line --no-insert-final-newline --sort-imports --sort-tailwindcss --sort-package-json --jsdoc --staged --changed --stdin-filepath --ignore-path --ignore-pattern --with-node-modules --toml --no-error-on-unmatched-pattern --ignore-unknown --allow-unsupported --list-files --cwd --check --list-different --log-level --format --color --no-color --timing --threads --no-verify --version --help --all --help-all --write --config-precedence --find-config-path --parser --plugin --require-pragma --check-ignore-pragma --insert-pragma --range-start --range-end --cursor-offset --experimental-ternaries --experimental-operator-position --jsx-bracket-same-line --flavor --disable-nested-config -u -c -l -f -v -h -w" -- "${cur_word}") ); diff --git a/completions/bun.fish b/completions/bun.fish index 1ec288c83604..8bd9ce795897 100644 --- a/completions/bun.fish +++ b/completions/bun.fish @@ -224,6 +224,7 @@ complete -c bun -n "__fish_seen_subcommand_from lint" -s "c" -l "config" -r -F - complete -c bun -n "__fish_seen_subcommand_from lint" -l "no-config-lookup" -d "Do not look for a config file" complete -c bun -n "__fish_seen_subcommand_from lint" -l "rule" -r -d "Set a rule: --rule 'eqeqeq: [error, smart]'" complete -c bun -n "__fish_seen_subcommand_from lint" -l "rules" -d "List the built-in rules" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "explain" -r -d "Print the documentation of a rule" complete -c bun -n "__fish_seen_subcommand_from lint" -l "init" -d "Add a [lint] section to bunfig.toml" complete -c bun -n "__fish_seen_subcommand_from lint" -l "global" -r -d "Define global variables: --global a,b:true" complete -c bun -n "__fish_seen_subcommand_from lint" -l "no-inline-config" -d "Ignore eslint-disable and other config comments" diff --git a/completions/bun.zsh b/completions/bun.zsh index c3df74caccb0..ec9fb57cd267 100644 --- a/completions/bun.zsh +++ b/completions/bun.zsh @@ -773,6 +773,7 @@ _bun_lint_completion() { '--no-config-lookup[Do not look for a config file]' \ '--rule[Set a rule\: --rule '"'"'eqeqeq\: \[error, smart\]'"'"']:rule' \ '--rules[List the built-in rules]' \ + '--explain[Print the documentation of a rule]:explain' \ '--init[Add a \[lint\] section to bunfig.toml]' \ '--global[Define global variables\: --global a,b\:true]:global' \ '--no-inline-config[Ignore eslint-disable and other config comments]' \ diff --git a/docs/guides/format/migrate-from-oxfmt.mdx b/docs/guides/format/migrate-from-oxfmt.mdx index 2f0b258c51e0..6c995ba005fd 100644 --- a/docs/guides/format/migrate-from-oxfmt.mdx +++ b/docs/guides/format/migrate-from-oxfmt.mdx @@ -6,6 +6,24 @@ mode: center `bun format` reads your `.oxfmtrc.json` and prints what oxfmt 0.72 prints. In most projects you only change the command. +Start with `--doctor`. It shows what Bun will do with your config, and it doesn't format or write anything. + +```sh terminal icon="terminal" +bun format --doctor +``` + +```txt +Using .oxfmtrc.json (oxfmt config) + + 3 files are formatted natively + +You can remove: oxfmt +``` + +--- + +Change the scripts. + ```json package.json icon="file-json" { "scripts": { @@ -19,7 +37,7 @@ mode: center Flags and arguments stay the same. Change CI and Git hooks too. -Edit the script first. If `package.json` has a `format` script, `bun format` runs that script, so you'd still get oxfmt. +Don't skip this. If `package.json` has a `format` script, `bun format` runs that script, so you'd still get oxfmt. --- @@ -34,30 +52,13 @@ $ bun format --check Checking formatting... All matched files use the correct format. -Finished in 3ms on 3 files using 8 threads. +Finished in 4ms on 3 files using 64 threads. ``` If you're on a version of oxfmt before 0.72, a few files change once, like when you upgrade oxfmt. --- -Run `--doctor` to see what Bun does with your config. It doesn't format or write anything. - -```sh terminal icon="terminal" -bun format --doctor -``` - -```txt -$ bun format --doctor -Using .oxfmtrc.json (oxfmt config) - - 3 files are formatted natively - -You can remove: oxfmt -``` - ---- - Uninstall oxfmt. ```sh terminal icon="terminal" diff --git a/docs/guides/format/migrate-from-prettier.mdx b/docs/guides/format/migrate-from-prettier.mdx index a7d53f7ea345..98185f1d13a2 100644 --- a/docs/guides/format/migrate-from-prettier.mdx +++ b/docs/guides/format/migrate-from-prettier.mdx @@ -6,6 +6,26 @@ mode: center `bun format` reads your `.prettierrc` and prints what Prettier 3 prints. In most projects you only change the command. +Start with `--doctor`. It shows what Bun will do with your config, and it doesn't format or write anything. + +```sh terminal icon="terminal" +bun format --doctor +``` + +```txt +Using .prettierrc (Prettier config) + + 4 files are formatted natively + +Built-in plugins: @ianvs/prettier-plugin-sort-imports + +You can remove: @ianvs/prettier-plugin-sort-imports, prettier +``` + +--- + +Change the scripts. + ```json package.json icon="file-json" { "scripts": { @@ -19,7 +39,7 @@ mode: center `bun format` writes by default and formats the current directory, so `--write .` goes away. Other flags and arguments stay the same. Change CI and Git hooks too. -Edit the script first. If `package.json` has a `format` script, `bun format` runs that script, so you'd still get Prettier. +Don't skip this. If `package.json` has a `format` script, `bun format` runs that script, so you'd still get Prettier. --- @@ -37,25 +57,6 @@ All matched files use Prettier code style! --- -Run `--doctor` to see what Bun does with your config. It doesn't format or write anything. - -```sh terminal icon="terminal" -bun format --doctor -``` - -```txt -$ bun format --doctor -Using .prettierrc (Prettier config) - - 4 files are formatted natively - -Built-in plugins: @ianvs/prettier-plugin-sort-imports - -You can remove: @ianvs/prettier-plugin-sort-imports, prettier -``` - ---- - Uninstall what `--doctor` says you can remove. Leave `.prettierrc` as it is, including `plugins`. ```sh terminal icon="terminal" @@ -98,6 +99,8 @@ If you use `prettier-plugin-tailwindcss`, take it out of `.prettierrc` and turn functions = ["clsx"] ``` +The order of the classes is the same. A few edge cases differ, like a class string inside a nested function call, so expect a small diff the first time. + --- Svelte, Astro, and other languages that come from a plugin go to your installed Prettier. Keep `prettier` and every plugin in `plugins` installed. `--doctor` shows which files those are. diff --git a/docs/guides/lint/migrate-from-eslint.mdx b/docs/guides/lint/migrate-from-eslint.mdx index 58ce98fb0f5c..260d8bfe942d 100644 --- a/docs/guides/lint/migrate-from-eslint.mdx +++ b/docs/guides/lint/migrate-from-eslint.mdx @@ -38,7 +38,7 @@ $ bun lint --max-warnings 0 ✖ 3 problems (2 errors, 1 warning) 1 error and 0 warnings potentially fixable with the `--fix` option. -Linted 3 files [115.93ms] +Linted 3 files [108.90ms] error: script "lint" exited with code 1 ``` @@ -51,7 +51,6 @@ bun lint --doctor ``` ```txt -$ bun lint --max-warnings 0 --doctor Using eslint.config.js (ESLint flat config) 3 files to lint @@ -77,6 +76,15 @@ bun remove eslint --- +You can drop lint-staged. `--staged` lints only the staged files, and with `--fix` it stages the fixed files again. + +```sh .husky/pre-commit icon="terminal" +npx lint-staged # [!code --] +bun lint --staged --fix # [!code ++] +``` + +--- + Type-aware rules need no changes. If your config asks for types, they run. ```js eslint.config.js icon="/icons/javascript.svg" @@ -103,8 +111,6 @@ bun lint --ext .js,.ts,.tsx src # [!code ++] If your config asks for something Bun can't do, Bun lints everything else, tells you what it skipped, and exits with code `2`. ```txt -note: 1 deprecated formatting rule did not run: semi - Use bun format instead. error: 1 rule did not run because rules that run in JavaScript cannot use type information: local/typed Everything else was linted. Use --allow-unsupported to make this a warning. ``` diff --git a/docs/guides/lint/migrate-from-oxlint.mdx b/docs/guides/lint/migrate-from-oxlint.mdx index 9a7f9a8f26d0..b0a79bfb87a1 100644 --- a/docs/guides/lint/migrate-from-oxlint.mdx +++ b/docs/guides/lint/migrate-from-oxlint.mdx @@ -46,7 +46,7 @@ error: Invalid `typeof` comparison value. valid-typeof note: Did you mean `"string"`? -Linted 1 file [18.39ms] +Linted 1 file [6.46ms] error: script "lint" exited with code 1 ``` @@ -60,6 +60,21 @@ Run `--doctor` to see what Bun does with your config. It doesn't lint or write a bun lint --doctor ``` +```txt +Using .oxlintrc.json (oxlint config) + + 1 file to lint + + 116 rules run natively + 0 rules run in JavaScript + 25 rules do not run + 25 need types, use --type-aware: @typescript-eslint/await-thenable, @typescript-eslint/consistent-return, @typescript-eslint/no-array-delete, .. + +You can remove: oxlint +``` + +oxlint also skips type-aware rules unless you pass `--type-aware`, so you lose nothing here. + --- Uninstall oxlint. @@ -70,7 +85,20 @@ bun remove oxlint oxlint-tsgolint `import { defineConfig } from "oxlint"` in an `oxlint.config.ts` keeps working without the package. -If the project has no config file, run `oxlint --init` first. Without one, Bun only uses oxlint's defaults while `package.json` depends on `oxlint`. After that you get [Bun's defaults](/runtime/lint#default-rules). +No config file? Then Bun only uses oxlint's defaults while `package.json` depends on `oxlint`. To keep them, run this before you uninstall. It writes `flavor = "oxlint"` to `bunfig.toml`. Without it you get [Bun's defaults](/runtime/lint#default-rules). + +```sh terminal icon="terminal" +bun lint --init --flavor=oxlint +``` + +--- + +You can drop lint-staged. `--staged` lints only the staged files, and with `--fix` it stages the fixed files again. + +```sh .husky/pre-commit icon="terminal" +npx lint-staged # [!code --] +bun lint --staged --fix # [!code ++] +``` --- @@ -119,7 +147,7 @@ If `bun lint` and oxlint 1.87 disagree about a rule, that's a bug. [Open an issu - **Patterns in rule options are JavaScript regular expressions.** oxlint uses Rust's `regex` crate. - **Control flow rules follow ESLint** where oxlint differs: `no-unreachable`, `no-fallthrough`, `getter-return`, `array-callback-return`, `constructor-super`, `no-this-before-super`, `no-useless-assignment`. - **`--fix` never writes a file that no longer parses.** Bun leaves the file alone and warns. -- **`--init` adds `[lint]` to `bunfig.toml`.** With an oxlint config in place it does nothing. +- **`--init` adds `[lint]` to `bunfig.toml`**, not an `.oxlintrc.json`. If you already have an oxlint config, it does nothing. - **`--lsp` starts [Bun's language server](/runtime/lint#editors)**, not oxlint's. --- diff --git a/docs/lint/plugins.mdx b/docs/lint/plugins.mdx index 62a2e45fc2ed..37627216ed88 100644 --- a/docs/lint/plugins.mdx +++ b/docs/lint/plugins.mdx @@ -68,7 +68,7 @@ bun lint ✖ 1 problem (0 errors, 1 warning) -Linted 3 files [77.36ms] +Linted 3 files [73.16ms] ``` You don't need the `eslint` package. To learn the API, read ESLint's guide to [plugins](https://eslint.org/docs/latest/extend/plugins). @@ -93,7 +93,9 @@ export default [ ]; ``` -Six plugins are built into Bun and run natively: `@typescript-eslint`, `react`, `react-hooks`, `import`, `n`, and `prettier`. `bun lint --doctor` shows which of your rules run natively and which run in JavaScript. +With an ESLint config, six plugins run natively: `@typescript-eslint`, `react`, `react-hooks`, `import`, `n`, and `prettier`. Everything else runs from `node_modules`, including `unicorn`, `jsx-a11y`, `promise`, `jest`, and `vitest`. Bun's built-in versions of those follow oxlint, so Bun only uses them with `bunfig.toml` or an oxlint config. + +`bun lint --doctor` shows which of your rules run natively and which run in JavaScript. ## Performance diff --git a/docs/lint/rules/bun-test/consistent-test-filename.mdx b/docs/lint/rules/bun-test/consistent-test-filename.mdx index 29db9720fa3e..eb08ea581e78 100644 --- a/docs/lint/rules/bun-test/consistent-test-filename.mdx +++ b/docs/lint/rules/bun-test/consistent-test-filename.mdx @@ -1,19 +1,27 @@ --- title: "bun:test/consistent-test-filename" description: "Require test files to follow one naming pattern" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- -When some test files are named `user.test.ts` and others `user.spec.ts`, a glob or a search written for one name misses the other. This rule picks one. By default a file that ends in `.test` or `.spec` plus `.js`, `.jsx`, `.ts` or `.tsx` is a test file, and its name has to use `.test`. +When some test files are named `user.test.ts` and others `user.spec.ts`, a glob or a search written for one name misses the other. -Only files that import from `"bun:test"` are checked. +By default, a file that ends in `.test` or `.spec` plus `.js`, `.jsx`, `.ts` or `.tsx` is a test file, and it has to use `.test`. Only files that import from `"bun:test"` are checked. ## Examples -Reported, in a file named `src/user.spec.ts`: +Reported: {/* bad {"filename": "src/user.spec.ts"} */} - -```ts +```ts src/user.spec.ts import { expect, test } from "bun:test"; test("has a name", () => { @@ -21,11 +29,10 @@ test("has a name", () => { }); ``` -Not reported, in a file named `src/user.test.ts`: +Not reported: {/* good {"filename": "src/user.test.ts"} */} - -```ts +```ts src/user.test.ts import { expect, test } from "bun:test"; test("has a name", () => { @@ -35,11 +42,11 @@ test("has a name", () => { ## Options -Both options are regular expressions, written as strings and tested against the file's path. `/pattern/` works too. Flags after the last slash are ignored. +Both options are regular expressions, written as strings and tested against the file's path. `/pattern/` works too, but flags after the last slash are ignored. -| Option | Default | What it does | -| ---------------- | ---------------------------- | -------------------------------------------------------- | -| `pattern` | `.*\.test\.[tj]sx?$` | What the path of a test file has to match. | +| Option | Default | What it does | +| --- | --- | --- | +| `pattern` | `.*\.test\.[tj]sx?$` | What the path of a test file has to match. | | `allTestPattern` | `.*\.(test\|spec)\.[tj]sx?$` | Which files are test files. Other files are not checked. | ```toml bunfig.toml @@ -47,29 +54,19 @@ Both options are regular expressions, written as strings and tested against the "bun:test/consistent-test-filename" = ["error", { pattern = '.*\.spec\.ts$' }] ``` -With that `pattern`, `src/user.test.ts` is reported and `src/user.spec.ts` is not: +With that `pattern`, `.test.ts` is the name that is reported: {/* bad {"options": [{"pattern": ".*\\.spec\\.ts$"}], "filename": "src/user.test.ts"} */} - -```ts -import { test } from "bun:test"; - -test.todo("has a name"); -``` - -{/* good {"options": [{"pattern": ".*\\.spec\\.ts$"}], "filename": "src/user.spec.ts"} */} - -```ts +```ts src/user.test.ts import { test } from "bun:test"; test.todo("has a name"); ``` -With `allTestPattern` set to `__tests__`, every file under a `__tests__` directory is a test file. `__tests__/user.ts` is reported because it does not end in `.test.ts`: +With `allTestPattern` set to `__tests__`, every file with `__tests__` in its path is a test file. This one is reported because it does not end in `.test.ts`: {/* bad {"options": [{"allTestPattern": "__tests__"}], "filename": "__tests__/user.ts"} */} - -```ts +```ts __tests__/user.ts import { test } from "bun:test"; test.todo("has a name"); diff --git a/docs/lint/rules/bun-test/consistent-test-it.mdx b/docs/lint/rules/bun-test/consistent-test-it.mdx index 7707adca1b02..30a56d697184 100644 --- a/docs/lint/rules/bun-test/consistent-test-it.mdx +++ b/docs/lint/rules/bun-test/consistent-test-it.mdx @@ -1,6 +1,15 @@ --- title: "bun:test/consistent-test-it" description: "Enforce one of `test` or `it` for defining tests" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "yes" +typeAware: false +deprecated: false --- `test` and `it` are two names for the same function. When a project uses both at random, a reader wonders whether the difference means something. It does not. @@ -14,7 +23,6 @@ By default, a test at the top level of a file uses `test`, and a test inside a ` Reported: {/* bad {"count": 2} */} - ```ts import { describe, expect, it, test } from "bun:test"; @@ -32,7 +40,6 @@ describe("subtract", () => { Not reported: {/* good */} - ```ts import { describe, expect, it, test } from "bun:test"; @@ -50,7 +57,6 @@ describe("subtract", () => { Forms such as `it.skip`, `it.only` and `it.each` are checked too: {/* bad */} - ```ts import { expect, it } from "bun:test"; @@ -61,10 +67,10 @@ it.skip("adds", () => { ## Options -| Option | Default | What it does | -| ---------------- | ------------------------------------------------- | ------------------------------------------------------------------ | -| `fn` | `"test"` | The name for tests at the top level of a file: `"test"` or `"it"`. | -| `withinDescribe` | the value of `fn` if you set it, otherwise `"it"` | The name for tests inside a `describe`. | +| Option | Default | What it does | +| --- | --- | --- | +| `fn` | `"test"` | The name for tests at the top level of a file: `"test"` or `"it"`. | +| `withinDescribe` | the value of `fn` if you set it, otherwise `"it"` | The name for tests inside a `describe`. | To use `test` everywhere, set only `fn`: @@ -73,8 +79,9 @@ To use `test` everywhere, set only `fn`: "bun:test/consistent-test-it" = ["error", { fn = "test" }] ``` -{/* bad {"options": [{"fn": "test"}]} */} +Then `it` inside a `describe` is reported too: +{/* bad {"options": [{"fn": "test"}]} */} ```ts import { describe, expect, it } from "bun:test"; @@ -84,15 +91,3 @@ describe("subtract", () => { }); }); ``` - -{/* good {"options": [{"fn": "test"}]} */} - -```ts -import { describe, expect, test } from "bun:test"; - -describe("subtract", () => { - test("goes below zero", () => { - expect(1 - 2).toBe(-1); - }); -}); -``` diff --git a/docs/lint/rules/bun-test/expect-expect.mdx b/docs/lint/rules/bun-test/expect-expect.mdx index 631532962760..6921965b29bb 100644 --- a/docs/lint/rules/bun-test/expect-expect.mdx +++ b/docs/lint/rules/bun-test/expect-expect.mdx @@ -1,9 +1,18 @@ --- title: "bun:test/expect-expect" description: "Require every test to make at least one assertion" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "problem" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- -A test with no assertion passes as long as the code does not throw. It is green in the report whether the result is right or wrong. Most of the time the `expect` was forgotten, or removed while the test was being changed. +A test with no assertion passes as long as the code does not throw, whether the result is right or wrong. Usually the `expect` was forgotten, or lost while the test was being changed. Calls to `expect` and `expectTypeOf` count as assertions. `test.todo` and `test.skip` are not checked. @@ -12,7 +21,6 @@ Calls to `expect` and `expectTypeOf` count as assertions. `test.todo` and `test. Reported: {/* bad */} - ```ts import { test } from "bun:test"; @@ -24,7 +32,6 @@ test("parses the port", () => { Not reported: {/* good */} - ```ts import { expect, test } from "bun:test"; @@ -41,17 +48,16 @@ test.skip("reads the file", () => { ## Options -| Option | Default | What it does | -| ------------------------------ | ---------------------------- | --------------------------------------------------------------------------------------------------- | -| `assertFunctionNames` | `["expect", "expectTypeOf"]` | The functions that count as an assertion. Your list replaces the default, so keep `"expect"` in it. | -| `additionalTestBlockFunctions` | `[]` | Other `bun:test` functions to check like `test`, such as `"afterEach"`. | +| Option | Default | What it does | +| --- | --- | --- | +| `assertFunctionNames` | `["expect", "expectTypeOf"]` | The functions that count as an assertion. Your list replaces the default, so keep `"expect"` in it. | +| `additionalTestBlockFunctions` | `[]` | Other `bun:test` functions to check like `test`, such as `"afterEach"`. | ### `assertFunctionNames` The rule does not look inside the helpers that a test calls. This test is reported even though `checkUser` calls `expect`: {/* bad */} - ```ts import { expect, test } from "bun:test"; @@ -64,7 +70,7 @@ test("creates a user", () => { }); ``` -Add the helper's name: +Add the helper's name, and it is not reported: ```toml bunfig.toml [lint.rules] @@ -72,7 +78,6 @@ Add the helper's name: ``` {/* good {"options": [{"assertFunctionNames": ["expect", "checkUser"]}]} */} - ```ts import { expect, test } from "bun:test"; @@ -85,10 +90,11 @@ test("creates a user", () => { }); ``` -A name can be a chain with dots. `*` stands for any letters and digits in one part of the chain, and `**` for any number of parts. Upper and lower case are treated the same. +A name can be a chain, such as `request.get.expect`. `*` matches any letters and digits in one part of the chain, and `**` matches one or more parts. Case is ignored. -{/* good {"options": [{"assertFunctionNames": ["expect", "request.**.expect"]}]} */} +With `"request.**.expect"` in the list, this is not reported: +{/* good {"options": [{"assertFunctionNames": ["expect", "request.**.expect"]}]} */} ```ts import { test } from "bun:test"; @@ -99,10 +105,9 @@ test("is healthy", async () => { ### `additionalTestBlockFunctions` -This option only works for functions that come from `bun:test`. A wrapper of your own is not checked, even if you list it. +This option only works for functions that come from `bun:test`. With `["afterEach"]`, this is reported: {/* bad {"options": [{"additionalTestBlockFunctions": ["afterEach"]}]} */} - ```ts import { afterEach } from "bun:test"; @@ -111,15 +116,16 @@ afterEach(() => { }); ``` -{/* good {"options": [{"additionalTestBlockFunctions": ["testWithDatabase"]}]} */} +A wrapper of your own is not checked, even if you list it. With `["testWithDatabase"]`, this is not reported: +{/* good {"options": [{"additionalTestBlockFunctions": ["testWithDatabase"]}]} */} ```ts import { afterAll } from "bun:test"; import { closeDatabase, testWithDatabase } from "./helpers"; afterAll(closeDatabase); -testWithDatabase("saves the user", db => { +testWithDatabase("saves the user", (db) => { db.save(createUser()); }); ``` diff --git a/docs/lint/rules/bun-test/max-expects.mdx b/docs/lint/rules/bun-test/max-expects.mdx index 6aad70a421be..c0ac6dac6f38 100644 --- a/docs/lint/rules/bun-test/max-expects.mdx +++ b/docs/lint/rules/bun-test/max-expects.mdx @@ -1,9 +1,18 @@ --- title: "bun:test/max-expects" description: "Enforce a maximum number of `expect()` calls in a test" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- -A test with many assertions usually checks several things at once. The first `expect` that fails stops the test, so one failure hides the ones after it, and the test's name no longer says what broke. +A test with many assertions usually checks several things at once. The first `expect` that fails stops the test, so it hides the failures after it, and the test's name no longer says what broke. By default a test can have 5 `expect()` calls. Each call after that is reported. @@ -12,7 +21,6 @@ By default a test can have 5 `expect()` calls. Each call after that is reported. Reported: {/* bad {"count": 1} */} - ```ts import { expect, test } from "bun:test"; @@ -30,7 +38,6 @@ test("creates a user", () => { Not reported: {/* good */} - ```ts import { expect, test } from "bun:test"; @@ -48,35 +55,20 @@ test("fills in the defaults", () => { }); ``` -Calls are counted per function and per block. An `expect` inside a loop or a callback does not add to the count of the test around it: - -{/* good {"options": [{"max": 1}]} */} - -```ts -import { expect, test } from "bun:test"; - -test("lists active users", () => { - const users = listUsers({ active: true }); - expect(users).toHaveLength(2); - for (const user of users) { - expect(user.active).toBe(true); - } -}); -``` - ## Options -| Option | Default | What it does | -| ------ | ------- | --------------------------------------- | -| `max` | `5` | The number of `expect()` calls allowed. | +| Option | Default | What it does | +| --- | --- | --- | +| `max` | `5` | The number of `expect()` calls allowed. | ```toml bunfig.toml [lint.rules] "bun:test/max-expects" = ["error", { max = 2 }] ``` -{/* bad {"options": [{"max": 2}], "count": 1} */} +With `max = 2`, the third call is reported: +{/* bad {"options": [{"max": 2}], "count": 1} */} ```ts import { expect, test } from "bun:test"; @@ -87,3 +79,19 @@ test("parses a URL", () => { expect(url.path).toBe("/docs"); }); ``` + +Calls are counted per function and per block. An `expect` inside a loop or a callback does not count toward the test around it, so this is not reported: + +{/* good {"options": [{"max": 2}]} */} +```ts +import { expect, test } from "bun:test"; + +test("lists active users", () => { + const users = listUsers({ active: true }); + expect(users).toHaveLength(2); + expect(users[0].name).toBe("Ada"); + for (const user of users) { + expect(user.active).toBe(true); + } +}); +``` diff --git a/docs/lint/rules/bun-test/max-nested-describe.mdx b/docs/lint/rules/bun-test/max-nested-describe.mdx index e7236d702629..40ded89339ef 100644 --- a/docs/lint/rules/bun-test/max-nested-describe.mdx +++ b/docs/lint/rules/bun-test/max-nested-describe.mdx @@ -1,11 +1,20 @@ --- title: "bun:test/max-nested-describe" description: "Enforce a maximum depth of nested `describe()` calls" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- Every level of `describe` can add its own hooks and variables. To understand a test that sits many levels deep, you have to read all the blocks around it, and the test's full name gets long. -By default `describe` blocks can be 5 levels deep. A `describe` at a deeper level is reported. Forms such as `describe.each` count as levels too. +By default `describe` blocks can be 5 levels deep. A `describe` at a deeper level is reported. ## Examples @@ -19,7 +28,6 @@ These examples use a maximum of 2: Reported: {/* bad {"options": [{"max": 2}], "count": 1} */} - ```ts import { describe, expect, test } from "bun:test"; @@ -37,7 +45,6 @@ describe("cart", () => { Not reported: {/* good {"options": [{"max": 2}]} */} - ```ts import { describe, expect, test } from "bun:test"; @@ -52,32 +59,6 @@ describe("cart", () => { ## Options -| Option | Default | What it does | -| ------ | ------- | --------------------------------------------------------------------- | -| `max` | `5` | The number of levels allowed. With `0`, every `describe` is reported. | - -{/* bad {"options": [{"max": 1}]} */} - -```ts -import { describe, expect, test } from "bun:test"; - -describe("parse", () => { - describe.each(["json", "toml"])("%s", format => { - test("accepts an empty file", () => { - expect(parse("", format)).toEqual({}); - }); - }); -}); -``` - -{/* bad {"options": [{"max": 0}]} */} - -```ts -import { describe, expect, test } from "bun:test"; - -describe("parse", () => { - test("accepts an empty file", () => { - expect(parse("", "json")).toEqual({}); - }); -}); -``` +| Option | Default | What it does | +| --- | --- | --- | +| `max` | `5` | The number of levels allowed. | diff --git a/docs/lint/rules/bun-test/no-alias-methods.mdx b/docs/lint/rules/bun-test/no-alias-methods.mdx index 850078ac31d6..7fa0a28283ac 100644 --- a/docs/lint/rules/bun-test/no-alias-methods.mdx +++ b/docs/lint/rules/bun-test/no-alias-methods.mdx @@ -1,6 +1,15 @@ --- title: "bun:test/no-alias-methods" description: "Disallow matcher aliases such as `toBeCalled` in favor of `toHaveBeenCalled`" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "yes" +typeAware: false +deprecated: false --- Some matchers have two names: `toBeCalled` is the same matcher as `toHaveBeenCalled`. If a project uses both, a search for one name misses the tests that use the other. @@ -12,7 +21,6 @@ Some matchers have two names: `toBeCalled` is the same matcher as `toHaveBeenCal Reported: {/* bad */} - ```ts import { expect, mock, test } from "bun:test"; @@ -26,7 +34,6 @@ test("saves the draft", () => { Not reported: {/* good */} - ```ts import { expect, mock, test } from "bun:test"; @@ -39,16 +46,16 @@ test("saves the draft", () => { The full list: -| Reported | Write instead | -| ------------------ | -------------------------- | -| `toBeCalled` | `toHaveBeenCalled` | -| `toBeCalledTimes` | `toHaveBeenCalledTimes` | -| `toBeCalledWith` | `toHaveBeenCalledWith` | -| `lastCalledWith` | `toHaveBeenLastCalledWith` | -| `nthCalledWith` | `toHaveBeenNthCalledWith` | -| `toReturn` | `toHaveReturned` | -| `toReturnTimes` | `toHaveReturnedTimes` | -| `toReturnWith` | `toHaveReturnedWith` | -| `lastReturnedWith` | `toHaveLastReturnedWith` | -| `nthReturnedWith` | `toHaveNthReturnedWith` | -| `toThrowError` | `toThrow` | +| Reported | Write instead | +| --- | --- | +| `toBeCalled` | `toHaveBeenCalled` | +| `toBeCalledTimes` | `toHaveBeenCalledTimes` | +| `toBeCalledWith` | `toHaveBeenCalledWith` | +| `lastCalledWith` | `toHaveBeenLastCalledWith` | +| `nthCalledWith` | `toHaveBeenNthCalledWith` | +| `toReturn` | `toHaveReturned` | +| `toReturnTimes` | `toHaveReturnedTimes` | +| `toReturnWith` | `toHaveReturnedWith` | +| `lastReturnedWith` | `toHaveLastReturnedWith` | +| `nthReturnedWith` | `toHaveNthReturnedWith` | +| `toThrowError` | `toThrow` | diff --git a/docs/lint/rules/bun-test/no-commented-out-tests.mdx b/docs/lint/rules/bun-test/no-commented-out-tests.mdx index 74ec34d5f1a4..7567bb3a8fee 100644 --- a/docs/lint/rules/bun-test/no-commented-out-tests.mdx +++ b/docs/lint/rules/bun-test/no-commented-out-tests.mdx @@ -1,6 +1,15 @@ --- title: "bun:test/no-commented-out-tests" description: "Disallow tests that are inside a comment" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "problem" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- A test inside a comment never runs, and the test output does not say so. It is easy to comment one out while debugging and forget it. @@ -14,7 +23,6 @@ A comment is reported if one of its lines starts with a call to `test`, `it` or Reported: {/* bad */} - ```ts import { expect, test } from "bun:test"; @@ -24,7 +32,6 @@ import { expect, test } from "bun:test"; ``` {/* bad */} - ```ts import { describe, expect, it } from "bun:test"; @@ -40,7 +47,6 @@ describe("Math.floor", () => { Not reported: {/* good */} - ```ts import { expect, test } from "bun:test"; diff --git a/docs/lint/rules/bun-test/no-conditional-expect.mdx b/docs/lint/rules/bun-test/no-conditional-expect.mdx index f4e2564ffe52..8207e9330608 100644 --- a/docs/lint/rules/bun-test/no-conditional-expect.mdx +++ b/docs/lint/rules/bun-test/no-conditional-expect.mdx @@ -1,6 +1,15 @@ --- title: "bun:test/no-conditional-expect" description: "Disallow `expect` inside `if`, `catch` and other code that only runs sometimes" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "problem" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- An `expect` that only runs under a condition may not run at all. If the condition is false, or nothing is thrown, the test passes without checking anything. @@ -14,7 +23,6 @@ It also checks `function` declarations that a test uses. It does not look inside Reported, because the test passes if `JSON.parse` stops throwing: {/* bad */} - ```ts import { expect, test } from "bun:test"; @@ -28,7 +36,6 @@ test("rejects bad JSON", () => { ``` {/* bad */} - ```ts import { expect, test } from "bun:test"; import { readFile } from "node:fs/promises"; @@ -41,7 +48,6 @@ test("rejects a missing file", async () => { ``` {/* bad */} - ```ts import { expect, test } from "bun:test"; @@ -58,7 +64,6 @@ test("finds the admin", () => { Not reported: {/* good */} - ```ts import { expect, test } from "bun:test"; import { readFile } from "node:fs/promises"; diff --git a/docs/lint/rules/bun-test/no-conditional-in-test.mdx b/docs/lint/rules/bun-test/no-conditional-in-test.mdx index ab2fd5452fbc..721b7311d2c0 100644 --- a/docs/lint/rules/bun-test/no-conditional-in-test.mdx +++ b/docs/lint/rules/bun-test/no-conditional-in-test.mdx @@ -1,6 +1,15 @@ --- title: "bun:test/no-conditional-in-test" description: "Disallow `if`, `switch`, `? :`, `&&`, `||` and `??` inside a test" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- A test with a branch in it checks different things on different runs. When it fails you first have to find out which path it took, and a path that is never taken is never tested. The branch often repeats the logic of the code under test, mistakes included. @@ -12,7 +21,6 @@ Write one test for each case, or a table of cases with `test.each`. Conditions o Reported: {/* bad */} - ```ts import { expect, test } from "bun:test"; import { label, users } from "./users"; @@ -28,7 +36,6 @@ test("labels every user", () => { Not reported: {/* good */} - ```ts import { expect, test } from "bun:test"; import { label } from "./users"; @@ -44,7 +51,6 @@ test.each([ Reported: {/* bad */} - ```ts import { expect, test } from "bun:test"; import { homeDirectory } from "./paths"; @@ -61,7 +67,6 @@ test("finds the home directory", () => { Not reported, because the condition decides which test runs and is not inside one: {/* good */} - ```ts import { expect, test } from "bun:test"; import { homeDirectory } from "./paths"; @@ -80,7 +85,6 @@ test.skipIf(isWindows)("finds the home directory", () => { The argument of `skipIf` counts as part of the test, so an `||` written there is reported. Put the condition in a variable first. {/* bad */} - ```ts import { expect, test } from "bun:test"; import { homeDirectory } from "./paths"; diff --git a/docs/lint/rules/bun-test/no-conditional-tests.mdx b/docs/lint/rules/bun-test/no-conditional-tests.mdx index 52c12f192fbd..03aead95eb76 100644 --- a/docs/lint/rules/bun-test/no-conditional-tests.mdx +++ b/docs/lint/rules/bun-test/no-conditional-tests.mdx @@ -1,6 +1,15 @@ --- title: "bun:test/no-conditional-tests" description: "Disallow `test` and `describe` inside an `if` statement" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "problem" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- A test declared inside an `if` only exists when the condition is true. When it is false, the test is missing from the output, so nobody sees that it did not run. @@ -14,7 +23,6 @@ The rule is about the `if` around a test. For an `if` inside a test, see `bun:te Reported: {/* bad */} - ```ts import { expect, test } from "bun:test"; @@ -26,7 +34,6 @@ if (process.platform === "linux") { ``` {/* bad */} - ```ts import { describe, expect, test } from "bun:test"; import { connect } from "./database"; @@ -43,7 +50,6 @@ describe("database", () => { Not reported: {/* good */} - ```ts import { expect, test } from "bun:test"; @@ -53,7 +59,6 @@ test.if(process.platform === "linux")("reads the kernel version", async () => { ``` {/* good */} - ```ts import { describe, expect, test } from "bun:test"; import { connect } from "./database"; diff --git a/docs/lint/rules/bun-test/no-disabled-tests.mdx b/docs/lint/rules/bun-test/no-disabled-tests.mdx index 8ce2c3b73d9a..bb53f8a86f3d 100644 --- a/docs/lint/rules/bun-test/no-disabled-tests.mdx +++ b/docs/lint/rules/bun-test/no-disabled-tests.mdx @@ -1,6 +1,15 @@ --- title: "bun:test/no-disabled-tests" description: "Disallow skipped tests such as `test.skip`, `xtest` and `xdescribe`" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "problem" +category: null +default: "warn" +fixable: "no" +typeAware: false +deprecated: false --- A skipped test does not run, and `bun test` still passes. `.skip` is useful while you work on something else. If it stays in the code, nobody notices when the behavior it covered breaks. @@ -14,7 +23,6 @@ This rule is on by default, as a warning, in files that import from `"bun:test"` Reported: {/* bad {"count": 2} */} - ```ts import { describe, expect, test, xtest } from "bun:test"; @@ -32,7 +40,6 @@ xtest("applies the coupon", () => { A test with no function has nothing to run: {/* bad */} - ```ts import { test } from "bun:test"; @@ -42,7 +49,6 @@ test("refunds the order"); Not reported: {/* good */} - ```ts import { expect, test } from "bun:test"; diff --git a/docs/lint/rules/bun-test/no-done-callback.mdx b/docs/lint/rules/bun-test/no-done-callback.mdx index 7de5a4d29994..4ad92246b871 100644 --- a/docs/lint/rules/bun-test/no-done-callback.mdx +++ b/docs/lint/rules/bun-test/no-done-callback.mdx @@ -1,6 +1,15 @@ --- title: "bun:test/no-done-callback" description: "Disallow the `done` callback in tests and hooks in favor of promises" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- A test that takes a `done` parameter waits until `done()` is called. If some code path never calls it, the test hangs until it times out, and the timeout does not say why. An `async` test, or one that returns a promise, ends when the promise settles and fails with the error it rejects with. @@ -12,7 +21,6 @@ The name of the parameter does not matter. Any test or hook callback with exactl Reported: {/* bad */} - ```ts import { expect, test } from "bun:test"; @@ -27,7 +35,6 @@ test("reads the config", done => { Not reported: {/* good */} - ```ts import { expect, test } from "bun:test"; @@ -40,7 +47,6 @@ test("reads the config", async () => { If the API only takes a callback, wrap it in a promise: {/* good */} - ```ts import { afterAll } from "bun:test"; @@ -50,7 +56,6 @@ afterAll(() => new Promise(resolve => server.close(resolve))); The callback of `test.each([...])` is not checked, because its parameters are the values of the row: {/* good */} - ```ts import { expect, test } from "bun:test"; diff --git a/docs/lint/rules/bun-test/no-duplicate-hooks.mdx b/docs/lint/rules/bun-test/no-duplicate-hooks.mdx index a5bbfd5dbcaa..f2e4fd66a18d 100644 --- a/docs/lint/rules/bun-test/no-duplicate-hooks.mdx +++ b/docs/lint/rules/bun-test/no-duplicate-hooks.mdx @@ -1,6 +1,15 @@ --- title: "bun:test/no-duplicate-hooks" description: "Disallow using the same hook twice in one `describe` block" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- Two `beforeEach` calls in the same block both run, so nothing fails. But someone who reads the first one assumes it is all the setup and misses the second, which can be many lines below. @@ -12,7 +21,6 @@ Hooks are counted per `describe` block, and the top level of the file counts as Reported: {/* bad */} - ```ts import { beforeEach, describe, test } from "bun:test"; @@ -32,7 +40,6 @@ describe("cart", () => { Not reported: {/* good */} - ```ts import { beforeEach, describe, test } from "bun:test"; diff --git a/docs/lint/rules/bun-test/no-export.mdx b/docs/lint/rules/bun-test/no-export.mdx index f3e4ea99ab1f..fc9f3ffdeb8d 100644 --- a/docs/lint/rules/bun-test/no-export.mdx +++ b/docs/lint/rules/bun-test/no-export.mdx @@ -1,6 +1,15 @@ --- title: "bun:test/no-export" description: "Disallow exports from a file that has tests" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "problem" +category: null +default: "error" +fixable: "no" +typeAware: false +deprecated: false --- When another file imports from a test file, the whole test file runs, including its `test()` calls, so its tests can run as part of the importing file. Put shared helpers in a file of their own. @@ -14,7 +23,6 @@ This rule is on by default in files that import from `"bun:test"`. Reported: {/* bad {"filename": "src/cart.test.ts"} */} - ```ts import { expect, test } from "bun:test"; @@ -30,7 +38,6 @@ test("starts empty", () => { Not reported, with the helper in a file of its own: {/* file "helpers.ts" */} - ```ts export function makeCart() { return { items: [] }; @@ -38,7 +45,6 @@ export function makeCart() { ``` {/* good {"filename": "cart.test.ts"} */} - ```ts import { expect, test } from "bun:test"; import { makeCart } from "./helpers"; diff --git a/docs/lint/rules/bun-test/no-focused-tests.mdx b/docs/lint/rules/bun-test/no-focused-tests.mdx index 1941bdf1ab2c..21e8c920ded7 100644 --- a/docs/lint/rules/bun-test/no-focused-tests.mdx +++ b/docs/lint/rules/bun-test/no-focused-tests.mdx @@ -1,6 +1,15 @@ --- title: "bun:test/no-focused-tests" description: "Disallow `test.only` and `describe.only`" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "problem" +category: null +default: "error" +fixable: "suggestion" +typeAware: false +deprecated: false --- `.only` makes `bun test` run that test and skip every other one in the file. It is useful while debugging. Committed by accident, it hides failures in the tests that no longer run. @@ -12,7 +21,6 @@ This rule is on by default in files that import from `"bun:test"`. Reported: {/* bad */} - ```ts import { expect, test } from "bun:test"; @@ -24,7 +32,6 @@ test.only("adds", () => { Not reported: {/* good */} - ```ts import { expect, test } from "bun:test"; diff --git a/docs/lint/rules/bun-test/no-hooks.mdx b/docs/lint/rules/bun-test/no-hooks.mdx index c2f1e69de791..a02a54cd5fa8 100644 --- a/docs/lint/rules/bun-test/no-hooks.mdx +++ b/docs/lint/rules/bun-test/no-hooks.mdx @@ -1,6 +1,15 @@ --- title: "bun:test/no-hooks" description: "Disallow `beforeAll`, `beforeEach`, `afterEach` and `afterAll`" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- A hook usually sets a variable that every test in the block shares. To understand one test you have to find the hooks above it, and a test that changes the shared value can break the next one. A test that calls a setup function itself shows everything it depends on. @@ -10,7 +19,6 @@ A hook usually sets a variable that every test in the block shares. To understan Reported: {/* bad */} - ```ts import { beforeEach, expect, test } from "bun:test"; @@ -28,7 +36,6 @@ test("starts empty", () => { Not reported: {/* good */} - ```ts import { expect, test } from "bun:test"; @@ -40,14 +47,13 @@ test("starts empty", () => { ## Options -| Option | Default | What it does | -| ------- | ------- | ------------------------ | -| `allow` | `[]` | Names of hooks to allow. | +| Option | Default | What it does | +| --- | --- | --- | +| `allow` | `[]` | Names of hooks to allow. | With `{ "allow": ["afterEach"] }`, only `beforeAll` is reported here: {/* bad {"options": [{"allow": ["afterEach"]}], "count": 1} */} - ```ts import { afterEach, beforeAll, mock } from "bun:test"; diff --git a/docs/lint/rules/bun-test/no-identical-title.mdx b/docs/lint/rules/bun-test/no-identical-title.mdx index 46529c809d3c..f18398a84187 100644 --- a/docs/lint/rules/bun-test/no-identical-title.mdx +++ b/docs/lint/rules/bun-test/no-identical-title.mdx @@ -1,6 +1,15 @@ --- title: "bun:test/no-identical-title" description: "Disallow two tests or two `describe` blocks with the same title in one block" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- When two tests in the same block share a title, a failure report does not tell you which one failed, and filtering by name runs both. The second one is often a copy that kept the old title. @@ -12,7 +21,6 @@ Titles are compared inside one block. The same title in two different `describe` Reported: {/* bad */} - ```ts import { describe, expect, test } from "bun:test"; @@ -30,7 +38,6 @@ describe("parsePrice", () => { Two `describe` blocks next to each other are compared the same way: {/* bad */} - ```ts import { describe, test } from "bun:test"; @@ -46,7 +53,6 @@ describe("cart", () => { Not reported: {/* good */} - ```ts import { describe, expect, test } from "bun:test"; @@ -70,7 +76,6 @@ describe("formatPrice", () => { Only titles that are plain strings are compared. A title built from a variable is skipped, and so is `.each`, which fills in the title for each row: {/* good */} - ```ts import { expect, test } from "bun:test"; @@ -89,7 +94,6 @@ test("rounds " + mode, () => {}); `test.if()`, `test.skipIf()` and the other conditional forms are skipped too, because a pair with opposite conditions is one test: {/* good */} - ```ts import { expect, test } from "bun:test"; diff --git a/docs/lint/rules/bun-test/no-interpolation-in-snapshots.mdx b/docs/lint/rules/bun-test/no-interpolation-in-snapshots.mdx index d6fbbb5a430d..009448f46e89 100644 --- a/docs/lint/rules/bun-test/no-interpolation-in-snapshots.mdx +++ b/docs/lint/rules/bun-test/no-interpolation-in-snapshots.mdx @@ -1,9 +1,18 @@ --- title: "bun:test/no-interpolation-in-snapshots" description: "Disallow `${}` in inline snapshots" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- -An inline snapshot is text that the test runner writes into your file and rewrites when you update snapshots. With `${}` in it, the expected text is computed while the test runs, so the runner cannot write it back, and the snapshot no longer shows what the output looks like. +An inline snapshot is text that `bun test` writes into your file and rewrites when you update snapshots. With `${}` in it, the expected text is computed while the test runs. The snapshot no longer shows what the output looks like, and `bun test --update-snapshots` cannot rewrite it. The rule checks the template literals passed to `toMatchInlineSnapshot` and `toThrowErrorMatchingInlineSnapshot`. @@ -12,7 +21,6 @@ The rule checks the template literals passed to `toMatchInlineSnapshot` and `toT Reported: {/* bad */} - ```ts import { expect, test } from "bun:test"; @@ -28,7 +36,6 @@ test("creates a user", () => { ``` {/* bad */} - ```ts import { expect, test } from "bun:test"; @@ -40,20 +47,16 @@ test("rejects a missing file", () => { Not reported. For a value that changes from run to run, pass a property matcher as the first argument and keep the snapshot plain text: {/* good */} - ```ts import { expect, test } from "bun:test"; test("creates a user", () => { const user = createUser("Ada"); - expect(user).toMatchInlineSnapshot( - { id: expect.any(Number) }, - ` + expect(user).toMatchInlineSnapshot({ id: expect.any(Number) }, ` { "id": Any, "name": "Ada", } - `, - ); + `); }); ``` diff --git a/docs/lint/rules/bun-test/no-large-snapshots.mdx b/docs/lint/rules/bun-test/no-large-snapshots.mdx index 4589e9d0755e..8dbcd5093358 100644 --- a/docs/lint/rules/bun-test/no-large-snapshots.mdx +++ b/docs/lint/rules/bun-test/no-large-snapshots.mdx @@ -1,6 +1,15 @@ --- title: "bun:test/no-large-snapshots" description: "Disallow snapshots that are longer than a set number of lines" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- Nobody reads a snapshot of several hundred lines in a review. When it changes, it gets updated without a look, and then the test passes whatever the code does. A short snapshot, or an assertion on the few values that matter, still tells you something when it fails. @@ -14,7 +23,6 @@ These examples set `inlineMaxSize` to `3` to stay short. Reported: {/* bad {"options": [{"inlineMaxSize": 3}]} */} - ```ts import { expect, test } from "bun:test"; @@ -32,7 +40,6 @@ test("loads the user", () => { Not reported: {/* good {"options": [{"inlineMaxSize": 3}]} */} - ```ts import { expect, test } from "bun:test"; @@ -45,18 +52,19 @@ test("loads the user", () => { ## Options -| Option | Default | What it does | -| --------------- | ---------------------- | ------------------------------------------------- | -| `inlineMaxSize` | the value of `maxSize` | The most lines an inline snapshot can have. | -| `maxSize` | `50` | The limit to use when `inlineMaxSize` is not set. | +| Option | Default | What it does | +| --- | --- | --- | +| `inlineMaxSize` | the value of `maxSize` | The most lines an inline snapshot can have. | +| `maxSize` | `50` | The limit to use when `inlineMaxSize` is not set. | ```toml bunfig.toml [lint.rules] "bun:test/no-large-snapshots" = ["error", { inlineMaxSize = 20 }] ``` -{/* bad {"options": [{"maxSize": 3}]} */} +Without `inlineMaxSize`, `maxSize` applies: +{/* bad {"options": [{"maxSize": 3}]} */} ```ts import { expect, test } from "bun:test"; diff --git a/docs/lint/rules/bun-test/no-restricted-jest-methods.mdx b/docs/lint/rules/bun-test/no-restricted-jest-methods.mdx index a35ecdd9bbb9..382000af8ed4 100644 --- a/docs/lint/rules/bun-test/no-restricted-jest-methods.mdx +++ b/docs/lint/rules/bun-test/no-restricted-jest-methods.mdx @@ -1,6 +1,15 @@ --- title: "bun:test/no-restricted-jest-methods" description: "Disallow the `jest` and `vi` methods that you list" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- Some teams agree not to use parts of the `jest` object: no module mocks, no fake timers, no spies. This rule reports the methods you list, with your own message if you give one, so the agreement does not depend on review. @@ -14,7 +23,6 @@ These examples restrict `advanceTimersByTime` and `spyOn`. Reported: {/* bad {"count": 2, "options": [{"advanceTimersByTime": null, "spyOn": "Pass the dependency in instead of spying on it"}]} */} - ```ts import { expect, jest, test } from "bun:test"; @@ -29,7 +37,6 @@ test("retries after a second", () => { Not reported: {/* good {"options": [{"advanceTimersByTime": null, "spyOn": "Pass the dependency in instead of spying on it"}]} */} - ```ts import { expect, jest, test } from "bun:test"; @@ -43,7 +50,6 @@ test("retries once", () => { `vi` is checked the same way as `jest`: {/* bad {"options": [{"spyOn": "Pass the dependency in instead of spying on it"}]} */} - ```ts import { vi } from "bun:test"; @@ -53,7 +59,6 @@ const send = vi.spyOn(mailer, "send"); Only methods of `jest` and `vi` are checked. The `spyOn` and `mock` functions that `"bun:test"` exports are not: {/* good {"options": [{"spyOn": "Pass the dependency in instead of spying on it"}]} */} - ```ts import { spyOn } from "bun:test"; @@ -69,4 +74,4 @@ One object. Each key is the name of a method. The value is the message to report "bun:test/no-restricted-jest-methods" = ["error", { spyOn = "Pass the dependency in instead of spying on it" }] ``` -Where the config format has `null`, a `null` value gives the default message, which is ``Use of `spyOn` is not allowed``. +In a JSON or JavaScript config, `null` in place of the message gives the default message: ``Use of `spyOn` is not allowed``. diff --git a/docs/lint/rules/bun-test/no-restricted-matchers.mdx b/docs/lint/rules/bun-test/no-restricted-matchers.mdx index e986f8baa78c..139748734718 100644 --- a/docs/lint/rules/bun-test/no-restricted-matchers.mdx +++ b/docs/lint/rules/bun-test/no-restricted-matchers.mdx @@ -1,20 +1,31 @@ --- title: "bun:test/no-restricted-matchers" description: "Disallow the matchers and modifiers you list" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- -Some matchers are too loose for your tests. `toBeTruthy()` passes for `1`, `"no"` and `[]`, so it does not prove that a value is `true`. This rule reports the matchers and modifiers you name in its options. +Some matchers are too loose for your tests. `toBeTruthy()` passes for `1`, `"no"` and `[]`, so it does not prove that a value is `true`. This rule reports the matchers and modifiers you list. Without options it reports nothing. ## Examples -Both examples use `{ "toBeTruthy": "Use toBe(true) instead" }`. +```toml bunfig.toml +[lint.rules] +"bun:test/no-restricted-matchers" = ["error", { toBeTruthy = "Use toBe(true) instead" }] +``` Reported: {/* bad {"options": [{"toBeTruthy": "Use toBe(true) instead"}]} */} - ```ts import { expect, test } from "bun:test"; @@ -27,7 +38,6 @@ test("a new user is active", () => { Not reported: {/* good {"options": [{"toBeTruthy": "Use toBe(true) instead"}]} */} - ```ts import { expect, test } from "bun:test"; @@ -39,27 +49,17 @@ test("a new user is active", () => { ## Options -One object. A key is everything you write after `expect(..)`, joined with dots. A value is a hint that is shown with the report, or `null` for no hint. +One object. A key is what you write after `expect(..)`, joined with dots. A value is a hint shown with the report, or `""` for no hint. In `bunfig.toml`, put quotes around a key with a dot: `"not.toBe" = ""`. -```json -{ - "toBeTruthy": "Use toBe(true) instead", - "not.toHaveBeenCalledWith": null, - "resolves": "Await the promise, then check the value" -} -``` +| Key | What it reports | +| --- | --- | +| A matcher, such as `"toBe"` | `expect(a).toBe(b)`, but not `expect(a).not.toBe(b)`. | +| A chain, such as `"not.toBe"` | `expect(a).not.toBe(b)`, but not `expect(a).toBe(b)`. | +| `"not"`, `"resolves"`, `"rejects"`, or a chain that ends in `.not`, such as `"resolves.not"` | Every chain that starts with it. | -| Key | What it reports | -| ----------------------------------------------------- | ----------------------------------------------------- | -| A matcher, such as `"toBe"` | `expect(a).toBe(b)`, but not `expect(a).not.toBe(b)`. | -| A chain, such as `"not.toBe"` | `expect(a).not.toBe(b)`, but not `expect(a).toBe(b)`. | -| `"not"`, `"resolves"` or `"rejects"` | Every chain that starts with that modifier. | -| A chain that ends in `.not`, such as `"resolves.not"` | Every chain that starts with it. | - -A matcher is only matched as the whole chain, so `"toBe"` allows this: - -{/* good {"options": [{"toBe": null}]} */} +A matcher has to be the whole chain, so `{ toBe = "" }` allows this: +{/* good {"options": [{"toBe": ""}]} */} ```ts import { expect, test } from "bun:test"; @@ -68,10 +68,9 @@ test("ids differ", () => { }); ``` -A modifier matches whatever comes after it: - -{/* bad {"options": [{"resolves": null}], "count": 2} */} +A modifier matches whatever comes after it. With `{ resolves = "" }`, both lines are reported: +{/* bad {"options": [{"resolves": ""}], "count": 2} */} ```ts import { expect, test } from "bun:test"; diff --git a/docs/lint/rules/bun-test/no-standalone-expect.mdx b/docs/lint/rules/bun-test/no-standalone-expect.mdx index 4f25426596f0..3dc220c39121 100644 --- a/docs/lint/rules/bun-test/no-standalone-expect.mdx +++ b/docs/lint/rules/bun-test/no-standalone-expect.mdx @@ -1,18 +1,26 @@ --- title: "bun:test/no-standalone-expect" description: "Disallow `expect` outside of a `test` or `it` block" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "problem" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- -An `expect` written directly in a `describe` block or at the top of the file is not part of any test. It runs while the file is loaded, before the first test starts, and a failure there does not show up as a failed test with a name. Most of the time a `test(..)` around it was forgotten. An `expect` in a hook such as `beforeEach` is reported too. +An `expect` directly in a `describe` block or at the top of the file is not part of any test. It runs while the file loads, before the first test starts, so a failure does not show up as a failed test. Usually the `test(..)` around it was forgotten. An `expect` in a hook such as `beforeEach` is reported too. -An `expect` in a function declaration, or in a function stored in a variable, is allowed, because a test can call that helper. So are `expect.extend()` and asymmetric matchers such as `expect.any(String)`. +An `expect` in a function declaration, or in a function assigned to a variable, is allowed, because a test can call that helper. So are `expect.extend()` and asymmetric matchers such as `expect.any(String)`. ## Examples Reported: {/* bad {"count": 2} */} - ```ts import { beforeEach, describe, expect } from "bun:test"; @@ -29,7 +37,6 @@ describe("cart", () => { Not reported: {/* good */} - ```ts import { describe, expect, test } from "bun:test"; @@ -48,14 +55,13 @@ describe("cart", () => { ## Options -| Option | Default | What it does | -| ------------------------------ | ------- | ---------------------------------------------------------------------------------------------------------- | -| `additionalTestBlockFunctions` | `[]` | Names of other functions to treat like `test`. An `expect` in a callback passed to one of them is allowed. | +| Option | Default | What it does | +| --- | --- | --- | +| `additionalTestBlockFunctions` | `[]` | Names of other functions to treat like `test`. An `expect` in a callback passed to one of them is allowed. | Use it for your own wrappers around `test`, or to allow `expect` in a hook. A name can have dots. {/* good {"options": [{"additionalTestBlockFunctions": ["beforeEach", "admin.test"]}]} */} - ```ts import { beforeEach, expect } from "bun:test"; diff --git a/docs/lint/rules/bun-test/no-test-prefixes.mdx b/docs/lint/rules/bun-test/no-test-prefixes.mdx index a32effb2dc8a..fc3531ba7d8d 100644 --- a/docs/lint/rules/bun-test/no-test-prefixes.mdx +++ b/docs/lint/rules/bun-test/no-test-prefixes.mdx @@ -1,18 +1,26 @@ --- title: "bun:test/no-test-prefixes" description: "Require `.skip` instead of the `x` prefix" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "yes" +typeAware: false +deprecated: false --- `xit`, `xtest` and `xdescribe` are short names for `it.skip`, `test.skip` and `describe.skip`. One extra letter is easy to miss in a review, and a search for `.skip` does not find it. -`--fix` rewrites `xtest` to `test.skip` if the file already imports `test`. The same goes for `xit` with `it` and `xdescribe` with `describe`. +`--fix` rewrites the short name only if the file already imports the long one: `xtest` becomes `test.skip` if `test` is imported. ## Examples Reported: {/* bad {"count": 2} */} - ```ts import { expect, xdescribe, xtest } from "bun:test"; @@ -26,7 +34,6 @@ xdescribe("division", () => {}); Not reported: {/* good */} - ```ts import { describe, expect, test } from "bun:test"; @@ -37,10 +44,9 @@ test.skip("subtracts", () => { describe.skip("division", () => {}); ``` -What follows the name is kept. This is reported, and the long form is `it.skip.each`: +`xit.each` is reported too. Write `it.skip.each`: {/* bad */} - ```ts import { expect, xit } from "bun:test"; diff --git a/docs/lint/rules/bun-test/no-test-return-statement.mdx b/docs/lint/rules/bun-test/no-test-return-statement.mdx index ef02d049f7af..b2ba35761b4d 100644 --- a/docs/lint/rules/bun-test/no-test-return-statement.mdx +++ b/docs/lint/rules/bun-test/no-test-return-statement.mdx @@ -1,9 +1,18 @@ --- title: "bun:test/no-test-return-statement" description: "Disallow `return expect(..)` in a test" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- -`bun test` ignores what a test returns, unless it is a promise to wait for. A matcher such as `toBe()` returns nothing, so the `return` in `return expect(total).toBe(3)` does nothing. It only makes the reader wonder what the value is for. +The only thing worth returning from a test is a promise for `bun test` to wait for. A matcher such as `toBe()` returns nothing, so the `return` in `return expect(total).toBe(3)` has no effect. It only makes the test look like it waits for something. The rule reports `return expect(..).matcher(..)` in the callback of `test` or `it`, and in a function declaration that is passed to one of them. @@ -12,7 +21,6 @@ The rule reports `return expect(..).matcher(..)` in the callback of `test` or `i Reported: {/* bad */} - ```ts import { expect, test } from "bun:test"; @@ -23,7 +31,6 @@ test("adds the prices", () => { ``` {/* bad */} - ```ts import { expect, test } from "bun:test"; @@ -37,7 +44,6 @@ function addsThePrices() { Not reported: {/* good */} - ```ts import { expect, test } from "bun:test"; @@ -50,7 +56,6 @@ test("adds the prices", () => { Other `return` statements are not reported. That includes a returned promise, which `bun test` waits for: {/* good */} - ```ts import { expect, test } from "bun:test"; diff --git a/docs/lint/rules/bun-test/no-unneeded-async-expect-function.mdx b/docs/lint/rules/bun-test/no-unneeded-async-expect-function.mdx index 3cfc8a8f8743..d71fb141810c 100644 --- a/docs/lint/rules/bun-test/no-unneeded-async-expect-function.mdx +++ b/docs/lint/rules/bun-test/no-unneeded-async-expect-function.mdx @@ -1,9 +1,18 @@ --- title: "bun:test/no-unneeded-async-expect-function" description: "Disallow an async function around a single awaited call in `expect`" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "yes" +typeAware: false +deprecated: false --- -`expect` takes a promise. An async function that only awaits one call gives `expect` the same promise as the call itself, with more to read. +`.rejects` and `.resolves` work on a promise, so you can pass the call itself to `expect`. An async function that only awaits that call rejects with the same error, and is more to read. `--fix` replaces the function with the call. @@ -12,7 +21,6 @@ description: "Disallow an async function around a single awaited call in `expect Reported: {/* bad {"count": 2} */} - ```ts import { expect, test } from "bun:test"; @@ -28,7 +36,6 @@ test("rejects an empty name", async () => { Not reported: {/* good */} - ```ts import { expect, test } from "bun:test"; @@ -40,7 +47,6 @@ test("rejects an empty name", async () => { A function that does more than await one call is needed, and is not reported: {/* good */} - ```ts import { expect, test } from "bun:test"; diff --git a/docs/lint/rules/bun-test/padding-around-after-all-blocks.mdx b/docs/lint/rules/bun-test/padding-around-after-all-blocks.mdx index a7b9a7a5e14c..337a226bcccb 100644 --- a/docs/lint/rules/bun-test/padding-around-after-all-blocks.mdx +++ b/docs/lint/rules/bun-test/padding-around-after-all-blocks.mdx @@ -1,11 +1,20 @@ --- title: "bun:test/padding-around-after-all-blocks" description: "Require a blank line before each `afterAll` call" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "yes" +typeAware: false +deprecated: false --- An `afterAll` hook written right under another statement is easy to miss when you look for a file's cleanup code. A blank line above it sets it apart. -Only the line above the call is checked. Comments directly above the call belong to it, so the blank line goes above them. An `afterAll` that is the first statement of a file or of a `describe` callback needs no blank line. +Only the line above the call is checked, not the line below. Comments directly above the call count as part of it, so the blank line goes above them. An `afterAll` that comes first in a file or in a `describe` callback needs no blank line. `--fix` inserts the blank line. @@ -14,7 +23,6 @@ Only the line above the call is checked. Comments directly above the call belong Reported: {/* bad */} - ```ts import { afterAll, describe } from "bun:test"; @@ -27,7 +35,6 @@ describe("database", () => { Two `afterAll` calls in a row need a blank line between them too: {/* bad {"count": 2} */} - ```ts import { afterAll } from "bun:test"; @@ -39,7 +46,6 @@ afterAll(() => removeTempFiles()); Not reported: {/* good */} - ```ts import { afterAll, describe } from "bun:test"; diff --git a/docs/lint/rules/bun-test/padding-around-test-blocks.mdx b/docs/lint/rules/bun-test/padding-around-test-blocks.mdx index df42cd43e1de..549919a18d74 100644 --- a/docs/lint/rules/bun-test/padding-around-test-blocks.mdx +++ b/docs/lint/rules/bun-test/padding-around-test-blocks.mdx @@ -1,11 +1,20 @@ --- title: "bun:test/padding-around-test-blocks" description: "Require a blank line before each `test` and `it` call" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "yes" +typeAware: false +deprecated: false --- Tests written right under each other, or right under setup code, are hard to tell apart when you scan a file. A blank line above each `test` or `it` call separates them. -Only the line above the call is checked. Comments directly above the call belong to it, so the blank line goes above them. A test that is the first statement of a file or of a `describe` callback needs no blank line. +Only the line above the call is checked, not the line below. Comments directly above the call count as part of it, so the blank line goes above them. A test that comes first in a file or in a `describe` callback needs no blank line. `--fix` inserts the blank line. @@ -14,7 +23,6 @@ Only the line above the call is checked. Comments directly above the call belong Reported: {/* bad {"count": 2} */} - ```ts import { describe, expect, test } from "bun:test"; @@ -32,7 +40,6 @@ describe("cart", () => { Not reported: {/* good */} - ```ts import { describe, expect, test } from "bun:test"; diff --git a/docs/lint/rules/bun-test/prefer-called-once.mdx b/docs/lint/rules/bun-test/prefer-called-once.mdx index 12806a104f39..c00203110213 100644 --- a/docs/lint/rules/bun-test/prefer-called-once.mdx +++ b/docs/lint/rules/bun-test/prefer-called-once.mdx @@ -1,18 +1,26 @@ --- title: "bun:test/prefer-called-once" description: "Require `toHaveBeenCalledOnce()` instead of `toHaveBeenCalledTimes(1)`" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "yes" +typeAware: false +deprecated: false --- -`toHaveBeenCalledTimes(1)` and `toHaveBeenCalledOnce()` assert the same thing. This rule picks the shorter one, so a project writes it one way. The alias `toBeCalledTimes(1)` is reported too. +`toHaveBeenCalledTimes(1)` and `toHaveBeenCalledOnce()` assert the same thing. This rule picks the shorter one, so the whole project writes it the same way. The alias `toBeCalledTimes(1)` is reported too. -`--fix` rewrites both to `toHaveBeenCalledOnce()`. +`--fix` rewrites both to `toHaveBeenCalledOnce()`, because `bun:test` has no `toBeCalledOnce()`. ## Examples Reported: {/* bad */} - ```ts import { expect, mock, test } from "bun:test"; @@ -26,7 +34,6 @@ test("notifies the listener", () => { Not reported: {/* good */} - ```ts import { expect, mock, test } from "bun:test"; diff --git a/docs/lint/rules/bun-test/prefer-called-times.mdx b/docs/lint/rules/bun-test/prefer-called-times.mdx index 684ee7446974..199c9c042534 100644 --- a/docs/lint/rules/bun-test/prefer-called-times.mdx +++ b/docs/lint/rules/bun-test/prefer-called-times.mdx @@ -1,9 +1,18 @@ --- title: "bun:test/prefer-called-times" description: "Require `toHaveBeenCalledTimes(1)` instead of `toHaveBeenCalledOnce()`" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "yes" +typeAware: false +deprecated: false --- -`toHaveBeenCalledOnce()` and `toHaveBeenCalledTimes(1)` assert the same thing. With `toHaveBeenCalledTimes()` every call count is written the same way, and a new expected count is a change of one number. +`toHaveBeenCalledOnce()` and `toHaveBeenCalledTimes(1)` assert the same thing. With `toHaveBeenCalledTimes()`, every call count is written the same way, and changing the expected count means changing one number. `--fix` rewrites the matcher. @@ -12,7 +21,6 @@ description: "Require `toHaveBeenCalledTimes(1)` instead of `toHaveBeenCalledOnc Reported: {/* bad */} - ```ts import { expect, mock, test } from "bun:test"; @@ -26,7 +34,6 @@ test("does not retry after a success", () => { Not reported: {/* good */} - ```ts import { expect, mock, test } from "bun:test"; diff --git a/docs/lint/rules/bun-test/prefer-called-with.mdx b/docs/lint/rules/bun-test/prefer-called-with.mdx index f0920c0d15d0..a01d39c812d9 100644 --- a/docs/lint/rules/bun-test/prefer-called-with.mdx +++ b/docs/lint/rules/bun-test/prefer-called-with.mdx @@ -1,20 +1,28 @@ --- title: "bun:test/prefer-called-with" description: "Require `toHaveBeenCalledWith()` instead of `toHaveBeenCalled()`" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "suggestion" +typeAware: false +deprecated: false --- `toHaveBeenCalled()` passes whatever the mock was called with, so the test keeps passing when the code starts to pass the wrong arguments. `toHaveBeenCalledWith(...)` checks the arguments too. The alias `toBeCalled()` is reported the same way. `.not.toHaveBeenCalled()` is allowed: a mock that never ran has no arguments to check. -The rename is offered as a suggestion, which `--fix-suggestions` applies. It leaves the parentheses empty. Fill in the arguments you expect: `toHaveBeenCalledWith()` only passes for a call without arguments. +`--fix-suggestions` renames the matcher and leaves the parentheses empty. Fill in the arguments you expect: `toHaveBeenCalledWith()` only passes for a call without arguments. That is why `--fix` does not rename it. ## Examples Reported: {/* bad */} - ```ts import { expect, mock, test } from "bun:test"; @@ -28,7 +36,6 @@ test("sends a welcome email", () => { Not reported: {/* good */} - ```ts import { expect, mock, test } from "bun:test"; diff --git a/docs/lint/rules/bun-test/prefer-comparison-matcher.mdx b/docs/lint/rules/bun-test/prefer-comparison-matcher.mdx index e797be91f50d..e55e05e66294 100644 --- a/docs/lint/rules/bun-test/prefer-comparison-matcher.mdx +++ b/docs/lint/rules/bun-test/prefer-comparison-matcher.mdx @@ -1,20 +1,28 @@ --- title: "bun:test/prefer-comparison-matcher" description: "Require matchers such as `toBeGreaterThan()` instead of comparing inside `expect()`" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "suggestion" +typeAware: false +deprecated: false --- `expect(price < 100).toBe(true)` compares first and hands `expect` only the result. When it fails, the message says that `false` is not `true`, and you do not see the price. `expect(price).toBeLessThan(100)` prints both numbers. The rule reports `>`, `>=`, `<` and `<=` inside `expect()` when the result is checked against `true` or `false` with `toBe()`, `toEqual()` or `toStrictEqual()`. -The rewrite is offered as a suggestion, which `--fix-suggestions` applies. +`--fix-suggestions` rewrites the assertion. `--fix` does not, because the comparison matchers throw on anything that is not a number or a bigint. ## Examples Reported: {/* bad {"count": 2} */} - ```ts import { expect, test } from "bun:test"; @@ -28,7 +36,6 @@ test("applies the discount", () => { Not reported: {/* good */} - ```ts import { expect, test } from "bun:test"; @@ -42,7 +49,6 @@ test("applies the discount", () => { The comparison matchers only take numbers and bigints, so a comparison with a string literal or a template literal is left alone: {/* good */} - ```ts import { expect, test } from "bun:test"; @@ -53,4 +59,13 @@ test("sorts after the first entry", () => { ## When not to use it -The rule cannot see types. It also reports comparisons of strings or dates that are held in variables, and the comparison matchers reject those values. +The rule does not look at types, so it also reports comparisons of dates, or of strings held in variables. The comparison matchers throw on those values. This test is correct as written: + +{/* bad */} +```ts +import { expect, test } from "bun:test"; + +test("renewing extends the subscription", () => { + expect(renewed.expiresAt > current.expiresAt).toBe(true); +}); +``` diff --git a/docs/lint/rules/bun-test/prefer-describe-function-title.mdx b/docs/lint/rules/bun-test/prefer-describe-function-title.mdx index 12405bc6117c..ee3fce1da191 100644 --- a/docs/lint/rules/bun-test/prefer-describe-function-title.mdx +++ b/docs/lint/rules/bun-test/prefer-describe-function-title.mdx @@ -1,20 +1,28 @@ --- title: "bun:test/prefer-describe-function-title" description: "Require `describe(fn, ...)` instead of a string with the name of an imported function" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "suggestion" +typeAware: false +deprecated: false --- -`describe` takes a function or a class as its title and uses its name. A string that repeats the name of something you import stays behind when the function is renamed, and the test output then names a function that no longer exists. +`describe` accepts a function or a class as its title and prints its name. A string that repeats the name is not updated when you rename the function, so the test output keeps showing a name that no longer exists. -The rule reports a `describe` title that is a string equal to an imported name, and also `slugify.name`. +The rule reports a `describe` title that is a string equal to a name the file imports. A title like `slugify.name` is reported too. -`--fix-suggestions` replaces the title with the name, unless the import is type-only. +`--fix-suggestions` replaces the title with the name, unless the import is type-only. `--fix` does not, because the import may not be a function or a class. ## Examples Reported: {/* bad */} - ```ts import { describe, expect, test } from "bun:test"; import { slugify } from "./slugify"; @@ -27,7 +35,6 @@ describe("slugify", () => { ``` {/* bad */} - ```ts import { describe } from "bun:test"; import { slugify } from "./slugify"; @@ -38,7 +45,6 @@ describe(slugify.name, () => {}); Not reported: {/* good */} - ```ts import { describe, expect, test } from "bun:test"; import { slugify } from "./slugify"; diff --git a/docs/lint/rules/bun-test/prefer-each.mdx b/docs/lint/rules/bun-test/prefer-each.mdx index 589007996792..ddb9a48a0682 100644 --- a/docs/lint/rules/bun-test/prefer-each.mdx +++ b/docs/lint/rules/bun-test/prefer-each.mdx @@ -1,18 +1,26 @@ --- title: "bun:test/prefer-each" description: "Require `test.each` and `describe.each` instead of a loop around tests" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- -A `for` loop that declares tests works, but the table of cases is hidden in the loop and every title has to be built by hand. `test.each` and `describe.each` take the table as an argument and fill in the title for each row. +`test.each` and `describe.each` take the list of cases as an argument and fill in the title for each one. A `for` loop around `test` does the same with more code, and you build every title by hand. -The rule reports `for`, `for...of` and `for...in` loops that contain a `test`, a `describe` or a hook such as `beforeEach`. A loop inside a test is fine, and `cases.forEach(...)` around a test is not reported. +The rule reports `for`, `for...of` and `for...in` loops that contain a `test`, a `describe` or a hook such as `beforeEach`. A loop inside a test is fine. `while` loops and `cases.forEach(...)` around a test are not reported. ## Examples Reported: {/* bad */} - ```ts import { expect, test } from "bun:test"; @@ -31,7 +39,6 @@ for (const [input, expected] of cases) { Not reported: {/* good */} - ```ts import { expect, test } from "bun:test"; diff --git a/docs/lint/rules/bun-test/prefer-ending-with-an-expect.mdx b/docs/lint/rules/bun-test/prefer-ending-with-an-expect.mdx index c9cd5a254927..2c99217f0df5 100644 --- a/docs/lint/rules/bun-test/prefer-ending-with-an-expect.mdx +++ b/docs/lint/rules/bun-test/prefer-ending-with-an-expect.mdx @@ -1,18 +1,26 @@ --- title: "bun:test/prefer-ending-with-an-expect" description: "Require the last statement of a test to be an assertion" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- -Nothing checks what a test does after its last assertion. If the test ends with a click, a request or a save, that step can break and the test still passes. +Whatever a test does after its last assertion is not checked. If the test ends with a click, a request or a save, that step can do the wrong thing and the test still passes. -The last statement of the test has to be a call to `expect`, with or without `await`. A test with an empty body is reported, and so is one that ends with an `if`, a loop or a `return`, even if there is an `expect` inside. +The last statement of the test has to be a call to `expect`, with or without `await`. A test with an empty body is reported. So is one that ends with an `if`, a loop or a `return`, even if there is an `expect` inside. ## Examples Reported: {/* bad */} - ```ts import { expect, test } from "bun:test"; @@ -24,7 +32,6 @@ test("renames the user", async () => { ``` {/* bad */} - ```ts import { expect, test } from "bun:test"; @@ -36,7 +43,6 @@ test("loads the user", () => { Not reported: {/* good */} - ```ts import { expect, test } from "bun:test"; @@ -53,15 +59,14 @@ test("loads the user", async () => { ## Options -| Option | Default | What it does | -| ------------------------------ | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `assertFunctionNames` | see below | The functions that count as an assertion, besides `expect` itself. | -| `additionalTestBlockFunctions` | `[]` | Other functions to check like `test`. Only names that `bun:test` defines have an effect, such as `afterEach`. Your own wrappers around `test` are not checked. | +| Option | Default | What it does | +| --- | --- | --- | +| `assertFunctionNames` | any name that contains `expect` | The calls that count as an assertion, besides `expect` itself. | +| `additionalTestBlockFunctions` | `[]` | Other functions to check like `test`. It only works for names that `bun:test` defines, such as `afterEach`. Your own wrappers around `test` are not checked. | -Without `assertFunctionNames`, any call with `expect` somewhere in its name counts: +By default, a helper or a method with `expect` in its name counts: {/* good */} - ```ts import { test } from "bun:test"; @@ -74,10 +79,9 @@ test("responds with 200", async () => { }); ``` -With `assertFunctionNames`, only `expect` and the names in the list count. A name can be a dotted path. `*` stands for any letters and digits in one part of the path, and `**` for any number of parts. +With `assertFunctionNames`, only `expect` and the names in the list count. A name can be a dotted path. `*` matches any letters and digits in one part of the path, and `**` matches any number of parts. {/* good {"options": [{"assertFunctionNames": ["assert*", "request.**.expect"]}]} */} - ```ts import { test } from "bun:test"; @@ -91,7 +95,6 @@ test("responds with 200", async () => { ``` {/* bad {"options": [{"assertFunctionNames": ["assert*"]}]} */} - ```ts import { test } from "bun:test"; @@ -100,10 +103,9 @@ test("has a valid user", () => { }); ``` -With `additionalTestBlockFunctions`, a hook can be held to the same rule: +With `additionalTestBlockFunctions`, a hook is checked too: {/* bad {"options": [{"additionalTestBlockFunctions": ["afterEach"]}]} */} - ```ts import { afterEach, expect } from "bun:test"; diff --git a/docs/lint/rules/bun-test/prefer-equality-matcher.mdx b/docs/lint/rules/bun-test/prefer-equality-matcher.mdx index 52ca9111b68b..e0ce1025e3d4 100644 --- a/docs/lint/rules/bun-test/prefer-equality-matcher.mdx +++ b/docs/lint/rules/bun-test/prefer-equality-matcher.mdx @@ -1,11 +1,20 @@ --- title: "bun:test/prefer-equality-matcher" description: "Require `toBe` or `toEqual` instead of comparing with `===` inside `expect`" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "suggestion" +typeAware: false +deprecated: false --- -`expect(a === b).toBe(true)` compares the values before `expect` sees them. When the test fails, the output says that `false` is not `true`. It does not show the two values that differ. +`expect(a === b).toBe(true)` compares the values before `expect` sees them. When the test fails, the output only says that `false` is not `true`. It does not show the two values. -The rule reports `===` and `!==` inside `expect` when the result is matched against `true` or `false` with `toBe`, `toEqual` or `toStrictEqual`. `==` and `!=` are not reported. +The rule reports `===` and `!==` inside `expect` when the result is compared with `true` or `false` using `toBe`, `toEqual` or `toStrictEqual`. `==` and `!=` are not reported. `--fix-suggestions` rewrites the assertion with `toBe`. Your editor also offers `toEqual` and `toStrictEqual`. @@ -14,7 +23,6 @@ The rule reports `===` and `!==` inside `expect` when the result is matched agai Reported: {/* bad {"count": 2} */} - ```ts import { expect, test } from "bun:test"; @@ -27,7 +35,6 @@ test("the session belongs to the user", () => { Not reported: {/* good */} - ```ts import { expect, test } from "bun:test"; @@ -38,7 +45,6 @@ test("the session belongs to the user", () => { ``` {/* good */} - ```ts import { expect, test } from "bun:test"; diff --git a/docs/lint/rules/bun-test/prefer-expect-assertions.mdx b/docs/lint/rules/bun-test/prefer-expect-assertions.mdx index 771c4b173723..a1c09a51f15c 100644 --- a/docs/lint/rules/bun-test/prefer-expect-assertions.mdx +++ b/docs/lint/rules/bun-test/prefer-expect-assertions.mdx @@ -1,11 +1,20 @@ --- title: "bun:test/prefer-expect-assertions" description: "Require `expect.assertions()` or `expect.hasAssertions()` at the start of every test" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "suggestion" +typeAware: false +deprecated: false --- An `expect` in a `catch`, a callback or a loop may never run, and a test in which no `expect` runs passes. `expect.hasAssertions()` makes the test fail unless at least one assertion ran. `expect.assertions(2)` makes it fail unless exactly two ran. -The first statement of every test has to be one of the two. The rule also reports `expect.hasAssertions()` with an argument, and `expect.assertions()` with anything but one number. +The first statement of every test has to be one of the two. The rule also reports `expect.hasAssertions()` with an argument, and `expect.assertions()` with anything but one number literal. `--fix-suggestions` adds `expect.hasAssertions()`. @@ -14,7 +23,6 @@ The first statement of every test has to be one of the two. The rule also report Reported: {/* bad */} - ```ts import { expect, test } from "bun:test"; @@ -28,7 +36,6 @@ test("rejects a wrong password", async () => { ``` {/* bad */} - ```ts import { expect, test } from "bun:test"; @@ -41,7 +48,6 @@ test("adds", () => { Not reported: {/* good */} - ```ts import { expect, test } from "bun:test"; @@ -55,10 +61,9 @@ test("rejects a wrong password", async () => { }); ``` -A `beforeEach` or `afterEach` hook that calls `expect.hasAssertions()` covers the tests after it in the same `describe`, or in the file if it is at the top level: +A `beforeEach` or `afterEach` hook that calls `expect.hasAssertions()` covers the tests after it in the same `describe`, or in the rest of the file if it is at the top level: {/* good */} - ```ts import { beforeEach, expect, test } from "bun:test"; @@ -75,39 +80,25 @@ test("adds", () => { Without options, every test is checked. With one or more of them, only the tests that match at least one are checked. -| Option | Default | What it does | -| ----------------------------------- | ------- | -------------------------------------------------------- | -| `onlyFunctionsWithAsyncKeyword` | `false` | Check tests that are `async` functions. | +| Option | Default | What it does | +| --- | --- | --- | +| `onlyFunctionsWithAsyncKeyword` | `false` | Check tests that are `async` functions. | | `onlyFunctionsWithExpectInCallback` | `false` | Check tests that call `expect` inside a nested function. | -| `onlyFunctionsWithExpectInLoop` | `false` | Check tests that call `expect` inside a loop. | +| `onlyFunctionsWithExpectInLoop` | `false` | Check tests that call `expect` inside a loop. | {/* bad {"options": [{"onlyFunctionsWithExpectInCallback": true}]} */} - ```ts import { expect, test } from "bun:test"; test("emits the saved user", () => { - store.on("save", user => { + store.on("save", (user) => { expect(user.name).toBe("Ada"); }); store.save({ name: "Ada" }); }); ``` -{/* bad {"options": [{"onlyFunctionsWithExpectInLoop": true}]} */} - -```ts -import { expect, test } from "bun:test"; - -test("every price is positive", () => { - for (const price of loadPrices()) { - expect(price).toBeGreaterThan(0); - } -}); -``` - -{/* good {"options": [{"onlyFunctionsWithAsyncKeyword": true, "onlyFunctionsWithExpectInLoop": true}]} */} - +{/* good {"options": [{"onlyFunctionsWithExpectInCallback": true}]} */} ```ts import { expect, test } from "bun:test"; @@ -118,4 +109,4 @@ test("adds", () => { ## When not to use it -In a test that calls `expect` directly, one statement after another, the extra line proves nothing. Use the options to check only the tests where an assertion can be skipped. +In a test that only calls `expect` at its top level, the extra line adds nothing. Use the options to check only the tests where an assertion can be skipped. diff --git a/docs/lint/rules/bun-test/prefer-expect-resolves.mdx b/docs/lint/rules/bun-test/prefer-expect-resolves.mdx index a478d1c5c8cd..25b61a7cb441 100644 --- a/docs/lint/rules/bun-test/prefer-expect-resolves.mdx +++ b/docs/lint/rules/bun-test/prefer-expect-resolves.mdx @@ -1,18 +1,26 @@ --- title: "bun:test/prefer-expect-resolves" description: "Require `await expect(promise).resolves` instead of `expect(await promise)`" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "suggestion" +typeAware: false +deprecated: false --- -In `expect(await promise)`, a rejected promise throws before `expect` is called. The test fails with that error alone, and nothing says which assertion it belongs to. With `.resolves`, the promise goes to `expect`, and a rejection fails that assertion. +In `expect(await promise)`, a rejected promise throws before `expect` is called, so the test fails with the raw error. With `.resolves`, `expect` gets the promise, and a rejection is reported as a failed assertion. -`--fix-suggestions` rewrites the assertion. +`--fix-suggestions` rewrites the assertion. `--fix` does not, because `.resolves` fails on a value that is not a promise. ## Examples Reported: {/* bad */} - ```ts import { expect, test } from "bun:test"; @@ -24,7 +32,6 @@ test("loads the user", async () => { Not reported: {/* good */} - ```ts import { expect, test } from "bun:test"; diff --git a/docs/lint/rules/bun-test/prefer-expect-type-of.mdx b/docs/lint/rules/bun-test/prefer-expect-type-of.mdx index c341e8e3905d..1d6502429665 100644 --- a/docs/lint/rules/bun-test/prefer-expect-type-of.mdx +++ b/docs/lint/rules/bun-test/prefer-expect-type-of.mdx @@ -1,11 +1,20 @@ --- title: "bun:test/prefer-expect-type-of" description: "Require `toBeTypeOf` instead of `expect(typeof value).toBe(...)`" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "yes" +typeAware: false +deprecated: false --- -`expect(typeof value).toBe("string")` hands `expect` only the name of the type. When the test fails, the output compares `"number"` with `"string"` and does not show the value. `expect(value).toBeTypeOf("string")` shows it. +`expect(typeof value).toBe("string")` only gives `expect` the name of the type. When the test fails, the output compares `"number"` with `"string"` and does not show the value. `expect(value).toBeTypeOf("string")` shows it. -The rule reports `typeof` inside `expect` with `toBe` or `toEqual`, also after `.not`. +The rule reports `typeof` inside `expect` with `toBe` or `toEqual`, with or without `.not`. `--fix` rewrites the assertion. @@ -14,7 +23,6 @@ The rule reports `typeof` inside `expect` with `toBe` or `toEqual`, also after ` Reported: {/* bad {"count": 2} */} - ```ts import { expect, test } from "bun:test"; @@ -27,7 +35,6 @@ test("the user has a name", () => { Not reported: {/* good */} - ```ts import { expect, test } from "bun:test"; diff --git a/docs/lint/rules/bun-test/prefer-hooks-in-order.mdx b/docs/lint/rules/bun-test/prefer-hooks-in-order.mdx index ebd155d3b1d0..7425185cb77b 100644 --- a/docs/lint/rules/bun-test/prefer-hooks-in-order.mdx +++ b/docs/lint/rules/bun-test/prefer-hooks-in-order.mdx @@ -1,18 +1,26 @@ --- title: "bun:test/prefer-hooks-in-order" description: "Require hooks to be written in the order they run" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- -`bun test` runs hooks in a fixed order: `beforeAll`, `beforeEach`, `afterEach`, `afterAll`. If a file lists them in another order, you read the teardown before the setup it undoes, and the order on the page is not the order that runs. +`bun test` runs hooks in a fixed order: `beforeAll`, `beforeEach`, `afterEach`, `afterAll`. If a file lists them in another order, you read the teardown before the setup it undoes. -Each `describe` block is checked on its own. Only hooks that follow each other are compared: any other call between two hooks, such as a test, starts a new group. +Each `describe` block is checked on its own. Only hooks written one after another are compared. Any other call between two hooks, such as a test, starts a new group. ## Examples Reported: {/* bad */} - ```ts import { afterEach, beforeEach } from "bun:test"; @@ -28,7 +36,6 @@ beforeEach(() => { Not reported: {/* good */} - ```ts import { afterEach, beforeEach } from "bun:test"; @@ -41,10 +48,9 @@ afterEach(() => { }); ``` -The test between these two hooks starts a new group, so this rule says nothing. `bun:test/prefer-hooks-on-top` reports it. +The test between these two hooks starts a new group, so this is not reported. `bun:test/prefer-hooks-on-top` reports it instead. {/* good */} - ```ts import { afterEach, beforeEach, test } from "bun:test"; diff --git a/docs/lint/rules/bun-test/prefer-hooks-on-top.mdx b/docs/lint/rules/bun-test/prefer-hooks-on-top.mdx index 380b8593a816..254576a25b1e 100644 --- a/docs/lint/rules/bun-test/prefer-hooks-on-top.mdx +++ b/docs/lint/rules/bun-test/prefer-hooks-on-top.mdx @@ -1,9 +1,18 @@ --- title: "bun:test/prefer-hooks-on-top" description: "Require hooks to come before the tests in their block" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- -A hook applies to every test in its block, wherever it is written. A `beforeEach` below a test still runs before that test, and someone who reads the file from the top does not know that yet. +A hook applies to every test in its block, wherever it is written. A `beforeEach` below a test still runs before that test, but someone reading from the top has not seen it yet. Each `describe` block is checked on its own. @@ -12,7 +21,6 @@ Each `describe` block is checked on its own. Reported: {/* bad */} - ```ts import { beforeEach, expect, test } from "bun:test"; @@ -28,7 +36,6 @@ beforeEach(() => { Not reported: {/* good */} - ```ts import { beforeEach, expect, test } from "bun:test"; @@ -44,7 +51,6 @@ test("starts empty", () => { A hook at the top of a `describe` block is fine, even if there is a test before the block: {/* good */} - ```ts import { beforeEach, describe, expect, test } from "bun:test"; diff --git a/docs/lint/rules/bun-test/prefer-lowercase-title.mdx b/docs/lint/rules/bun-test/prefer-lowercase-title.mdx index 15c81b0f915b..5ddb7d0c586b 100644 --- a/docs/lint/rules/bun-test/prefer-lowercase-title.mdx +++ b/docs/lint/rules/bun-test/prefer-lowercase-title.mdx @@ -1,18 +1,26 @@ --- title: "bun:test/prefer-lowercase-title" description: "Require the titles of tests and `describe` blocks to start with a lowercase letter" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "yes" +typeAware: false +deprecated: false --- -`bun test` prints a title after the titles of the blocks around it, as in `cart > adds an item`. A capital in the middle of that line reads like the start of a new sentence, and output where some titles have one and others do not is harder to scan. +`bun test` prints a title after the titles of the blocks around it, as in `cart > adds an item`. A capital in the middle of that line reads like the start of a new sentence, and a mix of both styles is harder to scan. -`--fix` makes the first letter lowercase. It leaves the titles alone in a file that calls `toMatchSnapshot()` or `toThrowErrorMatchingSnapshot()`, because snapshots are stored under the title of their test. +`--fix` makes the first letter lowercase. It does not touch titles in a file that uses `toMatchSnapshot()` or `toThrowErrorMatchingSnapshot()`, because snapshots are stored under the title of their test. ## Examples Reported: {/* bad */} - ```ts import { expect, test } from "bun:test"; @@ -24,7 +32,6 @@ test("Adds an item", () => { Not reported: {/* good */} - ```ts import { expect, test } from "bun:test"; @@ -35,51 +42,25 @@ test("adds an item", () => { ## Options -| Option | Default | What it does | -| ----------------------------- | ------- | ---------------------------------------------------------------------- | -| `ignore` | `[]` | Functions to leave alone: `"describe"`, `"test"`, `"it"`. | -| `allowedPrefixes` | `[]` | Allow a title that starts with one of these strings. | -| `ignoreTopLevelDescribe` | `false` | Allow a capital in a `describe` that is not inside another block. | -| `lowercaseFirstCharacterOnly` | `true` | Set it to `false` to report an uppercase letter anywhere in the title. | +| Option | Default | What it does | +| --- | --- | --- | +| `ignore` | `[]` | Functions to skip: `"describe"`, `"test"`, `"it"`. `"test"` does not cover `it`. | +| `allowedPrefixes` | `[]` | Allow a title that starts with one of these strings. | +| `ignoreTopLevelDescribe` | `false` | Allow a capital in a `describe` that is not inside another block. | +| `lowercaseFirstCharacterOnly` | `true` | Set it to `false` to report an uppercase letter anywhere in the title. `--fix` then makes the whole title lowercase. | `allowedPrefixes` is for titles that start with a name you cannot change: {/* good {"options": [{"allowedPrefixes": ["GET", "POST"]}]} */} - ```ts import { test } from "bun:test"; test("GET /users returns a list", () => {}); ``` -With `ignoreTopLevelDescribe`, the outer `describe` can be named after a class or a component. Everything inside it is still checked: - -{/* good {"options": [{"ignoreTopLevelDescribe": true}]} */} - -```ts -import { describe, test } from "bun:test"; - -describe("Cart", () => { - test("adds an item", () => {}); -}); -``` +With `ignoreTopLevelDescribe`, the outer `describe` can be named after a class or a component. Only `"Checkout"` is reported here: {/* bad {"options": [{"ignoreTopLevelDescribe": true}], "count": 1} */} - -```ts -import { describe, test } from "bun:test"; - -describe("Cart", () => { - describe("Checkout", () => { - test("adds an item", () => {}); - }); -}); -``` - -With `{ "ignore": ["describe"] }`, no `describe` is checked: - -{/* good {"options": [{"ignore": ["describe"]}]} */} - ```ts import { describe, test } from "bun:test"; @@ -93,7 +74,6 @@ describe("Cart", () => { With `{ "lowercaseFirstCharacterOnly": false }`: {/* bad {"options": [{"lowercaseFirstCharacterOnly": false}]} */} - ```ts import { test } from "bun:test"; diff --git a/docs/lint/rules/bun-test/prefer-mock-promise-shorthand.mdx b/docs/lint/rules/bun-test/prefer-mock-promise-shorthand.mdx index f1ae471afb53..4f9c0d5efaa5 100644 --- a/docs/lint/rules/bun-test/prefer-mock-promise-shorthand.mdx +++ b/docs/lint/rules/bun-test/prefer-mock-promise-shorthand.mdx @@ -1,15 +1,18 @@ --- title: "bun:test/prefer-mock-promise-shorthand" description: "Require `mockResolvedValue()` and `mockRejectedValue()` for mocks that return a promise" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "yes" +typeAware: false +deprecated: false --- -A mock has methods for the two things an async function can do. `mockResolvedValue(user)` says the same as `mockReturnValue(Promise.resolve(user))` or `mockImplementation(() => Promise.resolve(user))`, without the wrapping. - -| Reported | Write | -| ------------------------------------------------------------------------------------- | ------------------------------------------------------ | -| `mockReturnValue(Promise.resolve(x))`, `mockImplementation(() => Promise.resolve(x))` | `mockResolvedValue(x)` | -| `mockReturnValue(Promise.reject(x))`, `mockImplementation(() => Promise.reject(x))` | `mockRejectedValue(x)` | -| `mockReturnValueOnce(..)`, `mockImplementationOnce(..)` | `mockResolvedValueOnce(x)`, `mockRejectedValueOnce(x)` | +`mockReturnValue(Promise.resolve(user))` and `mockImplementation(() => Promise.resolve(user))` do the same as `mockResolvedValue(user)`, with more to read. The same goes for `Promise.reject()` and `mockRejectedValue()`, and for the `Once` version of each method. `--fix` rewrites the call. @@ -18,7 +21,6 @@ A mock has methods for the two things an async function can do. `mockResolvedVal Reported: {/* bad {"count": 2} */} - ```ts import { mock } from "bun:test"; @@ -29,7 +31,6 @@ const saveUser = mock().mockImplementationOnce(() => Promise.reject(new Error("o Not reported: {/* good */} - ```ts import { mock } from "bun:test"; @@ -40,7 +41,6 @@ const saveUser = mock().mockRejectedValueOnce(new Error("offline")); An implementation that takes parameters is allowed, because its result can depend on them: {/* good */} - ```ts import { mock } from "bun:test"; diff --git a/docs/lint/rules/bun-test/prefer-mock-return-shorthand.mdx b/docs/lint/rules/bun-test/prefer-mock-return-shorthand.mdx index 4df855aa5c94..8efa5964b545 100644 --- a/docs/lint/rules/bun-test/prefer-mock-return-shorthand.mdx +++ b/docs/lint/rules/bun-test/prefer-mock-return-shorthand.mdx @@ -1,18 +1,26 @@ --- title: "bun:test/prefer-mock-return-shorthand" description: "Require `mockReturnValue()` for a mock that only returns a value" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "yes" +typeAware: false +deprecated: false --- -`mockImplementation(() => value)` wraps a value in a function that does nothing but return it. `mockReturnValue(value)` says the same with less to read. The same goes for `mockImplementationOnce()` and `mockReturnValueOnce()`. +`mockImplementation(() => value)` wraps a value in a function that only returns it. `mockReturnValue(value)` does the same with less to read. The same goes for `mockImplementationOnce()` and `mockReturnValueOnce()`. -`--fix` rewrites the call. If the returned value contains a function call or `new`, the mock is reported but not rewritten, because `mockReturnValue(createUser())` runs `createUser()` once instead of on every call. +`--fix` rewrites the call. ## Examples Reported: {/* bad {"count": 2} */} - ```ts import { mock } from "bun:test"; @@ -26,7 +34,6 @@ const getUser = mock().mockImplementationOnce(() => { Not reported: {/* good */} - ```ts import { mock } from "bun:test"; @@ -35,10 +42,9 @@ const getPort = mock().mockReturnValue(3000); const getUser = mock().mockReturnValueOnce({ name: "Ada" }); ``` -A function that takes parameters, is `async`, does more than return, or returns a variable that can change needs `mockImplementation()`, so it is not reported: +A function that takes parameters, is `async`, does more than return, or returns a `let` or `var` variable needs `mockImplementation()`, so it is not reported: {/* good */} - ```ts import { mock } from "bun:test"; @@ -53,6 +59,15 @@ const nextVisit = mock().mockImplementation(() => { }); ``` +A function that returns the result of a call or a `new` is reported, but `--fix` leaves it alone. `mockReturnValue(createUser())` calls `createUser()` once, not each time the mock is called. + +{/* bad */} +```ts +import { mock } from "bun:test"; + +const getUser = mock().mockImplementation(() => createUser()); +``` + ## When not to use it -`mockReturnValue({ name: "Ada" })` returns the same object on every call, and `mockImplementation(() => ({ name: "Ada" }))` returns a new one. If the code under test changes what the mock returns, keep the function and disable the rule on that line. +`mockReturnValue({ name: "Ada" })` returns the same object on every call. `mockImplementation(() => ({ name: "Ada" }))` returns a new one. If the code under test changes what the mock returns, keep the function and disable the rule on that line. diff --git a/docs/lint/rules/bun-test/prefer-snapshot-hint.mdx b/docs/lint/rules/bun-test/prefer-snapshot-hint.mdx index 67911e3e1989..aa871fafab02 100644 --- a/docs/lint/rules/bun-test/prefer-snapshot-hint.mdx +++ b/docs/lint/rules/bun-test/prefer-snapshot-hint.mdx @@ -1,9 +1,18 @@ --- title: "bun:test/prefer-snapshot-hint" description: "Require a hint on `toMatchSnapshot()` and `toThrowErrorMatchingSnapshot()`" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "problem" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- -Snapshots in a `.snap` file are named after the test and numbered: `renders the menu 1`, `renders the menu 2`. With several in one test, a reviewer cannot tell which snapshot belongs to which `expect`, and a new one in the middle renumbers the ones after it. A hint becomes part of the name: `renders the menu: closed 1`. +Snapshots in a `.snap` file are named after the test and numbered: `renders the menu 1`, `renders the menu 2`. With several in one test, you cannot tell which snapshot belongs to which `expect`, and adding one in the middle renumbers the rest. A hint becomes part of the name: `renders the menu: closed 1`. By default a hint is only required in a test that has more than one snapshot. Inline snapshots are not checked. @@ -12,7 +21,6 @@ By default a hint is only required in a test that has more than one snapshot. In Reported: {/* bad {"count": 2} */} - ```ts import { expect, test } from "bun:test"; @@ -25,7 +33,6 @@ test("renders the menu", () => { Not reported: {/* good */} - ```ts import { expect, test } from "bun:test"; @@ -39,10 +46,21 @@ test("renders the footer", () => { }); ``` -If `toMatchSnapshot()` gets property matchers, the hint is the second argument: +A single argument only counts as a hint if it is a string or a template literal. Property matchers or a variable are reported: -{/* good */} +{/* bad {"count": 2} */} +```ts +import { expect, test } from "bun:test"; +test("creates users", () => { + expect(createUser("Ada")).toMatchSnapshot({ id: expect.any(Number) }); + expect(createUser("Grace")).toMatchSnapshot(hint); +}); +``` + +With property matchers, the hint is the second argument: + +{/* good */} ```ts import { expect, test } from "bun:test"; @@ -59,13 +77,12 @@ test("creates users", () => { "bun:test/prefer-snapshot-hint" = ["error", "always"] ``` -| Option | What it does | -| ------------------- | ------------------------------------------------------ | +| Option | What it does | +| --- | --- | | `"multi"` (default) | Require a hint if the test has more than one snapshot. | -| `"always"` | Require a hint on every snapshot. | +| `"always"` | Require a hint on every snapshot. | {/* bad {"options": ["always"]} */} - ```ts import { expect, test } from "bun:test"; diff --git a/docs/lint/rules/bun-test/prefer-spy-on.mdx b/docs/lint/rules/bun-test/prefer-spy-on.mdx index dde2ab5869fa..1db1100d7db1 100644 --- a/docs/lint/rules/bun-test/prefer-spy-on.mdx +++ b/docs/lint/rules/bun-test/prefer-spy-on.mdx @@ -1,20 +1,26 @@ --- title: "bun:test/prefer-spy-on" description: "Require `spyOn()` instead of assigning `jest.fn()` to a property" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "suggestion" +typeAware: false +deprecated: false --- -`Date.now = jest.fn()` throws the real `Date.now` away. Unless you saved it and put it back by hand, every test that runs afterwards gets the mock. `spyOn(Date, "now")` remembers the original, so `mockRestore()` or `mock.restore()` brings it back. +`Date.now = jest.fn()` throws the real `Date.now` away. Unless you save it and put it back yourself, every test that runs afterwards gets the mock. `spyOn(Date, "now")` remembers the original, so `mockRestore()` or `mock.restore()` brings it back. -A spy calls the original function until you give it an implementation, so the suggestion turns `obj.save = jest.fn()` into `jest.spyOn(obj, 'save').mockImplementation()`. - -The rewrite is a suggestion in your editor. `--fix` does not apply it. +`--fix-suggestions` rewrites `obj.save = jest.fn()` to `jest.spyOn(obj, 'save').mockImplementation()`. The `.mockImplementation()` is there because a spy calls the original function until you give it an implementation. ## Examples Reported: {/* bad {"count": 2} */} - ```ts import { beforeEach, jest } from "bun:test"; @@ -27,7 +33,6 @@ beforeEach(() => { Not reported: {/* good */} - ```ts import { afterEach, beforeEach, mock, spyOn } from "bun:test"; @@ -44,7 +49,6 @@ afterEach(() => { A mock that is stored in a variable or written in an object literal replaces nothing, so it is not reported: {/* good */} - ```ts import { jest } from "bun:test"; @@ -52,10 +56,9 @@ const onSave = jest.fn(); const logger = { warn: jest.fn() }; ``` -The rule looks for `jest.fn()` and `vi.fn()`. It does not report `mock()`, which has the same problem: +The rule only looks for `jest.fn()` and `vi.fn()`. It does not report `mock()`, which has the same problem: {/* good */} - ```ts import { mock } from "bun:test"; diff --git a/docs/lint/rules/bun-test/prefer-strict-boolean-matchers.mdx b/docs/lint/rules/bun-test/prefer-strict-boolean-matchers.mdx index fc53187a8d60..14a962688ded 100644 --- a/docs/lint/rules/bun-test/prefer-strict-boolean-matchers.mdx +++ b/docs/lint/rules/bun-test/prefer-strict-boolean-matchers.mdx @@ -1,18 +1,26 @@ --- title: "bun:test/prefer-strict-boolean-matchers" description: "Require `toBe(true)` and `toBe(false)` instead of `toBeTruthy()` and `toBeFalsy()`" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "suggestion" +typeAware: false +deprecated: false --- `toBeTruthy()` passes for `1`, `"no"` and `{}`. `toBeFalsy()` passes for `0`, `""`, `null` and `undefined`. If a function should return a boolean and starts to return `undefined`, a test with `toBeFalsy()` still passes. `toBe(false)` fails. -The rewrite is a suggestion in your editor. `--fix` does not apply it, because a test that passed with a value that is not a boolean fails afterwards. +`--fix-suggestions` rewrites the matcher. `--fix` does not, because a test that passed with a value that is not a boolean fails afterwards. ## Examples Reported: {/* bad {"count": 2} */} - ```ts import { expect, test } from "bun:test"; @@ -25,7 +33,6 @@ test("checks the password", () => { Not reported: {/* good */} - ```ts import { expect, test } from "bun:test"; @@ -35,6 +42,19 @@ test("checks the password", () => { }); ``` +`not.toBeTruthy()` is reported too, and the rewrite is `not.toBe(true)`, which passes for everything except `true`. Write `toBe(false)` instead. + +{/* bad */} +```ts +import { expect, test } from "bun:test"; + +test("rejects an empty password", () => { + expect(isValid("")).not.toBeTruthy(); +}); +``` + ## When not to use it Some tests only care that there is a value: a match from `String.prototype.match()`, a non-empty string. There `toBeTruthy()` says what you mean. + +`bun:test/prefer-to-be-truthy` and `bun:test/prefer-to-be-falsy` ask for the opposite. Do not turn them on together with this rule. diff --git a/docs/lint/rules/bun-test/prefer-strict-equal.mdx b/docs/lint/rules/bun-test/prefer-strict-equal.mdx index 7a3393694290..5ed85d54a045 100644 --- a/docs/lint/rules/bun-test/prefer-strict-equal.mdx +++ b/docs/lint/rules/bun-test/prefer-strict-equal.mdx @@ -1,18 +1,26 @@ --- title: "bun:test/prefer-strict-equal" description: "Require `toStrictEqual()` instead of `toEqual()`" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "suggestion" +typeAware: false +deprecated: false --- `toEqual()` skips properties that are `undefined`, treats a hole in an array like `undefined`, and does not compare the classes of the two objects. So `expect({ name: "Ada", email: undefined }).toEqual({ name: "Ada" })` passes. `toStrictEqual()` checks all three, and that test fails. -The rewrite is a suggestion in your editor. `--fix` does not apply it, because a test that passes with `toEqual()` can fail with `toStrictEqual()`. +`--fix-suggestions` renames the matcher. `--fix` does not, because a test that passes with `toEqual()` can fail with `toStrictEqual()`. ## Examples Reported: {/* bad */} - ```ts import { expect, test } from "bun:test"; @@ -24,7 +32,6 @@ test("creates a user", () => { Not reported: {/* good */} - ```ts import { expect, test } from "bun:test"; diff --git a/docs/lint/rules/bun-test/prefer-to-be-falsy.mdx b/docs/lint/rules/bun-test/prefer-to-be-falsy.mdx index b8b097444bc4..ab2b9741c7da 100644 --- a/docs/lint/rules/bun-test/prefer-to-be-falsy.mdx +++ b/docs/lint/rules/bun-test/prefer-to-be-falsy.mdx @@ -1,11 +1,20 @@ --- title: "bun:test/prefer-to-be-falsy" description: "Require `toBeFalsy()` instead of `toBe(false)`" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "suggestion" +typeAware: false +deprecated: false --- -`toBe(false)` passes only for the value `false`. `toBeFalsy()` passes for everything that counts as false in an `if`: `false`, `0`, `""`, `null`, `undefined`, and `NaN`. With this rule, a test does not depend on which of those values the code returns. +`toBe(false)` passes only for `false` itself. If the code returns `undefined`, `null`, or `0` instead, the test fails, even though `if (!value)` treats them all the same. `toBeFalsy()` passes for any falsy value. -The rule reports `toBe(false)`, `toEqual(false)`, and `toStrictEqual(false)`, with or without `.not`. +`toEqual(false)` and `toStrictEqual(false)` are reported too, with or without `.not`. `--fix-suggestions` replaces the matcher with `toBeFalsy()`. `--fix` does not, because the new matcher accepts more values. @@ -14,7 +23,6 @@ The rule reports `toBe(false)`, `toEqual(false)`, and `toStrictEqual(false)`, wi Reported: {/* bad {"count": 2} */} - ```ts import { expect, test } from "bun:test"; @@ -28,7 +36,6 @@ test("a new cart has no discount", () => { Not reported: {/* good */} - ```ts import { expect, test } from "bun:test"; @@ -41,4 +48,4 @@ test("a new cart has no discount", () => { ## When not to use it -If a function must return exactly `false`, and `undefined` or `0` would be a bug, `toBe(false)` is the stricter and better check. Leave this rule off in that case. +`toBe(false)` is the stricter check. If returning `undefined` or `0` instead of `false` would be a bug, keep it. `bun:test/prefer-strict-boolean-matchers` is the opposite rule. diff --git a/docs/lint/rules/bun-test/prefer-to-be-truthy.mdx b/docs/lint/rules/bun-test/prefer-to-be-truthy.mdx index 9237d077ef51..00703efe1c34 100644 --- a/docs/lint/rules/bun-test/prefer-to-be-truthy.mdx +++ b/docs/lint/rules/bun-test/prefer-to-be-truthy.mdx @@ -1,11 +1,20 @@ --- title: "bun:test/prefer-to-be-truthy" description: "Require `toBeTruthy()` instead of `toBe(true)`" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "suggestion" +typeAware: false +deprecated: false --- -`toBe(true)` passes only for the value `true`. `toBeTruthy()` passes for everything that counts as true in an `if`, such as `1`, `"yes"`, or an object. With this rule, a test does not depend on which of those values the code returns. +`toBe(true)` passes only for `true` itself. If the code returns `1`, a string, or an object instead, the test fails, even though `if (value)` treats them all the same. `toBeTruthy()` passes for any truthy value. -The rule reports `toBe(true)`, `toEqual(true)`, and `toStrictEqual(true)`, with or without `.not`. +`toEqual(true)` and `toStrictEqual(true)` are reported too, with or without `.not`. `--fix-suggestions` replaces the matcher with `toBeTruthy()`. `--fix` does not, because the new matcher accepts more values. @@ -14,7 +23,6 @@ The rule reports `toBe(true)`, `toEqual(true)`, and `toStrictEqual(true)`, with Reported: {/* bad {"count": 2} */} - ```ts import { expect, test } from "bun:test"; @@ -28,7 +36,6 @@ test("an admin can delete posts", () => { Not reported: {/* good */} - ```ts import { expect, test } from "bun:test"; @@ -41,4 +48,4 @@ test("an admin can delete posts", () => { ## When not to use it -If a function must return exactly `true`, and `1` or a string would be a bug, `toBe(true)` is the stricter and better check. Leave this rule off in that case. +`toBe(true)` is the stricter check. If returning `1` or a string instead of `true` would be a bug, keep it. `bun:test/prefer-strict-boolean-matchers` is the opposite rule. diff --git a/docs/lint/rules/bun-test/prefer-to-be.mdx b/docs/lint/rules/bun-test/prefer-to-be.mdx index 62b470264af8..90763164b554 100644 --- a/docs/lint/rules/bun-test/prefer-to-be.mdx +++ b/docs/lint/rules/bun-test/prefer-to-be.mdx @@ -1,17 +1,26 @@ --- title: "bun:test/prefer-to-be" description: "Require `toBe()` for primitive literals, and `toBeNull()`, `toBeUndefined()`, `toBeNaN()`" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "yes" +typeAware: false +deprecated: false --- -`toEqual()` and `toStrictEqual()` compare objects property by property. A string, a number or a boolean has nothing to walk through, so `toBe()` does the same check and tells the reader that one plain value is expected. For `null`, `undefined` and `NaN` there are matchers that name the value. +`toEqual()` and `toStrictEqual()` compare objects property by property. For a string, a number or a boolean they do the same check as `toBe()`, and `toBe()` tells the reader that one plain value is expected. `null`, `undefined` and `NaN` have matchers that name the value. -| Written | Use instead | -| -------------------------------------------- | --------------------------- | -| `toEqual(2)`, `toStrictEqual("config")` | `toBe(2)`, `toBe("config")` | -| `toBe(null)`, `toEqual(null)` | `toBeNull()` | -| `toBe(undefined)`, `not.toBeDefined()` | `toBeUndefined()` | -| `not.toBe(undefined)`, `not.toBeUndefined()` | `toBeDefined()` | -| `toBe(NaN)`, `toEqual(NaN)` | `toBeNaN()` | +| Written | Use instead | +| --- | --- | +| `toEqual(2)`, `toStrictEqual("config")` | `toBe(2)`, `toBe("config")` | +| `toBe(null)`, `toEqual(null)` | `toBeNull()` | +| `toBe(undefined)`, `not.toBeDefined()` | `toBeUndefined()` | +| `not.toBe(undefined)`, `not.toBeUndefined()` | `toBeDefined()` | +| `toBe(NaN)`, `toEqual(NaN)` | `toBeNaN()` | `--fix` rewrites the matcher. @@ -19,8 +28,7 @@ description: "Require `toBe()` for primitive literals, and `toBeNull()`, `toBeUn Reported: -{/* bad {"count": 5} */} - +{/* bad {"count": 7} */} ```ts import { expect, test } from "bun:test"; @@ -28,7 +36,9 @@ test("parses the header", () => { expect(header.version).toEqual(2); expect(header.name).toStrictEqual("config"); expect(header.parent).toBe(null); + expect(header.comment).not.toBeDefined(); expect(header.size).not.toBe(undefined); + expect(header.date).not.toBeUndefined(); expect(header.ratio).toEqual(NaN); }); ``` @@ -36,7 +46,6 @@ test("parses the header", () => { Not reported: {/* good */} - ```ts import { expect, test } from "bun:test"; @@ -44,21 +53,21 @@ test("parses the header", () => { expect(header.version).toBe(2); expect(header.name).toBe("config"); expect(header.parent).toBeNull(); + expect(header.comment).toBeUndefined(); expect(header.size).toBeDefined(); + expect(header.date).toBeDefined(); expect(header.ratio).toBeNaN(); }); ``` -Only literals count. Objects, arrays and variables are left alone, and so is a number written with a decimal point: +Only literals count. Objects, arrays and variables are left alone: {/* good */} - ```ts import { expect, test } from "bun:test"; test("parses the body", () => { expect(body.tags).toEqual(["a", "b"]); expect(body.length).toEqual(expectedLength); - expect(body.scale).toEqual(0.5); }); ``` diff --git a/docs/lint/rules/bun-test/prefer-to-contain.mdx b/docs/lint/rules/bun-test/prefer-to-contain.mdx index d05dde5c80ef..97f461912dbd 100644 --- a/docs/lint/rules/bun-test/prefer-to-contain.mdx +++ b/docs/lint/rules/bun-test/prefer-to-contain.mdx @@ -1,11 +1,20 @@ --- title: "bun:test/prefer-to-contain" description: "Require `toContain()` instead of comparing the result of `includes()` with a boolean" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "suggestion" +typeAware: false +deprecated: false --- -When `expect(tags.includes("new")).toBe(true)` fails, the message only says that `false` is not `true`. `expect(tags).toContain("new")` prints the array and the item it looked for, so you see what is wrong without a debugger. +When `expect(tags.includes("new")).toBe(true)` fails, the message only says that `false` is not `true`. `expect(tags).toContain("new")` prints the array and the item it looked for. -The rule reports `includes()` with one argument inside `expect()`, compared with `true` or `false` by `toBe()`, `toEqual()`, or `toStrictEqual()`. +The rule reports `expect(x.includes(y))` matched against `true` or `false` with `toBe()`, `toEqual()`, or `toStrictEqual()`. `--fix-suggestions` rewrites the assertion. `--fix` does not, because not every object with an `includes()` method works with `toContain()`. @@ -14,7 +23,6 @@ The rule reports `includes()` with one argument inside `expect()`, compared with Reported: {/* bad {"count": 2} */} - ```ts import { expect, test } from "bun:test"; @@ -28,7 +36,6 @@ test("a draft is tagged", () => { Not reported: {/* good */} - ```ts import { expect, test } from "bun:test"; @@ -42,7 +49,6 @@ test("a draft is tagged", () => { A start index has no equivalent in `toContain()`, so this is allowed: {/* good */} - ```ts import { expect, test } from "bun:test"; diff --git a/docs/lint/rules/bun-test/prefer-to-have-been-called-times.mdx b/docs/lint/rules/bun-test/prefer-to-have-been-called-times.mdx index 4358fc046edb..1bfb86c1457f 100644 --- a/docs/lint/rules/bun-test/prefer-to-have-been-called-times.mdx +++ b/docs/lint/rules/bun-test/prefer-to-have-been-called-times.mdx @@ -1,18 +1,26 @@ --- title: "bun:test/prefer-to-have-been-called-times" description: "Require `toHaveBeenCalledTimes()` instead of checking the length of `mock.calls`" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "yes" +typeAware: false +deprecated: false --- -`expect(save.mock.calls).toHaveLength(2)` treats the mock as a plain array. When it fails, the message is about the length of an array. `expect(save).toHaveBeenCalledTimes(2)` says what you test, and its failure message is about the number of calls. +When `expect(save.mock.calls).toHaveLength(2)` fails, the message is about the length of an array. `expect(save).toHaveBeenCalledTimes(2)` says what the test means, and its failure message gives the expected and the received number of calls. -`--fix` rewrites the assertion and keeps `.not`. +`--fix` rewrites the assertion. ## Examples Reported: -{/* bad {"count": 2} */} - +{/* bad */} ```ts import { expect, mock, test } from "bun:test"; @@ -20,14 +28,12 @@ test("saves each item once", () => { const save = mock(); saveAll(["a", "b"], save); expect(save.mock.calls).toHaveLength(2); - expect(save.mock.calls).not.toHaveLength(0); }); ``` Not reported: {/* good */} - ```ts import { expect, mock, test } from "bun:test"; @@ -35,6 +41,6 @@ test("saves each item once", () => { const save = mock(); saveAll(["a", "b"], save); expect(save).toHaveBeenCalledTimes(2); - expect(save.mock.calls[0]).toHaveLength(1); + expect(save.mock.calls[0]).toHaveLength(1); // the arguments of the first call }); ``` diff --git a/docs/lint/rules/bun-test/prefer-to-have-been-called.mdx b/docs/lint/rules/bun-test/prefer-to-have-been-called.mdx index 11b7530f5749..7c854e74738d 100644 --- a/docs/lint/rules/bun-test/prefer-to-have-been-called.mdx +++ b/docs/lint/rules/bun-test/prefer-to-have-been-called.mdx @@ -1,20 +1,28 @@ --- title: "bun:test/prefer-to-have-been-called" -description: "Require `toHaveBeenCalled()` instead of `toHaveBeenCalledTimes(0)`" +description: "Require `not.toHaveBeenCalled()` instead of `toHaveBeenCalledTimes(0)`" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "yes" +typeAware: false +deprecated: false --- -"Called zero times" is a roundabout way to say "not called", and `.not.toHaveBeenCalledTimes(0)` is a double negative for "called". `toHaveBeenCalled()` and `not.toHaveBeenCalled()` say it directly. +`toHaveBeenCalledTimes(0)` means "not called", and `.not.toHaveBeenCalledTimes(0)` is a double negative for "called". `.not.toHaveBeenCalled()` and `toHaveBeenCalled()` say it directly. The alias `toBeCalledTimes(0)` is reported too. Any other count is fine. -`--fix` rewrites `toHaveBeenCalledTimes(0)` to `not.toHaveBeenCalled()`, and `not.toHaveBeenCalledTimes(0)` to `toHaveBeenCalled()`. +`--fix` rewrites both forms. ## Examples Reported: {/* bad {"count": 2} */} - ```ts import { expect, mock, test } from "bun:test"; @@ -30,7 +38,6 @@ test("only notifies on failure", () => { Not reported: {/* good */} - ```ts import { expect, mock, test } from "bun:test"; diff --git a/docs/lint/rules/bun-test/prefer-to-have-length.mdx b/docs/lint/rules/bun-test/prefer-to-have-length.mdx index 5c5b0412eee5..35938dee420d 100644 --- a/docs/lint/rules/bun-test/prefer-to-have-length.mdx +++ b/docs/lint/rules/bun-test/prefer-to-have-length.mdx @@ -1,6 +1,15 @@ --- title: "bun:test/prefer-to-have-length" description: "Require `toHaveLength()` instead of comparing the `length` property" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "yes" +typeAware: false +deprecated: false --- When `expect(users.length).toBe(2)` fails, the message shows two bare numbers. `expect(users).toHaveLength(2)` labels them as the expected and the received length. If the value has no `length` property, it says so instead of comparing `undefined` with `2`. @@ -14,7 +23,6 @@ The rule reports `.length` inside `expect()` when the matcher is `toBe()`, `toEq Reported: {/* bad {"count": 2} */} - ```ts import { expect, test } from "bun:test"; @@ -28,7 +36,6 @@ test("finds the active users", () => { Not reported: {/* good */} - ```ts import { expect, test } from "bun:test"; diff --git a/docs/lint/rules/bun-test/prefer-todo.mdx b/docs/lint/rules/bun-test/prefer-todo.mdx index 260eb61ed2ad..3d2f0c40a082 100644 --- a/docs/lint/rules/bun-test/prefer-todo.mdx +++ b/docs/lint/rules/bun-test/prefer-todo.mdx @@ -1,20 +1,28 @@ --- title: "bun:test/prefer-todo" description: "Require `test.todo` for tests that have no body yet" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "yes" +typeAware: false +deprecated: false --- -A test with an empty body passes, so the summary counts a test you have not written as one that works. `test.todo` says what it is, and `bun test` lists it apart from the passing tests until you write it. +A test with an empty body passes, so the summary counts a test you have not written yet as one that works. `bun test` counts a `test.todo` as todo, not as passed. A test with only a title is reported too, and so is an empty `test.skip`. -`--fix` rewrites the call to `test.todo` and removes the empty function. It leaves a test that has options, such as `{ timeout: 500 }`, for you to change. +`--fix` rewrites the call to `test.todo` and removes the empty function. A test with options, such as `{ timeout: 500 }`, is reported but not fixed. ## Examples Reported: {/* bad {"count": 3} */} - ```ts import { test } from "bun:test"; @@ -28,7 +36,6 @@ test.skip("logs out", function () {}); Not reported: {/* good */} - ```ts import { expect, test } from "bun:test"; diff --git a/docs/lint/rules/bun-test/require-hook.mdx b/docs/lint/rules/bun-test/require-hook.mdx index 4c3bc59414c3..a0c15c695bff 100644 --- a/docs/lint/rules/bun-test/require-hook.mdx +++ b/docs/lint/rules/bun-test/require-hook.mdx @@ -1,20 +1,31 @@ --- title: "bun:test/require-hook" description: "Require setup and teardown code to be inside a hook" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- -Code at the top level of a test file, or directly in the body of a `describe`, runs once while `bun test` collects the tests. It runs even if every test in that block is skipped, and the state it builds is shared by all tests, so one test can break the next. Code in `beforeEach`, `beforeAll`, `afterEach` or `afterAll` runs when the tests do. +Code at the top level of a test file, or directly in the body of a `describe`, runs once while `bun test` collects the tests. It runs even if every test in that block is skipped, and the state it builds is shared by all tests, so one test can break the next. Put it in `beforeAll`, `beforeEach`, `afterEach` or `afterAll`. -Two things are reported in those places: a function call that stands alone as a statement, and a `let` or `var` that starts with a value other than `null` or `undefined`. +Two things are reported in those places: -`const` declarations and calls of `describe`, `test`, `it` and the hooks are allowed. So are `setDefaultTimeout()`, `expectTypeOf()`, and calls on `mock`, `expect`, `jest` and `vi`, such as `mock.module()` and `expect.extend()`. +- a statement that is only a function call, such as `setup();` +- a `let` or `var` that starts with a value other than `null` or `undefined` + +`const` declarations are allowed. So are calls of `describe`, `test`, `it` and the hooks, `setDefaultTimeout()`, `expectTypeOf()`, and calls on `mock`, `expect`, `jest` and `vi`, such as `mock.module()` and `expect.extend()`. ## Examples Reported: {/* bad {"count": 3} */} - ```ts import { describe, expect, test } from "bun:test"; @@ -33,7 +44,6 @@ describe("users", () => { Not reported: {/* good */} - ```ts import { beforeAll, beforeEach, describe, expect, mock, setDefaultTimeout, test } from "bun:test"; @@ -61,12 +71,11 @@ describe("users", () => { ## Options -| Option | Default | What it does | -| ---------------------- | ------- | ------------------------------------------------------------------------------------------------- | -| `allowedFunctionCalls` | `[]` | Names of functions that you can call outside a hook. Write a method with its object: `"db.seed"`. | +| Option | Default | What it does | +| --- | --- | --- | +| `allowedFunctionCalls` | `[]` | Names of functions that you can call outside a hook. Write a method with its object: `"db.seed"`. | {/* good {"options": [{"allowedFunctionCalls": ["installMatchers", "db.seed"]}]} */} - ```ts import { expect, test } from "bun:test"; diff --git a/docs/lint/rules/bun-test/require-mock-type-parameters.mdx b/docs/lint/rules/bun-test/require-mock-type-parameters.mdx index 2baced043293..c90226cd17db 100644 --- a/docs/lint/rules/bun-test/require-mock-type-parameters.mdx +++ b/docs/lint/rules/bun-test/require-mock-type-parameters.mdx @@ -1,18 +1,26 @@ --- title: "bun:test/require-mock-type-parameters" description: "Require a type parameter on `jest.fn()` and `vi.fn()`" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "problem" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- -A plain `jest.fn()` stands for a function that takes any arguments and returns anything. TypeScript then accepts `mockReturnValue(42)` for a function that returns a string, and it says nothing when the real function changes and the mock no longer matches it. With a type parameter, the mock is checked like the function it replaces. +A plain `jest.fn()` is typed as a function that takes any arguments and returns `any`. TypeScript then accepts `mockReturnValue(42)` on a mock of a function that returns a string. With a type parameter, what you pass to the mock and what it returns are checked. -A mock with an implementation, such as `jest.fn(() => 1)`, is reported too. `mock()` is not checked, and neither are JavaScript files. +A mock with an implementation, such as `jest.fn(() => 1)`, is reported too, even though TypeScript infers its type. `mock()` is not checked, and neither are JavaScript files. ## Examples Reported: {/* bad {"count": 2} */} - ```ts import { jest, vi } from "bun:test"; @@ -23,13 +31,12 @@ const log = vi.fn((message: string) => {}); Not reported: {/* good */} - ```ts import { jest, mock, vi } from "bun:test"; type User = { name: string }; const findUser = jest.fn<(id: string) => Promise>(); -const log = vi.fn<(message: string) => void>(message => {}); +const log = vi.fn<(message: string) => void>((message) => {}); const send = mock(); ``` diff --git a/docs/lint/rules/bun-test/require-test-timeout.mdx b/docs/lint/rules/bun-test/require-test-timeout.mdx index a0201bbed58a..83ade9c9a34e 100644 --- a/docs/lint/rules/bun-test/require-test-timeout.mdx +++ b/docs/lint/rules/bun-test/require-test-timeout.mdx @@ -1,11 +1,20 @@ --- title: "bun:test/require-test-timeout" description: "Require every test to set a timeout" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- -A test without a timeout of its own gets the default, 5 seconds unless you run `bun test --timeout`. That is a long wait for a unit test that hangs, and too short for a test that starts a server, which then fails now and then on a slow machine. This rule makes you choose the limit for each test. +A test without its own timeout gets the default, which is 5 seconds unless you pass `--timeout` to `bun test`. That is a long wait when a unit test hangs, and too short for a test that starts a server, which then fails now and then on a slow machine. -You can give the timeout as a number after the test function, as `{ timeout }` in the options, or for all the tests below with `setDefaultTimeout()` or `jest.setTimeout()`. +Set the timeout as a number after the test function, as `{ timeout }` in the options, or for all the tests below with `setDefaultTimeout()` or `jest.setTimeout()`. Tests marked `.skip` or `.todo` are not checked. @@ -14,7 +23,6 @@ Tests marked `.skip` or `.todo` are not checked. Reported: {/* bad */} - ```ts import { expect, test } from "bun:test"; @@ -26,7 +34,6 @@ test("loads the report", async () => { Not reported: {/* good */} - ```ts import { expect, test } from "bun:test"; @@ -34,17 +41,12 @@ test("loads the report", async () => { expect(await loadReport()).toBeDefined(); }, 10_000); -test( - "exports the report", - async () => { - expect(await exportReport()).toBeDefined(); - }, - { timeout: 30_000 }, -); +test("exports the report", async () => { + expect(await exportReport()).toBeDefined(); +}, { timeout: 30_000 }); ``` {/* good */} - ```ts import { expect, setDefaultTimeout, test } from "bun:test"; @@ -58,27 +60,18 @@ test("loads the report", async () => { The timeout has to be written as a number that is not negative. A variable is reported, and so are options without `timeout`: {/* bad {"count": 2} */} - ```ts import { test } from "bun:test"; const SLOW = 30_000; -test( - "exports the report", - async () => { - await exportReport(); - }, - SLOW, -); - -test( - "prints the report", - async () => { - await printReport(); - }, - { retry: 2 }, -); +test("exports the report", async () => { + await exportReport(); +}, SLOW); + +test("prints the report", async () => { + await printReport(); +}, { retry: 2 }); ``` ## When not to use it diff --git a/docs/lint/rules/bun-test/require-to-throw-message.mdx b/docs/lint/rules/bun-test/require-to-throw-message.mdx index 8621c812ac38..728e8aa99ecb 100644 --- a/docs/lint/rules/bun-test/require-to-throw-message.mdx +++ b/docs/lint/rules/bun-test/require-to-throw-message.mdx @@ -1,6 +1,15 @@ --- title: "bun:test/require-to-throw-message" description: "Require `toThrow()` to say which error it expects" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "problem" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- `toThrow()` without an argument passes for any error at all, including a `TypeError` from a typo in the code under test. The test stays green although the check you meant to test was never reached. Pass part of the message, a regular expression, or the error class. @@ -12,7 +21,6 @@ description: "Require `toThrow()` to say which error it expects" Reported: {/* bad {"count": 3} */} - ```ts import { expect, test } from "bun:test"; @@ -26,7 +34,6 @@ test("rejects a negative amount", async () => { Not reported: {/* good */} - ```ts import { expect, test } from "bun:test"; diff --git a/docs/lint/rules/bun-test/require-top-level-describe.mdx b/docs/lint/rules/bun-test/require-top-level-describe.mdx index 980f91f32f4c..4a79a17cf1a5 100644 --- a/docs/lint/rules/bun-test/require-top-level-describe.mdx +++ b/docs/lint/rules/bun-test/require-top-level-describe.mdx @@ -1,6 +1,15 @@ --- title: "bun:test/require-top-level-describe" description: "Require tests and hooks to be inside a `describe` block" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- A test at the top level of a file shows up in the output with no group name, so a failure says "starts empty" but not what starts empty. A hook at the top level runs for every test in the file, including the ones somebody adds later for something else. Inside a `describe`, both say what they belong to. @@ -10,7 +19,6 @@ A test at the top level of a file shows up in the output with no group name, so Reported: {/* bad {"count": 2} */} - ```ts import { beforeEach, expect, test } from "bun:test"; @@ -26,7 +34,6 @@ test("starts empty", () => { Not reported: {/* good */} - ```ts import { beforeEach, describe, expect, test } from "bun:test"; @@ -43,12 +50,11 @@ describe("cart", () => { ## Options -| Option | Default | What it does | -| ------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------- | +| Option | Default | What it does | +| --- | --- | --- | | `maxNumberOfTopLevelDescribes` | no limit | How many `describe` blocks a file can have at the top level. Each one after that is reported. Nested blocks do not count. | {/* bad {"options": [{"maxNumberOfTopLevelDescribes": 1}], "count": 1} */} - ```ts import { describe, test } from "bun:test"; @@ -62,7 +68,6 @@ describe("checkout", () => { ``` {/* good {"options": [{"maxNumberOfTopLevelDescribes": 1}]} */} - ```ts import { describe, test } from "bun:test"; diff --git a/docs/lint/rules/bun-test/valid-describe-callback.mdx b/docs/lint/rules/bun-test/valid-describe-callback.mdx index bfe47f20d8c6..442f885eadde 100644 --- a/docs/lint/rules/bun-test/valid-describe-callback.mdx +++ b/docs/lint/rules/bun-test/valid-describe-callback.mdx @@ -1,11 +1,18 @@ --- title: "bun:test/valid-describe-callback" description: "Require `describe` to get a callback that is a function with no parameters" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "problem" +category: null +default: "error" +fixable: "no" +typeAware: false +deprecated: false --- -`bun test` calls the callback of `describe` to collect the tests inside it. Without a callback there is nothing to collect. The callback is not a test, so it has no `done` to call at the end. - -The rule reports a `describe` with no callback, a callback that is not a function, and a callback with parameters. An `async` callback is allowed, because `bun test` waits for the promise it returns. +`bun test` runs the callback of `describe` to collect the tests inside it. Without a callback there is nothing to collect. The callback is not a test, so it has no `done` to call. This rule is on by default in files that import from `"bun:test"`. @@ -13,31 +20,23 @@ This rule is on by default in files that import from `"bun:test"`. Reported: -{/* bad */} - +{/* bad {"count": 3} */} ```ts import { describe, test } from "bun:test"; -describe("cart", done => { +describe("cart"); // no callback + +describe("cart", "adds an item"); // not a function + +describe("cart", (done) => { test("adds an item", () => {}); done(); }); ``` -{/* bad {"count": 2} */} - -```ts -import { describe } from "bun:test"; - -describe("cart"); - -describe("checkout", "adds the tax"); -``` - Not reported: {/* good */} - ```ts import { describe, expect, test } from "bun:test"; @@ -46,6 +45,13 @@ describe("cart", () => { expect(add(item).length).toBe(1); }); }); +``` + +An `async` callback is allowed, because `bun test` waits for the promise it returns: + +{/* good */} +```ts +import { describe, expect, test } from "bun:test"; describe("checkout", async () => { const rates = await loadRates(); @@ -56,10 +62,9 @@ describe("checkout", async () => { }); ``` -`describe.todo` and `describe.skip` need only a title. A callback that you pass by name is taken to be a function: +`describe.todo` and `describe.skip` need only a title. A callback passed by name is assumed to be a function: {/* good */} - ```ts import { describe } from "bun:test"; import { cartTests } from "./shared"; @@ -73,11 +78,10 @@ describe("cart", cartTests); The callback of `describe.each` gets the values of a row, so it can have parameters: {/* good */} - ```ts import { describe, expect, test } from "bun:test"; -describe.each([1, 2, 3])("cart with %i items", count => { +describe.each([1, 2, 3])("cart with %i items", (count) => { test("counts them", () => { expect(fill(count).length).toBe(count); }); diff --git a/docs/lint/rules/bun-test/valid-expect-in-promise.mdx b/docs/lint/rules/bun-test/valid-expect-in-promise.mdx index dfe52f8d089d..077a647b2e33 100644 --- a/docs/lint/rules/bun-test/valid-expect-in-promise.mdx +++ b/docs/lint/rules/bun-test/valid-expect-in-promise.mdx @@ -1,6 +1,15 @@ --- title: "bun:test/valid-expect-in-promise" description: "Require a promise chain that contains `expect` to be awaited or returned from the test" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "problem" +category: null +default: "error" +fixable: "no" +typeAware: false +deprecated: false --- A callback passed to `.then()`, `.catch()` or `.finally()` runs later. If the test neither awaits nor returns the promise, `bun test` does not wait for it. The test is over before the `expect` in the callback runs, so a failed assertion does not fail that test. @@ -12,12 +21,11 @@ This rule is on by default in files that import from `"bun:test"`. Reported: {/* bad */} - ```ts import { expect, test } from "bun:test"; test("loads the user", () => { - loadUser(1).then(user => { + loadUser(1).then((user) => { expect(user.name).toBe("Ada"); }); }); @@ -26,29 +34,27 @@ test("loads the user", () => { A promise kept in a variable has to be awaited or returned too: {/* bad */} - ```ts import { expect, test } from "bun:test"; test("loads the user", () => { - const check = loadUser(1).then(user => { + const check = loadUser(1).then((user) => { expect(user.name).toBe("Ada"); }); }); ``` -A chain written after the `return` of the test never starts: +Code after the `return` of the test never runs: {/* bad */} - ```ts import { expect, test } from "bun:test"; test("loads both users", () => { - return loadUser(1).then(user => { + return loadUser(1).then((user) => { expect(user.name).toBe("Ada"); }); - loadUser(2).then(user => { + loadUser(2).then((user) => { expect(user.name).toBe("Grace"); }); }); @@ -57,18 +63,17 @@ test("loads both users", () => { Not reported: {/* good */} - ```ts import { expect, test } from "bun:test"; test("loads the user", () => { - return loadUser(1).then(user => { + return loadUser(1).then((user) => { expect(user.name).toBe("Ada"); }); }); test("loads the admin", async () => { - await loadUser(2).then(user => { + await loadUser(2).then((user) => { expect(user.role).toBe("admin"); }); }); @@ -82,15 +87,14 @@ test("loads the guest", async () => { Several promises can be awaited together: {/* good */} - ```ts import { expect, test } from "bun:test"; test("loads both users", async () => { - const first = loadUser(1).then(user => { + const first = loadUser(1).then((user) => { expect(user.name).toBe("Ada"); }); - const second = loadUser(2).then(user => { + const second = loadUser(2).then((user) => { expect(user.name).toBe("Grace"); }); await Promise.all([first, second]); @@ -100,12 +104,11 @@ test("loads both users", async () => { A test with a parameter is not checked, because calling `done` is another way to make `bun test` wait: {/* good */} - ```ts import { expect, test } from "bun:test"; -test("loads the user", done => { - loadUser(1).then(user => { +test("loads the user", (done) => { + loadUser(1).then((user) => { expect(user.name).toBe("Ada"); done(); }); diff --git a/docs/lint/rules/bun-test/valid-expect.mdx b/docs/lint/rules/bun-test/valid-expect.mdx index 2e1663a4945f..e2e401225da5 100644 --- a/docs/lint/rules/bun-test/valid-expect.mdx +++ b/docs/lint/rules/bun-test/valid-expect.mdx @@ -1,6 +1,15 @@ --- title: "bun:test/valid-expect" description: "Require `expect` to get a value, end in a called matcher, and be awaited when it is async" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "problem" +category: null +default: "error" +fixable: "suggestion" +typeAware: false +deprecated: false --- `expect(value)` checks nothing by itself. The check happens when a matcher such as `.toBe()` is called, so an `expect` with no matcher, or with a matcher that is not called, passes whatever the value is. @@ -14,7 +23,6 @@ This rule is on by default in files that import from `"bun:test"`. Reported: {/* bad {"count": 4} */} - ```ts import { expect, test } from "bun:test"; @@ -27,7 +35,6 @@ test("adds the tax", () => { ``` {/* bad */} - ```ts import { expect, test } from "bun:test"; @@ -39,7 +46,6 @@ test("loads the user", () => { Not reported: {/* good */} - ```ts import { expect, test } from "bun:test"; @@ -60,12 +66,11 @@ test("rejects an unknown id", () => { A second argument is the message to print on failure, and does not count toward `maxArgs`. `expect().pass()` and `expect().fail()` need no value: {/* good */} - ```ts import { expect, test } from "bun:test"; test("adds the tax", () => { - expect(total(100), "total after tax").toBe(108); + expect(total(100), "after tax").toBe(108); }); test("never calls back", () => { @@ -73,13 +78,17 @@ test("never calls back", () => { }); ``` -Only a statement is reported. An `expect` that you keep in a variable or pass on can still get its matcher, or its `await`, somewhere else: +A missing matcher or a missing `await` is only reported when the `expect` is a statement of its own. One that you keep in a variable or pass to a function can get its matcher or its `await` somewhere else: {/* good */} - ```ts import { expect, test } from "bun:test"; +test("adds the tax", () => { + const result = expect(total(100)); + result.toBe(108); +}); + test("loads both users", async () => { const first = expect(loadUser(1)).resolves.toEqual({ id: 1 }); const second = expect(loadUser(2)).resolves.toEqual({ id: 2 }); @@ -89,15 +98,14 @@ test("loads both users", async () => { ## Options -| Option | Default | What it does | -| --------------- | --------------------------- | ---------------------------------------------------------------------------------------------------------------------- | -| `alwaysAwait` | `false` | Report every async assertion that is not awaited where it is written, also one that is returned or kept in a variable. | -| `asyncMatchers` | `["toResolve", "toReject"]` | Matchers that return a promise, and so have to be awaited or returned. Your list replaces the default one. | -| `minArgs` | `1` | The fewest arguments `expect` can take. | -| `maxArgs` | `1` | The most arguments `expect` can take. | +| Option | Default | What it does | +| --- | --- | --- | +| `alwaysAwait` | `false` | Report every async assertion that is not awaited where it is written, even one that is returned or kept in a variable. | +| `asyncMatchers` | `["toResolve", "toReject"]` | Matchers that return a promise, so they have to be awaited or returned. Your list replaces the default. | +| `minArgs` | `1` | The fewest arguments `expect` can take. | +| `maxArgs` | `1` | The most arguments `expect` can take. | {/* bad {"options": [{"alwaysAwait": true}]} */} - ```ts import { expect, test } from "bun:test"; @@ -106,10 +114,9 @@ test("rejects an unknown id", () => { }); ``` -`asyncMatchers` is for matchers of your own, added with `expect.extend()`, that return a promise: +Use `asyncMatchers` for matchers that you add with `expect.extend()` and that return a promise: {/* bad {"options": [{"asyncMatchers": ["toResolveWith"]}]} */} - ```ts import { expect, test } from "bun:test"; @@ -117,15 +124,3 @@ test("loads the user", () => { expect(loadUser(1)).toResolveWith({ id: 1 }); }); ``` - -With `{ "minArgs": 2 }`, every `expect` needs a message: - -{/* bad {"options": [{"minArgs": 2}]} */} - -```ts -import { expect, test } from "bun:test"; - -test("adds the tax", () => { - expect(total(100)).toBe(108); -}); -``` diff --git a/docs/lint/rules/bun-test/valid-title.mdx b/docs/lint/rules/bun-test/valid-title.mdx index 78e7c286a49b..8a2e647793e0 100644 --- a/docs/lint/rules/bun-test/valid-title.mdx +++ b/docs/lint/rules/bun-test/valid-title.mdx @@ -1,18 +1,26 @@ --- title: "bun:test/valid-title" -description: "Require the titles of tests and `describe` blocks to be clean, non-empty strings" +description: "Require test and `describe` titles to be non-empty, with no stray spaces or repeated prefix" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "problem" +category: null +default: "off" +fixable: "yes" +typeAware: false +deprecated: false --- -The title is how you find a test in the output when it fails. The rule reports a test title that is not a string, an empty title, a space at the start or the end, and a title that starts with the name of the function it is passed to, as in `test("test adds an item")`. With options, it also holds titles to your own naming rules. +The title is how you find a test in the output when it fails. This rule reports a title that is empty, starts or ends with a space, or repeats the name of its function, as in `test("test adds an item")`. It also reports a test title that is not a string. Options let you add naming rules of your own. -`--fix` removes the spaces and the repeated word. It leaves the titles alone in a file that calls `toMatchSnapshot()` or `toThrowErrorMatchingSnapshot()`, because snapshots are stored under the title of their test. +`--fix` removes the spaces and the repeated word. It changes nothing in a file that uses `toMatchSnapshot()` or `toThrowErrorMatchingSnapshot()`, because snapshots are stored under the title of their test. ## Examples Reported: {/* bad {"count": 5} */} - ```ts import { describe, test } from "bun:test"; @@ -27,7 +35,6 @@ describe("describe cart", () => { Not reported: {/* good */} - ```ts import { describe, test } from "bun:test"; @@ -41,7 +48,6 @@ describe("cart", () => { `bun test` takes a function, a class or a number as the title of a `describe`, so that is allowed by default: {/* good */} - ```ts import { describe, test } from "bun:test"; @@ -52,20 +58,19 @@ describe(Cart, () => { ## Options -| Option | Default | What it does | -| -------------------------- | ------- | --------------------------------------------------------------------------- | -| `allowArguments` | `false` | Allow a title that is a plain variable, such as `title`. | -| `ignoreTypeOfDescribeName` | `true` | Allow a `describe` title that is not a string. | -| `ignoreTypeOfTestName` | `false` | Allow a `test` or `it` title that is not a string. | -| `ignoreSpaces` | `false` | Allow spaces at the start and the end. | -| `disallowedWords` | `[]` | Whole words that a title cannot contain. Upper and lower case are the same. | -| `mustMatch` | none | A pattern that every title has to match. | -| `mustNotMatch` | none | A pattern that no title can match. | +| Option | Default | What it does | +| --- | --- | --- | +| `allowArguments` | `false` | Allow a title that is a plain variable, such as `title`. | +| `ignoreTypeOfDescribeName` | `true` | Allow a `describe` title that is not a string. | +| `ignoreTypeOfTestName` | `false` | Allow a `test` or `it` title that is not a string. | +| `ignoreSpaces` | `false` | Allow spaces at the start and the end. | +| `disallowedWords` | `[]` | Whole words that a title cannot contain. Case does not matter. | +| `mustMatch` | none | A pattern that every title has to match. | +| `mustNotMatch` | none | A pattern that no title can match. | A variable as the title of a test is reported by default, because the rule cannot tell what it holds: {/* bad */} - ```ts import { test } from "bun:test"; @@ -73,7 +78,6 @@ test(title, () => {}); ``` {/* good {"options": [{"allowArguments": true}]} */} - ```ts import { test } from "bun:test"; @@ -81,7 +85,6 @@ test(title, () => {}); ``` {/* bad {"options": [{"ignoreTypeOfDescribeName": false}]} */} - ```ts import { describe, test } from "bun:test"; @@ -91,27 +94,24 @@ describe(Cart, () => { ``` {/* bad {"options": [{"disallowedWords": ["should"]}]} */} - ```ts import { test } from "bun:test"; test("Should add an item", () => {}); ``` -`mustMatch` and `mustNotMatch` take a regular expression written as a string, `"^adds "` or `"/^adds /"`. Flags after the last `/` are ignored. To choose the message of the report, give a pair: +`mustMatch` and `mustNotMatch` take a regular expression as a string, either `"^adds "` or `"/^adds /"`. Flags after the last `/` are ignored. To set the message of the report, pass `[pattern, message]`: {/* bad {"options": [{"mustNotMatch": ["\\.$", "Do not end a title with a period"]}]} */} - ```ts import { test } from "bun:test"; test("adds an item.", () => {}); ``` -To have a pattern for only some functions, give an object with the keys `describe`, `test` and `it`: +To use a different pattern for `describe`, `test` and `it`, pass an object. A function that is not in the object is not checked: {/* bad {"options": [{"mustMatch": {"it": "^should "}}], "count": 1} */} - ```ts import { it, test } from "bun:test"; diff --git a/docs/lint/rules/bun-test/warn-todo.mdx b/docs/lint/rules/bun-test/warn-todo.mdx index 88ff991bb9a4..a470ba425eec 100644 --- a/docs/lint/rules/bun-test/warn-todo.mdx +++ b/docs/lint/rules/bun-test/warn-todo.mdx @@ -1,16 +1,24 @@ --- title: "bun:test/warn-todo" description: "Disallow `.todo` on `test`, `it` and `describe`" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun-test" +aliases: [] +kind: "problem" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- -`test.todo` stands for a test that nobody has written yet, or one that does not pass yet. `bun test` still passes, so the behavior it names stays untested for as long as the line is forgotten. +`test.todo` marks a test that nobody has written yet, or one that does not pass yet. `bun test` still passes, so it is easy to forget, and the behavior it names stays untested. ## Examples Reported: {/* bad {"count": 2} */} - ```ts import { describe, test } from "bun:test"; @@ -22,7 +30,6 @@ describe.todo("checkout"); Not reported: {/* good */} - ```ts import { expect, test } from "bun:test"; diff --git a/docs/lint/rules/bun/consistent-directive-prefix.mdx b/docs/lint/rules/bun/consistent-directive-prefix.mdx index f4d53a296376..feaab1107e09 100644 --- a/docs/lint/rules/bun/consistent-directive-prefix.mdx +++ b/docs/lint/rules/bun/consistent-directive-prefix.mdx @@ -1,6 +1,15 @@ --- title: "bun/consistent-directive-prefix" description: "Require comments that turn rules off to start with the same word, `eslint` or `oxlint`" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "yes" +typeAware: false +deprecated: false --- `eslint-disable` and `oxlint-disable` comments both turn a rule off. If a project mixes them, a search for one spelling misses the other, and ESLint ignores the `oxlint` ones. @@ -14,7 +23,6 @@ The default is `eslint`, which ESLint, oxlint, and `bun lint` all read. The rule Reported: {/* bad */} - ```js // oxlint-disable-next-line no-console console.log(version); @@ -23,7 +31,6 @@ console.log(version); Not reported: {/* good */} - ```js // eslint-disable-next-line no-console console.log(version); @@ -31,8 +38,8 @@ console.log(version); ## Options -| Option | Default | What it does | -| -------- | ---------- | ------------------------------------------------- | +| Option | Default | What it does | +| --- | --- | --- | | `prefix` | `"eslint"` | The word to start with: `"eslint"` or `"oxlint"`. | ```toml bunfig.toml @@ -41,7 +48,6 @@ console.log(version); ``` {/* bad {"options": [{"prefix": "oxlint"}]} */} - ```js /* eslint-disable no-console */ console.log(version); @@ -49,7 +55,6 @@ console.log(version); ``` {/* good {"options": [{"prefix": "oxlint"}]} */} - ```js /* oxlint-disable no-console */ console.log(version); diff --git a/docs/lint/rules/bun/format.mdx b/docs/lint/rules/bun/format.mdx index 44bd1aef1ca8..3448fc455ba3 100644 --- a/docs/lint/rules/bun/format.mdx +++ b/docs/lint/rules/bun/format.mdx @@ -1,6 +1,15 @@ --- title: "bun/format" description: "Require code to be formatted the way `bun format` prints it" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun" +aliases: [] +kind: "layout" +category: null +default: "off" +fixable: "yes" +typeAware: false +deprecated: false --- This rule compares each file with what `bun format` prints and reports every place where they differ. Formatting problems then show up next to your lint problems, in the terminal and in the editor. @@ -16,15 +25,13 @@ A file that Bun cannot format the way your config asks, for example because of a Reported: {/* bad */} - ```js -const user = { name: "Ada" }; +const user = {name:'Ada'} ``` Not reported: {/* good */} - ```js const user = { name: "Ada" }; ``` @@ -39,23 +46,21 @@ The first option is an object of formatting options, with the names they have in ``` {/* good {"options": [{"singleQuote": true, "semi": false}]} */} - ```js -const user = { name: "Ada" }; +const user = { name: 'Ada' } ``` {/* bad {"options": [{"singleQuote": true, "semi": false}]} */} - ```js const user = { name: "Ada" }; ``` The second option is an object with these keys: -| Option | Default | What it does | -| ----------------- | ------- | ----------------------------------------------------------------------------------------------------------------- | -| `usePrettierrc` | `true` | Read config files such as `.prettierrc` and `.editorconfig`. | -| `fileInfoOptions` | `{}` | `ignorePath` names the ignore file or files to read. `withNodeModules: true` also checks files in `node_modules`. | +| Option | Default | What it does | +| --- | --- | --- | +| `usePrettierrc` | `true` | Read config files such as `.prettierrc` and `.editorconfig`. | +| `fileInfoOptions` | `{}` | `ignorePath` names the ignore file or files to read. `withNodeModules: true` also checks files in `node_modules`. | ## When not to use it diff --git a/docs/lint/rules/bun/jsx-no-forbidden-nesting.mdx b/docs/lint/rules/bun/jsx-no-forbidden-nesting.mdx index 3444a7389b1d..bb3a3349bc54 100644 --- a/docs/lint/rules/bun/jsx-no-forbidden-nesting.mdx +++ b/docs/lint/rules/bun/jsx-no-forbidden-nesting.mdx @@ -1,18 +1,27 @@ --- title: "bun/jsx-no-forbidden-nesting" description: "Disallow a JSX element inside another element that cannot contain it" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun" +aliases: [] +kind: "problem" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- HTML does not allow a link or a button inside another link or button. Browsers differ in what a click on the inner element does. In server-rendered HTML, the parser ends an `a` or a `button` where the next one starts and ignores a `form` inside a `form`, so the DOM does not match your JSX. The inner element is reported at any depth, not only as a direct child. By default the rule knows these pairs: -| Outer | Cannot contain | -| -------- | -------------- | -| `a` | `a`, `button` | -| `button` | `a`, `button` | -| `form` | `form` | -| `label` | `label` | +| Outer | Cannot contain | +| --- | --- | +| `a` | `a`, `button` | +| `button` | `a`, `button` | +| `form` | `form` | +| `label` | `label` | Only the tag names are compared, so a `Link` component that renders an `a` does not count as an `a`. @@ -21,7 +30,6 @@ Only the tag names are compared, so a `Link` component that renders an `a` does Reported: {/* bad */} - ```jsx const card = ( @@ -34,7 +42,6 @@ const card = ( Not reported: {/* good */} - ```jsx const card = (
    @@ -48,8 +55,8 @@ const card = ( ## Options -| Option | Default | What it does | -| ------- | --------------- | ----------------------------------------------------------------- | +| Option | Default | What it does | +| --- | --- | --- | | `pairs` | The table above | A list of `{ outer, inner }` names. It replaces the default list. | Use it for your own components: @@ -60,7 +67,6 @@ Use it for your own components: ``` {/* bad {"options": [{"pairs": [{"outer": "Link", "inner": "Button"}]}]} */} - ```jsx const row = ( @@ -72,7 +78,6 @@ const row = ( The default pairs no longer apply then. Add them to the list if you want to keep them: {/* good {"options": [{"pairs": [{"outer": "Link", "inner": "Button"}]}]} */} - ```jsx const row = ( diff --git a/docs/lint/rules/bun/no-array-just-to-count.mdx b/docs/lint/rules/bun/no-array-just-to-count.mdx index e011376ec986..a68de3b29cfe 100644 --- a/docs/lint/rules/bun/no-array-just-to-count.mdx +++ b/docs/lint/rules/bun/no-array-just-to-count.mdx @@ -1,6 +1,15 @@ --- title: "bun/no-array-just-to-count" description: "Disallow `.split(separator).length` with a one-character separator" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "yes" +typeAware: false +deprecated: false --- `text.split("\n").length` copies every line of `text` into a new array, reads the length, and throws the array away. For a large string that is a lot of memory for one number. A loop that counts the separator allocates nothing. @@ -12,7 +21,6 @@ Only a literal separator of one character is reported, not a variable or a regul Reported: {/* bad */} - ```js const lineCount = source.split("\n").length; ``` @@ -20,7 +28,6 @@ const lineCount = source.split("\n").length; Not reported: {/* good */} - ```js function count(text, separator) { let found = 0; @@ -36,7 +43,6 @@ const lineCount = count(source, "\n") + 1; An array that is used for more than its length is fine, and so is a longer separator: {/* good */} - ```js const lines = source.split("\n"); console.log(lines.length, lines[0]); @@ -46,9 +52,9 @@ const paragraphCount = source.split("\n\n").length; ## Options -| Option | Default | What it does | -| --------------- | ------- | ----------------------------------------------------------------------------------------------------------------------- | -| `countFunction` | none | The name of a function that is called as `countFunction(string, separator)` and returns how often the separator occurs. | +| Option | Default | What it does | +| --- | --- | --- | +| `countFunction` | none | The name of a function that is called as `countFunction(string, separator)` and returns how often the separator occurs. | Without `countFunction`, the rule only reports. With it, `--fix` rewrites `source.split("\n").length` to `count(source, "\n") + 1`, and `source.split("\n").length - 1` to `count(source, "\n")`. The fix applies only where a function of that name is in scope. @@ -58,7 +64,6 @@ Without `countFunction`, the rule only reports. With it, `--fix` rewrites `sourc ``` {/* bad {"options": [{"countFunction": "count"}]} */} - ```js import { count } from "./strings.js"; diff --git a/docs/lint/rules/bun/no-cast-to-brand.mdx b/docs/lint/rules/bun/no-cast-to-brand.mdx index 1047eea3994c..b9f0152ad8ae 100644 --- a/docs/lint/rules/bun/no-cast-to-brand.mdx +++ b/docs/lint/rules/bun/no-cast-to-brand.mdx @@ -1,6 +1,15 @@ --- title: "bun/no-cast-to-brand" description: "Disallow casts to a branded type outside the module that declares it" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun" +aliases: [] +kind: "problem" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- A branded type such as `UserId` says "this string has been checked". That only holds if every `UserId` comes from the function that checks. `raw as UserId` anywhere else makes one from a value that nothing has checked, and the compiler accepts it. @@ -19,7 +28,6 @@ These examples use this config: Reported: {/* bad {"filename": "src/routes.ts", "options": [{"branded": [{"module": "src/ids", "type": "UserId"}]}]} */} - ```ts import type { UserId } from "./ids"; @@ -31,7 +39,6 @@ export function userOf(params: { id: string }) { Casts to an array, tuple, or union of the type, or to an alias of it, are reported too: {/* bad {"filename": "src/routes.ts", "options": [{"branded": [{"module": "src/ids", "type": "UserId"}]}]} */} - ```ts import type { UserId } from "./ids"; @@ -41,7 +48,6 @@ const admins = ["u_1", "u_2"] as UserId[]; Not reported: {/* good {"filename": "src/routes.ts", "options": [{"branded": [{"module": "src/ids", "type": "UserId"}]}]} */} - ```ts import { parseUserId } from "./ids"; @@ -53,7 +59,6 @@ export function userOf(params: { id: string }) { The module that declares the type is where the cast belongs: {/* good {"filename": "src/ids.ts", "options": [{"branded": [{"module": "src/ids", "type": "UserId"}]}]} */} - ```ts export type UserId = string & { readonly brand: unique symbol }; @@ -67,17 +72,16 @@ export function parseUserId(text: string): UserId { `branded` is a list of objects with these keys: -| Key | Default | What it does | -| ---------- | -------- | -------------------------------------------------------------------------------------------------------------------------------- | -| `module` | required | The end of the path of the file that declares the type, without the extension: `"src/ids"` or `"ids"`. A package name works too. | -| `type` | required | The name of the type as the module exports it. | -| `mint` | `[]` | Functions of the module that make a value of the type without checking it. | -| `mintedIn` | `[]` | The ends of the paths of the files that may import the `mint` functions, with the extension. | +| Key | Default | What it does | +| --- | --- | --- | +| `module` | required | The end of the path of the file that declares the type, without the extension: `"src/ids"` or `"ids"`. A package name works too. | +| `type` | required | The name of the type as the module exports it. | +| `mint` | `[]` | Functions of the module that make a value of the type without checking it. | +| `mintedIn` | `[]` | The ends of the paths of the files that may import the `mint` functions, with the extension. | With `mint`, importing or re-exporting one of those functions is reported in every file that is not in `mintedIn`: {/* bad {"filename": "src/routes.ts", "options": [{"branded": [{"module": "src/ids", "type": "UserId", "mint": ["unsafeUserId"], "mintedIn": ["src/db.ts"]}]}]} */} - ```ts import { unsafeUserId } from "./ids"; @@ -85,7 +89,6 @@ const id = unsafeUserId(request.headers.get("x-user") ?? ""); ``` {/* good {"filename": "src/db.ts", "options": [{"branded": [{"module": "src/ids", "type": "UserId", "mint": ["unsafeUserId"], "mintedIn": ["src/db.ts"]}]}]} */} - ```ts import { unsafeUserId } from "./ids"; diff --git a/docs/lint/rules/bun/no-console-window-flash.mdx b/docs/lint/rules/bun/no-console-window-flash.mdx index e16092d2a80f..f44c05f1c3d2 100644 --- a/docs/lint/rules/bun/no-console-window-flash.mdx +++ b/docs/lint/rules/bun/no-console-window-flash.mdx @@ -1,6 +1,15 @@ --- title: "bun/no-console-window-flash" description: "Require `windowsHide` for a child process that does not share a stream with its parent" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- On Windows, a child process that is started from an app without a console gets a console window of its own. The window opens and closes again as the process runs, which users see as a black flash. `windowsHide: true` prevents it, and has no effect on other systems. @@ -12,7 +21,6 @@ The rule looks at `exec`, `execSync`, `execFile`, `execFileSync`, `spawn`, and ` Reported: {/* bad */} - ```js import { execFile } from "node:child_process"; @@ -22,7 +30,6 @@ execFile("git", ["status", "--short"], (error, stdout) => { ``` {/* bad */} - ```js const git = Bun.spawn(["git", "status", "--short"], { stdout: "pipe" }); ``` @@ -30,7 +37,6 @@ const git = Bun.spawn(["git", "status", "--short"], { stdout: "pipe" }); Not reported: {/* good */} - ```js import { execFile } from "node:child_process"; @@ -44,7 +50,6 @@ const git = Bun.spawn(["git", "status", "--short"], { stdout: "pipe", windowsHid A process that inherits a stream writes to the terminal of its parent, so it is not reported. Any value for `windowsHide` counts, because writing `false` is a deliberate choice. Options that are not an object literal in the call, or that spread another object, are not checked: {/* good */} - ```js import { spawn } from "node:child_process"; diff --git a/docs/lint/rules/bun/no-cwd-dependent-path.mdx b/docs/lint/rules/bun/no-cwd-dependent-path.mdx index 252b992842d4..cdb477ab1ab6 100644 --- a/docs/lint/rules/bun/no-cwd-dependent-path.mdx +++ b/docs/lint/rules/bun/no-cwd-dependent-path.mdx @@ -1,6 +1,15 @@ --- title: "bun/no-cwd-dependent-path" description: "Disallow relative paths that depend on the directory the process was started in" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- `readFileSync("config.json")` does not look next to the file that calls it. It looks in the directory that the process was started in. The code works when you run it from the project root and fails with `ENOENT` when it runs from anywhere else. Start from `import.meta.dirname` to name a file next to your source. @@ -16,7 +25,6 @@ The rule checks a string or a template literal that starts with a relative path, Reported: {/* bad */} - ```js import { readFileSync } from "node:fs"; @@ -24,7 +32,6 @@ const config = JSON.parse(readFileSync("config.json", "utf8")); ``` {/* bad {"count": 2} */} - ```js import path from "node:path"; @@ -35,7 +42,6 @@ const output = path.resolve("dist", "index.js"); Not reported: {/* good */} - ```js import { readFileSync } from "node:fs"; import path from "node:path"; @@ -48,7 +54,6 @@ const output = path.resolve(import.meta.dirname, "dist", "index.js"); Absolute paths and paths that come from a variable are fine: {/* good */} - ```js import { readFileSync } from "node:fs"; @@ -59,12 +64,11 @@ readFileSync(`${root}/config.json`, "utf8"); ## Options -| Option | Default | What it does | -| ----------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------- | -| `functions` | `[]` | Names of more functions that take a path as their first argument. A name matches both `loadFixture()` and `helpers.loadFixture()`. | +| Option | Default | What it does | +| --- | --- | --- | +| `functions` | `[]` | Names of more functions that take a path as their first argument. A name matches both `loadFixture()` and `helpers.loadFixture()`. | {/* bad {"count": 2, "options": [{"functions": ["loadFixture"]}]} */} - ```js const user = loadFixture("fixtures/user.json"); const team = helpers.loadFixture("fixtures/team.json"); @@ -75,7 +79,6 @@ const team = helpers.loadFixture("fixtures/team.json"); A command-line tool often means the working directory. It reads the `package.json` of the project it was started in. Say so with `process.cwd()`, or turn the rule off for those files: {/* good */} - ```js import { readFileSync } from "node:fs"; import path from "node:path"; diff --git a/docs/lint/rules/bun/no-eager-dynamic-import.mdx b/docs/lint/rules/bun/no-eager-dynamic-import.mdx index 986fb8629bef..7950f0b855f6 100644 --- a/docs/lint/rules/bun/no-eager-dynamic-import.mdx +++ b/docs/lint/rules/bun/no-eager-dynamic-import.mdx @@ -1,6 +1,15 @@ --- title: "bun/no-eager-dynamic-import" description: "Disallow `import()` that runs as soon as the module is loaded" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- The point of `import()` is to load a module only when it is needed. An `import()` at the top level of a file runs as soon as the file is imported, so the module is always loaded. With `await`, everything that imports the file waits for it too. Move the call into the function that needs the module, or write an `import` statement. @@ -10,7 +19,6 @@ The point of `import()` is to load a module only when it is needed. An `import() Reported: {/* bad */} - ```js const { marked } = await import("marked"); @@ -22,7 +30,6 @@ export function render(text) { Not reported: {/* good */} - ```js export async function render(text) { const { marked } = await import("marked"); @@ -31,7 +38,6 @@ export async function render(text) { ``` {/* good */} - ```js import { marked } from "marked"; @@ -43,7 +49,6 @@ export function render(text) { A function that is called right where it is defined, a `static` field, and a `static` block also run while the file loads: {/* bad {"count": 3} */} - ```js (async () => { const { marked } = await import("marked"); @@ -61,7 +66,6 @@ class Renderer { The `import()` that tells Vitest which module to mock is allowed in `vi.mock()`, `vi.doMock()`, `vi.unmock()`, and `vi.doUnmock()`: {/* good */} - ```js import { vi } from "vitest"; diff --git a/docs/lint/rules/bun/no-eager-native-addon.mdx b/docs/lint/rules/bun/no-eager-native-addon.mdx index 9f33637c7e1e..f7b5eabb184d 100644 --- a/docs/lint/rules/bun/no-eager-native-addon.mdx +++ b/docs/lint/rules/bun/no-eager-native-addon.mdx @@ -1,6 +1,15 @@ --- title: "bun/no-eager-native-addon" description: "Disallow loading a `.node` native addon as soon as the module is loaded" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- A native addon is a shared library. Loading one takes time, and it throws if the file is missing or was built for another platform. If that happens at the top level of a file, everything that imports the file fails to start, even code that never calls the addon. Load it in the function that first needs it. @@ -12,7 +21,6 @@ The rule looks at `import` and `export ... from` statements, `require()`, and `i Reported: {/* bad */} - ```js const native = require("./build/Release/resize.node"); @@ -22,7 +30,6 @@ export function resize(image, width) { ``` {/* bad */} - ```js import native from "./build/Release/resize.node"; ``` @@ -30,7 +37,6 @@ import native from "./build/Release/resize.node"; Not reported: {/* good */} - ```js let native; @@ -43,7 +49,6 @@ export function resize(image, width) { A type-only import loads nothing: {/* good */} - ```ts import type { Resizer } from "./build/Release/resize.node"; ``` diff --git a/docs/lint/rules/bun/no-env-at-module-scope.mdx b/docs/lint/rules/bun/no-env-at-module-scope.mdx index 390de315bd67..ed0b91c86bd3 100644 --- a/docs/lint/rules/bun/no-env-at-module-scope.mdx +++ b/docs/lint/rules/bun/no-env-at-module-scope.mdx @@ -1,6 +1,15 @@ --- title: "bun/no-env-at-module-scope" description: "Disallow reading environment variables as soon as the module is loaded" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- A variable that is read at the top level of a file is read once, when the file is first imported. A test that sets `process.env.API_URL` after the import changes nothing, and neither does code that loads the environment later. What the module does then depends on the order of the imports. Read the variable in the function that needs it. @@ -12,7 +21,6 @@ The rule knows `process.env`, `Bun.env`, `import.meta.env`, and `env` imported f Reported: {/* bad */} - ```js const apiUrl = process.env.API_URL; @@ -22,7 +30,6 @@ export function fetchUser(id) { ``` {/* bad {"count": 3} */} - ```js import { env } from "node:process"; @@ -34,7 +41,6 @@ const port = Number(env.PORT); Not reported: {/* good */} - ```js export function fetchUser(id) { return fetch(`${process.env.API_URL}/users/${id}`); @@ -42,7 +48,6 @@ export function fetchUser(id) { ``` {/* good */} - ```js process.env.TZ = "UTC"; @@ -51,19 +56,17 @@ const isProduction = process.env.NODE_ENV === "production"; ## Options -| Option | Default | What it does | -| ------- | -------------- | ----------------------------------------------------------------------------------- | +| Option | Default | What it does | +| --- | --- | --- | | `allow` | `["NODE_ENV"]` | Names of variables that may be read at the top level. It replaces the default list. | {/* good {"options": [{"allow": ["NODE_ENV", "CI"]}]} */} - ```js const isCI = process.env.CI === "true"; const { NODE_ENV, CI } = process.env; ``` {/* bad {"options": [{"allow": ["CI"]}]} */} - ```js const isProduction = process.env.NODE_ENV === "production"; ``` diff --git a/docs/lint/rules/bun/no-literal-temp-dir.mdx b/docs/lint/rules/bun/no-literal-temp-dir.mdx index d8c8051b31ab..4e98f38f9cea 100644 --- a/docs/lint/rules/bun/no-literal-temp-dir.mdx +++ b/docs/lint/rules/bun/no-literal-temp-dir.mdx @@ -1,6 +1,15 @@ --- title: "bun/no-literal-temp-dir" description: "Disallow hard-coded temporary directories such as `/tmp` in favor of `os.tmpdir()`" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- Windows has no `/tmp`, and on other systems `TMPDIR` can point somewhere else, as it does for every user on macOS. A path that starts with `/tmp` ignores that. `os.tmpdir()` returns the directory that the system wants you to use. @@ -12,7 +21,6 @@ The rule reports a string or a template that is one of the directories or starts Reported: {/* bad {"count": 2} */} - ```js import { writeFileSync } from "node:fs"; @@ -23,7 +31,6 @@ writeFileSync(`/var/tmp/${name}.log`, lines.join("\n")); Not reported: {/* good */} - ```js import { writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -35,19 +42,17 @@ writeFileSync(join(tmpdir(), "report.json"), JSON.stringify(report)); A name that only begins with the same letters is fine: {/* good */} - ```js const template = "/tmpl/email.html"; ``` ## Options -| Option | Default | What it does | -| ------------- | ---------------------- | -------------------------------------------------------- | +| Option | Default | What it does | +| --- | --- | --- | | `directories` | `["/tmp", "/var/tmp"]` | The directories to report. It replaces the default list. | {/* bad {"options": [{"directories": ["/tmp", "/var/tmp", "/private/tmp"]}]} */} - ```js const socket = "/private/tmp/worker.sock"; ``` diff --git a/docs/lint/rules/bun/no-negative-repeat-count.mdx b/docs/lint/rules/bun/no-negative-repeat-count.mdx index 27e9b540f45c..59b9cf24a8d1 100644 --- a/docs/lint/rules/bun/no-negative-repeat-count.mdx +++ b/docs/lint/rules/bun/no-negative-repeat-count.mdx @@ -1,6 +1,15 @@ --- title: "bun/no-negative-repeat-count" description: "Disallow a `repeat()` count that comes from a subtraction and can be negative" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun" +aliases: [] +kind: "problem" +category: null +default: "off" +fixable: "suggestion" +typeAware: false +deprecated: false --- `" ".repeat(width - label.length)` works until a label is longer than `width`. Then the count is negative and `repeat()` throws a `RangeError`. Tests with short labels do not catch it. @@ -14,7 +23,6 @@ The rule suggests wrapping the count in `Math.max(0, ...)`. `--fix-suggestions` Reported: {/* bad */} - ```js function pad(label, width) { return label + " ".repeat(width - label.length); @@ -24,7 +32,6 @@ function pad(label, width) { Rounding does not help, and neither does an upper bound: {/* bad {"count": 2} */} - ```js const left = " ".repeat(Math.floor((width - title.length) / 2)); const bar = "#".repeat(Math.min(total - done, 40)); @@ -33,7 +40,6 @@ const bar = "#".repeat(Math.min(total - done, 40)); Not reported: {/* good */} - ```js function pad(label, width) { return label + " ".repeat(Math.max(0, width - label.length)); @@ -43,7 +49,6 @@ function pad(label, width) { For padding, use `padEnd()` or `padStart()`. They return a string that is already long enough unchanged: {/* good */} - ```js function pad(label, width) { return label.padEnd(width); @@ -53,7 +58,6 @@ function pad(label, width) { Only the expression in the call is looked at. A count that is computed earlier is not followed: {/* good */} - ```js const missing = width - label.length; const padding = " ".repeat(missing); diff --git a/docs/lint/rules/bun/no-redundant-catch-type.mdx b/docs/lint/rules/bun/no-redundant-catch-type.mdx index 3ebb5feec9b5..becd645c149d 100644 --- a/docs/lint/rules/bun/no-redundant-catch-type.mdx +++ b/docs/lint/rules/bun/no-redundant-catch-type.mdx @@ -1,6 +1,15 @@ --- title: "bun/no-redundant-catch-type" description: "Disallow `: unknown` on the variable of a `catch` clause" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "yes" +typeAware: false +deprecated: false --- With `strict` or `useUnknownInCatchVariables` in `tsconfig.json`, the variable of a `catch` clause is already `unknown`. Writing `: unknown` adds nothing. @@ -12,7 +21,6 @@ With `strict` or `useUnknownInCatchVariables` in `tsconfig.json`, the variable o Reported: {/* bad */} - ```ts try { await save(draft); @@ -24,7 +32,6 @@ try { Not reported: {/* good */} - ```ts try { await save(draft); diff --git a/docs/lint/rules/bun/no-restricted-text.mdx b/docs/lint/rules/bun/no-restricted-text.mdx index d3cb7c04f76d..85f65aed7e0a 100644 --- a/docs/lint/rules/bun/no-restricted-text.mdx +++ b/docs/lint/rules/bun/no-restricted-text.mdx @@ -1,6 +1,15 @@ --- title: "bun/no-restricted-text" description: "Disallow text that matches a pattern in strings, templates, JSX text, comments, and regular expressions" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- Some mistakes are in the words, not in the code: an `http://` URL, an old product name, three dots where the design asks for an ellipsis. This rule reports text that matches a regular expression you give it, with your own message. @@ -19,7 +28,6 @@ These examples use this config: Reported: {/* bad {"count": 2, "options": [{"patterns": [{"pattern": "http://", "message": "Use https.", "in": ["strings", "templates"]}]}]} */} - ```js const api = "http://api.example.com"; const avatar = `http://img.example.com/${user.id}.png`; @@ -28,7 +36,6 @@ const avatar = `http://img.example.com/${user.id}.png`; Not reported: {/* good {"options": [{"patterns": [{"pattern": "http://", "message": "Use https.", "in": ["strings", "templates"]}]}]} */} - ```js // This was http:// until the certificate arrived. const api = "https://api.example.com"; @@ -38,25 +45,23 @@ const api = "https://api.example.com"; `patterns` is a list of objects with these keys: -| Key | Default | What it does | -| --------- | -------------------------------- | --------------------------------------------------------------------------------------------- | -| `pattern` | required | The source of a JavaScript regular expression. | -| `flags` | `"u"` | Its flags. | -| `message` | The matched text and the pattern | What the report says. | -| `in` | All five | Where to look: `"strings"`, `"templates"`, `"jsxText"`, `"comments"`, `"regularExpressions"`. | +| Key | Default | What it does | +| --- | --- | --- | +| `pattern` | required | The source of a JavaScript regular expression. | +| `flags` | `"u"` | Its flags. | +| `message` | The matched text and the pattern | What the report says. | +| `in` | All five | Where to look: `"strings"`, `"templates"`, `"jsxText"`, `"comments"`, `"regularExpressions"`. | The pattern sees the text as it is written in the file, so an escape such as `\n` in a string is a backslash and an `n`. A pattern that is not a valid regular expression is an error in the config. Text between JSX tags: {/* bad {"options": [{"patterns": [{"pattern": "\\.\\.\\.", "message": "Use the ellipsis character.", "in": ["jsxText"]}]}]} */} - ```jsx const status =

    Loading...

    ; ``` {/* good {"options": [{"patterns": [{"pattern": "\\.\\.\\.", "message": "Use the ellipsis character.", "in": ["jsxText"]}]}]} */} - ```jsx const status =

    Loading…

    ; ``` @@ -64,7 +69,6 @@ const status =

    Loading…

    ; With the `i` flag, upper and lower case both match: {/* bad {"options": [{"patterns": [{"pattern": "acme corp", "flags": "iu", "in": ["strings"]}]}]} */} - ```js const footer = "Copyright ACME Corp"; ``` diff --git a/docs/lint/rules/bun/no-side-effects-on-import.mdx b/docs/lint/rules/bun/no-side-effects-on-import.mdx index fc7c014e4c83..ed76227dddf4 100644 --- a/docs/lint/rules/bun/no-side-effects-on-import.mdx +++ b/docs/lint/rules/bun/no-side-effects-on-import.mdx @@ -1,6 +1,15 @@ --- title: "bun/no-side-effects-on-import" description: "Disallow top-level statements that call something or assign to a property" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- A file that does something at its top level does it for everyone who imports it. A test that imports one helper from the file also starts the server, and a bundler has to keep the file even if nothing from it is used. Export a function instead, and let the importer decide when to call it. @@ -12,7 +21,6 @@ The rule reports a statement that is not inside a function if it calls something Reported: {/* bad */} - ```js import { createServer } from "./server.js"; @@ -20,7 +28,6 @@ createServer().listen(3000); ``` {/* bad */} - ```js globalThis.appVersion = "1.4.0"; ``` @@ -28,7 +35,6 @@ globalThis.appVersion = "1.4.0"; Not reported: {/* good */} - ```js import { createServer } from "./server.js"; @@ -40,7 +46,6 @@ export function start() { Declarations are not checked, so a module can still build its constants. `require()` and assigning to `module.exports` or `exports` are allowed too: {/* good */} - ```js require("./polyfills.js"); @@ -53,21 +58,19 @@ exports.size = 100; A call in the value that is assigned is still reported: {/* bad */} - ```js module.exports = createApp(); ``` ## Options -| Option | Default | What it does | -| ------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | +| Option | Default | What it does | +| --- | --- | --- | | `allow` | `after`, `afterAll`, `afterEach`, `before`, `beforeAll`, `beforeEach`, `describe`, `expect`, `it`, `jest`, `mock`, `suite`, `test`, `vi` | What may be called at the top level. It replaces the default list. | The default list is what declares tests, hooks, and mocks, so test files pass: {/* good */} - ```js import { expect, mock, test } from "bun:test"; @@ -81,13 +84,11 @@ test.each([1, 2])("is positive", value => { A plain name such as `"test"` allows everything that starts with it: `test()`, `test.each()()`. A name with a dot such as `"customElements.define"` allows only that call: {/* good {"options": [{"allow": ["customElements.define"]}]} */} - ```js customElements.define("user-card", UserCard); ``` {/* bad {"options": [{"allow": ["customElements.define"]}]} */} - ```js customElements.upgrade(document.body); ``` diff --git a/docs/lint/rules/bun/no-unbounded-requests.mdx b/docs/lint/rules/bun/no-unbounded-requests.mdx index a8d008e59187..19fb85585f99 100644 --- a/docs/lint/rules/bun/no-unbounded-requests.mdx +++ b/docs/lint/rules/bun/no-unbounded-requests.mdx @@ -1,6 +1,15 @@ --- title: "bun/no-unbounded-requests" description: "Require an axios request to have a `timeout` or a `signal`" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun" +aliases: [] +kind: "problem" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- By default axios has no timeout. If the server accepts the connection and never answers, the promise never settles, and whatever awaits it hangs. Set `timeout`, or pass a `signal` that ends the request. @@ -12,7 +21,6 @@ The rule looks at `axios()`, `axios.request()`, `axios.create()`, and the method Reported: {/* bad {"count": 3} */} - ```js import axios from "axios"; @@ -24,7 +32,6 @@ const api = axios.create({ baseURL: "https://api.example.com", timeout: 0 }); Not reported: {/* good */} - ```js import axios from "axios"; @@ -36,7 +43,6 @@ const api = axios.create({ baseURL: "https://api.example.com", timeout: 5000 }); A file that sets a default timeout is not checked: {/* good */} - ```js import axios from "axios"; @@ -48,7 +54,6 @@ const user = await axios.get(`/users/${id}`); A config with `cancelToken` passes too. A config that is a variable, or that spreads another object, is not checked, because the rule cannot see what is in it. For an instance, the `axios.create()` call is checked and the requests made with it are not. {/* good */} - ```js import axios from "axios"; @@ -61,9 +66,9 @@ await api.get("/users"); ## Options -| Option | Default | What it does | -| --------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `clients` | `[]` | More modules that export something you call like `axios`. Each entry is `{ module, names }`: the import specifier and the names of the exports. Without `names`, the default export is the client. | +| Option | Default | What it does | +| --- | --- | --- | +| `clients` | `[]` | More modules that export something you call like `axios`. Each entry is `{ module, names }`: the import specifier and the names of the exports. Without `names`, the default export is the client. | ```toml bunfig.toml [lint.rules] @@ -71,7 +76,6 @@ await api.get("/users"); ``` {/* bad {"options": [{"clients": [{"module": "./http.js", "names": ["api"]}]}]} */} - ```js import { api } from "./http.js"; @@ -79,7 +83,6 @@ const users = await api.get("/users"); ``` {/* bad {"options": [{"clients": [{"module": "./http.js"}]}]} */} - ```js import api from "./http.js"; diff --git a/docs/lint/rules/bun/no-unhandled-emitter-errors.mdx b/docs/lint/rules/bun/no-unhandled-emitter-errors.mdx index 3b18b2022e93..8fa994d2587b 100644 --- a/docs/lint/rules/bun/no-unhandled-emitter-errors.mdx +++ b/docs/lint/rules/bun/no-unhandled-emitter-errors.mdx @@ -1,27 +1,35 @@ --- title: "bun/no-unhandled-emitter-errors" description: "Require an `error` listener on event emitters that Node.js functions return" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun" +aliases: [] +kind: "problem" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- When an event emitter emits `error` and nobody listens for it, the error is thrown instead. A `try` around the call does not catch it, because it happens later. So `spawn("gti")` with a typo, or `createReadStream()` on a file that is gone, ends the whole process. The rule looks at what these functions return: -| Module | Functions | -| ------------------------- | ------------------------------------------------ | -| `node:child_process` | `spawn`, `fork` | -| `node:cluster` | `fork` | -| `node:fs` | `createReadStream`, `createWriteStream`, `watch` | -| `node:http`, `node:https` | `request`, `get` | -| `node:http2`, `node:tls` | `connect` | -| `node:net` | `connect`, `createConnection` | +| Module | Functions | +| --- | --- | +| `node:child_process` | `spawn`, `fork` | +| `node:cluster` | `fork` | +| `node:fs` | `createReadStream`, `createWriteStream`, `watch` | +| `node:http`, `node:https` | `request`, `get` | +| `node:http2`, `node:tls` | `connect` | +| `node:net` | `connect`, `createConnection` | ## Examples Reported: {/* bad */} - ```js import { spawn } from "node:child_process"; @@ -33,7 +41,6 @@ child.on("exit", done); `pipe()` does not pass errors on, so the source still needs a listener: {/* bad */} - ```js import { createReadStream } from "node:fs"; @@ -43,7 +50,6 @@ createReadStream(file).pipe(response); Not reported: {/* good */} - ```js import { spawn } from "node:child_process"; @@ -54,7 +60,6 @@ child.on("error", fail); ``` {/* good */} - ```js import { createReadStream } from "node:fs"; @@ -64,7 +69,6 @@ createReadStream(file).on("error", fail).pipe(response); An emitter that is returned or passed to another function is not reported, because the listener may be added there. `pipeline()` handles the errors of its streams: {/* good */} - ```js import { createReadStream } from "node:fs"; import { pipeline } from "node:stream/promises"; @@ -78,9 +82,9 @@ export function open(path) { ## Options -| Option | Default | What it does | -| ----------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `factories` | `[]` | More functions that return an emitter, checked in addition to the table above. Each entry is `{ module, names }`: the import specifier and the names of the functions. Without `names`, the default export is the function. | +| Option | Default | What it does | +| --- | --- | --- | +| `factories` | `[]` | More functions that return an emitter, checked in addition to the table above. Each entry is `{ module, names }`: the import specifier and the names of the functions. Without `names`, the default export is the function. | ```toml bunfig.toml [lint.rules] @@ -88,7 +92,6 @@ export function open(path) { ``` {/* bad {"options": [{"factories": [{"module": "ws", "names": ["createWebSocketStream"]}]}]} */} - ```js import { createWebSocketStream } from "ws"; diff --git a/docs/lint/rules/bun/no-unportable-commands.mdx b/docs/lint/rules/bun/no-unportable-commands.mdx index 0fa381366662..e80dacb4e21c 100644 --- a/docs/lint/rules/bun/no-unportable-commands.mdx +++ b/docs/lint/rules/bun/no-unportable-commands.mdx @@ -1,6 +1,15 @@ --- title: "bun/no-unportable-commands" description: "Disallow starting a process with a command that does not work the same on every system" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- A command that works on macOS and Linux can fail on Windows: @@ -16,7 +25,6 @@ The rule checks `exec`, `spawn` and `execFile` from `node:child_process`, their Reported: {/* bad {"count": 3} */} - ```js import { exec, spawn } from "node:child_process"; @@ -26,13 +34,11 @@ exec("bun run build && bun run deploy"); ``` {/* bad */} - ```js Bun.spawn(["cat", "notes.txt"]); ``` {/* bad */} - ```js const listProcesses = "ps -ef"; ``` @@ -40,7 +46,6 @@ const listProcesses = "ps -ef"; Not reported: {/* good */} - ```js import { spawn } from "node:child_process"; import { rm } from "node:fs/promises"; @@ -52,7 +57,6 @@ await rm("dist", { recursive: true, force: true }); Bun Shell has its own `rm`, `ls`, `cat`, `&&` and `|`, which work the same on every system: {/* good */} - ```js import { $ } from "bun"; @@ -62,16 +66,15 @@ await $`bun run build && bun run deploy`; ## Options -| Option | Default | What it does | -| ----------- | ----------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | -| `scripts` | `["npm", "npx", "pnpm", "pnpx", "yarn"]` | Commands that are `.cmd` scripts on Windows. Reported when they are started without a shell. | -| `posixOnly` | `awk`, `cat`, `chmod`, `cp`, `grep`, `kill`, `ls`, `mv`, `rm`, `sed`, `sh`, `touch` and 21 more | Commands that Windows does not have. Reported with or without a shell. | -| `launchers` | `[]` | Names of your own functions that start a process. Their first argument is checked like the first argument of `spawn`. | +| Option | Default | What it does | +| --- | --- | --- | +| `scripts` | `["npm", "npx", "pnpm", "pnpx", "yarn"]` | Commands that are `.cmd` scripts on Windows. Reported when they are started without a shell. | +| `posixOnly` | `awk`, `cat`, `chmod`, `cp`, `grep`, `kill`, `ls`, `mv`, `rm`, `sed`, `sh`, `touch` and 21 more | Commands that Windows does not have. Reported with or without a shell. | +| `launchers` | `[]` | Names of your own functions that start a process. Their first argument is checked like the first argument of `spawn`. | Setting a list replaces the default, so repeat the defaults you want to keep. {/* bad {"options": [{"scripts": ["npm", "tsc"]}]} */} - ```js import { spawnSync } from "node:child_process"; @@ -79,7 +82,6 @@ spawnSync("tsc", ["--noEmit"]); ``` {/* bad {"options": [{"launchers": ["run"]}]} */} - ```js await run("grep", ["-r", "TODO", "src"]); ``` diff --git a/docs/lint/rules/bun/no-unseeded-random-in-property-test.mdx b/docs/lint/rules/bun/no-unseeded-random-in-property-test.mdx index 72c28cbfab76..d7f13fb06460 100644 --- a/docs/lint/rules/bun/no-unseeded-random-in-property-test.mdx +++ b/docs/lint/rules/bun/no-unseeded-random-in-property-test.mdx @@ -1,6 +1,15 @@ --- title: "bun/no-unseeded-random-in-property-test" description: "Disallow `Math.random()` and `fc.sample()` inside a fast-check property" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun" +aliases: [] +kind: "problem" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- fast-check draws every input of a property from one seed. When a property fails, it prints the seed so you can run the same inputs again, and it shrinks the inputs to the smallest ones that still fail. @@ -12,7 +21,6 @@ A value from `Math.random()` or `fc.sample()` inside the function you pass to `f Reported: {/* bad */} - ```ts import fc from "fast-check"; import { pad } from "./text"; @@ -26,7 +34,6 @@ fc.assert( ``` {/* bad */} - ```ts import { assert, integer, property, sample, string } from "fast-check"; import { pad } from "./text"; @@ -42,7 +49,6 @@ assert( Not reported: {/* good */} - ```ts import fc from "fast-check"; import { pad } from "./text"; @@ -57,7 +63,6 @@ fc.assert( `fc.sample()` outside a property is fine: {/* good */} - ```ts import fc from "fast-check"; diff --git a/docs/lint/rules/bun/no-useless-spread-fallback.mdx b/docs/lint/rules/bun/no-useless-spread-fallback.mdx index 42572574e860..90a7fa5c20cf 100644 --- a/docs/lint/rules/bun/no-useless-spread-fallback.mdx +++ b/docs/lint/rules/bun/no-useless-spread-fallback.mdx @@ -1,6 +1,15 @@ --- title: "bun/no-useless-spread-fallback" description: "Disallow an empty object as the fallback in `...(condition ? value : {})`" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "suggestion" +typeAware: false +deprecated: false --- Spreading `false`, `null` or `undefined` into an object does nothing. So the `{}` in `...(condition ? { a } : {})` is not needed, and `...(condition && { a })` does the same with less to read. @@ -14,7 +23,6 @@ The rule checks spreads in object literals and in JSX attributes. Reported: {/* bad */} - ```js const headers = { accept: "application/json", @@ -23,19 +31,13 @@ const headers = { ``` {/* bad */} - ```jsx -const link = ( -
    - {label} - -); +const link = {label}; ``` Not reported: {/* good */} - ```js const headers = { accept: "application/json", @@ -46,27 +48,24 @@ const headers = { An empty object in the first branch is reported too, without a suggestion: {/* bad */} - ```js const query = { page, ...(showAll ? {} : { limit: 20 }) }; ``` {/* good */} - ```js const query = { page, ...(!showAll && { limit: 20 }) }; ``` ## Options -| Option | Default | What it does | -| -------------------------- | ------- | ------------------------------------------------------------------------------------- | -| `ignoreConditionsThatCall` | `[]` | Names of functions. A conditional is not reported if its condition calls one of them. | +| Option | Default | What it does | +| --- | --- | --- | +| `ignoreConditionsThatCall` | `[]` | Names of functions. A conditional is not reported if its condition calls one of them. | Use it for a function that a build tool only understands as the condition of `? :`, such as a feature flag: {/* good {"options": [{"ignoreConditionsThatCall": ["feature"]}]} */} - ```js const routes = { home, diff --git a/docs/lint/rules/bun/prefer-builtin-sleep.mdx b/docs/lint/rules/bun/prefer-builtin-sleep.mdx index 55f0bc164cec..105f9c8ecb66 100644 --- a/docs/lint/rules/bun/prefer-builtin-sleep.mdx +++ b/docs/lint/rules/bun/prefer-builtin-sleep.mdx @@ -1,6 +1,15 @@ --- title: "bun/prefer-builtin-sleep" description: "Prefer `Bun.sleep()` to a promise that only waits for `setTimeout`" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "suggestion" +typeAware: false +deprecated: false --- `new Promise(resolve => setTimeout(resolve, ms))` takes a moment to read, and all it does is wait. `Bun.sleep(ms)` does the same and says so. `setTimeout(ms)` from `node:timers/promises` does too. @@ -12,13 +21,11 @@ description: "Prefer `Bun.sleep()` to a promise that only waits for `setTimeout` Reported: {/* bad */} - ```js await new Promise(resolve => setTimeout(resolve, 500)); ``` {/* bad */} - ```js function wait(ms) { return new Promise(resolve => { @@ -30,13 +37,11 @@ function wait(ms) { Not reported: {/* good */} - ```js await Bun.sleep(500); ``` {/* good */} - ```js import { setTimeout } from "node:timers/promises"; @@ -46,7 +51,6 @@ await setTimeout(500); A promise that does more than wait is not reported. This one resolves with a value: {/* good */} - ```js const answer = await new Promise(resolve => setTimeout(() => resolve("timed out"), 500)); ``` diff --git a/docs/lint/rules/bun/prefer-lazy-schema.mdx b/docs/lint/rules/bun/prefer-lazy-schema.mdx index d2dad522ff2d..43519d1c8b0a 100644 --- a/docs/lint/rules/bun/prefer-lazy-schema.mdx +++ b/docs/lint/rules/bun/prefer-lazy-schema.mdx @@ -1,6 +1,15 @@ --- title: "bun/prefer-lazy-schema" description: "Disallow building a validation schema while the module loads" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- A Zod schema at the top level of a module is built as soon as the module is imported, even if nothing is ever validated with it. With hundreds of schemas, that makes every start slower. @@ -12,7 +21,6 @@ The rule reports a chain of calls that starts with `z`, where `z` is imported or Reported: {/* bad {"count": 1} */} - ```ts import { z } from "zod"; @@ -25,7 +33,6 @@ export const User = z.object({ Not reported: {/* good */} - ```ts import { z } from "zod"; @@ -42,7 +49,6 @@ export function userSchema() { `z.lazy()` builds nothing until it is used, so it is allowed: {/* good */} - ```ts import { z } from "zod"; @@ -51,15 +57,14 @@ export const Comment = z.lazy(() => z.object({ text: z.string() })); ## Options -| Option | Default | What it does | -| --------------- | ------- | ------------------------------------------------------------------------------------------------- | -| `roots` | `["z"]` | The names that a schema starts with, such as the `z` of `z.object()`. | -| `methods` | `[]` | Methods that build a schema, whatever they are called on, such as `extend`. | -| `factorySuffix` | none | Also report calls to functions with a name that ends in this string. | -| `lazyWrapper` | none | The name of your helper that builds a schema on first use. The message then shows how to call it. | +| Option | Default | What it does | +| --- | --- | --- | +| `roots` | `["z"]` | The names that a schema starts with, such as the `z` of `z.object()`. | +| `methods` | `[]` | Methods that build a schema, whatever they are called on, such as `extend`. | +| `factorySuffix` | none | Also report calls to functions with a name that ends in this string. | +| `lazyWrapper` | none | The name of your helper that builds a schema on first use. The message then shows how to call it. | {/* bad {"options": [{"roots": ["v"]}]} */} - ```ts import * as v from "valibot"; @@ -67,7 +72,6 @@ export const Email = v.pipe(v.string(), v.email()); ``` {/* bad {"options": [{"methods": ["extend"]}]} */} - ```ts import { User } from "./user"; @@ -75,7 +79,6 @@ export const Admin = User.extend({ level: 1 }); ``` {/* bad {"options": [{"factorySuffix": "Schema"}]} */} - ```ts import { createUserSchema } from "./user"; @@ -85,7 +88,6 @@ export const User = createUserSchema(); `lazyWrapper` does not change what is reported. A schema in a function that you pass to a helper is allowed with or without it: {/* good {"options": [{"lazyWrapper": "once"}]} */} - ```ts import { z } from "zod"; import { once } from "./once"; diff --git a/docs/lint/rules/bun/prefer-local-module.mdx b/docs/lint/rules/bun/prefer-local-module.mdx index 0d9a37e98a97..208527bd0b83 100644 --- a/docs/lint/rules/bun/prefer-local-module.mdx +++ b/docs/lint/rules/bun/prefer-local-module.mdx @@ -1,6 +1,15 @@ --- title: "bun/prefer-local-module" description: "Require importing a package through the module in your project that wraps it" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "yes" +typeAware: false +deprecated: false --- A project often wraps a package in a module of its own, for example to give an HTTP client a base URL, a timeout and logging. A file that imports the package directly skips all of that, and nothing tells you. @@ -21,7 +30,6 @@ These examples use this configuration: Reported: {/* bad {"options": [{"modules": [{"package": "axios", "use": "@/http"}]}]} */} - ```ts import axios from "axios"; @@ -31,7 +39,6 @@ const users = await axios.get("/users"); Not reported: {/* good {"options": [{"modules": [{"package": "axios", "use": "@/http"}]}]} */} - ```ts import axios from "@/http"; @@ -40,26 +47,25 @@ const users = await axios.get("/users"); ## Options -| Option | Default | What it does | -| ------------- | ------- | -------------------------------------------------------------------------- | -| `modules` | `[]` | The wrapped packages. See below. | -| `exemptFiles` | `[]` | Files that may import the packages directly. List the wrapper itself here. | +| Option | Default | What it does | +| --- | --- | --- | +| `modules` | `[]` | The wrapped packages. See below. | +| `exemptFiles` | `[]` | Files that may import the packages directly. List the wrapper itself here. | Each entry of `modules` has: -| Key | What it is | -| --------- | -------------------------------------------------------------------------------- | -| `package` | The package that must not be imported directly. Required. | -| `use` | The specifier to import instead. Required. | -| `default` | The name under which your module exports the default export of the package. | -| `names` | Exports that have another name in your module: `{ "isCancel": "wasCancelled" }`. | +| Key | What it is | +| --- | --- | +| `package` | The package that must not be imported directly. Required. | +| `use` | The specifier to import instead. Required. | +| `default` | The name under which your module exports the default export of the package. | +| `names` | Exports that have another name in your module: `{ "isCancel": "wasCancelled" }`. | `default` and `names` only matter for `--fix`. With `default` set to `"http"`, `import axios from "axios"` becomes `import { http as axios } from "@/http"`. If either is set, only `import` statements are fixed, and not `import * as` or `import type`. An entry of `exemptFiles` is the end of a path, in whole names. `http.ts` matches `src/http.ts` but not `src/other-http.ts`. {/* good {"filename": "src/http.ts", "options": [{"modules": [{"package": "axios", "use": "@/http"}], "exemptFiles": ["src/http.ts"]}]} */} - ```ts import axios from "axios"; diff --git a/docs/lint/rules/bun/prefer-timeout-signal.mdx b/docs/lint/rules/bun/prefer-timeout-signal.mdx index 089324fd5aad..f56308051491 100644 --- a/docs/lint/rules/bun/prefer-timeout-signal.mdx +++ b/docs/lint/rules/bun/prefer-timeout-signal.mdx @@ -1,6 +1,15 @@ --- title: "bun/prefer-timeout-signal" description: "Prefer `AbortSignal.timeout()` to an `AbortController` that only a timer aborts" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- `AbortSignal.timeout(ms)` does in one call what an `AbortController` and a `setTimeout` that aborts it do together. Its signal also aborts with a `TimeoutError` instead of an `AbortError`, so the code that catches the error can tell a timeout from a cancellation. @@ -12,7 +21,6 @@ A controller is reported if one `setTimeout(() => controller.abort(), ms)` is th Reported: {/* bad */} - ```js const controller = new AbortController(); setTimeout(() => controller.abort(), 5000); @@ -23,7 +31,6 @@ const response = await fetch(url, { signal: controller.signal }); Not reported: {/* good */} - ```js const response = await fetch(url, { signal: AbortSignal.timeout(5000) }); ``` @@ -31,7 +38,6 @@ const response = await fetch(url, { signal: AbortSignal.timeout(5000) }); A controller that something else can abort too is not reported: {/* good */} - ```js const controller = new AbortController(); setTimeout(() => controller.abort(), 5000); @@ -43,7 +49,6 @@ const response = await fetch(url, { signal: controller.signal }); Neither is one that aborts with a reason of its own: {/* good */} - ```js const controller = new AbortController(); setTimeout(() => controller.abort(new Error("The upload took too long")), 5000); @@ -53,12 +58,11 @@ await upload(file, { signal: controller.signal }); ## Options -| Option | Default | What it does | -| ----------- | ------- | ----------------------------------------------------------------------------------------------------------------------- | -| `factories` | `[]` | Names of your own functions that return an `AbortController`. What they return is checked like `new AbortController()`. | +| Option | Default | What it does | +| --- | --- | --- | +| `factories` | `[]` | Names of your own functions that return an `AbortController`. What they return is checked like `new AbortController()`. | {/* bad {"options": [{"factories": ["createController"]}]} */} - ```js const controller = createController(); setTimeout(() => controller.abort(), 5000); diff --git a/docs/lint/rules/bun/prefer-url-for-sibling-files.mdx b/docs/lint/rules/bun/prefer-url-for-sibling-files.mdx index 0a3613cae947..307955923bad 100644 --- a/docs/lint/rules/bun/prefer-url-for-sibling-files.mdx +++ b/docs/lint/rules/bun/prefer-url-for-sibling-files.mdx @@ -1,6 +1,15 @@ --- title: "bun/prefer-url-for-sibling-files" -description: 'Prefer `new URL("./file", import.meta.url)` to a path joined onto the module''s directory' +description: "Prefer `new URL(\"./file\", import.meta.url)` to a path joined onto the module's directory" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- Bundlers such as Vite and webpack recognize `new URL("./file", import.meta.url)`. They copy the file to the output and point the URL at the copy. A path that is joined onto `import.meta.dirname` or `__dirname` is only a string to them, so after bundling it names a file that is not there. @@ -12,7 +21,6 @@ The rule reports `join()` and `resolve()` from `node:path`, `+` and template lit Reported: {/* bad */} - ```js import { join } from "node:path"; @@ -20,7 +28,6 @@ const schema = await Bun.file(join(import.meta.dirname, "schema.sql")).text(); ``` {/* bad {"count": 2} */} - ```js import { readFileSync } from "node:fs"; @@ -29,7 +36,6 @@ const icon = readFileSync(import.meta.dirname + "/assets/icon.svg"); ``` {/* bad */} - ```js import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -41,7 +47,6 @@ export const template = resolve(folder, "templates", "email.html"); Not reported: {/* good */} - ```js const schema = await Bun.file(new URL("./schema.sql", import.meta.url)).text(); ``` @@ -49,7 +54,6 @@ const schema = await Bun.file(new URL("./schema.sql", import.meta.url)).text(); A path with a part that is only known at run time, or one that ends in `.`, `..` or `/`, is not reported: {/* good */} - ```js import { join } from "node:path"; @@ -60,7 +64,6 @@ export const root = join(import.meta.dirname, ".."); CommonJS files are not checked, because they have no `import.meta.url`: {/* good {"filename": "load.cjs"} */} - ```js const { join } = require("node:path"); diff --git a/docs/lint/rules/bun/react-no-latest-value-ref.mdx b/docs/lint/rules/bun/react-no-latest-value-ref.mdx index 46a104a7078a..47ca912c935e 100644 --- a/docs/lint/rules/bun/react-no-latest-value-ref.mdx +++ b/docs/lint/rules/bun/react-no-latest-value-ref.mdx @@ -1,6 +1,15 @@ --- title: "bun/react-no-latest-value-ref" description: "Disallow a ref that is set to the latest value on every render" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun" +aliases: [] +kind: "problem" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- A common trick keeps the latest props or state in a ref, so that an effect can read them without listing them as dependencies. Setting `ref.current` while the component renders is not safe. React can render a component more than once, or throw the result away, so the ref can hold a value from a render that never reached the screen. @@ -12,7 +21,6 @@ An effect that only copies the value into the ref is safe, but it is reported to Reported: {/* bad */} - ```jsx import { useRef } from "react"; @@ -24,7 +32,6 @@ function Chat({ onMessage }) { ``` {/* bad */} - ```jsx import { useEffect, useRef } from "react"; @@ -40,7 +47,6 @@ function Chat({ onMessage }) { Not reported: {/* good */} - ```jsx import { useEffect, useEffectEvent } from "react"; @@ -59,7 +65,6 @@ function Chat({ room, onMessage }) { A ref that is set under a condition, in an event handler, or in an effect that also does something else is not reported: {/* good */} - ```jsx import { useEffect, useRef } from "react"; diff --git a/docs/lint/rules/bun/react-no-local-render-helpers.mdx b/docs/lint/rules/bun/react-no-local-render-helpers.mdx index aa362ae403e3..2243c48a4e8d 100644 --- a/docs/lint/rules/bun/react-no-local-render-helpers.mdx +++ b/docs/lint/rules/bun/react-no-local-render-helpers.mdx @@ -1,6 +1,15 @@ --- title: "bun/react-no-local-render-helpers" description: "Disallow `renderItem()` functions that are defined inside a component and used in its JSX" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- A `renderItem()` function inside a component does the job of a component, but React does not see it as one. It is created again on every render, it does not show up in React DevTools, and React cannot skip it when its input stays the same. Move it out and make it a component. @@ -12,7 +21,6 @@ The rule reports a function if its name is `render` followed by a capital letter Reported: {/* bad */} - ```jsx function UserList({ users }) { const renderUser = user =>
  • {user.name}
  • ; @@ -22,7 +30,6 @@ function UserList({ users }) { ``` {/* bad */} - ```jsx function Page({ title }) { function renderHeader() { @@ -36,7 +43,6 @@ function Page({ title }) { Not reported: {/* good */} - ```jsx function User({ user }) { return
  • {user.name}
  • ; @@ -56,7 +62,6 @@ function UserList({ users }) { A function that is passed as a prop is not reported: {/* good */} - ```jsx function UserList({ users }) { const renderUser = user => ; @@ -67,12 +72,11 @@ function UserList({ users }) { ## Options -| Option | Default | What it does | -| --------- | ------- | ---------------------------------------------------------------------------------------- | -| `pattern` | none | A regular expression for the names to report, in place of `render` and a capital letter. | +| Option | Default | What it does | +| --- | --- | --- | +| `pattern` | none | A regular expression for the names to report, in place of `render` and a capital letter. | {/* bad {"options": [{"pattern": "^(render|draw)[A-Z]"}]} */} - ```jsx function Chart({ points }) { const drawPoint = point => ; diff --git a/docs/lint/rules/bun/react-no-namespace-hooks.mdx b/docs/lint/rules/bun/react-no-namespace-hooks.mdx index 623dd11c59d0..99341aedb6c2 100644 --- a/docs/lint/rules/bun/react-no-namespace-hooks.mdx +++ b/docs/lint/rules/bun/react-no-namespace-hooks.mdx @@ -1,6 +1,15 @@ --- title: "bun/react-no-namespace-hooks" description: "Require React hooks to be imported by name: `useState()`, not `React.useState()`" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "yes" +typeAware: false +deprecated: false --- `React.useState()` and `useState()` are the same function. With both spellings in one project, the same code looks different from file to file. This rule keeps the short one. @@ -14,7 +23,6 @@ It checks `React` from `import React from "react"` and from `import * as React f Reported: {/* bad {"count": 2} */} - ```jsx import React from "react"; @@ -31,7 +39,6 @@ function Counter() { Not reported: {/* good */} - ```jsx import { useEffect, useState } from "react"; @@ -48,7 +55,6 @@ function Counter() { Only hooks are reported. Other members of `React` are allowed: {/* good */} - ```jsx import * as React from "react"; diff --git a/docs/lint/rules/bun/react-prefer-updater-function.mdx b/docs/lint/rules/bun/react-prefer-updater-function.mdx index a7e732376ffd..359bd4d815ad 100644 --- a/docs/lint/rules/bun/react-prefer-updater-function.mdx +++ b/docs/lint/rules/bun/react-prefer-updater-function.mdx @@ -1,6 +1,15 @@ --- title: "bun/react-prefer-updater-function" description: "Prefer `setItems(previous => ..)` to a new array or object made from the state of the last render" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "bun" +aliases: [] +kind: "suggestion" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- The `items` of `const [items, setItems] = useState([])` is the state as of the last render. If `setItems([...items, item])` runs after an `await`, in a timer, or twice before the next render, `items` is out of date and the other update is lost. A function gets the current state: `setItems(previous => [...previous, item])`. @@ -12,7 +21,6 @@ The rule reports a setter that is called with an array or object literal that sp Reported: {/* bad */} - ```jsx import { useState } from "react"; @@ -28,7 +36,6 @@ function Cart() { ``` {/* bad */} - ```jsx import { useState } from "react"; @@ -42,7 +49,6 @@ function Profile() { Not reported: {/* good */} - ```jsx import { useState } from "react"; @@ -60,7 +66,6 @@ function Cart() { If neither name comes from a `useState()` in the file, for example because both are props, the names decide. `setRows` is taken for the setter of `rows`: {/* bad */} - ```jsx function AddRow({ rows, setRows }) { return ; @@ -70,7 +75,6 @@ function AddRow({ rows, setRows }) { Spreading something else is fine: {/* good */} - ```jsx import { useState } from "react"; diff --git a/docs/lint/rules/eslint/accessor-pairs.mdx b/docs/lint/rules/eslint/accessor-pairs.mdx index 82e912ec9b0d..3ba67c53a21b 100644 --- a/docs/lint/rules/eslint/accessor-pairs.mdx +++ b/docs/lint/rules/eslint/accessor-pairs.mdx @@ -1,6 +1,15 @@ --- title: "accessor-pairs" description: "Require a getter for every setter in objects and classes" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/accessor-pairs"] +kind: "suggestion" +category: "pedantic" +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- A property that has a setter and no getter can be written but never read: `user.name = "Ada"` works, and `user.name` is `undefined`. Most of the time the getter was forgotten. @@ -12,7 +21,6 @@ The rule looks at object literals, classes, and the descriptors passed to `Objec Reported: {/* bad */} - ```js const user = { set name(value) { @@ -22,7 +30,6 @@ const user = { ``` {/* bad */} - ```js Object.defineProperty(user, "name", { set(value) { @@ -34,7 +41,6 @@ Object.defineProperty(user, "name", { Not reported: {/* good */} - ```js const user = { get name() { @@ -54,15 +60,14 @@ const config = { ## Options -| Option | Default | What it does | -| ------------------------ | ------- | ------------------------------------------------------------------- | -| `setWithoutGet` | `true` | Report a setter that has no getter. | -| `getWithoutSet` | `false` | Report a getter that has no setter. | -| `enforceForClassMembers` | `true` | Also check classes. A static accessor only pairs with a static one. | -| `enforceForTSTypes` | `false` | Also check TypeScript interfaces and object types. | +| Option | Default | What it does | +| --- | --- | --- | +| `setWithoutGet` | `true` | Report a setter that has no getter. | +| `getWithoutSet` | `false` | Report a getter that has no setter. | +| `enforceForClassMembers` | `true` | Also check classes. A static accessor only pairs with a static one. | +| `enforceForTSTypes` | `false` | Also check TypeScript interfaces and object types. | {/* bad {"options": [{"getWithoutSet": true}]} */} - ```js const config = { get version() { @@ -72,7 +77,6 @@ const config = { ``` {/* good {"options": [{"enforceForClassMembers": false}]} */} - ```js class User { set name(value) { @@ -82,7 +86,6 @@ class User { ``` {/* bad {"options": [{"enforceForTSTypes": true}]} */} - ```ts interface User { set name(value: string); diff --git a/docs/lint/rules/eslint/array-callback-return.mdx b/docs/lint/rules/eslint/array-callback-return.mdx index fd190f84c671..bf26a5d03b24 100644 --- a/docs/lint/rules/eslint/array-callback-return.mdx +++ b/docs/lint/rules/eslint/array-callback-return.mdx @@ -1,6 +1,15 @@ --- title: "array-callback-return" description: "Require callbacks of array methods such as `map` and `filter` to return a value" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/array-callback-return"] +kind: "problem" +category: "pedantic" +default: "off" +fixable: "suggestion" +typeAware: false +deprecated: false --- Methods like `map` and `filter` use what the callback returns. If the callback forgets `return`, they get `undefined`: `map` fills the array with `undefined`, and `filter` drops every item. A callback that returns a value on some paths only is reported too. @@ -12,7 +21,6 @@ The rule checks a function passed directly to `every`, `filter`, `find`, `findIn Reported: {/* bad */} - ```js const names = users.map(user => { user.name.trim(); @@ -20,7 +28,6 @@ const names = users.map(user => { ``` {/* bad */} - ```js const adults = users.filter(user => { if (user.age >= 18) { @@ -32,7 +39,6 @@ const adults = users.filter(user => { Not reported: {/* good */} - ```js const names = users.map(user => { return user.name.trim(); @@ -43,14 +49,13 @@ const adults = users.filter(user => user.age >= 18); ## Options -| Option | Default | What it does | -| --------------- | ------- | ----------------------------------------------------------------------------------------------------------------- | -| `allowImplicit` | `false` | Allow `return;` without a value as a way to return `undefined`. | -| `checkForEach` | `false` | Also check `forEach`, the other way around: report a callback that returns a value, because `forEach` ignores it. | -| `allowVoid` | `false` | With `checkForEach`, allow a returned value that starts with `void`. | +| Option | Default | What it does | +| --- | --- | --- | +| `allowImplicit` | `false` | Allow `return;` without a value as a way to return `undefined`. | +| `checkForEach` | `false` | Also check `forEach`, the other way around: report a callback that returns a value, because `forEach` ignores it. | +| `allowVoid` | `false` | With `checkForEach`, allow a returned value that starts with `void`. | {/* good {"options": [{"allowImplicit": true}]} */} - ```js const first = users.find(user => { if (!user.active) { @@ -61,13 +66,11 @@ const first = users.find(user => { ``` {/* bad {"options": [{"checkForEach": true}]} */} - ```js users.forEach(user => save(user)); ``` {/* good {"options": [{"checkForEach": true}]} */} - ```js users.forEach(user => { save(user); @@ -75,7 +78,6 @@ users.forEach(user => { ``` {/* good {"options": [{"checkForEach": true, "allowVoid": true}]} */} - ```js users.forEach(user => void save(user)); ``` diff --git a/docs/lint/rules/eslint/arrow-body-style.mdx b/docs/lint/rules/eslint/arrow-body-style.mdx index 2ae2107d2cc7..a5f5670bc394 100644 --- a/docs/lint/rules/eslint/arrow-body-style.mdx +++ b/docs/lint/rules/eslint/arrow-body-style.mdx @@ -1,6 +1,15 @@ --- title: "arrow-body-style" description: "Enforce one style for the braces around arrow function bodies" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/arrow-body-style"] +kind: "suggestion" +category: "style" +default: "off" +fixable: "yes" +typeAware: false +deprecated: false --- `x => x * 2` and `x => { return x * 2; }` do the same thing. By default, this rule reports the braces when the body is nothing but one `return`, so short functions stay short. @@ -12,7 +21,6 @@ description: "Enforce one style for the braces around arrow function bodies" Reported: {/* bad */} - ```js const double = items.map(item => { return item * 2; @@ -22,7 +30,6 @@ const double = items.map(item => { Not reported: {/* good */} - ```js const double = items.map(item => item * 2); @@ -39,20 +46,18 @@ const logged = items.map(item => { arrow-body-style = ["error", "always"] ``` -| Option | What it does | -| ----------------------- | --------------------------------------------------------------------------------------------------------------------- | -| `"as-needed"` (default) | Report braces around a body that is only a `return`. | -| `"always"` | Report every arrow function without braces. | -| `"never"` | Report every arrow function with braces, whatever is in them. `--fix` only rewrites a body that is a single `return`. | +| Option | What it does | +| --- | --- | +| `"as-needed"` (default) | Report braces around a body that is only a `return`. | +| `"always"` | Report every arrow function without braces. | +| `"never"` | Report every arrow function with braces, whatever is in them. `--fix` only rewrites a body that is a single `return`. | {/* bad {"options": ["always"]} */} - ```js const double = items.map(item => item * 2); ``` {/* bad {"options": ["never"]} */} - ```js const logged = items.map(item => { console.log(item); @@ -63,13 +68,11 @@ const logged = items.map(item => { With `"as-needed"`, a second option `{ "requireReturnForObjectLiteral": true }` (default `false`) requires braces and a `return` when the function returns an object literal, instead of `=> ({ ... })`. {/* bad {"options": ["as-needed", {"requireReturnForObjectLiteral": true}]} */} - ```js const toPoint = item => ({ x: item.left, y: item.top }); ``` {/* good {"options": ["as-needed", {"requireReturnForObjectLiteral": true}]} */} - ```js const toPoint = item => { return { x: item.left, y: item.top }; diff --git a/docs/lint/rules/eslint/block-scoped-var.mdx b/docs/lint/rules/eslint/block-scoped-var.mdx index d3c086c69af2..f46b3c42f2ce 100644 --- a/docs/lint/rules/eslint/block-scoped-var.mdx +++ b/docs/lint/rules/eslint/block-scoped-var.mdx @@ -1,6 +1,15 @@ --- title: "block-scoped-var" description: "Disallow using a `var` outside the block it is declared in" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/block-scoped-var"] +kind: "suggestion" +category: "suspicious" +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- A `var` declared inside an `if`, a loop or a `switch` is visible in the whole function. Code after the block can read it, and gets `undefined` when the block did not run. This rule reports every use of a `var` outside the block that declares it, as if it were a `let`. @@ -10,7 +19,6 @@ A `var` declared inside an `if`, a loop or a `switch` is visible in the whole fu Reported: {/* bad */} - ```js function label(user) { if (user.admin) { @@ -21,7 +29,6 @@ function label(user) { ``` {/* bad */} - ```js function indexOfFirstEmpty(items) { for (var i = 0; i < items.length; i++) { @@ -34,7 +41,6 @@ function indexOfFirstEmpty(items) { Not reported: {/* good */} - ```js function label(user) { var prefix = ""; diff --git a/docs/lint/rules/eslint/callback-return.mdx b/docs/lint/rules/eslint/callback-return.mdx index 89776977434f..2d6b98d8e98e 100644 --- a/docs/lint/rules/eslint/callback-return.mdx +++ b/docs/lint/rules/eslint/callback-return.mdx @@ -1,6 +1,15 @@ --- title: "callback-return" description: "Require `return` when calling a callback" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/callback-return"] +kind: "suggestion" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: true --- Calling a callback does not stop the function. If you call `callback(err)` in an `if` and forget to return, the code below runs too, and the callback is often called a second time. @@ -12,7 +21,6 @@ The rule reports a call to `callback`, `cb` or `next` inside a function, unless Reported: {/* bad */} - ```js function load(path, callback) { readFile(path, (err, text) => { @@ -27,7 +35,6 @@ function load(path, callback) { Not reported: {/* good */} - ```js function load(path, callback) { readFile(path, (err, text) => { @@ -52,7 +59,6 @@ function check(request, response, next) { One option: the list of names that count as callbacks. It replaces the default, `["callback", "cb", "next"]`. A name can be a path such as `"response.send"`. {/* bad {"options": [["done", "response.send"]]} */} - ```js function handle(request, response) { if (!request.user) { @@ -65,7 +71,6 @@ function handle(request, response) { With the same list, `callback` is no longer checked: {/* good {"options": [["done", "response.send"]]} */} - ```js function load(err, callback) { if (err) { diff --git a/docs/lint/rules/eslint/camelcase.mdx b/docs/lint/rules/eslint/camelcase.mdx index c42aa026759d..78ae98556597 100644 --- a/docs/lint/rules/eslint/camelcase.mdx +++ b/docs/lint/rules/eslint/camelcase.mdx @@ -1,6 +1,15 @@ --- title: "camelcase" description: "Require camelCase names for variables, functions and properties" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/camelcase"] +kind: "suggestion" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- JavaScript's built-in APIs and most libraries use camelCase. If a codebase has both `user_name` and `userName`, you have to look up which spelling each variable uses. @@ -12,7 +21,6 @@ The rule reports a name that has an underscore in the middle. Underscores at the Reported: {/* bad {"count": 3} */} - ```js const first_name = "Ada"; @@ -24,7 +32,6 @@ function full_name(user) { Not reported: {/* good */} - ```js const MAX_RETRIES = 3; const _cache = new Map(); @@ -38,16 +45,15 @@ function fullName(user) { ## Options -| Option | Default | What it does | -| --------------------- | ---------- | ------------------------------------------------------------------------------------------------- | -| `properties` | `"always"` | `"never"` stops checking property names: keys of object literals, class members, and `a.b_c = 1`. | -| `ignoreDestructuring` | `false` | Allow a destructured variable that keeps the name of the property. | -| `ignoreImports` | `false` | Allow a named import that keeps the name it is exported with. | -| `ignoreGlobals` | `false` | Allow the global variables that your config or a `/* global */` comment declares. | -| `allow` | `[]` | Names to allow. Each entry is a name or a regular expression. | +| Option | Default | What it does | +| --- | --- | --- | +| `properties` | `"always"` | `"never"` stops checking property names: keys of object literals, class members, and `a.b_c = 1`. | +| `ignoreDestructuring` | `false` | Allow a destructured variable that keeps the name of the property. | +| `ignoreImports` | `false` | Allow a named import that keeps the name it is exported with. | +| `ignoreGlobals` | `false` | Allow the global variables that your config or a `/* global */` comment declares. | +| `allow` | `[]` | Names to allow. Each entry is a name or a regular expression. | {/* good {"options": [{"properties": "never"}]} */} - ```js const body = { user_id: 7 }; body.created_at = Date.now(); @@ -56,19 +62,16 @@ body.created_at = Date.now(); With `ignoreDestructuring`, only the variable with the same name as the property is allowed. A new name is still checked: {/* good {"options": [{"ignoreDestructuring": true}]} */} - ```js const { user_id } = await response.json(); ``` {/* bad {"options": [{"ignoreDestructuring": true}]} */} - ```js const { id: user_id } = await response.json(); ``` {/* good {"options": [{"ignoreImports": true}]} */} - ```js import { unstable_cache } from "next/cache"; ``` @@ -76,7 +79,6 @@ import { unstable_cache } from "next/cache"; `ignoreDestructuring` and `ignoreImports` only cover the line that creates the variable. Using it later is still reported, except in a call: {/* bad {"options": [{"ignoreDestructuring": true}], "count": 1} */} - ```js const { user_id } = await response.json(); console.log(user_id); @@ -84,21 +86,18 @@ const key = "user:" + user_id; ``` {/* bad {"options": [{"ignoreImports": true}], "count": 1} */} - ```js import { unstable_cache } from "next/cache"; export const cache = unstable_cache; ``` {/* good {"options": [{"ignoreGlobals": true}]} */} - ```js /* global api_key */ const key = api_key; ``` {/* good {"options": [{"allow": ["^UNSAFE_", "user_id"]}]} */} - ```js const user_id = 7; diff --git a/docs/lint/rules/eslint/capitalized-comments.mdx b/docs/lint/rules/eslint/capitalized-comments.mdx index 7ea286a88eeb..e8028ba1b635 100644 --- a/docs/lint/rules/eslint/capitalized-comments.mdx +++ b/docs/lint/rules/eslint/capitalized-comments.mdx @@ -1,6 +1,15 @@ --- title: "capitalized-comments" description: "Require comments to start with a capital letter, or forbid it" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/capitalized-comments"] +kind: "suggestion" +category: "style" +default: "off" +fixable: "yes" +typeAware: false +deprecated: false --- This is a style rule: it makes every comment start the same way. By default, a comment that starts with a lowercase letter is reported. @@ -14,7 +23,6 @@ Comments that start with something other than a letter, comments that start with Reported: {/* bad */} - ```js // retry once, then give up await send(message); @@ -23,7 +31,6 @@ await send(message); Not reported: {/* good */} - ```js // Retry once, then give up await send(message); @@ -44,7 +51,6 @@ capitalized-comments = ["error", "always", { ignoreConsecutiveComments = true }] The first option is `"always"` (default), which requires a capital letter, or `"never"`, which requires a lowercase letter. {/* bad {"options": ["never"]} */} - ```js // Retry once, then give up await send(message); @@ -52,26 +58,23 @@ await send(message); The second option is an object: -| Option | Default | What it does | -| --------------------------- | ------- | -------------------------------------------------------------------------------------------------------- | -| `ignorePattern` | none | A regular expression. A comment that starts with a match is not checked. | -| `ignoreInlineComments` | `false` | Do not check a comment that has code before it and after it on the same line. | +| Option | Default | What it does | +| --- | --- | --- | +| `ignorePattern` | none | A regular expression. A comment that starts with a match is not checked. | +| `ignoreInlineComments` | `false` | Do not check a comment that has code before it and after it on the same line. | | `ignoreConsecutiveComments` | `false` | Do not check a comment that comes right after another comment, so only the first line of a group counts. | {/* good {"options": ["always", {"ignorePattern": "webpack"}]} */} - ```js const editor = import(/* webpackChunkName: "editor" */ "./editor.js"); ``` {/* good {"options": ["always", {"ignoreInlineComments": true}]} */} - ```js function resize(width, /* optional */ height) {} ``` {/* good {"options": ["always", {"ignoreConsecutiveComments": true}]} */} - ```js // Retry once, then give up, // so that a slow server does not block the queue @@ -81,7 +84,6 @@ await send(message); To use different settings for `//` and `/* */` comments, put the same options under `line` and `block`. Here only the `//` comment is reported: {/* bad {"options": ["always", {"block": {"ignorePattern": "webpack"}}], "count": 1} */} - ```js // webpack splits the bundle here const editor = import(/* webpackChunkName: "editor" */ "./editor.js"); diff --git a/docs/lint/rules/eslint/class-methods-use-this.mdx b/docs/lint/rules/eslint/class-methods-use-this.mdx index f2808b3e173d..79f134af4c7b 100644 --- a/docs/lint/rules/eslint/class-methods-use-this.mdx +++ b/docs/lint/rules/eslint/class-methods-use-this.mdx @@ -1,6 +1,15 @@ --- title: "class-methods-use-this" description: "Require class methods to use `this`" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/class-methods-use-this"] +kind: "suggestion" +category: "restriction" +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- A method that never touches `this` does not need an instance. Callers still have to create one to reach it. Make it `static`, or move it out of the class as a plain function. @@ -12,7 +21,6 @@ The rule checks methods, getters, setters, and fields that hold a function. Cons Reported: {/* bad */} - ```js class Cart { formatPrice(cents) { @@ -24,7 +32,6 @@ class Cart { Not reported: {/* good */} - ```js class Cart { static formatPrice(cents) { @@ -39,17 +46,16 @@ class Cart { ## Options -| Option | Default | What it does | -| ----------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------- | -| `exceptMethods` | `[]` | Names of methods that are not checked. Write a private method with its `#`. | -| `enforceForClassFields` | `true` | Also check functions that are the value of a field: `format = () => {}`. | -| `ignoreOverrideMethods` | `false` | Do not check members marked `override` in TypeScript. | +| Option | Default | What it does | +| --- | --- | --- | +| `exceptMethods` | `[]` | Names of methods that are not checked. Write a private method with its `#`. | +| `enforceForClassFields` | `true` | Also check functions that are the value of a field: `format = () => {}`. | +| `ignoreOverrideMethods` | `false` | Do not check members marked `override` in TypeScript. | | `ignoreClassesWithImplements` | not set | `"all"` skips every member of a class that has an `implements` clause. `"public-fields"` skips only its public members. | A framework may call a method on the instance whether it uses `this` or not: {/* good {"options": [{"exceptMethods": ["render", "#label"]}]} */} - ```js class Spinner extends Component { render() { @@ -63,7 +69,6 @@ class Spinner extends Component { ``` {/* good {"options": [{"enforceForClassFields": false}]} */} - ```js class Cart { formatPrice = cents => "$" + (cents / 100).toFixed(2); @@ -71,7 +76,6 @@ class Cart { ``` {/* good {"options": [{"ignoreOverrideMethods": true}]} */} - ```ts class Guest extends User { override displayName() { @@ -83,7 +87,6 @@ class Guest extends User { With `"public-fields"`, the interface may ask for `name()`, but it cannot ask for the private `prefix()`: {/* bad {"options": [{"ignoreClassesWithImplements": "public-fields"}], "count": 1} */} - ```ts class Guest implements Named { name() { diff --git a/docs/lint/rules/eslint/complexity.mdx b/docs/lint/rules/eslint/complexity.mdx index 4ecab31445aa..084bb8858e74 100644 --- a/docs/lint/rules/eslint/complexity.mdx +++ b/docs/lint/rules/eslint/complexity.mdx @@ -1,6 +1,15 @@ --- title: "complexity" description: "Enforce a maximum number of branches in a function" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/complexity"] +kind: "suggestion" +category: "restriction" +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- Every branch in a function is one more path to keep in mind and to test. This rule counts them and reports a function that has more than 20. @@ -12,7 +21,6 @@ The count starts at 1. Each of these adds 1: `if`, `? :`, a loop, a `case`, a `c With a maximum of 3, this function is reported. It has a complexity of 4: {/* bad {"options": [3]} */} - ```js function shipping(order) { if (order.total > 100) { @@ -29,7 +37,6 @@ function shipping(order) { Not reported: {/* good {"options": [3]} */} - ```js function shipping(order) { if (order.total > 100) { @@ -52,15 +59,14 @@ complexity = ["error", 10] The option is a number, which is the maximum, or an object: -| Option | Default | What it does | -| --------- | ----------- | -------------------------------------------------------------------------- | -| `max` | `20` | The highest complexity that is allowed. `maximum` is an older name for it. | -| `variant` | `"classic"` | `"modified"` counts a whole `switch` as 1, however many `case`s it has. | +| Option | Default | What it does | +| --- | --- | --- | +| `max` | `20` | The highest complexity that is allowed. `maximum` is an older name for it. | +| `variant` | `"classic"` | `"modified"` counts a whole `switch` as 1, however many `case`s it has. | Three `case`s give this function a complexity of 4. `default` does not count: {/* bad {"options": [{"max": 3}]} */} - ```js function label(status) { switch (status) { @@ -79,7 +85,6 @@ function label(status) { With `"modified"`, the same function has a complexity of 2: {/* good {"options": [{"max": 2, "variant": "modified"}]} */} - ```js function label(status) { switch (status) { diff --git a/docs/lint/rules/eslint/consistent-return.mdx b/docs/lint/rules/eslint/consistent-return.mdx index 26a16187e94a..9dad2f86c20a 100644 --- a/docs/lint/rules/eslint/consistent-return.mdx +++ b/docs/lint/rules/eslint/consistent-return.mdx @@ -1,6 +1,15 @@ --- title: "consistent-return" description: "Require every `return` in a function to have a value, or none of them" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/consistent-return"] +kind: "suggestion" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- A function that returns a value on one path and nothing on another sometimes gives its caller `undefined`. That can be on purpose, but often a value was forgotten, or the function runs off its end by accident. @@ -12,7 +21,6 @@ The first `return` of the function sets what is expected. Every `return` that di Reported: {/* bad */} - ```js function findUser(id) { if (!id) { @@ -23,7 +31,6 @@ function findUser(id) { ``` {/* bad */} - ```js function discount(order) { if (order.total > 100) { @@ -35,7 +42,6 @@ function discount(order) { Not reported: {/* good */} - ```js function findUser(id) { if (!id) { @@ -61,12 +67,11 @@ function log(message) { ## Options -| Option | Default | What it does | -| ----------------------------- | ------- | --------------------------------------------------------------------- | +| Option | Default | What it does | +| --- | --- | --- | | `treatUndefinedAsUnspecified` | `false` | Treat `return undefined;` and `return void 0;` the same as `return;`. | {/* bad {"options": [{"treatUndefinedAsUnspecified": true}]} */} - ```js function findUser(id) { if (!id) { diff --git a/docs/lint/rules/eslint/consistent-this.mdx b/docs/lint/rules/eslint/consistent-this.mdx index 720e02d5a7f2..cb00c2c52925 100644 --- a/docs/lint/rules/eslint/consistent-this.mdx +++ b/docs/lint/rules/eslint/consistent-this.mdx @@ -1,6 +1,15 @@ --- title: "consistent-this" description: "Enforce one name for variables that hold `this`" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/consistent-this"] +kind: "suggestion" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- Code that saves `this` for a nested function calls the variable `that`, `self`, `me` or `vm`. With one name everywhere, you know what the variable holds without looking for its declaration. @@ -12,7 +21,6 @@ The rule reports `this` assigned to any other name. It also reports the chosen n Reported: {/* bad */} - ```js function Timer() { const self = this; @@ -23,7 +31,6 @@ function Timer() { ``` {/* bad */} - ```js const that = document.body; ``` @@ -31,7 +38,6 @@ const that = document.body; Not reported: {/* good */} - ```js function Timer() { const that = this; @@ -44,7 +50,6 @@ function Timer() { Destructuring is not an alias, so it is allowed: {/* good */} - ```js function Timer() { const { interval, tick } = this; @@ -62,7 +67,6 @@ consistent-this = ["error", "self", "vm"] ``` {/* good {"options": ["self", "vm"]} */} - ```js function Timer() { const self = this; diff --git a/docs/lint/rules/eslint/constructor-super.mdx b/docs/lint/rules/eslint/constructor-super.mdx index 0d471566eb53..7ec3aaf00985 100644 --- a/docs/lint/rules/eslint/constructor-super.mdx +++ b/docs/lint/rules/eslint/constructor-super.mdx @@ -1,6 +1,15 @@ --- title: "constructor-super" description: "Require `super()` to be called exactly once in the constructor of a derived class" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/constructor-super"] +kind: "problem" +category: "correctness" +default: "error" +fixable: "no" +typeAware: false +deprecated: false --- In a class with `extends`, the constructor has to call `super()` before it uses `this` or returns. Without the call, `new` throws a `ReferenceError`. A second call throws too. These errors only show up when that path of the constructor runs. @@ -14,7 +23,6 @@ This rule is on by default in JavaScript files. It is off in TypeScript files, w Reported: {/* bad */} - ```js class Admin extends User { constructor(name) { @@ -24,7 +32,6 @@ class Admin extends User { ``` {/* bad */} - ```js class Admin extends User { constructor(name) { @@ -36,7 +43,6 @@ class Admin extends User { ``` {/* bad */} - ```js class Admin extends User { constructor(name) { @@ -47,7 +53,6 @@ class Admin extends User { ``` {/* bad */} - ```js class Dictionary extends null { constructor() { @@ -59,7 +64,6 @@ class Dictionary extends null { Not reported: {/* good */} - ```js class Admin extends User { constructor(name) { diff --git a/docs/lint/rules/eslint/curly.mdx b/docs/lint/rules/eslint/curly.mdx index 94f2ffa2ed5f..4e0761b2523d 100644 --- a/docs/lint/rules/eslint/curly.mdx +++ b/docs/lint/rules/eslint/curly.mdx @@ -1,6 +1,15 @@ --- title: "curly" description: "Require braces around the bodies of `if`, `else` and loops" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/curly"] +kind: "suggestion" +category: "style" +default: "off" +fixable: "yes" +typeAware: false +deprecated: false --- Without braces, only the next statement belongs to the `if` or the loop. Add a second line below it with the same indentation, and that line runs every time, whatever the condition says. @@ -12,7 +21,6 @@ Without braces, only the next statement belongs to the `if` or the loop. Add a s Reported: {/* bad {"count": 2} */} - ```js if (!user) redirect("/login"); @@ -22,7 +30,6 @@ for (const item of items) total += item.price; Not reported: {/* good */} - ```js if (!user) { redirect("/login"); @@ -40,15 +47,14 @@ for (const item of items) { curly = ["error", "multi-line", "consistent"] ``` -| Option | What it does | -| ----------------- | --------------------------------------------------------------------------------------------------------- | -| `"all"` (default) | Braces everywhere. | -| `"multi"` | Braces only around more than one statement. Braces around a single statement are reported. | -| `"multi-line"` | You can leave the braces out if the body is on the same line as the `if` or the loop. | +| Option | What it does | +| --- | --- | +| `"all"` (default) | Braces everywhere. | +| `"multi"` | Braces only around more than one statement. Braces around a single statement are reported. | +| `"multi-line"` | You can leave the braces out if the body is on the same line as the `if` or the loop. | | `"multi-or-nest"` | No braces around a single statement that fits on one line. Braces around a body that takes several lines. | {/* bad {"options": ["multi"]} */} - ```js if (!user) { redirect("/login"); @@ -58,7 +64,6 @@ if (!user) { Braces that the code needs are never reported, for example around a `const`: {/* good {"options": ["multi"]} */} - ```js if (!user) redirect("/login"); @@ -70,17 +75,16 @@ if (debug) { With `"multi-line"`, the first `if` is allowed and the second one is reported: {/* bad {"options": ["multi-line"], "count": 1} */} - ```js if (!user) redirect("/login"); -if (!user.active) redirect("/activate"); +if (!user.active) + redirect("/activate"); ``` With `"multi-or-nest"`, the body below is one statement, but it takes three lines: {/* bad {"options": ["multi-or-nest"]} */} - ```js if (!user) save({ @@ -89,9 +93,9 @@ if (!user) ``` {/* good {"options": ["multi-or-nest"]} */} - ```js -if (!user) redirect("/login"); +if (!user) + redirect("/login"); if (user.admin) { save({ @@ -103,16 +107,15 @@ if (user.admin) { With any option but `"all"`, a second option `"consistent"` makes all branches of an `if` / `else` chain look the same. If one of them needs braces, all of them get braces. {/* bad {"options": ["multi", "consistent"]} */} - ```js if (user.admin) { grant(user); notify(user); -} else deny(user); +} else + deny(user); ``` {/* good {"options": ["multi", "consistent"]} */} - ```js if (user.admin) { grant(user); diff --git a/docs/lint/rules/eslint/default-case-last.mdx b/docs/lint/rules/eslint/default-case-last.mdx index 7c23992b242c..f0389b475298 100644 --- a/docs/lint/rules/eslint/default-case-last.mdx +++ b/docs/lint/rules/eslint/default-case-last.mdx @@ -1,6 +1,15 @@ --- title: "default-case-last" description: "Require `default` to be the last clause of a `switch`" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/default-case-last"] +kind: "suggestion" +category: "style" +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- `default` runs when no `case` matches, wherever it is written. Placed first or in the middle, it reads as if it were tested in order, and without a `break` it falls through into the `case` below it. At the end, it reads like the `else` that it is. @@ -10,7 +19,6 @@ description: "Require `default` to be the last clause of a `switch`" Reported: {/* bad */} - ```js switch (level) { default: @@ -25,7 +33,6 @@ switch (level) { Not reported: {/* good */} - ```js switch (level) { case "error": diff --git a/docs/lint/rules/eslint/default-case.mdx b/docs/lint/rules/eslint/default-case.mdx index a3f524778de5..11816593a9ed 100644 --- a/docs/lint/rules/eslint/default-case.mdx +++ b/docs/lint/rules/eslint/default-case.mdx @@ -1,6 +1,15 @@ --- title: "default-case" description: "Require a `default` case in every `switch`" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/default-case"] +kind: "suggestion" +category: "restriction" +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- A `switch` without `default` does nothing when no `case` matches. When a new value shows up, it is silently skipped. A `default` makes you decide what happens then, even if the answer is to throw. @@ -12,7 +21,6 @@ If you leave it out on purpose, say so with a `// no default` comment after the Reported: {/* bad */} - ```js switch (status) { case "open": @@ -27,7 +35,6 @@ switch (status) { Not reported: {/* good */} - ```js switch (status) { case "open": @@ -42,7 +49,6 @@ switch (status) { ``` {/* good */} - ```js switch (status) { case "open": @@ -57,12 +63,11 @@ switch (status) { ## Options -| Option | Default | What it does | -| ---------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| Option | Default | What it does | +| --- | --- | --- | | `commentPattern` | `"^no default$"`, in any case | A regular expression for the comment that excuses a missing `default`. It is tested on the last comment after the last `case`. | {/* good {"options": [{"commentPattern": "^skip default"}]} */} - ```js switch (status) { case "open": @@ -75,7 +80,6 @@ switch (status) { With your own pattern, `// no default` no longer counts: {/* bad {"options": [{"commentPattern": "^skip default"}]} */} - ```js switch (status) { case "open": diff --git a/docs/lint/rules/eslint/default-param-last.mdx b/docs/lint/rules/eslint/default-param-last.mdx index 32cfb990a152..4a23f91718f9 100644 --- a/docs/lint/rules/eslint/default-param-last.mdx +++ b/docs/lint/rules/eslint/default-param-last.mdx @@ -1,6 +1,15 @@ --- title: "default-param-last" description: "Require parameters with a default value to come after the required ones" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/default-param-last"] +kind: "suggestion" +category: "style" +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- A default value is used when the argument is left out. You can only leave out arguments at the end of a call. If a required parameter follows, the caller has to write `createUser(undefined, "Ada")` to get the default. @@ -10,7 +19,6 @@ A default value is used when the argument is left out. You can only leave out ar Reported: {/* bad */} - ```js function createUser(role = "member", name) { return { name, role }; @@ -20,7 +28,6 @@ function createUser(role = "member", name) { Not reported: {/* good */} - ```js function createUser(name, role = "member") { return { name, role }; diff --git a/docs/lint/rules/eslint/dot-notation.mdx b/docs/lint/rules/eslint/dot-notation.mdx index f835d9c9f22a..842cd2b3d6b3 100644 --- a/docs/lint/rules/eslint/dot-notation.mdx +++ b/docs/lint/rules/eslint/dot-notation.mdx @@ -1,6 +1,15 @@ --- title: "dot-notation" -description: 'Require `user.name` instead of `user["name"]`' +description: "Require `user.name` instead of `user[\"name\"]`" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/dot-notation"] +kind: "suggestion" +category: null +default: "off" +fixable: "yes" +typeAware: false +deprecated: false --- `user["name"]` and `user.name` read the same property. The dot form is shorter, and it leaves brackets for the cases that need them: a key in a variable, or a name that is not a valid identifier. @@ -12,7 +21,6 @@ description: 'Require `user.name` instead of `user["name"]`' Reported: {/* bad */} - ```js const name = user["name"]; ``` @@ -20,7 +28,6 @@ const name = user["name"]; Not reported: {/* good */} - ```js const name = user.name; const value = user[key]; @@ -30,19 +37,17 @@ const first = items[0]; ## Options -| Option | Default | What it does | -| --------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -| `allowKeywords` | `true` | With `false`, reserved words go in brackets: `node.class` is reported, and `node["class"]` is allowed. Only engines older than ES5 need this. | -| `allowPattern` | `""` | A regular expression. Property names that match it can be written in brackets. | +| Option | Default | What it does | +| --- | --- | --- | +| `allowKeywords` | `true` | With `false`, reserved words go in brackets: `node.class` is reported, and `node["class"]` is allowed. Only engines older than ES5 need this. | +| `allowPattern` | `""` | A regular expression. Property names that match it can be written in brackets. | {/* bad {"options": [{"allowKeywords": false}]} */} - ```js const kind = node.class; ``` {/* good {"options": [{"allowKeywords": false}]} */} - ```js const kind = node["class"]; ``` @@ -50,13 +55,11 @@ const kind = node["class"]; To allow brackets for names that have an underscore: {/* good {"options": [{"allowPattern": "_"}]} */} - ```js const firstName = row["first_name"]; ``` {/* bad {"options": [{"allowPattern": "_"}]} */} - ```js const firstName = row["name"]; ``` diff --git a/docs/lint/rules/eslint/eqeqeq.mdx b/docs/lint/rules/eslint/eqeqeq.mdx index 41ffdb00be73..f4353a67f2f3 100644 --- a/docs/lint/rules/eslint/eqeqeq.mdx +++ b/docs/lint/rules/eslint/eqeqeq.mdx @@ -1,6 +1,15 @@ --- title: "eqeqeq" description: "Require `===` and `!==` instead of `==` and `!=`" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/eqeqeq"] +kind: "suggestion" +category: "pedantic" +default: "off" +fixable: "yes" +typeAware: false +deprecated: false --- `==` and `!=` convert their operands before comparing them, so `"" == 0` and `[] == false` are both `true`. `===` and `!==` compare without converting. @@ -10,7 +19,6 @@ description: "Require `===` and `!==` instead of `==` and `!=`" Reported: {/* bad */} - ```js if (count == 0) { reset(); @@ -20,7 +28,6 @@ if (count == 0) { Not reported: {/* good */} - ```js if (count === 0) { reset(); @@ -34,17 +41,16 @@ if (count === 0) { eqeqeq = ["error", "smart"] ``` -| Option | What it does | -| -------------------- | --------------------------------------------------------------------------------------------------- | -| `"always"` (default) | Report every `==` and `!=`. | -| `"smart"` | Allow `==` when comparing with `null`, comparing two literals, or comparing the result of `typeof`. | +| Option | What it does | +| --- | --- | +| `"always"` (default) | Report every `==` and `!=`. | +| `"smart"` | Allow `==` when comparing with `null`, comparing two literals, or comparing the result of `typeof`. | With `"always"`, a second option decides what happens with `null`: `{ "null": "always" }` (default) reports `x == null`, `"ignore"` allows it, and `"never"` reports `x === null` instead. With `"smart"`, this is allowed, because `== null` is a common way to test for both `null` and `undefined`: {/* good {"options": ["smart"]} */} - ```js if (value == null) { value = fallback; @@ -54,7 +60,6 @@ if (value == null) { And this is still reported: {/* bad {"options": ["smart"]} */} - ```js if (value == other) { merge(value, other); diff --git a/docs/lint/rules/eslint/for-direction.mdx b/docs/lint/rules/eslint/for-direction.mdx index 73884797737e..6b4c30b43cfd 100644 --- a/docs/lint/rules/eslint/for-direction.mdx +++ b/docs/lint/rules/eslint/for-direction.mdx @@ -1,6 +1,15 @@ --- title: "for-direction" description: "Require the update clause of a `for` loop to move the counter toward the end of the loop" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/for-direction"] +kind: "problem" +category: "correctness" +default: "error" +fixable: "no" +typeAware: false +deprecated: false --- A loop that tests `i < 10` and then runs `i--` moves away from 10, so the test stays true and the loop never ends. It is almost always a typo: `--` for `++`, or `<` for `>`. @@ -14,7 +23,6 @@ This rule is on by default. Reported: {/* bad */} - ```js for (let i = 0; i < items.length; i--) { render(items[i]); @@ -22,7 +30,6 @@ for (let i = 0; i < items.length; i--) { ``` {/* bad */} - ```js for (let i = items.length - 1; i >= 0; i += 1) { render(items[i]); @@ -32,7 +39,6 @@ for (let i = items.length - 1; i >= 0; i += 1) { Not reported: {/* good */} - ```js for (let i = 0; i < items.length; i++) { render(items[i]); @@ -46,7 +52,6 @@ for (let i = items.length - 1; i >= 0; i -= 1) { The sign of `step` is not known here, so the loop is left alone: {/* good */} - ```js function walk(items, step) { for (let i = 0; i < items.length; i += step) { diff --git a/docs/lint/rules/eslint/func-name-matching.mdx b/docs/lint/rules/eslint/func-name-matching.mdx index c8a018952de2..328066279698 100644 --- a/docs/lint/rules/eslint/func-name-matching.mdx +++ b/docs/lint/rules/eslint/func-name-matching.mdx @@ -1,6 +1,15 @@ --- title: "func-name-matching" description: "Require a function expression's name to match the variable or property it is assigned to" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/func-name-matching"] +kind: "suggestion" +category: "style" +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- A function expression can have its own name, different from the variable or property that holds it. Stack traces then show one name while the rest of the code calls the other, so searching for the name from the trace finds no caller. @@ -12,7 +21,6 @@ Only function expressions that have a name are checked. Arrow functions and `fun Reported: {/* bad {"count": 3} */} - ```js const loadUser = function fetchUser(id) { return db.users.get(id); @@ -28,7 +36,6 @@ const handlers = { Not reported: {/* good */} - ```js const loadUser = function loadUser(id) { return db.users.get(id); @@ -51,18 +58,17 @@ func-name-matching = ["error", "always", { considerPropertyDescriptor = true }] The first option is a string. You can leave it out and pass only the object. -| Option | What it does | -| -------------------- | -------------------------------- | -| `"always"` (default) | The two names must be the same. | -| `"never"` | The two names must be different. | +| Option | What it does | +| --- | --- | +| `"always"` (default) | The two names must be the same. | +| `"never"` | The two names must be different. | -| Option | Default | What it does | -| ------------------------------ | ------- | --------------------------------------------------------------------------------------------------------------------- | -| `considerPropertyDescriptor` | `false` | In a property descriptor, compare the name of the `value` function with the property being defined. | +| Option | Default | What it does | +| --- | --- | --- | +| `considerPropertyDescriptor` | `false` | In a property descriptor, compare the name of the `value` function with the property being defined. | | `includeCommonJSModuleExports` | `false` | Also check `module.exports = function name() {}`. The property is `exports`, so that is the name it is compared with. | {/* bad {"options": ["never"]} */} - ```js const loadUser = function loadUser(id) {}; ``` @@ -72,7 +78,6 @@ const loadUser = function loadUser(id) {}; This applies to `Object.defineProperty`, `Reflect.defineProperty`, `Object.defineProperties` and `Object.create`. Without the option, the function below is compared with its key, `value`, and reported: {/* bad */} - ```js Object.defineProperty(user, "save", { value: function save() {}, @@ -80,7 +85,6 @@ Object.defineProperty(user, "save", { ``` {/* good {"options": ["always", {"considerPropertyDescriptor": true}]} */} - ```js Object.defineProperty(user, "save", { value: function save() {}, @@ -90,13 +94,11 @@ Object.defineProperty(user, "save", { ### `includeCommonJSModuleExports` {/* good */} - ```js module.exports = function createServer() {}; ``` {/* bad {"options": [{"includeCommonJSModuleExports": true}]} */} - ```js module.exports = function createServer() {}; ``` diff --git a/docs/lint/rules/eslint/func-names.mdx b/docs/lint/rules/eslint/func-names.mdx index eec788219902..344094e80418 100644 --- a/docs/lint/rules/eslint/func-names.mdx +++ b/docs/lint/rules/eslint/func-names.mdx @@ -1,6 +1,15 @@ --- title: "func-names" description: "Require or disallow names for `function` expressions" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/func-names"] +kind: "suggestion" +category: "style" +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- A callback written as `function () {}` shows up as `` in stack traces and profiles. When several callbacks are involved, you cannot tell from the trace which one failed. @@ -12,7 +21,6 @@ Arrow functions and methods are not checked. Reported: {/* bad */} - ```js button.addEventListener("click", function () { save(); @@ -20,7 +28,6 @@ button.addEventListener("click", function () { ``` {/* bad */} - ```js export default function () { return "ready"; @@ -30,7 +37,6 @@ export default function () { Not reported: {/* good */} - ```js button.addEventListener("click", function onClick() { save(); @@ -46,11 +52,11 @@ button.addEventListener("blur", () => save()); func-names = ["error", "as-needed"] ``` -| Option | What it does | -| -------------------- | ------------------------------------------------------------------------------------- | -| `"always"` (default) | Every function expression needs a name. | -| `"as-needed"` | A name is needed only where JavaScript does not give the function one by itself. | -| `"never"` | No function expression has a name, unless the function refers to itself by that name. | +| Option | What it does | +| --- | --- | +| `"always"` (default) | Every function expression needs a name. | +| `"as-needed"` | A name is needed only where JavaScript does not give the function one by itself. | +| `"never"` | No function expression has a name, unless the function refers to itself by that name. | A second option, `{ "generators": "always" | "as-needed" | "never" }`, sets the same choice for generator functions only. Without it, generators follow the first option. @@ -59,7 +65,6 @@ A second option, `{ "generators": "always" | "as-needed" | "never" }`, sets the A function assigned to a variable, or written as the value of an object key, takes that name, so it does not need its own: {/* good {"options": ["as-needed"]} */} - ```js const save = function () {}; @@ -71,7 +76,6 @@ const handlers = { With `"always"`, both are reported: {/* bad {"count": 2} */} - ```js const save = function () {}; @@ -83,7 +87,6 @@ const handlers = { A callback gets no name that way, and neither does a function assigned to `object.property`. Both are still reported: {/* bad {"options": ["as-needed"], "count": 2} */} - ```js items.forEach(function (item) { render(item); @@ -95,7 +98,6 @@ handlers.open = function () {}; ### `"never"` {/* bad {"options": ["never"]} */} - ```js items.forEach(function renderItem(item) { render(item); @@ -103,7 +105,6 @@ items.forEach(function renderItem(item) { ``` {/* good {"options": ["never"]} */} - ```js items.forEach(function (item) { render(item); @@ -117,7 +118,6 @@ const factorial = function fact(n) { ### `generators` {/* good {"options": ["always", {"generators": "never"}]} */} - ```js run(function* () { yield 1; diff --git a/docs/lint/rules/eslint/func-style.mdx b/docs/lint/rules/eslint/func-style.mdx index 63472ca245b2..e8b68f7e12b1 100644 --- a/docs/lint/rules/eslint/func-style.mdx +++ b/docs/lint/rules/eslint/func-style.mdx @@ -1,6 +1,15 @@ --- title: "func-style" description: "Enforce one way to define functions: declarations, or expressions assigned to variables" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/func-style"] +kind: "suggestion" +category: "style" +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- A function declaration can be called above the line that defines it. A function assigned to a variable exists only after that line has run. If a project mixes both, you have to check how each function is written to know where it can be called. @@ -12,7 +21,6 @@ This rule makes you pick one. By default it asks for expressions. Reported: {/* bad */} - ```js function formatName(user) { return `${user.first} ${user.last}`; @@ -22,19 +30,17 @@ function formatName(user) { Not reported: {/* good */} - ```js const formatName = function (user) { return `${user.first} ${user.last}`; }; -const initials = user => user.first[0] + user.last[0]; +const initials = (user) => user.first[0] + user.last[0]; ``` `export default function` is never reported, and neither is a TypeScript function that has overload signatures: {/* good */} - ```ts export default function main() {} @@ -52,31 +58,29 @@ function parse(input: string | number) { func-style = ["error", "declaration", { allowArrowFunctions = true }] ``` -| Option | What it does | -| ------------------------ | ------------------------------------------------------------- | -| `"expression"` (default) | Report function declarations. | -| `"declaration"` | Report functions assigned to a variable where it is declared. | +| Option | What it does | +| --- | --- | +| `"expression"` (default) | Report function declarations. | +| `"declaration"` | Report functions assigned to a variable where it is declared. | -| Option | Default | What it does | -| ------------------------ | ------- | -------------------------------------------------------------------------------------------------------------- | -| `allowArrowFunctions` | `false` | With `"declaration"`, allow arrow functions. | -| `allowTypeAnnotation` | `false` | With `"declaration"`, allow a function if its variable has a type annotation. | +| Option | Default | What it does | +| --- | --- | --- | +| `allowArrowFunctions` | `false` | With `"declaration"`, allow arrow functions. | +| `allowTypeAnnotation` | `false` | With `"declaration"`, allow a function if its variable has a type annotation. | | `overrides.namedExports` | not set | The style for `export function` and `export const`: `"declaration"`, `"expression"`, or `"ignore"` for either. | ### `"declaration"` {/* bad {"options": ["declaration"], "count": 2} */} - ```js const formatName = function (user) { return `${user.first} ${user.last}`; }; -const initials = user => user.first[0] + user.last[0]; +const initials = (user) => user.first[0] + user.last[0]; ``` {/* good {"options": ["declaration"]} */} - ```js function formatName(user) { return `${user.first} ${user.last}`; @@ -86,7 +90,6 @@ function formatName(user) { An arrow function that uses `this` is not reported, because `this` would mean something else in a declaration: {/* good {"options": ["declaration"]} */} - ```js function Timer() { const tick = () => { @@ -99,9 +102,8 @@ function Timer() { ### `allowArrowFunctions` {/* good {"options": ["declaration", {"allowArrowFunctions": true}]} */} - ```js -const initials = user => user.first[0] + user.last[0]; +const initials = (user) => user.first[0] + user.last[0]; ``` ### `allowTypeAnnotation` @@ -109,11 +111,10 @@ const initials = user => user.first[0] + user.last[0]; A declaration cannot be given a function type as a whole, so this option lets such functions stay expressions: {/* good {"options": ["declaration", {"allowTypeAnnotation": true}]} */} - ```ts type Handler = (event: Event) => void; -const onClick: Handler = event => { +const onClick: Handler = (event) => { event.preventDefault(); }; ``` @@ -121,7 +122,6 @@ const onClick: Handler = event => { ### `overrides.namedExports` {/* good {"options": ["expression", {"overrides": {"namedExports": "declaration"}}]} */} - ```js export function formatName(user) { return user.name; @@ -133,7 +133,6 @@ const trim = function (text) { ``` {/* bad {"options": ["expression", {"overrides": {"namedExports": "declaration"}}]} */} - ```js export const formatName = function (user) { return user.name; diff --git a/docs/lint/rules/eslint/getter-return.mdx b/docs/lint/rules/eslint/getter-return.mdx index 5d7e4e203d35..b39e9b4e5109 100644 --- a/docs/lint/rules/eslint/getter-return.mdx +++ b/docs/lint/rules/eslint/getter-return.mdx @@ -1,6 +1,15 @@ --- title: "getter-return" description: "Require getters to return a value" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/getter-return"] +kind: "problem" +category: "correctness" +default: "error" +fixable: "no" +typeAware: false +deprecated: false --- A getter that does not return gives `undefined` to whoever reads the property. Usually the `return` was forgotten, or one branch of the getter has none. @@ -14,7 +23,6 @@ This rule is on by default in JavaScript files. It is off in TypeScript files, w Reported: {/* bad */} - ```js const user = { get fullName() { @@ -24,7 +32,6 @@ const user = { ``` {/* bad */} - ```js class Cart { get total() { @@ -36,7 +43,6 @@ class Cart { ``` {/* bad */} - ```js Object.defineProperty(user, "age", { get: function () { @@ -48,7 +54,6 @@ Object.defineProperty(user, "age", { Not reported: {/* good */} - ```js const user = { get fullName() { @@ -68,12 +73,11 @@ class Cart { ## Options -| Option | Default | What it does | -| --------------- | ------- | ------------------------------------------------------------------------------------------- | +| Option | Default | What it does | +| --- | --- | --- | | `allowImplicit` | `false` | Allow `return;` without a value. The getter still has to end with a `return` on every path. | {/* bad */} - ```js class Session { get token() { @@ -83,7 +87,6 @@ class Session { ``` {/* good {"options": [{"allowImplicit": true}]} */} - ```js class Session { get token() { diff --git a/docs/lint/rules/eslint/global-require.mdx b/docs/lint/rules/eslint/global-require.mdx index c03408abd2aa..01db4a5a81b7 100644 --- a/docs/lint/rules/eslint/global-require.mdx +++ b/docs/lint/rules/eslint/global-require.mdx @@ -1,6 +1,15 @@ --- title: "global-require" description: "Require `require()` calls to be at the top level of the module" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/global-require"] +kind: "suggestion" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: true --- `require()` loads a module synchronously. Inside a function, that work happens in the middle of the first call instead of at startup, and a missing module throws only when that call is made. The file's dependencies are also no longer listed in one place. @@ -10,7 +19,6 @@ description: "Require `require()` calls to be at the top level of the module" Reported: {/* bad */} - ```js function readConfig(path) { const fs = require("fs"); @@ -19,7 +27,6 @@ function readConfig(path) { ``` {/* bad */} - ```js if (process.env.TRACE) { require("./trace"); @@ -29,7 +36,6 @@ if (process.env.TRACE) { Not reported: {/* good */} - ```js const fs = require("fs"); const { join } = require("path"); @@ -43,7 +49,6 @@ function readConfig(path) { A function of your own that is named `require` is not reported: {/* good */} - ```js function load(require) { return require("settings"); diff --git a/docs/lint/rules/eslint/grouped-accessor-pairs.mdx b/docs/lint/rules/eslint/grouped-accessor-pairs.mdx index ce5de1dd6bd3..36c862014242 100644 --- a/docs/lint/rules/eslint/grouped-accessor-pairs.mdx +++ b/docs/lint/rules/eslint/grouped-accessor-pairs.mdx @@ -1,6 +1,15 @@ --- title: "grouped-accessor-pairs" description: "Require the getter and setter of a property to be next to each other" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/grouped-accessor-pairs"] +kind: "suggestion" +category: "style" +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- When other members sit between the getter and the setter of a property, a reader who finds the getter can miss that there is a setter further down. @@ -12,7 +21,6 @@ The rule checks object literals and classes. In a class, a static pair and an in Reported: {/* bad */} - ```js class User { get name() { @@ -28,7 +36,6 @@ class User { Not reported: {/* good */} - ```js class User { get name() { @@ -48,18 +55,17 @@ class User { grouped-accessor-pairs = ["error", "getBeforeSet"] ``` -| Option | What it does | -| ---------------------- | ------------------------------------------- | +| Option | What it does | +| --- | --- | | `"anyOrder"` (default) | The pair must be adjacent, in either order. | -| `"getBeforeSet"` | The getter also has to come first. | -| `"setBeforeGet"` | The setter also has to come first. | +| `"getBeforeSet"` | The getter also has to come first. | +| `"setBeforeGet"` | The setter also has to come first. | -| Option | Default | What it does | -| ------------------- | ------- | ---------------------------------------------------------------------- | +| Option | Default | What it does | +| --- | --- | --- | | `enforceForTSTypes` | `false` | Also check `get` and `set` signatures in interfaces and type literals. | {/* bad {"options": ["getBeforeSet"]} */} - ```js const volume = { set level(value) { @@ -72,7 +78,6 @@ const volume = { ``` {/* good {"options": ["getBeforeSet"]} */} - ```js const volume = { get level() { @@ -87,7 +92,6 @@ const volume = { ### `enforceForTSTypes` {/* good */} - ```ts interface Volume { get level(): number; @@ -97,7 +101,6 @@ interface Volume { ``` {/* bad {"options": ["anyOrder", {"enforceForTSTypes": true}]} */} - ```ts interface Volume { get level(): number; diff --git a/docs/lint/rules/eslint/guard-for-in.mdx b/docs/lint/rules/eslint/guard-for-in.mdx index 11694842c860..1cc598b3579b 100644 --- a/docs/lint/rules/eslint/guard-for-in.mdx +++ b/docs/lint/rules/eslint/guard-for-in.mdx @@ -1,6 +1,15 @@ --- title: "guard-for-in" description: "Require the body of a `for...in` loop to be guarded by an `if`" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/guard-for-in"] +kind: "suggestion" +category: "style" +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- `for...in` visits the enumerable properties an object inherits from its prototype chain as well as its own. If anything was added to a prototype, the loop body runs for keys the object does not have itself. @@ -12,7 +21,6 @@ The rule looks only at the shape of the loop. The body has to be a single `if`, Reported: {/* bad */} - ```js for (const key in settings) { apply(key, settings[key]); @@ -20,7 +28,6 @@ for (const key in settings) { ``` {/* bad */} - ```js for (const key in settings) { if (Object.hasOwn(settings, key)) { @@ -33,7 +40,6 @@ for (const key in settings) { Not reported: {/* good */} - ```js for (const key in settings) { if (Object.hasOwn(settings, key)) { @@ -51,7 +57,6 @@ for (const key in settings) { `Object.keys()` returns only the object's own keys, so a `for...of` loop over it needs no guard: {/* good */} - ```js for (const key of Object.keys(settings)) { apply(key, settings[key]); diff --git a/docs/lint/rules/eslint/handle-callback-err.mdx b/docs/lint/rules/eslint/handle-callback-err.mdx index 60f737584a7a..e8cab88b3e38 100644 --- a/docs/lint/rules/eslint/handle-callback-err.mdx +++ b/docs/lint/rules/eslint/handle-callback-err.mdx @@ -1,6 +1,15 @@ --- title: "handle-callback-err" description: "Require callbacks to use their error parameter" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/handle-callback-err"] +kind: "suggestion" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: true --- A Node.js style callback gets the error as its first argument. If the function never looks at it, a failure goes unnoticed and the code carries on with a result that is `undefined`. @@ -12,7 +21,6 @@ The rule reports a function if its first parameter is named `err` and is never u Reported: {/* bad */} - ```js fs.readFile(path, "utf8", function (err, text) { render(text); @@ -22,7 +30,6 @@ fs.readFile(path, "utf8", function (err, text) { Not reported: {/* good */} - ```js fs.readFile(path, "utf8", function (err, text) { if (err) { @@ -43,7 +50,6 @@ handle-callback-err = ["error", "^(err|error)$"] The one option is a string: the name of the error parameter. The default is `"err"`. A string that starts with `^` is a regular expression that the name has to match. {/* bad {"options": ["error"]} */} - ```js fs.readFile(path, "utf8", (error, text) => { render(text); @@ -51,13 +57,12 @@ fs.readFile(path, "utf8", (error, text) => { ``` {/* bad {"options": ["^(err|error)$"], "count": 2} */} - ```js fs.readFile(path, "utf8", (error, text) => { render(text); }); -fs.unlink(path, err => { +fs.unlink(path, (err) => { done(); }); ``` @@ -65,7 +70,6 @@ fs.unlink(path, err => { With the default, a parameter named `error` is not checked: {/* good */} - ```js fs.readFile(path, "utf8", (error, text) => { render(text); diff --git a/docs/lint/rules/eslint/id-blacklist.mdx b/docs/lint/rules/eslint/id-blacklist.mdx index d121fa833c00..a615cb693dcf 100644 --- a/docs/lint/rules/eslint/id-blacklist.mdx +++ b/docs/lint/rules/eslint/id-blacklist.mdx @@ -1,6 +1,15 @@ --- title: "id-blacklist" description: "Disallow the identifier names you list (old name of `id-denylist`)" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/id-blacklist"] +kind: "suggestion" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: true --- This is the old name of `id-denylist`. It takes the same options. Use `id-denylist` in a new config. @@ -17,7 +26,6 @@ id-blacklist = ["error", "data", "callback"] Reported: {/* bad {"options": ["data", "callback"], "count": 4} */} - ```js const data = loadUser(id); @@ -30,7 +38,6 @@ response.data = 2; Not reported: {/* good {"options": ["data", "callback"]} */} - ```js const user = loadUser(id); @@ -42,7 +49,6 @@ console.log(response.data); Unlike `id-denylist`, this rule does not look at private names: {/* good {"options": ["data"]} */} - ```js class Store { #data = new Map(); diff --git a/docs/lint/rules/eslint/id-denylist.mdx b/docs/lint/rules/eslint/id-denylist.mdx index 00feb75d282e..c9d94ab56299 100644 --- a/docs/lint/rules/eslint/id-denylist.mdx +++ b/docs/lint/rules/eslint/id-denylist.mdx @@ -1,6 +1,15 @@ --- title: "id-denylist" description: "Disallow the identifier names you list" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/id-denylist"] +kind: "suggestion" +category: "style" +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- Names like `data`, `tmp` or `cb` say little about what they hold. This rule reports the names you list when they name a variable, parameter, function, class, import, object key or class member, or a property you assign to. @@ -17,7 +26,6 @@ id-denylist = ["error", "data", "callback"] Reported: {/* bad {"options": ["data", "callback"], "count": 4} */} - ```js const data = loadUser(id); @@ -30,7 +38,6 @@ response.data = 2; Not reported: {/* good {"options": ["data", "callback"]} */} - ```js const user = loadUser(id); @@ -40,7 +47,6 @@ function save(user, onSaved) {} A variable is reported where it is declared and again where it is used, except where it is called or passed as an argument. This has two reports, on lines 1 and 3: {/* bad {"options": ["data"], "count": 2} */} - ```js function render(data) { log(data); @@ -51,7 +57,6 @@ function render(data) { Reading a property is allowed, because the object may come from code you do not control. So is an import or a destructured property that you rename: {/* good {"options": ["data", "callback"]} */} - ```js import { data as users } from "./api"; @@ -63,7 +68,6 @@ console.log(response.data, users, orders); Private names are compared without the `#`: {/* bad {"options": ["data"]} */} - ```js class Store { #data = new Map(); diff --git a/docs/lint/rules/eslint/id-length.mdx b/docs/lint/rules/eslint/id-length.mdx index 61b0aa685430..0a25e27280a7 100644 --- a/docs/lint/rules/eslint/id-length.mdx +++ b/docs/lint/rules/eslint/id-length.mdx @@ -1,6 +1,15 @@ --- title: "id-length" description: "Enforce a minimum and a maximum length for identifier names" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/id-length"] +kind: "suggestion" +category: "style" +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- A one-letter name such as `e`, `n` or `x` makes the reader go back to the declaration to learn what it holds. A very long name makes every line that uses it hard to scan. @@ -12,11 +21,10 @@ The rule checks a name where your code introduces it: variables, parameters, fun Reported: {/* bad {"count": 4} */} - ```js const n = items.length; -const prices = items.map(x => x.price); +const prices = items.map((x) => x.price); try { save(); @@ -30,11 +38,10 @@ const point = { y: 0 }; Not reported: {/* good */} - ```js const count = items.length; -const prices = items.map(item => item.price); +const prices = items.map((item) => item.price); try { save(); @@ -46,7 +53,6 @@ try { Type parameters are not checked: {/* good */} - ```ts function first(items: T[]): T { return items[0]; @@ -60,23 +66,21 @@ function first(items: T[]): T { id-length = ["error", { min = 3, max = 30, exceptions = ["i", "id"] }] ``` -| Option | Default | What it does | -| ------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------- | -| `min` | `2` | The shortest name allowed. | -| `max` | none | The longest name allowed. | -| `properties` | `"always"` | `"never"` stops checking object keys and the properties you assign to. | -| `exceptions` | `[]` | Names that are always allowed. | -| `exceptionPatterns` | `[]` | Regular expressions. A name that matches one is always allowed. | -| `checkGeneric` | `true` | Only with an oxlint config, which also checks type parameters such as the `T` in `Box`. `false` stops checking them. | +| Option | Default | What it does | +| --- | --- | --- | +| `min` | `2` | The shortest name allowed. | +| `max` | none | The longest name allowed. | +| `properties` | `"always"` | `"never"` stops checking object keys and the properties you assign to. | +| `exceptions` | `[]` | Names that are always allowed. | +| `exceptionPatterns` | `[]` | Regular expressions. A name that matches one is always allowed. | +| `checkGeneric` | `true` | Only with an oxlint config, which also checks type parameters such as the `T` in `Box`. `false` stops checking them. | {/* bad {"options": [{"max": 20}]} */} - ```js const numberOfItemsInTheShoppingCart = cart.items.length; ``` {/* good {"options": [{"exceptions": ["i"]}]} */} - ```js for (let i = 0; i < items.length; i++) { render(items[i]); @@ -84,7 +88,6 @@ for (let i = 0; i < items.length; i++) { ``` {/* good {"options": [{"exceptionPatterns": ["^[xyz]$"]}]} */} - ```js function move(x, y, z) { return [x + 1, y + 1, z + 1]; @@ -94,7 +97,6 @@ function move(x, y, z) { ### `properties` {/* good {"options": [{"properties": "never"}]} */} - ```js const point = { x: 0, y: 0 }; point.x = 10; @@ -103,7 +105,6 @@ point.x = 10; Variables and class members are still checked: {/* bad {"options": [{"properties": "never"}], "count": 2} */} - ```js const p = { x: 0, y: 0 }; diff --git a/docs/lint/rules/eslint/id-match.mdx b/docs/lint/rules/eslint/id-match.mdx index 9a44e7eb467d..b37654824a31 100644 --- a/docs/lint/rules/eslint/id-match.mdx +++ b/docs/lint/rules/eslint/id-match.mdx @@ -1,6 +1,15 @@ --- title: "id-match" description: "Require identifier names to match a regular expression" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/id-match"] +kind: "suggestion" +category: "style" +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- Use this rule to hold a project to one naming convention, for example no underscores or only camelCase. You give it a regular expression, and it reports the names that do not match. @@ -19,7 +28,6 @@ The examples on this page use `^[^_]+$`, which means no underscores. Reported: {/* bad {"options": ["^[^_]+$"], "count": 3} */} - ```js const user_name = "Ada"; @@ -29,7 +37,6 @@ function load_user(user_id) {} Not reported: {/* good {"options": ["^[^_]+$"]} */} - ```js const userName = "Ada"; @@ -39,7 +46,6 @@ function loadUser(userId) {} A function you call and a property you read may come from code you do not control, so they are not checked: {/* good {"options": ["^[^_]+$"]} */} - ```js const total = get_total(order.line_items); ``` @@ -48,24 +54,22 @@ const total = get_total(order.line_items); The first option is the pattern. The second is an object: -| Option | Default | What it does | -| --------------------- | ------- | ------------------------------------------------------------------------- | -| `properties` | `false` | Also check the keys of object literals and the properties you assign to. | -| `classFields` | `false` | Also check class fields. Methods are checked either way. | -| `onlyDeclarations` | `false` | Check a name only where it is declared, not where it is used. | +| Option | Default | What it does | +| --- | --- | --- | +| `properties` | `false` | Also check the keys of object literals and the properties you assign to. | +| `classFields` | `false` | Also check class fields. Methods are checked either way. | +| `onlyDeclarations` | `false` | Check a name only where it is declared, not where it is used. | | `ignoreDestructuring` | `false` | Do not check a destructured variable that keeps the name of the property. | ### `properties` {/* good {"options": ["^[^_]+$"]} */} - ```js const user = { first_name: "Ada" }; user.last_name = "Lovelace"; ``` {/* bad {"options": ["^[^_]+$", {"properties": true}], "count": 2} */} - ```js const user = { first_name: "Ada" }; user.last_name = "Lovelace"; @@ -74,7 +78,6 @@ user.last_name = "Lovelace"; ### `classFields` {/* bad {"options": ["^[^_]+$"], "count": 1} */} - ```js class User { first_name = "Ada"; @@ -83,7 +86,6 @@ class User { ``` {/* bad {"options": ["^[^_]+$", {"classFields": true}], "count": 2} */} - ```js class User { first_name = "Ada"; @@ -94,7 +96,6 @@ class User { ### `onlyDeclarations` {/* bad {"options": ["^[^_]+$"]} */} - ```js if (legacy_mode) { start(); @@ -102,7 +103,6 @@ if (legacy_mode) { ``` {/* good {"options": ["^[^_]+$", {"onlyDeclarations": true}]} */} - ```js if (legacy_mode) { start(); @@ -110,7 +110,6 @@ if (legacy_mode) { ``` {/* bad {"options": ["^[^_]+$", {"onlyDeclarations": true}]} */} - ```js const legacy_mode = true; ``` @@ -118,13 +117,11 @@ const legacy_mode = true; ### `ignoreDestructuring` {/* bad {"options": ["^[^_]+$"]} */} - ```js const { user_id } = row; ``` {/* good {"options": ["^[^_]+$", {"ignoreDestructuring": true}]} */} - ```js const { user_id } = row; ``` @@ -132,7 +129,6 @@ const { user_id } = row; A new name that you choose is still checked: {/* bad {"options": ["^[^_]+$", {"ignoreDestructuring": true}]} */} - ```js const { user_id: owner_id } = row; ``` diff --git a/docs/lint/rules/eslint/init-declarations.mdx b/docs/lint/rules/eslint/init-declarations.mdx index a112faf1c321..ae721e58bef0 100644 --- a/docs/lint/rules/eslint/init-declarations.mdx +++ b/docs/lint/rules/eslint/init-declarations.mdx @@ -1,6 +1,15 @@ --- title: "init-declarations" description: "Require or disallow a value in variable declarations" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/init-declarations"] +kind: "suggestion" +category: "style" +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- A variable declared without a value is `undefined` until something assigns to it. If one path through the code skips the assignment, later code reads `undefined` without any error. Giving every variable a value where it is declared closes that gap. @@ -10,7 +19,6 @@ A variable declared without a value is `undefined` until something assigns to it Reported: {/* bad */} - ```js let total; if (cart.items.length > 0) { @@ -21,7 +29,6 @@ if (cart.items.length > 0) { Not reported: {/* good */} - ```js let total = 0; if (cart.items.length > 0) { @@ -32,7 +39,6 @@ if (cart.items.length > 0) { The variable of a `for...in` or `for...of` loop gets its value from the loop, so it counts as initialized: {/* good */} - ```js for (let item of cart.items) { render(item); @@ -42,7 +48,6 @@ for (let item of cart.items) { In TypeScript, a `declare` statement cannot have a value and is not reported: {/* good */} - ```ts declare let version: string; ``` @@ -54,23 +59,21 @@ declare let version: string; init-declarations = ["error", "never", { ignoreForLoopInit = true }] ``` -| Option | What it does | -| -------------------- | --------------------------------------------------------------------------------------- | -| `"always"` (default) | Every `var` and `let` needs a value. | -| `"never"` | No `var` or `let` has a value. `const` is left alone, because it cannot do without one. | +| Option | What it does | +| --- | --- | +| `"always"` (default) | Every `var` and `let` needs a value. | +| `"never"` | No `var` or `let` has a value. `const` is left alone, because it cannot do without one. | -| Option | Default | What it does | -| ------------------- | ------- | --------------------------------------------------------------------------------------------------- | +| Option | Default | What it does | +| --- | --- | --- | | `ignoreForLoopInit` | `false` | With `"never"`, allow the variables declared in the head of a `for`, `for...in` or `for...of` loop. | {/* bad {"options": ["never"]} */} - ```js let count = 0; ``` {/* good {"options": ["never"]} */} - ```js let count; count = 0; @@ -81,7 +84,6 @@ const limit = 10; ### `ignoreForLoopInit` {/* bad {"options": ["never"], "count": 2} */} - ```js for (let i = 0; i < 3; i++) { retry(); @@ -93,7 +95,6 @@ for (let item of cart.items) { ``` {/* good {"options": ["never", {"ignoreForLoopInit": true}]} */} - ```js for (let i = 0; i < 3; i++) { retry(); diff --git a/docs/lint/rules/eslint/logical-assignment-operators.mdx b/docs/lint/rules/eslint/logical-assignment-operators.mdx index 9c148e55570c..4e176f11470b 100644 --- a/docs/lint/rules/eslint/logical-assignment-operators.mdx +++ b/docs/lint/rules/eslint/logical-assignment-operators.mdx @@ -1,6 +1,15 @@ --- title: "logical-assignment-operators" description: "Require or disallow the logical assignment operators `||=`, `&&=` and `??=`" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/logical-assignment-operators"] +kind: "suggestion" +category: "style" +default: "off" +fixable: "yes" +typeAware: false +deprecated: false --- `options = options || {}` names the variable twice, and it assigns even when `options` keeps its value. `options ||= {}` says the same with one name and assigns only when it has to. @@ -12,7 +21,6 @@ description: "Require or disallow the logical assignment operators `||=`, `&&=` Reported: {/* bad {"count": 5} */} - ```js options = options || {}; cache = cache ?? new Map(); @@ -24,7 +32,6 @@ user.name = user.name || "guest"; Not reported: {/* good */} - ```js options ||= {}; cache ??= new Map(); @@ -40,23 +47,21 @@ user.name ||= "guest"; logical-assignment-operators = ["error", "always", { enforceForIfStatements = true }] ``` -| Option | What it does | -| -------------------- | --------------------------------------------------------- | +| Option | What it does | +| --- | --- | | `"always"` (default) | Report code that could use a logical assignment operator. | -| `"never"` | Report every logical assignment operator. | +| `"never"` | Report every logical assignment operator. | -| Option | Default | What it does | -| ------------------------ | ------- | ----------------------------------------------------------------------------- | +| Option | Default | What it does | +| --- | --- | --- | | `enforceForIfStatements` | `false` | With `"always"`, also report an `if` that only assigns to the value it tests. | {/* bad {"options": ["never"]} */} - ```js options ||= {}; ``` {/* good {"options": ["never"]} */} - ```js options = options || {}; ``` @@ -64,7 +69,6 @@ options = options || {}; ### `enforceForIfStatements` {/* good */} - ```js if (!title) { title = "Untitled"; @@ -74,7 +78,6 @@ if (cache == null) cache = new Map(); ``` {/* bad {"options": ["always", {"enforceForIfStatements": true}], "count": 2} */} - ```js if (!title) { title = "Untitled"; diff --git a/docs/lint/rules/eslint/max-classes-per-file.mdx b/docs/lint/rules/eslint/max-classes-per-file.mdx index a106fa5e0044..20e87b36823b 100644 --- a/docs/lint/rules/eslint/max-classes-per-file.mdx +++ b/docs/lint/rules/eslint/max-classes-per-file.mdx @@ -1,6 +1,15 @@ --- title: "max-classes-per-file" description: "Enforce a maximum number of classes per file" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/max-classes-per-file"] +kind: "suggestion" +category: "pedantic" +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- When a file holds several classes, its name can describe only one of them, and the others are hard to find. @@ -12,7 +21,6 @@ Class declarations and class expressions both count. The default maximum is 1. Reported: {/* bad */} - ```js class User {} @@ -22,7 +30,6 @@ class Session {} Not reported: {/* good */} - ```js class User {} ``` @@ -36,13 +43,12 @@ max-classes-per-file = ["error", { max = 2, ignoreExpressions = true }] The option is either a number, the maximum, or an object: -| Option | Default | What it does | -| ------------------- | ------- | -------------------------------------- | -| `max` | `1` | The number of classes a file can have. | -| `ignoreExpressions` | `false` | Do not count class expressions. | +| Option | Default | What it does | +| --- | --- | --- | +| `max` | `1` | The number of classes a file can have. | +| `ignoreExpressions` | `false` | Do not count class expressions. | {/* good {"options": [2]} */} - ```js class User {} @@ -50,7 +56,6 @@ class Session {} ``` {/* bad {"options": [{"max": 2}]} */} - ```js class User {} @@ -62,7 +67,6 @@ class Token {} ### `ignoreExpressions` {/* bad */} - ```js class User {} @@ -70,7 +74,6 @@ const Guest = class extends User {}; ``` {/* good {"options": [{"ignoreExpressions": true}]} */} - ```js class User {} diff --git a/docs/lint/rules/eslint/max-depth.mdx b/docs/lint/rules/eslint/max-depth.mdx index 8518a0ff637a..e330fba0c94c 100644 --- a/docs/lint/rules/eslint/max-depth.mdx +++ b/docs/lint/rules/eslint/max-depth.mdx @@ -1,6 +1,15 @@ --- title: "max-depth" description: "Enforce a maximum depth that blocks can be nested" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/max-depth"] +kind: "suggestion" +category: "pedantic" +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- To understand a line that sits five blocks deep, you have to keep every condition and loop around it in your head. This rule reports a statement that is nested deeper than the maximum, which is 4 by default. @@ -12,7 +21,6 @@ To understand a line that sits five blocks deep, you have to keep every conditio With a maximum of 2, this is reported: {/* bad {"options": [2]} */} - ```js function notify(users) { for (const user of users) { @@ -28,7 +36,6 @@ function notify(users) { Not reported: {/* good {"options": [2]} */} - ```js function notify(users) { for (const user of users) { @@ -42,7 +49,6 @@ function notify(users) { An `else if` is as deep as the `if` it continues. This passes with a maximum of 1: {/* good {"options": [1]} */} - ```js if (size < 10) { small(); @@ -56,10 +62,9 @@ if (size < 10) { So does this, because the inner `if` is the first level of the callback: {/* good {"options": [1]} */} - ```js if (ready) { - items.forEach(item => { + items.forEach((item) => { if (item.done) { archive(item); } @@ -74,8 +79,8 @@ if (ready) { max-depth = ["error", 3] ``` -| Option | Default | What it does | -| ------ | ------- | --------------------------------------------------------------------- | -| `max` | `4` | The deepest level that is allowed. `maximum` is an older name for it. | +| Option | Default | What it does | +| --- | --- | --- | +| `max` | `4` | The deepest level that is allowed. `maximum` is an older name for it. | A number alone is short for `{ "max": number }`. diff --git a/docs/lint/rules/eslint/max-lines-per-function.mdx b/docs/lint/rules/eslint/max-lines-per-function.mdx index af6aa5d07da7..13312bfb7fc3 100644 --- a/docs/lint/rules/eslint/max-lines-per-function.mdx +++ b/docs/lint/rules/eslint/max-lines-per-function.mdx @@ -1,6 +1,15 @@ --- title: "max-lines-per-function" description: "Enforce a maximum number of lines in a function" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/max-lines-per-function"] +kind: "suggestion" +category: "pedantic" +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- A function that does not fit on the screen is hard to read in one go, and it usually does several jobs that could have a name each. This rule reports a function that has more lines than the maximum, which is 50 by default. @@ -12,7 +21,6 @@ The count runs from the line where the function starts to the line where it ends With a maximum of 5, this function of 7 lines is reported: {/* bad {"options": [5]} */} - ```js function checkout(cart) { let total = 0; @@ -26,7 +34,6 @@ function checkout(cart) { Not reported: {/* good {"options": [5]} */} - ```js function totalOf(cart) { let total = 0; @@ -46,19 +53,18 @@ function checkout(cart) { max-lines-per-function = ["error", { max = 80, skipBlankLines = true, skipComments = true }] ``` -| Option | Default | What it does | -| ---------------- | ------- | ------------------------------------------------------------------------------------ | -| `max` | `50` | The number of lines a function can have. | -| `skipBlankLines` | `false` | Do not count lines that are empty or only have whitespace. | -| `skipComments` | `false` | Do not count lines that only have a comment. | -| `IIFEs` | `false` | Also check a function that is called where it is written: `(function () { ... })()`. | +| Option | Default | What it does | +| --- | --- | --- | +| `max` | `50` | The number of lines a function can have. | +| `skipBlankLines` | `false` | Do not count lines that are empty or only have whitespace. | +| `skipComments` | `false` | Do not count lines that only have a comment. | +| `IIFEs` | `false` | Also check a function that is called where it is written: `(function () { ... })()`. | A number alone is short for `{ "max": number }`. This function has 5 lines, so a maximum of 3 reports it: {/* bad {"options": [3]} */} - ```js function greet(user) { // Fall back to the login name. @@ -70,7 +76,6 @@ function greet(user) { Without the comment and the blank line it has 3: {/* good {"options": [{"max": 3, "skipBlankLines": true, "skipComments": true}]} */} - ```js function greet(user) { // Fall back to the login name. @@ -82,7 +87,6 @@ function greet(user) { A function that is called where it is written is skipped by default. This passes with a maximum of 2: {/* good {"options": [2]} */} - ```js (function () { setup(); @@ -93,7 +97,6 @@ A function that is called where it is written is skipped by default. This passes With `IIFEs` it is reported: {/* bad {"options": [{"max": 2, "IIFEs": true}]} */} - ```js (function () { setup(); diff --git a/docs/lint/rules/eslint/max-lines.mdx b/docs/lint/rules/eslint/max-lines.mdx index a7f4032a104c..0c5d01b94985 100644 --- a/docs/lint/rules/eslint/max-lines.mdx +++ b/docs/lint/rules/eslint/max-lines.mdx @@ -1,6 +1,15 @@ --- title: "max-lines" description: "Enforce a maximum number of lines per file" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/max-lines"] +kind: "suggestion" +category: "pedantic" +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- A file that keeps growing ends up doing many unrelated things, and finding anything in it means scrolling. This rule reports a file that has more lines than the maximum, which is 300 by default. @@ -10,7 +19,6 @@ A file that keeps growing ends up doing many unrelated things, and finding anyth With a maximum of 4, this file of 5 lines is reported: {/* bad {"options": [4]} */} - ```js import { readFile } from "node:fs/promises"; @@ -22,7 +30,6 @@ console.log(text.split("\n").length); Not reported, with the work moved to another file: {/* good {"options": [4]} */} - ```js import { countLines } from "./count-lines.js"; console.log(await countLines("notes.txt")); @@ -35,18 +42,17 @@ console.log(await countLines("notes.txt")); max-lines = ["error", { max = 500, skipBlankLines = true, skipComments = true }] ``` -| Option | Default | What it does | -| ---------------- | ------- | ----------------------------------------------------------------------------------------- | -| `max` | `300` | The number of lines a file can have. | -| `skipBlankLines` | `false` | Do not count lines that are empty or only have whitespace. | -| `skipComments` | `false` | Do not count lines that only have a comment. A line with code and a comment still counts. | +| Option | Default | What it does | +| --- | --- | --- | +| `max` | `300` | The number of lines a file can have. | +| `skipBlankLines` | `false` | Do not count lines that are empty or only have whitespace. | +| `skipComments` | `false` | Do not count lines that only have a comment. A line with code and a comment still counts. | A number alone is short for `{ "max": number }`. With `skipBlankLines`, the first example has 3 lines: {/* good {"options": [{"max": 4, "skipBlankLines": true}]} */} - ```js import { readFile } from "node:fs/promises"; @@ -58,7 +64,6 @@ console.log(text.split("\n").length); With `skipComments`, so does this one: {/* good {"options": [{"max": 4, "skipComments": true}]} */} - ```js // Prints the number of lines in notes.txt. // Run it with: bun count.js diff --git a/docs/lint/rules/eslint/max-nested-callbacks.mdx b/docs/lint/rules/eslint/max-nested-callbacks.mdx index 8ad082d73d27..511368045d06 100644 --- a/docs/lint/rules/eslint/max-nested-callbacks.mdx +++ b/docs/lint/rules/eslint/max-nested-callbacks.mdx @@ -1,6 +1,15 @@ --- title: "max-nested-callbacks" description: "Enforce a maximum depth that callbacks can be nested" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/max-nested-callbacks"] +kind: "suggestion" +category: "pedantic" +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- Callbacks inside callbacks push the code to the right, and each level needs its own error handling. This rule reports a callback that is nested deeper than the maximum, which is 10 by default. @@ -12,11 +21,10 @@ A callback is a function expression or an arrow function that is passed as an ar With a maximum of 2, the third callback is reported: {/* bad {"options": [2]} */} - ```js -readConfig(config => { - connect(config, db => { - db.query("select 1", rows => { +readConfig((config) => { + connect(config, (db) => { + db.query("select 1", (rows) => { console.log(rows); }); }); @@ -26,7 +34,6 @@ readConfig(config => { Not reported: {/* good {"options": [2]} */} - ```js const config = await readConfig(); const db = await connect(config); @@ -41,9 +48,9 @@ console.log(rows); max-nested-callbacks = ["error", 3] ``` -| Option | Default | What it does | -| ------------------------------- | ------- | ----------------------------------------------------------------------------- | -| `max` | `10` | The deepest level that is allowed. `maximum` is an older name for it. | +| Option | Default | What it does | +| --- | --- | --- | +| `max` | `10` | The deepest level that is allowed. `maximum` is an older name for it. | | `checkConstructorCallCallbacks` | `false` | Also count a function that is passed to `new`, as in `new Promise(() => {})`. | A number alone is short for `{ "max": number }`. @@ -51,10 +58,9 @@ A number alone is short for `{ "max": number }`. By default the function passed to `new Promise` is not a level: {/* good {"options": [1]} */} - ```js button.addEventListener("click", () => { - return new Promise(resolve => { + return new Promise((resolve) => { setTimeout(resolve, 100); }); }); @@ -63,10 +69,9 @@ button.addEventListener("click", () => { With `checkConstructorCallCallbacks` it is the second level: {/* bad {"options": [{"max": 1, "checkConstructorCallCallbacks": true}]} */} - ```js button.addEventListener("click", () => { - return new Promise(resolve => { + return new Promise((resolve) => { setTimeout(resolve, 100); }); }); diff --git a/docs/lint/rules/eslint/max-params.mdx b/docs/lint/rules/eslint/max-params.mdx index 9adc70b47d6f..f79424174497 100644 --- a/docs/lint/rules/eslint/max-params.mdx +++ b/docs/lint/rules/eslint/max-params.mdx @@ -1,6 +1,15 @@ --- title: "max-params" description: "Enforce a maximum number of parameters in function definitions" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/max-params"] +kind: "suggestion" +category: "style" +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- At a call like `createUser("Ada", "ada@example.com", "admin", "core")` nothing says which argument is which, and swapping two of them goes unnoticed. This rule reports a function that has more parameters than the maximum, which is 3 by default. @@ -10,7 +19,6 @@ At a call like `createUser("Ada", "ada@example.com", "admin", "core")` nothing s Reported: {/* bad */} - ```js function createUser(name, email, role, team) { return { name, email, role, team }; @@ -20,7 +28,6 @@ function createUser(name, email, role, team) { Not reported: {/* good */} - ```js function createUser({ name, email, role, team }) { return { name, email, role, team }; @@ -34,18 +41,17 @@ function createUser({ name, email, role, team }) { max-params = ["error", 4] ``` -| Option | Default | What it does | -| --------------- | --------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| `max` | `3` | The number of parameters a function can have. `maximum` is an older name for it. | -| `countThis` | `"except-void"` | Whether a TypeScript `this` parameter counts: `"always"`, `"never"`, or `"except-void"`, which counts it unless it is `this: void`. | -| `countVoidThis` | `false` | An older spelling. `true` is the same as `countThis: "always"`. | +| Option | Default | What it does | +| --- | --- | --- | +| `max` | `3` | The number of parameters a function can have. `maximum` is an older name for it. | +| `countThis` | `"except-void"` | Whether a TypeScript `this` parameter counts: `"always"`, `"never"`, or `"except-void"`, which counts it unless it is `this: void`. | +| `countVoidThis` | `false` | An older spelling. `true` is the same as `countThis: "always"`. | A number alone is short for `{ "max": number }`. By default a typed `this` counts. This function has 3 parameters, so a maximum of 2 reports it: {/* bad {"options": [2]} */} - ```ts function onClick(this: HTMLButtonElement, event: Event, target: Element) {} ``` @@ -53,7 +59,6 @@ function onClick(this: HTMLButtonElement, event: Event, target: Element) {} With `countThis: "never"` it has 2: {/* good {"options": [{"max": 2, "countThis": "never"}]} */} - ```ts function onClick(this: HTMLButtonElement, event: Event, target: Element) {} ``` @@ -61,7 +66,6 @@ function onClick(this: HTMLButtonElement, event: Event, target: Element) {} `this: void` does not count by default, so this passes with a maximum of 2: {/* good {"options": [2]} */} - ```ts function onClick(this: void, event: Event, target: Element) {} ``` @@ -69,7 +73,6 @@ function onClick(this: void, event: Event, target: Element) {} With `countThis: "always"` it is reported: {/* bad {"options": [{"max": 2, "countThis": "always"}]} */} - ```ts function onClick(this: void, event: Event, target: Element) {} ``` diff --git a/docs/lint/rules/eslint/max-statements.mdx b/docs/lint/rules/eslint/max-statements.mdx index 9373afc8cfec..8564499ce4ff 100644 --- a/docs/lint/rules/eslint/max-statements.mdx +++ b/docs/lint/rules/eslint/max-statements.mdx @@ -1,6 +1,15 @@ --- title: "max-statements" description: "Enforce a maximum number of statements in a function" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/max-statements"] +kind: "suggestion" +category: "style" +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- A function with a long list of statements usually does several jobs, and you have to read all of it to change any part. This rule reports a function that has more statements than the maximum, which is 10 by default. @@ -12,7 +21,6 @@ Statements in nested blocks count too. The statements of an inner function count With a maximum of 3, this function of 4 statements is reported: {/* bad {"options": [3]} */} - ```js function register(form) { const user = parse(form); @@ -25,7 +33,6 @@ function register(form) { Not reported: {/* good {"options": [3]} */} - ```js function register(form) { const user = parse(form); @@ -37,7 +44,6 @@ function register(form) { This function has 3 statements, the `if` and the two in its block, so a maximum of 2 reports it: {/* bad {"options": [2]} */} - ```js function log(message) { if (verbose) { @@ -56,14 +62,13 @@ max-statements = ["error", 15, { ignoreTopLevelFunctions = true }] The first option is the maximum, as a number or as `{ "max": number }`. `maximum` is an older name for `max`. The second option is an object: -| Option | Default | What it does | -| ------------------------- | ------- | --------------------------------------------------------------------------------------------- | +| Option | Default | What it does | +| --- | --- | --- | | `ignoreTopLevelFunctions` | `false` | Do not report the only top-level function of a file, such as a wrapper around a whole script. | With it, this passes with a maximum of 2: {/* good {"options": [2, {"ignoreTopLevelFunctions": true}]} */} - ```js (function () { const app = createApp(); @@ -75,7 +80,6 @@ With it, this passes with a maximum of 2: In a file with two or more top-level functions the option changes nothing: {/* bad {"options": [2, {"ignoreTopLevelFunctions": true}]} */} - ```js function start() { const app = createApp(); diff --git a/docs/lint/rules/eslint/new-cap.mdx b/docs/lint/rules/eslint/new-cap.mdx index cd2df572fba5..96ed61efc1a8 100644 --- a/docs/lint/rules/eslint/new-cap.mdx +++ b/docs/lint/rules/eslint/new-cap.mdx @@ -1,6 +1,15 @@ --- title: "new-cap" description: "Require constructor names to begin with a capital letter" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/new-cap"] +kind: "suggestion" +category: "style" +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- A capital first letter is how a reader tells a constructor from an ordinary function. If the names do not follow that, it is easy to forget `new`: a class then throws, and an older constructor function runs with the wrong `this`. @@ -14,7 +23,6 @@ The built-ins that work without `new` are allowed: `Array`, `BigInt`, `Boolean`, Reported: {/* bad {"count": 2} */} - ```js const user = new person("Ada"); const team = Team("core"); @@ -23,7 +31,6 @@ const team = Team("core"); Not reported: {/* good */} - ```js const user = new Person("Ada"); const team = createTeam("core"); @@ -38,20 +45,19 @@ const start = Date.UTC(2026, 0, 1); new-cap = ["error", { capIsNewExceptions = ["Router"] }] ``` -| Option | Default | What it does | -| -------------------------- | ------- | ------------------------------------------------------------------------------ | -| `newIsCap` | `true` | Report `new` with a lowercase name. | -| `capIsNew` | `true` | Report a call of a capitalized name without `new`. | -| `properties` | `true` | Also check names after a dot, as in `new net.socket()` and `express.Router()`. | -| `newIsCapExceptions` | `[]` | Lowercase names that are allowed after `new`. | -| `newIsCapExceptionPattern` | none | A regular expression for such names. | -| `capIsNewExceptions` | `[]` | Capitalized names that can be called without `new`. | -| `capIsNewExceptionPattern` | none | A regular expression for such names. | +| Option | Default | What it does | +| --- | --- | --- | +| `newIsCap` | `true` | Report `new` with a lowercase name. | +| `capIsNew` | `true` | Report a call of a capitalized name without `new`. | +| `properties` | `true` | Also check names after a dot, as in `new net.socket()` and `express.Router()`. | +| `newIsCapExceptions` | `[]` | Lowercase names that are allowed after `new`. | +| `newIsCapExceptionPattern` | none | A regular expression for such names. | +| `capIsNewExceptions` | `[]` | Capitalized names that can be called without `new`. | +| `capIsNewExceptionPattern` | none | A regular expression for such names. | Reported by default: {/* bad */} - ```js const router = express.Router(); ``` @@ -59,7 +65,6 @@ const router = express.Router(); Not reported with `capIsNewExceptions: ["Router"]`. `"express.Router"` works too: {/* good {"options": [{"capIsNewExceptions": ["Router"]}]} */} - ```js const router = express.Router(); ``` @@ -67,7 +72,6 @@ const router = express.Router(); A pattern is tested against the whole `express.Router`. Not reported with `capIsNewExceptionPattern: "^express\\."`: {/* good {"options": [{"capIsNewExceptionPattern": "^express\\."}]} */} - ```js const router = express.Router(); ``` @@ -75,7 +79,6 @@ const router = express.Router(); Not reported with `newIsCapExceptions: ["moment"]`: {/* good {"options": [{"newIsCapExceptions": ["moment"]}]} */} - ```js const now = new moment(); ``` @@ -83,7 +86,6 @@ const now = new moment(); Not reported with `properties: false`: {/* good {"options": [{"properties": false}]} */} - ```js const router = express.Router(); const socket = new net.socket(); diff --git a/docs/lint/rules/eslint/no-alert.mdx b/docs/lint/rules/eslint/no-alert.mdx index dcd3fd477089..25fb8b2aa931 100644 --- a/docs/lint/rules/eslint/no-alert.mdx +++ b/docs/lint/rules/eslint/no-alert.mdx @@ -1,6 +1,15 @@ --- title: "no-alert" description: "Disallow `alert`, `confirm`, and `prompt`" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/no-alert"] +kind: "suggestion" +category: "restriction" +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- `alert`, `confirm` and `prompt` stop all JavaScript on the page until the user answers, and the box they show cannot be styled. They are often left over from debugging. @@ -10,7 +19,6 @@ description: "Disallow `alert`, `confirm`, and `prompt`" Reported: {/* bad {"count": 2} */} - ```js alert("Saved"); @@ -22,7 +30,6 @@ if (window.confirm("Delete this file?")) { Not reported: {/* good */} - ```js showToast("Saved"); @@ -34,7 +41,6 @@ if (await dialog.confirm("Delete this file?")) { A function of your own with one of these names is fine: {/* good */} - ```js import { alert } from "./ui/dialogs.js"; diff --git a/docs/lint/rules/eslint/no-array-constructor.mdx b/docs/lint/rules/eslint/no-array-constructor.mdx index 0596b532867a..0c3b2363ac0f 100644 --- a/docs/lint/rules/eslint/no-array-constructor.mdx +++ b/docs/lint/rules/eslint/no-array-constructor.mdx @@ -1,6 +1,15 @@ --- title: "no-array-constructor" description: "Disallow `Array` constructors in favor of array literals" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/no-array-constructor"] +kind: "suggestion" +category: "pedantic" +default: "off" +fixable: "yes" +typeAware: false +deprecated: false --- What `Array()` does depends on how many arguments it gets. `Array(10, 20)` is an array with two items, but `Array(10)` is an array with 10 empty slots. An array literal always holds what you wrote. @@ -14,7 +23,6 @@ The rule reports `Array()` and `new Array()` with no argument or with more than Reported: {/* bad {"count": 2} */} - ```js const empty = new Array(); const sizes = Array(10, 20, 30); @@ -23,7 +31,6 @@ const sizes = Array(10, 20, 30); Not reported: {/* good */} - ```js const empty = []; const sizes = [10, 20, 30]; @@ -33,7 +40,6 @@ const slots = new Array(5); In TypeScript, a call with a type argument is not reported either: {/* good */} - ```ts const names = new Array(); ``` diff --git a/docs/lint/rules/eslint/no-async-promise-executor.mdx b/docs/lint/rules/eslint/no-async-promise-executor.mdx index 2352e2c05e0d..4678f5b7e49c 100644 --- a/docs/lint/rules/eslint/no-async-promise-executor.mdx +++ b/docs/lint/rules/eslint/no-async-promise-executor.mdx @@ -1,6 +1,15 @@ --- title: "no-async-promise-executor" description: "Disallow an async function as a Promise executor" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/no-async-promise-executor"] +kind: "problem" +category: "correctness" +default: "error" +fixable: "no" +typeAware: false +deprecated: false --- If the function you pass to `new Promise` is async and it throws, or something it awaits rejects, the new promise never hears about it. It stays pending forever, and a `.catch()` on it or a `try` around its `await` does not see the error. @@ -14,9 +23,8 @@ This rule is on by default. Reported: {/* bad */} - ```js -const user = new Promise(async resolve => { +const user = new Promise(async (resolve) => { const response = await fetch("/api/user"); resolve(await response.json()); }); @@ -25,7 +33,6 @@ const user = new Promise(async resolve => { Not reported: {/* good */} - ```js async function loadUser() { const response = await fetch("/api/user"); diff --git a/docs/lint/rules/eslint/no-await-in-loop.mdx b/docs/lint/rules/eslint/no-await-in-loop.mdx index 3fa834352bd6..c2a6f9534110 100644 --- a/docs/lint/rules/eslint/no-await-in-loop.mdx +++ b/docs/lint/rules/eslint/no-await-in-loop.mdx @@ -1,6 +1,15 @@ --- title: "no-await-in-loop" description: "Disallow `await` inside of loops" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/no-await-in-loop"] +kind: "problem" +category: "perf" +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- An `await` in a loop makes every round wait for the one before it. Ten requests of 100 ms each take a second, where starting them together and awaiting them with `Promise.all` takes about 100 ms. @@ -10,7 +19,6 @@ An `await` in a loop makes every round wait for the one before it. Ten requests Reported: {/* bad */} - ```js async function loadAll(urls) { const pages = []; @@ -24,17 +32,15 @@ async function loadAll(urls) { Not reported: {/* good */} - ```js async function loadAll(urls) { - return Promise.all(urls.map(url => fetch(url))); + return Promise.all(urls.map((url) => fetch(url))); } ``` A `for await` loop is meant to wait, so an `await` in its body is not reported: {/* good */} - ```js async function copy(stream, file) { for await (const chunk of stream) { @@ -46,7 +52,6 @@ async function copy(stream, file) { The list a `for ... of` loop runs over is only evaluated once: {/* good */} - ```js async function printUsers() { for (const user of await loadUsers()) { diff --git a/docs/lint/rules/eslint/no-bitwise.mdx b/docs/lint/rules/eslint/no-bitwise.mdx index 648c77f8928b..2b71ca970780 100644 --- a/docs/lint/rules/eslint/no-bitwise.mdx +++ b/docs/lint/rules/eslint/no-bitwise.mdx @@ -1,6 +1,15 @@ --- title: "no-bitwise" description: "Disallow bitwise operators" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/no-bitwise"] +kind: "suggestion" +category: "restriction" +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- Most application code never needs `&`, `|`, `^`, `~`, `<<`, `>>` or `>>>`, so a single `&` or `|` is usually a typo for `&&` or `||`. The code still runs, but it evaluates both sides every time and gives a number instead of one of the operands. @@ -12,7 +21,6 @@ The assignment forms such as `|=` and `<<=` are reported too. Reported: {/* bad */} - ```js if (user.active & user.verified) { grant(user); @@ -22,7 +30,6 @@ if (user.active & user.verified) { Not reported: {/* good */} - ```js if (user.active && user.verified) { grant(user); @@ -36,15 +43,14 @@ if (user.active && user.verified) { no-bitwise = ["error", { allow = ["&", "|"] }] ``` -| Option | Default | What it does | -| ----------- | ------- | ------------------------------------------------------------------- | -| `allow` | `[]` | Operators that are not reported. | +| Option | Default | What it does | +| --- | --- | --- | +| `allow` | `[]` | Operators that are not reported. | | `int32Hint` | `false` | Allow `value \| 0`, a way to cut a number down to a 32-bit integer. | With `allow: ["&", "|"]`, this is not reported: {/* good {"options": [{"allow": ["&", "|"]}]} */} - ```js const mode = READ | WRITE; const canWrite = (mode & WRITE) !== 0; @@ -53,7 +59,6 @@ const canWrite = (mode & WRITE) !== 0; With `int32Hint`, neither is this: {/* good {"options": [{"int32Hint": true}]} */} - ```js const whole = price | 0; ``` @@ -61,7 +66,6 @@ const whole = price | 0; Any other number is still reported: {/* bad {"options": [{"int32Hint": true}]} */} - ```js const odd = price | 1; ``` diff --git a/docs/lint/rules/eslint/no-buffer-constructor.mdx b/docs/lint/rules/eslint/no-buffer-constructor.mdx index 62f71122a806..5115aa6d8d75 100644 --- a/docs/lint/rules/eslint/no-buffer-constructor.mdx +++ b/docs/lint/rules/eslint/no-buffer-constructor.mdx @@ -1,6 +1,15 @@ --- title: "no-buffer-constructor" description: "Disallow the deprecated `Buffer()` constructor" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/no-buffer-constructor"] +kind: "problem" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: true --- `Buffer(value)` does different things depending on the type of `value`: a string is copied, a number allocates that many bytes. If `value` comes from a request, a caller who sends a number where you expect a string decides how much memory your server allocates. @@ -12,7 +21,6 @@ Node.js has deprecated the constructor. `Buffer.from()`, `Buffer.alloc()` and `B Reported: {/* bad {"count": 2} */} - ```js const header = new Buffer(16); const name = Buffer("Ada", "utf8"); @@ -21,7 +29,6 @@ const name = Buffer("Ada", "utf8"); Not reported: {/* good */} - ```js const header = Buffer.alloc(16); const name = Buffer.from("Ada", "utf8"); diff --git a/docs/lint/rules/eslint/no-caller.mdx b/docs/lint/rules/eslint/no-caller.mdx index c5f745166405..a6ee509bfdf5 100644 --- a/docs/lint/rules/eslint/no-caller.mdx +++ b/docs/lint/rules/eslint/no-caller.mdx @@ -1,6 +1,15 @@ --- title: "no-caller" description: "Disallow `arguments.caller` and `arguments.callee`" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/no-caller"] +kind: "suggestion" +category: "correctness" +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- Reading `arguments.callee` throws a `TypeError` in strict mode, and every ES module and every class body is in strict mode. `arguments.caller` is no longer part of the language. @@ -12,7 +21,6 @@ To let a function call itself, give it a name. Reported: {/* bad */} - ```js const factorial = function (n) { return n <= 1 ? 1 : n * arguments.callee(n - 1); @@ -22,7 +30,6 @@ const factorial = function (n) { Not reported: {/* good */} - ```js const factorial = function fact(n) { return n <= 1 ? 1 : n * fact(n - 1); diff --git a/docs/lint/rules/eslint/no-case-declarations.mdx b/docs/lint/rules/eslint/no-case-declarations.mdx index 63826c2ee597..9083f28cf5af 100644 --- a/docs/lint/rules/eslint/no-case-declarations.mdx +++ b/docs/lint/rules/eslint/no-case-declarations.mdx @@ -1,6 +1,15 @@ --- title: "no-case-declarations" description: "Disallow `let`, `const`, `function` and `class` declarations directly in a `case` clause" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/no-case-declarations"] +kind: "suggestion" +category: "pedantic" +default: "warn" +fixable: "suggestion" +typeAware: false +deprecated: false --- All the `case` clauses of a `switch` share one scope. A `let`, `const` or `class` that you declare in one clause is visible in the others, but it only gets its value if its own clause runs. Reading it from another clause throws a `ReferenceError`, and declaring the same name in two clauses is a syntax error. @@ -14,7 +23,6 @@ This rule is on by default. Reported: {/* bad */} - ```js switch (action.type) { case "add": @@ -29,7 +37,6 @@ switch (action.type) { Not reported: {/* good */} - ```js switch (action.type) { case "add": { diff --git a/docs/lint/rules/eslint/no-catch-shadow.mdx b/docs/lint/rules/eslint/no-catch-shadow.mdx index ab9da1dcc063..2c455649bc3a 100644 --- a/docs/lint/rules/eslint/no-catch-shadow.mdx +++ b/docs/lint/rules/eslint/no-catch-shadow.mdx @@ -1,6 +1,15 @@ --- title: "no-catch-shadow" description: "Disallow a `catch` parameter with the name of a variable in the scope around it" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/no-catch-shadow"] +kind: "suggestion" +category: null +default: "off" +fixable: "no" +typeAware: false +deprecated: true --- In Internet Explorer 8 and earlier, the parameter of a `catch` clause leaks into the scope around it. If a variable there has the same name, the caught error overwrites it. @@ -12,7 +21,6 @@ The rule reports a `catch` parameter that has the name of a variable, a function Reported: {/* bad */} - ```js const error = loadLastError(); @@ -26,7 +34,6 @@ try { Not reported: {/* good */} - ```js const error = loadLastError(); diff --git a/docs/lint/rules/eslint/no-class-assign.mdx b/docs/lint/rules/eslint/no-class-assign.mdx index de27198eefa3..43054aeff944 100644 --- a/docs/lint/rules/eslint/no-class-assign.mdx +++ b/docs/lint/rules/eslint/no-class-assign.mdx @@ -1,6 +1,15 @@ --- title: "no-class-assign" description: "Disallow assigning to the name of a class" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/no-class-assign"] +kind: "problem" +category: "correctness" +default: "error" +fixable: "no" +typeAware: false +deprecated: false --- The name of a class declaration can be assigned to like a `let` variable. After that the class is gone, and every `new Logger()` and `instanceof Logger` in the file works with the new value. That is almost always a mistake. @@ -12,7 +21,6 @@ This rule is on by default in JavaScript files. It is off in TypeScript files, w Reported: {/* bad */} - ```js class Logger {} @@ -22,7 +30,6 @@ Logger = createLogger(); Not reported: {/* good */} - ```js class Logger {} @@ -32,7 +39,6 @@ const logger = createLogger(); A variable that holds a class expression is an ordinary variable: {/* good */} - ```js let Store = class {}; diff --git a/docs/lint/rules/eslint/no-compare-neg-zero.mdx b/docs/lint/rules/eslint/no-compare-neg-zero.mdx index a4112dd3f10b..17abe85b366a 100644 --- a/docs/lint/rules/eslint/no-compare-neg-zero.mdx +++ b/docs/lint/rules/eslint/no-compare-neg-zero.mdx @@ -1,6 +1,15 @@ --- title: "no-compare-neg-zero" description: "Disallow comparing against `-0`" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/no-compare-neg-zero"] +kind: "problem" +category: "correctness" +default: "error" +fixable: "suggestion" +typeAware: false +deprecated: false --- `0` and `-0` compare as equal, so `x === -0` is also `true` when `x` is `0`. The minus sign has no effect. @@ -14,7 +23,6 @@ This rule is on by default. Reported: {/* bad */} - ```js if (offset === -0) { flipDirection(); @@ -24,7 +32,6 @@ if (offset === -0) { Not reported: {/* good */} - ```js if (Object.is(offset, -0)) { flipDirection(); @@ -38,7 +45,6 @@ if (offset === 0) { `<`, `<=`, `>` and `>=` are reported too: {/* bad */} - ```js const isForward = speed >= -0; ``` diff --git a/docs/lint/rules/eslint/no-cond-assign.mdx b/docs/lint/rules/eslint/no-cond-assign.mdx index 60ce26ae12d1..5852c3e89806 100644 --- a/docs/lint/rules/eslint/no-cond-assign.mdx +++ b/docs/lint/rules/eslint/no-cond-assign.mdx @@ -1,6 +1,15 @@ --- title: "no-cond-assign" description: "Disallow assignments in the test of `if`, loops and `? :`" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/no-cond-assign"] +kind: "problem" +category: "correctness" +default: "error" +fixable: "no" +typeAware: false +deprecated: false --- `if (user.role = "admin")` is usually a typo for `===`. It overwrites `user.role`, and the branch always runs because `"admin"` is truthy. @@ -14,9 +23,8 @@ This rule is on by default. Reported: {/* bad */} - ```js -if ((user.role = "admin")) { +if (user.role = "admin") { showSettings(); } ``` @@ -24,7 +32,6 @@ if ((user.role = "admin")) { Not reported: {/* good */} - ```js if (user.role === "admin") { showSettings(); @@ -38,15 +45,14 @@ while ((node = node.next)) { ## Options -| Option | What it does | -| --------------------------- | ----------------------------------------------------------------------------------- | +| Option | What it does | +| --- | --- | | `"except-parens"` (default) | Report a test that is an assignment, unless the assignment has its own parentheses. | -| `"always"` | Report every assignment inside a test, with or without parentheses. | +| `"always"` | Report every assignment inside a test, with or without parentheses. | With `"always"`, an assignment that is only part of the test is reported too: {/* bad {"options": ["always"]} */} - ```js let match; while ((match = pattern.exec(text)) !== null) { @@ -57,7 +63,6 @@ while ((match = pattern.exec(text)) !== null) { The default allows it: {/* good */} - ```js let match; while ((match = pattern.exec(text)) !== null) { @@ -68,13 +73,11 @@ while ((match = pattern.exec(text)) !== null) { With the default, the test of `? :` needs two pairs of parentheses. One pair is still reported: {/* bad */} - ```js const label = (current = next()) ? current.name : "none"; ``` {/* good */} - ```js -const label = (current = next()) ? current.name : "none"; +const label = ((current = next())) ? current.name : "none"; ``` diff --git a/docs/lint/rules/eslint/no-console.mdx b/docs/lint/rules/eslint/no-console.mdx index f587f29c1ac0..2dfb810b32be 100644 --- a/docs/lint/rules/eslint/no-console.mdx +++ b/docs/lint/rules/eslint/no-console.mdx @@ -1,6 +1,15 @@ --- title: "no-console" description: "Disallow the use of `console`" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/no-console"] +kind: "suggestion" +category: "restriction" +default: "off" +fixable: "suggestion" +typeAware: false +deprecated: false --- `console.log()` calls added while debugging are easy to forget. In a web page they show up in every visitor's console, and in a library they write to the output of someone else's program. @@ -12,7 +21,6 @@ Any property of the global `console` is reported, whether it is called or not. A Reported: {/* bad {"count": 2} */} - ```js console.log("loaded", items.length); @@ -22,7 +30,6 @@ items.forEach(console.warn); Not reported: {/* good */} - ```js import { logger } from "./logger.js"; @@ -30,7 +37,6 @@ logger.info("loaded", items.length); ``` {/* good */} - ```js function report(console) { console.log("done"); @@ -39,9 +45,9 @@ function report(console) { ## Options -| Option | Default | What it does | -| ------- | ------- | --------------------------------------- | -| `allow` | none | The `console` methods that are allowed. | +| Option | Default | What it does | +| --- | --- | --- | +| `allow` | none | The `console` methods that are allowed. | ```toml bunfig.toml [lint.rules] @@ -49,13 +55,11 @@ no-console = ["error", { allow = ["warn", "error"] }] ``` {/* good {"options": [{"allow": ["warn", "error"]}]} */} - ```js console.error("could not save", reason); ``` {/* bad {"options": [{"allow": ["warn", "error"]}]} */} - ```js console.log("saved"); ``` diff --git a/docs/lint/rules/eslint/no-const-assign.mdx b/docs/lint/rules/eslint/no-const-assign.mdx index 47819861737a..d139998918e8 100644 --- a/docs/lint/rules/eslint/no-const-assign.mdx +++ b/docs/lint/rules/eslint/no-const-assign.mdx @@ -1,6 +1,15 @@ --- title: "no-const-assign" description: "Disallow reassigning `const`, `using` and `await using` variables" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/no-const-assign"] +kind: "problem" +category: "correctness" +default: "error" +fixable: "no" +typeAware: false +deprecated: false --- Assigning to a variable declared with `const`, `using` or `await using` throws a `TypeError` when that line runs. The rule catches it earlier, including in branches your tests never reach. @@ -14,7 +23,6 @@ This rule is on by default in JavaScript files. It is off in TypeScript files, w Reported: {/* bad {"count": 2} */} - ```js const retries = 0; @@ -30,7 +38,6 @@ function reset() { Not reported: {/* good */} - ```js let retries = 0; @@ -42,7 +49,6 @@ function onFailure() { `const` protects the variable, not the value it holds. Changing a property is fine: {/* good */} - ```js const settings = { theme: "light" }; settings.theme = "dark"; diff --git a/docs/lint/rules/eslint/no-constant-binary-expression.mdx b/docs/lint/rules/eslint/no-constant-binary-expression.mdx index f7ad6ee68e93..622112088056 100644 --- a/docs/lint/rules/eslint/no-constant-binary-expression.mdx +++ b/docs/lint/rules/eslint/no-constant-binary-expression.mdx @@ -1,6 +1,15 @@ --- title: "no-constant-binary-expression" description: "Disallow comparisons and `&&`, `||`, `??` that always give the same result" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/no-constant-binary-expression"] +kind: "problem" +category: "correctness" +default: "error" +fixable: "no" +typeAware: false +deprecated: false --- Some expressions look like they depend on a value but always give the same result. `a + b ?? c` means `(a + b) ?? c`, and a sum is never `null`, so `c` is never used. `items === []` is always `false`, because `[]` is a new array that is equal to nothing else. @@ -18,7 +27,6 @@ This rule is on by default. Reported: {/* bad {"count": 3} */} - ```js const label = "Hello, " + user.name ?? "guest"; @@ -32,7 +40,6 @@ const isMissing = !user.email === undefined; Not reported: {/* good */} - ```js const label = "Hello, " + (user.name ?? "guest"); @@ -45,18 +52,16 @@ const isMissing = user.email === undefined; ## Options -| Option | Default | What it does | -| ---------------------------- | ------- | ------------------------------------------------------------------------------------------------------------- | +| Option | Default | What it does | +| --- | --- | --- | | `checkRelationalComparisons` | `false` | Also report `<`, `<=`, `>` and `>=` between two literal values. With an oxlint config, the default is `true`. | {/* bad {"options": [{"checkRelationalComparisons": true}]} */} - ```js const isLarge = 1024 > 4096; ``` {/* good */} - ```js const isLarge = 1024 > 4096; ``` diff --git a/docs/lint/rules/eslint/no-constant-condition.mdx b/docs/lint/rules/eslint/no-constant-condition.mdx index b7aabc6b8cfe..213142b9e0e1 100644 --- a/docs/lint/rules/eslint/no-constant-condition.mdx +++ b/docs/lint/rules/eslint/no-constant-condition.mdx @@ -1,6 +1,15 @@ --- title: "no-constant-condition" description: "Disallow conditions that are always truthy or always falsy" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/no-constant-condition"] +kind: "problem" +category: "correctness" +default: "warn" +fixable: "no" +typeAware: false +deprecated: false --- If a condition never changes, one branch always runs and the other is dead code. It is often a mistake: `role === "admin" || "owner"` is always truthy, because `"owner"` is a non-empty string. @@ -14,7 +23,6 @@ This rule is on by default. Reported: {/* bad {"count": 2} */} - ```js if (role === "admin" || "owner") { showSettings(); @@ -26,7 +34,6 @@ const title = "" ? "Draft" : "Published"; Not reported: {/* good */} - ```js if (role === "admin" || role === "owner") { showSettings(); @@ -43,14 +50,13 @@ while (true) { `checkLoops` controls how loops are checked: -| Value | What it does | -| -------------------------------- | -------------------------------------- | +| Value | What it does | +| --- | --- | | `"allExceptWhileTrue"` (default) | Check loops, but allow `while (true)`. | -| `"all"` or `true` | Check loops, including `while (true)`. | -| `"none"` or `false` | Do not check loops. | +| `"all"` or `true` | Check loops, including `while (true)`. | +| `"none"` or `false` | Do not check loops. | {/* bad {"options": [{"checkLoops": "all"}]} */} - ```js while (true) { poll(); @@ -60,7 +66,6 @@ while (true) { The default only allows the literal `true` in a `while`: {/* bad {"count": 2} */} - ```js while (1) { poll(); @@ -72,7 +77,6 @@ do { ``` {/* good {"options": [{"checkLoops": "none"}]} */} - ```js while (1) { poll(); @@ -82,7 +86,6 @@ while (1) { A loop in a generator function is not reported if it contains a `yield`, because the caller decides when to stop: {/* good {"options": [{"checkLoops": "all"}]} */} - ```js function* ids() { let next = 0; diff --git a/docs/lint/rules/eslint/no-constructor-return.mdx b/docs/lint/rules/eslint/no-constructor-return.mdx index 3ce0cbb1752b..a587bab447d2 100644 --- a/docs/lint/rules/eslint/no-constructor-return.mdx +++ b/docs/lint/rules/eslint/no-constructor-return.mdx @@ -1,6 +1,15 @@ --- title: "no-constructor-return" description: "Disallow returning a value from a constructor" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/no-constructor-return"] +kind: "problem" +category: "pedantic" +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- If a constructor returns an object, `new` gives the caller that object instead of the new instance, so the result may not be an instance of the class. Returning a primitive such as a number is ignored, or throws a `TypeError` if the class extends another one. @@ -12,7 +21,6 @@ A bare `return;` is allowed, to leave the constructor early. Reported: {/* bad */} - ```js class Connection { constructor(url) { @@ -27,7 +35,6 @@ class Connection { Not reported: {/* good */} - ```js class Connection { constructor(url) { @@ -44,7 +51,6 @@ class Connection { ``` {/* good */} - ```js class Timer { constructor(delay) { diff --git a/docs/lint/rules/eslint/no-continue.mdx b/docs/lint/rules/eslint/no-continue.mdx index eb106be2d470..bad03d2f8364 100644 --- a/docs/lint/rules/eslint/no-continue.mdx +++ b/docs/lint/rules/eslint/no-continue.mdx @@ -1,6 +1,15 @@ --- title: "no-continue" description: "Disallow `continue` statements" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/no-continue"] +kind: "suggestion" +category: "style" +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- `continue` jumps to the next iteration from the middle of the loop body. In a long loop, you have to find every `continue` to know which lines run for which items. An `if` around the rest of the body shows that in the structure of the code. @@ -12,7 +21,6 @@ description: "Disallow `continue` statements" Reported: {/* bad */} - ```js for (const user of users) { if (!user.active) { @@ -25,7 +33,6 @@ for (const user of users) { Not reported: {/* good */} - ```js for (const user of users) { if (user.active) { diff --git a/docs/lint/rules/eslint/no-control-regex.mdx b/docs/lint/rules/eslint/no-control-regex.mdx index 7e898cd2599e..4ee2b072d66c 100644 --- a/docs/lint/rules/eslint/no-control-regex.mdx +++ b/docs/lint/rules/eslint/no-control-regex.mdx @@ -1,6 +1,15 @@ --- title: "no-control-regex" description: "Disallow control characters in regular expressions" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/no-control-regex"] +kind: "problem" +category: "correctness" +default: "error" +fixable: "no" +typeAware: false +deprecated: false --- The characters U+0000 to U+001F are invisible and rarely appear in text you want to match. In a regular expression, one is often a typo in an escape, such as `\x0C` where `\xC0` was meant. @@ -14,7 +23,6 @@ This rule is on by default. Reported: {/* bad {"count": 2} */} - ```js const lineBreak = /\x0A/; @@ -24,7 +32,6 @@ const separator = new RegExp("\x1F"); Not reported: {/* good */} - ```js const lineBreak = /\n/; @@ -34,13 +41,11 @@ const space = /\x20/; In a string, `"\n"` is the raw character by the time `RegExp` sees it. Escape the backslash: {/* bad */} - ```js const lineBreak = new RegExp("\n"); ``` {/* good */} - ```js const lineBreak = new RegExp("\\n"); ``` @@ -50,7 +55,6 @@ const lineBreak = new RegExp("\\n"); Some formats are built on control characters. Terminal color codes start with `\x1B`, so a pattern that strips them has to contain it. Disable the rule for that line: {/* good */} - ```js // eslint-disable-next-line no-control-regex const colorCodes = /\x1B\[[0-9;]*m/g; diff --git a/docs/lint/rules/eslint/no-debugger.mdx b/docs/lint/rules/eslint/no-debugger.mdx index 2c827f59e9c4..250069af6691 100644 --- a/docs/lint/rules/eslint/no-debugger.mdx +++ b/docs/lint/rules/eslint/no-debugger.mdx @@ -1,6 +1,15 @@ --- title: "no-debugger" description: "Disallow `debugger` statements" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/no-debugger"] +kind: "problem" +category: "correctness" +default: "error" +fixable: "no" +typeAware: false +deprecated: false --- `debugger` pauses the program when a debugger is attached, for example when the browser's developer tools are open. It is useful while you track down a bug. Committed by accident, it freezes the page for anyone who has the tools open. @@ -12,7 +21,6 @@ This rule is on by default. Reported: {/* bad */} - ```js function total(items) { debugger; @@ -23,7 +31,6 @@ function total(items) { Not reported: {/* good */} - ```js function total(items) { return items.reduce((sum, item) => sum + item.price, 0); diff --git a/docs/lint/rules/eslint/no-delete-var.mdx b/docs/lint/rules/eslint/no-delete-var.mdx index 831e2acfa5de..e3502f9adc14 100644 --- a/docs/lint/rules/eslint/no-delete-var.mdx +++ b/docs/lint/rules/eslint/no-delete-var.mdx @@ -1,6 +1,15 @@ --- title: "no-delete-var" description: "Disallow `delete` on variables" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/no-delete-var"] +kind: "suggestion" +category: "correctness" +default: "warn" +fixable: "no" +typeAware: false +deprecated: false --- `delete` removes a property from an object. It does not remove a variable: on one declared with `var`, `let` or `const` it does nothing and returns `false`. @@ -14,7 +23,6 @@ This rule is on by default. Reported, in a `.cjs` file: {/* bad {"filename": "legacy.cjs"} */} - ```js var session = createSession(); delete session; @@ -23,7 +31,6 @@ delete session; Not reported: {/* good */} - ```js var session = createSession(); session = undefined; diff --git a/docs/lint/rules/eslint/no-div-regex.mdx b/docs/lint/rules/eslint/no-div-regex.mdx index c220fefe4e49..d6a319b5fa53 100644 --- a/docs/lint/rules/eslint/no-div-regex.mdx +++ b/docs/lint/rules/eslint/no-div-regex.mdx @@ -1,6 +1,15 @@ --- title: "no-div-regex" description: "Disallow regular expressions that start with `=`" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/no-div-regex"] +kind: "suggestion" +category: "restriction" +default: "off" +fixable: "yes" +typeAware: false +deprecated: false --- A regular expression literal that starts with `=` begins with `/=`, which is also the division assignment operator. You have to look twice to see which one it is. @@ -12,7 +21,6 @@ A regular expression literal that starts with `=` begins with `/=`, which is als Reported: {/* bad */} - ```js const value = line.replace(/=\s*/, ""); ``` @@ -20,7 +28,6 @@ const value = line.replace(/=\s*/, ""); Not reported: {/* good */} - ```js const value = line.replace(/[=]\s*/, ""); ``` diff --git a/docs/lint/rules/eslint/no-dupe-args.mdx b/docs/lint/rules/eslint/no-dupe-args.mdx index 1193d7056caa..135e8aeceb51 100644 --- a/docs/lint/rules/eslint/no-dupe-args.mdx +++ b/docs/lint/rules/eslint/no-dupe-args.mdx @@ -1,6 +1,15 @@ --- title: "no-dupe-args" description: "Disallow two parameters with the same name in a function" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/no-dupe-args"] +kind: "problem" +category: null +default: "error" +fixable: "no" +typeAware: false +deprecated: false --- When two parameters have the same name, the name refers to the last one. The argument passed for the first one can only be reached through `arguments`. It is nearly always a typo or a copy-paste mistake. @@ -14,7 +23,6 @@ This rule is on by default in JavaScript files. It is off in TypeScript files, w Reported, in a `.cjs` file: {/* bad {"filename": "legacy.cjs"} */} - ```js function resize(width, height, width) { return width * height; @@ -24,7 +32,6 @@ function resize(width, height, width) { Not reported: {/* good */} - ```js function resize(width, height, depth) { return width * height * depth; diff --git a/docs/lint/rules/eslint/no-dupe-class-members.mdx b/docs/lint/rules/eslint/no-dupe-class-members.mdx index 1a63ba6d4c3d..be8c6eab2ec5 100644 --- a/docs/lint/rules/eslint/no-dupe-class-members.mdx +++ b/docs/lint/rules/eslint/no-dupe-class-members.mdx @@ -1,6 +1,15 @@ --- title: "no-dupe-class-members" description: "Disallow two class members with the same name" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/no-dupe-class-members"] +kind: "problem" +category: "correctness" +default: "error" +fixable: "no" +typeAware: false +deprecated: false --- When a class declares two methods with the same name, the last one silently replaces the first. Calls run the second method, and edits to the first one have no effect. @@ -14,7 +23,6 @@ This rule is on by default in JavaScript files. It is off in TypeScript files, w Reported: {/* bad */} - ```js class Cart { total() { @@ -30,7 +38,6 @@ class Cart { Not reported: {/* good */} - ```js class Cart { count() { @@ -44,7 +51,6 @@ class Cart { ``` {/* good */} - ```js class Cart { #discount = 0; @@ -66,7 +72,6 @@ class Cart { A field and a method with the same name are reported too: {/* bad */} - ```js class Cart { total = 0; @@ -80,7 +85,6 @@ class Cart { In TypeScript, overload signatures do not count: {/* good */} - ```ts class Cart { add(id: string): void; diff --git a/docs/lint/rules/eslint/no-dupe-else-if.mdx b/docs/lint/rules/eslint/no-dupe-else-if.mdx index 74b8032de8f6..fb6f80b72ff3 100644 --- a/docs/lint/rules/eslint/no-dupe-else-if.mdx +++ b/docs/lint/rules/eslint/no-dupe-else-if.mdx @@ -1,6 +1,15 @@ --- title: "no-dupe-else-if" description: "Disallow an `else if` with a condition that an earlier branch already covers" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/no-dupe-else-if"] +kind: "problem" +category: "correctness" +default: "error" +fixable: "no" +typeAware: false +deprecated: false --- In an `if ... else if` chain, a branch only runs if every condition before it was false. If a condition repeats an earlier one, its branch can never run. It is usually a copied line where the condition was not changed. @@ -14,7 +23,6 @@ This rule is on by default. Reported: {/* bad */} - ```js if (status === "active") { enable(); @@ -28,7 +36,6 @@ if (status === "active") { Not reported: {/* good */} - ```js if (status === "active") { enable(); @@ -42,7 +49,6 @@ if (status === "active") { A condition that is narrower than an earlier one is reported too: {/* bad {"count": 2} */} - ```js if (user.isAdmin || user.isOwner) { showAll(); @@ -56,7 +62,6 @@ if (user.isAdmin || user.isOwner) { Put the narrower condition first: {/* good */} - ```js if (user.isOwner && user.isVerified) { showMost(); diff --git a/docs/lint/rules/eslint/no-dupe-keys.mdx b/docs/lint/rules/eslint/no-dupe-keys.mdx index 8323df2ef275..c33b6b28ed1e 100644 --- a/docs/lint/rules/eslint/no-dupe-keys.mdx +++ b/docs/lint/rules/eslint/no-dupe-keys.mdx @@ -1,6 +1,15 @@ --- title: "no-dupe-keys" description: "Disallow two keys with the same name in an object literal" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/no-dupe-keys"] +kind: "problem" +category: "correctness" +default: "error" +fixable: "no" +typeAware: false +deprecated: false --- When an object literal has the same key twice, the last value silently replaces the first. In a long object it is easy to add a key that is already there, and then one of the two has no effect. @@ -14,7 +23,6 @@ This rule is on by default in JavaScript files. It is off in TypeScript files, w Reported: {/* bad */} - ```js const options = { timeout: 1000, @@ -26,7 +34,6 @@ const options = { Not reported: {/* good */} - ```js const options = { timeout: 5000, @@ -37,7 +44,6 @@ const options = { Keys are compared by value, so `"timeout"` is the same key as `timeout`, and `0x1` is the same key as `1`: {/* bad {"count": 2} */} - ```js const options = { timeout: 1000, @@ -53,7 +59,6 @@ const labels = { A computed key such as `[name]` and a spread are not compared, because their keys are only known at runtime: {/* good */} - ```js const options = { timeout: 1000, diff --git a/docs/lint/rules/eslint/no-duplicate-case.mdx b/docs/lint/rules/eslint/no-duplicate-case.mdx index b1341b64cd9e..db07839fe6b9 100644 --- a/docs/lint/rules/eslint/no-duplicate-case.mdx +++ b/docs/lint/rules/eslint/no-duplicate-case.mdx @@ -1,6 +1,15 @@ --- title: "no-duplicate-case" description: "Disallow two `case` labels with the same test in a `switch`" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/no-duplicate-case"] +kind: "problem" +category: "correctness" +default: "error" +fixable: "no" +typeAware: false +deprecated: false --- A `switch` runs the first `case` that matches. A second `case` with the same test is never chosen, so its code only runs if the case above it falls through. It is usually a copied `case` where the label was not changed. @@ -14,7 +23,6 @@ This rule is on by default. Reported: {/* bad */} - ```js switch (event.key) { case "ArrowUp": @@ -32,7 +40,6 @@ switch (event.key) { Not reported: {/* good */} - ```js switch (event.key) { case "ArrowUp": @@ -50,7 +57,6 @@ switch (event.key) { Labels do not have to be literals: {/* bad */} - ```js switch (true) { case size > limit: diff --git a/docs/lint/rules/eslint/no-duplicate-imports.mdx b/docs/lint/rules/eslint/no-duplicate-imports.mdx index 51d949bfdc59..e6f693b3871d 100644 --- a/docs/lint/rules/eslint/no-duplicate-imports.mdx +++ b/docs/lint/rules/eslint/no-duplicate-imports.mdx @@ -1,6 +1,15 @@ --- title: "no-duplicate-imports" description: "Disallow importing from the same module more than once in a file" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/no-duplicate-imports"] +kind: "problem" +category: "style" +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- Two `import` statements for the same module split one list of names over two places. A reader has to find both to know what the file takes from that module, and the next person to add a name has to pick one. @@ -12,7 +21,6 @@ Only statements that can be merged are reported. `import * as path` and `import Reported: {/* bad */} - ```js import { readFile } from "node:fs/promises"; import { join } from "node:path"; @@ -22,14 +30,12 @@ import { writeFile } from "node:fs/promises"; Not reported: {/* good */} - ```js import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; ``` {/* good */} - ```js import * as path from "node:path"; import { join } from "node:path"; @@ -37,15 +43,14 @@ import { join } from "node:path"; ## Options -| Option | Default | What it does | -| -------------------------- | ------- | ------------------------------------------------------------------------------ | -| `includeExports` | `false` | Also look at `export ... from` statements. | +| Option | Default | What it does | +| --- | --- | --- | +| `includeExports` | `false` | Also look at `export ... from` statements. | | `allowSeparateTypeImports` | `false` | Allow an `import type` statement next to a normal import from the same module. | With `includeExports`, an `export ... from` a module that the file already imports or exports from is reported: {/* bad {"options": [{"includeExports": true}]} */} - ```js import { parse } from "./parser.js"; export { tokenize } from "./parser.js"; @@ -54,7 +59,6 @@ export { tokenize } from "./parser.js"; `export * from` cannot be merged with an import of names, so it is still allowed: {/* good {"options": [{"includeExports": true}]} */} - ```js import { parse } from "./parser.js"; export * from "./parser.js"; @@ -63,14 +67,12 @@ export * from "./parser.js"; Without `allowSeparateTypeImports`, types and values from one module belong in one statement: {/* bad */} - ```ts import type { User } from "./user"; import { loadUser } from "./user"; ``` {/* good */} - ```ts import { loadUser, type User } from "./user"; ``` @@ -78,7 +80,6 @@ import { loadUser, type User } from "./user"; With it, the two statements are allowed: {/* good {"options": [{"allowSeparateTypeImports": true}]} */} - ```ts import type { User } from "./user"; import { loadUser } from "./user"; diff --git a/docs/lint/rules/eslint/no-else-return.mdx b/docs/lint/rules/eslint/no-else-return.mdx index f1772fcbf838..816e5bb3a72e 100644 --- a/docs/lint/rules/eslint/no-else-return.mdx +++ b/docs/lint/rules/eslint/no-else-return.mdx @@ -1,6 +1,15 @@ --- title: "no-else-return" description: "Disallow `else` after an `if` branch that always returns" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/no-else-return"] +kind: "suggestion" +category: "pedantic" +default: "off" +fixable: "yes" +typeAware: false +deprecated: false --- When the `if` branch ends with `return`, the code after the `if` only runs if the condition was false. An `else` around that code adds a level of indentation and says nothing new. @@ -12,7 +21,6 @@ When the `if` branch ends with `return`, the code after the `if` only runs if th Reported: {/* bad */} - ```js function label(user) { if (user.admin) { @@ -26,7 +34,6 @@ function label(user) { Not reported: {/* good */} - ```js function label(user) { if (user.admin) { @@ -38,14 +45,13 @@ function label(user) { ## Options -| Option | Default | What it does | -| ------------- | ------- | -------------------------------------------- | -| `allowElseIf` | `true` | Allow `else if` after a branch that returns. | +| Option | Default | What it does | +| --- | --- | --- | +| `allowElseIf` | `true` | Allow `else if` after a branch that returns. | By default an `else if` is fine: {/* good */} - ```js function label(user) { if (user.admin) { @@ -60,7 +66,6 @@ function label(user) { The last `else` of a chain is still reported if every branch before it returns: {/* bad */} - ```js function label(user) { if (user.admin) { @@ -76,7 +81,6 @@ function label(user) { With `allowElseIf: false`, the `else if` is reported too: {/* bad {"options": [{"allowElseIf": false}]} */} - ```js function label(user) { if (user.admin) { diff --git a/docs/lint/rules/eslint/no-empty-character-class.mdx b/docs/lint/rules/eslint/no-empty-character-class.mdx index aa53e5428f57..0312d556b3ff 100644 --- a/docs/lint/rules/eslint/no-empty-character-class.mdx +++ b/docs/lint/rules/eslint/no-empty-character-class.mdx @@ -1,6 +1,15 @@ --- title: "no-empty-character-class" description: "Disallow empty character classes `[]` in regular expressions" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/no-empty-character-class"] +kind: "problem" +category: "correctness" +default: "warn" +fixable: "no" +typeAware: false +deprecated: false --- `[]` in a regular expression is a character class with no characters in it. It matches nothing, so `/^#[]{6}$/` never matches any string. Most often someone forgot to fill in the class, or meant the text `[]`, which is written `\[]`. @@ -14,7 +23,6 @@ This rule is on by default. Reported: {/* bad */} - ```js const isHexColor = /^#[]{6}$/.test(color); ``` @@ -22,13 +30,11 @@ const isHexColor = /^#[]{6}$/.test(color); Not reported: {/* good */} - ```js const isHexColor = /^#[0-9a-f]{6}$/i.test(color); ``` {/* good */} - ```js const anyCharacter = /[^]/; const emptyArrayText = /\[]/; diff --git a/docs/lint/rules/eslint/no-empty-function.mdx b/docs/lint/rules/eslint/no-empty-function.mdx index 8a98616d9f38..7f4f91751cd9 100644 --- a/docs/lint/rules/eslint/no-empty-function.mdx +++ b/docs/lint/rules/eslint/no-empty-function.mdx @@ -1,6 +1,15 @@ --- title: "no-empty-function" description: "Disallow functions with an empty body" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/no-empty-function"] +kind: "suggestion" +category: "restriction" +default: "off" +fixable: "suggestion" +typeAware: false +deprecated: false --- An empty function reads like something that was never finished. A reader cannot tell whether it does nothing on purpose, as a default callback does, or whether the body is missing. @@ -12,7 +21,6 @@ A comment in the body answers that question, and the rule accepts it. Reported: {/* bad {"count": 2} */} - ```js function onClose() {} @@ -24,7 +32,6 @@ const handlers = { Not reported: {/* good */} - ```js function onClose() { // Nothing to clean up. @@ -40,7 +47,6 @@ const handlers = { In TypeScript, a constructor that declares fields in its parameters does its work without a body: {/* good */} - ```ts class User { constructor(private name: string) {} @@ -49,38 +55,36 @@ class User { ## Options -| Option | Default | What it does | -| ------- | ------- | ----------------------------------------- | -| `allow` | `[]` | The kinds of functions that may be empty. | - -| Kind | Allows | -| ------------------------- | ----------------------------------------- | -| `"functions"` | `function noop() {}` and `function () {}` | -| `"arrowFunctions"` | `() => {}`, also if it is `async` | -| `"generatorFunctions"` | `function* items() {}` | -| `"asyncFunctions"` | `async function load() {}` | -| `"methods"` | `render() {}` in a class or an object | -| `"generatorMethods"` | `*items() {}` | -| `"asyncMethods"` | `async load() {}` | -| `"getters"` | `get name() {}` | -| `"setters"` | `set name(value) {}` | -| `"constructors"` | `constructor() {}` | -| `"privateConstructors"` | `private constructor() {}` | -| `"protectedConstructors"` | `protected constructor() {}` | -| `"decoratedFunctions"` | a method with a decorator | -| `"overrideMethods"` | `override render() {}` | +| Option | Default | What it does | +| --- | --- | --- | +| `allow` | `[]` | The kinds of functions that may be empty. | + +| Kind | Allows | +| --- | --- | +| `"functions"` | `function noop() {}` and `function () {}` | +| `"arrowFunctions"` | `() => {}`, also if it is `async` | +| `"generatorFunctions"` | `function* items() {}` | +| `"asyncFunctions"` | `async function load() {}` | +| `"methods"` | `render() {}` in a class or an object | +| `"generatorMethods"` | `*items() {}` | +| `"asyncMethods"` | `async load() {}` | +| `"getters"` | `get name() {}` | +| `"setters"` | `set name(value) {}` | +| `"constructors"` | `constructor() {}` | +| `"privateConstructors"` | `private constructor() {}` | +| `"protectedConstructors"` | `protected constructor() {}` | +| `"decoratedFunctions"` | a method with a decorator | +| `"overrideMethods"` | `override render() {}` | Each kind allows only what its row shows. `"methods"` does not cover `async load() {}`, and `"arrowFunctions"` does not cover an empty `function`: {/* good {"options": [{"allow": ["arrowFunctions"]}]} */} - ```js const noop = () => {}; const wait = async () => {}; ``` {/* bad {"options": [{"allow": ["arrowFunctions"]}]} */} - ```js function noop() {} ``` @@ -88,7 +92,6 @@ function noop() {} A private constructor is a common way to stop `new Registry()` from outside the class: {/* good {"options": [{"allow": ["privateConstructors"]}]} */} - ```ts class Registry { static shared = new Registry(); diff --git a/docs/lint/rules/eslint/no-empty-pattern.mdx b/docs/lint/rules/eslint/no-empty-pattern.mdx index 2072491e1bc9..9c82b6d29ba1 100644 --- a/docs/lint/rules/eslint/no-empty-pattern.mdx +++ b/docs/lint/rules/eslint/no-empty-pattern.mdx @@ -1,6 +1,15 @@ --- title: "no-empty-pattern" description: "Disallow destructuring patterns that bind nothing" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/no-empty-pattern"] +kind: "problem" +category: "correctness" +default: "warn" +fixable: "no" +typeAware: false +deprecated: false --- `const {} = user` looks like a declaration but creates no variable. The usual cause is one wrong character: `{ settings: {} }` destructures `settings` into nothing, where `{ settings = {} }` would give it a default value. @@ -12,11 +21,8 @@ This rule is on by default. Reported: {/* bad {"count": 3} */} - ```js -const { - settings: {}, -} = user; +const { settings: {} } = user; const [] = items; function render({}) { @@ -27,7 +33,6 @@ function render({}) { Not reported: {/* good */} - ```js const { settings = {} } = user; const [first] = items; @@ -39,14 +44,13 @@ function render({ title }) { ## Options -| Option | Default | What it does | -| --------------------------------- | ------- | ---------------------------------------------------------------------------------------- | +| Option | Default | What it does | +| --- | --- | --- | | `allowObjectPatternsAsParameters` | `false` | Allow `{}` as a parameter of a function, with no default value or with the default `{}`. | This is for a function that is called with an object it does not need, and that uses a later parameter: {/* good {"options": [{"allowObjectPatternsAsParameters": true}]} */} - ```js function withServer({}, use) { return use(startServer()); @@ -60,7 +64,6 @@ function withDatabase({} = {}, use) { An empty array pattern as a parameter is still reported: {/* bad {"options": [{"allowObjectPatternsAsParameters": true}]} */} - ```js function withServer([], use) { return use(startServer()); diff --git a/docs/lint/rules/eslint/no-empty-static-block.mdx b/docs/lint/rules/eslint/no-empty-static-block.mdx index 303e1c43617d..76f7a3cb03e3 100644 --- a/docs/lint/rules/eslint/no-empty-static-block.mdx +++ b/docs/lint/rules/eslint/no-empty-static-block.mdx @@ -1,6 +1,15 @@ --- title: "no-empty-static-block" description: "Disallow empty `static {}` blocks in classes" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/no-empty-static-block"] +kind: "suggestion" +category: "correctness" +default: "warn" +fixable: "suggestion" +typeAware: false +deprecated: false --- A `static` block runs once, when the class is defined. An empty one does nothing. It is usually what a refactor left behind, or setup code that was never written. @@ -14,7 +23,6 @@ This rule is on by default. Reported: {/* bad */} - ```js class Config { static values = new Map(); @@ -26,7 +34,6 @@ class Config { Not reported: {/* good */} - ```js class Config { static values = new Map(); @@ -38,7 +45,6 @@ class Config { ``` {/* good */} - ```js class Config { static { diff --git a/docs/lint/rules/eslint/no-empty.mdx b/docs/lint/rules/eslint/no-empty.mdx index 3c13e79b4b3c..3dd4ba911570 100644 --- a/docs/lint/rules/eslint/no-empty.mdx +++ b/docs/lint/rules/eslint/no-empty.mdx @@ -1,6 +1,15 @@ --- title: "no-empty" description: "Disallow empty block statements" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/no-empty"] +kind: "suggestion" +category: "restriction" +default: "warn" +fixable: "suggestion" +typeAware: false +deprecated: false --- An empty block is usually code that was never finished, or what a refactor left behind. An empty `catch` is worse: the error disappears and nothing tells you that the call failed. @@ -14,7 +23,6 @@ This rule is on by default. Reported: {/* bad */} - ```js try { cache.clear(); @@ -22,7 +30,6 @@ try { ``` {/* bad {"count": 2} */} - ```js if (user.active) { } @@ -34,7 +41,6 @@ switch (user.role) { Not reported: {/* good */} - ```js try { cache.clear(); @@ -47,12 +53,11 @@ function noop() {} ## Options -| Option | Default | What it does | -| ----------------- | ------- | ----------------------------- | +| Option | Default | What it does | +| --- | --- | --- | | `allowEmptyCatch` | `false` | Allow an empty `catch` block. | {/* good {"options": [{"allowEmptyCatch": true}]} */} - ```js try { cache.clear(); @@ -62,7 +67,6 @@ try { Other empty blocks are still reported, including the `finally` of the same statement: {/* bad {"options": [{"allowEmptyCatch": true}]} */} - ```js try { cache.clear(); diff --git a/docs/lint/rules/eslint/no-eq-null.mdx b/docs/lint/rules/eslint/no-eq-null.mdx index 86a0f7fdec8c..dd69eedfc8ae 100644 --- a/docs/lint/rules/eslint/no-eq-null.mdx +++ b/docs/lint/rules/eslint/no-eq-null.mdx @@ -1,6 +1,15 @@ --- title: "no-eq-null" description: "Disallow comparing with `null` using `==` or `!=`" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/no-eq-null"] +kind: "suggestion" +category: "restriction" +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- `value == null` is `true` for `null` and also for `undefined`. If you meant only `null`, the check lets `undefined` through. If you meant both, the next reader cannot tell that from the code. @@ -10,7 +19,6 @@ description: "Disallow comparing with `null` using `==` or `!=`" Reported: {/* bad {"count": 2} */} - ```js if (user == null) { redirect("/login"); @@ -24,7 +32,6 @@ while (node != null) { Not reported: {/* good */} - ```js if (user === null) { redirect("/login"); diff --git a/docs/lint/rules/eslint/no-eval.mdx b/docs/lint/rules/eslint/no-eval.mdx index 019346354fcc..34cfbec3c690 100644 --- a/docs/lint/rules/eslint/no-eval.mdx +++ b/docs/lint/rules/eslint/no-eval.mdx @@ -1,6 +1,15 @@ --- title: "no-eval" description: "Disallow `eval()`" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/no-eval"] +kind: "suggestion" +category: "correctness" +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- `eval()` runs a string as code, with access to the variables around the call. If any part of that string comes from outside your program, the person who controls it can run anything your program can. @@ -12,7 +21,6 @@ Most uses have a direct replacement: `JSON.parse()` for data, `object[key]` for Reported: {/* bad */} - ```js const settings = eval("(" + text + ")"); ``` @@ -20,7 +28,6 @@ const settings = eval("(" + text + ")"); Reaching `eval` another way is reported too: {/* bad {"count": 3} */} - ```js const run = eval; globalThis.eval(code); @@ -30,7 +37,6 @@ globalThis.eval(code); Not reported: {/* good */} - ```js const settings = JSON.parse(text); const value = user[field]; @@ -39,21 +45,19 @@ const value = user[field]; A method named `eval` on your own object is fine: {/* good */} - ```js const result = sandbox.eval(code); ``` ## Options -| Option | Default | What it does | -| --------------- | ------- | ------------------------------- | +| Option | Default | What it does | +| --- | --- | --- | | `allowIndirect` | `false` | Allow indirect calls to `eval`. | An indirect call runs the code in the global scope, so it cannot read or change the local variables around the call: {/* good {"options": [{"allowIndirect": true}]} */} - ```js (0, eval)(code); globalThis.eval(code); @@ -63,7 +67,6 @@ eval?.(code); A direct call is still reported: {/* bad {"options": [{"allowIndirect": true}]} */} - ```js eval(code); ``` diff --git a/docs/lint/rules/eslint/no-ex-assign.mdx b/docs/lint/rules/eslint/no-ex-assign.mdx index 768c6a604b8f..743e93ac69fd 100644 --- a/docs/lint/rules/eslint/no-ex-assign.mdx +++ b/docs/lint/rules/eslint/no-ex-assign.mdx @@ -1,6 +1,15 @@ --- title: "no-ex-assign" description: "Disallow assigning to the error parameter of a `catch` clause" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/no-ex-assign"] +kind: "problem" +category: "correctness" +default: "error" +fixable: "no" +typeAware: false +deprecated: false --- Assigning to the parameter of a `catch` clause overwrites the error that was caught. From that line on, nothing in the block can read the original message or stack. @@ -12,7 +21,6 @@ This rule is on by default. Reported: {/* bad */} - ```js try { upload(file); @@ -25,7 +33,6 @@ try { Names that the parameter destructures count as well: {/* bad */} - ```js try { upload(file); @@ -38,7 +45,6 @@ try { Not reported: {/* good */} - ```js try { upload(file); @@ -51,7 +57,6 @@ try { Setting a property keeps the error, so it is allowed: {/* good */} - ```js try { upload(file); diff --git a/docs/lint/rules/eslint/no-extend-native.mdx b/docs/lint/rules/eslint/no-extend-native.mdx index 9bcb8276d5a3..778ab77583aa 100644 --- a/docs/lint/rules/eslint/no-extend-native.mdx +++ b/docs/lint/rules/eslint/no-extend-native.mdx @@ -1,6 +1,15 @@ --- title: "no-extend-native" description: "Disallow adding properties to the prototypes of built-in objects" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/no-extend-native"] +kind: "suggestion" +category: "suspicious" +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- A property added to `Array.prototype` or `Object.prototype` shows up on every array or object in the program, including those inside libraries. It appears in their `for...in` loops, and it breaks when a library or a later version of JavaScript defines the same name with a different meaning. @@ -12,7 +21,6 @@ Both plain assignment and `Object.defineProperty()` / `Object.defineProperties() Reported: {/* bad {"count": 2} */} - ```js Array.prototype.last = function () { return this[this.length - 1]; @@ -28,7 +36,6 @@ Object.defineProperty(String.prototype, "shout", { Not reported: {/* good */} - ```js function last(items) { return items[items.length - 1]; @@ -45,12 +52,11 @@ Stack.prototype.peek = Stack.prototype.last; ## Options -| Option | Default | What it does | -| ------------ | ------- | ---------------------------------------------- | -| `exceptions` | `[]` | Names of built-in objects that you may extend. | +| Option | Default | What it does | +| --- | --- | --- | +| `exceptions` | `[]` | Names of built-in objects that you may extend. | {/* good {"options": [{"exceptions": ["Array"]}]} */} - ```js Array.prototype.last = function () { return this[this.length - 1]; @@ -58,7 +64,6 @@ Array.prototype.last = function () { ``` {/* bad {"options": [{"exceptions": ["Array"]}]} */} - ```js String.prototype.shout = function () { return this.toUpperCase() + "!"; diff --git a/docs/lint/rules/eslint/no-extra-bind.mdx b/docs/lint/rules/eslint/no-extra-bind.mdx index 902b93b25597..723c939b7583 100644 --- a/docs/lint/rules/eslint/no-extra-bind.mdx +++ b/docs/lint/rules/eslint/no-extra-bind.mdx @@ -1,6 +1,15 @@ --- title: "no-extra-bind" description: "Disallow `.bind()` on functions that do not use `this`" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/no-extra-bind"] +kind: "suggestion" +category: "suspicious" +default: "off" +fixable: "yes" +typeAware: false +deprecated: false --- `.bind(user)` sets what `this` is inside a function. On a function that never reads `this` it changes nothing, and on an arrow function it has no effect, because an arrow function takes `this` from where it is written. The call is usually left from a time when the function did use `this`. @@ -14,7 +23,6 @@ Only a function written right before the `.bind()` is checked, and only a call w Reported: {/* bad {"count": 2} */} - ```js const greet = function () { return "Hello"; @@ -28,7 +36,6 @@ const log = (() => { A `this` inside a nested `function` belongs to that function, not to the outer one: {/* bad */} - ```js const setup = function () { button.onclick = function () { @@ -40,7 +47,6 @@ const setup = function () { Not reported: {/* good */} - ```js const greet = function () { return `Hello ${this.name}`; @@ -52,7 +58,6 @@ const save = user.save.bind(user); More arguments fill in parameters, which is useful without `this`: {/* good */} - ```js const addFive = function (a, b) { return a + b; diff --git a/docs/lint/rules/eslint/no-extra-boolean-cast.mdx b/docs/lint/rules/eslint/no-extra-boolean-cast.mdx index 253cc8cd9530..1d9ea9843d65 100644 --- a/docs/lint/rules/eslint/no-extra-boolean-cast.mdx +++ b/docs/lint/rules/eslint/no-extra-boolean-cast.mdx @@ -1,6 +1,15 @@ --- title: "no-extra-boolean-cast" description: "Disallow `!!` and `Boolean()` where the value is converted to a boolean anyway" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/no-extra-boolean-cast"] +kind: "suggestion" +category: "correctness" +default: "warn" +fixable: "yes" +typeAware: false +deprecated: false --- The test of an `if`, a loop or a `? :`, the operand of `!`, and the argument of `Boolean()` are already converted to a boolean. Writing `!!value` or `Boolean(value)` there converts twice and gives the same result. @@ -14,7 +23,6 @@ This rule is on by default. Reported: {/* bad {"count": 3} */} - ```js if (!!user) { greet(user); @@ -27,7 +35,6 @@ const isEmpty = !Boolean(items.length); Not reported: {/* good */} - ```js if (user) { greet(user); @@ -40,7 +47,6 @@ const isEmpty = !items.length; Where you store or return the value, the cast does something, so it is allowed: {/* good */} - ```js const isActive = !!user.active; const hasItems = Boolean(items.length); @@ -48,17 +54,16 @@ const hasItems = Boolean(items.length); ## Options -| Option | Default | What it does | -| ---------------------------- | ------- | ----------------------------------------------------------------------------------------- | +| Option | Default | What it does | +| --- | --- | --- | | `enforceForInnerExpressions` | `false` | Also look inside `\|\|`, `&&`, `??`, `? :` and comma expressions that are used as a test. | -| `enforceForLogicalOperands` | `false` | The same, for `\|\|` and `&&` only. | +| `enforceForLogicalOperands` | `false` | The same, for `\|\|` and `&&` only. | Set one or the other. A config with both is rejected. By default only the outermost expression of a test is checked: {/* good */} - ```js if (!!user || guest) { greet(); @@ -66,7 +71,6 @@ if (!!user || guest) { ``` {/* bad {"options": [{"enforceForInnerExpressions": true}]} */} - ```js if (!!user || guest) { greet(); @@ -76,7 +80,6 @@ if (!!user || guest) { `enforceForLogicalOperands` reports that one too. The two differ on the other operators: {/* bad {"options": [{"enforceForInnerExpressions": true}]} */} - ```js if (loggedIn ? !!user : guest) { greet(); @@ -84,7 +87,6 @@ if (loggedIn ? !!user : guest) { ``` {/* good {"options": [{"enforceForLogicalOperands": true}]} */} - ```js if (loggedIn ? !!user : guest) { greet(); diff --git a/docs/lint/rules/eslint/no-extra-label.mdx b/docs/lint/rules/eslint/no-extra-label.mdx index 5bf8cd90605a..6a30a3052e97 100644 --- a/docs/lint/rules/eslint/no-extra-label.mdx +++ b/docs/lint/rules/eslint/no-extra-label.mdx @@ -1,6 +1,15 @@ --- title: "no-extra-label" description: "Disallow labels on `break` and `continue` that name the innermost loop or `switch`" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/no-extra-label"] +kind: "suggestion" +category: "style" +default: "off" +fixable: "yes" +typeAware: false +deprecated: false --- `break` and `continue` without a label already apply to the innermost loop or `switch`. Naming the label of that same statement changes nothing, and it sends the reader looking for an outer loop that is not there. @@ -12,7 +21,6 @@ description: "Disallow labels on `break` and `continue` that name the innermost Reported: {/* bad */} - ```js search: for (const item of items) { if (item.id === id) { @@ -25,7 +33,6 @@ search: for (const item of items) { Not reported: {/* good */} - ```js for (const item of items) { if (item.id === id) { @@ -38,7 +45,6 @@ for (const item of items) { A label is needed to leave an outer loop from an inner one: {/* good */} - ```js rows: for (const row of table) { for (const cell of row) { diff --git a/docs/lint/rules/eslint/no-fallthrough.mdx b/docs/lint/rules/eslint/no-fallthrough.mdx index 06599de2a9d8..3377e7f5592c 100644 --- a/docs/lint/rules/eslint/no-fallthrough.mdx +++ b/docs/lint/rules/eslint/no-fallthrough.mdx @@ -1,6 +1,15 @@ --- title: "no-fallthrough" description: "Disallow `case` clauses that run on into the next one" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/no-fallthrough"] +kind: "problem" +category: "pedantic" +default: "error" +fixable: "no" +typeAware: false +deprecated: false --- A `case` that does not end with `break`, `return`, `throw` or `continue` keeps going into the code of the next `case`. A forgotten `break` runs code for a value it was not written for, and nothing fails at that line. @@ -14,7 +23,6 @@ This rule is on by default. Reported: {/* bad */} - ```js switch (post.status) { case "draft": @@ -28,7 +36,6 @@ switch (post.status) { Not reported: {/* good */} - ```js switch (post.status) { case "draft": @@ -41,7 +48,6 @@ switch (post.status) { ``` {/* good */} - ```js switch (post.status) { case "draft": @@ -56,14 +62,13 @@ switch (post.status) { ## Options -| Option | Default | What it does | -| -------------------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------- | -| `commentPattern` | `"falls?\\s?through"`, ignoring case | A regular expression for the comment that allows a fallthrough. It replaces the default. | -| `allowEmptyCase` | `false` | Allow an empty `case` with a blank line before the next one. | -| `reportUnusedFallthroughComment` | `false` | Report a fallthrough comment after a `case` that cannot fall through. | +| Option | Default | What it does | +| --- | --- | --- | +| `commentPattern` | `"falls?\\s?through"`, ignoring case | A regular expression for the comment that allows a fallthrough. It replaces the default. | +| `allowEmptyCase` | `false` | Allow an empty `case` with a blank line before the next one. | +| `reportUnusedFallthroughComment` | `false` | Report a fallthrough comment after a `case` that cannot fall through. | {/* good {"options": [{"commentPattern": "break omitted"}]} */} - ```js switch (post.status) { case "draft": @@ -77,7 +82,6 @@ switch (post.status) { An empty `case` followed by a blank line looks like its body was deleted, so it is reported unless you set `allowEmptyCase`: {/* bad */} - ```js switch (post.status) { case "draft": @@ -88,7 +92,6 @@ switch (post.status) { ``` {/* good {"options": [{"allowEmptyCase": true}]} */} - ```js switch (post.status) { case "draft": @@ -101,7 +104,6 @@ switch (post.status) { With `reportUnusedFallthroughComment`, a comment that is no longer true is reported: {/* bad {"options": [{"reportUnusedFallthroughComment": true}]} */} - ```js switch (post.status) { case "draft": diff --git a/docs/lint/rules/eslint/no-func-assign.mdx b/docs/lint/rules/eslint/no-func-assign.mdx index a2c1f77f9520..ef86dfea4799 100644 --- a/docs/lint/rules/eslint/no-func-assign.mdx +++ b/docs/lint/rules/eslint/no-func-assign.mdx @@ -1,6 +1,15 @@ --- title: "no-func-assign" description: "Disallow assigning to the name of a function declaration" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/no-func-assign"] +kind: "problem" +category: "correctness" +default: "error" +fixable: "no" +typeAware: false +deprecated: false --- Assigning to the name of a `function` declaration replaces the function for every caller from then on. It is almost always a mistake: a typo, or a variable that happens to have the same name as a function in the file. @@ -14,7 +23,6 @@ This rule is on by default in JavaScript files. In TypeScript files it is off by Reported: {/* bad */} - ```js function format(value) { return String(value); @@ -24,7 +32,6 @@ format = value => value.toFixed(2); ``` {/* bad */} - ```js const run = function retry() { retry = null; @@ -34,7 +41,6 @@ const run = function retry() { Not reported: {/* good */} - ```js let format = function (value) { return String(value); @@ -46,7 +52,6 @@ format = value => value.toFixed(2); A parameter or a local variable with the same name is a different variable: {/* good */} - ```js function total(items) { let total = 0; diff --git a/docs/lint/rules/eslint/no-global-assign.mdx b/docs/lint/rules/eslint/no-global-assign.mdx index d32c177eb6a8..59b1098cdab0 100644 --- a/docs/lint/rules/eslint/no-global-assign.mdx +++ b/docs/lint/rules/eslint/no-global-assign.mdx @@ -1,6 +1,15 @@ --- title: "no-global-assign" description: "Disallow assigning to built-in and other read-only global variables" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/no-global-assign"] +kind: "suggestion" +category: "correctness" +default: "warn" +fixable: "no" +typeAware: false +deprecated: false --- Assigning to a global such as `Promise` or `window` replaces it for all the code that runs afterwards, including libraries. More often it is an accident: a `const` or `let` is missing, and the name you picked is already a global of the browser. @@ -12,7 +21,6 @@ This rule is on by default. Reported: {/* bad {"count": 3} */} - ```js Promise = loadPromiseLibrary(); window = createFakeWindow(); @@ -22,7 +30,6 @@ status = "ready"; Not reported: {/* good */} - ```js const Promise = loadPromiseLibrary(); let status = "ready"; @@ -32,14 +39,12 @@ status = "done"; A global that is declared as writable can be assigned: {/* good */} - ```js /* global analytics:writable */ analytics = createAnalytics(); ``` {/* bad */} - ```js /* global analytics:readonly */ analytics = createAnalytics(); @@ -47,12 +52,11 @@ analytics = createAnalytics(); ## Options -| Option | Default | What it does | -| ------------ | ------- | ------------------------------------------------ | -| `exceptions` | `[]` | Names of read-only globals that may be assigned. | +| Option | Default | What it does | +| --- | --- | --- | +| `exceptions` | `[]` | Names of read-only globals that may be assigned. | {/* good {"options": [{"exceptions": ["Promise"]}]} */} - ```js Promise = loadPromiseLibrary(); ``` diff --git a/docs/lint/rules/eslint/no-implicit-coercion.mdx b/docs/lint/rules/eslint/no-implicit-coercion.mdx index 868c5a0359fb..b41fe4be099b 100644 --- a/docs/lint/rules/eslint/no-implicit-coercion.mdx +++ b/docs/lint/rules/eslint/no-implicit-coercion.mdx @@ -1,6 +1,15 @@ --- title: "no-implicit-coercion" description: "Require `Boolean()`, `Number()` and `String()` instead of operator tricks like `!!x` and `+x`" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/no-implicit-coercion"] +kind: "suggestion" +category: "style" +default: "off" +fixable: "yes" +typeAware: false +deprecated: false --- `!!value`, `+value` and `"" + value` convert a value as a side effect of an operator. You have to know the trick to see that a conversion happens. `Boolean(value)`, `Number(value)` and `String(value)` say it by name. @@ -12,7 +21,6 @@ description: "Require `Boolean()`, `Number()` and `String()` instead of operator Reported: {/* bad {"count": 6} */} - ```js const hasItems = !!items.length; const isMember = ~names.indexOf(user.name); @@ -25,7 +33,6 @@ label += ""; Not reported: {/* good */} - ```js const hasItems = Boolean(items.length); const isMember = names.indexOf(user.name) !== -1; @@ -37,16 +44,15 @@ label = String(label); ## Options -| Option | Default | What it does | -| --------------------------- | ------- | ----------------------------------------------------------------------------- | -| `boolean` | `true` | Report `!!value` and `~list.indexOf(value)`. | -| `number` | `true` | Report `+value`, `- -value`, `1 * value` and `value - 0`. | -| `string` | `true` | Report `"" + value` and `value += ""`. | -| `disallowTemplateShorthand` | `false` | Also report a template that holds one expression and nothing else. | -| `allow` | `[]` | Operators to leave alone: any of `"~"`, `"!!"`, `"+"`, `"- -"`, `"-"`, `"*"`. | +| Option | Default | What it does | +| --- | --- | --- | +| `boolean` | `true` | Report `!!value` and `~list.indexOf(value)`. | +| `number` | `true` | Report `+value`, `- -value`, `1 * value` and `value - 0`. | +| `string` | `true` | Report `"" + value` and `value += ""`. | +| `disallowTemplateShorthand` | `false` | Also report a template that holds one expression and nothing else. | +| `allow` | `[]` | Operators to leave alone: any of `"~"`, `"!!"`, `"+"`, `"- -"`, `"-"`, `"*"`. | {/* bad {"options": [{"disallowTemplateShorthand": true}]} */} - ```js const id = `${user.id}`; ``` @@ -54,13 +60,11 @@ const id = `${user.id}`; `allow` keeps one trick while the rest of its group is still reported: {/* good {"options": [{"allow": ["!!"]}]} */} - ```js const hasItems = !!items.length; ``` {/* bad {"options": [{"allow": ["!!"]}]} */} - ```js const isMember = ~names.indexOf(user.name); ``` @@ -68,7 +72,6 @@ const isMember = ~names.indexOf(user.name); `"+"` stands for both `+value` and `"" + value`: {/* good {"options": [{"allow": ["+"]}]} */} - ```js const quantity = +input.value; const id = "" + user.id; diff --git a/docs/lint/rules/eslint/no-implicit-globals.mdx b/docs/lint/rules/eslint/no-implicit-globals.mdx index 9728686d4fc0..cc127ead1443 100644 --- a/docs/lint/rules/eslint/no-implicit-globals.mdx +++ b/docs/lint/rules/eslint/no-implicit-globals.mdx @@ -1,6 +1,15 @@ --- title: "no-implicit-globals" description: "Disallow declarations and assignments that create global variables by accident" +# Generated by src/codegen/lint-rule-docs.ts. Do not edit below this line. +plugin: "eslint" +aliases: ["eslint/no-implicit-globals"] +kind: "suggestion" +category: "restriction" +default: "off" +fixable: "no" +typeAware: false +deprecated: false --- In a classic script, one loaded with `\n`; + const result = await format({ ...bunfig("sortImports = true"), "a.vue": vue }); + expect(result.files["a.vue"]).toBe(`\n`); + }); + + test("it is laid over a .prettierrc, as the flag is", async () => { + const result = await format({ + ...bunfig("sortImports = true"), + ".prettierrc": `{ "semi": false }`, + "a.ts": imports, + }); + expect(result.stderr).toBe(""); + expect(result.files["a.ts"]).toBe(sorted.replaceAll(";", "")); + }); + + test.each([ + [`sortImports = "yes"`, "expected boolean or object but received string"], + ["sortImports = 1", "expected boolean or object but received number"], + [ + "sortImports = { newlineBetween = false }", + `unknown key "newlineBetween" in [format.sortImports]. Did you mean "newlinesBetween"?`, + ], + ["sortImports = { importOrder = [] }", `unknown key "importOrder" in [format.sortImports].`], + [`sortImports = { newlinesBetween = "no" }`, "expected boolean but received string"], + [`sortImports = { groups = "builtin" }`, "expected array but received string"], + ["sortImports = { internalPattern = [1] }", "expected string but received number"], + [`sortImports = { order = "up" }`, 'Invalid `sortImports` configuration: `order` has to be "asc" or "desc"'], + [ + `sortImports = { groups = ["packages"] }`, + "Invalid `sortImports` configuration: unknown group name `packages` in `groups`", + ], + [ + "sortImports = { partitionByNewline = true }", + "`partitionByNewline: true` and `newlinesBetween: true` cannot be used together", + ], + ])("%s is refused: %s", async (line, message) => { + const result = await format({ ...bunfig(line), "a.ts": imports }); + expect(result.stderr).toContain(message); + expect(result.stderr).toContain("bunfig.toml:2:"); + expect(result.files["a.ts"]).toBe(imports); + expect(result.exitCode).toBe(2); + }); +}); + +describe.concurrent("sortTailwindcss", () => { + /** Tailwind CSS 4, as far as it is asked. A style sheet with `reversed` in it turns the order around. */ + const tailwind = { + "node_modules/tailwindcss/package.json": JSON.stringify({ + name: "tailwindcss", + version: "4.0.0", + main: "index.js", + }), + "node_modules/tailwindcss/theme.css": "", + "node_modules/tailwindcss/index.js": `const order = ["m-2", "flex", "p-4"]; +exports.__unstable__loadDesignSystem = async css => { + const sign = css.includes("reversed") ? -1 : 1; + return { getClassOrder: classes => classes.map(name => [name, order.includes(name) ? BigInt(sign * order.indexOf(name)) : null]) }; +}; +`, + }; + const input = `;\n`; + const sorted = `;\n`; + + test("true, in every language that has classes", async () => { + const result = await format({ + ...tailwind, + ...bunfig("sortTailwindcss = true"), + "a.jsx": input + wide, + "a.html": `\n`, + "a.vue": `\n`, + "a.css": `a {\n @apply p-4 flex m-2;\n}\n`, + }); + expect(result.stderr).toBe(""); + expect(result.files["a.jsx"]).toBe(sorted + broken); + expect(result.files["a.html"]).toBe(`\n`); + expect(result.files["a.vue"]).toBe(`\n`); + expect(result.files["a.css"]).toBe(`a {\n @apply m-2 flex p-4;\n}\n`); + expect(result.exitCode).toBe(0); + }); + + test("it is off unless it is set, and the package is not asked for", async () => { + const result = await format({ "a.jsx": input }); + expect(result.stderr).toBe(""); + expect(result.files["a.jsx"]).toBe(input); + expect(result.exitCode).toBe(0); + }); + + test("--sort-tailwindcss and --no-sort-tailwindcss", async () => { + const [on, off] = await Promise.all([ + format({ ...tailwind, "a.jsx": input }, ["--sort-tailwindcss", "."]), + format({ ...tailwind, ...bunfig("sortTailwindcss = true"), "a.jsx": input }, ["--no-sort-tailwindcss", "."]), + ]); + expect(on.files["a.jsx"]).toBe(sorted); + expect(off.files["a.jsx"]).toBe(input); + }); + + test("a table has the options, and its paths are from the working directory", async () => { + const result = await format({ + ...tailwind, + "bunfig.toml": `[format.sortTailwindcss] +stylesheet = "./styles/app.css" +functions = ["cn"] +attributes = ["look"] +preserveDuplicates = true +`, + "styles/app.css": "/* reversed */\n", + "src/a.jsx": `;\ncn("m-2 p-4");\nother("m-2 p-4");\n`, + }); + expect(result.stderr).toBe(""); + expect(result.files["src/a.jsx"]).toBe( + `;\ncn("p-4 m-2");\nother("m-2 p-4");\n`, + ); + expect(result.exitCode).toBe(0); + }); + + test("it is laid over a .prettierrc, as the flag is", async () => { + const files = { ...tailwind, ".prettierrc": `{ "jsxSingleQuote": true }`, "a.jsx": input }; + const result = await format({ ...files, ...bunfig("sortTailwindcss = true") }); + expect(result.stderr).toBe(""); + expect(result.files["a.jsx"]).toBe(sorted.replaceAll('"', "'")); + }); + + test("without the package", async () => { + const result = await format({ ...bunfig("sortTailwindcss = true"), "a.jsx": input, "b.js": "b ;\n" }); + expect(result.stderr).toContain("the tailwindcss package was not found"); + expect(result.files["a.jsx"]).toBe(input); + // A file without classes needs none. + expect(result.files["b.js"]).toBe("b;\n"); + expect(result.exitCode).toBe(2); + }); + + test.each([ + [`sortTailwindcss = "yes"`, "expected boolean or object but received string"], + [ + "sortTailwindcss = { function = [] }", + `unknown key "function" in [format.sortTailwindcss]. Did you mean "functions"?`, + ], + [`sortTailwindcss = { functions = "cn" }`, "expected array but received string"], + ["sortTailwindcss = { attributes = [true] }", "expected string but received boolean"], + ["sortTailwindcss = { stylesheet = 1 }", "expected string but received number"], + ["sortTailwindcss = { preserveWhitespace = 1 }", "expected boolean but received number"], + ])("%s is refused: %s", async (line, message) => { + const result = await format({ ...tailwind, ...bunfig(line), "a.jsx": input }); + expect(result.stderr).toContain(message); + expect(result.files["a.jsx"]).toBe(input); + expect(result.exitCode).toBe(2); + }); +}); + +describe.concurrent("sortPackageJson", () => { + const json = (value: unknown) => `${JSON.stringify(value, null, 2)}\n`; + const input = json({ version: "1.0.0", scripts: { test: "a", build: "b" }, name: "x" }); + const sorted = json({ name: "x", version: "1.0.0", scripts: { test: "a", build: "b" } }); + const withScripts = json({ name: "x", version: "1.0.0", scripts: { build: "b", test: "a" } }); + + test("the key, the flags and the table", async () => { + const [off, key, flag, under, table] = await Promise.all([ + format({ "package.json": input }), + format({ ...bunfig("sortPackageJson = true"), "package.json": input, "other.json": input }), + format({ "package.json": input }, ["--sort-package-json", "."]), + format({ ...bunfig("sortPackageJson = { sortScripts = true }"), "package.json": input }, [ + "--no-sort-package-json", + ".", + ]), + format({ ...bunfig("sortPackageJson = { sortScripts = true }"), "package.json": input }), + ]); + expect(off.files["package.json"]).toBe(input); + expect(key.files["package.json"]).toBe(sorted); + expect(key.files["other.json"]).toBe(input); + expect(flag.files["package.json"]).toBe(sorted); + expect(under.files["package.json"]).toBe(input); + expect(table.files["package.json"]).toBe(withScripts); + }); + + test("in a .prettierrc it is an option that Prettier does not know", async () => { + const result = await format({ ".prettierrc": `{ "sortPackageJson": true }`, "package.json": input }); + expect(result.stderr).toContain("Ignored unknown option { sortPackageJson: true }."); + expect(result.files["package.json"]).toBe(input); + }); + + test.each([ + ["sortPackageJson = []", "expected boolean or object but received array"], + [ + "sortPackageJson = { sortScript = true }", + `unknown key "sortScript" in [format.sortPackageJson]. Did you mean "sortScripts"?`, + ], + [`sortPackageJson = { sortScripts = "all" }`, "expected boolean but received string"], + ])("%s is refused: %s", async (line, message) => { + const result = await format({ ...bunfig(line), "package.json": input }); + expect(result.stderr).toContain(message); + expect(result.exitCode).toBe(2); + }); +}); + +describe.concurrent("jsdoc", () => { + const input = `/** + * adds two numbers + * @returns {number} the sum + * @param {number} b second + * @param {number} a first + */ +export function add(a, b) {} +`; + const formatted = `/** + * Adds two numbers + * + * @param {number} a First + * @param {number} b Second + * @returns {number} The sum + */ +export function add(a, b) {} +`; + + test("the key, the flags and the table", async () => { + const options = "jsdoc = { capitalizeDescriptions = false, descriptionWithDot = true }"; + const [off, key, flag, under, table, over] = await Promise.all([ + format({ "a.js": input }), + format({ ...bunfig("jsdoc = true"), "a.js": input }), + format({ "a.js": input }, ["--jsdoc", "."]), + format({ ...bunfig(options), "a.js": input }, ["--no-jsdoc", "."]), + format({ ...bunfig(options), "a.js": input }), + format({ ...bunfig(options), "a.js": input }, ["--jsdoc", "."]), + ]); + expect(off.files["a.js"]).toBe(input); + expect(key.files["a.js"]).toBe(formatted); + expect(flag.files["a.js"]).toBe(formatted); + expect(under.files["a.js"]).toBe(input); + expect(table.files["a.js"]).toBe(`/** + * adds two numbers. + * + * @param {number} a first. + * @param {number} b second. + * @returns {number} the sum + */ +export function add(a, b) {} +`); + expect(over.files["a.js"]).toBe(table.files["a.js"]); + }); + + test.each([ + [`jsdoc = "all"`, "expected boolean or object but received string"], + ["jsdoc = { capitalize = true }", `unknown key "capitalize" in [format.jsdoc].`], + ["jsdoc = { descriptionTag = 1 }", "expected boolean but received number"], + [`jsdoc = { commentLineStrategy = "one" }`, "Invalid jsdoc.commentLineStrategy value: one."], + ])("%s is refused: %s", async (line, message) => { + const result = await format({ ...bunfig(line), "a.js": input }); + expect(result.stderr).toContain(message); + expect(result.files["a.js"]).toBe(input); + expect(result.exitCode).toBe(2); + }); +}); + +describe.concurrent("insertFinalNewline", () => { + test("the key and the flags", async () => { + const files = { "a.js": "a ;\n", "a.css": "a {\n}\n", "a.md": "a\n" }; + const [plain, key, flag, under] = await Promise.all([ + format(files), + format({ ...bunfig("insertFinalNewline = false"), ...files }), + format(files, ["--no-insert-final-newline", "."]), + format({ ...bunfig("insertFinalNewline = false"), ...files }, ["--insert-final-newline", "."]), + ]); + expect(plain.files).toEqual({ "a.js": "a;\n", "a.css": "a {\n}\n", "a.md": "a\n" }); + expect(key.files).toMatchObject({ "a.js": "a;", "a.css": "a {\n}", "a.md": "a" }); + expect(flag.files).toEqual({ "a.js": "a;", "a.css": "a {\n}", "a.md": "a" }); + expect(under.files).toMatchObject(plain.files); + }); + + test("in a .prettierrc it is an option that Prettier does not know", async () => { + const result = await format({ ".prettierrc": `{ "insertFinalNewline": false }`, "a.js": "a ;\n" }); + expect(result.stderr).toContain("Ignored unknown option { insertFinalNewline: false }."); + expect(result.files["a.js"]).toBe("a;\n"); + }); + + test("a value that is no boolean is refused", async () => { + const result = await format({ ...bunfig(`insertFinalNewline = "no"`), "a.js": "a ;\n" }); + expect(result.stderr).toContain("expected boolean but received string"); + expect(result.exitCode).toBe(2); + }); +}); + +describe.concurrent("toml", () => { + const input = `a = 1\n[b]\nc = [ "d",'e' ]\n`; + const formatted = `a = 1\n[b]\nc = ["d", 'e']\n`; + + test("the key and the flags", async () => { + const [off, key, flag, under] = await Promise.all([ + format({ "a.toml": input, "a.js": "a ;\n" }), + format({ ...bunfig("toml = true"), "a.toml": input }), + format({ "a.toml": input }, ["--toml", "."]), + format({ ...bunfig("toml = true"), "a.toml": input, "a.js": "a ;\n" }, ["--no-toml", "."]), + ]); + expect(off.files["a.toml"]).toBe(input); + expect(key.stderr).toBe(""); + expect(key.files).toEqual({ "bunfig.toml": "[format]\ntoml = true\n", "a.toml": formatted }); + expect(flag.files["a.toml"]).toBe(formatted); + expect(under.files["a.toml"]).toBe(input); + expect(key.exitCode).toBe(0); + }); + + test("a file that is named", async () => { + const [off, on] = await Promise.all([ + format({ "a.toml": input }, ["a.toml"]), + format({ "a.toml": input }, ["--toml", "a.toml"]), + ]); + expect(off.stderr).toContain("No parser could be inferred for file"); + expect(off.exitCode).toBe(2); + expect(on.files["a.toml"]).toBe(formatted); + expect(on.exitCode).toBe(0); + }); + + test("the lines are as wide as everywhere", async () => { + const items = Array(9).fill(`"aaaaaaaa"`); + const result = await format({ ...bunfig("toml = true"), "a.toml": `a = [${items.join(", ")}]\n` }); + expect(result.files["a.toml"]).toBe(`a = [\n${items.map(it => ` ${it},\n`).join("")}]\n`); + }); +}); + +describe.concurrent("overrides", () => { + const files = { + "a.js": `a("b");\n`, + "a.test.js": `a("b");\n`, + "src/b.test.js": `a("b");\n`, + "src/skip.test.js": `a("b");\n`, + }; + const table = `[format] +semi = false + +[[format.overrides]] +files = "*.test.js" +excludeFiles = ["src/skip.*"] +options = { singleQuote = true, semi = true } +`; + + test("options for the files that match, over those for all files", async () => { + const result = await format({ "bunfig.toml": table, ...files }); + expect(result.stderr).toBe(""); + expect(result.files).toMatchObject({ + "a.js": `a("b")\n`, + "a.test.js": `a('b');\n`, + "src/b.test.js": `a('b');\n`, + "src/skip.test.js": `a("b")\n`, + }); + expect(result.exitCode).toBe(0); + }); + + test("a later one wins, and a flag is for every file", async () => { + const more = `${table} +[[format.overrides]] +files = ["src/**"] +options = { singleQuote = false, useTabs = true } +`; + const block = { "src/c.test.js": `{\n a("b");\n}\n` }; + const [later, flag] = await Promise.all([ + format({ "bunfig.toml": more, ...block }), + format({ "bunfig.toml": more, ...block }, ["--no-semi", "--single-quote", "--no-use-tabs", "."]), + ]); + expect(later.files["src/c.test.js"]).toBe(`{\n\ta("b");\n}\n`); + expect(flag.files["src/c.test.js"]).toBe(`{\n a('b')\n}\n`); + }); + + test("they are laid over a .prettierrc, as the other keys are", async () => { + const result = await format({ + "bunfig.toml": table, + ".prettierrc": `{ "singleQuote": false, "semi": true }`, + ...files, + }); + expect(result.files).toMatchObject({ "a.js": `a("b")\n`, "a.test.js": `a('b');\n` }); + }); + + test("parser", async () => { + const result = await format({ + "bunfig.toml": `[[format.overrides]]\nfiles = ["*.rc", ".babelrc"]\noptions = { parser = "json5" }\n`, + "a.rc": `{"a":1}\n`, + "a.other": `{"a":1}\n`, + }); + expect(result.stderr).toBe(""); + expect(result.files).toMatchObject({ "a.rc": `{ a: 1 }\n`, "a.other": `{"a":1}\n` }); + }); + + test("what is built in besides, for some files", async () => { + const imports = `import b from "./b";\nimport a from "a";\n`; + const bunfig = `[[format.overrides]] +files = "src/**" +[format.overrides.options] +sortImports = { newlinesBetween = false } +insertFinalNewline = false +`; + const [result, off, on] = await Promise.all([ + format({ "bunfig.toml": bunfig, "a.js": imports, "src/a.js": imports }), + format({ "bunfig.toml": bunfig, "src/a.js": imports }, ["--no-sort-imports", "."]), + format({ "bunfig.toml": bunfig, "a.js": imports, "src/a.js": imports }, ["--sort-imports", "."]), + ]); + expect(result.stderr).toBe(""); + expect(result.files).toMatchObject({ "a.js": imports, "src/a.js": `import a from "a";\nimport b from "./b";` }); + expect(off.files["src/a.js"]).toBe(imports.trimEnd()); + // The flag turns it on everywhere, and leaves the options where there are some. + expect(on.files).toMatchObject({ + "a.js": `import a from "a";\n\nimport b from "./b";\n`, + "src/a.js": `import a from "a";\nimport b from "./b";`, + }); + }); + + test.each([ + ["overrides = {}", "expected array but received object"], + ["overrides = [1]", "expected object but received number"], + ["overrides = [{ options = { semi = false } }]", `an override needs "files"`], + ["overrides = [{ files = 1 }]", "expected string or array but received number"], + ["overrides = [{ files = [1] }]", "expected string but received number"], + [`overrides = [{ files = "a[" }]`, "Invalid glob pattern `a[`"], + [`overrides = [{ file = "a" }]`, `unknown key "file" in [format.overrides]. Did you mean "files"?`], + [`overrides = [{ files = "a", options = { semi = 1 } }]`, "expected boolean but received number"], + [`overrides = [{ files = "a", options = { printWidth = 1.5 } }]`, "Invalid printWidth value: 1.5."], + [ + `overrides = [{ files = "a", options = { threads = 1 } }]`, + `unknown key "threads" in [format.overrides.options].`, + ], + [`overrides = [{ files = "a", options = { toml = true } }]`, `unknown key "toml" in [format.overrides.options].`], + [`overrides = [{ files = "a", options = { check = true } }]`, `unknown key "check" in [format.overrides.options].`], + [`overides = []`, `unknown key "overides" in [format]. Did you mean "overrides"?`], + ])("%s is refused: %s", async (line, message) => { + const result = await format({ ...bunfig(line), "a.js": "a ;\n" }); + expect(result.stderr).toContain(message); + expect(result.files["a.js"]).toBe("a ;\n"); + expect(result.exitCode).toBe(2); + }); +}); diff --git a/test/cli/format/doctor.test.ts b/test/cli/format/format-doctor.test.ts similarity index 87% rename from test/cli/format/doctor.test.ts rename to test/cli/format/format-doctor.test.ts index f32a58763fdf..70354d8d3888 100644 --- a/test/cli/format/doctor.test.ts +++ b/test/cli/format/format-doctor.test.ts @@ -124,17 +124,29 @@ You can remove: @trivago/prettier-plugin-sort-imports, prettier }); test("a plugin that is not supported, and one that is not installed", async () => { - const { stdout, exitCode } = await doctor( - { - ...installed("prettier"), - "package.json": dependencies("prettier", "prettier-plugin-tailwindcss", "prettier-plugin-astro"), - ".prettierrc.json": JSON.stringify({ plugins: ["prettier-plugin-astro"] }), - "styled/.prettierrc.json": JSON.stringify({ plugins: ["prettier-plugin-tailwindcss"] }), - "styled/a.js": ugly, - "b.astro": "

    b

    \n", - }, - ["-f", "json"], - ); + const project = { + ...installed("prettier"), + "package.json": dependencies("prettier", "prettier-plugin-tailwindcss", "prettier-plugin-astro"), + ".prettierrc.json": JSON.stringify({ plugins: ["prettier-plugin-astro"] }), + "styled/.prettierrc.json": JSON.stringify({ plugins: ["prettier-plugin-tailwindcss"] }), + "styled/a.js": ugly, + "b.astro": "

    b

    \n", + }; + const [text, { stdout, exitCode }] = await Promise.all([doctor(project), doctor(project, ["-f", "json"])]); + expect(text.stdout).toStartWith(`Using .prettierrc.json (Prettier config) +Also styled/.prettierrc.json + + 2 files are formatted natively + 1 file cannot be formatted: 1 *.astro + 2 files are skipped because a plugin is not supported + Use --allow-unsupported to format them without it + +Plugins that use your installed Prettier: prettier-plugin-astro +Plugins that are not supported: prettier-plugin-tailwindcss +Not installed: prettier-plugin-astro + +Still needed: prettier, prettier-plugin-astro (some files go to them) +`); const { config, files, plugins, packages } = JSON.parse(stdout); expect(config.nested).toEqual(["styled/.prettierrc.json"]); expect(files).toEqual({ formatted: 2, by_prettier: {}, not_formatted: { "*.astro": 1 }, left_for_plugins: 2 }); @@ -213,7 +225,7 @@ Run bun format --init to add a [format] section to bunfig.toml test("-f takes json only", async () => { const { stderr, exitCode } = await doctor({ "a.js": ugly }, ["-f", "yaml"]); - expect(stderr).toContain("Option format: 'yaml' is not json."); + expect(stderr).toContain(`--format takes json, not "yaml"`); expect(exitCode).not.toBe(0); }); diff --git a/test/cli/format/format.test.ts b/test/cli/format/format.test.ts index b41e2b62d694..825ed136a82d 100644 --- a/test/cli/format/format.test.ts +++ b/test/cli/format/format.test.ts @@ -402,7 +402,7 @@ describe.concurrent("bun format", () => { for (const result of [inRepository, inNone]) { expect(result.files).toEqual({ "outside/c/d.js": ugly }); expect(result.stderr).toContain( - '[error] Unable to write file "c/d.js":\n[error] A link leads out of the repository.', + '[error] Unable to write file "c/d.js":\n[error] it is a symlink to a file outside the repository', ); expect(result.exitCode).toBe(2); } @@ -486,7 +486,8 @@ describe.concurrent("bun format", () => { }); test("--check writes nothing and exits with 1", async () => { - const result = await format({ "a.js": ugly, "b.js": formatted, "c.js": ugly }, ["--check"], { reads: ["a.js"] }); + const files = { ".prettierrc": "{}\n", "a.js": ugly, "b.js": formatted, "c.js": ugly }; + const result = await format(files, ["--check"], { reads: ["a.js"] }); expect(result.files).toEqual({ "a.js": ugly }); expect(result.stdout).toMatchInlineSnapshot(`"Checking formatting..."`); expect(result.stderr).toMatchInlineSnapshot(` @@ -498,7 +499,7 @@ describe.concurrent("bun format", () => { }); test("--check exits with 0 if everything is formatted", async () => { - const result = await format({ "a.js": formatted }, ["--check"]); + const result = await format({ ".prettierrc": "{}\n", "a.js": formatted }, ["--check"]); expect(result.stdout).toMatchInlineSnapshot(` "Checking formatting... All matched files use Prettier code style!" @@ -1358,7 +1359,7 @@ exports.format = async (text, options) => { { reads: ["a.svelte", "c.svelte"] }, ); expect(result.files).toEqual({ "a.svelte": "

    a

    \n", "c.svelte": "

    waits

    \n" }); - expect(result.stderr).toContain("[error] c.svelte: A promise is not settled, and nothing is left to wait for."); + expect(result.stderr).toContain("[error] c.svelte: A promise never settled, and nothing is left to wait for."); expect(result.exitCode).toBe(2); }, timeout, @@ -4139,8 +4140,8 @@ describe.concurrent("a file that is not UTF-8, or has a NUL", () => { const result = await bytesAfter(files); expect(result.files).toEqual(files); expect(result.errors.sort()).toEqual([ - "[error] a.html: SyntaxError: What follows this NUL would be lost (2:5)", - "[error] a.vue: SyntaxError: What follows this NUL would be lost (2:5)", + "[error] a.html: SyntaxError: Unexpected NUL character (2:5)", + "[error] a.vue: SyntaxError: Unexpected NUL character (2:5)", ]); expect(result.exitCode).toBe(2); }); @@ -4899,12 +4900,8 @@ describe("bun format on Windows, macOS and Linux", () => { const results = await Promise.all([format(listed, ["-l"]), format(checked, ["--check"]), format(written, [])]); expect(results.map(it => it.stdout)).toEqual([ "a.js\nsrc/deep/b.ts\n", - "Checking formatting...\nError occurred when checking code style in the above file.\n", "a.js\nsrc/deep/b.ts\n", - ]); - expect(results[1].stderr.split("\n").filter(line => line.startsWith("[warn] "))).toEqual([ - "[warn] a.js", - "[warn] src/deep/b.ts", + "a.js\nsrc/deep/b.ts\n", ]); for (const { stderr } of results) { expect(stderr).toContain("[error] src/broken.js: SyntaxError: "); @@ -5558,7 +5555,7 @@ describe.concurrent("[format] in bunfig.toml", () => { run({ ...files, "bunfig.toml": "[format]\nthreads = 0\n" }, ["a.js", "b.js"]), ]); expect(byKey).toEqual(byFlag); - expect(zero.stderr).toContain(`--threads takes a number above zero, not "0".`); + expect(zero.stderr).toContain(`--threads takes a number above zero, not "0"`); expect(zero.exitCode).toBe(2); }); @@ -5617,9 +5614,7 @@ describe.concurrent("[format] in bunfig.toml", () => { ])("%s is refused, and nothing is written", async (line, flag) => { const key = line.split(" ")[0]; const result = await run({ "bunfig.toml": `[format]\n${line}\n`, "a.js": ugly }, ["a.js"]); - expect(result.stderr).toContain( - `"${key}" cannot be set in bunfig.toml, it says what one run does. Pass ${flag} on the command line`, - ); + expect(result.stderr).toContain(`"${key}" cannot be set in bunfig.toml. Pass ${flag} on the command line instead.`); expect(result.files["a.js"]).toBe(ugly); expect(result.exitCode).toBe(2); }); diff --git a/test/cli/format/glob.test.ts b/test/cli/format/glob.test.ts new file mode 100644 index 000000000000..d8b131e171ba --- /dev/null +++ b/test/cli/format/glob.test.ts @@ -0,0 +1,102 @@ +import { afterAll, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; +import { endChildren, spawn } from "../children"; + +afterAll(endChildren); + +const env = { ...bunEnv, AGENT: "0", CLAUDECODE: undefined, GITHUB_ACTIONS: undefined }; +const ugly = "if (a) {\nb( 1 )\n}\n"; + +async function format(files: Record, args: string[], stdin?: string) { + using dir = tempDir("bun-format-glob", files); + await using proc = spawn({ + cmd: [bunExe(), "format", ...args], + env, + cwd: String(dir), + stdin: stdin === undefined ? "ignore" : Buffer.from(stdin), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; +} + +const names = [ + "a.js", + "a.cjs", + "a.mjs", + "a.ts", + "src/a.js", + "src/b.ts", + "src/x/d.js", + "src/x/e.tsx", + "lib/f.js", + "lib/deep/g.js", + "lib/deep/h.min.js", + "sub/i.js", +]; +const project = { ...Object.fromEntries(names.map(name => [name, ugly])), ".prettierrc.json": "{}" }; + +// What Prettier 3.9 lists for each. +test.concurrent.each([ + ["src/**/*.@(js|ts)", "src/a.js src/b.ts src/x/d.js"], + ["{a,src/x/*}.?(c)js", "a.cjs a.js src/x/d.js"], + ["lib/!(deep)*.js", "lib/f.js"], + ["**/+(a|d).js", "a.js src/a.js src/x/d.js"], + // To Prettier parentheses alone are a group too. + ["(src|lib)/*.js", "lib/f.js src/a.js"], + // And `a/**` matches `a`. + ["lib/*/**", "lib/deep/g.js lib/deep/h.min.js lib/f.js"], +])("a pattern on the command line: %s", async (pattern, expected) => { + const { stdout, exitCode } = await format(project, ["-l", pattern]); + expect(stdout.trim().split("\n").sort().join(" ")).toBe(expected); + expect(exitCode).toBe(1); +}); + +test.concurrent("`files` and `excludeFiles` of an override of Prettier", async () => { + const overrides = [ + { files: "*.?(c|m)js", options: { semi: false } }, + { files: "src/**/*.@(ts|tsx)", options: { tabWidth: 8 } }, + { files: "lib/!(deep)/**", options: { tabWidth: 1 } }, + { files: ["lib/+(deep|x)/*.js"], excludeFiles: "**/*.min.js", options: { useTabs: true } }, + ]; + const files = { ".prettierrc.json": JSON.stringify({ overrides }) }; + const second = async (name: string) => (await format(files, ["--stdin-filepath", name], ugly)).stdout.split("\n")[1]; + expect(Object.fromEntries(await Promise.all(names.map(async name => [name, await second(name)])))).toEqual({ + "a.js": " b(1)", + "a.cjs": " b(1)", + "a.mjs": " b(1)", + "a.ts": " b(1);", + "src/a.js": " b(1)", + "src/b.ts": " b(1);", + "src/x/d.js": " b(1)", + "src/x/e.tsx": " b(1);", + "lib/f.js": " b(1)", + "lib/deep/g.js": "\tb(1)", + "lib/deep/h.min.js": " b(1)", + "sub/i.js": " b(1)", + }); +}); + +test.concurrent.each([ + // Braces around one thing are characters. + ["{a.js}", "a.js", " "], + ["{a.js,b.js}", "a.js", " "], + ["*.{js}", "a.js", " "], + // A `**` crosses `/` wherever it is. + ["**.js", "lib/deep/a.js", " "], + ["lib/**.js", "lib/deep/a.js", " "], + ["lib/**.js", "a.js", " "], + ["li**a.js", "lib/deep/a.js", " "], + ["li**/a.js", "lib/deep/a.js", " "], + ["li**", "lib/deep/a.js", " "], + ["lib/*.js", "lib/deep/a.js", " "], + // Without a `/` it is for a name in any directory. + ["a.js", "lib/deep/a.js", " "], + ["/a.js", "lib/deep/a.js", " "], +])("a section of .editorconfig for Prettier: [%s] and %s", async (section, name, indent) => { + const files = { ".prettierrc.json": "{}", ".editorconfig": `root = true\n[${section}]\nindent_size = 4\n` }; + const { stdout, exitCode } = await format(files, ["--stdin-filepath", name], ugly); + expect(stdout).toBe(`if (a) {\n${indent}b(1);\n}\n`); + expect(exitCode).toBe(0); +}); diff --git a/test/cli/format/words.test.ts b/test/cli/format/words.test.ts new file mode 100644 index 000000000000..9bff2a2178e1 --- /dev/null +++ b/test/cli/format/words.test.ts @@ -0,0 +1,150 @@ +// What `bun format` says where no configuration of Prettier or oxfmt applies. +import { afterAll, describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { endChildren, spawn } from "../children"; + +afterAll(endChildren); + +const env = { ...bunEnv, NO_COLOR: undefined, FORCE_COLOR: undefined }; +const ugly = "a ;\n"; +const formatted = "a;\n"; + +async function format(files: Record, args: string[], more: Record = {}) { + using dir = tempDir("bun-format-words", files); + await using proc = spawn({ + cmd: [bunExe(), "format", ...args], + env: { ...env, ...more }, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const after = Object.keys(files).map(name => [name, readFileSync(join(String(dir), name), "utf8")]); + return { stdout, stderr: stderr.replace(/ \[\d+(\.\d+)?m?s\]/g, ""), exitCode, files: Object.fromEntries(after) }; +} + +describe.concurrent("bun format --check", () => { + test("names the files that need formatting, writes nothing and exits with 1", async () => { + const files = { "a.js": ugly, "b.js": formatted, "src/c.ts": ugly, "d.json": "{}\n" }; + expect(await format(files, ["--check"])).toEqual({ + stdout: "a.js\nsrc/c.ts\n", + stderr: "2 files need formatting. Run bun format to fix them.\n", + exitCode: 1, + files, + }); + }); + + test("one file", async () => { + const files = { "a.js": ugly, "b.js": formatted }; + expect(await format(files, ["--check"])).toEqual({ + stdout: "a.js\n", + stderr: "1 file needs formatting. Run bun format to fix it.\n", + exitCode: 1, + files, + }); + }); + + test("exits with 0 if every file is formatted", async () => { + const [four, one] = await Promise.all([ + format({ "a.js": formatted, "b.ts": formatted, "c.json": "{}\n", "d.css": "a {\n}\n" }, ["--check"]), + format({ "a.js": formatted }, ["--check"]), + ]); + expect(four).toMatchObject({ stdout: "", stderr: "All 4 files are formatted.\n", exitCode: 0 }); + expect(one).toMatchObject({ stdout: "", stderr: "1 file is formatted.\n", exitCode: 0 }); + }); + + test("a file that cannot be read as code is counted apart", async () => { + const { stdout, stderr, exitCode } = await format( + { "a.js": ugly, "b.js": formatted, "broken.js": "const = 1;\n" }, + ["--check"], + ); + expect(stdout).toBe("a.js\n"); + expect(stderr).toContain("broken.js: SyntaxError: "); + expect(stderr).toEndWith("\n1 file needs formatting. Run bun format to fix it.\nCould not check 1 file.\n"); + expect(exitCode).toBe(2); + }); + + test("[format] in bunfig.toml and an .editorconfig change nothing about the words", async () => { + const files = { + "a.js": "a;\n", + "b.js": "if (a) {\n b\n}\n", + "bunfig.toml": "[format]\nsemi = false\n", + ".editorconfig": "[*]\nindent_size = 4\n", + }; + expect(await format(files, ["--check", "a.js", "b.js"])).toMatchObject({ + stdout: "a.js\n", + stderr: "1 file needs formatting. Run bun format to fix it.\n", + exitCode: 1, + }); + }); + + test("--log-level", async () => { + const files = { "a.js": ugly, "b.js": formatted }; + const [silent, error, warn] = await Promise.all( + ["silent", "error", "warn"].map(level => format(files, ["--check", "--log-level", level])), + ); + expect(silent).toMatchObject({ stdout: "", stderr: "", exitCode: 1 }); + expect(error).toMatchObject({ stdout: "", stderr: "", exitCode: 1 }); + expect(warn).toMatchObject({ + stdout: "", + stderr: "1 file needs formatting. Run bun format to fix it.\n", + exitCode: 1, + }); + }); + + test("colors change nothing but the colors", async () => { + const files = { "a.js": ugly, "b.js": formatted }; + const [plain, colored] = await Promise.all([ + format(files, ["--check"]), + format(files, ["--check"], { FORCE_COLOR: "1" }), + ]); + expect(colored.stderr).toContain("\x1b["); + expect(Bun.stripANSI(colored.stderr)).toBe(plain.stderr); + expect(colored.stdout).toBe(plain.stdout); + }); + + test.each([ + ["a .prettierrc", { ".prettierrc": "{}\n" }, []], + ["prettier in package.json", { "package.json": `{ "prettier": {} }` }, []], + ["--config", { "mine.json": "{}\n" }, ["--config", "mine.json"]], + ["--flavor prettier", {}, ["--flavor", "prettier"]], + ])("with %s the words are Prettier's", async (_, configuration, flags) => { + const [different, same] = await Promise.all([ + format({ ...configuration, "a.js": ugly, "b.js": ugly }, [...flags, "--check", "a.js", "b.js"]), + format({ ...configuration, "a.js": formatted }, [...flags, "--check", "a.js"]), + ]); + expect(different).toMatchObject({ + stdout: "Checking formatting...\n", + stderr: "[warn] a.js\n[warn] b.js\n[warn] Code style issues found in 2 files. Run bun format to fix.\n", + exitCode: 1, + }); + expect(same).toMatchObject({ + stdout: "Checking formatting...\nAll matched files use Prettier code style!\n", + stderr: "", + exitCode: 0, + }); + }); +}); + +describe.concurrent("bun format", () => { + test("names the files that it writes, and counts them", async () => { + const { stdout, stderr, exitCode, files } = await format({ "a.js": ugly, "b.js": formatted, "c.js": ugly }, []); + expect(stdout).toBe("a.js\nc.js\n"); + expect(stderr).toBe("Formatted 2 files, 1 unchanged\n"); + expect(files).toEqual({ "a.js": formatted, "b.js": formatted, "c.js": formatted }); + expect(exitCode).toBe(0); + }); + + test("--check --write does the same", async () => { + const { stdout, stderr, exitCode, files } = await format({ "a.js": ugly, "b.js": formatted }, [ + "--check", + "--write", + ]); + expect(stdout).toBe("a.js\n"); + expect(stderr).toBe("Formatted 1 file, 1 unchanged\n"); + expect(files).toEqual({ "a.js": formatted, "b.js": formatted }); + expect(exitCode).toBe(0); + }); +}); diff --git a/test/cli/init/init.test.ts b/test/cli/init/init.test.ts index 1c35702f6951..1f0b6964996c 100644 --- a/test/cli/init/init.test.ts +++ b/test/cli/init/init.test.ts @@ -497,6 +497,34 @@ const initEnv = { ...bunEnv, BUN_AGENT_RULE_DISABLED: "1" }; ]); expect({ checkStdout, checkStderr, checkExited }).toMatchObject({ checkStdout: "", checkExited: 0 }); + // `bun lint` has nothing to report, not even a warning, and `bun format` nothing to change. + await using lint = Bun.spawn({ + cmd: [bunExe(), "lint", "-f", "json"], + cwd: temp, + stdio: ["ignore", "pipe", "pipe"], + env: bunEnv, + }); + const [lintStdout, lintStderr, lintExited] = await Promise.all([ + lint.stdout.text(), + lint.stderr.text(), + lint.exited, + ]); + const problems = JSON.parse(lintStdout).flatMap((file: { messages: unknown[] }) => file.messages); + expect({ problems, lintStderr, lintExited }).toMatchObject({ problems: [], lintExited: 0 }); + + await using format = Bun.spawn({ + cmd: [bunExe(), "format", "--check"], + cwd: temp, + stdio: ["ignore", "pipe", "pipe"], + env: bunEnv, + }); + const [formatStdout, formatStderr, formatExited] = await Promise.all([ + format.stdout.text(), + format.stderr.text(), + format.exited, + ]); + expect({ formatStdout, formatStderr, formatExited }).toMatchObject({ formatExited: 0 }); + // The blank template has no `build` script; the react templates do. // --bun for the same reason: the script is `bun run build.ts`. const pkg = JSON.parse(fs.readFileSync(path.join(temp, "package.json"), "utf8")); @@ -518,6 +546,32 @@ const initEnv = { ...bunEnv, BUN_AGENT_RULE_DISABLED: "1" }; 180_000, ); + test.skipIf(isWindows)( + "the CLAUDE.md is as `bun format` leaves it", + async () => { + await using temp = tempDir("bun-init-claude-md", { "bin/claude": "#!/bin/sh\n" }); + fs.chmodSync(path.join(temp, "bin/claude"), 0o755); + await using init = Bun.spawn({ + cmd: [bunExe(), "init", "-y", "app"], + cwd: temp, + stdio: ["ignore", "ignore", "inherit"], + env: { ...bunEnv, PATH: `${path.join(temp, "bin")}:${bunEnv.PATH}`, CURSOR_AGENT_RULE_DISABLED: "1" }, + }); + expect(await init.exited).toBe(0); + expect(fs.readFileSync(path.join(temp, "app/CLAUDE.md"), "utf8")).toStartWith("Default to using Bun"); + + await using format = Bun.spawn({ + cmd: [bunExe(), "format", "--check", "CLAUDE.md"], + cwd: path.join(temp, "app"), + stdio: ["ignore", "pipe", "pipe"], + env: bunEnv, + }); + const [stdout, stderr, exitCode] = await Promise.all([format.stdout.text(), format.stderr.text(), format.exited]); + expect({ stdout, stderr, exitCode }).toMatchObject({ exitCode: 0 }); + }, + 30_000, + ); + test("nested `bun install` output is inherited", async () => { // `bun init` spawns `bun install` via spawn_sync_inherit. The child must // inherit stdout/stderr so its output reaches the parent's pipe — a diff --git a/test/cli/lint/bun-rules.test.ts b/test/cli/lint/bun-rules.test.ts index 0294859089c2..3e2f3e401e03 100644 --- a/test/cli/lint/bun-rules.test.ts +++ b/test/cli/lint/bun-rules.test.ts @@ -2260,14 +2260,13 @@ describe.concurrent("the plugin bun", () => { expect(exitCode).toBe(1); }); - test("--rules lists them, without a category and without a page", async () => { + test("--rules lists them, without a category", async () => { const { stdout } = await lint({ ".oxlintrc.json": "{}" }, ["--rules", "-f", "json"]); const listed = (JSON.parse(stdout) as Record[]).filter(it => it.scope === "bun"); // `bun/format` is tested with `prettier/prettier`: prettier-rule.test.ts. expect(listed.map(it => it.value)).toEqual([...Object.keys(rules), "format"].toSorted()); - expect([...new Set(listed.map(it => JSON.stringify([it.category, it.default, it.docs_url])))]).toEqual([ - "[null,false,null]", - ]); + expect([...new Set(listed.map(it => JSON.stringify([it.category, it.default])))]).toEqual(["[null,false]"]); + expect(listed.map(it => it.docs_url)).toEqual(listed.map(it => `https://bun.com/docs/lint/rules/bun/${it.value}`)); }); test("options that a rule does not have are refused", async () => { @@ -2291,7 +2290,7 @@ describe.concurrent("the plugin bun", () => { const options = { patterns: [{ pattern: "(" }] }; const files = { "eslint.config.mjs": flat({ "bun/no-restricted-text": ["error", options] }), "a.js": eager }; const { stderr, exitCode } = await lint(files, ["a.js"]); - expect(stderr).toContain(`Invalid regular expression "(": `); + expect(stderr).toContain(`Invalid pattern "(": Invalid regular expression: `); expect(exitCode).toBe(2); }); @@ -2875,7 +2874,7 @@ var a = b; expect(JSON.parse(oxlint.stdout).plugins).toEqual([]); }); - test("--rules lists them, without a category and without a page", async () => { + test("--rules lists them, without a category", async () => { const [json, lines, alone] = await Promise.all([ lint({ ".oxlintrc.json": "{}" }, ["--rules", "-f", "json"]), lint({}, ["--rules"]), @@ -2885,9 +2884,10 @@ var a = b; expect(alone.stdout).toBe(json.stdout); const listed = (JSON.parse(json.stdout) as Record[]).filter(it => it.scope === "bun:test"); expect(listed.map(it => it.value)).toEqual(Object.keys(twins)); - expect([...new Set(listed.map(it => JSON.stringify([it.category, it.default, it.docs_url])))]).toEqual([ - "[null,false,null]", - ]); + expect([...new Set(listed.map(it => JSON.stringify([it.category, it.default])))]).toEqual(["[null,false]"]); + expect(listed.map(it => it.docs_url)).toEqual( + listed.map(it => `https://bun.com/docs/lint/rules/bun-test/${it.value}`), + ); expect(lines.stdout.split("\n").filter(it => it.startsWith("bun:test/"))).toEqual( Object.keys(twins).map(it => `bun:test/${it}`), ); diff --git a/test/cli/lint/config-files.test.ts b/test/cli/lint/config-files.test.ts index 3e8ae53ce9c8..8f5637f1be76 100644 --- a/test/cli/lint/config-files.test.ts +++ b/test/cli/lint/config-files.test.ts @@ -3317,7 +3317,7 @@ describe.concurrent("whose configuration files count", () => { test("--flavor takes two values", async () => { const { stderr, exitCode } = await lint(both, ["--flavor=biome", "."]); - expect(stderr).toContain("Option flavor: 'biome' not one of eslint or oxlint."); + expect(stderr).toContain('--flavor must be eslint or oxlint, not "biome"'); expect(exitCode).toBe(1); }); @@ -3623,11 +3623,11 @@ describe.concurrent("the command line of oxlint", () => { test.each([ [["--nonsense"], "Invalid option '--nonsense'"], - [["--threads=x"], '--threads takes a number, not "x".'], + [["--threads=x"], '--threads takes a number, not "x"'], [["--max-warnings=x"], "Invalid value for option 'max-warnings'"], [["--debug=nonsense"], "couldn't parse `nonsense`: 'nonsense' is not a known debug option"], [["--debug=files,timings"], "debug option 'files' cannot be combined with other debug options"], - [["-f", "nonsense"], 'There is no formatter "nonsense".'], + [["-f", "nonsense"], 'Unknown format "nonsense"\n Available formats: stylish, '], [["-c", "nowhere.json"], "nowhere.json"], [["--tsconfig", "nowhere.json"], 'nowhere.json" does not exist, Please provide a valid tsconfig file.'], [ @@ -3903,6 +3903,16 @@ describe.concurrent("what the configuration asks for and cannot be done", () => expect((await lint({ ...files, "a.js": "let x = 1;\nx;\n" }, ["--allow-unsupported", "."])).exitCode).toBe(0); }); + test("a rule that only a comment turns on, of a plugin that is built in only in part", async () => { + const files = { "a.js": "/* eslint promise/not-there: error, jest/nope: error */\nexport {};\n" }; + const { problems, stderr, exitCode } = await lint(files); + expect(problems).toEqual([]); + expect(stderr).toContain( + "error: 2 rules enabled in comments did not run: jest/nope, promise/not-there\n Everything else was linted.", + ); + expect(exitCode).toBe(2); + }); + test("files in a language that is not read here", async () => { const files = { "eslint.config.mjs": `export default [ @@ -4851,7 +4861,7 @@ describe.concurrent("a search that leaves the project of an eslint.config.js", ( ["..", { links: { "linked": "theirs" } }, "linked"], ])("a file without a configuration file ends the run: %s %j", async (argument, more, file) => { const { ran, files, stderr, exitCode } = await run("mine", [argument], more); - expect(stderr).toContain(`Could not find config file.
    /w/${file} has no eslint.config.* `); + expect(stderr).toContain(`Could not find config file.\n No eslint.config.* was found next to /w/${file} or`); expect(ran.filter(it => !it.startsWith("mine"))).toEqual([]); expect({ files, exitCode }).toEqual({ files: [], exitCode: 2 }); }); diff --git a/test/cli/lint/defaults.test.ts b/test/cli/lint/defaults.test.ts new file mode 100644 index 000000000000..7081abaca4e9 --- /dev/null +++ b/test/cli/lint/defaults.test.ts @@ -0,0 +1,383 @@ +// What `bun lint` does in a project that has no configuration of ESLint or oxlint. +import { afterAll, describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, normalizeBunSnapshot, tempDir } from "harness"; +import { endChildren, spawn } from "../children"; + +afterAll(endChildren); + +const env = { + ...bunEnv, + AGENT: "0", + CLAUDECODE: undefined, + REPL_ID: undefined, + GITHUB_ACTIONS: undefined, + NO_COLOR: "1", +}; + +type Result = { filePath: string; messages: { line: number; column: number; severity: number; ruleId: string }[] }; + +/** `reports`: `a.js:1:1 error no-debugger` for each problem. */ +async function lint(files: Record, args: string[], stdin?: string) { + using dir = tempDir("bun-lint-defaults", files); + await using proc = spawn({ + cmd: [bunExe(), "lint", "-f", "json", ...args], + env, + cwd: String(dir), + stdin: stdin === undefined ? "ignore" : Buffer.from(stdin), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const json = JSON.parse(stdout || "null"); + const nameOf = (path: string) => normalizeBunSnapshot(path, String(dir)).slice("/".length); + const reports = (Array.isArray(json) ? (json as Result[]) : []).flatMap(({ filePath, messages }) => + messages.map( + it => `${nameOf(filePath)}:${it.line}:${it.column} ${it.severity === 2 ? "error" : "warning"} ${it.ruleId}`, + ), + ); + return { json, reports, stderr, exitCode }; +} + +describe.concurrent("severities", () => { + const files = { + "a.js": "debugger;\nif (Bun) {\n}\nlet unused;\n", + "b.ts": "export let a: any = 1;\nexport const b = 1 as unknown as Number;\n", + }; + + test("a default rule about a problem is an error, one about code that does nothing and every other one a warning", async () => { + const { reports, exitCode } = await lint(files, []); + expect(reports).toEqual([ + "a.js:1:1 error no-debugger", + "a.js:2:10 warning no-empty", + "a.js:4:5 warning no-unused-vars", + "b.ts:1:12 warning prefer-const", + "b.ts:1:15 warning @typescript-eslint/no-explicit-any", + "b.ts:2:34 error @typescript-eslint/no-wrapper-object-types", + ]); + expect(exitCode).toBe(1); + }); + + test("with --type-aware too", async () => { + const { reports, exitCode } = await lint( + { + "tsconfig.json": "{}", + "a.ts": "export async function f() {\n return 1;\n}\nf();\nawait 1;\n", + }, + ["--type-aware"], + ); + expect(reports).toEqual([ + "a.ts:1:8 warning @typescript-eslint/require-await", + "a.ts:4:1 error @typescript-eslint/no-floating-promises", + "a.ts:5:1 warning @typescript-eslint/await-thenable", + ]); + expect(exitCode).toBe(1); + }); + + test("warnings do not fail the run, --max-warnings does, --quiet hides them", async () => { + const warned = { "a.js": files["a.js"].replace("debugger;", "") }; + const [plain, limited, quiet] = await Promise.all([ + lint(warned, []), + lint(warned, ["--max-warnings", "0"]), + lint(files, ["--quiet"]), + ]); + expect(plain.reports).toEqual(["a.js:2:10 warning no-empty", "a.js:4:5 warning no-unused-vars"]); + expect(limited.reports).toEqual(plain.reports); + expect(limited.stderr).toContain("Found too many warnings (maximum: 0)."); + expect(quiet.reports).toEqual([ + "a.js:1:1 error no-debugger", + "b.ts:2:34 error @typescript-eslint/no-wrapper-object-types", + ]); + expect([plain.exitCode, limited.exitCode, quiet.exitCode]).toEqual([0, 1, 1]); + }); + + test("what bunfig.toml and --rule set stays as it is set", async () => { + const bunfig = `[lint.rules]\n"no-empty" = "error"\n"no-debugger" = "warn"\n"no-var" = "error"\n`; + const [byFile, byFlag] = await Promise.all([ + lint({ ...files, "bunfig.toml": bunfig, "c.js": "var c = 1;\nexport { c };\n" }, []), + lint(files, ["--rule", "prefer-const: error", "--rule", "no-debugger: off"]), + ]); + expect(byFile.reports).toEqual([ + "a.js:1:1 warning no-debugger", + "a.js:2:10 error no-empty", + "a.js:4:5 warning no-unused-vars", + "b.ts:1:12 warning prefer-const", + "b.ts:1:15 warning @typescript-eslint/no-explicit-any", + "b.ts:2:34 error @typescript-eslint/no-wrapper-object-types", + "c.js:1:1 error no-var", + ]); + expect(byFlag.reports).toEqual([ + "a.js:2:10 warning no-empty", + "a.js:4:5 warning no-unused-vars", + "b.ts:1:12 error prefer-const", + "b.ts:1:15 warning @typescript-eslint/no-explicit-any", + "b.ts:2:34 error @typescript-eslint/no-wrapper-object-types", + ]); + }); + + test("the same configurations are all errors in an eslint.config.js", async () => { + const config = `export default [ + { files: ["**/*.{js,ts}"], extends: ["eslint:recommended"], languageOptions: { globals: { Bun: "readonly" } } }, + { files: ["**/*.ts"], extends: ["typescript-eslint/recommended"] }, + ];\n`; + const { reports } = await lint({ ...files, "eslint.config.js": config }, ["a.js", "b.ts"]); + expect(reports).toEqual([ + "a.js:1:1 error no-debugger", + "a.js:2:10 error no-empty", + "a.js:4:5 error no-unused-vars", + "b.ts:1:12 error prefer-const", + "b.ts:1:15 error @typescript-eslint/no-explicit-any", + "b.ts:2:34 error @typescript-eslint/no-wrapper-object-types", + ]); + }); + + test("bun:test/no-disabled-tests is a warning, the other rules for tests are errors", async () => { + const code = `import { expect, test } from "bun:test";\ntest.skip("a", () => {});\ntest.only("b", () => {\n expect(1);\n});\n`; + const { reports } = await lint({ "a.test.ts": code }, []); + expect(reports).toEqual([ + "a.test.ts:2:1 warning bun:test/no-disabled-tests", + "a.test.ts:3:6 error bun:test/no-focused-tests", + "a.test.ts:4:3 error bun:test/valid-expect", + ]); + }); + + test("--print-config", async () => { + const { json } = await lint(files, ["--print-config", "b.ts"]); + const { rules } = json; + expect({ + "no-debugger": rules["no-debugger"], + "no-empty": rules["no-empty"], + "prefer-const": rules["prefer-const"], + "@typescript-eslint/no-explicit-any": rules["@typescript-eslint/no-explicit-any"], + "@typescript-eslint/no-unused-vars": rules["@typescript-eslint/no-unused-vars"], + "no-unused-vars": rules["no-unused-vars"], + }).toEqual({ + "no-debugger": [2], + "no-empty": [1], + "prefer-const": [1], + "@typescript-eslint/no-explicit-any": [1], + "@typescript-eslint/no-unused-vars": [1], + "no-unused-vars": [0], + }); + }); +}); + +describe.concurrent("declaration files", () => { + const code = `declare var a: any; +declare const b: Number; +declare const d: {}; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +declare const c: any; +`; + + test.each(["types.d.ts", "types.d.mts", "types.d.cts", "types.d.css.ts"])( + "%s: the default rules that are errors run, those that are warnings do not", + async name => { + const { reports, exitCode } = await lint({ [name]: code }, []); + expect(reports).toEqual([`${name}:2:18 error @typescript-eslint/no-wrapper-object-types`]); + expect(exitCode).toBe(1); + }, + ); + + test("other files that are called alike have the warnings", async () => { + const { reports } = await lint({ "d.ts": "export const a: any = 1;\n", "a.d.js": "let a;\n" }, []); + expect(reports).toEqual([ + "a.d.js:1:5 warning no-unused-vars", + "d.ts:1:17 warning @typescript-eslint/no-explicit-any", + ]); + }); + + test("what bunfig.toml and --rule set is for them too", async () => { + const bunfig = `[lint.rules]\n"@typescript-eslint/no-explicit-any" = "warn"\n"no-var" = "error"\n`; + const expected = [ + "types.d.ts:1:1 error no-var", + "types.d.ts:1:16 warning @typescript-eslint/no-explicit-any", + "types.d.ts:2:18 error @typescript-eslint/no-wrapper-object-types", + ]; + const [byFile, byFlag] = await Promise.all([ + lint({ "types.d.ts": code, "bunfig.toml": bunfig }, []), + lint({ "types.d.ts": code }, ["--rule", "@typescript-eslint/no-explicit-any: warn", "--rule", "no-var: error"]), + ]); + expect(byFile.reports).toEqual(expected); + expect(byFlag.reports).toEqual(expected); + }); + + test("with an eslint.config.js they are files like any other", async () => { + const config = `export default [{ files: ["**/*.ts"], extends: ["typescript-eslint/recommended"] }];\n`; + const { reports } = await lint({ "types.d.ts": code, "eslint.config.js": config }, ["types.d.ts"]); + expect(reports).toEqual([ + "types.d.ts:1:1 error no-var", + "types.d.ts:1:16 error @typescript-eslint/no-explicit-any", + "types.d.ts:2:18 error @typescript-eslint/no-wrapper-object-types", + "types.d.ts:3:18 error @typescript-eslint/no-empty-object-type", + ]); + }); +}); + +describe.concurrent("React", () => { + const component = `import { useEffect, useState } from "react"; +export function App({ items, on }) { + if (on) { + useState(0); + } + useEffect(() => { + console.log(items); + }, []); + return ( +
      + {items.map(it => ( +
    • {it}
    • + ))} +
    + ); +} +`; + const hook = `import { useEffect } from "react"; +export function useThing(a, on) { + if (on) { + useEffect(() => { + console.log(a); + }, []); + } +} +`; + const found = (name: string) => [ + `${name}:4:5 error react-hooks/rules-of-hooks`, + `${name}:8:6 warning react-hooks/exhaustive-deps`, + `${name}:12:9 error react/jsx-key`, + ]; + const dependingOn = (field: string, name = "react") => + JSON.stringify({ name: "app", [field]: { [name]: "^19.0.0" } }); + + test.each(["dependencies", "devDependencies", "peerDependencies"])( + "a file with JSX beside a package.json with react in %s", + async field => { + const files = { "package.json": dependingOn(field), "src/App.jsx": component, "src/App.tsx": component }; + const { reports, exitCode } = await lint(files, []); + expect(reports).toEqual([...found("src/App.jsx"), ...found("src/App.tsx")]); + expect(exitCode).toBe(1); + }, + ); + + test("JSX in a .js file counts, a file without JSX does not", async () => { + const files = { "package.json": dependingOn("dependencies"), "App.js": component, "useThing.js": hook }; + const { reports } = await lint(files, []); + expect(reports).toEqual(found("App.js")); + }); + + test.each([ + ["no package.json", {}], + ["a package.json without react", { "package.json": dependingOn("dependencies", "preact") }], + ["react in optionalDependencies", { "package.json": dependingOn("optionalDependencies") }], + ])("%s: nothing", async (_, more) => { + const { reports, exitCode } = await lint({ ...more, "App.jsx": component }, []); + expect(reports).toEqual([]); + expect(exitCode).toBe(0); + }); + + test("the closest package.json decides", async () => { + const files = { + "package.json": dependingOn("dependencies"), + "App.jsx": component, + "deep/er/App.jsx": component, + "mail/package.json": dependingOn("dependencies", "preact"), + "mail/src/App.jsx": component, + "mail/web/package.json": dependingOn("peerDependencies"), + "mail/web/App.jsx": component, + }; + const { reports } = await lint(files, []); + expect(reports).toEqual([...found("App.jsx"), ...found("deep/er/App.jsx"), ...found("mail/web/App.jsx")]); + }); + + test("a file that is named, and standard input", async () => { + const files = { "package.json": dependingOn("dependencies"), "src/App.jsx": component }; + const [named, piped] = await Promise.all([ + lint(files, ["src/App.jsx"]), + lint(files, ["--stdin", "--stdin-filename", "src/Other.jsx"], component), + ]); + expect(named.reports).toEqual(found("src/App.jsx")); + expect(piped.reports).toEqual(found("src/Other.jsx")); + }); + + test("with --type-aware", async () => { + const files = { + "package.json": dependingOn("dependencies"), + "tsconfig.json": JSON.stringify({ compilerOptions: { jsx: "preserve", noImplicitAny: false } }), + "App.tsx": component, + "useThing.ts": hook, + }; + const { reports } = await lint(files, ["--type-aware"]); + expect(reports.filter(it => it.includes("react"))).toEqual(found("App.tsx")); + }); + + test("what is set for them stays as it is set, also for a file without JSX", async () => { + const bunfig = `[lint.rules]\n"react-hooks/rules-of-hooks" = "warn"\n"react-hooks/exhaustive-deps" = "error"\n"react/jsx-key" = "off"\n`; + const files = { "bunfig.toml": bunfig, "App.jsx": component, "useThing.js": hook }; + const expected = [ + "App.jsx:4:5 warning react-hooks/rules-of-hooks", + "App.jsx:8:6 error react-hooks/exhaustive-deps", + "useThing.js:4:5 warning react-hooks/rules-of-hooks", + "useThing.js:6:8 error react-hooks/exhaustive-deps", + ]; + const [withReact, without] = await Promise.all([ + lint({ ...files, "package.json": dependingOn("dependencies") }, []), + lint(files, []), + ]); + expect(withReact.reports).toEqual(expected); + expect(without.reports).toEqual(expected); + }); + + test("a comment that names one in a file without JSX is not called unused", async () => { + const commented = hook.replace( + " }, []);", + " // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);", + ); + const files = { "package.json": dependingOn("dependencies"), "useThing.js": commented }; + const { reports, exitCode } = await lint(files, []); + expect(reports).toEqual([]); + expect(exitCode).toBe(0); + }); + + test("comments disable them", async () => { + const files = { + "package.json": dependingOn("dependencies"), + "App.jsx": component + .replace(" useState(0);", " // eslint-disable-next-line react-hooks/rules-of-hooks\n useState(0);") + .replace(" }, []);", " // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);") + .replace("
  • ", " // eslint-disable-next-line react/jsx-key\n
  • "), + }; + const { reports, exitCode } = await lint(files, []); + expect(reports).toEqual([]); + expect(exitCode).toBe(0); + }); + + test("--print-config has them where package.json has react", async () => { + const files = { + "package.json": dependingOn("dependencies"), + "mail/package.json": dependingOn("dependencies", "preact"), + }; + const [withReact, without] = await Promise.all([ + lint(files, ["--print-config", "App.jsx"]), + lint(files, ["--print-config", "mail/App.jsx"]), + ]); + const ofReact = (printed: { rules: object }) => + Object.entries(printed.rules).filter(([name]) => name.startsWith("react")); + expect(ofReact(withReact.json)).toEqual([ + ["react-hooks/rules-of-hooks", [2]], + ["react-hooks/exhaustive-deps", [1]], + ["react/jsx-key", [2]], + ]); + expect(ofReact(without.json)).toEqual([]); + }); + + test("not with a configuration of ESLint or oxlint", async () => { + const files = { "package.json": dependingOn("dependencies"), "App.jsx": component }; + const ofEslint = `export default [{ files: ["**/*.jsx"], languageOptions: { parserOptions: { ecmaFeatures: { jsx: true } } } }];\n`; + const [eslint, oxlint] = await Promise.all([ + lint({ ...files, "eslint.config.js": ofEslint }, ["App.jsx"]), + lint({ ...files, ".oxlintrc.json": "{}" }, ["App.jsx"]), + ]); + expect(eslint.json).toEqual([expect.objectContaining({ messages: [] })]); + expect(oxlint.json.diagnostics).toEqual([]); + expect([eslint.exitCode, oxlint.exitCode]).toEqual([0, 0]); + }); +}); diff --git a/test/cli/lint/doctor.test.ts b/test/cli/lint/doctor.test.ts index e146c4631e55..26a26cef2974 100644 --- a/test/cli/lint/doctor.test.ts +++ b/test/cli/lint/doctor.test.ts @@ -304,7 +304,7 @@ Still needed: eslint-plugin-first (its rules run in JavaScript) async () => { const files = { ...flat, ...plugin("eslint-plugin-react-hooks", ["rules-of-hooks"], { version: "1.0.0" }) }; const [text, json] = await Promise.all([doctor(files), doctor(files, ["-f", "json"])]); - const note = /^eslint-plugin-react-hooks 1\.0\.0 is installed; bun lint follows [\d.]+\.$/; + const note = /^eslint-plugin-react-hooks 1\.0\.0 is installed/; expect(JSON.parse(json.stdout).notes).toEqual([expect.stringMatching(note)]); expect(text.stdout).toEndWith(`\n\nnote: ${JSON.parse(json.stdout).notes[0]}\n`); expect(text.exitCode).toBe(0); @@ -338,11 +338,12 @@ Still needed: eslint (some files go to it) 2 files to lint 2 files cannot be linted: 2 *.foo + Install eslint to lint them 1 rule runs natively 0 rules run in JavaScript -You can remove: eslint +Still needed: eslint (some files go to it) `); expect(installed.exitCode).toBe(0); expect(missing.exitCode).toBe(0); diff --git a/test/cli/lint/explain.test.ts b/test/cli/lint/explain.test.ts new file mode 100644 index 000000000000..1d1f5e9fd51d --- /dev/null +++ b/test/cli/lint/explain.test.ts @@ -0,0 +1,309 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "fs"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; +import { join } from "path"; + +const pagesDir = join(import.meta.dir, "../../../docs/lint/rules"); + +type Env = Record; + +async function lint(files: Record, args: string[], env: Env = {}) { + using dir = tempDir("bun-lint-explain", files); + await using proc = Bun.spawn({ + cmd: [bunExe(), "lint", ...args], + env: { ...bunEnv, COLUMNS: "80", ...env }, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; +} + +/** The same with a terminal as standard output. */ +async function lintInTerminal(args: string[], env: Env = {}) { + using dir = tempDir("bun-lint-explain", {}); + let output = ""; + const decoder = new TextDecoder(); + await using proc = Bun.spawn({ + cmd: [bunExe(), "lint", ...args], + env: { ...bunEnv, ...env }, + cwd: String(dir), + terminal: { cols: 200, rows: 50, data: (_, chunk) => void (output += decoder.decode(chunk, { stream: true })) }, + }); + const exitCode = await proc.exited; + return { output: output.replaceAll("\r\n", "\n"), exitCode }; +} + +interface Listed { + scope: string; + value: string; + category: string | null; + type_aware: boolean; + fix: string; + default: boolean; + docs_url: string; + id: string; + aliases: string[]; + description: string; + kind: "problem" | "suggestion" | "layout" | null; + deprecated: boolean; + on_by_default: "error" | "warn" | false; + modes: ("native" | "eslint" | "oxlint")[]; +} + +const listRules = async () => JSON.parse((await lint({}, ["--rules", "-f", "json"])).stdout) as Listed[]; + +/** `/` of every page. */ +const pages = () => [...new Bun.Glob("*/**/*.mdx").scanSync(pagesDir)].map(it => it.replaceAll("\\", "/").slice(0, -4)); + +function frontmatter(page: string) { + const text = readFileSync(join(pagesDir, page + ".mdx"), "utf8"); + const field = (key: string) => JSON.parse(new RegExp(`^${key}: (".*")$`, "m").exec(text.split("\n---\n")[0])![1]); + return { description: field("description") as string }; +} + +const base = "https://bun.com/docs/lint/rules/"; + +describe.concurrent("--rules -f json", () => { + test("has oxlint's keys first, then Bun's", async () => { + const rules = await listRules(); + expect(rules.length).toBeGreaterThan(1000); + expect([...new Set(rules.map(it => Object.keys(it).join()))]).toEqual([ + "scope,value,category,type_aware,fix,default,docs_url,id,aliases,description,kind,deprecated,on_by_default,modes", + ]); + expect(rules.find(it => it.id === "@typescript-eslint/no-floating-promises")).toEqual({ + scope: "typescript", + value: "no-floating-promises", + category: "correctness", + type_aware: true, + fix: "fixable_suggestion", + default: true, + docs_url: base + "typescript/no-floating-promises", + id: "@typescript-eslint/no-floating-promises", + aliases: ["typescript-eslint/no-floating-promises", "typescript/no-floating-promises"], + description: frontmatter("typescript/no-floating-promises").description, + kind: "problem", + deprecated: false, + on_by_default: false, + modes: ["native", "eslint", "oxlint"], + }); + }); + + test("is the same whatever config the project has", async () => { + const [alone, eslint, oxlint] = await Promise.all([ + lint({}, ["--rules", "-f", "json"]), + lint({ "eslint.config.mjs": "export default [];" }, ["--rules", "-f", "json"]), + lint({ ".oxlintrc.json": "{}", "bunfig.toml": '[lint.rules]\n"no-var" = "off"\n' }, ["--rules", "-f", "json"]), + ]); + expect(eslint.stdout).toBe(alone.stdout); + expect(oxlint.stdout).toBe(alone.stdout); + }); + + test("every rule has one ID, and no name means two rules", async () => { + const rules = await listRules(); + const names = rules.flatMap(it => [it.id, ...it.aliases]); + expect(names.filter((name, at) => names.indexOf(name) !== at)).toEqual([]); + expect(rules.filter(it => !it.modes.includes("native")).map(it => it.id)).toEqual([]); + expect([...new Set(rules.map(it => it.kind))].sort()).toEqual(["layout", "problem", "suggestion", null].sort()); + }); + + test("docs_url is the page of the rule, by the directory of its plugin", async () => { + const rules = await listRules(); + const directories = Object.fromEntries(rules.map(it => [it.scope, it.docs_url.slice(base.length).split("/")[0]])); + expect(directories).toEqual({ + "bun": "bun", + "bun:test": "bun-test", + "eslint": "eslint", + "import": "import", + "jest": "jest", + "jsx_a11y": "jsx-a11y", + "nextjs": "nextjs", + "node": "node", + "oxc": "oxc", + "prettier": "prettier", + "promise": "promise", + "react": "react", + "react_perf": "react-perf", + "typescript": "typescript", + "unicorn": "unicorn", + "vitest": "vitest", + }); + expect(rules.filter(it => it.docs_url !== `${base}${directories[it.scope]}/${it.value}`)).toEqual([]); + }); + + test("description is the description of the page", async () => { + const rules = new Map((await listRules()).map(it => [it.docs_url.slice(base.length), it])); + const written = pages().map(page => ({ page, description: frontmatter(page).description })); + expect(written.length).toBeGreaterThan(1000); + expect(written.map(({ page }) => ({ page, description: rules.get(page)?.description }))).toEqual(written); + }); + + test("on_by_default is what --print-config says where nothing is configured", async () => { + const react = { "package.json": JSON.stringify({ dependencies: { react: "^19.0.0" } }) }; + const [rules, ...printed] = await Promise.all([ + listRules(), + ...["a.js", "a.jsx", "a.ts", "a.tsx"].map(file => lint(react, ["--print-config", file])), + ]); + const strongest: Record = {}; + for (const { stdout } of printed) { + for (const [id, [severity]] of Object.entries(JSON.parse(stdout).rules as Record)) { + strongest[id] = Math.max(strongest[id] ?? 0, severity); + } + } + const on = Object.entries(strongest).filter(it => it[1] > 0); + expect(on.length).toBeGreaterThan(70); + expect(Object.fromEntries(rules.filter(it => it.on_by_default).map(it => [it.id, it.on_by_default]))).toEqual( + Object.fromEntries(on.map(([id, severity]) => [id, severity === 2 ? "error" : "warn"])), + ); + expect(strongest).toMatchObject({ + "no-debugger": 2, + "react-hooks/rules-of-hooks": 2, + "react-hooks/exhaustive-deps": 1, + }); + }); +}); + +describe.concurrent("--rules", () => { + test("into a pipe: a rule a line", async () => { + const [{ stdout, stderr, exitCode }, rules] = await Promise.all([lint({}, ["--rules"]), listRules()]); + expect(stdout).toBe(rules.map(it => `${it.scope}/${it.value}\n`).join("")); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); + + test.skipIf(isWindows)("in a terminal: by plugin, with the descriptions", async () => { + const [{ output, exitCode }, rules] = await Promise.all([lintInTerminal(["--rules"]), listRules()]); + const lines = output.split("\n"); + const count = (scope: string) => rules.filter(it => it.scope === scope).length; + expect(lines.filter(line => /^\S.* rules?\)$/.test(line))).toEqual( + [...new Set(rules.map(it => it.scope))].map(scope => { + const directory = rules + .find(it => it.scope === scope)! + .docs_url.slice(base.length) + .split("/")[0]; + return `${directory} (${count(scope)} ${count(scope) === 1 ? "rule" : "rules"})`; + }), + ); + const rows = lines.filter(line => line.startsWith(" ")); + expect(rows.map(row => row.slice(9).split(" ")[0])).toEqual(rules.map(it => it.id)); + expect(rows.map(row => row.slice(2, 9).trim() || false)).toEqual(rules.map(it => it.on_by_default)); + const described = (id: string) => + rows + .find(row => row.slice(9).split(" ")[0] === id)! + .slice(9 + id.length) + .trim(); + expect(described("no-var")).toBe(frontmatter("eslint/no-var").description.replaceAll("`", "")); + expect(lines.slice(-3)).toEqual([ + `${rules.length} rules. The ones marked error or warn are on by default.`, + "Run bun lint --explain to read about a rule.", + "", + ]); + expect(exitCode).toBe(0); + }); +}); + +describe.concurrent("--explain", () => { + test("prints the page of the rule", async () => { + const { stdout, stderr, exitCode } = await lint({}, ["--explain", "eqeqeq"]); + const { description } = frontmatter("eslint/eqeqeq"); + expect(stdout.split("\n").slice(0, 7)).toEqual([ + "eqeqeq", + "======", + "", + description.replaceAll("`", ""), + "", + "Off by default, fixable. Also called eslint/eqeqeq.", + "", + ]); + expect(stdout).toContain("\nExamples\n--------\n"); + expect(stdout).toEndWith(`\n${base}eslint/eqeqeq\n`); + // Neither the frontmatter nor the comments of MDX. + expect(stdout).not.toContain("title:"); + expect(stdout).not.toContain("{/*"); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); + + test("says what the code knows about the rule", async () => { + const third = async (rule: string) => + (await lint({}, ["--explain", rule], { COLUMNS: "200" })).stdout.split("\n\n")[2]; + const rules = ["no-debugger", "prefer-const", "typescript/no-floating-promises", "no-unused-vars", "jest/no-hooks"]; + expect(await Promise.all(rules.map(third))).toEqual([ + "On by default as an error. Also called eslint/no-debugger.", + "On by default as a warning, fixable. Also called eslint/prefer-const.", + "Off by default, needs types. Also called typescript-eslint/no-floating-promises, typescript/no-floating-promises.", + "On by default as a warning. Also called eslint/no-unused-vars. See also @typescript-eslint/no-unused-vars.", + "Off by default. See also bun:test/no-hooks, vitest/no-hooks.", + ]); + }); + + test("takes every name of a rule", async () => { + const rules = await listRules(); + const names = ["@typescript-eslint/no-explicit-any", "react-hooks/rules-of-hooks", "@next/next/no-img-element"]; + for (const rule of rules.filter(it => names.includes(it.id))) { + const printed = await Promise.all([rule.id, ...rule.aliases].map(name => lint({}, ["--explain", name]))); + expect(rule.aliases.length).toBeGreaterThan(0); + expect(printed[0].stdout).toStartWith(rule.id + "\n"); + expect([...new Set(printed.map(it => it.stdout))]).toHaveLength(1); + } + }); + + test("takes the name of a rule without its plugin, if only one rule has it", async () => { + const [short, long, several] = await Promise.all([ + lint({}, ["--explain", "no-floating-promises"]), + lint({}, ["--explain", "@typescript-eslint/no-floating-promises"]), + lint({}, ["--explain", "no-focused-tests"]), + ]); + expect(short).toEqual(long); + expect(short.exitCode).toBe(0); + expect(several).toEqual({ + stdout: "", + stderr: + 'error: 3 rules are called "no-focused-tests"\n' + + " Choose one: bun:test/no-focused-tests, jest/no-focused-tests, vitest/no-focused-tests\n", + exitCode: 2, + }); + }); + + test("a rule that does not exist", async () => { + expect(await lint({}, ["--explain", "no-vra"])).toEqual({ + stdout: "", + stderr: 'error: Unknown rule "no-vra"\n Did you mean "no-var"?\n', + exitCode: 2, + }); + expect((await lint({}, ["--explain", "no-explicit-an"])).stderr).toMatch( + /\n Did you mean ".+\/no-explicit-any"\?\n$/, + ); + }); + + test("without a rule", async () => { + const { stdout, exitCode } = await lint({}, ["--explain"]); + expect(stdout).toBe(""); + expect(exitCode).toBe(2); + }); + + test("reads no config and lints nothing", async () => { + const files = { "eslint.config.js": "throw new Error('read');", ".oxlintrc.json": "{", "a.js": "debugger;" }; + const [here, alone] = await Promise.all([lint(files, ["--explain", "no-var"]), lint({}, ["--explain", "no-var"])]); + expect(here).toEqual(alone); + expect(here.exitCode).toBe(0); + }); + + test("wraps at the width of the terminal, and has colors where they are on", async () => { + const [narrow, colored] = await Promise.all([ + lint({}, ["--explain", "no-var"], { COLUMNS: "40" }), + lint({}, ["--explain", "no-var"], { NO_COLOR: undefined, FORCE_COLOR: "1" }), + ]); + const prose = narrow.stdout.split("\n").filter(line => !/^[|+]/.test(line) && !line.startsWith(base)); + expect(Math.max(...prose.map(line => line.trimEnd().length))).toBeLessThanOrEqual(40); + expect(colored.stdout).toContain("\x1b[1m"); + expect(Bun.stripANSI(colored.stdout)).toContain("no-var"); + }); + + test.skipIf(isWindows)("in a terminal the address is a link", async () => { + const { output, exitCode } = await lintInTerminal(["--explain", "no-var"], { NO_COLOR: undefined }); + expect(output).toContain(`\x1b]8;;${base}eslint/no-var\x1b\\`); + expect(exitCode).toBe(0); + }); +}); diff --git a/test/cli/lint/git.test.ts b/test/cli/lint/git.test.ts index 1fddae084865..9d97a35b7db3 100644 --- a/test/cli/lint/git.test.ts +++ b/test/cli/lint/git.test.ts @@ -84,8 +84,10 @@ async function selected(root: string, cwd: string, args: string[], more?: Env) { const commands = ["lint", "format"] as const; // What `--fix` and `bun format` change, and what they make of it. -const [before, after] = ["export var a = 1;\n", { lint: "export let a = 1;\n", format: "export var a = 1;\n" }]; -const writing = { lint: ["lint", "--fix", "--rule", "no-var: error"], format: ["format"] }; +const before = "let a = 1;\nexport { a };\n"; +const after = { lint: "const a = 1;\nexport { a };\n", format: "let a = 1;\nexport { a };\n" }; +const done = { lint: "fixed", format: "formatted" }; +const writing = { lint: ["lint", "--fix", "--rule", "prefer-const: error"], format: ["format"] }; const tidy = "export const a = 1;\n"; describe.concurrent.each(commands)("bun %s", command => { @@ -337,7 +339,9 @@ describe.concurrent.each(commands)("bun %s", command => { write(root, "tidy.js", tidy); const one = await bun(root, [...writing[command], "--staged"]); - expect(one.stderr).toContain("partly.js has both staged and unstaged changes, so it was not written"); + expect(one.stderr).toContain(`partly.js has both staged and unstaged changes, so it was not ${done[command]}`); + // Nothing says that all is well. + expect(one.stderr).not.toContain("No problems"); expect(one.stderr).toContain( "Stage the rest with git add, or put it away with git stash --keep-index. Then run this again.", ); @@ -352,13 +356,13 @@ describe.concurrent.each(commands)("bun %s", command => { write(root, "sub/partly.js", more); const two = await bun(join(root, "sub"), [...writing[command], "--staged", ".."]); expect(two.stderr).toMatch( - /2 files have both staged and unstaged changes, so they were not written\n.* \.\.\/partly\.js\n.* partly\.js\n/, + /2 files have both staged and unstaged changes, so they were not \w+\n.* \.\.\/partly\.js\n.* partly\.js\n/, ); expect(two.exitCode).toBe(2); expect(read(root, "sub/partly.js")).toBe(more); // A run that writes nothing has nothing to refuse. - const looking = command === "lint" ? ["lint", "--rule", "no-var: error"] : ["format", "--check"]; + const looking = command === "lint" ? ["lint", "--rule", "prefer-const: error"] : ["format", "--check"]; const three = await bun(root, [...looking, "--staged"]); expect(three.stderr).not.toContain("unstaged"); expect(three.exitCode).toBe(1); @@ -409,3 +413,20 @@ test.concurrent("bun lint --staged prints what its format prints for no file", a const { stdout, stderr, exitCode } = await bun(repo.root, ["lint", "--staged", "-f", "json"]); expect({ stdout, stderr, exitCode }).toEqual({ stdout: "[]\n", stderr: "No staged files to lint\n", exitCode: 0 }); }); + +test.concurrent( + "bun lint with a configuration of oxlint says the same, where it says it for every configuration", + async () => { + using repo = await repository({ ".oxlintrc.json": "{}", "a.js": before }); + expect(await bun(repo.root, ["lint", "--staged"])).toEqual({ + stdout: "", + stderr: "No staged files to lint\n", + exitCode: 0, + }); + expect(await bun(repo.root, ["lint", "--changed=nothing"])).toEqual({ + stdout: "", + stderr: 'error: "nothing" is not a branch, a tag or a commit of this repository\n', + exitCode: 2, + }); + }, +); diff --git a/test/cli/lint/glob.test.ts b/test/cli/lint/glob.test.ts new file mode 100644 index 000000000000..ce45ee1721d7 --- /dev/null +++ b/test/cli/lint/glob.test.ts @@ -0,0 +1,183 @@ +import { afterAll, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; +import { relative } from "node:path"; +import { endChildren, spawn } from "../children"; + +afterAll(endChildren); + +const env = { ...bunEnv, AGENT: "0", CLAUDECODE: undefined, GITHUB_ACTIONS: undefined }; + +// Each of these rules reports this code once, so that one run tells which of 16 patterns are for which files. +const rules = [ + "no-var", + "eqeqeq", + "no-debugger", + "no-self-assign", + "no-empty", + "no-console", + "no-alert", + "no-eval", + "no-void", + "no-plusplus", + "no-ternary", + "no-undefined", + "no-bitwise", + "no-implicit-coercion", + "no-object-constructor", + "no-unused-expressions", +]; +const code = `var x = 1; +if (x == 2) debugger; +x = x; +if (x) {} +console.log(x); +alert(x); +eval("x"); +void x; +x++; +x = x ? 1 : 2; +x = undefined; +x = x | 1; +x = !!x; +x = new Object(); +x; +`; + +/** The files that each of `patterns` is for, as `files` of an object of an `eslint.config.mjs`. */ +async function filesOf(patterns: string[], files: string[], more: object[] = []) { + const objects = patterns.map((pattern, index) => ({ files: [pattern], rules: { [rules[index]]: "error" } })); + using dir = tempDir("bun-lint-glob", { + ...Object.fromEntries(files.map(file => [file, code])), + "eslint.config.mjs": `export default ${JSON.stringify([...more, ...objects])};\n`, + }); + await using proc = spawn({ + cmd: [bunExe(), "lint", "-f", "json", "."], + env, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + if (!stdout.startsWith("[")) throw new Error(`exit code ${exitCode}\n${stdout}\n${stderr}`); + const results: { filePath: string; messages: { ruleId: string }[] }[] = JSON.parse(stdout); + return patterns.map((pattern, index) => [ + pattern, + results + .filter(it => it.messages.some(message => message.ruleId === rules[index])) + .map(it => relative(String(dir), it.filePath).replaceAll("\\", "/")) + .sort() + .join(" "), + ]); +} + +const files = [ + "a.js", + "a.mjs", + "a.cjs", + "a.ts", + "a.tsx", + "a.mts", + "a.test.ts", + "a.css.js", + "src/a.js", + "src/b.js", + "src/c.js", + "src/ab.js", + "src/aab.js", + "src/abc.js", + "src/bbc.js", + "src/.js", + "src/x/a.js", + "src/x/y.ts", + "src/a.test.ts", + "lib/a.js", + "lib/x/a.js", + "app/(group)/a.js", + "app/group/a.js", +]; +const js = + "a.css.js a.js app/(group)/a.js app/group/a.js lib/a.js lib/x/a.js src/.js src/a.js src/aab.js src/ab.js src/abc.js src/b.js src/bbc.js src/c.js src/x/a.js"; +const all = [...files].sort().join(" "); + +// What ESLint 10.12 lints with each. +test.concurrent("?( ), *( ), +( ), @( ) and !( ) in `files`", async () => { + const expected = [ + ["**/*.?(c|m)js", ["a.cjs", "a.mjs", ...js.split(" ")].sort().join(" ")], + ["**/*.@(js|ts)", [...js.split(" "), "a.test.ts", "a.ts", "src/a.test.ts", "src/x/y.ts"].sort().join(" ")], + ["src/+(a|b).js", "src/a.js src/aab.js src/ab.js src/b.js"], + ["src/*(a|b).js", "src/.js src/a.js src/aab.js src/ab.js src/b.js"], + ["**/!(*.test).ts", "a.ts src/x/y.ts"], + ["**/*.?([cm])[jt]s?(x)", all], + [ + "@(src|lib)/**/*.js", + js + .split(" ") + .filter(it => /^(src|lib)\//.test(it)) + .join(" "), + ], + // In each other, and with braces. + ["src/@(a|{b,c}).js", "src/a.js src/b.js src/c.js"], + [ + "**/+(a|*(b)c).js", + "a.js app/(group)/a.js app/group/a.js lib/a.js lib/x/a.js src/a.js src/abc.js src/bbc.js src/c.js src/x/a.js", + ], + // Parentheses alone are characters. + ["app/(group)/*.js", "app/(group)/a.js"], + [ + "**/*.{js,?(m)ts}", + [...js.split(" "), "a.mts", "a.test.ts", "a.ts", "src/a.test.ts", "src/x/y.ts"].sort().join(" "), + ], + // A `**` at the end of an alternative that ends the pattern. + [ + "{lib/*.js,src/**}", + all + .split(" ") + .filter(it => it === "lib/a.js" || it.startsWith("src/")) + .join(" "), + ], + // Not what starts with `a` or `b`: what follows the group counts. + ["src/!(a|b)*.js", "src/.js src/c.js"], + // To ESLint a `!` at the start negates the pattern, whatever follows it. + ["!(src)/**/*.js", all], + ["**/@(a|b).!(js)", "a.cjs a.css.js a.mjs a.mts a.test.ts a.ts a.tsx src/a.test.ts"], + ["src/?(a)?(b).js", "src/.js src/a.js src/ab.js src/b.js"], + ]; + expect( + await filesOf( + expected.map(it => it[0]), + files, + ), + ).toEqual(expected); +}); + +test.concurrent("a directory in `ignores`: `a`, `a/`, `a/*`, `a/**`, and what `!` takes back", async () => { + const files = [ + "a.js", + "out/a.js", + "x/out/a.js", + "gen/a.js", + "gen/keep/a.js", + "all/a.js", + "all/keep/a.js", + "dir/a.js", + ]; + const ignores = ["**/out", "gen/*", "!gen/keep", "all/**", "!all/keep", "dir/", "@(no|none)/**"]; + // `all/**` has the directory `all`, which is not read then. `gen/*` does not have `gen`. + expect(await filesOf(["**/*.js"], files, [{ ignores }])).toEqual([["**/*.js", "a.js gen/keep/a.js"]]); +}); + +test.concurrent("many groups, and groups in groups, take no time to speak of", async () => { + const name = Buffer.alloc(60, "a").toString(); + const expected = [ + [`${Buffer.alloc(120, "*(a|a)").toString()}b.js`, ""], + [`${Buffer.alloc(100, "{a,a}").toString()}*b.js`, ""], + [`${Buffer.alloc(40, "+(").toString()}a${Buffer.alloc(20, ")").toString()}.js`, `${name}.js`], + [`${Buffer.alloc(40, "!(b)").toString()}.js`, `${name}.js`], + ]; + expect( + await filesOf( + expected.map(it => it[0]), + [`${name}.js`], + ), + ).toEqual(expected); +}); diff --git a/test/cli/lint/help.test.ts b/test/cli/lint/help.test.ts index afe74db823f1..b5931fe0f911 100644 --- a/test/cli/lint/help.test.ts +++ b/test/cli/lint/help.test.ts @@ -22,6 +22,19 @@ describe.concurrent.each([ { command: "lint", source: "src/lint/driver/cli.rs", + common: [ + "fix", + "staged", + "changed", + "rule", + "rules", + "explain", + "type-aware", + "quiet", + "max-warnings", + "init", + "doctor", + ], headings: [ "Rules", "Files", @@ -36,14 +49,27 @@ describe.concurrent.each([ { command: "format", source: "src/lint/driver/fmt/cli.rs", + common: [ + "check", + "staged", + "changed", + "stdin-filepath", + "print-width", + "use-tabs", + "no-semi", + "single-quote", + "init", + "doctor", + ], headings: ["Options", "Files", "Output", "Other", "Compatibility with Prettier", "Compatibility with oxfmt"], }, -])("bun $command", ({ command, source, headings }) => { +])("bun $command", ({ command, source, common, headings }) => { test("--help is short", async () => { const { stdout, stderr, exitCode } = await run(command, "--help"); const lines = stdout.trimEnd().split("\n"); expect(lines[0]).toBe(`Usage: bun ${command} [flags] [...files, directories or patterns]`); expect(stdout.match(/^\S.*:$/gm)).toEqual(["Flags:", "Examples:"]); + expect(Object.keys(listed(stdout))).toEqual(common); expect(lines.filter(line => line.startsWith(` bun ${command}`))).toHaveLength(3); expect(lines.slice(-2)).toEqual([ `Full documentation is available at https://bun.com/docs/runtime/${command}`, @@ -70,7 +96,6 @@ describe.concurrent.each([ test("--help says about a flag what --help --all says", async () => { const [short, all] = await Promise.all([run(command, "--help"), run(command, "--help", "--all")]); - expect(Object.keys(listed(short.stdout)).length).toBeGreaterThan(5); expect(listed(all.stdout)).toMatchObject(listed(short.stdout)); }); diff --git a/test/cli/lint/hints.test.ts b/test/cli/lint/hints.test.ts index b089adcc75c9..dbcb4cace194 100644 --- a/test/cli/lint/hints.test.ts +++ b/test/cli/lint/hints.test.ts @@ -82,7 +82,10 @@ const cases: [code: string, ...reports: string[]][] = [ ], [`use(f() === -0);`, "no-compare-neg-zero 1: -0 and 0 compare as equal. Use Object.is(x, -0) to tell them apart."], [`if (f()) {\n}`, "no-empty 1: Remove it, or put a comment in it that says why it is empty."], - [`try {\n f();\n} catch {}`, "no-empty 3: Remove it, or put a comment in it that says why it is empty."], + [ + `try {\n f();\n} catch {}`, + "no-empty 3: Handle the error, or put a comment in it that says why the error is ignored.", + ], [`use(/a[]/);`, "no-empty-character-class 1: [] in a regular expression matches nothing."], [ `export function g() {\n try {\n return f();\n } finally {\n return 2;\n }\n}`, @@ -107,22 +110,42 @@ const cases: [code: string, ...reports: string[]][] = [ "no-async-promise-executor 1: If it throws, the promise never settles. Await outside of new Promise().", ], [ - `use("a" + f() || "b");`, + `use(typeof f() || "b");`, "no-constant-binary-expression 1: The left side is always truthy or always falsy. Are parentheses missing?", ], + [ + `use("a" || f());`, + "no-constant-binary-expression 1: The left side is always truthy, so the right side is never used.", + ], + [ + `use(0 || f());`, + "no-constant-binary-expression 1: The left side is always falsy, so the result is always the right side.", + ], + [ + `use({} && f());`, + "no-constant-binary-expression 1: The left side is always truthy, so the result is always the right side.", + ], + [ + `use(null && f());`, + "no-constant-binary-expression 1: The left side is always falsy, so the right side is never used.", + ], [ `use(!f() ?? "b");`, - "no-constant-binary-expression 1: The left side is never null or undefined, or always is. Are parentheses missing?", + "no-constant-binary-expression 1: The left side is never null or undefined, so the right side is never used.", + ], + [ + `use(undefined ?? "b");`, + "no-constant-binary-expression 1: The left side is always null or undefined, so the result is always the right side.", ], [`use(!f() == null);`, "no-constant-binary-expression 1: The result is always the same. Are parentheses missing?"], [`use({} === f());`, "no-constant-binary-expression 1"], [ `try {\n f();\n} catch (e) {\n throw e;\n}`, - "no-useless-catch 1: It only throws the error again. The code does the same without try and catch.", + "no-useless-catch 1: It only throws the error again. Without try and catch, the code does the same.", ], [ `try {\n f();\n} catch (e) {\n throw e;\n} finally {\n g();\n}`, - "no-useless-catch 3: It only throws the error again. try and finally do the same without it.", + "no-useless-catch 3: It only throws the error again. Without it, try and finally do the same.", ], [`use([1, , 2]);`, "no-sparse-arrays 1: It leaves a hole in the array. Remove the comma, or write undefined."], [`const {} = f();`, "no-empty-pattern 1: It declares no variables."], @@ -153,8 +176,16 @@ const cases: [code: string, ...reports: string[]][] = [ [`use(9007199254740993);`, "no-loss-of-precision 1: At runtime it is 9007199254740992."], [`use(typeof f() === "strnig");`, 'valid-typeof 1: Did you mean "string"?'], [`use(typeof f() === "array");`, "valid-typeof 1: typeof never returns this string."], - [`use(typeof f() === null);`, "valid-typeof 1: typeof always returns a string."], - [`use(typeof f() === undefined);`, 'valid-typeof 1: typeof returns the string "undefined".'], + [ + `use(typeof f() === null);`, + "no-constant-binary-expression 1: The result is always the same. Are parentheses missing?", + "valid-typeof 1: typeof always returns a string.", + ], + [ + `use(typeof f() === undefined);`, + "no-constant-binary-expression 1: The result is always the same. Are parentheses missing?", + 'valid-typeof 1: typeof returns the string "undefined".', + ], [ `try {\n f();\n} catch (err) {\n throw new Error("no");\n}`, "no-unused-vars 3", @@ -162,9 +193,9 @@ const cases: [code: string, ...reports: string[]][] = [ ], [ String.raw`use("\d+");`, - String.raw`no-useless-escape 1: \d is the same as d. Remove the backslash, or write \\ for a real one.`, + String.raw`no-useless-escape 1: "\d" is the same as "d". Remove the backslash, or write \\ for a real one.`, ], - [String.raw`use(/a\:b/);`, String.raw`no-useless-escape 1: \: is the same as : here. Remove the backslash.`], + [String.raw`use(/[\$&&a]/v);`, String.raw`no-useless-escape 1: "\$" is the same as "$" here. Remove the backslash.`], [ `export function a() {\n return 1;\n function b() {}\n use(b);\n}`, "no-unreachable 4: Nothing runs after the return on line 2.", @@ -177,15 +208,111 @@ const cases: [code: string, ...reports: string[]][] = [ `export function a() {\n if (f()) {\n return 1;\n } else {\n throw g();\n }\n use(1);\n}`, "no-unreachable 7: Every path through the if on line 2 ends in return, throw, break or continue.", ], + [`const userName = f();\nuse(usrName, userName);`, 'no-undef 2: Did you mean "userName"?'], + [`consle.log(1);`, 'no-undef 1: Did you mean "console"?'], + [`use(new Promse(f));`, 'no-undef 1: Did you mean "Promise"?'], + [`describe("a", f);`, 'no-undef 1: With bun test, import it from "bun:test".'], + [`jQuery("a");`, "no-undef 1: If it is a global variable, declare it in [lint.globals] in bunfig.toml."], + [ + `try {\n f();\n} catch (e) {\n g();\n}`, + "no-unused-vars 3: A catch needs no parameter. Remove it and its parentheses.", + ], + [`try {\n f();\n} catch ({ a, b }) {\n g(a);\n}`, "no-unused-vars 3"], + [ + `const { a, ...others } = f();\nuse(others);`, + "no-unused-vars 1: To leave a property out this way, turn on the option ignoreRestSiblings.", + ], + [`const a = f();`, "no-unused-vars 1"], + [ + `use(1,\u00a02);`, + "no-irregular-whitespace 1: It is the character U+00A0. Replace it with a plain space or a line break.", + ], + [`use(f() === NaN);`, "use-isnan 1: Nothing is equal to NaN, not even NaN itself."], + [ + `use(/[👍🏽]/u);`, + "no-misleading-character-class 1: What looks like one character is several, and the class matches each of them alone.", + ], ]; +const globals = { "bunfig.toml": `[lint.globals]\nf = "readonly"\ng = "readonly"\nh = "readonly"\nuse = "readonly"\n` }; + test.concurrent.each(cases)("%s", async (code, ...reports) => { - expect(await hints(code + "\n/* global f, g, h, use */\n")).toEqual(reports); + expect(await hints(code + "\n", "a.js", globals)).toEqual(reports); +}); + +test.concurrent("@typescript-eslint/no-unused-vars", async () => { + const code = `try {\n f();\n} catch (e) {\n f();\n}\nconst { a, ...others } = f();\nf(others);\ndeclare function f(a?: unknown): any;\n`; + expect(await hints(code, "a.ts")).toEqual([ + "@typescript-eslint/no-unused-vars 3: A catch needs no parameter. Remove it and its parentheses.", + "@typescript-eslint/no-unused-vars 6: To leave a property out this way, turn on the option ignoreRestSiblings.", + "@typescript-eslint/no-explicit-any 8: unknown is the safe choice where the type is not known.", + ]); +}); + +test.concurrent("the rules of typescript-eslint", async () => { + const code = ` +declare const list: number[]; +list.pop; +list.length === 0; +list.length + 1; +export function later(this: object) { + const self = this; + return () => self; +} +export class A {} +export interface A { + a: number; +} +export let b: any; +export const fs = require("fs"); +import path = require("path"); +export { path }; +`; + expect(await hints(code, "a.ts")).toEqual([ + "@typescript-eslint/no-unused-expressions 3: Nothing uses the value. Did you mean to call it, with ()?", + "@typescript-eslint/no-unused-expressions 4: Nothing uses the result of the comparison. Did you mean to assign, with =?", + "@typescript-eslint/no-unused-expressions 5: Nothing uses the value, so the statement does nothing.", + "@typescript-eslint/no-this-alias 7: Use an arrow function instead. It has the this of the code around it.", + "@typescript-eslint/no-unsafe-declaration-merging 10: TypeScript does not check that the class sets what the interface declares.", + "@typescript-eslint/no-unsafe-declaration-merging 11: TypeScript does not check that the class sets what the interface declares.", + "@typescript-eslint/no-explicit-any 14: unknown is the safe choice where the type is not known.", + "@typescript-eslint/no-require-imports 15: Use an import declaration, or await import() to load it later.", + "@typescript-eslint/no-require-imports 16: Use an import declaration.", + ]); +}); + +test.concurrent("react-hooks/rules-of-hooks", async () => { + const code = ` +import { useEffect, useState } from "react"; + +export function List({ items, open }) { + if (!open) { + return null; + } + const [count] = useState(0); + if (count > 1) { + useEffect(() => {}); + } + const first = items.length > 0 && useState(items[0]); + return
      {first}
    ; +} +`; + const project = { "package.json": JSON.stringify({ dependencies: { react: "19.0.0" } }) }; + expect(await hints(code, "a.jsx", project)).toEqual([ + "react-hooks/rules-of-hooks 8: The return on line 6 can end the render before it is called.", + "react-hooks/rules-of-hooks 10: Whether it is called depends on the condition on line 9.", + "react-hooks/rules-of-hooks 12: Whether it is called depends on the condition on line 12.", + ]); +}); + +test.concurrent("bun:test/valid-expect", async () => { + const code = `import { expect, test } from "bun:test";\ntest("a", async () => {\n expect(Promise.resolve(1)).resolves.toBe(1);\n});\n`; + expect(await hints(code, "a.test.ts")).toEqual(["bun:test/valid-expect 3: Add await, or return the promise."]); }); test.concurrent("no-octal", async () => { expect(await hints(`module.exports = [071, 09];\n`, "a.cjs")).toEqual([ - "no-octal 1: It is octal: 071 is 57. Write 0o71 to make that clear.", + "no-octal 1: The leading 0 makes it octal, so 071 is 57. Write 0o71 to make that clear.", "no-octal 1: The leading zero does nothing here. Remove it.", ]); }); @@ -193,7 +320,6 @@ test.concurrent("no-octal", async () => { describe.concurrent("where a hint is shown", () => { const files = { "a.js": `export function f(a) {\n let b = a;\n b = 2;\n return b;\n}\n` }; const hint = "It is overwritten on line 3 before it is read."; - const message = "The value assigned to 'b' is not used in subsequent statements."; test("agent", async () => { const { stdout } = await lint(files, "-f", "agent"); @@ -207,7 +333,7 @@ describe.concurrent("where a hint is shown", () => { test.each(["stylish", "json", "unix", "github", "junit", "checkstyle", "gitlab", "sarif"])("not %s", async format => { const { stdout } = await lint(files, "-f", format); - expect(stdout).toContain(message.slice(0, -1)); + expect(stdout).toContain("is not used in subsequent statements"); expect(stdout).not.toContain("overwritten"); }); diff --git a/test/cli/lint/js-parsers.test.ts b/test/cli/lint/js-parsers.test.ts index bff83c9a2bd1..a7fcb7b411eb 100644 --- a/test/cli/lint/js-parsers.test.ts +++ b/test/cli/lint/js-parsers.test.ts @@ -316,7 +316,7 @@ describe.concurrent("bun lint with processors", () => { expect(result.exitCode).toBe(1); // What the processor gives back without a name is for the language. const whole = await lint(files, ["b.txt"]); - expect(whole.stderr).toContain(`b.txt returns text in the language "fenced/text".`); + expect(whole.stderr).toContain(`b.txt returned code in the language "fenced/text", but`); expect(whole.exitCode).toBe(2); }, timeout, diff --git a/test/cli/lint/js-plugins.test.ts b/test/cli/lint/js-plugins.test.ts index 6cc230d3cd54..0a5e1877a35a 100644 --- a/test/cli/lint/js-plugins.test.ts +++ b/test/cli/lint/js-plugins.test.ts @@ -635,7 +635,7 @@ describe.concurrent("bun lint with plugins in JavaScript", () => { ); expect(await notes({ ...calledN, "n.js": plugin }, { rules: { ...rules, ...more } })).toEqual([ `note: 2 "n" rules ran in JavaScript from ./n.js, but bun lint has them built in (version 18.4.1)`, - ` To use the built-in rules, rename them to "node/.." and add "node" to "plugins". Keep ./n.js for the other 1 rule. Reports may differ.`, + ` To use the built-in rules, rename them to "node/.." and add "node" to "plugins". Keep ./n.js for the other rule. Reports may differ.`, ]); }, timeout, @@ -1831,7 +1831,7 @@ describe.concurrent("bun lint with plugins in JavaScript", () => { }, ["a.js"], ); - expect(stdout + stderr).toContain("./plugin.mjs\n A promise is not settled, and nothing is left to wait for."); + expect(stdout + stderr).toContain("./plugin.mjs\n A promise never settled, and nothing is left to wait for."); expect(exitCode).toBe(1); }, timeout, diff --git a/test/cli/lint/lint.test.ts b/test/cli/lint/lint.test.ts index feec75755c2b..0245fb89c6d1 100644 --- a/test/cli/lint/lint.test.ts +++ b/test/cli/lint/lint.test.ts @@ -1627,14 +1627,14 @@ describe.concurrent("bun lint", () => { const rules = JSON.parse(raw); const find = (scope: string, value: string) => rules.find((it: any) => it.scope === scope && it.value === value); - expect(find("eslint", "no-debugger")).toEqual({ + expect(find("eslint", "no-debugger")).toMatchObject({ scope: "eslint", value: "no-debugger", category: "correctness", type_aware: false, fix: "none", default: true, - docs_url: "https://oxc.rs/docs/guide/usage/linter/rules/eslint/no-debugger.html", + docs_url: "https://bun.com/docs/lint/rules/eslint/no-debugger", }); expect(find("typescript", "no-floating-promises").type_aware).toBe(true); // Each once. A rule of the React Compiler is `react-hooks/purity` too, which has suggestions: oxlint's has none. @@ -3592,7 +3592,9 @@ describe.concurrent("bun lint", () => { ]); expect([named.files, found.files, withoutRepository.files]).toEqual([outside, outside, outside]); for (const { stdout, stderr } of [named, found, withoutRepository]) { - expect(stdout + stderr).toContain("Cannot write
    /project/c/d.js: A link leads out of the repository."); + expect(stdout + stderr).toContain( + "Cannot write /project/c/d.js: it is a symlink to a file outside the repository", + ); } // Beside an .oxlintrc.json what fails ends the run as it ends oxlint's. expect([named.exitCode, found.exitCode, withoutRepository.exitCode]).toEqual([2, 1, 2]); @@ -4322,7 +4324,7 @@ describe.concurrent("bun lint", () => { test("an unknown format fails with 2", async () => { const { stderr, exitCode } = await lint(files, ["-f", "nothing"]); - expect(stderr).toContain('There is no formatter "nothing"'); + expect(stderr).toContain('Unknown format "nothing"\n'); expect(exitCode).toBe(2); }); @@ -6624,7 +6626,8 @@ describe.concurrent("regular expressions in a configuration", () => { expect(stderr).not.toContain("JSC options"); expect(exitCode).toBe(1); - const own = await lint(files, ["--rule", 'no-fallthrough: [error, { commentPattern: "fall +through" }]'], { env }); + const pattern = 'no-fallthrough: [error, { commentPattern: "fall +through" }]'; + const own = await lint(files, ["-f", "stylish", "--rule", pattern], { env }); expect(own.stdout).toMatch(/ 5:3 +error +Expected a 'break' statement before 'case' +no-fallthrough\n/); expect(own.stderr).toContain("JSC options"); }); @@ -8005,7 +8008,7 @@ describe.concurrent("[lint] in bunfig.toml", () => { run({ ...eslint, "bunfig.toml": "[lint]\nthreads = -1\n" }, ["a.js"]), ]); expect(byKey).toEqual(byFlag); - expect(wrong.stderr).toContain(`--threads takes a number, not "-1".`); + expect(wrong.stderr).toContain(`--threads takes a number, not "-1"`); expect(wrong.exitCode).toBe(2); }); @@ -8133,7 +8136,7 @@ describe.concurrent("[lint] in bunfig.toml", () => { const result = await bun({ ...eslint, "bunfig.toml": bunfig }, ["lint", "a.js"]); expect(result.stderr).toContain(`6 | "no-var" = "always"\n`); expect(result.stderr).toMatch( - /error: Option rules: 'always' not one of off, warn, error, 0, 1, or 2\.\n\s+at .*bunfig\.toml:6:12\n/, + /error: expected "off", "warn" or "error" but received "always"\n\s+at .*bunfig\.toml:6:12\n/, ); expect(result.stdout).toBe(""); expect(result.exitCode).toBe(2); @@ -8159,9 +8162,7 @@ describe.concurrent("[lint] in bunfig.toml", () => { ])("%s is refused, and nothing is written", async (line, flag) => { const key = line.split(" ")[0]; const result = await run({ ...eslint, "bunfig.toml": `[lint]\n${line}\n` }, ["a.js"]); - expect(result.stderr).toContain( - `"${key}" cannot be set in bunfig.toml, it says what one run does. Pass ${flag} on the command line`, - ); + expect(result.stderr).toContain(`"${key}" cannot be set in bunfig.toml. Pass ${flag} on the command line instead.`); expect(result.stdout).toBe(""); expect(result.files["a.js"]).toBe(bad); expect(result.exitCode).toBe(2); @@ -8174,21 +8175,21 @@ describe.concurrent("[lint] in bunfig.toml", () => { ["config = false", "expected string but received boolean"], ["ignorePatterns = true", "expected array but received boolean"], ["ignorePatterns = [1]", "expected string but received number"], - [`flavor = "biome"`, "Option flavor: 'biome' not one of eslint or oxlint."], - [`format = "fancy"`, `There is no formatter "fancy". Those that exist: stylish, pretty, json,`], + [`flavor = "biome"`, '--flavor must be eslint or oxlint, not "biome"'], + [`format = "fancy"`, `Unknown format "fancy"\n Available formats: stylish, pretty, json,`], [`fixType = ["all"]`, `'fixTypes' must be an array of any of "directive", "problem", "suggestion", and "layout".`], [`reportUnusedDisableDirectives = "loud"`, "'loud' not one of off, warn, error, 0, 1, or 2."], ["reportUnusedDisableDirectives = 1", "expected string but received number"], [`reportUnusedInlineConfigs = true`, "expected string but received boolean"], ["rules = true", "expected object but received boolean"], - [`[lint.rules]\neqeqeq = "always"`, "Option rules: 'always' not one of off, warn, error, 0, 1, or 2."], - [`[lint.rules]\neqeqeq = ["smart"]`, "Option rules: 'smart' not one of off, warn, error, 0, 1, or 2."], + [`[lint.rules]\neqeqeq = "always"`, `expected "off", "warn" or "error" but received "always"`], + [`[lint.rules]\neqeqeq = ["smart"]`, `expected "off", "warn" or "error" but received "smart"`], [`[lint.rules]\neqeqeq = []`, "expected string but received array"], [`[lint.rules]\neqeqeq = true`, "expected string but received boolean"], [`[lint.globals]\na = "off"`, `expected "readonly" or "writable" but received string`], [`[lint.globals]\na = 1`, `expected "readonly" or "writable" but received number`], [`[lint.categories]\nstyles = "warn"`, `unknown category "styles"`], - [`[lint.categories]\nstyle = "deny"`, "Option categories: 'deny' not one of off, warn, error, 0, 1, or 2."], + [`[lint.categories]\nstyle = "deny"`, `expected "off", "warn" or "error" but received "deny"`], ["maxWarning = 0", `unknown key "maxWarning" in [lint]. Did you mean "maxWarnings"?`], ["max-warnings = 0", `unknown key "max-warnings" in [lint]. Did you mean "maxWarnings"?`], [`ignorePattern = ["a"]`, `unknown key "ignorePattern" in [lint]. Did you mean "ignorePatterns"?`], @@ -8777,10 +8778,10 @@ describe.concurrent("nativePluginRules", () => { test.each([ [ `["imprt"]`, - "Option native-plugin-rules: 'imprt' not one of @typescript-eslint, react-hooks, import, n, react, prettier.", + 'Unknown plugin "imprt" in --native-plugin-rules\n Available plugins: @typescript-eslint, react-hooks, import, n, react, prettier\n', ], - [`["unicorn"]`, "Option native-plugin-rules: 'unicorn' not one of"], - [`["true"]`, "Option native-plugin-rules: 'true' not one of"], + [`["unicorn"]`, 'Unknown plugin "unicorn" in --native-plugin-rules'], + [`["true"]`, 'Unknown plugin "true" in --native-plugin-rules'], [`"import"`, "expected boolean or array but received string"], ["[1]", "expected string but received number"], ])("nativePluginRules = %s is refused", async (value, message) => { diff --git a/test/cli/lint/oracle/robustness/cases.ts b/test/cli/lint/oracle/robustness/cases.ts index 6aa363966ff1..4b5013e8d5e7 100644 --- a/test/cli/lint/oracle/robustness/cases.ts +++ b/test/cli/lint/oracle/robustness/cases.ts @@ -386,7 +386,7 @@ export const cases: Case[] = [ const three = "await b;\nx = b as number;\np;\n"; const conditions = (n: number, name = "x") => rep(`if (a) { ${name} = 1; }\n`, n); const warning = - /This file is too large for control flow analysis\. What rules that need types say about it may be wrong or incomplete\./; + /This file is too large for control flow analysis\. Reports from rules that need type information may be wrong or incomplete\./; const [awaited, asserted, floating] = ["await-thenable", "no-unnecessary-type-assertion", "no-floating-promises"]; const common = { file: "a.ts", diff --git a/test/cli/lint/oracle/robustness/shapes-react-compiler.ts b/test/cli/lint/oracle/robustness/shapes-react-compiler.ts index 27f2f942db18..e25ec6ee592d 100644 --- a/test/cli/lint/oracle/robustness/shapes-react-compiler.ts +++ b/test/cli/lint/oracle/robustness/shapes-react-compiler.ts @@ -54,7 +54,7 @@ const tooDeep = (name: string, text: () => string): Case => ({ rules, args: ["-f", "json"], matches: - /^\{ "diagnostics": \[\{"message": "Support functions of this size","code": "react\(todo\)",.*"help": "What is in it is nested too deeply",.*\}\],$/m, + /^\{ "diagnostics": \[\{"message": "Support functions of this size","code": "react\(todo\)",.*"help": "The function body is nested too deeply",.*\}\],$/m, exitCode: 1, }); diff --git a/test/cli/lint/output.test.ts b/test/cli/lint/output.test.ts index aefe3f9349b1..50d7177b5174 100644 --- a/test/cli/lint/output.test.ts +++ b/test/cli/lint/output.test.ts @@ -227,7 +227,7 @@ describe.concurrent("the name of a rule leads to its page", () => { const files = { "a.js": "debugger;\n" }; const configs = { none: {}, eslint, oxlint }; // The last line has the time that the run took. - const lines = (text: string) => text.split("\n").filter(line => !line.includes("Linted 1 file")); + const lines = (text: string) => text.split("\n").filter(line => !line.includes("Linted ")); test.skipIf(!hasTerminal).each([ ["none", "pretty", "no-debugger"], diff --git a/test/cli/lint/rule-docs.test.ts b/test/cli/lint/rule-docs.test.ts new file mode 100644 index 000000000000..aedf5babd19d --- /dev/null +++ b/test/cli/lint/rule-docs.test.ts @@ -0,0 +1,195 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { + examplesOf, + generatedFor, + indexFor, + pageNames, + pageOf, + pagesRoot, + readPage, + type Example, + type Rule, +} from "../../../src/codegen/lint-rule-docs.ts"; + +const env = { ...bunEnv, NO_COLOR: "1", AGENT: "0", CLAUDECODE: undefined, CI: undefined, GITHUB_ACTIONS: undefined }; + +async function lint(cwd: string, args: string[]) { + await using proc = Bun.spawn({ cmd: [bunExe(), "lint", ...args], cwd, env, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; +} + +let rules: Rule[] = []; +const byPage = new Map(); +beforeAll(async () => { + using dir = tempDir("bun-lint-rule-docs", { "package.json": "{}" }); + rules = JSON.parse((await lint(String(dir), ["--rules", "-f", "json"])).stdout); + for (const rule of rules) byPage.set(pageOf(rule), rule); +}); + +const text = (page: string) => readFileSync(join(pagesRoot, page + ".mdx"), "utf8").replaceAll("\r\n", "\n"); + +describe("docs/lint/rules", () => { + test("every rule has a page, and every page a rule", () => { + expect(pageNames()).toEqual([...byPage.keys()].sort()); + }); + + test("the frontmatter of every page is right", () => { + const wrong: string[] = []; + for (const [name, rule] of byPage) { + const page = readPage(text(name)); + if (!page) wrong.push(`${name}: no frontmatter`); + else if (page.title !== rule.id) + wrong.push(`${name}: title is ${JSON.stringify(page.title)}, not ${JSON.stringify(rule.id)}`); + else if (typeof page.description !== "string" || !/^[A-Z`].{0,109}(? { + // bun src/codegen/lint-rule-docs.ts --write + expect(readFileSync(join(pagesRoot, "index.mdx"), "utf8").replaceAll("\r\n", "\n")).toBe(indexFor(rules)); + }); + + // Every example is a file of its own that ends with a comment that turns its rule on. One run lints them all. + test("what a page says is reported is reported, and what it says is not is not", async () => { + const wrong: string[] = []; + const examples: Example[] = []; + for (const name of byPage.keys()) { + const page = readPage(text(name)); + if (!page) continue; + const firstLine = text(name).length + ? text(name) + .slice(0, text(name).length - page.body.length) + .split("\n").length + : 1; + const found = examplesOf(name, page.body, firstLine); + wrong.push(...found.problems.map(it => `${name}: ${it}`)); + examples.push(...found.examples); + } + + using dir = tempDir("bun-lint-rule-docs", { + "package.json": JSON.stringify({ dependencies: { react: "^19.0.0" } }), + }); + const directoryOf = (it: Example) => join(String(dir), `${it.page.replaceAll("/", "__")}__${it.line}`); + const put = (path: string, content: string) => ( + mkdirSync(dirname(path), { recursive: true }), + writeFileSync(path, content) + ); + for (const it of examples) { + const rule = byPage.get(it.page)!; + for (const [name, content] of Object.entries(it.files)) put(join(directoryOf(it), name), content); + for (const [name, version] of Object.entries(it.packages ?? {})) + put(join(directoryOf(it), "node_modules", name, "package.json"), JSON.stringify({ name, version })); + // `**\/*.js`: a `*` and a `/` in a pattern would end the comment. + const setting = JSON.stringify(["error", ...it.options]).replaceAll("*/", "*\\/"); + put(join(directoryOf(it), it.filename), `${it.code}\n/* eslint ${rule.id}: ${setting} */\n`); + if (!rule.type_aware) continue; + const compilerOptions = { + strict: true, + noEmit: true, + target: "esnext", + module: "esnext", + moduleResolution: "bundler", + jsx: "preserve", + lib: ["esnext", "dom"], + skipLibCheck: true, + ...it.compilerOptions, + }; + put(join(directoryOf(it), "tsconfig.json"), JSON.stringify({ compilerOptions, include: ["."] })); + } + + // All at once, those that need types apart. An example that is a project of its own has a run of its own. + const native = examples.filter(it => it.config !== "oxlint"); + const together = native.filter(it => !it.root); + const runs = [ + ...[false, true].map(typed => ({ + cwd: String(dir), + typed, + part: together.filter(it => byPage.get(it.page)!.type_aware === typed), + })), + ...native + .filter(it => it.root) + .map(it => ({ cwd: directoryOf(it), typed: byPage.get(it.page)!.type_aware, part: [it] })), + ]; + for (const it of examples) + if (it.root && !it.files["package.json"]) put(join(directoryOf(it), "package.json"), "{}"); + for (const { cwd, typed, part } of runs) { + const paths = part.map(it => directoryOf(it).slice(cwd.length + 1) || "."); + const { stdout, stderr } = await lint(cwd, ["-f", "json", ...(typed ? ["--type-aware"] : []), ...paths]); + let report: { + filePath: string; + messages: { ruleId: string | null; fatal?: boolean; message: string; line: number }[]; + }[]; + try { + report = JSON.parse(stdout); + } catch { + throw new Error(`no report: ${stderr.slice(0, 2000)}`); + } + const byFile = new Map(report.map(it => [it.filePath, it.messages])); + for (const it of part) { + const rule = byPage.get(it.page)!; + const where = `${it.page}: line ${it.line}${it.options.length ? ` with ${JSON.stringify(it.options)}` : ""}`; + const messages = byFile.get(join(directoryOf(it), it.filename)); + const own = + messages?.filter(message => message.ruleId === rule.id || rule.aliases.includes(message.ruleId!)) ?? []; + // Options that are refused are reported in the name of the rule. + const fatal = + messages?.find(message => message.fatal) ?? + own.find(message => message.message.startsWith("Inline configuration for rule")); + if (!messages) wrong.push(`${where}: ${it.filename} was not linted`); + else if (fatal) wrong.push(`${where}: ${fatal.message}`); + else if (it.want === "good" && own.length) wrong.push(`${where}: reported: ${own[0].message}`); + else if (it.want === "bad" && !own.length) wrong.push(`${where}: not reported`); + else if (it.count !== undefined && own.length !== it.count) + wrong.push(`${where}: ${own.length} reports, not ${it.count}`); + } + } + // A rule that reports only beside a configuration of oxlint. + for (const it of examples.filter(it => it.config === "oxlint")) { + const rule = byPage.get(it.page)!; + const name = `${rule.scope.replaceAll("_", "-")}/${rule.value}`; + const plugins = [ + "typescript", + "unicorn", + "oxc", + "react", + "react-perf", + "import", + "jsx-a11y", + "nextjs", + "promise", + "node", + "jest", + "vitest", + ]; + put(join(directoryOf(it), "package.json"), "{}"); + put(join(directoryOf(it), it.filename), it.code); + put( + join(directoryOf(it), ".oxlintrc.json"), + JSON.stringify({ + plugins, + categories: { correctness: "off" }, + settings: it.settings ?? {}, + rules: { [name]: ["error", ...it.options] }, + }), + ); + const { stdout, stderr, exitCode } = await lint(directoryOf(it), ["-f", "unix", "."]); + const reports = stdout.split("\n").filter(line => /\[(Error|Warning)\/[^\]]+\]$/.test(line)).length; + const where = `${it.page}: line ${it.line}, with an oxlint config`; + if (exitCode === 2) wrong.push(`${where}: ${stderr.slice(0, 300)}`); + else if (it.want === "good" && reports) wrong.push(`${where}: reported`); + else if (it.want === "bad" && !reports) wrong.push(`${where}: not reported`); + else if (it.count !== undefined && reports !== it.count) + wrong.push(`${where}: ${reports} reports, not ${it.count}`); + } + expect(wrong).toEqual([]); + }, 300_000); +}); From 9247261292b712a926e2d6a770ff6e6ef2b52b61 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Mon, 12 Oct 2026 06:44:58 +0000 Subject: [PATCH 08/49] lint, format: 66 files --- .prettierrc | 8 + Cargo.lock | 2 + src/bunfig/arguments.rs | 9 + src/glob/braces.rs | 406 ---- src/glob/class.rs | 698 ------- src/glob/filtered.rs | 257 --- src/glob/ignore.rs | 507 ++--- src/glob/lib.rs | 14 +- src/glob/linear.rs | 531 ------ src/glob/matcher.rs | 2 +- src/glob/node.rs | 549 ------ src/glob/pattern.rs | 587 +++--- src/glob/read_ignore.rs | 922 --------- src/glob/read_minimatch.rs | 799 -------- src/glob/read_minimatch3.rs | 443 ----- src/glob/read_picomatch.rs | 1641 ----------------- src/glob/segments.rs | 532 ------ src/glob/sets.rs | 411 ----- src/glob/unit.rs | 285 --- src/lint/CLAUDE.md | 25 +- src/lint/driver/Cargo.toml | 2 + src/lint/driver/bunfig.rs | 106 +- src/lint/driver/cli.rs | 17 +- src/lint/driver/configs.rs | 45 +- src/lint/driver/explain.rs | 2 +- src/lint/driver/fmt/editorconfig.rs | 6 +- src/lint/driver/gitignore.rs | 8 +- src/lint/driver/lib.rs | 2 + src/lint/driver/lsp/answers.rs | 286 +++ src/lint/driver/lsp/mod.rs | 1170 ++++++++++++ src/lint/driver/lsp/rpc.rs | 213 +++ src/lint/driver/lsp/session.rs | 179 ++ src/lint/driver/lsp/text.rs | 173 ++ src/lint/driver/native.rs | 159 +- src/lint/driver/run.rs | 28 +- .../eslint/rules/no_restricted_imports.rs | 12 +- .../eslint/rules/no_restricted_modules.rs | 4 +- src/lint/graph/lib.rs | 4 +- src/lint/linter/config/ignore_lines.rs | 6 +- src/lint/linter/config/mod.rs | 11 +- src/lint/plugins/import_minimatch.rs | 2 +- .../typescript/rules/no_restricted_imports.rs | 6 +- src/lint/utils/mod.rs | 2 +- .../cli/init/react-app/src/APITester.tsx | 15 +- src/runtime/cli/init/react-app/src/index.ts | 2 +- src/runtime/cli/init/react-shadcn/build.ts | 4 +- .../cli/init/react-shadcn/src/APITester.tsx | 16 +- src/runtime/cli/init/react-shadcn/src/App.tsx | 15 +- .../react-shadcn/src/components/ui/button.tsx | 14 +- .../react-shadcn/src/components/ui/card.tsx | 50 +- .../react-shadcn/src/components/ui/label.tsx | 5 +- .../react-shadcn/src/components/ui/select.tsx | 43 +- .../cli/init/react-shadcn/src/index.ts | 2 +- src/runtime/cli/init/react-tailwind/build.ts | 4 +- .../cli/init/react-tailwind/src/App.tsx | 6 +- .../cli/init/react-tailwind/src/index.ts | 2 +- src/runtime/cli/lint_command.rs | 34 + src/runtime/cli/lint_js.rs | 19 +- src/runtime/cli/script_or_command.rs | 10 +- test/cli/format/bunfig.test.ts | 2 +- test/cli/lint/bun-rules.test.ts | 142 ++ test/cli/lint/conformance/README.md | 2 +- test/cli/lint/conformance/bundle.zst | Bin 4011224 -> 4008481 bytes test/cli/lint/conformance/expected-more.txt | 2 +- test/cli/lint/messages.test.ts | 161 ++ .../shared-helpers.inventory.json | 11 - 66 files changed, 3347 insertions(+), 8285 deletions(-) delete mode 100644 src/glob/braces.rs delete mode 100644 src/glob/class.rs delete mode 100644 src/glob/filtered.rs delete mode 100644 src/glob/linear.rs delete mode 100644 src/glob/node.rs delete mode 100644 src/glob/read_ignore.rs delete mode 100644 src/glob/read_minimatch.rs delete mode 100644 src/glob/read_minimatch3.rs delete mode 100644 src/glob/read_picomatch.rs delete mode 100644 src/glob/segments.rs delete mode 100644 src/glob/sets.rs delete mode 100644 src/glob/unit.rs create mode 100644 src/lint/driver/lsp/answers.rs create mode 100644 src/lint/driver/lsp/mod.rs create mode 100644 src/lint/driver/lsp/rpc.rs create mode 100644 src/lint/driver/lsp/session.rs create mode 100644 src/lint/driver/lsp/text.rs create mode 100644 test/cli/lint/messages.test.ts diff --git a/.prettierrc b/.prettierrc index 14285ca70431..b389721f9995 100644 --- a/.prettierrc +++ b/.prettierrc @@ -25,6 +25,14 @@ "options": { "printWidth": 100 } + }, + { + "files": ["src/runtime/cli/init/**"], + "options": { + "arrowParens": "always", + "printWidth": 80, + "quoteProps": "as-needed" + } } ] } diff --git a/Cargo.lock b/Cargo.lock index 9925ff1316b4..b4ad0c72c096 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1061,7 +1061,9 @@ dependencies = [ "bun_sema_parser", "bun_semver", "bun_sys", + "bun_text_diff", "bun_threading", + "bun_url", "bun_zstd", ] diff --git a/src/bunfig/arguments.rs b/src/bunfig/arguments.rs index 101173351356..14de9882ce6b 100644 --- a/src/bunfig/arguments.rs +++ b/src/bunfig/arguments.rs @@ -162,8 +162,17 @@ fn load_config_path_for_tool( fn report_bunfig_load_failure(cmd: CommandTag, log: *mut bun_ast::Log, err: crate::Error) -> ! { // SAFETY: process-global Log; see `load_bunfig` note. let log = unsafe { &mut *log }; + let is_of_tool = matches!(cmd, CommandTag::LintCommand | CommandTag::FormatCommand); if log.has_any() { + // These two name files as they name the files that they report about. + let locations = log.msgs.iter_mut().filter(|_| is_of_tool); + for location in locations.filter_map(|it| it.data.location.as_mut()) { + location.file = bun_paths::basename(&location.file[..]).to_vec().into(); + } let _ = log.print(std::ptr::from_mut(Output::error_writer())); + if is_of_tool { + Global::exit(exit_code_of_failure(cmd)); + } Output::print_error("\n"); } Output::err(err, "failed to load bunfig", ()); diff --git a/src/glob/braces.rs b/src/glob/braces.rs deleted file mode 100644 index d726b2b627e3..000000000000 --- a/src/glob/braces.rs +++ /dev/null @@ -1,406 +0,0 @@ -//! The package `brace-expansion` 5.0.12, with which minimatch expands `a{b,c}d` and `{1..3}` before it reads a pattern. - -use bun_core::strings; - -const MAX: usize = 100_000; -const MAX_LENGTH: usize = 4_000_000; -const MAX_DEPTH: usize = 1_000; -const MAX_REWRITES: usize = 1_000; - -// What an escaped character is replaced by while braces are expanded. None of these bytes occurs in UTF-8. -const ESC_SLASH: u8 = 0xF8; -const ESC_OPEN: u8 = 0xF9; -const ESC_CLOSE: u8 = 0xFA; -const ESC_COMMA: u8 = 0xFB; -const ESC_PERIOD: u8 = 0xFC; - -fn escape_braces(text: &[u8]) -> Vec { - let mut out = Vec::with_capacity(text.len()); - let mut at = 0; - while at < text.len() { - let escaped = match text[at..] { - [b'\\', b'\\', ..] => Some(ESC_SLASH), - [b'\\', b'{', ..] => Some(ESC_OPEN), - [b'\\', b'}', ..] => Some(ESC_CLOSE), - [b'\\', b',', ..] => Some(ESC_COMMA), - [b'\\', b'.', ..] => Some(ESC_PERIOD), - _ => None, - }; - out.push(escaped.unwrap_or(text[at])); - at += if escaped.is_some() { 2 } else { 1 }; - } - out -} - -fn unescape_braces(mut text: Vec) -> Vec { - for byte in &mut text { - *byte = match *byte { - ESC_SLASH => b'\\', - ESC_OPEN => b'{', - ESC_CLOSE => b'}', - ESC_COMMA => b',', - ESC_PERIOD => b'.', - byte => byte, - }; - } - text -} - -struct Balanced<'t> { - pre: &'t [u8], - body: &'t [u8], - post: &'t [u8], -} - -/// `balanced("{", "}", text)` of the package `balanced-match`: the first pair of braces that match each other. -fn balanced(text: &[u8]) -> Option> { - let find = |byte: u8, from: usize| strings::index_of_char_pos(text, byte, from); - let mut open = Some(find(b'{', 0)?); - let mut close = find(b'}', open? + 1); - close?; - let mut at = open; - let mut opened: Vec = Vec::new(); - let (mut left, mut right) = (text.len(), None); - let mut result = None; - while let Some(i) = at - && result.is_none() - { - if Some(i) == open { - opened.push(i); - open = find(b'{', i + 1); - } else if opened.len() == 1 { - result = opened.pop().zip(close); - } else { - if let Some(start) = opened.pop() - && start < left - { - left = start; - right = close; - } - close = find(b'}', i + 1); - } - at = match (open, close) { - (Some(open), Some(close)) if open < close => Some(open), - _ => close, - }; - } - if !opened.is_empty() - && let Some(right) = right - { - result = Some((left, right)); - } - let (start, end) = result?; - Some(Balanced { - pre: &text[..start], - body: &text[start + 1..end], - post: &text[end + 1..], - }) -} - -/// `text.split(",")`, but a braced section is not split. -fn parse_comma_parts(mut text: &[u8]) -> Vec> { - let mut parts: Vec> = Vec::new(); - // The part that is being written. What is before the first comma of what is left is added to it. - let mut carry: Vec = Vec::new(); - loop { - let found = balanced(text); - let mut split: Vec> = strings::split(found.as_ref().map_or(text, |m| m.pre), b",") - .map(<[u8]>::to_vec) - .collect(); - if let Some(first) = split.first_mut() { - carry.extend_from_slice(first); - *first = std::mem::take(&mut carry); - } - let Some(m) = found else { - parts.append(&mut split); - return parts; - }; - if let Some(last) = split.last_mut() { - last.push(b'{'); - last.extend_from_slice(m.body); - last.push(b'}'); - } - if m.post.is_empty() { - parts.append(&mut split); - return parts; - } - carry = split.pop().unwrap_or_default(); - parts.append(&mut split); - text = m.post; - } -} - -/// `-?\d+` -fn integer_len(text: &[u8]) -> Option { - let sign = usize::from(text.first() == Some(&b'-')); - let digits = text[sign..] - .iter() - .take_while(|b| b.is_ascii_digit()) - .count(); - (digits > 0).then_some(sign + digits) -} - -/// The parts of `a..b` or `a..b..c`, if the first two are what `first` accepts and the third is an integer. -fn sequence_parts(body: &[u8], first: fn(&[u8]) -> Option) -> Option> { - let mut parts = Vec::with_capacity(3); - let mut rest = body; - for i in 0..3 { - let len = if i < 2 { - first(rest)? - } else { - integer_len(rest)? - }; - parts.push(&rest[..len]); - rest = &rest[len..]; - if rest.is_empty() && i >= 1 { - return Some(parts); - } - rest = rest.strip_prefix(b"..")?; - } - None -} - -fn parse_integer(text: &[u8]) -> i64 { - let (is_negative, digits) = match text { - [b'-', digits @ ..] => (true, digits), - digits => (false, digits), - }; - let value = digits.iter().fold(0i64, |n, b| { - n.saturating_mul(10).saturating_add(i64::from(b - b'0')) - }); - if is_negative { -value } else { value } -} - -fn expand_sequence(parts: &[&[u8]], is_alpha: bool) -> Vec> { - let numeric = |text: &[u8]| { - if is_alpha && !text[0].is_ascii_digit() && text[0] != b'-' { - i64::from(text[0]) - } else { - parse_integer(text) - } - }; - let (x, y) = (numeric(parts[0]), numeric(parts[1])); - let width = parts[0].len().max(parts[1].len()); - let mut step = parts - .get(2) - .map_or(1, |it| parse_integer(it).saturating_abs().max(1)); - let is_reversed = y < x; - if is_reversed { - step = -step; - } - // `^-?0\d` - let is_padded = |it: &&[u8]| { - matches!( - it.strip_prefix(b"-").unwrap_or(*it), - [b'0', b'0'..=b'9', ..] - ) - }; - let pad = parts.iter().any(is_padded); - let (mut out, mut length, mut i) = (Vec::new(), 0, x); - while (if is_reversed { i >= y } else { i <= y }) && out.len() < MAX { - let item = if is_alpha { - match u8::try_from(i) { - Ok(b'\\') | Err(_) => Vec::new(), - Ok(byte) => vec![byte], - } - } else { - let mut written = i.to_string().into_bytes(); - if pad && width > written.len() { - let at = usize::from(i < 0); - written.splice(at..at, std::iter::repeat_n(b'0', width - written.len())); - } - written - }; - if length + item.len() > MAX_LENGTH { - break; - } - length += item.len(); - out.push(item); - let Some(next) = i.checked_add(step) else { - break; - }; - i = next; - } - out -} - -/// Every `acc[a] + pre + values[v]`. -fn combine(acc: Vec>, pre: &[u8], values: &[Vec], drop_empties: bool) -> Vec> { - if let [value] = values { - return append(acc, pre, value, drop_empties); - } - let mut out = Vec::new(); - let mut length = 0; - for a in &acc { - for value in values { - if out.len() >= MAX { - return out; - } - let len = a.len() + pre.len() + value.len(); - if drop_empties && len == 0 { - continue; - } - if length + len > MAX_LENGTH { - return out; - } - length += len; - out.push([&a[..], pre, &value[..]].concat()); - } - } - out -} - -/// `combine` with one value, in place. Otherwise a run of groups that do not multiply is quadratic. -fn append(mut acc: Vec>, pre: &[u8], value: &[u8], drop_empties: bool) -> Vec> { - let (mut length, mut kept, mut is_full) = (0, 0, false); - acc.retain_mut(|a| { - is_full |= kept >= MAX; - if is_full { - return false; - } - let len = a.len() + pre.len() + value.len(); - if drop_empties && len == 0 { - return false; - } - if length + len > MAX_LENGTH { - is_full = true; - return false; - } - length += len; - kept += 1; - a.extend_from_slice(pre); - a.extend_from_slice(value); - true - }); - acc -} - -/// `/,(?!,).*\}/.test(text)` -fn has_comma_before_close(text: &[u8]) -> bool { - let mut from = 0; - loop { - let Some(comma) = strings::index_of_char_pos(text, b',', from) else { - return false; - }; - from = comma + 1; - if text.get(from) == Some(&b',') { - continue; - } - let mut at = from; - while at < text.len() && !bun_core::lexer::starts_with_line_break(&text[at..]) { - if text[at] == b'}' { - return true; - } - at += 1; - } - // No comma up to here has a `}` behind it either. - from = at; - } -} - -/// At most `MAX_DEPTH` deep. -fn expand_inner(text: &[u8], depth: usize, mut is_top: bool) -> Vec> { - if depth > MAX_DEPTH { - return vec![text.to_vec()]; - } - let none = [Vec::new()]; - let mut owned: Vec; - let mut text = text; - let mut acc = vec![Vec::new()]; - let (mut rewrites, mut drop_empties, mut is_first_group) = (0, false, true); - loop { - let Some(m) = balanced(text) else { - return combine(acc, text, &none, drop_empties); - }; - let is_last = m.post.is_empty(); - if m.pre.ends_with(b"$") { - let braced = [m.pre, b"{", m.body, b"}"].concat(); - acc = combine(acc, &braced, &none, drop_empties && is_last); - is_first_group = false; - if is_last { - break; - } - text = m.post; - continue; - } - let numbers = sequence_parts(m.body, integer_len); - let letters = sequence_parts(m.body, |it| { - it.first().is_some_and(u8::is_ascii_alphabetic).then_some(1) - }); - let is_sequence = numbers.is_some() || letters.is_some(); - if !is_sequence && !strings::contains_char(m.body, b',') { - // `{a},b}` - if rewrites < MAX_REWRITES && has_comma_before_close(m.post) { - rewrites += 1; - owned = [m.pre, b"{", m.body, &[ESC_CLOSE], m.post].concat(); - text = &owned; - is_top = true; - continue; - } - let braced = [m.pre, b"{", m.body, b"}"].concat(); - let rest = [&braced[..], m.post].concat(); - return combine(acc, &rest, &none, drop_empties); - } - if is_first_group { - drop_empties = is_top && !is_sequence; - is_first_group = false; - } - let values = if let Some(parts) = numbers.as_ref().or(letters.as_ref()) { - expand_sequence(parts, numbers.is_none()) - } else { - let mut parts = parse_comma_parts(m.body); - if let [only] = &parts[..] { - // `x{{a,b}}y` is `x{a}y x{b}y`. - parts = expand_inner(only, depth + 1, false); - for part in &mut parts { - part.insert(0, b'{'); - part.push(b'}'); - } - if let [only] = &parts[..] { - let whole = [m.pre, &only[..]].concat(); - acc = combine(acc, &whole, &none, drop_empties && is_last); - if is_last { - break; - } - text = m.post; - continue; - } - } - let drops_empties = - drop_empties && is_last && m.pre.is_empty() && acc.iter().all(Vec::is_empty); - let (mut values, mut length) = (Vec::new(), 0); - 'parts: for part in &parts { - for value in expand_inner(part, depth + 1, false) { - if drops_empties && value.is_empty() { - continue; - } - if values.len() >= MAX || length + value.len() > MAX_LENGTH { - break 'parts; - } - length += value.len(); - values.push(value); - } - } - values - }; - acc = combine(acc, m.pre, &values, drop_empties && is_last); - if is_last { - break; - } - text = m.post; - } - acc -} - -/// `expand(text)` -pub(crate) fn expand(text: &[u8]) -> Vec> { - if text.is_empty() { - return Vec::new(); - } - let escaped = match text.strip_prefix(b"{}") { - Some(rest) => escape_braces(&[b"\\{\\}", rest].concat()), - None => escape_braces(text), - }; - let expanded = expand_inner(&escaped, 0, true); - expanded.into_iter().map(unescape_braces).collect() -} diff --git a/src/glob/class.rs b/src/glob/class.rs deleted file mode 100644 index 7f4b2c54bed9..000000000000 --- a/src/glob/class.rs +++ /dev/null @@ -1,698 +0,0 @@ -//! A class of units, and the four grammars in which one is written. - -use crate::unit::{Subject, Text, Unit, push_utf8, unfold}; -use bun_core::strings; - -const BANG: u32 = b'!' as u32; -const DASH: u32 = b'-' as u32; -const OPEN: u32 = b'[' as u32; -const BACKSLASH: u32 = b'\\' as u32; -const CLOSE: u32 = b']' as u32; -const CARET: u32 = b'^' as u32; - -type Ranges = Vec<(u32, u32)>; - -pub(crate) fn has_at(bytes: &[u8], at: usize, text: &[u8]) -> bool { - bytes.get(at..).is_some_and(|rest| rest.starts_with(text)) -} - -// ───────────────────────────── the names of minimatch, which are Unicode's ───────────────────────────── - -const ALNUM: u16 = 1 << 0; -const ALPHA: u16 = 1 << 1; -const ASCII: u16 = 1 << 2; -const BLANK: u16 = 1 << 3; -const CNTRL: u16 = 1 << 4; -const DIGIT: u16 = 1 << 5; -const GRAPH: u16 = 1 << 6; -const LOWER: u16 = 1 << 7; -const PRINT: u16 = 1 << 8; -const PUNCT: u16 = 1 << 9; -const SPACE: u16 = 1 << 10; -const UPPER: u16 = 1 << 11; -const WORD: u16 = 1 << 12; -const XDIGIT: u16 = 1 << 13; - -/// The name, its bit, and whether it needs the flag `u`. `[:graph:]` is written negated. -const MINIMATCH_NAMES: [(&[u8], u16, bool); 14] = [ - (b"[:alnum:]", ALNUM, true), - (b"[:alpha:]", ALPHA, true), - (b"[:ascii:]", ASCII, false), - (b"[:blank:]", BLANK, true), - (b"[:cntrl:]", CNTRL, true), - (b"[:digit:]", DIGIT, true), - (b"[:graph:]", GRAPH, true), - (b"[:lower:]", LOWER, true), - (b"[:print:]", PRINT, true), - (b"[:punct:]", PUNCT, true), - (b"[:space:]", SPACE, true), - (b"[:upper:]", UPPER, true), - (b"[:word:]", WORD, true), - (b"[:xdigit:]", XDIGIT, false), -]; - -/// `\p{Zs}` -fn is_space_separator(c: u32) -> bool { - matches!( - c, - 0x20 | 0xA0 | 0x1680 | 0x2000..=0x200A | 0x202F | 0x205F | 0x3000 - ) -} - -/// `\p{Z}` -fn is_separator(c: u32) -> bool { - is_space_separator(c) || matches!(c, 0x2028 | 0x2029) -} - -/// `\p{P}` in ASCII: not `$+<=>^`|~`, which are symbols. Beyond ASCII: none. -fn is_punctuation(c: u32) -> bool { - matches!( - c, - 33..=35 | 37..=42 | 44..=47 | 58 | 59 | 63 | 64 | 91..=93 | 95 | 123 | 125 - ) -} - -/// ASCII is exact. Beyond it `\p{L}\p{Nl}`, `\p{Nd}`, `\p{Ll}` and `\p{Lu}` are the properties that `core` has, and `\p{C}` is `\p{Cc}`. -fn in_names(names: u16, unit: u32) -> bool { - let Some(c) = char::from_u32(unit).filter(|_| names != 0) else { - return false; - }; - let has = |name: u16| names & name != 0; - let (is_letter, is_digit, is_control) = (c.is_alphabetic(), c.is_numeric(), c.is_control()); - has(ALNUM) && (is_letter || is_digit) - || has(ALPHA) && is_letter - || has(ASCII) && c.is_ascii() - || has(BLANK) && (is_space_separator(unit) || c == '\t') - || has(CNTRL | PRINT) && is_control - || has(DIGIT) && is_digit - || has(GRAPH) && (is_separator(unit) || is_control) - || has(LOWER) && c.is_lowercase() - || has(PUNCT) && is_punctuation(unit) - || has(SPACE) && (is_separator(unit) || matches!(unit, 9..=13)) - || has(UPPER) && c.is_uppercase() - || has(WORD) && (is_letter || is_digit || c == '_') - || has(XDIGIT) && c.is_ascii_hexdigit() -} - -// ───────────────────────────── a class ───────────────────────────── - -#[derive(Clone, PartialEq, Eq)] -pub(crate) struct Class { - /// The final answers for ASCII: negation, folding and "never a `/`" are in it. - ascii: [u64; 2], - ranges: Box<[(u32, u32)]>, - names: u16, - /// minimatch writes `[:graph:]` negated, beside the rest: "in these, or not in those". - not_names: u16, - negated: bool, - /// There are `ranges` or `names`. - has_first: bool, - folds: bool, -} - -fn in_ranges(ranges: &[(u32, u32)], c: u32) -> bool { - ranges.iter().any(|(low, high)| *low <= c && c <= *high) -} - -impl Class { - fn new(ranges: Ranges, negated: bool, folds: bool) -> Class { - Class { - ascii: [0; 2], - ranges: ranges.into(), - names: 0, - not_names: 0, - negated, - has_first: true, - folds, - } - } - - /// Fills in `ascii`. - fn finished(mut self, never_slash: bool) -> Class { - for c in 0..128_u8 { - let hit = match self.folds { - // Negation comes after folding: a member in either case makes both members. - true => { - let in_case = |c: u8| in_ranges(&self.ranges, u32::from(c)); - (in_case(c.to_ascii_uppercase()) || in_case(c.to_ascii_lowercase())) - != self.negated - } - false => self.says(u32::from(c)), - }; - if hit && !(never_slash && c == b'/') { - self.ascii[usize::from(c >> 6)] |= 1 << (c & 63); - } - } - self - } - - /// These ranges: `\d` outside of brackets. - pub(crate) fn of_ranges(ranges: &[(u32, u32)], folds: bool) -> Class { - Class::new(ranges.to_vec(), false, folds).finished(false) - } - - /// `[^.\/]` - pub(crate) fn not_dot_or_slash() -> Class { - let members = (*b"./").map(|it| (u32::from(it), u32::from(it))); - Class::new( - members.to_vec(), - /* negated */ true, - /* folds */ false, - ) - .finished(false) - } - - /// What it says of `c`, which is not folded, without the bitmap. - fn says(&self, c: u32) -> bool { - self.has_first && (in_ranges(&self.ranges, c) || in_names(self.names, c)) != self.negated - || self.not_names != 0 && in_names(self.not_names, c) == self.negated - } - - /// `unit` is folded if the class folds. - #[inline] - pub(crate) fn has(&self, unit: u32) -> bool { - if unit < 0x80 { - return self.ascii[(unit >> 6) as usize] & (1 << (unit & 63)) != 0; - } - match self.folds { - true => { - (in_ranges(&self.ranges, unit) || in_ranges(&self.ranges, unfold(unit))) - != self.negated - } - false => self.says(unit), - } - } - - pub(crate) fn takes_slash(&self) -> bool { - self.has(u32::from(b'/')) - } - - /// minimatch writes it `([..]|[^..])`, which does not start with a `[`. - pub(crate) fn is_written_in_parens(&self) -> bool { - self.has_first && self.not_names != 0 - } - - /// It takes nothing but a `/`. - pub(crate) fn is_slash(&self) -> bool { - !self.negated - && self.names == 0 - && self.not_names == 0 - && self.ascii == [1_u64 << b'/', 0] - && self.ranges.iter().all(|(_, high)| *high < 0x80) - } -} - -/// What starts with a `[`. -pub(crate) enum Read { - Class { - class: Class, - len: usize, - /// Of minimatch: it has a POSIX class that needs the flag `u`. - needs_code_points: bool, - }, - /// Of minimatch: a class of one character is that character. - One { unit: u32, len: usize }, - /// The `[` is a character. - NotAClass, - /// The pattern, or what is left of it, matches nothing. - Never, -} - -fn read(class: Class, len: usize) -> Read { - Read::Class { - class, - len, - needs_code_points: false, - } -} - -// ───────────────────────────── minimatch: `parseClass(glob, at)` ───────────────────────────── - -/// The length of the `[:name:]` at `at`, 0 if there is none. -fn minimatch_name_len(glob: &[u8], at: usize) -> usize { - let found = MINIMATCH_NAMES.iter().find(|it| has_at(glob, at, it.0)); - found.map_or(0, |it| it.0.len()) -} - -/// Where the last `]` of `glob` is that can close a class: it is not escaped and not the end of a `[:name:]`. -pub(crate) fn minimatch_last_close(glob: &[u8]) -> Option { - let (mut last, mut i) = (None, 0); - while let Some(byte) = glob.get(i) { - match byte { - b'\\' => i += 1, - b']' => last = Some(i), - b'[' => i += minimatch_name_len(glob, i).saturating_sub(1), - _ => {} - } - i += 1; - } - last -} - -/// Whether it is worth reading the class at `at`. Without this a name of nothing but `[` is read from each of them to its end. -pub(crate) fn minimatch_can_close(glob: &[u8], at: usize, last_close: Option) -> bool { - last_close.is_some_and(|it| it >= at + 2) || minimatch_name_len(glob, at) > 0 -} - -/// `glob[at]` is `[`. `code_points`: the name has a POSIX class that needs the flag `u`, so a character beyond U+FFFF is one unit. -pub(crate) fn minimatch(glob: &[u8], at: usize, code_points: bool) -> Read { - let unit = match code_points { - true => Unit::CodePoint, - false => Unit::Utf16, - }; - let text = Text { unit, folds: false }; - let mut ranges = Ranges::new(); - let (mut names, mut not_names) = (0, 0); - // How many elements `ranges` of the reference has. What is not one unit counts as two. - let mut count = 0; - // The one unit, if `ranges` is one unit. - let mut only = 0; - let (mut saw_start, mut escaping, mut negated) = (false, false, false); - let mut needs_code_points = false; - let mut range_start = None; - let (mut i, mut end) = (at + 1, at); - 'chars: while let Some((c, len)) = text.next(Subject::of(glob), i) { - if matches!(c, BANG | CARET) && i == at + 1 { - negated = true; - i += 1; - continue; - } - if c == CLOSE && saw_start && !escaping { - end = i + 1; - break; - } - saw_start = true; - if c == BACKSLASH && !escaping { - escaping = true; - i += 1; - continue; - } - if c == OPEN && !escaping { - for (name, bit, needs_u) in MINIMATCH_NAMES { - if !has_at(glob, i, name) { - continue; - } - if range_start.is_some() { - return Read::Never; - } - i += name.len(); - if bit == GRAPH { - not_names |= bit; - } else { - names |= bit; - count += 2; - } - needs_code_points |= needs_u; - continue 'chars; - } - } - escaping = false; - i += len; - if let Some(start) = range_start.take() { - if c > start { - ranges.push((start, c)); - count += 2; - } else if c == start { - ranges.push((c, c)); - count += 1; - only = c; - } - } else if has_at(glob, i, b"-]") { - ranges.extend([(c, c), (DASH, DASH)]); - count += 2; - i += 1; - } else if has_at(glob, i, b"-") { - range_start = Some(c); - i += 1; - } else { - ranges.push((c, c)); - count += 1; - only = c; - } - } - if end < i { - return Read::NotAClass; - } - if count == 0 && not_names == 0 { - return Read::Never; - } - let len = end - at; - // `/^\\?.$/`: one UTF-16 unit that is no line terminator. - let is_one_unit = only <= 0xFFFF - && char::from_u32(only).is_some() - && !bun_core::strings::is_js_line_terminator(only); - if not_names == 0 && count == 1 && !negated && is_one_unit { - return Read::One { unit: only, len }; - } - let has_first = count > 0; - let class = Class { - names, - not_names, - has_first, - ..Class::new(ranges, negated, false) - }; - Read::Class { - class: class.finished(false), - len, - needs_code_points, - } -} - -// ───────────────────────────── wildmatch: git, npm `ignore` 7.0.12 ───────────────────────────── - -const WILDMATCH_NAMES: [(&[u8], &[(u32, u32)]); 12] = [ - (b"alnum", &[(48, 57), (65, 90), (97, 122)]), - (b"alpha", &[(65, 90), (97, 122)]), - (b"blank", &[(32, 32), (9, 9)]), - (b"cntrl", &[(0, 31), (127, 127)]), - (b"digit", &[(48, 57)]), - (b"graph", &[(33, 126)]), - (b"lower", &[(97, 122)]), - (b"print", &[(32, 126)]), - (b"punct", &[(33, 47), (58, 64), (91, 96), (123, 126)]), - (b"space", &[(32, 32), (9, 10), (13, 13)]), - (b"upper", &[(65, 90)]), - (b"xdigit", &[(48, 57), (65, 70), (97, 102)]), -]; - -/// `dowild` of `wildmatch.c` at a `[`, and `scanBracket` of the package. `glob[at]` is `[`. It never takes a `/`. -pub(crate) fn wildmatch(glob: &[u8], at: usize, text: Text) -> Read { - let unit_at = |i: usize| text.plain().next(Subject::of(glob), i); - let negated = matches!(glob.get(at + 1), Some(b'!' | b'^')); - let mut i = at + 1 + usize::from(negated); - let mut ranges = Ranges::new(); - // What a `-` can start a range from. - let mut prev = None; - loop { - let Some((c, len)) = unit_at(i) else { - return Read::Never; - }; - let is_before_bound = glob.get(i + 1).is_some_and(|next| *next != b']'); - if c == BACKSLASH { - let Some((escaped, len)) = unit_at(i + 1) else { - return Read::Never; - }; - ranges.push((escaped, escaped)); - prev = Some(escaped); - i += 1 + len; - } else if let Some(low) = prev.filter(|_| c == DASH && is_before_bound) { - i += 1; - if glob.get(i) == Some(&b'\\') { - i += 1; - } - let Some((high, len)) = unit_at(i) else { - return Read::Never; - }; - if low <= high { - ranges.push((low, high)); - } - prev = None; - i += len; - } else if c == OPEN && glob.get(i + 1) == Some(&b':') { - let rest = glob.get(i + 2..).unwrap_or_default(); - let Some(end) = strings::index_of_char_usize(rest, b']') else { - return Read::Never; - }; - if let Some(name) = rest[..end].strip_suffix(b":") { - let Some((_, members)) = WILDMATCH_NAMES.iter().find(|it| it.0 == name) else { - return Read::Never; - }; - ranges.extend_from_slice(members); - prev = None; - i += 2 + end + 1; - } else { - ranges.push((OPEN, OPEN)); - prev = Some(OPEN); - i += 1; - } - } else { - ranges.push((c, c)); - prev = Some(c); - i += len; - } - if glob.get(i) == Some(&b']') { - let class = Class::new(ranges, negated, text.folds).finished(true); - return read(class, i + 1 - at); - } - } -} - -// ───────────────────────────── globset ───────────────────────────── - -fn utf8_of(c: u32) -> Vec { - let mut bytes = Vec::with_capacity(4); - push_utf8(&mut bytes, c); - bytes -} - -/// `Parser::parse_class`, written for bytes: a character beyond ASCII is each of its bytes. `Err`: why the line is refused. -pub(crate) fn globset(glob: &[u8], at: usize, folds: bool) -> Result> { - let text = Text { - unit: Unit::CodePoint, - folds: false, - }; - let negated = matches!(glob.get(at + 1), Some(b'!' | b'^')); - let mut i = at + 1 + usize::from(negated); - let mut chars = Ranges::new(); - let (mut first, mut in_range) = (true, false); - loop { - // `allow_unclosed_class` - let Some((c, len)) = text.next(Subject::of(glob), i) else { - return Ok(Read::NotAClass); - }; - i += len; - if c == CLOSE && !first { - break; - } - if c == DASH && !first && !in_range { - in_range = true; - } else if let Some(last) = chars.last_mut().filter(|_| in_range) { - last.1 = c; - if last.1 < last.0 { - let (low, high) = (utf8_of(last.0), utf8_of(last.1)); - return Err([b"invalid range; '", &low[..], b"' > '", &high, b"'"].concat()); - } - in_range = false; - } else { - chars.push((c, c)); - } - first = false; - } - if in_range { - chars.push((DASH, DASH)); - } - let mut ranges = Ranges::with_capacity(chars.len()); - let one = |byte: &u8| (u32::from(*byte), u32::from(*byte)); - for (low, high) in chars { - let (from, to) = (utf8_of(low), utf8_of(high)); - let ([before @ .., last], [first, after @ ..]) = (&from[..], &to[..]) else { - continue; - }; - if low == high { - ranges.extend(from.iter().map(one)); - continue; - } - // What the expression would be refused for. - if last > first { - return Ok(Read::Never); - } - ranges.extend(before.iter().map(one)); - ranges.push((u32::from(*last), u32::from(*first))); - ranges.extend(after.iter().map(one)); - } - Ok(read( - Class::new(ranges, negated, folds).finished(false), - i - at, - )) -} - -// ───────────────────────────── JavaScript: picomatch and npm `ignore` 5, 7.0.5 hand the text to `RegExp` ───────────────────────────── - -const MAX: u32 = 0x10FFFF; -const DIGITS: &[(u32, u32)] = &[(48, 57)]; -const NOT_DIGITS: &[(u32, u32)] = &[(0, 47), (58, MAX)]; -const WORDS: &[(u32, u32)] = &[(48, 57), (65, 90), (95, 95), (97, 122)]; -const NOT_WORDS: &[(u32, u32)] = &[(0, 47), (58, 64), (91, 94), (96, 96), (123, MAX)]; -const SPACES: &[(u32, u32)] = &[ - (9, 13), - (32, 32), - (0xA0, 0xA0), - (0x1680, 0x1680), - (0x2000, 0x200A), - (0x2028, 0x2029), - (0x202F, 0x202F), - (0x205F, 0x205F), - (0x3000, 0x3000), - (0xFEFF, 0xFEFF), -]; -const NOT_SPACES: &[(u32, u32)] = &[ - (0, 8), - (14, 31), - (33, 0x9F), - (0xA1, 0x167F), - (0x1681, 0x1FFF), - (0x200B, 0x2027), - (0x202A, 0x202E), - (0x2030, 0x205E), - (0x2060, 0x2FFF), - (0x3001, 0xFEFE), - (0xFF00, MAX), -]; - -/// What a `\` and what follows it are. `len` counts the `\`. -pub(crate) enum Escape { - Unit { - unit: u32, - len: usize, - }, - /// `\d`, `\W`, .. - Ranges(&'static [(u32, u32)]), - /// `\b`, and `\B`, which is negated. - WordBoundary { - negated: bool, - }, - /// `\c` without a letter: the `\` is a character. - Backslash, - /// `\1` .. `\9`: the group of that number if there is one, else `unit`. - Reference { - number: u32, - unit: u32, - len: usize, - }, -} - -/// The value of `digits` to the base `1 << bits`. `None` if one of them is no digit. -fn number(digits: Option<&[u8]>, bits: u32) -> Option { - digits?.iter().try_fold(0, |value, digit| { - let digit = char::from(*digit).to_digit(1 << bits)?; - Some((value << bits) | digit) - }) -} - -/// `source[at]` is `\`, and something follows. As `RegExp` without the flag `u` reads it. An octal escape is read to its end. -pub(crate) fn javascript_escape(source: &[u8], at: usize, in_class: bool) -> Escape { - let unit = |unit: u32, len: usize| Escape::Unit { unit, len }; - let c = source.get(at + 1).copied().unwrap_or(0); - match c { - b'd' => Escape::Ranges(DIGITS), - b'D' => Escape::Ranges(NOT_DIGITS), - b'w' => Escape::Ranges(WORDS), - b'W' => Escape::Ranges(NOT_WORDS), - b's' => Escape::Ranges(SPACES), - b'S' => Escape::Ranges(NOT_SPACES), - b't' => unit(9, 2), - b'n' => unit(10, 2), - b'v' => unit(11, 2), - b'f' => unit(12, 2), - b'r' => unit(13, 2), - b'b' if in_class => unit(8, 2), - b'B' if in_class => unit(66, 2), - b'b' | b'B' => Escape::WordBoundary { negated: c == b'B' }, - b'x' => match number(source.get(at + 2..at + 4), 4) { - Some(value) => unit(value, 4), - None => unit(120, 2), - }, - b'u' => match number(source.get(at + 2..at + 6), 4) { - Some(value) => unit(value, 6), - None => unit(117, 2), - }, - b'c' => match source.get(at + 2) { - Some(letter) - if letter.is_ascii_alphabetic() - || in_class && (letter.is_ascii_digit() || *letter == b'_') => - { - unit(u32::from(letter % 32), 3) - } - _ => Escape::Backslash, - }, - b'0'..=b'9' => { - // `\8` and `\9` are the digits. - let most = match c { - b'0'..=b'3' => 3, - b'4'..=b'7' => 2, - _ => 0, - }; - let digits = source.get(at + 1..).unwrap_or_default(); - let octal = digits - .iter() - .take(most) - .take_while(|it| matches!(it, b'0'..=b'7')); - let len = octal.count(); - let value = number(digits.get(..len), 3).filter(|_| len > 0); - let digit = u32::from(c); - let (value, len) = (value.unwrap_or(digit), 1 + len.max(1)); - match c == b'0' || in_class { - true => unit(value, len), - false => Escape::Reference { - number: u32::from(c - b'0'), - unit: value, - len, - }, - } - } - // An identity escape, of a UTF-16 unit. - _ => match Text::UTF16.next(Subject::of(source), at + 1) { - Some((escaped, len)) => unit(escaped, 1 + len), - None => Escape::Backslash, - }, - } -} - -/// An element of a class at `*i`. `None`: a set, which has been added. -fn atom(source: &[u8], i: &mut usize, ranges: &mut Ranges) -> Option { - if source.get(*i) == Some(&b'\\') && *i + 1 < source.len() { - return match javascript_escape(source, *i, true) { - Escape::Ranges(set) => { - *i += 2; - ranges.extend_from_slice(set); - None - } - Escape::Unit { unit, len } | Escape::Reference { unit, len, .. } => { - *i += len; - Some(unit) - } - Escape::Backslash | Escape::WordBoundary { .. } => { - *i += 1; - Some(BACKSLASH) - } - }; - } - let (unit, len) = Text::UTF16.next(Subject::of(source), *i)?; - *i += len; - Some(unit) -} - -/// `source[at]` is `[`. `Read::Never`: `RegExp` throws: it is not closed, or a range is out of order. -pub(crate) fn javascript(source: &[u8], at: usize, folds: bool) -> Read { - let negated = source.get(at + 1) == Some(&b'^'); - let mut i = at + 1 + usize::from(negated); - let mut ranges = Ranges::new(); - loop { - match source.get(i) { - None => return Read::Never, - Some(b']') => break, - Some(_) => {} - } - let low = atom(source, &mut i, &mut ranges); - let is_range = - source.get(i) == Some(&b'-') && source.get(i + 1).is_some_and(|it| *it != b']'); - if !is_range { - ranges.extend(low.map(|it| (it, it))); - continue; - } - i += 1; - match (low, atom(source, &mut i, &mut ranges)) { - (Some(low), Some(high)) if high < low => return Read::Never, - (Some(low), Some(high)) => ranges.push((low, high)), - // `[\d-x]`: the three of them. - (low, high) => { - ranges.extend(low.or(high).map(|it| (it, it))); - ranges.push((DASH, DASH)); - } - } - } - read( - Class::new(ranges, negated, folds).finished(false), - i + 1 - at, - ) -} diff --git a/src/glob/filtered.rs b/src/glob/filtered.rs deleted file mode 100644 index aa6ed4a75c31..000000000000 --- a/src/glob/filtered.rs +++ /dev/null @@ -1,257 +0,0 @@ -//! [`crate::match`], behind what tells most paths apart without it. - -use crate::matcher::{self, Token}; -use bun_core::strings; -use std::ops::Range; - -/// Where the last name of `path` starts. A `/` at the end of the path belongs to it. -pub(crate) fn last_name_at(path: &[u8]) -> usize { - let names = path.strip_suffix(b"/").unwrap_or(path); - strings::last_index_of_char(names, b'/').map_or(0, |slash| slash + 1) -} - -/// A pattern of Bun that has been read. -pub(crate) struct Filtered { - pattern: Box<[u8]>, - has_extglob: bool, - /// An odd number of `!` at its start. - is_negated: bool, - /// What matches starts with one of these, up to a `/` or to its end. Empty: it is not known. - heads: Box<[Box<[u8]>]>, - /// What matches starts with this much of the pattern, and ends with this much of it. - start_len: u32, - tail_len: u32, - /// This of the pattern is somewhere in what matches. - inner: Range, - /// `**/name`: only the last name of a path counts. - is_for_name: bool, - /// The names at the start, each of which takes one name of a path. - names: Box<[Range]>, - /// `names` are all of it. - is_names_only: bool, -} - -/// `[jt]`, `[a-z]`: letters and digits, so no `/`. -fn is_plain_class(class: &[u8]) -> bool { - let inside = &class[1..class.len() - 1]; - let is_bound = |at: usize| inside.get(at).is_some_and(u8::is_ascii_alphanumeric); - (0..inside.len()).all(|at| { - is_bound(at) || inside[at] == b'-' && at > 0 && is_bound(at - 1) && is_bound(at + 1) - }) -} - -/// Whether what `part` matches is in one name. `is_in_group`: a group in it is not looked into. -fn stays_in_a_name(part: &[u8], has_extglob: bool, is_in_group: bool) -> bool { - let mut at = 0; - while at < part.len() { - let (token, end) = matcher::token_at(part, at, has_extglob); - let stays = match token { - Token::Literal => part[at] != b'/', - Token::Star | Token::Any => true, - Token::Class => is_plain_class(&part[at..end]), - // A `,` or a `|` is a character there, which changes nothing. - Token::Group { first } if !is_in_group => { - stays_in_a_name(&part[first..end - 1], has_extglob, true) - } - _ => false, - }; - if !stays { - return false; - } - at = end; - } - true -} - -impl Filtered { - /// `has_extglob`: `match_with::`. - pub(crate) fn read(pattern: &[u8], has_extglob: bool) -> Filtered { - let mut read = Filtered { - pattern: pattern.into(), - has_extglob, - is_negated: false, - heads: Box::default(), - start_len: 0, - tail_len: 0, - inner: 0..0, - is_for_name: false, - names: Box::default(), - is_names_only: false, - }; - let bangs = matcher::negations(pattern, has_extglob); - if bangs > 0 { - read.is_negated = bangs % 2 == 1; - return read; - } - // The runs of characters without magic, and where the names start that are outside of all groups. - let mut runs: Vec> = Vec::new(); - let mut starts = vec![0]; - let mut first_group = None; - let (mut at, mut is_in_run, mut is_known) = (0, false, true); - while at < pattern.len() { - let (token, mut end) = matcher::token_at(pattern, at, has_extglob); - match token { - Token::Literal => { - match runs.last_mut().filter(|_| is_in_run) { - Some(run) => run.end = end, - None => runs.push(at..end), - } - if pattern[at] == b'/' { - starts.push(end); - } - } - // The `/` behind it can be nothing. - Token::Globstar if pattern.get(end) == Some(&b'/') => { - end += 1; - starts.push(end); - } - Token::Group { first } if first_group.is_none() => { - first_group = Some((at, first, end)); - } - Token::Unknown => is_known = false, - _ => {} - } - is_in_run = token == Token::Literal; - at = end; - } - if !is_known { - return read; - } - if is_in_run && let Some(run) = runs.last() { - read.tail_len = run.len() as u32; - } - if let Some(run) = runs.first().filter(|it| it.start == 0) { - read.start_len = run.end as u32; - } - let between = (runs.iter()).filter(|it| it.start > 0 && it.end < pattern.len()); - if let Some(run) = between.max_by_key(|it| it.len()) { - read.inner = run.start as u32..run.end as u32; - } - read.heads = heads(pattern, &runs, first_group, has_extglob); - - let stays = - |part: &[u8]| !part.starts_with(b"!") && stays_in_a_name(part, has_extglob, false); - let ends = (starts.iter().skip(1).map(|it| it - 1)).chain([pattern.len()]); - let parts = starts.iter().zip(ends).map(|(start, end)| *start..end); - let names = parts.take_while(|it| stays(&pattern[it.clone()])); - read.names = names.map(|it| it.start as u32..it.end as u32).collect(); - read.is_names_only = read.names.len() == starts.len(); - read.is_for_name = pattern.strip_prefix(b"**/").is_some_and(stays); - read - } - - #[inline] - fn asks(&self, pattern: &[u8], path: &[u8]) -> matcher::MatchResult { - match self.has_extglob { - true => matcher::match_with::(pattern, path), - false => matcher::r#match(pattern, path), - } - } - - /// An odd number of `!` at its start. - pub(crate) fn is_negated(&self) -> bool { - self.is_negated - } - - /// `name_at`: where the last name of `path` starts, to which a `/` at the end belongs. - #[inline] - pub(crate) fn matches(&self, path: &[u8], name_at: impl FnOnce() -> usize) -> bool { - // Most tails are a few bytes, which are compared without a call. - let tail = self.pattern.iter().rev().take(self.tail_len as usize); - let ends_with_tail = path.len() >= self.tail_len as usize - && path.iter().rev().zip(tail).all(|(a, b)| a == b); - ends_with_tail && self.matches_whole(path, name_at) - } - - fn matches_whole(&self, path: &[u8], name_at: impl FnOnce() -> usize) -> bool { - let inner = &self.pattern[self.inner.start as usize..self.inner.end as usize]; - if !path.starts_with(&self.pattern[..self.start_len as usize]) - || !(inner.is_empty() || strings::contains(path, inner)) - { - return false; - } - match self.is_for_name { - true if !path.ends_with(b"/") => { - (self.asks(&self.pattern[3..], &path[name_at()..])).matches() - } - _ => self.asks(&self.pattern, path).matches(), - } - } - - /// Whether something in `directory` can match. Never `false` if something can. - pub(crate) fn may_match_inside(&self, directory: &[u8]) -> bool { - if self.is_negated || directory.is_empty() { - return true; - } - let mut names = self.names.iter(); - for name in strings::split(directory, b"/") { - match names.next() { - Some(it) => { - let part = &self.pattern[it.start as usize..it.end as usize]; - if !self.asks(part, name).matches() { - return false; - } - } - None => return !self.is_names_only, - } - } - // What is in it has one name more. - !self.is_names_only || names.next().is_some() - } - - /// What a path that matches starts with, up to a `/` or to its end: one of these. `None`: the pattern does not say. - pub(crate) fn heads(&self) -> Option> { - (!self.heads.is_empty()).then(|| self.heads.iter().map(|it| &it[..]).collect()) - } -} - -/// [`Filtered::heads`]. `runs`: the characters without magic. `group`: where the first group starts, its first alternative, where it ends. -fn heads( - pattern: &[u8], - runs: &[Range], - group: Option<(usize, usize, usize)>, - has_extglob: bool, -) -> Box<[Box<[u8]>]> { - let run_from = |at: usize| { - runs.iter() - .find(|it| it.start == at) - .map_or(at..at, Range::clone) - }; - let start = run_from(0); - // All of a name, and so up to the last `/`, unless the pattern ends there. - let whole_names = |text: Vec, is_all: bool| match is_all { - true => text, - false => text[..strings::last_index_of_char(&text, b'/').unwrap_or(0)].to_vec(), - }; - let mut found = Vec::new(); - match group { - // `{a,b}/..`, `@(a|b)/..` - Some((at, first, end)) if at == start.end && matches!(pattern[at], b'{' | b'@') => { - let after = run_from(end); - let separator: &[u8] = if pattern[at] == b'{' { b"," } else { b"|" }; - let inside = &pattern[first..end - 1]; - let mut tokens = - (0..inside.len()).map(|it| matcher::token_at(inside, it, has_extglob).0); - if !tokens.all(|it| it == Token::Literal) { - return Box::default(); - } - for alternative in strings::split(inside, separator) { - let text = [ - &pattern[start.clone()], - alternative, - &pattern[after.clone()], - ] - .concat(); - found.push(whole_names(text, after.end == pattern.len())); - } - } - _ => found.push(whole_names( - pattern[start.clone()].to_vec(), - start.end == pattern.len(), - )), - } - match found.iter().all(|it| !it.is_empty()) { - true => found.into_iter().map(Vec::into_boxed_slice).collect(), - false => Box::default(), - } -} diff --git a/src/glob/ignore.rs b/src/glob/ignore.rs index a23046c362ab..a85fd1fe215c 100644 --- a/src/glob/ignore.rs +++ b/src/glob/ignore.rs @@ -1,30 +1,19 @@ //! The lines of one ignore file, or of one array in a configuration: the last line that matches decides. +//! A line becomes a pattern for [`crate::match`]. -use crate::linear::Shape; -use crate::node::{Assertion, Node, Program, lower, simplify}; -use crate::read_ignore::{self, Line}; -use crate::unit::{Subject, Text, Unit}; +use crate::matcher; +use crate::pattern::{Candidate, Pattern}; use bun_collections::StringHashMap; use bun_collections::smallvec::SmallVec; use bun_core::strings; -/// Who reads the lines. -#[derive(Copy, Clone, PartialEq, Eq, Debug)] -pub enum IgnoreSyntax { - /// The crate `ignore` 0.4.33 on `globset` 0.4.18: oxlint, oxfmt. - Globset, - /// npm `ignore` 5.3.2: the rules of ESLint. - Npm5, - /// npm `ignore` 7.0.5: Prettier. - Npm705, - /// npm `ignore` 7.0.12: the rules of typescript-eslint. - Npm7012, -} - #[derive(Copy, Clone, PartialEq, Eq, Debug)] pub struct IgnoreOptions { - pub syntax: IgnoreSyntax, + /// `{a,b}` is `a` or `b`, as for the crate `ignore`, which oxlint and oxfmt have. To git and to the package `ignore`, which + /// ESLint and Prettier have, braces are characters. + pub has_braces: bool, /// `ignoreCase` of the package, which is on unless told otherwise. `case_insensitive` of the crate, `core.ignorecase`. + /// Only ASCII is folded. pub ignores_case: bool, } @@ -38,138 +27,120 @@ pub enum Verdict { Kept, } -/// A line that `Globset` does not take. -pub struct Refused { - pub line: Box<[u8]>, - /// `ErrorKind` of globset as it prints it: `unclosed character class; missing ']'`, `invalid range; 'z' > 'a'`, .. - pub why: Box<[u8]>, +struct Rule { + /// `None`: whatever is found by its key matches. + pattern: Option, + is_negated: bool, + is_for_directories: bool, } -/// A path that the rules are asked about. -#[derive(Copy, Clone)] -struct Asked<'p> { - path: &'p [u8], - /// Where its last name starts. - name_at: usize, - /// How many `/` it has, if a rule wants to know. - slashes: u32, +/// What a line says. +struct Line { + /// For the path from the directory of the lines. + pattern: Vec, + /// Where in `pattern` what is written starts: behind the `**/` of a line that is for a name in any directory. + written_at: usize, + is_negated: bool, + is_for_directories: bool, } -/// `under/**/names`, or `names` alone: the names go to the end of the path and take a known number of `/`. So it is known where -/// they start. -struct Tail { - /// What is before the `**`, which takes anything. Empty, or it ends with a `/`. - under: Box<[u8]>, - slashes: u32, - /// The byte that the names start with, and the one that they end with, where the rule says. - first: Option, - last: Option, - /// For the names. `None`: there is no `**`, they are the whole rule and start where the path starts. - program: Option, +/// Without the blanks at the end, but for one that a `\` is before. +fn before_blanks(line: &[u8]) -> &[u8] { + let end = line.trim_ascii_end().len(); + let backslashes = line[..end].iter().rev().take_while(|it| **it == b'\\'); + &line[..end + usize::from(end < line.len() && backslashes.count() % 2 == 1)] } -impl Tail { - /// Of `node`, which is simplified, if it is such a rule. Bytes are compared as they are. - fn of(node: &Node, text: Text) -> Option { - let Node::Seq(nodes) = node else { - return None; - }; - let is_deep = |it: &Node| { - matches!( - it, - Node::Deep { - empty_names: true, - newlines: true +/// `Ok(None)`: it says nothing: a comment, blank. `Err`: with braces, it is refused: a group is not closed or not opened, a `\` is +/// the last character, a range is `z-a`. +fn read(line: &[u8], options: IgnoreOptions) -> Result, ()> { + let mut written = before_blanks(line); + if written.starts_with(b"#") { + return Ok(None); + } + let is_negated = written.starts_with(b"!"); + written = &written[usize::from(is_negated)..]; + let is_for_directories = written.len() > 1 && written.ends_with(b"/"); + written = &written[..written.len() - usize::from(is_for_directories)]; + // A `/` ties it to the directory of the lines, a `**/` at the start unties it. + let mut is_anchored = strings::contains_char(written, b'/'); + written = written.strip_prefix(b"/").unwrap_or(written); + while let Some(rest) = written.strip_prefix(b"**/") { + (written, is_anchored) = (rest, false); + } + if written.is_empty() { + return Ok(None); + } + let mut pattern = Vec::with_capacity(written.len() + 4); + if !is_anchored { + pattern.extend_from_slice(b"**/"); + } + let written_at = pattern.len(); + let (mut at, mut depth) = (0, 0_usize); + while let Some(&byte) = written.get(at) { + let mut end = at + 1; + match byte { + b'\\' => { + end += 1; + match written.get(at + 1) { + // What `\` makes of a letter in a pattern of Bun, it does not make here. + Some(escaped) if escaped.is_ascii_alphanumeric() => pattern.push(*escaped), + Some(_) => pattern.extend_from_slice(&written[at..end]), + None if options.has_braces => return Err(()), + None => return Ok(None), } - ) - }; - let deep = nodes.iter().position(is_deep); - let (before, after) = nodes.split_at_checked(deep.map_or(0, |at| at + 1))?; - let [names @ .., Node::Assert(Assertion::End)] = after else { - return None; - }; - let mut under = Vec::new(); - for node in before.iter().take(deep.unwrap_or(0)) { - let Node::Lit(bytes) = node else { - return None; - }; - under.extend_from_slice(bytes); - } - if text.unit != Unit::Byte || text.folds || !matches!(under.last(), None | Some(b'/')) { - return None; - } - let mut slashes = 0; - let mut copy = Vec::with_capacity(names.len() + 1); - for node in names { - copy.push(match node { - Node::Lit(bytes) => { - slashes += strings::count_char(bytes, b'/') as u32; - Node::Lit(bytes.clone()) + } + b'[' => match matcher::class_end(written, at) { + Some(close) if options.has_braces && has_reversed_range(&written[at..close]) => { + return Err(()); } - Node::Any => Node::Any, - Node::Star => Node::Star, - Node::Plus => Node::Plus, - Node::Class(class) if !class.takes_slash() => Node::Class(class.clone()), - _ => return None, - }); + Some(close) => { + // No class takes a `/`. + let inside = at + 1 + usize::from(matches!(written[at + 1], b'!' | b'^')); + pattern.extend_from_slice(&written[at..inside]); + if inside > at + 1 { + pattern.push(b'/'); + } + pattern.extend_from_slice(&written[inside..close]); + end = close; + } + // The crate takes the `[` of a class that is not closed as it is. To the package nothing matches it. + None if options.has_braces => pattern.extend_from_slice(b"\\["), + None => return Ok(None), + }, + b'{' | b'}' if !options.has_braces => pattern.extend_from_slice(&[b'\\', byte]), + b'{' => { + depth += 1; + pattern.push(byte); + } + b'}' => { + depth = depth.checked_sub(1).ok_or(())?; + pattern.push(byte); + } + b'!' if at == 0 && is_anchored => pattern.extend_from_slice(b"\\!"), + _ => pattern.push(byte), } - copy.push(Node::Assert(Assertion::End)); - let first = match names.first() { - Some(Node::Lit(bytes)) => bytes.first().copied(), - _ => None, - }; - let last = match names.last() { - Some(Node::Lit(bytes)) => bytes.last().copied(), - _ => None, - }; - Some(Tail { - under: under.into(), - slashes, - first, - last, - program: deep.map(|_| lower(Node::Seq(copy), text)), - }) + at = end; } - - /// `whole`: the program of the rule. - #[inline] - fn matches(&self, asked: Asked<'_>, whole: &Program) -> bool { - let path = asked.path; - let differs = |wanted: Option, found: Option<&u8>| { - wanted.is_some_and(|wanted| found != Some(&wanted)) - }; - if differs(self.last, path.last()) { - return false; - } - let Some(program) = &self.program else { - return asked.slashes == self.slashes - && !differs(self.first, path.first()) - && whole.matches(Subject::of(path)); - }; - if self.slashes == 0 && differs(self.first, path.get(asked.name_at)) - || !(self.under.is_empty() || path.starts_with(&self.under)) - { - return false; - } - let mut at = asked.name_at; - for _ in 0..self.slashes { - let before = &path[..at.saturating_sub(1)]; - at = strings::last_index_of_char(before, b'/').map_or(0, |slash| slash + 1); - } - at >= self.under.len() - && !differs(self.first, path.get(at)) - && program.matches(Subject::of(&path[at..])) + if depth > 0 { + return Err(()); } + if options.ignores_case { + pattern.make_ascii_lowercase(); + } + Ok(Some(Line { + pattern, + written_at, + is_negated, + is_for_directories, + })) } -struct Rule { - program: Program, - /// What gives the same answers with less of the path. - tail: Option, - /// Without `tail`: characters that are somewhere in whatever matches. Empty: it is not known. - needs: Box<[u8]>, - is_negated: bool, - is_for_directories: bool, +/// `[z-a]` +fn has_reversed_range(class: &[u8]) -> bool { + let inside = &class[1..class.len() - 1]; + (1..inside.len().saturating_sub(1)) + .any(|at| inside[at] == b'-' && inside[at - 1] > inside[at + 1]) } /// Where in `IgnoreRules::rules`, the first first. @@ -178,10 +149,10 @@ type Places = StringHashMap>; /// The lines of one ignore file, or of one array in a configuration. It knows no directory and reads no file. pub struct IgnoreRules { options: IgnoreOptions, - text: Text, rules: Vec, - refused: Vec, - /// Which rules to try: by name, by path, and `*.ext` by `ext`. With folding: keys in upper case, ASCII rules only. + /// The lines that are refused. + refused: Vec>, + /// Which rules to try: `name` by the name, `/a/b` by the path, `*.ext` by `ext`. With folding: keys in lower case. by_name: Places, by_path: Places, /// How long the longest key of `by_path` is. @@ -193,8 +164,6 @@ pub struct IgnoreRules { at_top: Vec, /// Where the rest are. others: Vec, - /// A rule without `**` takes a `/`. - counts_slashes: bool, /// It has a line with `!`. has_exceptions: bool, } @@ -206,31 +175,15 @@ fn places<'m>(map: &'m Places, key: &[u8]) -> Option<&'m [u32]> { } } -/// `.. x/ $`: that `/` is the one that tells a directory, so the rule is for directories only, and for the path as it is. -fn split_final_slash(line: Line) -> Line { - let mut nodes = match simplify(line.node) { - Node::Seq(nodes) if !line.is_for_directories => nodes, - node => return Line { node, ..line }, - }; - let mut is_for_directories = false; - if let [.., Node::Lit(before), Node::Assert(Assertion::End)] = &mut nodes[..] - && before.ends_with(b"/") - { - before.pop(); - is_for_directories = true; - } - Line { - node: Node::Seq(nodes), - is_for_directories, - ..line - } +/// Whether every byte of `text` stands for itself in a pattern. +fn is_literal(text: &[u8]) -> bool { + strings::index_of_any(text, b"*?[{}\\!").is_none() } impl IgnoreRules { fn new(options: IgnoreOptions) -> IgnoreRules { IgnoreRules { options, - text: read_ignore::text_for(options), rules: Vec::new(), refused: Vec::new(), by_name: Places::new(), @@ -240,66 +193,54 @@ impl IgnoreRules { by_first_name: Places::new(), at_top: Vec::new(), others: Vec::new(), - counts_slashes: false, has_exceptions: false, } } - /// The npm flavours tell a directory by a `/` behind the path, and a class or a `.+` of theirs can take that `/`. - fn tells_directories_by_slash(&self) -> bool { - use IgnoreSyntax::{Npm5, Npm705, Npm7012}; - matches!(self.options.syntax, Npm5 | Npm705 | Npm7012) - } - fn add_line(&mut self, line: &[u8]) { - let read = match read_ignore::line(line, self.options) { - Ok(Some(read)) if self.tells_directories_by_slash() => split_final_slash(read), + let read = match read(line, self.options) { Ok(Some(read)) => read, Ok(None) => return, - Err(why) => { - return self.refused.push(Refused { - line: line.into(), - why: why.into(), - }); - } + Err(()) => return self.refused.push(line.into()), }; - let node = simplify(read.node); - let tail = Tail::of(&node, self.text); - let program = lower(node, self.text); - // What can match nothing says nothing. - if program.is_never() { - return; - } let at = self.rules.len() as u32; - let place = match program.shape() { - Shape::Name(name) => Some((&mut self.by_name, name)), - Shape::Extension(extension) => Some((&mut self.by_extension, extension)), - Shape::Path(path) => Some((&mut self.by_path, path)), - Shape::Under(first) => Some((&mut self.by_first_name, first)), - Shape::Other => None, + let (written, is_anchored) = (&read.pattern[read.written_at..], read.written_at == 0); + let first_len = strings::index_of_char_usize(written, b'/'); + // By the key alone it is known that these three match. + let found_by_key = match written { + name if !is_anchored && first_len.is_none() && is_literal(name) => { + Some((&mut self.by_name, name)) + } + [b'*', b'.', extension @ ..] + if !is_anchored + && is_literal(extension) + && !extension.is_empty() + && strings::index_of_any(extension, b"./").is_none() => + { + Some((&mut self.by_extension, extension)) + } + path if is_anchored && is_literal(path) => { + self.longest_path = self.longest_path.max(path.len()); + Some((&mut self.by_path, path)) + } + _ => None, }; - if let Shape::Path(path) = program.shape() { - self.longest_path = self.longest_path.max(path.len()); - } - // How many `/` a path has that matches, if there is no `**`. - let slashes = (tail.as_ref()).and_then(|it| it.program.is_none().then_some(it.slashes)); + let is_found_by_key = found_by_key.is_some(); + let place = found_by_key.or_else(|| { + let first = &written[..first_len.filter(|_| is_anchored)?]; + is_literal(first).then_some((&mut self.by_first_name, first)) + }); match place { Some((places, key)) => { bun_core::handle_oom(places.get_or_put_value(key, Vec::new())).push(at); } - None if slashes == Some(0) => self.at_top.push(at), + None if is_anchored && first_len.is_none() => self.at_top.push(at), None => self.others.push(at), } - self.counts_slashes |= slashes.is_some_and(|it| it > 0); self.has_exceptions |= read.is_negated; - let needs = match tail.is_none() && !self.tells_directories_by_slash() { - true => program.longest_literal().into(), - false => Box::default(), - }; self.rules.push(Rule { - program, - tail, - needs, + // `may_keep_inside` asks those with `!`. + pattern: (!is_found_by_key || read.is_negated).then(|| Pattern::new(&read.pattern)), is_negated: read.is_negated, is_for_directories: read.is_for_directories, }); @@ -308,30 +249,14 @@ impl IgnoreRules { /// The text of a file: lines, `\r`, a byte order mark, comments. pub fn from_text(text: &[u8], options: IgnoreOptions) -> IgnoreRules { let mut rules = IgnoreRules::new(options); - // git and the crate drop it at the start of the file. The package drops it at the start of any line, which `read_ignore` does. - let mut rest = match rules.tells_directories_by_slash() { - true => text, - false => text.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(text), - }; - while !rest.is_empty() { - let (line, has_line_feed) = match strings::index_of_char_usize(rest, b'\n') { - Some(end) => { - let line = &rest[..end]; - rest = &rest[end + 1..]; - (line, true) - } - None => (std::mem::take(&mut rest), false), - }; - // `\r\n` - rules.add_line(match line.strip_suffix(b"\r") { - Some(line) if has_line_feed => line, - _ => line, - }); + let text = text.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(text); + for line in strings::split(text, b"\n") { + rules.add_line(line.strip_suffix(b"\r").unwrap_or(line)); } rules } - /// The elements of an array. A byte order mark is what the flavour makes of it there. + /// The elements of an array. pub fn from_lines<'l>( lines: impl IntoIterator, options: IgnoreOptions, @@ -352,19 +277,19 @@ impl IgnoreRules { self.has_exceptions } - pub fn refused(&self) -> &[Refused] { + /// The lines that are refused. Only with braces are there any. + pub fn refused(&self) -> &[Box<[u8]>] { &self.refused } - /// The last of `found` that matches and is behind `best`, or else `best`. + /// The last of `found` that matches and is behind `best`, or else `best`. `is_known`: what is found matches. fn look( &self, - found: Option<&[u32]>, + (found, is_known): (Option<&[u32]>, bool), best: Option, - asked: Asked<'_>, + path: &Candidate<'_>, is_directory: bool, ) -> Option { - let by_slash = self.tells_directories_by_slash(); // Those before the best so far say nothing. let later = found.unwrap_or_default().iter().rev(); let mut later = later.take_while(|at| Some(**at) > best); @@ -372,21 +297,13 @@ impl IgnoreRules { let rule = self.rules.get(**at as usize); rule.filter(|it| is_directory || !it.is_for_directories) .is_some_and(|rule| { - let whole = || { - rule.program.matches(Subject { - bytes: asked.path, - slash: is_directory && !rule.is_for_directories && by_slash, - }) + let asked = || { + rule.pattern + .as_ref() + .is_none_or(|it| it.matches_candidate(path)) }; - let hit = match &rule.tail { - Some(tail) => tail.matches(asked, &rule.program), - None => { - (rule.needs.is_empty() || strings::contains(asked.path, &rule.needs)) - && whole() - } - }; - debug_assert_eq!(hit, whole()); - hit + debug_assert!(!is_known || asked()); + is_known || asked() }) }; later.find(matches).copied().or(best) @@ -395,40 +312,41 @@ impl IgnoreRules { /// The last rule that matches. fn last_match(&self, path: &[u8], is_directory: bool) -> Option<&Rule> { let mut folded: SmallVec<[u8; 256]> = SmallVec::new(); - let key = match self.text.folds { + let path = match self.options.ignores_case { true => { folded.extend_from_slice(path); - folded.make_ascii_uppercase(); + folded.make_ascii_lowercase(); &folded[..] } false => path, }; - let name_at = strings::last_index_of_char(key, b'/').map_or(0, |it| it + 1); - let name = &key[name_at..]; + let candidate = Candidate::new(path); + let name = candidate.name(); let extension = strings::last_index_of_char(name, b'.').map(|dot| &name[dot + 1..]); - let first_len = || strings::index_of_char_usize(key, b'/').unwrap_or(key.len()); - let first = (!self.by_first_name.is_empty()).then(|| &key[..first_len()]); - let asked = Asked { - path, - name_at, - slashes: match self.counts_slashes { - true => strings::count_char(path, b'/') as u32, - false => 0, - }, - }; + let first_len = || strings::index_of_char_usize(path, b'/').unwrap_or(path.len()); + let first = (!self.by_first_name.is_empty()).then(|| &path[..first_len()]); let mut best = None; for found in [ - places(&self.by_name, name), - match key.len() <= self.longest_path { - true => places(&self.by_path, key), - false => None, - }, - extension.and_then(|it| places(&self.by_extension, it)), - first.and_then(|it| places(&self.by_first_name, it)), - (name_at == 0).then_some(&self.at_top[..]), - Some(&self.others[..]), + (places(&self.by_name, name), true), + ( + match path.len() <= self.longest_path { + true => places(&self.by_path, path), + false => None, + }, + true, + ), + ( + extension.and_then(|it| places(&self.by_extension, it)), + true, + ), + (first.and_then(|it| places(&self.by_first_name, it)), false), + ( + (name.len() == path.len()).then_some(&self.at_top[..]), + false, + ), + (Some(&self.others[..]), false), ] { - best = self.look(found, best, asked, is_directory); + best = self.look(found, best, &candidate, is_directory); } self.rules.get(best? as usize) } @@ -463,36 +381,18 @@ impl IgnoreRules { pub fn ignores(&self, path: &[u8]) -> bool { let is_ignored = |text: &[u8], is_directory: bool| self.verdict(text, is_directory) == Verdict::Ignored; - let has_empty_name = path.starts_with(b"/") || strings::contains(path, b"//"); - if self.options.syntax == IgnoreSyntax::Npm5 || !has_empty_name { - // At each `/` but the last byte. - let mut from = 0; - while let Some(slash) = path - .get(from..) - .and_then(|rest| strings::index_of_char_usize(rest, b'/')) - .map(|it| from + it) - .filter(|it| it + 1 < path.len()) - { - if is_ignored(&path[..slash], true) { - return true; - } - from = slash + 1; - } - } else { - // Empty names are left out of the directories. - let mut names = strings::split(path, b"/") - .filter(|it| !it.is_empty()) - .peekable(); - let mut parent = Vec::with_capacity(path.len()); - while let Some(name) = names.next().filter(|_| names.peek().is_some()) { - if !parent.is_empty() { - parent.push(b'/'); - } - parent.extend_from_slice(name); - if is_ignored(&parent, true) { - return true; - } + // At each `/` but the last byte. + let mut from = 0; + while let Some(slash) = path + .get(from..) + .and_then(|rest| strings::index_of_char_usize(rest, b'/')) + .map(|it| from + it) + .filter(|it| it + 1 < path.len()) + { + if slash > 0 && is_ignored(&path[..slash], true) { + return true; } + from = slash + 1; } match path.strip_suffix(b"/") { Some(directory) => is_ignored(directory, true), @@ -502,14 +402,13 @@ impl IgnoreRules { /// Whether a line with `!` can match something in `directory`. Never `false` if one can. pub fn may_keep_inside(&self, directory: &[u8]) -> bool { - let subject = Subject { - bytes: directory, - slash: true, - }; - self.has_exceptions - && self - .rules - .iter() - .any(|rule| rule.is_negated && rule.program.may_match_inside(subject)) + let mut folded: SmallVec<[u8; 256]> = SmallVec::new(); + folded.extend_from_slice(directory); + if self.options.ignores_case { + folded.make_ascii_lowercase(); + } + let may_match = |it: &Pattern| it.may_match_inside(&folded); + let mut exceptions = self.rules.iter().filter(|it| it.is_negated); + self.has_exceptions && exceptions.any(|it| it.pattern.as_ref().is_none_or(may_match)) } } diff --git a/src/glob/lib.rs b/src/glob/lib.rs index de1536258bac..e085a87f8624 100644 --- a/src/glob/lib.rs +++ b/src/glob/lib.rs @@ -1,27 +1,15 @@ #![warn(unused_must_use)] -mod braces; -mod class; -mod filtered; #[path = "GlobWalker.rs"] pub mod glob_walker; pub mod ignore; -mod linear; pub mod matcher; -mod node; pub mod pattern; -mod read_ignore; -mod read_minimatch; -mod read_minimatch3; -mod read_picomatch; pub mod scan; -mod segments; -mod sets; -mod unit; // `match` is a Rust keyword; re-export with raw identifier. pub use crate::glob_walker as walk; pub use crate::matcher::{MatchResult, r#match}; -pub use crate::pattern::{Candidate, How, Options, Pattern, Syntax}; +pub use crate::pattern::{Candidate, Pattern}; pub use walk::GlobWalker; // `ignore_filter_fn` is a runtime fn-pointer field supplied at `init()` rather than a type diff --git a/src/glob/linear.rs b/src/glob/linear.rs deleted file mode 100644 index 42ba89a9a39a..000000000000 --- a/src/glob/linear.rs +++ /dev/null @@ -1,531 +0,0 @@ -//! Patterns without groups, run as `matcher::glob_match_impl` runs them: at most (tokens + 1) x (bytes + 1) steps. - -use crate::class::Class; -use crate::node::Assertion; -use crate::unit::{Subject, Text, Unit, push_utf8}; -use bun_core::strings; - -#[derive(Copy, Clone)] -pub(crate) enum Tok { - /// In `Tokens::bytes`. - Lit { - start: u32, - len: u32, - has_slash: bool, - }, - Any, - Star, - Plus, - /// In `Tokens::classes`. - Class(u32), - Deep { - empty_names: bool, - newlines: bool, - }, - Rest { - min: u8, - newlines: bool, - }, - Assert(Assertion), -} - -/// The characters of a `Lit`, in `Tokens::bytes`. -#[derive(Copy, Clone, Default)] -struct Span { - start: u32, - len: u32, -} - -/// The `Lit` before the end of the text: what matches ends with it. `or_slash`: or with it and a `/`. -#[derive(Copy, Clone)] -struct Tail { - lit: Span, - or_slash: bool, -} - -fn without_slash(text: &[u8]) -> Option<&[u8]> { - text.strip_suffix(b"/") -} - -pub(crate) struct Tokens { - toks: Vec, - bytes: Vec, - classes: Vec, - pub(crate) text: Text, - pub(crate) has_two_kinds_of_deep: bool, - /// The `Lit` at the start: what matches starts with it. Empty: there is none. - head: Span, - tail: Option, - /// All of `bytes` is. - is_ascii: bool, - /// Where the last `**` is, if it takes anything and what follows goes to the end of the text, and how many `/` that takes: - /// it matches from so many names before the last one, or later, or not at all. - last_deep: Option<(u32, u32)>, -} - -/// What the tokens are, for an index of many. They end with the end of the text, or of the text but for a `/`. -pub(crate) enum Shape<'t> { - /// `[Deep, Lit without "/", end]`: whatever is called so, in any directory. - Name(&'t [u8]), - /// `[Deep, Star, Lit(".ext"), end]`: without the `.`, and there is no other in it. - Extension(&'t [u8]), - /// `[Lit, end]` - Path(&'t [u8]), - /// `[Lit with a "/", ..]`: the name before it, which is the first name of whatever matches. - Under(&'t [u8]), - Other, -} - -/// Where the text goes on behind the characters of a `Lit`. -enum LitEnd { - At(usize), - No, - /// The text ends in them. - UsedUp, -} - -fn has_line_terminator(text: Text, bytes: &[u8]) -> bool { - last_line_terminator(text, bytes).is_some() -} - -/// Where the last line terminator of `bytes` starts. -fn last_line_terminator(text: Text, bytes: &[u8]) -> Option { - // Hardly any text has one: one search says so. - strings::index_of_any(bytes, b"\n\r\xE2")?; - let ascii = strings::last_index_of_any(bytes, b"\n\r"); - if text.unit == Unit::Byte { - return ascii; - } - ascii - .max(strings::last_index_of(bytes, b"\xE2\x80\xA8")) - .max(strings::last_index_of(bytes, b"\xE2\x80\xA9")) -} - -/// Where the next `/` is, from `from`. -fn next_slash(subject: Subject<'_>, from: usize) -> Option { - let rest = subject.bytes.get(from..)?; - match strings::index_of_char_usize(rest, b'/') { - Some(slash) => Some(from + slash), - None => subject.slash.then_some(subject.bytes.len()), - } -} - -/// Where the name starts that has `after` names behind it, or else the first. A `/` at the end of the text belongs to the last name. -fn name_start_from_end(subject: Subject<'_>, after: u32) -> usize { - let mut names = match subject.slash { - true => subject.bytes, - false => subject.bytes.strip_suffix(b"/").unwrap_or(subject.bytes), - }; - for _ in 0..after { - match strings::last_index_of_char(names, b'/') { - Some(slash) => names = &names[..slash], - None => return 0, - } - } - strings::last_index_of_char(names, b'/').map_or(0, |slash| slash + 1) -} - -impl Tokens { - pub(crate) fn new(text: Text) -> Tokens { - Tokens { - toks: Vec::new(), - bytes: Vec::new(), - classes: Vec::new(), - text, - has_two_kinds_of_deep: false, - head: Span::default(), - tail: None, - is_ascii: false, - last_deep: None, - } - } - - /// After the last `push`: finds what `can_match` asks about. - pub(crate) fn finish(&mut self) { - use Assertion::{End, EndOrFinalSlash}; - // With folding only ASCII is compared without decoding. - let span_of = |tok: &Tok| match *tok { - Tok::Lit { start, len, .. } if !self.text.folds || self.lit(start, len).is_ascii() => { - Some(Span { start, len }) - } - _ => None, - }; - let head = self.toks.first().and_then(span_of).unwrap_or_default(); - let (tail, has_end) = match &self.toks[..] { - [.., before, Tok::Assert(end @ (End | EndOrFinalSlash))] => { - let or_slash = *end == EndOrFinalSlash; - (span_of(before).map(|lit| Tail { lit, or_slash }), true) - } - _ => (None, false), - }; - let is_deep = |tok: &Tok| matches!(tok, Tok::Deep { .. }); - let last_deep = (self.toks.iter().rposition(is_deep)).filter(|&at| { - let takes_anything = matches!( - self.toks[at], - Tok::Deep { - empty_names: true, - newlines: true, - } - ); - let goes_on = |tok: &Tok| matches!(tok, Tok::Rest { .. }); - takes_anything && has_end && !self.toks[at..].iter().any(goes_on) - }); - let slashes = |tok: &Tok| match *tok { - Tok::Lit { - start, - len, - has_slash: true, - } => strings::count_char(self.lit(start, len), b'/'), - _ => 0, - }; - let last_deep = last_deep.map(|at| { - ( - at as u32, - self.toks[at..].iter().map(slashes).sum::() as u32, - ) - }); - (self.head, self.tail, self.last_deep) = (head, tail, last_deep); - self.is_ascii = self.bytes.is_ascii(); - } - - /// Never `false` for a text that matches. - #[inline] - fn can_match(&self, subject: Subject<'_>) -> bool { - let bytes = subject.bytes; - let is_same = |a: &[u8], b: &[u8]| match self.text.folds { - true => a.eq_ignore_ascii_case(b), - false => a == b, - }; - // Most differ in one byte already, which is compared without a call. - let is_same_byte = |a: Option<&u8>, b: Option<&u8>| match (a, b) { - (Some(a), Some(b)) => a.eq_ignore_ascii_case(b), - _ => true, - }; - let ends = |text: &[u8], end: &[u8]| { - is_same_byte(text.last(), end.last()) - && (text.len().checked_sub(end.len())).is_some_and(|at| is_same(&text[at..], end)) - }; - let head = self.lit(self.head.start, self.head.len); - let common = head.len().min(bytes.len()); - if !is_same_byte(bytes.first(), head.first()) || !is_same(&bytes[..common], &head[..common]) - { - return false; - } - let Some(tail) = self.tail else { - return true; - }; - let end = self.lit(tail.lit.start, tail.lit.len); - match subject.slash { - true => { - tail.or_slash && ends(bytes, end) - || without_slash(end).is_some_and(|end| ends(bytes, end)) - } - false => { - ends(bytes, end) - || tail.or_slash && without_slash(bytes).is_some_and(|text| ends(text, end)) - } - } - } - - pub(crate) fn push(&mut self, tok: Tok) { - // `**` in a name is one `*`. - if !matches!((tok, self.toks.last()), (Tok::Star, Some(Tok::Star))) { - self.toks.push(tok); - } - } - - /// The pattern is folded when it is read. - pub(crate) fn push_lit(&mut self, bytes: &[u8]) { - let start = self.bytes.len(); - if self.text.folds { - let mut at = 0; - while let Some((unit, len)) = self.text.next(Subject::of(bytes), at) { - match unit { - 0..0x80 => self.bytes.push(unit as u8), - // A half of a pair and a byte that is no UTF-8 stay as they are. - 0xD800..=0xDFFF => self - .bytes - .extend(bytes.get(at..at + len).unwrap_or_default()), - _ if len == 1 => self.bytes.extend(bytes.get(at)), - _ => push_utf8(&mut self.bytes, unit), - } - at += len; - } - } else { - self.bytes.extend_from_slice(bytes); - } - self.toks.push(Tok::Lit { - start: start as u32, - len: (self.bytes.len() - start) as u32, - has_slash: strings::contains_char(bytes, b'/'), - }); - } - - pub(crate) fn push_class(&mut self, class: Class) { - self.toks.push(Tok::Class(self.classes.len() as u32)); - self.classes.push(class); - } - - fn lit(&self, start: u32, len: u32) -> &[u8] { - let start = start as usize; - self.bytes - .get(start..start + len as usize) - .unwrap_or_default() - } - - fn lit_end(&self, wanted: &[u8], subject: Subject<'_>, mut pi: usize) -> LitEnd { - if !self.text.folds { - if subject - .bytes - .get(pi..) - .is_some_and(|rest| rest.starts_with(wanted)) - { - return LitEnd::At(pi + wanted.len()); - } - for (k, byte) in wanted.iter().enumerate() { - match subject.get(pi + k) { - None => return LitEnd::UsedUp, - Some(found) if found != *byte => return LitEnd::No, - Some(_) => {} - } - } - return LitEnd::At(pi + wanted.len()); - } - if self.is_ascii - && let Some(found) = subject.bytes.get(pi..pi + wanted.len()) - { - return match found.eq_ignore_ascii_case(wanted) { - true => LitEnd::At(pi + wanted.len()), - false => LitEnd::No, - }; - } - // Unit by unit, because a letter and its capital can differ in length. - let mut k = 0; - while let Some((unit, len)) = self.text.plain().next(Subject::of(wanted), k) { - match self.text.next(subject, pi) { - None => return LitEnd::UsedUp, - Some((found, _)) if found != unit => return LitEnd::No, - Some((_, found_len)) => pi += found_len, - } - k += len; - } - LitEnd::At(pi) - } - - /// Matches from the start of `subject`. `partial`: it is a directory with its `/`, and tokens may be left when it is used up. - #[inline] - pub(crate) fn run(&self, subject: Subject<'_>, partial: bool) -> bool { - (partial || self.can_match(subject)) && self.run_loop(subject, partial) - } - - /// Apart from `run`: most texts are turned away by `can_match`, and do not pay for what this sets up. - #[inline(never)] - fn run_loop(&self, subject: Subject<'_>, partial: bool) -> bool { - let (text, n) = (self.text, subject.len()); - let (mut ti, mut pi) = (0, 0); - // The token behind the `*`, and how far it has taken. - let mut star: Option<(usize, usize)> = None; - // The token behind the `**`, where the name starts that it would take next, `empty_names` and `newlines`. - let mut deep: Option<(usize, usize, bool, bool)> = None; - // Looked for when it is first asked for. - let mut last_terminator: Option> = None; - let last = || last_line_terminator(text, subject.bytes); - loop { - let Some(tok) = self.toks.get(ti) else { - return true; - }; - if partial && pi == n { - return true; - } - let ok = match *tok { - Tok::Lit { - start, - len, - has_slash, - } => match self.lit_end(self.lit(start, len), subject, pi) { - LitEnd::At(end) => { - pi = end; - // A `*` ends at the next `/` and nowhere else, so taking more cannot help. - if has_slash { - star = None; - } - true - } - LitEnd::UsedUp if partial => return true, - LitEnd::UsedUp | LitEnd::No => false, - }, - Tok::Any | Tok::Plus | Tok::Class(_) => match text.next(subject, pi) { - Some((unit, len)) if self.takes(*tok, unit) => { - pi += len; - if matches!(tok, Tok::Plus) { - star = Some((ti + 1, pi)); - } - true - } - _ => false, - }, - Tok::Star => { - star = Some((ti + 1, pi)); - true - } - Tok::Deep { - empty_names, - newlines, - } => { - if let Some((_, slashes)) = self.last_deep.filter(|it| it.0 as usize == ti) - && !partial - { - pi = pi.max(name_start_from_end(subject, slashes)); - } - deep = Some((ti + 1, pi, empty_names, newlines)); - star = None; - true - } - Tok::Rest { min, newlines } => { - let is_one_line = newlines - || last_terminator - .get_or_insert_with(last) - .is_none_or(|it| it < pi); - let ok = is_one_line && pi + usize::from(min) <= n; - if ok { - pi = n; - } - ok - } - Tok::Assert(assertion) => assertion.holds(subject, pi), - }; - if ok { - ti += 1; - continue; - } - if let Some((behind, taken)) = star { - match text.next(subject, taken) { - Some((unit, len)) if unit != u32::from(b'/') => { - let taken = match partial { - true => taken + len, - false => self.next_try(behind, subject.bytes, taken + len), - }; - star = Some((behind, taken)); - (ti, pi) = (behind, taken); - continue; - } - _ => star = None, - } - } - // The `**` takes one more name. - let Some((behind, name, empty_names, newlines)) = deep else { - return false; - }; - let Some(slash) = next_slash(subject, name) else { - return false; - }; - let taken = subject.bytes.get(name..slash).unwrap_or_default(); - let stops = !newlines - && last_terminator.get_or_insert_with(last).is_some() - && has_line_terminator(text, taken); - if taken.is_empty() && !empty_names || stops { - return false; - } - deep = Some((behind, slash + 1, empty_names, newlines)); - (ti, pi) = (behind, slash + 1); - } - } - - /// Where the token at `behind` can match next, from `from`: a `Lit` only where its first byte is. Not beyond a `/`. - #[inline] - fn next_try(&self, behind: usize, bytes: &[u8], from: usize) -> usize { - let Some(&Tok::Lit { start, .. }) = self.toks.get(behind) else { - return from; - }; - let Some(first) = self.bytes.get(start as usize).filter(|it| it.is_ascii()) else { - return from; - }; - let rest = bytes.get(from..).unwrap_or_default(); - let cannot_start = |it: &&u8| **it != b'/' && !it.eq_ignore_ascii_case(first); - from + rest.iter().take_while(cannot_start).count() - } - - /// Whether `tok`, which takes one unit, takes `unit`. - #[inline] - fn takes(&self, tok: Tok, unit: u32) -> bool { - match tok { - Tok::Class(index) => self - .classes - .get(index as usize) - .is_some_and(|it| it.has(unit)), - _ => unit != u32::from(b'/'), - } - } - - pub(crate) fn longest_literal(&self) -> &[u8] { - // It is searched for byte by byte. - if self.text.folds { - return b""; - } - let lits = self.toks.iter().filter_map(|tok| match tok { - Tok::Lit { start, len, .. } => Some(self.lit(*start, *len)), - _ => None, - }); - lits.max_by_key(|it| it.len()).unwrap_or_default() - } - - /// With folding only what is ASCII has a shape. - pub(crate) fn shape(&self) -> Shape<'_> { - let is_free = |tok: &Tok| { - matches!( - tok, - Tok::Deep { - empty_names: true, - newlines: true - } - ) - }; - let fits = |bytes: &[u8]| !self.text.folds || bytes.is_ascii(); - let under = match self.toks.first() { - Some(&Tok::Lit { - start, - len, - has_slash: true, - }) => { - let lit = self.lit(start, len); - let first = &lit[..strings::index_of_char_usize(lit, b'/').unwrap_or(0)]; - match fits(first) { - true => Shape::Under(first), - false => Shape::Other, - } - } - _ => Shape::Other, - }; - let [ - before @ .., - Tok::Assert(Assertion::End | Assertion::EndOrFinalSlash), - ] = &self.toks[..] - else { - return under; - }; - let lit = |tok: &Tok| match tok { - Tok::Lit { start, len, .. } => Some(self.lit(*start, *len)).filter(|it| fits(it)), - _ => None, - }; - let has_slash = |bytes: &[u8]| strings::contains_char(bytes, b'/'); - match before { - [deep, name] if is_free(deep) => match lit(name) { - Some(bytes) if !has_slash(bytes) => Shape::Name(bytes), - _ => Shape::Other, - }, - [deep, Tok::Star, suffix] if is_free(deep) => match lit(suffix) { - Some([b'.', bytes @ ..]) - if !bytes.is_empty() && !strings::contains_any(bytes, b"./") => - { - Shape::Extension(bytes) - } - _ => Shape::Other, - }, - [path] => match lit(path) { - Some(bytes) if !bytes.ends_with(b"/") => Shape::Path(bytes), - _ => under, - }, - _ => under, - } - } -} diff --git a/src/glob/matcher.rs b/src/glob/matcher.rs index 18c6d4b382f5..a023fe01db75 100644 --- a/src/glob/matcher.rs +++ b/src/glob/matcher.rs @@ -713,7 +713,7 @@ pub(crate) fn token_at(glob: &[u8], at: usize, has_extglob: bool) -> (Token, usi } /// Where the class that starts at `open` ends. -fn class_end(glob: &[u8], open: usize) -> Option { +pub(crate) fn class_end(glob: &[u8], open: usize) -> Option { let mut at = open + 1; at += usize::from(matches!(glob.get(at), Some(b'^' | b'!'))); let character = |at: usize| { diff --git a/src/glob/node.rs b/src/glob/node.rs deleted file mode 100644 index 81fc046c8bb0..000000000000 --- a/src/glob/node.rs +++ /dev/null @@ -1,549 +0,0 @@ -//! The one tree that all readers make, the pieces that two of them write it in, and `lower`, which picks the loop that runs it. - -use crate::class::Class; -use crate::linear::{self, Tok, Tokens}; -use crate::sets; -use crate::unit::{Subject, Text, Unit, push_utf8}; -use bun_core::strings; - -// ───────────────────────────── assertions ───────────────────────────── - -/// Each is decided by at most four bytes around the position. -#[derive(Copy, Clone, PartialEq, Eq)] -pub(crate) enum Assertion { - /// `^` - Start, - /// `$` - End, - /// `(?=$|\/$)`, and `\/?$` - EndOrFinalSlash, - /// `(?:$|\/)` at the end of a lookahead - EndOrSlash, - /// `(?!\.)` - NotDot, - /// `(?=.)` - SomeUnit, - /// `(?!\.{1,2}(?:\/|$))` - NotDots, - /// `(?!\.{0,1}(?:\/|$))` - NotDotOrEmpty, - /// `(?!(?:^|\/)\.{1,2}(?:\/|$))` - NotDotsBehindStart, - /// `(?!(?:^|\/)\.)` - NotDotBehindStart, - /// `\b` - WordBoundary, - /// `\B` - NotWordBoundary, -} - -fn ends_name(byte: Option) -> bool { - matches!(byte, None | Some(b'/')) -} - -fn is_word(byte: Option) -> bool { - byte.is_some_and(strings::is_regexp_word_byte) -} - -/// `.` or `..` and then a `/` or the end, from `at`. -fn dots_at(subject: Subject<'_>, at: usize) -> bool { - let second = subject.get(at + 1); - subject.get(at) == Some(b'.') - && (ends_name(second) || second == Some(b'.') && ends_name(subject.get(at + 2))) -} - -impl Assertion { - /// It is not written as a lookahead. - fn is_anchor(self) -> bool { - use Assertion::{End, NotWordBoundary, Start, WordBoundary}; - matches!(self, Start | End | WordBoundary | NotWordBoundary) - } - - #[inline] - pub(crate) fn holds(self, subject: Subject<'_>, at: usize) -> bool { - let here = subject.get(at); - let (is_slash, is_dot) = (here == Some(b'/'), here == Some(b'.')); - match self { - Assertion::Start => at == 0, - Assertion::End => here.is_none(), - Assertion::EndOrFinalSlash => { - here.is_none() || is_slash && subject.get(at + 1).is_none() - } - Assertion::EndOrSlash => ends_name(here), - Assertion::NotDot => !is_dot, - Assertion::SomeUnit => { - let rest = subject.bytes.get(at..).unwrap_or_default(); - here.is_some() && !bun_core::lexer::starts_with_line_break(rest) - } - Assertion::NotDots => !dots_at(subject, at), - Assertion::NotDotOrEmpty => { - !(ends_name(here) || is_dot && ends_name(subject.get(at + 1))) - } - Assertion::NotDotsBehindStart => { - !(at == 0 && dots_at(subject, 0) || is_slash && dots_at(subject, at + 1)) - } - Assertion::NotDotBehindStart => { - !(at == 0 && is_dot || is_slash && subject.get(at + 1) == Some(b'.')) - } - Assertion::WordBoundary => is_word(subject.before(at)) != is_word(here), - Assertion::NotWordBoundary => is_word(subject.before(at)) == is_word(here), - } - } -} - -// ───────────────────────────── the tree ───────────────────────────── - -/// What the references write as a regular expression, as values. It matches from the start. The end is `Assert(End)`. -#[derive(PartialEq, Eq)] -pub(crate) enum Node { - /// `$.`, `[]`, `$^` - Fail, - Empty, - Lit(Vec), - /// `[^/]` - Any, - /// `.` - Dot { - newlines: bool, - }, - Class(Class), - /// `[^/]*` - Star, - /// `[^/]+` - Plus, - /// Names, each with its `/`: `(?:.*\/)?`, or without `empty_names` `(?:[^/]+\/)*`. - Deep { - empty_names: bool, - newlines: bool, - }, - /// `.+`, `.*` up to the end. - Rest { - min: u8, - newlines: bool, - }, - Assert(Assertion), - Seq(Vec), - Alt(Vec), - /// `min` is 0 or 1. - Repeat { - node: Box, - min: u8, - unbounded: bool, - }, - Look { - negated: bool, - node: Box, - }, -} - -/// How deep a tree can be. The readers see to it. Whatever walks a tree recurses at most this deep, and a few levels that `lower` adds. -pub(crate) const MAX_NESTING: usize = 128; - -impl Node { - pub(crate) fn repeat(node: Node, min: u8, unbounded: bool) -> Node { - Node::Repeat { - node: Box::new(node), - min, - unbounded, - } - } - - /// A unit of UTF-16 as characters. - pub(crate) fn of_unit(unit: u32) -> Node { - let mut bytes = Vec::with_capacity(3); - push_utf8(&mut bytes, unit); - Node::Lit(bytes) - } -} - -// ───────────────────────────── pieces ───────────────────────────── - -#[derive(Copy, Clone, PartialEq, Eq)] -pub(crate) enum Open { - /// `(?:` - Group, - /// `(` - Capture, - /// `(?=` - Look, - /// `(?!` - NotLook, -} - -/// What minimatch and picomatch write from left to right is not always nested: they write pieces, and `nest` makes the tree. -#[derive(PartialEq, Eq)] -pub(crate) enum Piece { - /// A leaf. - Node(Node), - Open(Open), - Close, - Bar, - /// `?`, `*`, `+` behind an atom. - Quant { - min: u8, - unbounded: bool, - }, - /// `\1`: the group of that number if there is one, else `unit`. - Reference { - number: u32, - unit: u32, - }, -} - -/// A group that is open: the alternatives that are closed, and the one that is being written. -struct Frame { - kind: Open, - alternatives: Vec, - nodes: Vec, - /// A `?`, `*` or `+` can follow. - repeatable: bool, -} - -impl Frame { - fn new(kind: Open) -> Frame { - Frame { - kind, - alternatives: Vec::new(), - nodes: Vec::new(), - repeatable: false, - } - } - - fn close(mut self) -> Node { - self.alternatives.push(Node::Seq(self.nodes)); - match self.alternatives.pop() { - Some(only) if self.alternatives.is_empty() => only, - last => { - self.alternatives.extend(last); - Node::Alt(self.alternatives) - } - } - } -} - -/// `None`: it is no expression, or nested too deep. `Fail`: it refers to a group. No recursion: a stack of open groups. -pub(crate) fn nest(pieces: Vec) -> Option { - let is_capture = |piece: &&Piece| matches!(piece, Piece::Open(Open::Capture)); - let captures = pieces.iter().filter(is_capture).count(); - let mut stack: Vec = Vec::new(); - let mut top = Frame::new(Open::Group); - let mut refers = false; - for piece in pieces { - match piece { - Piece::Node(node) => { - // `^*` is refused, and so is `[^/]*?*`. An assertion that is written as a lookahead can be repeated. - top.repeatable = match &node { - Node::Assert(assertion) => !assertion.is_anchor(), - node => !matches!(node, Node::Star | Node::Plus), - }; - top.nodes.push(node); - } - Piece::Reference { number, unit } => { - // It is an expression, but not one that is followed. If what is left is one too. - refers |= number as usize <= captures; - top.nodes.push(Node::of_unit(unit)); - top.repeatable = true; - } - Piece::Open(kind) => { - if stack.len() >= MAX_NESTING { - return None; - } - stack.push(std::mem::replace(&mut top, Frame::new(kind))); - } - Piece::Bar => { - let nodes = std::mem::take(&mut top.nodes); - top.alternatives.push(Node::Seq(nodes)); - top.repeatable = false; - } - Piece::Close => { - let closed = std::mem::replace(&mut top, stack.pop()?); - let (kind, inner) = (closed.kind, closed.close()); - top.nodes.push(match kind { - Open::Group | Open::Capture => inner, - Open::Look | Open::NotLook => Node::Look { - negated: kind == Open::NotLook, - node: Box::new(inner), - }, - }); - top.repeatable = true; - } - Piece::Quant { min, unbounded } => { - if !top.repeatable { - return None; - } - top.repeatable = false; - let repeated = match top.nodes.pop()? { - // Only its last unit is repeated. - Node::Lit(mut bytes) => { - let last = Text::UTF16.prev(Subject::of(&bytes), bytes.len()); - let unit = bytes.split_off(bytes.len() - last.map_or(0, |it| it.1)); - if !bytes.is_empty() { - top.nodes.push(Node::Lit(bytes)); - } - Node::Lit(unit) - } - // A lookahead that is repeated holds once or is left out. - Node::Look { .. } | Node::Assert(_) if min == 0 => continue, - look @ (Node::Look { .. } | Node::Assert(_)) => { - top.nodes.push(look); - continue; - } - other => other, - }; - top.nodes.push(Node::repeat(repeated, min, unbounded)); - } - } - } - stack.is_empty().then(|| match refers { - true => Node::Fail, - false => top.close(), - }) -} - -// ───────────────────────────── lower ───────────────────────────── - -fn push_part(nodes: &mut Vec, part: Node) { - match part { - Node::Empty => {} - Node::Lit(bytes) => match nodes.last_mut() { - Some(Node::Lit(last)) => last.extend_from_slice(&bytes), - _ => nodes.push(Node::Lit(bytes)), - }, - part => nodes.push(part), - } -} - -/// Without `Seq` in `Seq`, `Empty`, and `Lit` beside `Lit`. A `Seq` with a `Fail` is a `Fail`, an `Alt` loses it. As deep as the tree. -pub(crate) fn simplify(node: Node) -> Node { - match node { - Node::Seq(children) => { - let mut nodes: Vec = Vec::with_capacity(children.len()); - for child in children { - match simplify(child) { - Node::Fail => return Node::Fail, - Node::Seq(parts) => parts.into_iter().for_each(|it| push_part(&mut nodes, it)), - part => push_part(&mut nodes, part), - } - } - match nodes.pop() { - None => Node::Empty, - Some(only) if nodes.is_empty() => only, - Some(last) => { - nodes.push(last); - Node::Seq(nodes) - } - } - } - Node::Alt(children) => { - let simplified = children.into_iter().map(simplify); - let mut nodes: Vec = simplified.filter(|it| !matches!(it, Node::Fail)).collect(); - match nodes.pop() { - None => Node::Fail, - Some(only) if nodes.is_empty() => only, - Some(last) => { - nodes.push(last); - Node::Alt(nodes) - } - } - } - Node::Repeat { - node, - min, - unbounded, - } => match simplify(*node) { - Node::Fail if min > 0 => Node::Fail, - Node::Fail | Node::Empty => Node::Empty, - it => Node::repeat(it, min, unbounded), - }, - Node::Look { negated, node } => match simplify(*node) { - // What it comes to if it holds nowhere, and if it holds everywhere. - Node::Fail if negated => Node::Empty, - Node::Fail => Node::Fail, - Node::Empty if negated => Node::Fail, - Node::Empty => Node::Empty, - it => Node::Look { - negated, - node: Box::new(it), - }, - }, - Node::Lit(bytes) if bytes.is_empty() => Node::Empty, - Node::Class(class) if class.is_slash() => Node::Lit(vec![b'/']), - other => other, - } -} - -fn is_optional_slash(node: &Node) -> bool { - matches!(node, Node::Repeat { node, min: 0, unbounded: false } if matches!(&**node, Node::Lit(bytes) if bytes == b"/")) -} - -/// The tokens of `nodes`, if the linear loop gives the right answer for them. -fn tokens_of(nodes: &[Node], text: Text) -> Option { - let mut out = Tokens::new(text); - let mut first_deep = None; - for (i, node) in nodes.iter().enumerate() { - let after = nodes.get(i + 1..).unwrap_or_default(); - match node { - // That loop compares bytes. - Node::Lit(bytes) if text.has_replacement(bytes) => return None, - Node::Lit(bytes) => out.push_lit(bytes), - Node::Any => out.push(Tok::Any), - Node::Star => out.push(Tok::Star), - Node::Plus => out.push(Tok::Plus), - // A `*` cannot take the `/` that the class could, so "only the last `*` counts" does not hold. - Node::Class(class) if class.takes_slash() => return None, - Node::Class(class) => out.push_class(class.clone()), - Node::Deep { - empty_names, - newlines, - } => { - // It stands where a name starts. Otherwise a `*` before it is thrown away while it has taken nothing. - let starts_name = match nodes.get(..i) { - Some([] | [Node::Assert(Assertion::Start)]) => true, - Some([.., Node::Lit(before)]) => before.ends_with(b"/"), - _ => false, - }; - if !starts_name { - return None; - } - let kind = (*empty_names, *newlines); - out.has_two_kinds_of_deep |= *first_deep.get_or_insert(kind) != kind; - out.push(Tok::Deep { - empty_names: *empty_names, - newlines: *newlines, - }); - } - Node::Rest { min, newlines } => { - // It does not go back, so what follows has to hold at the end. - let holds_at_end = |it: &Node| { - use Assertion::{End, EndOrFinalSlash, EndOrSlash}; - matches!(it, Node::Assert(End | EndOrFinalSlash | EndOrSlash)) - }; - if !after.iter().all(holds_at_end) { - return None; - } - out.push(Tok::Rest { - min: *min, - newlines: *newlines, - }); - return Some(out); - } - Node::Assert(assertion) => out.push(Tok::Assert(*assertion)), - // `\/?$` - _ if is_optional_slash(node) && matches!(after, [Node::Assert(Assertion::End)]) => { - out.push(Tok::Assert(Assertion::EndOrFinalSlash)); - return Some(out); - } - _ => return None, - } - } - Some(out) -} - -/// A tree that is ready to run. -pub(crate) enum Program { - Never, - Linear { - tokens: Tokens, - /// With two kinds of `**`, for a text with an empty name or a line terminator: there the last `**` is not enough. - fallback: Option>, - /// The same tree for the other loop, which has to give the same answers. - #[cfg(debug_assertions)] - check: Box, - }, - Sets { - program: sets::Program, - /// For `may_match_inside`. - forwards: Box, - }, -} - -pub(crate) fn lower(node: Node, text: Text) -> Program { - let nodes = match simplify(node) { - Node::Fail => return Program::Never, - Node::Empty => Vec::new(), - Node::Seq(nodes) => nodes, - it => vec![it], - }; - let Some(mut tokens) = tokens_of(&nodes, text) else { - return Program::Sets { - program: sets::Program::new(&nodes, text), - forwards: Box::new(sets::Program::new_forwards(&nodes, text)), - }; - }; - tokens.finish(); - let other_loop = || Box::new(sets::Program::new(&nodes, text)); - Program::Linear { - fallback: tokens.has_two_kinds_of_deep.then(other_loop), - tokens, - #[cfg(debug_assertions)] - check: other_loop(), - } -} - -fn has_empty_name_or_line_terminator(text: Text, subject: Subject<'_>) -> bool { - let bytes = subject.bytes; - strings::contains(bytes, b"//") - || subject.slash && bytes.ends_with(b"/") - || strings::contains_any(bytes, b"\n\r") - || text.unit != Unit::Byte - && (strings::contains(bytes, b"\xE2\x80\xA8") - || strings::contains(bytes, b"\xE2\x80\xA9")) -} - -impl Program { - pub(crate) fn is_never(&self) -> bool { - matches!(self, Program::Never) - } - - #[inline] - pub(crate) fn matches(&self, subject: Subject<'_>) -> bool { - match self { - Program::Never => false, - Program::Linear { - tokens, - fallback: Some(fallback), - .. - } if has_empty_name_or_line_terminator(tokens.text, subject) => fallback.run(subject), - Program::Linear { tokens, .. } => { - let hit = tokens.run(subject, false); - #[cfg(debug_assertions)] - { - if let Program::Linear { check, .. } = self { - debug_assert_eq!(hit, check.run(subject), "the two loops differ"); - } - } - hit - } - Program::Sets { program, .. } => program.run(subject), - } - } - - /// Whether something in `directory` can match. `directory.slash` is set. Never `false` if something can. - pub(crate) fn may_match_inside(&self, directory: Subject<'_>) -> bool { - match self { - Program::Never => false, - Program::Linear { tokens, .. } => tokens.run(directory, true), - Program::Sets { forwards, .. } => forwards.run_prefix(directory), - } - } - - /// The longest run of characters that is somewhere in whatever matches. Empty: it is not known. - pub(crate) fn longest_literal(&self) -> &[u8] { - match self { - Program::Linear { tokens, .. } => tokens.longest_literal(), - _ => b"", - } - } - - /// For the index of `ignore.rs`. - pub(crate) fn shape(&self) -> linear::Shape<'_> { - match self { - Program::Linear { - tokens, - fallback: None, - .. - } => tokens.shape(), - _ => linear::Shape::Other, - } - } -} diff --git a/src/glob/pattern.rs b/src/glob/pattern.rs index 9c1ddb414bfe..c5f3129b9bdb 100644 --- a/src/glob/pattern.rs +++ b/src/glob/pattern.rs @@ -1,384 +1,315 @@ -//! A pattern that has been read, in the syntax of Bun or of another tool. +//! A pattern that many paths are matched with: [`crate::match`], behind what tells most paths apart without it. -use crate::filtered::{Filtered, last_name_at}; -use crate::node::Program; -use crate::read_ignore; -use crate::read_minimatch3; -use crate::read_picomatch; -use crate::segments::{self, Expansion}; -use crate::unit::{Subject, Text, well_formed}; +use crate::matcher::{self, Token}; use bun_core::strings; -use std::borrow::Cow; +use std::ops::Range; -pub use crate::segments::Candidate; - -/// Whose patterns these are. -#[derive(Copy, Clone, PartialEq, Eq, Debug)] -pub enum Syntax { - /// [`crate::match`]: `Bun.Glob`, and `glob_match` of the crate `fast-glob`. - Bun, - /// minimatch 10.2.6. - Minimatch, - /// picomatch 2.3.2, which micromatch 4.0.8 and fast-glob 3.3.3 call. - Picomatch, - /// minimatch 3.1.5: `.*` takes `.` and `..`, `a/../b` is not folded, the pattern is trimmed. - Minimatch3, - /// `makeRe(pattern).test(path)` of minimatch 3.1.5: nothing is cut at a `/`, and a `**` keeps the `/` behind it. - Minimatch3MakeRe, -} - -#[derive(Copy, Clone, PartialEq, Eq, Debug)] -pub struct Options { - pub syntax: Syntax, - /// `dot`: `*`, `?`, `**` and a class take a `.` at the start of a name. `Syntax::Bun` does not read it: there they do. - pub dot: bool, - /// `posix: true` of picomatch: `[!a]` is `[^a]`. - pub posix: bool, - /// Braces are expanded before the pattern is read, as fast-glob does. minimatch always does. - pub expands_braces_first: bool, - /// `nocomment` of minimatch: a `#` at the start is a character. - pub nocomment: bool, - /// `nonegate` of minimatch: so is a `!`. - pub nonegate: bool, - /// These four are read for minimatch 3.1.5 only. `nocase`: the flag `i`. - pub nocase: bool, - /// `noext`: `+(` is two characters. - pub noext: bool, - /// `nobrace`: braces are characters. - pub nobrace: bool, - /// `noglobstar`: `**` is `*`. - pub noglobstar: bool, +/// A path that many patterns are matched with. Separated by `/`. +#[derive(Copy, Clone)] +pub struct Candidate<'p> { + path: &'p [u8], + /// Where the last name starts. A `/` at the end of the path belongs to it. + name_at: usize, } -impl Options { - /// `bun_glob::match(pattern, path)`. - pub const BUN: Options = Options { - syntax: Syntax::Bun, - dot: true, - posix: false, - expands_braces_first: false, - nocomment: false, - nonegate: false, - nocase: false, - noext: false, - nobrace: false, - noglobstar: false, - }; - /// `new Minimatch(pattern, { dot: true })`: @eslint/config-array, the command line of ESLint, `.editorconfig`. - pub const MINIMATCH_DOT: Options = Options { - syntax: Syntax::Minimatch, - dot: true, - posix: false, - expands_braces_first: true, - nocomment: false, - nonegate: false, - nocase: false, - noext: false, - nobrace: false, - noglobstar: false, - }; - /// `minimatch(path, pattern)`: @trivago/prettier-plugin-sort-imports. - pub const MINIMATCH: Options = Options { - dot: false, - ..Options::MINIMATCH_DOT - }; - /// `minimatch(path, pattern, { dot: true })` of 3.1.5: eslint-plugin-import. - pub const MINIMATCH_3_DOT: Options = Options { - syntax: Syntax::Minimatch3, - ..Options::MINIMATCH_DOT - }; - /// `minimatch(path, pattern)` of 3.1.5: eslint-plugin-import. - pub const MINIMATCH_3: Options = Options { - dot: false, - ..Options::MINIMATCH_3_DOT - }; - /// `minimatch.makeRe(pattern).test(path)`: import/no-internal-modules. - pub const MINIMATCH_3_MAKE_RE: Options = Options { - syntax: Syntax::Minimatch3MakeRe, - ..Options::MINIMATCH - }; - /// `micromatch.isMatch(path, pattern, { dot: true })`: `overrides` of Prettier. - pub const MICROMATCH_DOT: Options = Options { - syntax: Syntax::Picomatch, - dot: true, - posix: false, - expands_braces_first: false, - nocomment: false, - nonegate: false, - nocase: false, - noext: false, - nobrace: false, - noglobstar: false, - }; - /// What fast-glob makes of a pattern with `{ dot: true }`: the command line of Prettier. - pub const FAST_GLOB_DOT: Options = Options { - posix: true, - expands_braces_first: true, - ..Options::MICROMATCH_DOT - }; -} +impl<'p> Candidate<'p> { + pub fn new(path: &'p [u8]) -> Candidate<'p> { + let names = path.strip_suffix(b"/").unwrap_or(path); + Candidate { + path, + name_at: strings::last_index_of_char(names, b'/').map_or(0, |slash| slash + 1), + } + } -/// How a path is matched. -#[derive(Copy, Clone, Default, PartialEq, Eq, Debug)] -pub struct How { - /// `flipNegate` of minimatch: a `!` at the start of the pattern is left out of the answer. - pub flip_negate: bool, - /// `match(path, true)` of minimatch: something in the directory `path` can match. Never `false` where the reference says `true`. - pub partial: bool, -} + /// The same for `path`: this one, which is empty or ends with a `/`, then a name, and a `/` or not. + pub fn entry<'e>(&self, path: &'e [u8]) -> Candidate<'e> { + let name_at = self.path.len(); + debug_assert_eq!(name_at, Candidate::new(path).name_at); + Candidate { path, name_at } + } -/// A pattern of picomatch, without braces if they are expanded first. -struct Alternative { - written: Box<[u8]>, - program: Program, - is_negated: bool, - /// `written` has U+FFFD, which a bad byte of a path is too. - has_replacement: bool, -} + /// The path as it is. + pub fn path(&self) -> &'p [u8] { + self.path + } -impl Alternative { - /// Whether `path` is the same string as the pattern, which is well formed. - #[inline] - fn is_written(&self, path: &[u8]) -> bool { - path == &self.written[..] || self.has_replacement && well_formed(path) == &self.written[..] + /// Its last name. + pub fn name(&self) -> &'p [u8] { + &self.path[self.name_at..] } -} -enum Kind { - /// It can match nothing: a comment of minimatch, too long. - Never, - /// The empty pattern of minimatch, which matches the empty path. - Empty, - Bun(Filtered), - /// One for each expansion of the braces, and the option `dot`. - Minimatch(Vec, bool), - /// `is_asked_directly`: as fast-glob asks the expressions themselves, without what `picomatch.test` puts before them. - Picomatch { - alternatives: Vec, - is_asked_directly: bool, - }, + /// Without the `/` at its end, if it has one. + pub fn without_slash(&self) -> Option> { + let path = self.path.strip_suffix(b"/")?; + Some(Candidate { path, ..*self }) + } } -/// A pattern that has been read. `Send + Sync`. Matching allocates only for a group on a long path. +/// A pattern that has been read. `Send + Sync`. pub struct Pattern { - kind: Kind, + pattern: Box<[u8]>, + has_extglob: bool, /// An odd number of `!` at its start. is_negated: bool, + /// What matches starts with one of these, up to a `/` or to its end. Empty: it is not known. + heads: Box<[Box<[u8]>]>, + /// What matches starts with this much of the pattern, and ends with this much of it. + start_len: u32, + tail_len: u32, + /// This of the pattern is somewhere in what matches. + inner: Range, + /// `**/name`: only the last name of a path counts. + is_for_name: bool, + /// The names at the start, each of which takes one name of a path. + names: Box<[Range]>, + /// `names` are all of it. + is_names_only: bool, } -/// minimatch and picomatch throw beyond it. They count UTF-16 units. -pub(crate) const MAX_PATTERN_LENGTH: usize = 65_536; - -fn bun(pattern: &[u8], has_extglob: bool) -> Pattern { - let read = Filtered::read(pattern, has_extglob); - Pattern { - is_negated: read.is_negated(), - kind: Kind::Bun(read), - } +/// `[jt]`, `[a-z]`: letters and digits, so no `/`. +fn is_plain_class(class: &[u8]) -> bool { + let inside = &class[1..class.len() - 1]; + let is_bound = |at: usize| inside.get(at).is_some_and(u8::is_ascii_alphanumeric); + (0..inside.len()).all(|at| { + is_bound(at) || inside[at] == b'-' && at > 0 && is_bound(at - 1) && is_bound(at + 1) + }) } -impl Pattern { - /// Never fails. What the reference refuses, or what is beyond a limit, matches nothing. - pub fn new(pattern: &[u8], options: Options) -> Pattern { - let of = |kind: Kind| Pattern { - kind, - is_negated: false, - }; - let pattern: &[u8] = &match options.syntax { - Syntax::Bun => Cow::Borrowed(pattern), - _ => well_formed(pattern), - }; - let pattern = match options.syntax { - Syntax::Minimatch3 => strings::trim_js_whitespace(pattern), - _ => pattern, - }; - match options.syntax { - Syntax::Bun => bun(pattern, false), - _ if pattern.len() > MAX_PATTERN_LENGTH => of(Kind::Never), - Syntax::Minimatch | Syntax::Minimatch3 - if pattern.starts_with(b"#") && !options.nocomment => - { - of(Kind::Never) - } - Syntax::Minimatch | Syntax::Minimatch3 if pattern.is_empty() => of(Kind::Empty), - Syntax::Minimatch | Syntax::Minimatch3 => { - let bangs = (pattern.iter()) - .take_while(|b| **b == b'!' && !options.nonegate) - .count(); - Pattern { - kind: Kind::Minimatch(segments::read(&pattern[bangs..], options), options.dot), - is_negated: bangs % 2 == 1, - } - } - // The `!` is in the expression. - Syntax::Minimatch3MakeRe => of(Kind::Picomatch { - alternatives: vec![Alternative { - written: Box::default(), - program: read_minimatch3::pattern(pattern, options), - is_negated: false, - has_replacement: false, - }], - is_asked_directly: true, - }), - // picomatch throws for the empty pattern. - Syntax::Picomatch if pattern.is_empty() => of(Kind::Never), - Syntax::Picomatch => { - let written = match options.expands_braces_first { - true => read_picomatch::expand_for_fast_glob(pattern), - false => vec![pattern.to_vec()], - }; - let alternative = |written: Vec| { - let (program, is_negated) = - read_picomatch::pattern(&written, options.dot, options.posix); - Alternative { - has_replacement: Text::UTF16.has_replacement(&written), - written: written.into(), - program, - is_negated, - } - }; - let alternatives: Vec = written.into_iter().map(alternative).collect(); - Pattern { - is_negated: matches!(&alternatives[..], [only] if only.is_negated), - kind: Kind::Picomatch { - alternatives, - is_asked_directly: options.expands_braces_first, - }, - } +/// Whether what `part` matches is in one name. `is_in_group`: a group in it is not looked into. +fn stays_in_a_name(part: &[u8], has_extglob: bool, is_in_group: bool) -> bool { + let mut at = 0; + while at < part.len() { + let (token, end) = matcher::token_at(part, at, has_extglob); + let stays = match token { + Token::Literal => part[at] != b'/', + Token::Star | Token::Any => true, + Token::Class => is_plain_class(&part[at..end]), + // A `,` or a `|` is a character there, which changes nothing. + Token::Group { first } if !is_in_group => { + stays_in_a_name(&part[first..end - 1], has_extglob, true) } + _ => false, + }; + if !stays { + return false; } + at = end; } + true +} - /// [`crate::matcher::match_with`] with `EXTGLOB`. - pub fn with_extglob(pattern: &[u8]) -> Pattern { - bun(pattern, true) +impl Pattern { + /// `match(pattern, path)`. + pub fn new(pattern: &[u8]) -> Pattern { + Pattern::read(pattern, false) } - /// `Glob::new(written)` of globset 0.4.18: a `*` crosses `/`. `None`: it is refused. A section of `.editorconfig` for oxfmt. - pub fn of_globset(written: &[u8]) -> Option { - Some(Pattern { - kind: Kind::Picomatch { - alternatives: vec![Alternative { - written: Box::default(), - program: read_ignore::globset_matcher(written)?, - is_negated: false, - has_replacement: false, - }], - is_asked_directly: true, - }, - is_negated: false, - }) + /// `match_with::(pattern, path)`. + pub fn with_extglob(pattern: &[u8]) -> Pattern { + Pattern::read(pattern, true) } /// `GlobSet::new` of oxc for one element: `./` is dropped, a pattern without `/` is for a name in any directory. pub fn of_oxc_glob_set(written: &[u8]) -> Pattern { match written.strip_prefix(b"./") { - Some(rest) => bun(rest, false), - None if strings::contains_char(written, b'/') => bun(written, false), - None => bun(&[b"**/", written].concat(), false), + Some(rest) => Pattern::new(rest), + None if strings::contains_char(written, b'/') => Pattern::new(written), + None => Pattern::new(&[b"**/", written].concat()), } } - /// `path` is separated by `/`. - pub fn matches(&self, path: &[u8]) -> bool { - self.matches_with(path, How::default()) + fn read(pattern: &[u8], has_extglob: bool) -> Pattern { + let mut read = Pattern { + pattern: pattern.into(), + has_extglob, + is_negated: false, + heads: Box::default(), + start_len: 0, + tail_len: 0, + inner: 0..0, + is_for_name: false, + names: Box::default(), + is_names_only: false, + }; + let bangs = matcher::negations(pattern, has_extglob); + if bangs > 0 { + read.is_negated = bangs % 2 == 1; + return read; + } + // The runs of characters without magic, and where the names start that are outside of all groups. + let mut runs: Vec> = Vec::new(); + let mut starts = vec![0]; + let mut first_group = None; + let (mut at, mut is_in_run, mut is_known) = (0, false, true); + while at < pattern.len() { + let (token, mut end) = matcher::token_at(pattern, at, has_extglob); + match token { + Token::Literal => { + match runs.last_mut().filter(|_| is_in_run) { + Some(run) => run.end = end, + None => runs.push(at..end), + } + if pattern[at] == b'/' { + starts.push(end); + } + } + // The `/` behind it can be nothing. + Token::Globstar if pattern.get(end) == Some(&b'/') => { + end += 1; + starts.push(end); + } + Token::Group { first } if first_group.is_none() => { + first_group = Some((at, first, end)); + } + Token::Unknown => is_known = false, + _ => {} + } + is_in_run = token == Token::Literal; + at = end; + } + if !is_known { + return read; + } + if is_in_run && let Some(run) = runs.last() { + read.tail_len = run.len() as u32; + } + if let Some(run) = runs.first().filter(|it| it.start == 0) { + read.start_len = run.end as u32; + } + let between = (runs.iter()).filter(|it| it.start > 0 && it.end < pattern.len()); + if let Some(run) = between.max_by_key(|it| it.len()) { + read.inner = run.start as u32..run.end as u32; + } + read.heads = heads(pattern, &runs, first_group, has_extglob); + + let stays = + |part: &[u8]| !part.starts_with(b"!") && stays_in_a_name(part, has_extglob, false); + let ends = (starts.iter().skip(1).map(|it| it - 1)).chain([pattern.len()]); + let parts = starts.iter().zip(ends).map(|(start, end)| *start..end); + let names = parts.take_while(|it| stays(&pattern[it.clone()])); + read.names = names.map(|it| it.start as u32..it.end as u32).collect(); + read.is_names_only = read.names.len() == starts.len(); + read.is_for_name = pattern.strip_prefix(b"**/").is_some_and(stays); + read } - /// `matches_with` with the option `matchBase` of minimatch. - pub fn matches_base_with(&self, path: &[u8], how: How) -> bool { - match &self.kind { - Kind::Minimatch(set, dot) if !(how.partial && path == b"/") => { - let hit = segments::matches_base(set, &Candidate::new(path), how.partial, *dot); - hit != (self.is_negated && !how.flip_negate) - } - _ => self.matches_with(path, how), + #[inline] + fn asks(&self, pattern: &[u8], path: &[u8]) -> matcher::MatchResult { + match self.has_extglob { + true => matcher::match_with::(pattern, path), + false => matcher::r#match(pattern, path), } } - pub fn matches_with(&self, path: &[u8], how: How) -> bool { - match &self.kind { - // `match("/", true)` - Kind::Minimatch(..) if how.partial && path == b"/" => true, - Kind::Minimatch(..) | Kind::Empty => self.matches_candidate(&Candidate::new(path), how), - _ => self.matches_path(path, how), + /// `path` is separated by `/`. + pub fn matches(&self, path: &[u8]) -> bool { + match self.is_for_name { + true => self.matches_candidate(&Candidate::new(path)), + false => self.matches_candidate(&Candidate { path, name_at: 0 }), } } #[inline] - pub fn matches_candidate(&self, path: &Candidate<'_>, how: How) -> bool { - match &self.kind { - Kind::Empty => path.path().is_empty(), - Kind::Minimatch(set, dot) => { - let hit = segments::matches(set, path, how.partial, *dot); - hit != (self.is_negated && !how.flip_negate) - } - Kind::Bun(read) if !how.partial => { - read.matches(path.path(), || path.name_at()) != (self.is_negated && how.flip_negate) - } - _ => self.matches_path(path.path(), how), - } + pub fn matches_candidate(&self, candidate: &Candidate<'_>) -> bool { + let path = candidate.path; + // Most tails are a few bytes, which are compared without a call. + let tail = self.pattern.iter().rev().take(self.tail_len as usize); + let ends_with_tail = path.len() >= self.tail_len as usize + && path.iter().rev().zip(tail).all(|(a, b)| a == b); + ends_with_tail && self.matches_whole(candidate) } - /// For a syntax that does not split the path. - fn matches_path(&self, path: &[u8], how: How) -> bool { - match &self.kind { - Kind::Bun(read) if how.partial => read.may_match_inside(path), - Kind::Bun(read) => { - read.matches(path, || last_name_at(path)) != (self.is_negated && how.flip_negate) - } - Kind::Picomatch { alternatives, .. } if how.partial => { - let directory = Subject { - bytes: path, - slash: !path.is_empty() && !path.ends_with(b"/"), - }; - alternatives - .iter() - .any(|it| it.is_negated || it.program.may_match_inside(directory)) + fn matches_whole(&self, candidate: &Candidate<'_>) -> bool { + let path = candidate.path; + let inner = &self.pattern[self.inner.start as usize..self.inner.end as usize]; + if !path.starts_with(&self.pattern[..self.start_len as usize]) + || !(inner.is_empty() || strings::contains(path, inner)) + { + return false; + } + match path.last() { + Some(last) if self.is_for_name && *last != b'/' => { + (self.asks(&self.pattern[3..], &path[candidate.name_at..])).matches() } - // `picomatch.test`: the empty path matches nothing. - Kind::Picomatch { - is_asked_directly: false, - .. - } if path.is_empty() => false, - Kind::Picomatch { - alternatives, - is_asked_directly, - } => alternatives.iter().any(|it| { - // `picomatch.test`: a path that is the pattern, letter for letter, matches, whatever the pattern means. - if !is_asked_directly && it.is_written(path) { - return true; - } - let hit = it.program.matches(Subject::of(path)); - match it.is_negated && !how.flip_negate { - // `^(?!^(?:..)$).*$`: `.` takes no line terminator. - true => !hit && !strings::contains_js_line_break(path), - false => hit, - } - }), - Kind::Never | Kind::Empty | Kind::Minimatch(..) => false, + _ => self.asks(&self.pattern, path).matches(), } } - /// `matches_with(directory, How { partial: true, flip_negate: false })`. + /// Whether something in `directory` can match. Never `false` if something can. pub fn may_match_inside(&self, directory: &[u8]) -> bool { - self.matches_with( - directory, - How { - partial: true, - flip_negate: false, - }, - ) + if self.is_negated || directory.is_empty() { + return true; + } + let mut names = self.names.iter(); + for name in strings::split(directory, b"/") { + match names.next() { + Some(it) => { + let part = &self.pattern[it.start as usize..it.end as usize]; + if !self.asks(part, name).matches() { + return false; + } + } + None => return !self.is_names_only, + } + } + // What is in it has one name more. + !self.is_names_only || names.next().is_some() } /// What a path that matches starts with, up to a `/` or to its end: one of these. `None`: the pattern does not say. pub fn heads(&self) -> Option> { - let heads: Vec<&[u8]> = match &self.kind { - _ if self.is_negated => return None, - Kind::Bun(read) => return read.heads(), - Kind::Minimatch(set, _) => set.iter().map(Expansion::head).collect(), - _ => return None, - }; - heads.iter().all(|it| !it.is_empty()).then_some(heads) + (!self.heads.is_empty()).then(|| self.heads.iter().map(|it| &it[..]).collect()) + } +} + +/// [`Pattern::heads`]. `runs`: the characters without magic. `group`: where the first group starts, its first alternative, where it ends. +fn heads( + pattern: &[u8], + runs: &[Range], + group: Option<(usize, usize, usize)>, + has_extglob: bool, +) -> Box<[Box<[u8]>]> { + let run_from = |at: usize| { + runs.iter() + .find(|it| it.start == at) + .map_or(at..at, Range::clone) + }; + let start = run_from(0); + // All of a name, and so up to the last `/`, unless the pattern ends there. + let whole_names = |text: Vec, is_all: bool| match is_all { + true => text, + false => text[..strings::last_index_of_char(&text, b'/').unwrap_or(0)].to_vec(), + }; + let mut found = Vec::new(); + match group { + // `{a,b}/..`, `@(a|b)/..` + Some((at, first, end)) if at == start.end && matches!(pattern[at], b'{' | b'@') => { + let after = run_from(end); + let separator: &[u8] = if pattern[at] == b'{' { b"," } else { b"|" }; + let inside = &pattern[first..end - 1]; + let mut tokens = + (0..inside.len()).map(|it| matcher::token_at(inside, it, has_extglob).0); + if !tokens.all(|it| it == Token::Literal) { + return Box::default(); + } + for alternative in strings::split(inside, separator) { + let text = [ + &pattern[start.clone()], + alternative, + &pattern[after.clone()], + ] + .concat(); + found.push(whole_names(text, after.end == pattern.len())); + } + } + _ => found.push(whole_names( + pattern[start.clone()].to_vec(), + start.end == pattern.len(), + )), + } + match found.iter().all(|it| !it.is_empty()) { + true => found.into_iter().map(Vec::into_boxed_slice).collect(), + false => Box::default(), } } diff --git a/src/glob/read_ignore.rs b/src/glob/read_ignore.rs deleted file mode 100644 index 692421773877..000000000000 --- a/src/glob/read_ignore.rs +++ /dev/null @@ -1,922 +0,0 @@ -//! A line of an ignore file becomes a tree: three grammars for five flavours. It never sees a path. - -use crate::class::{self, Class, Escape, Read, has_at}; -use crate::ignore::{IgnoreOptions, IgnoreSyntax}; -use crate::node::{Assertion, MAX_NESTING, Node, Program, lower, simplify}; -use crate::unit::{Subject, Text, Unit, push_utf8, well_formed}; -use bun_core::strings; - -pub(crate) struct Line { - pub(crate) node: Node, - pub(crate) is_negated: bool, - /// The tree is for the path without a `/` behind it. - pub(crate) is_for_directories: bool, -} - -const CODE_POINTS: Text = Text { - unit: Unit::CodePoint, - folds: false, -}; - -pub(crate) fn text_for(options: IgnoreOptions) -> Text { - Text { - unit: match options.syntax { - IgnoreSyntax::Globset => Unit::Byte, - _ => Unit::Utf16, - }, - folds: options.ignores_case, - } -} - -/// `Ok(None)`: the line says nothing: a comment, blank. `Err`: `Globset` does not take it, and why. -pub(crate) fn line(bytes: &[u8], options: IgnoreOptions) -> Result, Vec> { - match options.syntax { - IgnoreSyntax::Globset => globset_line(bytes, options.ignores_case), - IgnoreSyntax::Npm7012 => Ok(wildmatch_line(&well_formed(bytes), options)), - IgnoreSyntax::Npm5 | IgnoreSyntax::Npm705 => Ok(legacy_line(&well_formed(bytes), options)), - } -} - -fn slash() -> Node { - Node::Lit(vec![b'/']) -} - -/// `(?:^|\/)`, `(?:/?|.*/)`: any names. -const ANY_NAMES: Node = Node::Deep { - empty_names: true, - newlines: true, -}; - -/// `(?:[^/]+\/)*`: names that are not empty. -const FULL_NAMES: Node = Node::Deep { - empty_names: false, - newlines: true, -}; - -fn to_the_end(min: u8, newlines: bool) -> Node { - Node::Rest { min, newlines } -} - -/// `.*`, over line terminators too. -fn anything() -> Node { - Node::repeat(Node::Dot { newlines: true }, 0, true) -} - -// ───────────────────────────── globset: `GitignoreBuilder::add_line` of `ignore` 0.4.33, `Parser` of globset 0.4.18 ───────────────────────────── - -fn globset_line(mut line: &[u8], folds: bool) -> Result, Vec> { - if line.starts_with(b"#") { - return Ok(None); - } - if !line.ends_with(b"\\ ") { - line = bun_core::strings::trim_unicode_whitespace_end(line); - } - if line.is_empty() { - return Ok(None); - } - let (mut is_negated, mut is_absolute, mut is_for_directories) = (false, false, false); - if line.starts_with(b"\\!") || line.starts_with(b"\\#") { - line = &line[1..]; - } else { - if let Some(rest) = line.strip_prefix(b"!") { - is_negated = true; - line = rest; - } - if let Some(rest) = line.strip_prefix(b"/") { - is_absolute = true; - line = rest; - } - } - if let Some(rest) = line.strip_suffix(b"/") { - is_for_directories = true; - line = rest.strip_suffix(b"\\").unwrap_or(rest); - } - let is_for_names = !is_absolute && !strings::contains_char(line, b'/'); - let mut actual = Vec::with_capacity(line.len() + 5); - if is_for_names && !line.starts_with(b"**/") && line != b"**" { - actual.extend_from_slice(b"**/"); - } - actual.extend_from_slice(line); - // What is in a directory, and not the directory. - if actual.ends_with(b"/**") { - actual.extend_from_slice(b"/*"); - } - Ok(Some(Line { - node: globset_glob(&actual, Builder::OfIgnore { folds })?, - is_negated, - is_for_directories, - })) -} - -/// `Token` of globset, as nodes at once. The three of `**` are kept apart, because `parse_star` looks back at them. -enum Token { - Node(Node), - /// `(?:/?|.*/)` - RecursivePrefix, - /// `/.*` - RecursiveSuffix, - /// `(?:/|/.*/)` - RecursiveZeroOrMore, -} - -/// How deep `{` can be nested. globset has no such limit. -const MAX_ALTERNATES: usize = MAX_NESTING / 2; - -fn nodes_of_globset(tokens: Vec) -> Node { - let mut nodes = Vec::with_capacity(tokens.len()); - for token in tokens { - match token { - Token::Node(node) => nodes.push(node), - Token::RecursivePrefix => nodes.push(ANY_NAMES), - Token::RecursiveSuffix => nodes.extend([slash(), anything()]), - Token::RecursiveZeroOrMore => nodes.extend([slash(), ANY_NAMES]), - } - } - Node::Seq(nodes) -} - -/// `Parser` of globset: where it is in the pattern. -struct Chars<'g> { - glob: &'g [u8], - at: usize, - prev: Option, - cur: Option, -} - -impl Chars<'_> { - fn peek(&self) -> Option { - Some(CODE_POINTS.next(Subject::of(self.glob), self.at)?.0) - } - - fn bump(&mut self) -> Option { - self.prev = self.cur; - let next = CODE_POINTS.next(Subject::of(self.glob), self.at); - self.at += next.map_or(0, |it| it.1); - self.cur = next.map(|it| it.0); - self.cur - } -} - -fn character(c: u32) -> Token { - let mut bytes = Vec::with_capacity(4); - push_utf8(&mut bytes, c); - Token::Node(Node::Lit(bytes)) -} - -/// How `GlobBuilder` is set. -#[derive(Copy, Clone, PartialEq)] -enum Builder { - /// `.literal_separator(true).backslash_escape(true).allow_unclosed_class(true)` - OfIgnore { folds: bool }, - /// `Glob::new`: `*`, `?` take a `/`, a class that is not closed is refused. - Default, -} - -/// `Glob::new(glob).ok().map(|it| it.compile_matcher())` -pub(crate) fn globset_matcher(glob: &[u8]) -> Option { - let text = Text { - unit: Unit::Byte, - folds: false, - }; - Some(lower(globset_glob(glob, Builder::Default).ok()?, text)) -} - -fn globset_glob(glob: &[u8], builder: Builder) -> Result> { - let is_default = builder == Builder::Default; - let folds = builder == Builder::OfIgnore { folds: true }; - const SLASH: Option = Some(b'/' as u32); - const STAR: Option = Some(b'*' as u32); - const COMMA: Option = Some(b',' as u32); - const OPEN: Option = Some(b'{' as u32); - const CLOSE: Option = Some(b'}' as u32); - let star = || { - Token::Node(match is_default { - true => anything(), - false => Node::Star, - }) - }; - // The lists of tokens that are closed, and the one that is being written. `alternates`: where in `branches` each open group starts. - let mut branches: Vec> = Vec::new(); - let mut branch: Vec = Vec::new(); - let mut alternates: Vec = Vec::new(); - let mut found_unclosed_class = false; - let mut chars = Chars { - glob, - at: 0, - prev: None, - cur: None, - }; - loop { - let start = chars.at; - let Some(c) = chars.bump() else { - break; - }; - match u8::try_from(c).unwrap_or(0) { - b'?' if is_default => branch.push(Token::Node(Node::Dot { newlines: true })), - b'?' => branch.push(Token::Node(Node::Any)), - b'*' => { - let before = chars.prev; - if chars.peek() != STAR { - branch.push(star()); - continue; - } - chars.bump(); - let after = chars.peek(); - let is_in_group = !branches.is_empty(); - if branch.is_empty() { - if after.is_some() && after != SLASH { - branch.push(star()); - } else { - branch.push(Token::RecursivePrefix); - chars.bump(); - } - continue; - } - if before != SLASH && !(is_in_group && (before == COMMA || before == OPEN)) { - branch.push(star()); - continue; - } - let is_suffix = if after.is_none() || after == SLASH { - chars.bump(); - after.is_none() - } else if is_in_group && (after == COMMA || after == CLOSE) { - true - } else { - branch.push(star()); - continue; - }; - // The `/` before it, or a `**` of which this is one more. - let last = branch.pop(); - branch.push(match last { - Some(it @ (Token::RecursivePrefix | Token::RecursiveSuffix)) => it, - _ if is_suffix => Token::RecursiveSuffix, - _ => Token::RecursiveZeroOrMore, - }); - } - // No `]` follows the first one that is not closed, so none follows this one. - b'[' if found_unclosed_class => branch.push(character(c)), - b'[' => match class::globset(glob, start, folds)? { - Read::Class { class, len, .. } => { - branch.push(Token::Node(Node::Class(class))); - // As `bump` leaves them behind the `]`. - chars.at = start + len; - chars.prev = None; - chars.cur = Some(u32::from(b']')); - } - Read::Never => return Ok(Node::Fail), - Read::NotAClass | Read::One { .. } if is_default => { - return Err(b"unclosed character class; missing ']'".to_vec()); - } - Read::NotAClass | Read::One { .. } => { - found_unclosed_class = true; - branch.push(character(c)); - } - }, - b'{' => { - if alternates.len() >= MAX_ALTERNATES { - return Ok(Node::Fail); - } - branches.push(std::mem::take(&mut branch)); - alternates.push(branches.len()); - } - b'}' => { - let Some(first) = alternates.pop() else { - return Err( - b"unopened alternate group; missing '{' (maybe escape '}' with '[}]'?)" - .to_vec(), - ); - }; - branches.push(std::mem::take(&mut branch)); - // An alternative that is written as nothing is dropped: `empty_alternates` is off. - let group = branches.split_off(first).into_iter(); - let nodes: Vec = group - .map(|it| simplify(nodes_of_globset(it))) - .filter(|it| !matches!(it, Node::Empty)) - .collect(); - branch = branches.pop().unwrap_or_default(); - branch.push(Token::Node(match nodes.is_empty() { - true => Node::Empty, - false => Node::Alt(nodes), - })); - } - b',' if !alternates.is_empty() => branches.push(std::mem::take(&mut branch)), - b'\\' => match chars.bump() { - Some(escaped) => branch.push(character(escaped)), - None => return Err(b"dangling '\\'".to_vec()), - }, - _ => branch.push(character(c)), - } - } - if !branches.is_empty() { - return Err( - b"unclosed alternate group; missing '}' (maybe escape '{' with '[{]'?)".to_vec(), - ); - } - // The whole of it is `**`: everything. - if matches!(branch[..], [Token::RecursivePrefix]) { - return Ok(to_the_end(0, true)); - } - Ok(Node::Seq(vec![ - nodes_of_globset(branch), - Node::Assert(Assertion::End), - ])) -} - -// ───────────────────────────── what all three versions of the package leak ───────────────────────────── - -/// `$.|*+(){^` -fn is_leakable(c: u8) -> bool { - matches!( - c, - b'$' | b'.' | b'|' | b'*' | b'+' | b'(' | b')' | b'{' | b'^' - ) -} - -/// Behind a `\\` the package writes one of `$ . | * + ( ) { ^` as it is: `a\\*b` is `a`, any number of `\`, `b`. `false`: it throws. -fn write_leaked(alternatives: &mut Vec, nodes: &mut Vec, c: u8) -> bool { - match c { - b'*' | b'+' => match nodes.pop() { - Some(last @ (Node::Lit(_) | Node::Class(_))) => { - nodes.push(Node::repeat(last, u8::from(c == b'+'), true)); - } - _ => return false, - }, - b'.' => nodes.push(Node::Dot { newlines: false }), - b'$' => nodes.push(Node::Assert(Assertion::End)), - b'^' => nodes.push(Node::Assert(Assertion::Start)), - b'{' => nodes.push(Node::Lit(vec![c])), - b'|' => { - alternatives.push(Node::Seq(std::mem::take(nodes))); - // What is behind it need not start at the start. - nodes.push(anything()); - } - _ => return false, - } - true -} - -fn with_alternatives(mut alternatives: Vec, nodes: Vec) -> Node { - if alternatives.is_empty() { - return Node::Seq(nodes); - } - alternatives.push(Node::Seq(nodes)); - Node::Alt(alternatives) -} - -// ───────────────────────────── wildmatch: git, npm `ignore` 7.0.12 ───────────────────────────── - -fn trailing(bytes: &[u8], byte: u8) -> usize { - bytes.iter().rev().take_while(|it| **it == byte).count() -} - -/// `trimEnd` of the package: blanks at the end go, unless a `\` is before the first of them. -fn trim_blanks(mut body: &[u8]) -> &[u8] { - while let [rest @ .., b'\r' | b'\n'] = body { - body = rest; - } - let end = body.len() - trailing(body, b' '); - // `\ ` stays, as the two that it is: it is read as a blank below. - let is_escaped = end < body.len() && trailing(&body[..end], b'\\') % 2 == 1; - &body[..end + usize::from(is_escaped)] -} - -fn wildmatch_line(line: &[u8], options: IgnoreOptions) -> Option { - let text = text_for(options); - let mut body = line; - // `/(?:[^\\]|^)\\$/` - if body.ends_with(b"\\") && !body.ends_with(b"\\\\") { - return None; - } - body = body.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(body); - if body.is_empty() || body.starts_with(b"#") { - return None; - } - let is_negated = body.starts_with(b"!"); - body = trim_blanks(&body[usize::from(is_negated)..]); - if body.is_empty() { - return None; - } - let mut is_for_directories = false; - let line_of = |node: Node, is_for_directories: bool| { - Some(Line { - node, - is_negated, - is_for_directories, - }) - }; - // A `/` that is not the last character ties the pattern to the directory of the file. It counts wherever it stands. - let is_anchored = - strings::index_of_char_usize(body, b'/').is_some_and(|it| it == 0 || it + 1 < body.len()); - // Nothing but `**/`, any number of times, behind a `/` or not: every directory. - let mut at = usize::from(body.starts_with(b"/")); - while has_at(body, at, b"**/") { - at += 3; - } - if at == body.len() && at > 1 { - let end = Node::Assert(Assertion::EndOrFinalSlash); - return line_of(Node::Seq(vec![ANY_NAMES, end]), false); - } - if let [rest @ .., b'/'] = body - && !rest.is_empty() - { - // `\/` at the end matches nothing. - if trailing(rest, b'\\') % 2 == 1 { - return line_of(Node::Fail, false); - } - is_for_directories = true; - body = rest; - } - let (mut nodes, mut alternatives): (Vec, Vec) = (Vec::new(), Vec::new()); - let mut i = usize::from(body.starts_with(b"/")); - let is_slash_or_end = |at: usize| matches!(body.get(at), None | Some(b'/')); - // `**/` at the start, any number of times. - let mut has_deep = false; - while has_at(body, i, b"**/") { - i += 3; - has_deep = true; - } - if has_deep { - nodes.push(Node::Deep { - empty_names: true, - newlines: false, - }); - } else if !is_anchored { - nodes.push(ANY_NAMES); - } - // The last thing read is a `/` that is written as one. - let mut is_behind_slash = false; - while let Some(&c) = body.get(i) { - let was_behind_slash = std::mem::replace(&mut is_behind_slash, false); - match c { - b'\\' => { - let Some((_, len)) = CODE_POINTS.next(Subject::of(body), i + 1) else { - return line_of(Node::Fail, is_for_directories); - }; - let escaped = body.get(i + 1..i + 1 + len).unwrap_or_default(); - nodes.push(Node::Lit(escaped.to_vec())); - // The package looks at the byte before a `*` that is the last name, escaped or not. - is_behind_slash = escaped == b"/"; - i += 1 + len; - // The leak. A `*` that is the last character is spared. - if escaped == b"\\" - && let Some(&leaked) = body.get(i) - && is_leakable(leaked) - && !(leaked == b'*' && i + 1 == body.len() && !is_for_directories) - { - if !write_leaked(&mut alternatives, &mut nodes, leaked) { - return line_of(Node::Fail, is_for_directories); - } - i += 1; - } - } - b'?' => { - nodes.push(Node::Any); - i += 1; - } - b'[' => match class::wildmatch(body, i, text) { - Read::Class { class, len, .. } => { - nodes.push(Node::Class(class)); - i += len; - } - _ => return line_of(Node::Fail, is_for_directories), - }, - // `/**` that is a whole name, any number of times. - b'/' if is_anchored && has_at(body, i, b"/**") && is_slash_or_end(i + 3) => { - let mut groups = 0; - while has_at(body, i, b"/**") && is_slash_or_end(i + 3) { - i += 3; - groups += 1; - } - nodes.push(slash()); - if i < body.len() { - nodes.push(FULL_NAMES); - i += 1; - is_behind_slash = true; - } else if is_for_directories { - // `a/**/`: the directories in it, at any depth. - nodes.extend([FULL_NAMES, Node::Plus]); - } else { - if groups > 1 { - nodes.push(FULL_NAMES); - } - nodes.push(to_the_end(1, false)); - } - } - b'*' => { - let stars = body[i..].iter().take_while(|it| **it == b'*').count(); - i += stars; - // A `*` that is the last name, all of it, takes something. - let is_last_name = - !is_for_directories && stars == 1 && i == body.len() && was_behind_slash; - nodes.push(if is_last_name { Node::Plus } else { Node::Star }); - } - _ => { - nodes.push(Node::Lit(vec![c])); - is_behind_slash = c == b'/'; - i += 1; - } - } - } - nodes.push(Node::Assert(match is_for_directories { - true => Assertion::End, - false => Assertion::EndOrFinalSlash, - })); - line_of(with_alternatives(alternatives, nodes), is_for_directories) -} - -// ───────────────────────────── legacy: npm `ignore` 5.3.2 and 7.0.5 ───────────────────────────── - -fn chars_of(bytes: &[u8]) -> Vec { - let mut out = Vec::with_capacity(bytes.len()); - let mut at = 0; - while let Some((c, len)) = CODE_POINTS.next(Subject::of(bytes), at) { - out.push(char::from_u32(c).unwrap_or(char::REPLACEMENT_CHARACTER)); - at += len; - } - out -} - -fn legacy_line(line: &[u8], options: IgnoreOptions) -> Option { - let chars = chars_of(line); - if chars - .iter() - .all(|c| bun_core::strings::is_js_whitespace(*c as u32)) - || chars.first() == Some(&'#') - { - return None; - } - // `/(?:[^\\]|^)\\$/` - if chars.ends_with(&['\\']) && !chars.ends_with(&['\\', '\\']) { - return None; - } - let (is_negated, body) = match &chars[..] { - ['!', rest @ ..] => (true, rest), - all => (false, all), - }; - let body = match body { - ['\\', rest @ ..] if matches!(rest, ['!' | '#', ..]) => rest, - _ => body, - }; - let mut source = Vec::with_capacity(body.len() * 2); - for c in legacy_source(body, options.syntax) { - source.extend_from_slice(c.encode_utf8(&mut [0; 4]).as_bytes()); - } - Some(Line { - node: legacy_node(&source, options.ignores_case), - is_negated, - is_for_directories: false, - }) -} - -fn chars_at(s: &[char], at: usize, literal: &str) -> bool { - let mut rest = s.get(at..).unwrap_or_default().iter(); - literal.chars().all(|c| rest.next() == Some(&c)) -} - -/// The number of `\` that `s` starts with. -fn leading_backslashes(s: &[char]) -> usize { - s.iter().take_while(|c| **c == '\\').count() -} - -/// The number of `\` that `s` ends with. -fn trailing_backslashes(s: &[char]) -> usize { - s.iter().rev().take_while(|c| **c == '\\').count() -} - -/// `$.|*+(){^` -fn is_meta(c: char) -> bool { - matches!(c, '$' | '.' | '|' | '*' | '+' | '(' | ')' | '{' | '^') -} - -/// `sanitizeRange`: without the `b-a` that an expression is refused for. -fn sanitize_range(range: &[char]) -> Vec { - let is_bound = |c: Option<&char>| c.is_some_and(|c| ('0'..='z').contains(c)); - let mut out = Vec::with_capacity(range.len()); - let mut i = 0; - while i < range.len() { - if is_bound(range.get(i)) && range.get(i + 1) == Some(&'-') && is_bound(range.get(i + 2)) { - if range[i] <= range[i + 2] { - out.extend_from_slice(&range[i..i + 3]); - } - i += 3; - } else { - out.push(range[i]); - i += 1; - } - } - out -} - -/// `makeRegex(pattern).source`: the `REPLACERS` in their order. Later steps see what earlier steps wrote. -fn legacy_source(pattern: &[char], syntax: IgnoreSyntax) -> Vec { - let mut s = pattern - .strip_prefix(&['\u{FEFF}']) - .unwrap_or(pattern) - .to_vec(); - - // Trailing spaces are ignored unless they are quoted with a backslash. - let end = s.len() - - s.iter() - .rev() - .take_while(|c| bun_core::strings::is_js_whitespace(**c as u32)) - .count(); - if end < s.len() { - s.truncate(end); - if trailing_backslashes(&s) % 2 == 1 { - s.pop(); - s.push(' '); - } - } - - // `\ ` is a space. - let mut out = Vec::with_capacity(s.len() * 2); - let mut i = 0; - while i < s.len() { - let backslashes = leading_backslashes(&s[i..]); - if backslashes == 0 { - out.push(s[i]); - i += 1; - continue; - } - i += backslashes; - let is_before_space = s - .get(i) - .is_some_and(|c| bun_core::strings::is_js_whitespace(*c as u32)); - let kept = if is_before_space { - backslashes - backslashes % 2 - } else { - backslashes - }; - out.extend(std::iter::repeat_n('\\', kept)); - if is_before_space { - out.push(' '); - i += 1; - } - } - s = out; - - // Metacharacters are escaped, `?` is any character of a name, a leading `/` is the start. - let mut out = Vec::with_capacity(s.len() * 2); - for (i, &c) in s.iter().enumerate() { - match c { - '\\' => out.extend(['\\', c]), - c if is_meta(c) => out.extend(['\\', c]), - '?' => out.extend("[^\\/]".chars()), - '/' if i == 0 => out.push('^'), - '/' => out.extend(['\\', '/']), - _ => out.push(c), - } - } - s = out; - - // A leading `**/` is any directory. - let carets = s.iter().take_while(|c| **c == '^').count(); - if chars_at(&s, carets, "\\*\\*\\/") { - s.splice(..carets + 6, "^(?:.*\\/)?".chars()); - } - - // A pattern with a `/` that is not its last character is relative to the root. - if s.first().is_some_and(|c| *c != '^') { - let has_inner_slash = pattern.iter().rev().skip(1).any(|c| *c == '/'); - let start = if has_inner_slash { "^" } else { "(?:^|\\/)" }; - s.splice(..0, start.chars()); - } - - // `/**/` is any number of directories, a trailing `/**` everything inside. - let mut out = Vec::with_capacity(s.len() * 2); - let mut i = 0; - while i < s.len() { - if chars_at(&s, i, "\\/\\*\\*") && (i + 6 == s.len() || chars_at(&s, i + 6, "\\/")) { - out.extend( - if i + 6 < s.len() { - "(?:\\/[^\\/]+)*" - } else { - "\\/.+" - } - .chars(), - ); - i += 6; - } else { - out.push(s[i]); - i += 1; - } - } - s = out; - - // Other `*` that are neither escaped nor the last character: anything but a `/`. - let end_of_stars = |at: usize| { - let mut count = 0; - while chars_at(&s, at + 2 * count, "\\*") { - count += 1; - } - let mut ends = (1..=count).rev().map(|n| at + 2 * n); - ends.find(|&end| { - s.get(end) - .is_some_and(|c| !bun_core::strings::is_js_line_terminator(u32::from(*c))) - }) - }; - let mut out = Vec::with_capacity(s.len() * 2); - let (mut copied, mut i) = (0, 0); - while i < s.len() { - let mut stars = i; - let mut end = if i == 0 { end_of_stars(0) } else { None }; - if end.is_none() { - stars += s[i..].iter().take_while(|c| **c != '\\').count(); - if stars > i { - end = end_of_stars(stars); - } - } - match end { - Some(end) => { - out.extend_from_slice(&s[copied..stars]); - out.extend("[^\\/]*".chars()); - copied = end; - i = end; - } - None => i = stars.max(i + 1), - } - } - out.extend_from_slice(&s[copied..]); - s = out; - - // What the pattern itself escapes was escaped twice. - let mut out = Vec::with_capacity(s.len()); - let mut i = 0; - while i < s.len() { - let is_escaped_twice = - chars_at(&s, i, "\\\\\\") && s.get(i + 3).is_some_and(|c| is_meta(*c)); - out.push(s[i]); - i += if is_escaped_twice { 3 } else { 1 }; - } - s = out; - let mut out = Vec::with_capacity(s.len()); - let mut i = 0; - while i < s.len() { - out.push(s[i]); - i += if chars_at(&s, i, "\\\\") { 2 } else { 1 }; - } - s = out; - - // `[a-z]` - let mut out = Vec::with_capacity(s.len()); - let mut i = 0; - // The first `]` or `/` from where it was last looked for. - let mut close = 0; - while i < s.len() { - let is_escaped = s[i] == '\\' && s.get(i + 1) == Some(&'['); - let inside = i + usize::from(is_escaped) + 1; - if s.get(inside - 1) == Some(&'[') && close < inside { - let rest = s.get(inside..).unwrap_or_default().iter(); - close = inside + rest.take_while(|c| !matches!(**c, ']' | '/')).count(); - } - if s.get(inside - 1) != Some(&'[') || s.get(close) == Some(&'/') { - out.push(s[i]); - i += 1; - continue; - } - let escapes = trailing_backslashes(&s[inside..close]); - let range = &s[inside..close - escapes]; - let is_closed = close < s.len(); - if is_escaped { - out.extend(['\\', '[']); - out.extend_from_slice(range); - out.extend(std::iter::repeat_n('\\', escapes - escapes % 2)); - if is_closed { - out.push(']'); - } - } else if is_closed && escapes.is_multiple_of(2) { - out.push('['); - out.extend(sanitize_range(range)); - out.extend(std::iter::repeat_n('\\', escapes)); - out.push(']'); - } else { - out.extend(['[', ']']); - } - i = close + usize::from(is_closed); - } - s = out; - - // `a` matches `a` and `a/`, `a/` only the latter. - match s.last().copied() { - None | Some('*') => {} - Some('/') => s.push('$'), - Some(_) => s.extend("(?=$|\\/$)".chars()), - } - - // A trailing `*` - if s.ends_with(&['\\', '*']) { - s.truncate(s.len() - 2); - let is_whole_name = - s.ends_with(&['\\', '/']) || syntax == IgnoreSyntax::Npm5 && s.last() == Some(&'^'); - s.extend(if is_whole_name { "[^/]+" } else { "[^/]*" }.chars()); - s.extend("(?=$|\\/$)".chars()); - } - s -} - -/// What the chain has written, read by a closed list: its own pieces, an escape, a class, a character. `Fail`: none of these. -fn legacy_node(s: &[u8], folds: bool) -> Node { - const NAMES: &[u8] = b"(?:\\/[^\\/]+)*"; - /// What the chain writes, and what it is. - const PIECES: [(&[u8], fn() -> Node); 9] = [ - (b".+", || to_the_end(1, false)), - (b"[^\\/]*", || Node::Star), - (b"[^\\/]", || Node::Any), - (b"[^/]+", || Node::Plus), - (b"[^/]*", || Node::Star), - (b"[^/]", || Node::Any), - (b"(?=$|\\/$)", || Node::Assert(Assertion::EndOrFinalSlash)), - (b"$", || Node::Assert(Assertion::End)), - (b"\\/", slash), - ]; - let (mut nodes, mut alternatives) = (Vec::new(), Vec::new()); - // `!` alone, or with blanks: the empty expression, which finds something in every text. - if s.is_empty() { - return Node::Empty; - } - let mut i = if s.starts_with(b"(?:^|\\/)") { - nodes.push(ANY_NAMES); - 8 - } else if s.starts_with(b"^(?:.*\\/)?") { - nodes.push(Node::Deep { - empty_names: true, - newlines: false, - }); - 10 - } else if s.starts_with(b"^") { - 1 - } else { - return Node::Fail; - }; - while let Some(&byte) = s.get(i) { - if has_at(s, i, NAMES) { - while has_at(s, i, NAMES) { - i += NAMES.len(); - } - // `a(?:/n)*/b` is `a/(?:n/)*b`: a `**` stands where a name starts. - if has_at(s, i, b"\\/") { - nodes.extend([slash(), FULL_NAMES]); - i += 2; - } else { - nodes.push(Node::repeat(Node::Seq(vec![slash(), Node::Plus]), 0, true)); - } - continue; - } - if let Some((piece, node)) = PIECES.iter().find(|it| has_at(s, i, it.0)) { - nodes.push(node()); - i += piece.len(); - continue; - } - match byte { - b'[' => { - let Read::Class { class, len, .. } = class::javascript(s, i, folds) else { - return Node::Fail; - }; - i += len; - // `[a*`, `[a/*` of a pattern: a `*` or a `+` can follow. It is read below. - nodes.push(Node::Class(class)); - } - b'\\' if i + 1 < s.len() => match class::javascript_escape(s, i, false) { - // A half of a pair stays with its other half. - Escape::Unit { unit, len } if (0xD800..=0xDFFF).contains(&unit) => { - nodes.push(Node::Lit( - s.get(i + 1..i + len).unwrap_or_default().to_vec(), - )); - i += len; - } - // There is no group to refer to. - Escape::Unit { unit, len } | Escape::Reference { unit, len, .. } => { - nodes.push(Node::of_unit(unit)); - i += len; - } - Escape::Ranges(ranges) => { - nodes.push(Node::Class(Class::of_ranges(ranges, folds))); - i += 2; - } - Escape::WordBoundary { negated } => { - nodes.push(Node::Assert(match negated { - true => Assertion::NotWordBoundary, - false => Assertion::WordBoundary, - })); - i += 2; - } - Escape::Backslash => { - nodes.push(Node::Lit(vec![b'\\'])); - i += 1; - } - }, - _ if is_leakable(byte) => { - if !write_leaked(&mut alternatives, &mut nodes, byte) { - return Node::Fail; - } - i += 1; - } - b'?' | b'\\' => return Node::Fail, - _ => { - nodes.push(Node::Lit(vec![byte])); - i += 1; - } - } - } - with_alternatives(alternatives, nodes) -} diff --git a/src/glob/read_minimatch.rs b/src/glob/read_minimatch.rs deleted file mode 100644 index 86201f032a82..000000000000 --- a/src/glob/read_minimatch.rs +++ /dev/null @@ -1,799 +0,0 @@ -//! One name of a pattern of minimatch 10.2.6: `Minimatch.parse` and `ast.js`, written as pieces. It never sees a path. - -use crate::class::{self, Read}; -use crate::node::{Assertion, Node, Open, Piece, lower, nest}; -use crate::segments::{Part, is_dots}; -use crate::unit::{Text, Unit, push_utf8}; -use bun_core::strings; -use std::rc::Rc; - -/// `Minimatch.parse`. `pattern`: without a `/`. -pub(crate) fn part(pattern: &[u8], dot: bool) -> Part { - if pattern == b"**" { - return Part::GlobStar; - } - if pattern.is_empty() { - return Part::Literal(Box::default()); - } - let count = |byte: u8, text: &[u8]| text.iter().take_while(|b| **b == byte).count(); - // `[^+@!?*[(]*` - let is_plain = |text: &[u8]| !strings::contains_any(text, b"+@!?*[("); - let (stars, marks) = (count(b'*', pattern), count(b'?', pattern)); - let (after_stars, after_marks) = (&pattern[stars..], &pattern[marks..]); - if stars > 0 && after_stars.is_empty() { - return Part::Star; - } - // These two compare bytes. - let is_quick = !Text::UTF16.has_replacement(pattern); - if is_quick && stars > 0 && is_plain(after_stars) { - return Part::StarExt(after_stars.into()); - } - if is_quick && marks > 0 && is_plain(after_marks) { - return Part::QuestionMarks(Text::UTF16.count_units(pattern), after_marks.into()); - } - let is_dot_and_stars = |text: &[u8]| match text { - [b'.', rest @ ..] => !rest.is_empty() && count(b'*', rest) == rest.len(), - _ => false, - }; - if stars > 0 && is_dot_and_stars(after_stars) { - return Part::StarDotStar; - } - if is_dot_and_stars(pattern) { - return Part::DotStar; - } - // A POSIX class that needs the flag `u` makes code points of the units of the whole name, in every class of it too. - into_part(pattern, dot, false) - .or_else(|| into_part(pattern, dot, true)) - .unwrap_or(Part::Never) -} - -// ───────────────────────────── ast.js ───────────────────────────── - -const MAX_EXTGLOB_RECURSION: usize = 2; -/// minimatch has no such limit: extglobs that could be merged with the one around them nest as deep as the pattern is long. -const MAX_OPEN_EXTGLOBS: usize = 100; - -fn is_extglob_type(byte: u8) -> bool { - matches!(byte, b'!' | b'?' | b'+' | b'*' | b'@') -} - -fn adoption(parent: u8) -> &'static [u8] { - match parent { - b'!' | b'@' => b"@", - b'?' => b"?@", - b'*' => b"*+?@", - b'+' => b"+@", - _ => b"", - } -} - -fn adoption_with_space(parent: u8) -> &'static [u8] { - match parent { - b'!' | b'@' => b"?", - b'+' => b"?*", - _ => b"", - } -} - -fn adoption_any(parent: u8) -> &'static [u8] { - match parent { - b'!' | b'?' | b'@' => b"?@", - b'*' => b"*+?@", - b'+' => b"+@?*", - _ => b"", - } -} - -/// The type that an extglob `parent` becomes when it takes the place of its only child `child`. -fn usurped(parent: u8, child: u8) -> Option { - match (parent, child) { - (b'!', b'!') => Some(b'@'), - (b'?' | b'+', b'*') | (b'?', b'+') | (b'+', b'?') => Some(b'*'), - (b'@', child) if is_extglob_type(child) => Some(child), - _ => None, - } -} - -#[derive(Clone)] -enum Child { - Text(Rc<[u8]>), - Node(usize), -} - -struct Ext { - /// `!`, `?`, `+`, `*`, `@`. `None`: not an extglob. - kind: Option, - has_magic: Option, - needs_u_flag: bool, - children: Vec, - parent: Option, - parent_index: usize, - is_empty_ext: bool, -} - -/// What `toRegExpSource` returns. -struct Source { - pieces: Vec, - has_magic: bool, - needs_u_flag: bool, -} - -/// The class `AST`: all nodes of the tree for one name. The root is at 0. -struct Ast { - nodes: Vec, - negs: Vec, - has_filled_negs: bool, - /// How many extglobs are being parsed. - nesting: usize, - /// The option `dot`. - dot: bool, - code_points: bool, -} - -fn push_bytes(pieces: &mut Vec, bytes: &[u8]) { - match pieces.last_mut() { - Some(Piece::Node(Node::Lit(last))) => last.extend_from_slice(bytes), - _ => pieces.push(Piece::Node(Node::Lit(bytes.to_vec()))), - } -} - -fn group(inner: Vec) -> Vec { - let mut out = Vec::with_capacity(inner.len() + 2); - out.push(Piece::Open(Open::Group)); - out.extend(inner); - out.push(Piece::Close); - out -} - -fn assert(assertion: Assertion) -> Piece { - Piece::Node(Node::Assert(assertion)) -} - -/// What the reference asks of a character of the expression. -#[derive(PartialEq, Eq)] -enum Written { - /// A `.` that is a character. - Dot, - /// What is written with a `[` at its start: `?`, `*`, a class. - Bracket, - Other, -} - -/// What the `n`th character of the pattern is written as, if those before it are `.`. -fn kind_at(pieces: &[Piece], n: usize) -> Written { - let mut at = 0; - for piece in pieces { - match piece { - Piece::Node(Node::Lit(bytes)) => { - for byte in bytes { - if *byte != b'.' { - return Written::Other; - } - if at == n { - return Written::Dot; - } - at += 1; - } - } - Piece::Node(Node::Any | Node::Star | Node::Plus) if at == n => return Written::Bracket, - Piece::Node(Node::Class(class)) if at == n && !class.is_written_in_parens() => { - return Written::Bracket; - } - _ => return Written::Other, - } - } - Written::Other -} - -/// `kind(||)`: an extglob that can only match nothing is written as the characters that it is, into an expression. -fn raw_extglob(kind: u8, bars: usize) -> Vec { - let first = match kind { - b'@' => Piece::Node(Node::Lit(vec![b'@'])), - _ => Piece::Quant { - min: u8::from(kind == b'+'), - unbounded: kind != b'?', - }, - }; - let mut out = vec![first, Piece::Open(Open::Capture)]; - out.extend((0..bars).map(|_| Piece::Bar)); - out.push(Piece::Close); - out -} - -/// What pieces without magic are as characters: `unescape(src)`. -fn literal_of(pieces: &[Piece]) -> Box<[u8]> { - let mut out = Vec::new(); - for piece in pieces { - match piece { - Piece::Node(Node::Lit(bytes)) => out.extend_from_slice(bytes), - Piece::Open(_) => out.push(b'('), - Piece::Close => out.push(b')'), - Piece::Bar => out.push(b'|'), - Piece::Quant { min: 1, .. } => out.push(b'+'), - Piece::Quant { - unbounded: true, .. - } => out.push(b'*'), - Piece::Quant { .. } => out.push(b'?'), - Piece::Node(_) | Piece::Reference { .. } => {} - } - } - out.into() -} - -fn has_bar_at_top(pieces: &[Piece]) -> bool { - let mut depth = 0_usize; - for piece in pieces { - match piece { - Piece::Open(_) => depth += 1, - Piece::Close => depth = depth.saturating_sub(1), - Piece::Bar if depth == 0 => return true, - _ => {} - } - } - false -} - -impl Ast { - fn create(&mut self, kind: Option, parent: Option) -> usize { - let id = self.nodes.len(); - if kind == Some(b'!') && !self.has_filled_negs { - self.negs.push(id); - } - self.nodes.push(Ext { - kind, - has_magic: kind.map(|_| true), - needs_u_flag: false, - children: Vec::new(), - parent, - parent_index: parent.map_or(0, |it| self.nodes[it].children.len()), - is_empty_ext: false, - }); - id - } - - fn push_text(&mut self, node: usize, text: &[u8]) { - if !text.is_empty() { - self.nodes[node].children.push(Child::Text(text.into())); - } - } - - fn push_node(&mut self, node: usize, child: usize) { - self.nodes[node].children.push(Child::Node(child)); - } - - fn can_adopt_type(&self, node: usize, kind: u8, map: fn(u8) -> &'static [u8]) -> bool { - self.nodes[node] - .kind - .is_some_and(|parent| strings::contains_char(map(parent), kind)) - } - - /// `#parseAST`. Recursion: at most `MAX_OPEN_EXTGLOBS` deep. - fn parse(&mut self, text: &[u8], node: usize, pos: usize, depth: usize) -> usize { - let (mut escaping, mut in_class, mut class_start, mut class_is_negated) = - (false, false, usize::MAX, false); - let is_extglob = self.nodes[node].kind.is_some(); - let mut i = if is_extglob { pos + 1 } else { pos }; - // Where what has not been pushed yet starts. - let mut acc = i; - let mut part = if is_extglob { - self.create(None, Some(node)) - } else { - node - }; - let mut parts: Vec = Vec::new(); - while i < text.len() { - let c = text[i]; - i += 1; - if escaping || c == b'\\' { - escaping = !escaping; - continue; - } - if in_class { - if i == class_start + 1 { - if matches!(c, b'^' | b'!') { - class_is_negated = true; - } - } else if c == b']' && !(i == class_start + 2 && class_is_negated) { - in_class = false; - } - continue; - } - if c == b'[' { - in_class = true; - class_start = i; - class_is_negated = false; - continue; - } - let can_adopt = is_extglob && self.can_adopt_type(node, c, adoption_any); - let is_nested = depth <= MAX_EXTGLOB_RECURSION || can_adopt; - if is_extglob_type(c) - && text.get(i) == Some(&b'(') - && is_nested - && self.nesting < MAX_OPEN_EXTGLOBS - { - self.push_text(part, &text[acc..i - 1]); - let ext = self.create(Some(c), Some(part)); - self.nesting += 1; - if is_extglob { - self.push_node(part, ext); - i = self.parse(text, ext, i, depth + usize::from(!can_adopt)); - } else { - i = self.parse(text, ext, i, depth + 1); - self.push_node(part, ext); - } - self.nesting -= 1; - acc = i; - continue; - } - if is_extglob && c == b'|' { - self.push_text(part, &text[acc..i - 1]); - acc = i; - parts.push(part); - part = self.create(None, Some(node)); - continue; - } - if is_extglob && c == b')' { - if acc == i - 1 && self.nodes[node].children.is_empty() { - self.nodes[node].is_empty_ext = true; - } - self.push_text(part, &text[acc..i - 1]); - parts.push(part); - for part in parts { - self.push_node(node, part); - } - return i; - } - } - if is_extglob { - // It is not closed, so it is not an extglob. - let node = &mut self.nodes[node]; - node.kind = None; - node.has_magic = None; - node.children = vec![Child::Text(text[pos - 1..].into())]; - } else { - self.push_text(node, &text[acc..]); - } - i - } - - fn copy_in(&mut self, node: usize, child: &Child) { - match child { - Child::Text(text) => { - if !text.is_empty() { - self.nodes[node].children.push(child.clone()); - } - } - Child::Node(other) => { - let clone = self.clone_node(*other, node); - self.push_node(node, clone); - } - } - } - - /// As deep as the tree. - fn clone_node(&mut self, original: usize, parent: usize) -> usize { - let clone = self.create(self.nodes[original].kind, Some(parent)); - for child in self.nodes[original].children.clone() { - self.copy_in(clone, &child); - } - clone - } - - /// What follows a `!(..)` is copied into it, because what it must not match depends on it. - fn fill_negs(&mut self) { - if self.has_filled_negs { - return; - } - self.has_filled_negs = true; - while let Some(neg) = self.negs.pop() { - if self.nodes[neg].kind != Some(b'!') { - continue; - } - let mut child = neg; - while let Some(parent) = self.nodes[child].parent { - let mut i = self.nodes[child].parent_index + 1; - while self.nodes[parent].kind.is_none() && i < self.nodes[parent].children.len() { - let following = self.nodes[parent].children[i].clone(); - for part in self.nodes[neg].children.clone() { - if let Child::Node(part) = part { - self.copy_in(part, &following); - } - } - i += 1; - } - child = parent; - } - } - } - - fn is_start(&self, mut node: usize) -> bool { - while let Some(parent) = self.nodes[node].parent { - let before = self.nodes[parent].children.iter(); - let is_negation = - |it: &Child| matches!(it, Child::Node(it) if self.nodes[*it].kind == Some(b'!')); - let index = self.nodes[node].parent_index; - if index > self.nodes[parent].children.len() || !before.take(index).all(is_negation) { - return false; - } - node = parent; - } - true - } - - fn is_end(&self, mut node: usize) -> bool { - while let Some(parent) = self.nodes[node].parent { - if self.nodes[parent].kind == Some(b'!') { - return true; - } - let is_last = self.nodes[node].parent_index + 1 == self.nodes[parent].children.len(); - if self.nodes[node].kind.is_some() && !is_last { - return false; - } - node = parent; - } - true - } - - /// The only child of `child`, if `child` is not an extglob and that one is. - fn only_grandchild(&self, node: usize, child: usize) -> Option { - self.nodes[node].kind?; - match (&self.nodes[child].kind, &self.nodes[child].children[..]) { - (None, [Child::Node(grandchild)]) if self.nodes[*grandchild].kind.is_some() => { - Some(*grandchild) - } - _ => None, - } - } - - /// The children of `grandchild`, which now belong to `node`. - fn taken_from(&mut self, grandchild: usize, node: usize) -> Vec { - let children = self.nodes[grandchild].children.clone(); - for child in &children { - if let Child::Node(it) = child { - self.nodes[*it].parent = Some(node); - } - } - children - } - - /// `#flatten`. As deep as the tree. A round writes the list anew: the `splice` of the reference is quadratic. - fn flatten(&mut self, node: usize) { - if self.nodes[node].kind.is_none() { - for child in self.nodes[node].children.clone() { - if let Child::Node(child) = child { - self.flatten(child); - } - } - return; - } - for _ in 0..10 { - let mut is_done = true; - // What the loop has passed, and what it has not come to, the next one last. - let mut done: Vec = Vec::new(); - let mut ahead = self.nodes[node].children.clone(); - ahead.reverse(); - while let Some(child) = ahead.pop() { - let grandchild = match child { - Child::Node(child) => { - self.flatten(child); - self.only_grandchild(node, child) - } - Child::Text(_) => None, - }; - let Some(grandchild) = grandchild else { - done.push(child); - continue; - }; - let kind = self.nodes[grandchild].kind.unwrap_or_default(); - let parent_kind = self.nodes[node].kind.unwrap_or_default(); - let is_adopted = if self.can_adopt_type(node, kind, adoption) { - true - } else if self.can_adopt_type(node, kind, adoption_with_space) { - let blank = self.create(None, Some(grandchild)); - self.nodes[blank].children.push(Child::Text(Rc::default())); - self.push_node(grandchild, blank); - true - } else if done.is_empty() - && ahead.is_empty() - && let Some(new_kind) = usurped(parent_kind, kind) - { - let node = &mut self.nodes[node]; - node.kind = Some(new_kind); - node.is_empty_ext = false; - true - } else { - false - }; - if !is_adopted { - done.push(child); - continue; - } - // The children of `grandchild` take the place of the one that the loop is at. - is_done = false; - let mut adopted = self.taken_from(grandchild, node).into_iter(); - done.extend(adopted.next()); - ahead.extend(adopted.rev()); - } - self.nodes[node].children = done; - if is_done { - break; - } - } - } - - /// `#parseGlob` - fn parse_glob(&self, glob: &[u8], mut has_magic: bool, no_empty: bool) -> Source { - let (mut escaping, mut in_star, mut needs_u_flag) = (false, false, false); - let mut re: Vec = Vec::new(); - let is_only_stars = glob.iter().all(|b| *b == b'*'); - // Looked for at the first `[`. - let mut last_close: Option> = None; - let mut i = 0; - while let Some(&c) = glob.get(i) { - i += 1; - if escaping { - escaping = false; - // The reference escapes `().*{}+?[]^$\!` here and forgets the `|`, which then divides the group that it lands in. - match c { - b'|' => re.push(Piece::Bar), - _ => push_bytes(&mut re, &[c]), - } - continue; - } - if c == b'*' { - if !in_star { - in_star = true; - re.push(Piece::Node(match no_empty && is_only_stars { - true => Node::Plus, - false => Node::Star, - })); - has_magic = true; - } - continue; - } - in_star = false; - match c { - b'\\' if i < glob.len() => escaping = true, - b'?' => { - re.push(Piece::Node(Node::Any)); - has_magic = true; - } - b'[' if !class::minimatch_can_close( - glob, - i - 1, - *last_close.get_or_insert_with(|| class::minimatch_last_close(glob)), - ) => - { - push_bytes(&mut re, b"["); - } - b'[' => match class::minimatch(glob, i - 1, self.code_points) { - Read::NotAClass => push_bytes(&mut re, b"["), - Read::Never => { - re.push(Piece::Node(Node::Fail)); - has_magic = true; - i = glob.len(); - } - Read::One { unit, len } => { - let mut bytes = Vec::with_capacity(3); - push_utf8(&mut bytes, unit); - push_bytes(&mut re, &bytes); - i += len - 1; - } - Read::Class { - class, - len, - needs_code_points, - } => { - re.push(Piece::Node(Node::Class(class))); - needs_u_flag |= needs_code_points; - has_magic = true; - i += len - 1; - } - }, - _ => push_bytes(&mut re, &[c]), - } - } - Source { - pieces: re, - has_magic, - needs_u_flag, - } - } - - /// `#partsToRegExp` - fn children_to_regexp(&mut self, node: usize, dot: bool) -> Vec { - let mut sources: Vec> = Vec::new(); - for child in self.nodes[node].children.clone() { - if let Child::Node(child) = child { - let source = self.to_regexp_source(child, Some(dot)); - self.nodes[node].needs_u_flag |= source.needs_u_flag; - sources.push(source.pieces); - } - } - if self.is_start(node) && self.is_end(node) { - sources.retain(|it| !it.is_empty()); - } - let mut out = Vec::new(); - for (i, source) in sources.into_iter().enumerate() { - if i > 0 { - out.push(Piece::Bar); - } - out.extend(source); - } - out - } - - /// `toRegExpSource`. As deep as the tree. - fn to_regexp_source(&mut self, node: usize, allow_dot: Option) -> Source { - let dot = allow_dot.unwrap_or(self.dot); - if node == 0 { - self.flatten(0); - self.fill_negs(); - } - let Some(kind) = self.nodes[node].kind else { - return self.text_to_regexp_source(node, allow_dot, dot); - }; - let is_repeated = matches!(kind, b'*' | b'+'); - let mut body = self.children_to_regexp(node, dot); - if self.is_start(node) && self.is_end(node) && body.is_empty() && kind != b'!' { - let it = &mut self.nodes[node]; - let bars = it.children.len().saturating_sub(1); - let written = [&[kind, b'('][..], &vec![b'|'; bars], b")"].concat(); - it.children = vec![Child::Text(written.into())]; - it.kind = None; - it.has_magic = None; - return Source { - pieces: raw_extglob(kind, bars), - has_magic: false, - needs_u_flag: false, - }; - } - let mut body_dot_allowed = match !is_repeated || allow_dot == Some(true) || dot { - true => Vec::new(), - false => self.children_to_regexp(node, true), - }; - if body_dot_allowed == body { - body_dot_allowed.clear(); - } - let has_two_bodies = !body_dot_allowed.is_empty(); - if has_two_bodies { - body = group(body); - body.extend(group(body_dot_allowed)); - body.push(Piece::Quant { - min: 0, - unbounded: true, - }); - } - let no_dot = self.is_start(node) && !dot; - let mut pieces = Vec::with_capacity(body.len() + 8); - if kind == b'!' && self.nodes[node].is_empty_ext { - pieces.extend(no_dot.then(|| assert(Assertion::NotDot))); - pieces.push(Piece::Node(Node::Plus)); - } else if kind == b'!' { - // `(?:(?!(?:` body `))[^/]*?)` - pieces.extend([Piece::Open(Open::Group), Piece::Open(Open::NotLook)]); - pieces.extend(group(body)); - pieces.push(Piece::Close); - pieces.extend((no_dot && allow_dot != Some(true)).then(|| assert(Assertion::NotDot))); - pieces.extend([Piece::Node(Node::Star), Piece::Close]); - } else { - pieces = group(body); - pieces.extend( - match (kind, has_two_bodies) { - (b'?', _) | (b'*', true) => Some((0, false)), - (b'+', false) => Some((1, true)), - (b'*', false) => Some((0, true)), - _ => None, - } - .map(|(min, unbounded)| Piece::Quant { min, unbounded }), - ); - } - let it = &mut self.nodes[node]; - it.has_magic = Some(it.has_magic == Some(true)); - Source { - pieces, - has_magic: it.has_magic == Some(true), - needs_u_flag: it.needs_u_flag, - } - } - - /// `toRegExpSource` of what is not an extglob. - fn text_to_regexp_source(&mut self, node: usize, allow_dot: Option, dot: bool) -> Source { - let children = self.nodes[node].children.clone(); - let no_empty = self.is_start(node) - && self.is_end(node) - && children.iter().all(|it| matches!(it, Child::Text(_))); - let mut src: Vec = Vec::new(); - for child in &children { - let source = match child { - Child::Text(text) => { - self.parse_glob(text, self.nodes[node].has_magic == Some(true), no_empty) - } - Child::Node(child) => self.to_regexp_source(*child, allow_dot), - }; - let it = &mut self.nodes[node]; - it.has_magic = Some(it.has_magic == Some(true) || source.has_magic); - it.needs_u_flag |= source.needs_u_flag; - src.extend(source.pieces); - } - let mut out = Vec::with_capacity(src.len() + 2); - if self.is_start(node) - && let [Child::Text(first), rest @ ..] = &children[..] - && !(rest.is_empty() && is_dots(first)) - { - let kinds = [0, 1, 2].map(|n| kind_at(&src, n)); - let starts_with_bracket = kinds[0] == Written::Bracket; - let needs_no_traversal = dot && starts_with_bracket - || matches!( - kinds, - [Written::Dot, Written::Bracket, _] - | [Written::Dot, Written::Dot, Written::Bracket] - ); - if needs_no_traversal { - out.push(assert(Assertion::NotDotsBehindStart)); - } else if !dot && allow_dot != Some(true) && starts_with_bracket { - out.push(assert(Assertion::NotDot)); - } - } - out.extend(src); - let is_in_negation = - (self.nodes[node].parent).is_some_and(|it| self.nodes[it].kind == Some(b'!')); - if self.is_end(node) && self.has_filled_negs && is_in_negation { - out.push(assert(Assertion::EndOrSlash)); - } - let it = &mut self.nodes[node]; - it.has_magic = Some(it.has_magic == Some(true)); - Source { - pieces: out, - has_magic: it.has_magic == Some(true), - needs_u_flag: it.needs_u_flag, - } - } -} - -/// `AST.fromGlob(pattern).toMMPattern()`. `None`: it has to be read again, with `code_points`. -fn into_part(pattern: &[u8], dot: bool, code_points: bool) -> Option { - let mut ast = Ast { - nodes: Vec::new(), - negs: Vec::new(), - has_filled_negs: false, - nesting: 0, - dot, - code_points, - }; - let root = ast.create(None, None); - ast.parse(pattern, root, 0, 0); - let source = ast.to_regexp_source(root, None); - if !source.has_magic && ast.nodes[root].has_magic != Some(true) { - return Some(Part::Literal(literal_of(&source.pieces))); - } - if source.needs_u_flag && !code_points { - return None; - } - // `^` .. `$`. An escaped `|` divides even that: before it the end is free, behind it the start. - let floats = has_bar_at_top(&source.pieces); - let mut pieces = Vec::with_capacity(source.pieces.len() + 2); - pieces.push(assert(Assertion::Start)); - pieces.extend(source.pieces); - pieces.push(assert(Assertion::End)); - // No expression: the reference throws. - let Some(tree) = nest(pieces) else { - return Some(Part::Never); - }; - let whole = match floats { - true => Node::Seq(vec![ - Node::repeat(Node::Dot { newlines: true }, 0, true), - tree, - ]), - false => tree, - }; - let unit = match code_points { - true => Unit::CodePoint, - false => Unit::Utf16, - }; - let program = lower(whole, Text { unit, folds: false }); - Some(match program.is_never() { - true => Part::Never, - false => Part::Name(program), - }) -} diff --git a/src/glob/read_minimatch3.rs b/src/glob/read_minimatch3.rs deleted file mode 100644 index 46e6d28c7502..000000000000 --- a/src/glob/read_minimatch3.rs +++ /dev/null @@ -1,443 +0,0 @@ -//! minimatch 3.1.5: the names of a pattern for `minimatch()`, and `makeRe`: one expression for the whole path, which answers otherwise. - -use crate::braces; -use crate::node::{Node, Piece, Program, lower, nest}; -use crate::pattern::{MAX_PATTERN_LENGTH, Options}; -use crate::read_picomatch::{Unread, read_written}; -use crate::segments::{Part, has_braces}; -use crate::unit::Text; -use bun_core::strings; - -const QMARK: &[u8] = b"[^/]"; -const STAR: &[u8] = b"[^/]*?"; -const TWO_STAR_DOT: &[u8] = br"(?:(?!(?:\/|^)(?:\.{1,2})($|\/)).)*?"; -const TWO_STAR_NO_DOT: &[u8] = br"(?:(?!(?:\/|^)\.).)*?"; -/// Ours: how deep `parse` calls itself. Beyond it the pattern matches nothing. -const MAX_DEPTH: usize = 64; - -/// `reSpecials` -fn is_re_special(c: u8) -> bool { - matches!( - c, - b'(' | b')' - | b'.' - | b'*' - | b'{' - | b'}' - | b'+' - | b'?' - | b'[' - | b']' - | b'^' - | b'$' - | b'\\' - | b'!' - ) -} - -/// The flag `i`, without `u`. -fn text_of(options: Options) -> Text { - Text { - folds: options.nocase, - ..Text::UTF16 - } -} - -fn pieces_of(source: &[u8], options: Options) -> Result, Unread> { - let written: Vec = source.iter().map(|it| u16::from(*it)).collect(); - read_written(&written, options.dot, text_of(options)) -} - -/// Whether `new RegExp(source)` does not throw. -fn is_expression(source: &[u8]) -> bool { - match pieces_of(source, Options::MINIMATCH_3) { - Ok(pieces) => nest(pieces).is_some(), - Err(Unread::NotTaken) => true, - Err(Unread::NoExpression) => false, - } -} - -/// `plTypes[kind].open` -fn open_of(kind: u8) -> &'static [u8] { - match kind { - b'!' => b"(?:(?!(?:", - _ => b"(?:", - } -} - -/// `plTypes[kind].close` -fn close_of(kind: u8) -> &'static [u8] { - match kind { - b'!' => b"))[^/]*?)", - b'?' => b")?", - b'+' => b")+", - b'*' => b")*", - _ => b")", - } -} - -/// `tail.replace(/((?:\\{2}){0,64})(\\?)\|/g, ..)` -fn with_bars_as_characters(tail: &[u8]) -> Vec { - let mut out = Vec::with_capacity(tail.len() + 8); - let mut run = 0_usize; - for c in tail { - if *c == b'|' { - let taken = run.min(129); - out.truncate(out.len() - taken); - out.extend(std::iter::repeat_n(b'\\', 4 * (taken / 2) + 1)); - } - out.push(*c); - run = if *c == b'\\' { run + 1 } else { 0 }; - } - out -} - -/// An extglob that is open or has been closed: where in the text it starts, and ends. -struct List { - kind: u8, - re_start: usize, - re_end: usize, -} - -struct Parsed { - re: Vec, - has_magic: bool, -} - -impl Parsed { - /// What `sp[0]` and `sp[1]` are of what is no array. - fn undefined() -> Parsed { - Parsed { - re: b"undefined".to_vec(), - has_magic: false, - } - } -} - -/// What `parse` is writing. -struct Writer { - re: Vec, - has_magic: bool, - state_char: Option, -} - -impl Writer { - fn clear_state_char(&mut self) { - match self.state_char.take() { - Some(b'*') => { - self.re.extend_from_slice(STAR); - self.has_magic = true; - } - Some(b'?') => { - self.re.extend_from_slice(QMARK); - self.has_magic = true; - } - Some(c) => self.re.extend([b'\\', c]), - None => {} - } - } -} - -/// `Minimatch.prototype.parse`. `depth`: above 0 it is `SUBPARSE`. `None`: beyond a limit. -fn parse(pattern: &[u8], options: Options, depth: usize) -> Option { - let pattern = match pattern { - b"**" if options.noglobstar => &b"*"[..], - _ => pattern, - }; - let mut w = Writer { - re: Vec::with_capacity(pattern.len() * 2), - has_magic: options.nocase, - state_char: None, - }; - let mut escaping = false; - // Where the class that is open starts, in the pattern and in the text. - let mut class: Option<(usize, usize)> = None; - let (mut lists, mut negative_lists): (Vec, Vec) = (Vec::new(), Vec::new()); - let sub = |cs: &[u8]| match cs { - // `parse` gives `''` and `GLOBSTAR` for these. Neither has a `[0]`, and `'\\[' + undefined` is written. - b"" => Some(Parsed::undefined()), - b"**" if !options.noglobstar => Some(Parsed::undefined()), - _ if depth < MAX_DEPTH => parse(cs, options, depth + 1), - _ => None, - }; - for (i, &c) in pattern.iter().enumerate() { - if escaping && is_re_special(c) { - w.re.extend([b'\\', c]); - escaping = false; - continue; - } - let is_first_in_class = class.is_some_and(|it| i == it.0 + 1); - match c { - b'\\' => { - w.clear_state_char(); - escaping = true; - } - b'?' | b'*' | b'+' | b'@' | b'!' if class.is_some() => { - w.re.push(if c == b'!' && is_first_in_class { - b'^' - } else { - c - }); - } - b'*' if w.state_char == Some(b'*') => {} - b'?' | b'*' | b'+' | b'@' | b'!' => { - w.clear_state_char(); - w.state_char = Some(c); - if options.noext { - w.clear_state_char(); - } - } - b'(' if class.is_some() => w.re.push(b'('), - b'(' => match w.state_char.take() { - None => w.re.extend_from_slice(b"\\("), - Some(kind) => { - lists.push(List { - kind, - re_start: w.re.len(), - re_end: 0, - }); - w.re.extend_from_slice(open_of(kind)); - } - }, - b')' if class.is_some() => w.re.extend_from_slice(b"\\)"), - b')' => match lists.pop() { - None => w.re.extend_from_slice(b"\\)"), - Some(list) => { - w.clear_state_char(); - w.has_magic = true; - w.re.extend_from_slice(close_of(list.kind)); - if list.kind == b'!' { - let re_end = w.re.len(); - negative_lists.push(List { re_end, ..list }); - } - } - }, - b'|' if class.is_some() || lists.is_empty() || escaping => { - w.re.extend_from_slice(b"\\|"); - escaping = false; - } - b'|' => { - w.clear_state_char(); - w.re.push(b'|'); - } - b'[' => { - w.clear_state_char(); - match class { - Some(_) => w.re.extend_from_slice(b"\\["), - None => { - class = Some((i, w.re.len())); - w.re.push(b'['); - } - } - } - b']' => match class.filter(|_| !is_first_in_class) { - None => { - w.re.extend_from_slice(b"\\]"); - escaping = false; - } - Some((class_start, re_class_start)) => { - class = None; - let cs = &pattern[class_start + 1..i]; - if is_expression(&[b"[", cs, b"]"].concat()) { - w.has_magic = true; - w.re.push(b']'); - } else { - let sp = sub(cs)?; - w.re.truncate(re_class_start); - w.re.extend_from_slice(&[b"\\[", &sp.re[..], b"\\]"].concat()); - w.has_magic |= sp.has_magic; - } - } - }, - _ => { - w.clear_state_char(); - if escaping { - escaping = false; - } else if is_re_special(c) && !(c == b'^' && class.is_some()) { - w.re.push(b'\\'); - } - w.re.push(c); - } - } - } - if let Some((class_start, re_class_start)) = class { - let sp = sub(&pattern[class_start + 1..])?; - w.re.truncate(re_class_start); - w.re.extend_from_slice(b"\\["); - w.re.extend_from_slice(&sp.re); - w.has_magic |= sp.has_magic; - } - while let Some(list) = lists.pop() { - let tail = with_bars_as_characters(&w.re[list.re_start + open_of(list.kind).len()..]); - w.re.truncate(list.re_start); - match list.kind { - b'*' => w.re.extend_from_slice(STAR), - b'?' => w.re.extend_from_slice(QMARK), - kind => w.re.extend([b'\\', kind]), - } - w.re.extend_from_slice(b"\\("); - w.re.extend_from_slice(&tail); - w.has_magic = true; - } - w.clear_state_char(); - if escaping { - w.re.extend_from_slice(b"\\\\"); - } - let Writer { - mut re, has_magic, .. - } = w; - let adds_pattern_start = matches!(re.first(), Some(b'[' | b'.' | b'(')); - for list in negative_lists.iter().rev() { - // What has been cut since has moved the text under these places. `slice` takes what is there. - let at = |place: usize| place.min(re.len()); - let (before, first) = re[..at(list.re_end - 8)].split_at(at(list.re_start)); - let last = &re[at(list.re_end - 8)..]; - let mut after = re[at(list.re_end)..].to_vec(); - for _ in 0..strings::count_char(before, b'(') { - let Some(at) = strings::index_of_char_usize(&after, b')') else { - break; - }; - let has_quantifier = matches!(after.get(at + 1), Some(b'+' | b'*' | b'?')); - after.drain(at..at + 1 + usize::from(has_quantifier)); - } - let dollar: &[u8] = if after.is_empty() && depth == 0 { - b"$" - } else { - b"" - }; - re = [before, first, &after, dollar, last].concat(); - } - if !re.is_empty() && has_magic { - re.splice(0..0, *b"(?=.)"); - } - if adds_pattern_start { - let pattern_start: &[u8] = match (pattern.first(), options.dot) { - (Some(b'.'), _) => b"", - (_, true) => br"(?!(?:^|\/)\.{1,2}(?:$|\/))", - (_, false) => br"(?!\.)", - }; - re.splice(0..0, pattern_start.iter().copied()); - } - Some(Parsed { re, has_magic }) -} - -/// The bytes of `globUnescape(part)`. -fn each_character(part: &[u8], mut take: impl FnMut(u8)) { - let mut rest = part; - while let [c, after @ ..] = rest { - let (c, after) = match (c, after) { - (b'\\', [escaped, behind @ ..]) if !bun_core::lexer::starts_with_line_break(after) => { - (escaped, behind) - } - _ => (c, after), - }; - take(*c); - rest = after; - } -} - -/// `regExpEscape(globUnescape(part))`, but for the `\` that change nothing. -fn write_characters(part: &[u8], re: &mut Vec) { - each_character(part, |c| { - if c == b'|' || c != b'!' && is_re_special(c) { - re.push(b'\\'); - } - re.push(c); - }); -} - -/// `None`: `makeRe` gives `false`, or it is beyond a limit. -fn source(pattern: &[u8], options: Options) -> Option> { - let dot = options.dot; - let pattern = strings::trim_js_whitespace(pattern); - let is_comment = pattern.starts_with(b"#") && !options.nocomment; - if pattern.len() > MAX_PATTERN_LENGTH || pattern.is_empty() || is_comment { - return None; - } - let bangs = (pattern.iter()) - .take_while(|it| **it == b'!' && !options.nonegate) - .count(); - let rest = &pattern[bangs..]; - let globs = match has_braces(rest) && !options.nobrace { - true => braces::expand(rest), - false => vec![rest.to_vec()], - }; - if globs.is_empty() { - return None; - } - let mut re = b"^(?:".to_vec(); - for (index, glob) in globs.iter().enumerate() { - if index > 0 { - re.push(b'|'); - } - // `split(/\/+/)` - let mut names = strings::split(&glob[..], b"/").enumerate().peekable(); - let mut is_first = true; - while let Some((at, part)) = names.next() { - // An empty name in the middle is a second `/`. - if part.is_empty() && at > 0 && names.peek().is_some() { - continue; - } - if !std::mem::take(&mut is_first) { - re.extend_from_slice(b"\\/"); - } - if part == b"**" && !options.noglobstar { - re.extend_from_slice(if dot { TWO_STAR_DOT } else { TWO_STAR_NO_DOT }); - continue; - } - // `if (pattern === '') return ''` - if part.is_empty() { - continue; - } - let parsed = parse(part, options, 0)?; - // What `RegExp` refuses is `/$./`, which has no `_src`: nothing is written. - if !parsed.has_magic { - write_characters(part, &mut re); - } else if is_expression(&[b"^", &parsed.re[..], b"$"].concat()) { - re.extend_from_slice(&parsed.re); - } - } - } - re.extend_from_slice(b")$"); - Some(match bangs % 2 { - 1 => [b"^(?!", &re[..], b").*$"].concat(), - _ => re, - }) -} - -/// `None`: `RegExp` refuses it. -fn program_of(written: &[u8], options: Options) -> Option { - let tree = match pieces_of(written, options) { - Ok(pieces) => nest(pieces), - Err(Unread::NotTaken) => Some(Node::Fail), - Err(Unread::NoExpression) => None, - }; - tree.map(|it| lower(it, text_of(options))) -} - -pub(crate) fn pattern(bytes: &[u8], options: Options) -> Program { - let program = source(bytes, options).and_then(|it| program_of(&it, options)); - program.unwrap_or(Program::Never) -} - -/// What `make` makes of a name, for `match`. -pub(crate) fn part(name: &[u8], options: Options) -> Part { - if name == b"**" && !options.noglobstar { - return Part::GlobStar; - } - // `if (pattern === '') return ''` - if name.is_empty() { - return Part::Literal(Box::default()); - } - match parse(name, options, 0) { - None => Part::Never, - Some(parsed) if !parsed.has_magic => { - let mut characters = Vec::with_capacity(name.len()); - each_character(name, |c| characters.push(c)); - Part::Literal(characters.into()) - } - // What `RegExp` refuses is `/$./`. - Some(parsed) => program_of(&[b"^", &parsed.re[..], b"$"].concat(), options) - .map_or(Part::Never, Part::Name), - } -} diff --git a/src/glob/read_picomatch.rs b/src/glob/read_picomatch.rs deleted file mode 100644 index 535f9cde0560..000000000000 --- a/src/glob/read_picomatch.rs +++ /dev/null @@ -1,1641 +0,0 @@ -//! A pattern of picomatch 2.3.2: `parse.js`, token by token. What it writes is a list of bytes of the pattern and eleven constants. - -use crate::braces; -use crate::class::{self, Class, Escape, Read}; -use crate::node::{Assertion, MAX_NESTING, Node, Open, Piece, Program, lower, nest}; -use crate::unit::Text; -use bun_collections::StringHashMap; -use bun_core::strings::{self, is_regexp_word_byte as is_word}; - -// ───────────────────────────── what is written ───────────────────────────── - -/// Below 256 a byte, else one of the constants. -type Written = Vec; - -/// `[^/]` -const QMARK: u16 = 256; -/// `[^/]*?` -const STAR: u16 = 257; -/// `(?=.)` -const ONE_CHAR: u16 = 258; -/// `(?!\.)` -const NO_DOT: u16 = 259; -/// `(?!(?:^|\/)\.{1,2}(?:\/|$))` -const NO_DOTS: u16 = 260; -/// `(?!\.{0,1}(?:\/|$))` -const NO_DOT_SLASH: u16 = 261; -/// `(?!\.{1,2}(?:\/|$))` -const NO_DOTS_SLASH: u16 = 262; -/// `[^.\/]` -const QMARK_NO_DOT: u16 = 263; -/// `(?:(?:(?!(?:^|\/)\.{1,2}(?:\/|$)).)*?)`. Without `dot`: `(?:(?:(?!(?:^|\/)\.).)*?)` -const GLOBSTAR: u16 = 264; -/// `(?:` -const OPEN: u16 = 265; -/// `(?!` -const OPEN_NOT: u16 = 266; - -const BANG: u16 = b'!' as u16; -const DOLLAR: u16 = b'$' as u16; -const OPEN_PAREN: u16 = b'(' as u16; -const CLOSE_PAREN: u16 = b')' as u16; -const ASTERISK: u16 = b'*' as u16; -const PLUS: u16 = b'+' as u16; -const COMMA: u16 = b',' as u16; -const MINUS: u16 = b'-' as u16; -const DOT: u16 = b'.' as u16; -const SLASH: u16 = b'/' as u16; -const COLON: u16 = b':' as u16; -const LESS: u16 = b'<' as u16; -const EQUALS: u16 = b'=' as u16; -const QUESTION: u16 = b'?' as u16; -const OPEN_SQUARE: u16 = b'[' as u16; -const BACKSLASH: u16 = b'\\' as u16; -const CLOSE_SQUARE: u16 = b']' as u16; -const CARET: u16 = b'^' as u16; -const OPEN_CURLY: u16 = b'{' as u16; -const BAR: u16 = b'|' as u16; -const CLOSE_CURLY: u16 = b'}' as u16; - -const DOT_LITERAL: [u16; 2] = [BACKSLASH, DOT]; -const PLUS_LITERAL: [u16; 2] = [BACKSLASH, PLUS]; -const SLASH_LITERAL: [u16; 2] = [BACKSLASH, SLASH]; - -fn written(text: &[u8]) -> Written { - text.iter().map(|byte| u16::from(*byte)).collect() -} - -fn is(written: &[u16], text: &[u8]) -> bool { - written.len() == text.len() && written.iter().zip(text).all(|(a, b)| *a == u16::from(*b)) -} - -fn count(written: &[u16], wanted: u16) -> usize { - written.iter().filter(|it| **it == wanted).count() -} - -/// A constant with a `(` in it as the text that the reference writes: for `escapeLast`, which cuts one in two. -fn constant_text(constant: u16, dot: bool) -> Option<&'static [u8]> { - Some(match constant { - ONE_CHAR => &b"(?=.)"[..], - NO_DOT => b"(?!\\.)", - NO_DOTS => b"(?!(?:^|\\/)\\.{1,2}(?:\\/|$))", - NO_DOT_SLASH => b"(?!\\.{0,1}(?:\\/|$))", - NO_DOTS_SLASH => b"(?!\\.{1,2}(?:\\/|$))", - GLOBSTAR if dot => b"(?:(?:(?!(?:^|\\/)\\.{1,2}(?:\\/|$)).)*?)", - GLOBSTAR => b"(?:(?:(?!(?:^|\\/)\\.).)*?)", - OPEN => b"(?:", - OPEN_NOT => b"(?!", - _ => return None, - }) -} - -/// `/[-*+?.^${}(|)[\]]/` -fn is_regex_char(c: u16) -> bool { - const MEMBERS: [u16; 14] = [ - MINUS, - ASTERISK, - PLUS, - QUESTION, - DOT, - CARET, - DOLLAR, - OPEN_CURLY, - CLOSE_CURLY, - OPEN_PAREN, - BAR, - CLOSE_PAREN, - OPEN_SQUARE, - CLOSE_SQUARE, - ]; - MEMBERS.contains(&c) -} - -/// `utils.escapeRegex` -fn escape_regex(text: &[u16]) -> Written { - let mut out = Written::with_capacity(text.len() + 2); - for c in text { - if is_regex_char(*c) { - out.push(BACKSLASH); - } - out.push(*c); - } - out -} - -/// `/^[^@![\].,$*+?^{}()|\\/]+/`: one of those that it leaves out. -fn is_special(byte: u8) -> bool { - matches!( - byte, - b'@' | b'!' - | b'[' - | b']' - | b'.' - | b',' - | b'$' - | b'*' - | b'+' - | b'?' - | b'^' - | b'{' - | b'}' - | b'(' - | b')' - | b'|' - | b'\\' - | b'/' - ) -} - -const POSIX_REGEX_SOURCE: [(&[u8], &[u8]); 14] = [ - (b"alnum", b"a-zA-Z0-9"), - (b"alpha", b"a-zA-Z"), - (b"ascii", b"\\x00-\\x7F"), - (b"blank", b" \\t"), - (b"cntrl", b"\\x00-\\x1F\\x7F"), - (b"digit", b"0-9"), - (b"graph", b"\\x21-\\x7E"), - (b"lower", b"a-z"), - (b"print", b"\\x20-\\x7E "), - (b"punct", b"\\-!\"#$%&'()\\*+,./:;<=>?@[\\]^_`{|}~"), - (b"space", b" \\t\\r\\n\\v\\f"), - (b"upper", b"A-Z"), - (b"word", b"A-Za-z0-9_"), - (b"xdigit", b"A-Fa-f0-9"), -]; - -/// How many brackets of all kinds can be open. picomatch has no such limit. Beyond it the pattern matches nothing. -const MAX_OPEN: usize = MAX_NESTING - 8; - -/// Why a written text has no pieces. -pub(crate) enum Unread { - /// It is no expression. - NoExpression, - /// It is one, with what is not taken: lookbehind, a named group. - NotTaken, -} - -/// The pieces of a written text. Of the pattern: `\x`, a class, `(`, `(?:`, `(?=`, `(?!`, `)`, `|`, `?`, `+`, `*`, `.`, `^`, `$`. -pub(crate) fn read_written(written: &[u16], dot: bool, text: Text) -> Result, Unread> { - let mut out = Vec::with_capacity(written.len()); - let assert = |assertion: Assertion| Piece::Node(Node::Assert(assertion)); - // A class and an escape are read from bytes. - let bytes: Vec = written - .iter() - .map(|c| u8::try_from(*c).unwrap_or(0xFF)) - .collect(); - let is_constant = |c: &u16| *c >= 256; - let mut i = 0; - while let Some(&c) = written.get(i) { - i += 1; - let piece = match c { - QMARK => Piece::Node(Node::Any), - STAR => Piece::Node(Node::Star), - ONE_CHAR => assert(Assertion::SomeUnit), - NO_DOT => assert(Assertion::NotDot), - NO_DOTS => assert(Assertion::NotDotsBehindStart), - NO_DOT_SLASH => assert(Assertion::NotDotOrEmpty), - NO_DOTS_SLASH => assert(Assertion::NotDots), - QMARK_NO_DOT => Piece::Node(Node::Class(Class::not_dot_or_slash())), - GLOBSTAR => { - let guard = Node::Assert(match dot { - true => Assertion::NotDotsBehindStart, - false => Assertion::NotDotBehindStart, - }); - let unit = Node::Seq(vec![guard, Node::Dot { newlines: false }]); - Piece::Node(Node::repeat(unit, 0, true)) - } - OPEN => Piece::Open(Open::Group), - OPEN_NOT => Piece::Open(Open::NotLook), - BACKSLASH => { - if written.get(i).is_none_or(is_constant) { - return Err(Unread::NoExpression); - } - let (piece, len) = match class::javascript_escape(&bytes, i - 1, false) { - // A half of a pair stays with its other half. - Escape::Unit { unit, len } if (0xD800..=0xDFFF).contains(&unit) => { - let half = bytes.get(i..i - 1 + len).unwrap_or_default(); - (Piece::Node(Node::Lit(half.to_vec())), len) - } - Escape::Unit { unit, len } => (Piece::Node(Node::of_unit(unit)), len), - Escape::Ranges(ranges) => ( - Piece::Node(Node::Class(Class::of_ranges(ranges, text.folds))), - 2, - ), - Escape::WordBoundary { negated: true } => { - (assert(Assertion::NotWordBoundary), 2) - } - Escape::WordBoundary { negated: false } => (assert(Assertion::WordBoundary), 2), - Escape::Backslash => (Piece::Node(Node::Lit(vec![b'\\'])), 1), - Escape::Reference { number, unit, len } => { - (Piece::Reference { number, unit }, len) - } - }; - i += len - 1; - piece - } - OPEN_SQUARE => { - let Read::Class { class, len, .. } = class::javascript(&bytes, i - 1, text.folds) - else { - return Err(Unread::NoExpression); - }; - let inside = written.get(i - 1..i - 1 + len).unwrap_or_default(); - if inside.iter().any(is_constant) { - return Err(Unread::NoExpression); - } - i += len - 1; - Piece::Node(Node::Class(class)) - } - OPEN_PAREN if written.get(i) != Some(&QUESTION) => Piece::Open(Open::Capture), - OPEN_PAREN => { - i += 2; - Piece::Open(match written.get(i - 1) { - Some(&COLON) => Open::Group, - Some(&EQUALS) => Open::Look, - Some(&BANG) => Open::NotLook, - Some(&LESS) => return Err(Unread::NotTaken), - _ => return Err(Unread::NoExpression), - }) - } - CLOSE_PAREN => Piece::Close, - BAR => Piece::Bar, - QUESTION | PLUS | ASTERISK => { - // Lazy or greedy is the same here. - i += usize::from(written.get(i) == Some(&QUESTION)); - Piece::Quant { - min: u8::from(c == PLUS), - unbounded: c != QUESTION, - } - } - // `\.{1,2}` and `\.{0,1}`, of a constant that has been written out. A `{` of the pattern is never written as it is. - OPEN_CURLY - if written.get(i + 1) == Some(&COMMA) - && written.get(i + 3) == Some(&CLOSE_CURLY) - && matches!(out.last(), Some(Piece::Node(Node::Lit(_)))) => - { - if written.get(i) == Some(&u16::from(b'1')) - && let Some(Piece::Node(Node::Lit(last))) = out.last() - { - let again = last.clone(); - out.push(Piece::Node(Node::Lit(again))); - } - i += 4; - Piece::Quant { - min: 0, - unbounded: false, - } - } - DOT => Piece::Node(Node::Dot { newlines: false }), - CARET => assert(Assertion::Start), - DOLLAR => assert(Assertion::End), - // A character. One piece for each, so that a `+` behind it repeats the right thing. - _ => { - let start = i - 1; - while bytes.get(i).is_some_and(|it| (0x80..=0xBF).contains(it)) - && written.get(i).is_some_and(|it| !is_constant(it)) - { - i += 1; - } - Piece::Node(Node::Lit(bytes.get(start..i).unwrap_or_default().to_vec())) - } - }; - out.push(piece); - } - Ok(out) -} - -// ───────────────────────────── the helpers of parse.js for `+(..)` and `*(..)` ───────────────────────────── - -/// `String.prototype.trim`, of ASCII. -fn trim(text: &[u8]) -> &[u8] { - let is_space = |byte: &u8| matches!(byte, 9..=13 | b' '); - let from = text.iter().take_while(|it| is_space(it)).count(); - let rest = &text[from..]; - &rest[..rest.len() - rest.iter().rev().take_while(|it| is_space(it)).count()] -} - -/// What a scan of a pattern is in. -#[derive(Default)] -struct Nesting { - bracket: usize, - paren: usize, - is_quoted: bool, - is_escaped: bool, -} - -fn split_top_level(input: &[u8]) -> Vec<&[u8]> { - let mut parts = Vec::new(); - let mut nesting = Nesting::default(); - let mut start = 0; - for (at, ch) in input.iter().enumerate() { - if nesting.is_escaped { - nesting.is_escaped = false; - continue; - } - match ch { - b'\\' => nesting.is_escaped = true, - b'"' => nesting.is_quoted = !nesting.is_quoted, - _ if nesting.is_quoted => {} - b'[' => nesting.bracket += 1, - b']' if nesting.bracket > 0 => nesting.bracket -= 1, - _ if nesting.bracket > 0 => {} - b'(' => nesting.paren += 1, - b')' if nesting.paren > 0 => nesting.paren -= 1, - b'|' if nesting.paren == 0 => { - parts.push(&input[start..at]); - start = at + 1; - } - _ => {} - } - } - parts.push(&input[start..]); - parts -} - -fn is_plain_branch(branch: &[u8]) -> bool { - let mut is_escaped = false; - for ch in branch { - if is_escaped { - is_escaped = false; - } else if *ch == b'\\' { - is_escaped = true; - } else if matches!( - ch, - b'?' | b'*' | b'+' | b'@' | b'!' | b'(' | b')' | b'[' | b']' | b'{' | b'}' - ) { - return false; - } - } - true -} - -/// The branch without `@(..)` around it and without escapes. `None` if it is not plain. -fn normalize_simple_branch(branch: &[u8]) -> Option> { - let mut value = trim(branch); - // `/^@\([^\\()[\]{}|]+\)$/` - while let [b'@', b'(', inner @ .., b')'] = value - && !inner.is_empty() - && !strings::contains_any(inner, b"\\()[]{}|") - { - value = inner; - } - if !is_plain_branch(value) { - return None; - } - // `/\\(.)/g` -> `$1` - let mut out = Vec::with_capacity(value.len()); - let mut rest = value; - while let [first, after @ ..] = rest { - rest = after; - match (first, after) { - (b'\\', [escaped, after @ ..]) if !matches!(escaped, b'\n' | b'\r') => { - out.push(*escaped); - rest = after; - } - _ => out.push(*first), - } - } - Some(out) -} - -/// The length of the first character of `text` in bytes. -fn first_len(text: &[u8]) -> usize { - match text.first() { - None | Some(0..0x80) => 1, - Some(0x80..0xE0) => 2, - Some(0xE0..0xF0) => 3, - Some(_) => 4, - } -} - -/// Two branches that are one and the same character, repeated: `a|aa`. They always overlap, so the characters are remembered. -fn has_repeated_char_prefix_overlap(branches: &[&[u8]]) -> bool { - let mut seen = StringHashMap::<()>::new(); - for branch in branches { - let Some(value) = normalize_simple_branch(branch).filter(|it| !it.is_empty()) else { - continue; - }; - let len = first_len(&value); - // A character beyond U+FFFF is two units, which are not each other. - let is_repeat = len < 4 - && value.len().is_multiple_of(len) - && value - .iter() - .zip(value.iter().skip(len)) - .all(|(a, b)| a == b); - if is_repeat && bun_core::handle_oom(seen.get_or_put(&value[..len])).found_existing { - return true; - } - } - false -} - -/// `+(..)` or `*(..)`. -struct Repeated<'p> { - kind: u8, - body: &'p [u8], - /// Where its `)` is. - end: usize, -} - -/// What `pattern` starts with. `None`: it is neither, or (`require_end`) something follows it. -fn parse_repeated_extglob(pattern: &[u8], require_end: bool) -> Option> { - let [kind @ (b'+' | b'*'), b'(', ..] = pattern else { - return None; - }; - let mut nesting = Nesting::default(); - for (i, ch) in pattern.iter().enumerate().skip(1) { - if nesting.is_escaped { - nesting.is_escaped = false; - continue; - } - match ch { - b'\\' => nesting.is_escaped = true, - b'"' => nesting.is_quoted = !nesting.is_quoted, - _ if nesting.is_quoted => {} - b'[' => nesting.bracket += 1, - b']' if nesting.bracket > 0 => nesting.bracket -= 1, - _ if nesting.bracket > 0 => {} - b'(' => nesting.paren += 1, - b')' => { - nesting.paren = nesting.paren.saturating_sub(1); - if nesting.paren == 0 { - return (!require_end || i + 1 == pattern.len()).then_some(Repeated { - kind: *kind, - body: &pattern[2..i], - end: i, - }); - } - } - _ => {} - } - } - None -} - -/// `*(a)*(b)`: `[ab]*`. `None`: it is not of that form. -fn star_extglob_sequence_output(pattern: &[u8]) -> Option { - let mut chars: Vec = Vec::new(); - let mut rest = pattern; - while !rest.is_empty() { - let found = parse_repeated_extglob(rest, false).filter(|it| it.kind == b'*')?; - let [only] = split_top_level(found.body)[..] else { - return None; - }; - let branch = normalize_simple_branch(only).filter(|it| !it.is_empty())?; - // One UTF-16 unit. - if branch.len() != first_len(&branch) || branch.len() == 4 { - return None; - } - chars.push(escape_regex(&written(&branch))); - rest = &rest[found.end + 1..]; - } - let mut out = match &chars[..] { - [] => return None, - [only] => only.clone(), - all => [&[OPEN_SQUARE][..], &all.concat(), &[CLOSE_SQUARE]].concat(), - }; - out.push(ASTERISK); - Some(out) -} - -/// `analyzeRepeatedExtglob`: whether it is risky, and what is written for it then, if not the characters that it is. -fn analyze_repeated_extglob(body: &[u8]) -> (bool, Option) { - let branches: Vec<&[u8]> = split_top_level(body).into_iter().map(trim).collect(); - // Empty, or `/^[*?]+$/` - let is_wild = |branch: &&[u8]| branch.iter().all(|c| matches!(c, b'*' | b'?')); - if branches.len() > 1 - && (branches.iter().any(is_wild) || has_repeated_char_prefix_overlap(&branches)) - { - return (true, None); - } - for branch in branches { - if let Some(safe_output) = star_extglob_sequence_output(branch) { - return (true, Some(safe_output)); - } - // `maxExtglobRecursion` is 0: one in it that is all of a branch is too many. - if parse_repeated_extglob(branch, true).is_some() { - return (true, None); - } - } - (false, None) -} - -// ───────────────────────────── parse.js ───────────────────────────── - -#[derive(Copy, Clone, PartialEq, Eq)] -enum Kind { - Bos, - Text, - Star, - Globstar, - Slash, - Paren, - Bracket, - Brace, - Comma, - Dot, - Dots, - Qmark, - Plus, - At, - Negate, - MaybeSlash, -} - -/// What is open: `stack` of the reference. -#[derive(Copy, Clone, PartialEq, Eq)] -enum Opened { - Parens, - Braces, - Brackets, -} - -struct Token { - kind: Kind, - value: Written, - /// `None`: it is written as `value`. - output: Option, - /// Where in `Parser::all`. - prev: Option, - is_extglob: bool, - is_star: bool, - is_posix: bool, - /// Of a `[`: how many `[` and `:` are in `value` behind its first byte. - inner_opens: usize, - inner_colons: usize, - /// Of a `{`. - has_comma: bool, - has_dots: bool, - output_index: usize, - tokens_index: usize, -} - -impl Token { - fn new(kind: Kind, value: Written, output: Option) -> Token { - Token { - kind, - value, - output, - prev: None, - is_extglob: false, - is_star: false, - is_posix: false, - inner_opens: 0, - inner_colons: 0, - has_comma: false, - has_dots: false, - output_index: 0, - tokens_index: 0, - } - } - - fn written(&self) -> &[u16] { - self.output.as_ref().unwrap_or(&self.value) - } - - fn output_len(&self) -> usize { - self.output.as_ref().map_or(0, Vec::len) - } - - /// One more member of a class. - fn add_member(&mut self, member: &[u16]) { - self.inner_opens += count(member, OPEN_SQUARE); - self.inner_colons += count(member, COLON); - self.value.extend_from_slice(member); - } -} - -/// An extglob that is open. -struct Extglob { - kind: Kind, - close: Written, - /// Of the values of the tokens in it: how long they are, and whether they have a `/` and a `*`. - inner_len: usize, - has_slash: bool, - has_star: bool, - parens: isize, - /// How much was written before it. - output_len: usize, - start_index: usize, - tokens_index: usize, -} - -fn replaced(input: &[u8]) -> &[u8] { - match input { - b"***" => b"*", - b"**/**" | b"**/**/**" => b"**", - _ => input, - } -} - -/// `utils.escapeLast(output, byte)`. The `(` that is found can be one that `parse.js` wrote itself: `src(/**` matches `src`. -fn escape_last(output: &mut Written, byte: u16, dot: bool) { - for at in (0..output.len()).rev() { - if byte == OPEN_PAREN - && let Some(text) = constant_text(output[at], dot) - { - let mut text = written(text); - let last = text.iter().rposition(|c| *c == byte).unwrap_or(0); - text.insert(last, BACKSLASH); - output.splice(at..=at, text); - return; - } - if output[at] == byte && (at == 0 || output[at - 1] != BACKSLASH) { - return output.insert(at, BACKSLASH); - } - } -} - -/// `/[!=<:]/` -fn is_group_kind(byte: Option) -> bool { - matches!(byte, Some(b'!' | b'=' | b'<' | b':')) -} - -struct Parser<'i> { - input: &'i [u8], - dot: bool, - posix: bool, - /// `parse` calls itself for `.ext` behind a `!(..)`. - depth: usize, - /// Every token that was made, and where in it those are that count. The first is the start of the pattern. - all: Vec, - tokens: Vec, - prev: usize, - /// How much of the input has been read: `state.index + 1`. - at: usize, - start: usize, - output: Written, - backtrack: bool, - negated: bool, - brackets: isize, - braces: isize, - parens: isize, - is_quoted: bool, - extglobs: Vec, - open_braces: Vec, - stack: Vec, - is_broken: bool, - /// Where the last `` of the input starts, once it has been looked for. - last_angle: Option>, -} - -impl Parser<'_> { - fn eos(&self) -> bool { - self.at >= self.input.len() - } - - /// `peek(n)` - fn peek(&self, n: usize) -> Option { - self.input.get(self.at + n - 1).copied() - } - - fn advance(&mut self) -> Option { - self.at += 1; - self.input.get(self.at - 1).copied() - } - - fn cut(&mut self, count: usize) { - self.output - .truncate(self.output.len().saturating_sub(count)); - } - - fn counter(&mut self, kind: Opened) -> &mut isize { - match kind { - Opened::Parens => &mut self.parens, - Opened::Braces => &mut self.braces, - Opened::Brackets => &mut self.brackets, - } - } - - fn increment(&mut self, kind: Opened) { - *self.counter(kind) += 1; - self.stack.push(kind); - self.is_broken |= self.stack.len() > MAX_OPEN; - } - - fn decrement(&mut self, kind: Opened) { - *self.counter(kind) -= 1; - self.stack.pop(); - } - - fn prev(&self) -> &Token { - &self.all[self.prev] - } - - fn prev_mut(&mut self) -> &mut Token { - &mut self.all[self.prev] - } - - /// `/<([!=]|\w+>)/.test(input[from..])`, without a search each time. - fn has_angle_behind(&mut self, from: usize) -> bool { - let input = self.input; - let last = *self.last_angle.get_or_insert_with(|| { - (0..input.len()).rev().find(|at| match &input[*at..] { - [b'<', b'!' | b'=', ..] => true, - [b'<', after @ ..] => { - let word = after.iter().take_while(|it| is_word(**it)).count(); - word > 0 && after.get(word) == Some(&b'>') - } - _ => false, - }) - }); - last.is_some_and(|it| it >= from) - } - - fn push(&mut self, tok: Token) { - if self.prev().kind == Kind::Globstar { - let is_brace = self.braces > 0 && matches!(tok.kind, Kind::Comma | Kind::Brace); - let is_extglob = tok.is_extglob || !self.extglobs.is_empty() && tok.kind == Kind::Paren; - if !matches!(tok.kind, Kind::Slash | Kind::Paren) && !is_brace && !is_extglob { - self.cut(self.prev().output_len()); - let prev = self.prev_mut(); - prev.kind = Kind::Star; - prev.value = vec![ASTERISK]; - prev.output = Some(vec![STAR]); - self.output.push(STAR); - } - } - if tok.kind != Kind::Paren - && let Some(extglob) = self.extglobs.last_mut() - { - extglob.inner_len += tok.value.len(); - extglob.has_slash |= count(&tok.value, SLASH) > 0; - extglob.has_star |= count(&tok.value, ASTERISK) > 0; - } - self.output.extend_from_slice(tok.written()); - let prev = self.prev_mut(); - if prev.kind == Kind::Text && tok.kind == Kind::Text { - // As the reference has it: what the first of them was written as is lost if the expression is put together again. - prev.output - .get_or_insert_default() - .extend_from_slice(&tok.value); - prev.value.extend_from_slice(&tok.value); - return; - } - self.all.push(Token { - prev: Some(self.prev), - ..tok - }); - self.prev = self.all.len() - 1; - self.tokens.push(self.prev); - } - - fn push_new(&mut self, kind: Kind, value: &[u16], output: Option<&[u16]>) { - self.push(Token::new( - kind, - value.to_vec(), - output.map(<[u16]>::to_vec), - )); - } - - /// `value`: `?`, `!`, `+` or `*`, before a `(`. - fn extglob_open(&mut self, kind: Kind, value: u8) { - let (open, close): (&[u16], &[u16]) = match kind { - Kind::Negate => ( - &[OPEN, OPEN_NOT, OPEN], - &[CLOSE_PAREN, CLOSE_PAREN, STAR, CLOSE_PAREN], - ), - Kind::Qmark => (&[OPEN], &[CLOSE_PAREN, QUESTION]), - Kind::Plus => (&[OPEN], &[CLOSE_PAREN, PLUS]), - _ => (&[OPEN], &[CLOSE_PAREN, ASTERISK]), - }; - let extglob = Extglob { - kind, - close: close.to_vec(), - inner_len: 0, - has_slash: false, - has_star: false, - parens: self.parens, - output_len: self.output.len(), - start_index: self.at - 1, - tokens_index: self.tokens.len(), - }; - self.increment(Opened::Parens); - let guard: &[u16] = if self.output.is_empty() { - &[ONE_CHAR] - } else { - &[] - }; - self.push_new(kind, &[u16::from(value)], Some(guard)); - self.advance(); - self.push(Token { - is_extglob: true, - ..Token::new(Kind::Paren, vec![OPEN_PAREN], Some(open.to_vec())) - }); - self.extglobs.push(extglob); - } - - /// At the `)` of `it`. - fn extglob_close(&mut self, it: Extglob) { - let input = self.input; - let mut output = it.close; - if matches!(it.kind, Kind::Plus | Kind::Star) { - let body = input - .get(it.start_index + 2..self.at - 1) - .unwrap_or_default(); - if let (true, safe_output) = analyze_repeated_extglob(body) { - // It is taken as the characters that it is, or as a class with a `*`. - let literal = written(input.get(it.start_index..self.at).unwrap_or_default()); - let as_written = match safe_output { - Some(safe) if it.output_len == 0 => [&[ONE_CHAR][..], &safe].concat(), - Some(safe) => safe, - None => escape_regex(&literal), - }; - self.output.truncate(it.output_len); - self.output.extend_from_slice(&as_written); - let mut inside = self - .tokens - .get(it.tokens_index..) - .unwrap_or_default() - .iter(); - if let Some(open) = inside.next() { - let open = &mut self.all[*open]; - open.kind = Kind::Text; - open.value = literal; - open.output = Some(as_written); - } - for token in inside { - self.all[*token].value = Written::new(); - self.all[*token].output = Some(Written::new()); - } - self.backtrack = true; - output = Written::new(); - } - } else if it.kind == Kind::Negate { - let rest = input.get(self.at..).unwrap_or_default(); - let extglob_star = match it.inner_len > 1 && it.has_slash { - true => GLOBSTAR, - false => STAR, - }; - // The end, or `/^\)+$/.test(remaining())` - if extglob_star != STAR || rest.iter().all(|c| *c == b')') { - output = vec![CLOSE_PAREN, DOLLAR, CLOSE_PAREN, CLOSE_PAREN, extglob_star]; - } - // `/^\.[^\\/.]+$/.test(rest)`: `.ts`, `.{ts,tsx}`, `.@(js)` behind it is what must not follow either. - if it.has_star - && let [b'.', extension @ ..] = rest - && !extension.is_empty() - && !strings::contains_any(extension, b"\\/.") - { - match parse(rest, self.dot, self.posix, false, self.depth + 1) { - Some((expression, _)) => { - output = vec![CLOSE_PAREN]; - output.extend(expression); - output.extend([CLOSE_PAREN, extglob_star, CLOSE_PAREN]); - } - None => self.is_broken = true, - } - } - } - self.push(Token { - is_extglob: true, - ..Token::new(Kind::Paren, vec![CLOSE_PAREN], Some(output)) - }); - self.decrement(Opened::Parens); - } - - /// A `:` in a class: `[:alpha:]` has been read up to its second `:`. Whether it has been replaced. - fn posix_class(&mut self) -> bool { - let prev = self.prev_mut(); - if prev.inner_opens == 0 { - return false; - } - prev.is_posix = true; - if prev.inner_colons == 0 { - return false; - } - // No name is longer than six, so a `[` that is further back is of no name. - let tail = prev.value.len().saturating_sub(8); - let Some(at) = prev.value[tail..].iter().rposition(|c| *c == OPEN_SQUARE) else { - return false; - }; - let name = prev.value.split_off(tail + at); - let Some((_, source)) = POSIX_REGEX_SOURCE - .iter() - .find(|it| name.get(2..).is_some_and(|name| is(name, it.0))) - else { - prev.value.extend(name); - return false; - }; - prev.inner_opens -= 1; - prev.inner_colons -= count(&name, COLON); - prev.add_member(&written(source)); - self.backtrack = true; - self.advance(); - if self.all[0].output_len() == 0 && self.tokens.get(1) == Some(&self.prev) { - self.all[0].output = Some(vec![ONE_CHAR]); - } - true - } - - /// At a `}` that closes `brace`. - fn brace_close(&mut self, brace: usize) { - let (mut closing, mut output) = (vec![CLOSE_CURLY], vec![CLOSE_PAREN]); - if self.all[brace].has_dots { - let mut range: Vec = Vec::new(); - while let Some(it) = self.tokens.pop() { - let it = &self.all[it]; - match it.kind { - Kind::Brace => break, - Kind::Dots => {} - _ => range.push(it.value.clone()), - } - } - output = expand_range(range, self.dot); - self.backtrack = true; - } else if !self.all[brace].has_comma { - self.output.truncate(self.all[brace].output_index); - self.all[brace].value = vec![BACKSLASH, OPEN_CURLY]; - self.all[brace].output = Some(vec![BACKSLASH, OPEN_CURLY]); - closing = vec![BACKSLASH, CLOSE_CURLY]; - output.clone_from(&closing); - let inside = self.tokens.get(self.all[brace].tokens_index..); - for it in inside.unwrap_or_default() { - let it = &self.all[*it]; - self.output.extend_from_slice(match &it.output { - Some(output) if !output.is_empty() => output, - _ => &it.value, - }); - } - } - self.push(Token::new(Kind::Brace, closing, Some(output))); - self.decrement(Opened::Braces); - self.open_braces.pop(); - } - - /// At the second `*` of two. `prev` is the first. - fn second_star(&mut self) { - let input = self.input; - let Some(prior) = self.prev().prev else { - return; - }; - let kind_of = |it: Option| it.map(|it| self.all[it].kind); - let (prior_kind, before) = (self.all[prior].kind, kind_of(self.all[prior].prev)); - let is_start = matches!(prior_kind, Kind::Slash | Kind::Bos); - let after_star = matches!(before, Some(Kind::Star | Kind::Globstar)); - let is_brace = self.braces > 0 && matches!(prior_kind, Kind::Comma | Kind::Brace); - if !is_start && prior_kind != Kind::Paren && !is_brace { - return self.push_new(Kind::Star, &[ASTERISK], Some(&[])); - } - // Consecutive `/**/` are one. - while input - .get(self.at..) - .is_some_and(|rest| rest.starts_with(b"/**")) - && matches!(input.get(self.at + 3), None | Some(b'/')) - { - self.at += 3; - } - let is_before_slash = input.get(self.at) == Some(&b'/'); - let is_behind_name = prior_kind == Kind::Slash && before != Some(Kind::Bos); - let is_first = prior_kind == Kind::Bos; - self.prev_mut().value.push(ASTERISK); - self.prev_mut().kind = Kind::Globstar; - // What is written for it, and whether `(?:` is put before the `/` that is before it. - let (output, opens_before_slash): (Written, bool) = if is_first && self.eos() { - self.output.clear(); - (vec![GLOBSTAR], false) - } else if is_behind_name && !after_star && self.eos() { - (vec![GLOBSTAR, BAR, DOLLAR, CLOSE_PAREN], true) - } else if is_behind_name && is_before_slash { - let mut output = vec![GLOBSTAR, BACKSLASH, SLASH, BAR, BACKSLASH, SLASH]; - if input.get(self.at + 1).is_some() { - output.extend([BAR, DOLLAR]); - } - output.push(CLOSE_PAREN); - (output, true) - } else if is_first && is_before_slash { - self.output.clear(); - let output = [ - &[OPEN, CARET, BAR][..], - &SLASH_LITERAL, - &[BAR, GLOBSTAR], - &SLASH_LITERAL, - &[CLOSE_PAREN], - ]; - (output.concat(), false) - } else { - self.cut(self.prev().output_len()); - self.prev_mut().output = Some(vec![GLOBSTAR]); - return self.output.push(GLOBSTAR); - }; - if opens_before_slash { - self.cut(self.all[prior].output_len() + self.prev().output_len()); - let prior = &mut self.all[prior]; - prior.output.get_or_insert_default().insert(0, OPEN); - self.output.extend_from_slice(prior.written()); - } - self.output.extend_from_slice(&output); - self.prev_mut().output = Some(output); - if !self.eos() { - self.advance(); - self.push_new(Kind::Slash, &[SLASH], Some(&[])); - } - } - - fn run(&mut self) { - let input = self.input; - let last_close_square = strings::last_index_of_char(input, b']'); - while !self.eos() && !self.is_broken { - let Some(value) = self.advance() else { - break; - }; - let unit = u16::from(value); - if value == 0 { - continue; - } - - // Escaped characters. `text`: `value` where it is more than one character. - let mut text: Option = None; - if value == b'\\' { - match self.peek(1) { - Some(b'/' | b'.' | b';') => continue, - None => { - self.push_new(Kind::Text, &[BACKSLASH, BACKSLASH], None); - continue; - } - Some(_) => {} - } - let mut escaped = vec![BACKSLASH]; - let rest = input.get(self.at..).unwrap_or_default(); - let slashes = rest.iter().take_while(|it| **it == b'\\').count(); - if slashes > 2 { - self.at += slashes; - if !slashes.is_multiple_of(2) { - escaped.push(BACKSLASH); - } - } - // One UTF-16 unit, not one byte: a character of up to three bytes, or the first half of four. - let after = self.advance(); - escaped.extend(after.map(u16::from)); - let more = match after { - Some(0xC0..0xE0 | 0xF0..) => 1, - Some(0xE0..0xF0) => 2, - _ => 0, - }; - for _ in 0..more { - if !matches!(self.peek(1), Some(0x80..=0xBF)) { - break; - } - escaped.extend(self.advance().map(u16::from)); - } - if self.brackets == 0 { - self.push(Token::new(Kind::Text, escaped, None)); - continue; - } - text = Some(escaped); - } - - // In a class, up to the closing bracket. - let is_at_start_of_class = - is(&self.prev().value, b"[") || is(&self.prev().value, b"[^"); - if self.brackets > 0 && (text.is_some() || value != b']' || is_at_start_of_class) { - if text.is_none() && value == b':' && self.posix_class() { - continue; - } - let member = text.unwrap_or_else(|| match (value, self.peek(1)) { - (b'!', _) if self.posix && is(&self.prev().value, b"[") => vec![CARET], - (b']', _) => vec![BACKSLASH, unit], - (b'[', next) if next != Some(b':') => vec![BACKSLASH, unit], - (b'-', Some(b']')) => vec![BACKSLASH, unit], - _ => vec![unit], - }); - self.prev_mut().add_member(&member); - self.output.extend(member); - continue; - } - - // In quotes. - if value == b'"' { - self.is_quoted = !self.is_quoted; - continue; - } - if self.is_quoted { - let escaped = escape_regex(&[unit]); - self.prev_mut().value.extend_from_slice(&escaped); - self.output.extend(escaped); - continue; - } - - match value { - b'(' => { - self.increment(Opened::Parens); - self.push_new(Kind::Paren, &[unit], None); - } - b')' => { - if self - .extglobs - .last() - .is_some_and(|it| self.parens == it.parens + 1) - && let Some(extglob) = self.extglobs.pop() - { - self.extglob_close(extglob); - continue; - } - let output: &[u16] = match self.parens { - 0 => &[BACKSLASH, CLOSE_PAREN], - _ => &[CLOSE_PAREN], - }; - self.push_new(Kind::Paren, &[unit], Some(output)); - self.decrement(Opened::Parens); - } - b'[' if last_close_square.is_none_or(|it| it < self.at) => { - self.push_new(Kind::Bracket, &[BACKSLASH, unit], None); - } - b'[' => { - self.increment(Opened::Brackets); - self.push_new(Kind::Bracket, &[unit], None); - } - b']' if self.brackets == 0 - || self.prev().kind == Kind::Bracket && self.prev().value.len() == 1 => - { - self.push_new(Kind::Text, &[unit], Some(&[BACKSLASH, unit])); - } - b']' => { - self.decrement(Opened::Brackets); - let prev = &mut self.all[self.prev]; - let inside = prev.value.get(1..).unwrap_or_default(); - let is_special = inside.iter().any(|c| is_regex_char(*c)); - let adds_slash = !prev.is_posix - && inside.first() == Some(&CARET) - && count(inside, SLASH) == 0; - let closing: &[u16] = match adds_slash { - true => &[SLASH, CLOSE_SQUARE], - false => &[CLOSE_SQUARE], - }; - prev.value.extend_from_slice(closing); - self.output.extend_from_slice(closing); - if is_special { - continue; - } - // Nothing in it is special: it may be meant as the characters that it is. - let class = std::mem::take(&mut prev.value); - prev.value = [ - &[OPEN][..], - &escape_regex(&class), - &[BAR], - &class, - &[CLOSE_PAREN], - ] - .concat(); - let len = self.output.len().saturating_sub(class.len()); - self.output.truncate(len); - self.output.extend_from_slice(&prev.value); - } - b'{' => { - self.increment(Opened::Braces); - self.push(Token { - output_index: self.output.len(), - tokens_index: self.tokens.len(), - ..Token::new(Kind::Brace, vec![unit], Some(vec![OPEN_PAREN])) - }); - self.open_braces.push(self.prev); - } - b'}' => match self.open_braces.last().copied() { - Some(brace) => self.brace_close(brace), - None => self.push_new(Kind::Text, &[unit], Some(&[unit])), - }, - b'|' => self.push_new(Kind::Text, &[unit], None), - b',' => { - let brace = self.open_braces.last().copied(); - match brace.filter(|_| self.stack.last() == Some(&Opened::Braces)) { - Some(brace) => { - self.all[brace].has_comma = true; - self.push_new(Kind::Comma, &[unit], Some(&[BAR])); - } - None => self.push_new(Kind::Comma, &[unit], Some(&[unit])), - } - } - // `./` at the start is left out, any number of times. - b'/' if self.prev().kind == Kind::Dot && self.at == self.start + 2 => { - self.start = self.at; - self.output.clear(); - self.tokens.pop(); - self.prev = 0; - } - b'/' => self.push_new(Kind::Slash, &[unit], Some(&SLASH_LITERAL)), - b'.' if self.braces > 0 && self.prev().kind == Kind::Dot => { - let prev = self.prev_mut(); - if is(&prev.value, b".") { - prev.output = Some(DOT_LITERAL.to_vec()); - } - prev.kind = Kind::Dots; - prev.output.get_or_insert_default().push(unit); - prev.value.push(unit); - if let Some(brace) = self.open_braces.last() { - self.all[*brace].has_dots = true; - } - } - b'.' => { - let is_in_name = self.braces + self.parens == 0 - && !matches!(self.prev().kind, Kind::Bos | Kind::Slash); - let kind = if is_in_name { Kind::Text } else { Kind::Dot }; - self.push_new(kind, &[unit], Some(&DOT_LITERAL)); - } - b'?' => { - let is_group = is(&self.prev().value, b"("); - let next = self.peek(1); - if !is_group && next == Some(b'(') && self.peek(2) != Some(b'?') { - self.extglob_open(Kind::Qmark, value); - } else if self.prev().kind == Kind::Paren { - let is_escaped = is_group && !is_group_kind(next) - || next == Some(b'<') && !self.has_angle_behind(self.at); - let output: &[u16] = match is_escaped { - true => &[BACKSLASH, QUESTION], - false => &[QUESTION], - }; - self.push_new(Kind::Text, &[unit], Some(output)); - } else { - let is_first = matches!(self.prev().kind, Kind::Slash | Kind::Bos); - let output = if !self.dot && is_first { - QMARK_NO_DOT - } else { - QMARK - }; - self.push_new(Kind::Qmark, &[unit], Some(&[output])); - } - } - b'!' if self.peek(1) == Some(b'(') - && (self.peek(2) != Some(b'?') || !is_group_kind(self.peek(3))) => - { - self.extglob_open(Kind::Negate, value); - } - // `negate()` - b'!' if self.at == 1 => { - let mut bangs = 1; - while self.peek(1) == Some(b'!') - && (self.peek(2) != Some(b'(') || self.peek(3) == Some(b'?')) - { - self.advance(); - self.start += 1; - bangs += 1; - } - if bangs % 2 == 1 { - self.negated = true; - self.start += 1; - } - } - b'+' if self.peek(1) == Some(b'(') && self.peek(2) != Some(b'?') => { - self.extglob_open(Kind::Plus, value); - } - b'+' => { - let prev = self.prev(); - if is(&prev.value, b"(") { - self.push_new(Kind::Plus, &[unit], Some(&PLUS_LITERAL)); - } else if matches!(prev.kind, Kind::Bracket | Kind::Paren | Kind::Brace) - || self.parens > 0 - { - self.push_new(Kind::Plus, &[unit], None); - } else { - self.push_new(Kind::Plus, &PLUS_LITERAL, None); - } - } - b'@' if self.peek(1) == Some(b'(') && self.peek(2) != Some(b'?') => { - self.push(Token { - is_extglob: true, - ..Token::new(Kind::At, vec![unit], Some(Written::new())) - }); - } - b'@' => self.push_new(Kind::Text, &[unit], None), - b'*' => self.star(), - // Plain text. - _ => { - let mut run = match value { - b'$' | b'^' => vec![BACKSLASH, unit], - _ => vec![unit], - }; - let rest = input.get(self.at..).unwrap_or_default(); - let plain = rest.iter().take_while(|it| !is_special(**it)).count(); - run.extend(rest[..plain].iter().map(|it| u16::from(*it))); - self.at += plain; - self.push(Token::new(Kind::Text, run, None)); - } - } - } - } - - /// At a `*`. - fn star(&mut self) { - if self.prev().kind == Kind::Globstar || self.prev().is_star { - let prev = self.prev_mut(); - prev.kind = Kind::Star; - prev.is_star = true; - prev.value.push(ASTERISK); - prev.output = Some(vec![STAR]); - self.backtrack = true; - return; - } - // `/^\([^?]/.test(rest)` - if self.peek(1) == Some(b'(') && self.peek(2).is_some_and(|it| it != b'?') { - return self.extglob_open(Kind::Star, b'*'); - } - if self.prev().kind == Kind::Star { - return self.second_star(); - } - if self.at == self.start + 1 || matches!(self.prev().kind, Kind::Slash | Kind::Dot) { - let mut guard = vec![match (self.prev().kind, self.dot) { - (Kind::Dot, _) => NO_DOT_SLASH, - (_, true) => NO_DOTS_SLASH, - (_, false) => NO_DOT, - }]; - if self.peek(1) != Some(b'*') { - guard.push(ONE_CHAR); - } - self.output.extend_from_slice(&guard); - self.prev_mut().output.get_or_insert_default().extend(guard); - } - self.push_new(Kind::Star, &[ASTERISK], Some(&[STAR])); - } -} - -/// `parse(input, options)`: what is written, and whether it is negated. `None`: beyond `MAX_OPEN`, or a `{` is not closed. -fn parse( - input: &[u8], - dot: bool, - posix: bool, - fastpaths: bool, - depth: usize, -) -> Option<(Written, bool)> { - if depth > 4 { - return None; - } - let input = replaced(input); - // `utils.removePrefix` - let input = input.strip_prefix(b"./").unwrap_or(input); - // The fast path: nothing but names, `?`, `*`, `.` and escapes. - if fastpaths - && !matches!(input.first(), Some(b'*' | b'!')) - && !strings::contains_any(input, b"/()[]{}\"") - { - let qmark_no_dot = if dot { QMARK } else { QMARK_NO_DOT }; - let mut output = vec![CARET, OPEN]; - output.extend(fast_path(input, qmark_no_dot)); - output.extend([CLOSE_PAREN, DOLLAR]); - return Some((output, false)); - } - let mut parser = Parser { - input, - dot, - posix, - depth, - all: vec![Token::new(Kind::Bos, Written::new(), Some(Written::new()))], - tokens: vec![0], - prev: 0, - at: 0, - start: 0, - output: Written::new(), - backtrack: false, - negated: false, - brackets: 0, - braces: 0, - parens: 0, - is_quoted: false, - extglobs: Vec::new(), - open_braces: Vec::new(), - stack: Vec::new(), - is_broken: false, - last_angle: None, - }; - parser.run(); - if parser.is_broken { - return None; - } - // What is not closed is a character: the last one that is written, wherever it came from. - for (open, byte) in [(parser.brackets, OPEN_SQUARE), (parser.parens, OPEN_PAREN)] { - for _ in 0..open { - escape_last(&mut parser.output, byte, dot); - } - } - // A `{` is written `(`, which no `\` before a `{` closes: it is no expression. - if parser.braces > 0 { - return None; - } - if matches!(parser.prev().kind, Kind::Star | Kind::Bracket) { - parser.push_new(Kind::MaybeSlash, &[], Some(&[BACKSLASH, SLASH, QUESTION])); - } - // Put together again from the tokens, if one of them was changed after it was written. - if parser.backtrack { - parser.output.clear(); - for it in &parser.tokens { - parser.output.extend_from_slice(parser.all[*it].written()); - } - } - Some((parser.output, parser.negated)) -} - -/// `expandRange`: `{a..c}` is the class `[a-c]`, whatever the two are, if that is an expression at all. -fn expand_range(args: Vec, dot: bool) -> Written { - // The value of a class that may be meant as characters has a `(?:` in it. - let as_text = |it: Written| -> Written { - let mut out = Written::with_capacity(it.len()); - for c in it { - match constant_text(c, dot) { - Some(text) => out.extend(written(text)), - None => out.push(c), - } - } - out - }; - let mut args: Vec = args.into_iter().map(as_text).collect(); - // JavaScript's order of strings is that of UTF-16 units. For what is here the order of bytes is the same but beyond U+FFFF. - args.sort(); - let value = [&[OPEN_SQUARE][..], &args.join(&MINUS), &[CLOSE_SQUARE]].concat(); - let is_expression = match read_written(&value, dot, Text::UTF16) { - Ok(pieces) => nest(pieces).is_some(), - Err(Unread::NotTaken) => true, - Err(Unread::NoExpression) => false, - }; - if is_expression { - return value; - } - let escaped: Vec = args.iter().map(|it| escape_regex(it)).collect(); - escaped.join(&[DOT, DOT][..]) -} - -/// `REGEX_SPECIAL_CHARS_BACKREF` of the fast path: `/(\\?)((\W)(\3*))/g`. Then runs of `\` are cut down. -fn fast_path(input: &[u8], qmark_no_dot: u16) -> Written { - let mut out = Written::with_capacity(input.len() * 2); - let mut has_backslashes = false; - let mut i = 0; - while let Some(&byte) = input.get(i) { - if is_word(byte) { - out.push(u16::from(byte)); - i += 1; - continue; - } - let index = i; - // `(\\?)`: it is given back if nothing that is no word character follows. - let esc = byte == b'\\' && input.get(i + 1).is_some_and(|it| !is_word(*it)); - i += usize::from(esc); - // One UTF-16 unit. A character of four bytes is two, which never repeat: it is kept whole, which comes to the same. - let first = input.get(i).copied().unwrap_or(0); - let unit_len = match first { - 0..0x80 => 1, - 0x80..0xE0 => 2, - 0xE0..0xF0 => 3, - 0xF0.. => 4, - }; - let unit = input.get(i..i + unit_len).unwrap_or_default(); - let is_again = |times: usize| { - let rest = input.get(i + times * unit_len..).unwrap_or_default(); - !rest.is_empty() && rest.starts_with(unit) - }; - let mut times = 1; - while unit_len < 4 && is_again(times) { - times += 1; - } - let end = (i + times * unit_len).min(input.len()); - let (chars, all) = (written(&input[i..end]), written(&input[index..end])); - i = end; - let qmarks = |times: usize| std::iter::repeat_n(QMARK, times); - match first { - b'\\' => { - has_backslashes = true; - out.extend(all); - } - b'?' if esc => { - out.extend([BACKSLASH, QUESTION]); - out.extend(qmarks(times - 1)); - } - b'?' if index == 0 => { - out.push(qmark_no_dot); - out.extend(qmarks(times - 1)); - } - b'?' => out.extend(qmarks(times)), - b'.' => out.extend((0..times).flat_map(|_| DOT_LITERAL)), - b'*' if esc => { - out.extend([BACKSLASH, ASTERISK]); - out.extend((times > 1).then_some(STAR)); - } - b'*' => out.push(STAR), - _ if esc => out.extend(all), - _ => { - out.push(BACKSLASH); - out.extend(chars); - } - } - } - if !has_backslashes { - return out; - } - // `/\\+/g`: an even run is one `\` that is a character, an odd run is one that escapes. - let mut cut = Written::with_capacity(out.len()); - let mut rest = &out[..]; - while let Some(first) = rest.first() { - let run = rest.iter().take_while(|it| **it == BACKSLASH).count(); - cut.push(*first); - if run > 0 && run.is_multiple_of(2) { - cut.push(BACKSLASH); - } - rest = &rest[run.max(1)..]; - } - cut -} - -/// `parse.fastpaths`: eight frequent patterns, with any number of `.ext` behind them. `None`: it is none of them. -fn fastpaths(input: &[u8], dot: bool) -> Option { - let input = replaced(input); - let input = input.strip_prefix(b"./").unwrap_or(input); - let (nodot, slash_dot) = match dot { - true => (NO_DOTS, NO_DOTS_SLASH), - false => (NO_DOT, NO_DOT), - }; - const DEEP: u16 = 0; - // `/^(.*?)\.(\w+)$/`, again and again: what is behind the last `.` is letters, digits and `_`. - let mut end = input.len(); - loop { - // `DEEP` stands for `(?:` nodot globstar `\/)?`. - let source = match &input[..end] { - b"*" => Some(vec![nodot, ONE_CHAR, STAR]), - b".*" => Some(vec![BACKSLASH, DOT, ONE_CHAR, STAR]), - b"*.*" => Some(vec![nodot, STAR, BACKSLASH, DOT, ONE_CHAR, STAR]), - b"*/*" => Some(vec![ - nodot, STAR, BACKSLASH, SLASH, ONE_CHAR, slash_dot, STAR, - ]), - b"**" => Some(vec![nodot, GLOBSTAR]), - b"**/*" => Some(vec![DEEP, slash_dot, ONE_CHAR, STAR]), - b"**/*.*" => Some(vec![DEEP, slash_dot, STAR, BACKSLASH, DOT, ONE_CHAR, STAR]), - b"**/.*" => Some(vec![DEEP, BACKSLASH, DOT, ONE_CHAR, STAR]), - _ => None, - }; - if let Some(source) = source { - let mut out = Written::with_capacity(input.len() + 16); - for c in source { - match c { - DEEP => out.extend([ - OPEN, - nodot, - GLOBSTAR, - BACKSLASH, - SLASH, - CLOSE_PAREN, - QUESTION, - ]), - c => out.push(c), - } - } - // The extensions that were taken off: `.` is written `\.`, the rest is words. - for byte in &input[end..] { - if *byte == b'.' { - out.push(BACKSLASH); - } - out.push(u16::from(*byte)); - } - out.extend([BACKSLASH, SLASH, QUESTION]); - return Some(out); - } - let word = input[..end] - .iter() - .rev() - .take_while(|it| is_word(**it)) - .count(); - let dot_at = (end - word).checked_sub(1)?; - // `.*?` takes no line terminator. One would end up here as the last character, and be no word. - if word == 0 || input[dot_at] != b'.' { - return None; - } - end = dot_at; - } -} - -/// `picomatch.makeRe(pattern, { dot, posix })`. Negated: `^(?!^(?:..)$).*$`, and the program is what is in the lookahead. -pub(crate) fn pattern(bytes: &[u8], dot: bool, posix: bool) -> (Program, bool) { - let fast = match bytes.first() { - Some(b'.' | b'*') => fastpaths(bytes, dot).map(|output| (output, false)), - _ => None, - }; - // No expression: `toRegex` catches that, and `/$^/` matches nothing, negated or not. - let never = (Program::Never, false); - let Some((output, negated)) = fast.or_else(|| parse(bytes, dot, posix, true, 0)) else { - return never; - }; - let whole = [&[CARET, OPEN][..], &output, &[CLOSE_PAREN, DOLLAR]].concat(); - match read_written(&whole, dot, Text::UTF16).map(nest) { - Ok(Some(tree)) => (lower(tree, Text::UTF16), negated), - // It is an expression, and one that is not followed. It matches nothing, so negated it matches all. - Err(Unread::NotTaken) => (Program::Never, negated), - Ok(None) | Err(Unread::NoExpression) => never, - } -} - -/// `processPatterns` of fast-glob: `braces` 3.0.3, for which `braces::expand` stands in, and `removeDuplicateSlashes`. -pub(crate) fn expand_for_fast_glob(bytes: &[u8]) -> Vec> { - // `hasBraces` of micromatch: a `{` and a `}` behind it. - let has_braces = strings::index_of_char_usize(bytes, b'{') - .is_some_and(|open| strings::contains_char(&bytes[open..], b'}')); - let mut out = match has_braces { - true => braces::expand(bytes), - false => vec![bytes.to_vec()], - }; - let mut seen = StringHashMap::<()>::new(); - out.retain_mut(|it| { - // `/(?!^)\/{2,}/g`: a run of `/` is one, but at the very start. - let (mut at, mut was_slash) = (0_usize, false); - it.retain(|byte| { - let is_dropped = *byte == b'/' && at >= 2 && was_slash; - (at, was_slash) = (at + 1, *byte == b'/'); - !is_dropped - }); - !it.is_empty() && !bun_core::handle_oom(seen.get_or_put(it)).found_existing - }); - out -} diff --git a/src/glob/segments.rs b/src/glob/segments.rs deleted file mode 100644 index 1784981b038c..000000000000 --- a/src/glob/segments.rs +++ /dev/null @@ -1,532 +0,0 @@ -//! What minimatch 10.2.6 does with a path that is split at its slashes: at most 2 x parts x names tests of a name. - -use crate::braces; -use crate::node::{Assertion, Node, Program, lower}; -use crate::pattern::{Options, Syntax}; -use crate::read_minimatch; -use crate::read_minimatch3; -use crate::unit::{Subject, Text}; -use bun_collections::StringHashMap; -use bun_collections::smallvec::SmallVec; -use bun_core::strings; -use std::cell::OnceCell; - -// ───────────────────────────── a name of a pattern ───────────────────────────── - -pub(crate) enum Part { - /// `**` - GlobStar, - /// Without magic. - Literal(Box<[u8]>), - /// `*` - Star, - /// `*.js`: what follows the stars. - StarExt(Box<[u8]>), - /// `??`, `??.js`: the length of all of it in UTF-16 code units, and what follows the question marks. - QuestionMarks(usize, Box<[u8]>), - /// `*.*` - StarDotStar, - /// `.*` - DotStar, - Name(Program), - /// It can match nothing, or the reference throws. - Never, -} - -pub(crate) fn is_dots(name: &[u8]) -> bool { - matches!(name, b"." | b"..") -} - -/// A name that neither `*` nor `**` takes. `dot`: the option. -fn is_left_out(name: &[u8], dot: bool) -> bool { - match dot { - true => is_dots(name), - false => name.starts_with(b"."), - } -} - -impl Part { - fn test(&self, name: &[u8], dot: bool) -> bool { - match self { - Part::GlobStar | Part::Never => false, - Part::Literal(literal) => name == &literal[..], - Part::Star => !name.is_empty() && !is_left_out(name, dot), - Part::StarExt(ext) => name.ends_with(ext) && (dot || !name.starts_with(b".")), - Part::QuestionMarks(len, ext) => { - Text::UTF16.count_units(name) == *len - && !is_left_out(name, dot) - && name.ends_with(ext) - } - Part::StarDotStar => !is_left_out(name, dot) && strings::contains_char(name, b'.'), - Part::DotStar => !is_dots(name) && name.starts_with(b"."), - Part::Name(program) => program.matches(Subject::of(name)), - } - } -} - -// ───────────────────────────── a path ───────────────────────────── - -type Names<'p> = SmallVec<[&'p [u8]; 16]>; - -/// `slashSplit`: `path.split(/\/+/)` -fn split_path(path: &[u8]) -> Names<'_> { - let mut names = Names::new(); - let mut rest = path; - while let Some(slash) = strings::index_of_char_usize(rest, b'/') { - names.push(&rest[..slash]); - rest = &rest[slash..]; - rest = &rest[rest.iter().take_while(|b| **b == b'/').count()..]; - } - names.push(rest); - names -} - -struct Split<'p> { - names: Names<'p>, - /// Where the last name is that no `**` takes, with the option `dot` and without. - last_dots: Option, - last_hidden: Option, -} - -/// A path that many patterns are matched with: split once, if at all. Separated by `/`. -pub struct Candidate<'p> { - whole: &'p [u8], - /// The path, if its names are separated by one slash each. - text: Option<&'p [u8]>, - /// No name starts with a `.`. - is_plain: bool, - /// Where the last name starts. A `/` at the end of the path belongs to it. - name_at: usize, - split: OnceCell>, -} - -impl<'p> Candidate<'p> { - pub fn new(path: &'p [u8]) -> Candidate<'p> { - let names = path.strip_suffix(b"/").unwrap_or(path); - Candidate { - whole: path, - text: (!strings::contains(path, b"//")).then_some(path), - is_plain: !path.starts_with(b".") && !strings::contains(path, b"/."), - name_at: strings::last_index_of_char(names, b'/').map_or(0, |slash| slash + 1), - split: OnceCell::new(), - } - } - - /// The same for `path`: this one, which is empty or ends with a `/`, then a name, and a `/` or not. - pub fn entry<'e>(&self, path: &'e [u8]) -> Candidate<'e> { - let name_at = self.whole.len(); - let entry = Candidate { - whole: path, - text: self.text.map(|_| path), - is_plain: self.is_plain && path.get(name_at) != Some(&b'.'), - name_at, - split: OnceCell::new(), - }; - #[cfg(debug_assertions)] - { - let computed = Candidate::new(path); - debug_assert_eq!( - (entry.text, entry.is_plain, entry.name_at), - (computed.text, computed.is_plain, computed.name_at) - ); - } - entry - } - - /// The path as it is. - pub fn path(&self) -> &'p [u8] { - self.whole - } - - pub(crate) fn name_at(&self) -> usize { - self.name_at - } - - fn split(&self) -> &Split<'p> { - self.split.get_or_init(|| { - let names = split_path(self.whole); - Split { - last_dots: names.iter().rposition(|it| is_dots(it)), - last_hidden: names.iter().rposition(|it| it.starts_with(b".")), - names, - } - }) - } -} - -// ───────────────────────────── a pattern ───────────────────────────── - -/// A pattern without braces. -pub(crate) struct Expansion { - parts: Vec, - /// Where the first `**` is, and the last one. - globstars: Option<(usize, usize)>, - /// The parts at the start that are without magic, joined by slashes: what matches starts with them. - head: Vec, - /// What the last part ends with, and so what matches, unless that ends with a slash. - tail: Vec, - /// The longest run of characters without magic in the parts between these, which is somewhere in what matches. - inner: Vec, - /// It ends with a `/`, and so does what matches, but for the empty path. - ends_with_slash: bool, - /// `**/name` - is_for_name: bool, -} - -impl Expansion { - fn new(parts: Vec) -> Expansion { - let is_globstar = |part: &Part| matches!(part, Part::GlobStar); - let literals = parts.iter().map_while(|it| match it { - Part::Literal(literal) => Some(&literal[..]), - _ => None, - }); - let literals: Vec<&[u8]> = literals.collect(); - let tail = match parts.last() { - Some(Part::Literal(end) | Part::StarExt(end)) => &end[..], - _ => b"", - }; - // What neither of them covers. - let before_tail = parts.len() - usize::from(!tail.is_empty()); - let between = parts.get(literals.len()..before_tail).unwrap_or_default(); - let literals_between = between.iter().map(|it| match it { - Part::Literal(literal) => &literal[..], - Part::Name(program) => program.longest_literal(), - _ => b"", - }); - Expansion { - inner: literals_between - .max_by_key(|it| it.len()) - .unwrap_or_default() - .to_vec(), - head: literals.join(&b'/'), - tail: tail.to_vec(), - ends_with_slash: matches!(&parts[..], [_, .., Part::Literal(last)] if last.is_empty()), - is_for_name: matches!(&parts[..], [Part::GlobStar, name] if !is_globstar(name)), - globstars: parts - .iter() - .position(is_globstar) - .zip(parts.iter().rposition(is_globstar)), - parts, - } - } - - /// What a path that matches starts with, up to a slash or to its end. Empty: it does not say. - pub(crate) fn head(&self) -> &[u8] { - &self.head - } - - /// Whether `path` can match, as far as that shows without looking at its names. - #[inline] - fn can_match(&self, path: &[u8]) -> bool { - // Most heads are empty and most tails are a few bytes, which are compared without a call. - let ends_with_tail = || { - path.len() >= self.tail.len() - && path - .iter() - .rev() - .zip(self.tail.iter().rev()) - .all(|(a, b)| a == b) - }; - (self.head.is_empty() - || path.starts_with(&self.head) - && matches!(path.get(self.head.len()), None | Some(b'/'))) - && (ends_with_tail() || path.last() == Some(&b'/')) - && (!self.ends_with_slash || matches!(path.last(), None | Some(b'/'))) - && (self.inner.is_empty() || strings::contains(path, &self.inner)) - } - - /// Whether `path`, whose names are separated by one slash each, matches. `name`: its last name, with the `/` behind it, if no - /// name of it starts with a `.`. - #[inline] - fn matches_text( - &self, - path: &Candidate<'_>, - text: &[u8], - name: Option<&[u8]>, - dot: bool, - ) -> bool { - // No `**` stops at a name of such a path, so only the last name counts. - let hit = match (name, &self.parts[..]) { - (Some(name), [_, part]) if self.is_for_name => { - self.can_match(name) - && match name.strip_suffix(b"/") { - Some(name) => part.test(b"", dot) || part.test(name, dot), - None => part.test(name, dot), - } - } - _ => self.can_match(text) && self.matches_names(path, false, dot), - }; - debug_assert_eq!(hit, self.matches_names(path, false, dot)); - hit - } - - /// Apart from `matches`: most paths are turned away by `can_match`, and do not pay for what this sets up. - #[inline(never)] - fn matches_names(&self, path: &Candidate<'_>, partial: bool, dot: bool) -> bool { - match self.globstars { - Some(globstars) => match_globstar(path, &self.parts, globstars, partial, dot), - None => match_plain(&path.split().names, &self.parts, partial, dot), - } - } -} - -/// `/\{(?:(?!\{).)*\}/.test(pattern)` -pub(crate) fn has_braces(pattern: &[u8]) -> bool { - let mut is_open = false; - let mut rest = pattern; - while let [byte, after @ ..] = rest { - match byte { - b'{' => is_open = true, - b'}' if is_open => return true, - _ if bun_core::lexer::starts_with_line_break(rest) => is_open = false, - _ => {} - } - rest = after; - } - false -} - -/// Braces, split, `levelOneOptimize`, parts. `pattern`: without the `!` at its start. -pub(crate) fn read(pattern: &[u8], options: Options) -> Vec { - // 3.1.5 has no `levelOneOptimize`. - let (is_3, dot) = (options.syntax == Syntax::Minimatch3, options.dot); - let mut globs = match has_braces(pattern) && !(is_3 && options.nobrace) { - true => braces::expand(pattern), - false => vec![pattern.to_vec()], - }; - if globs.len() > 1 { - let mut seen = StringHashMap::<()>::new(); - globs.retain(|glob| !bun_core::handle_oom(seen.get_or_put(glob)).found_existing); - } - let expansion = |glob: &Vec| { - let mut names: Vec<&[u8]> = Vec::new(); - for name in split_path(glob) { - let previous = names.last().copied(); - if !is_3 && name == b"**" && previous.is_some_and(|it| it == b"**") { - continue; - } - if !is_3 - && name == b".." - && let Some(previous) = previous - && !previous.is_empty() - && !matches!(previous, b".." | b"." | b"**") - { - names.pop(); - continue; - } - names.push(name); - } - if names.is_empty() { - names.push(b""); - } - let parts = names.into_iter().map(|it| match is_3 { - true => read_minimatch3::part(it, options), - false => read_minimatch::part(it, dot), - }); - // A name without magic is compared byte by byte. - let parts = parts.map(|it| match it { - Part::Literal(name) if Text::UTF16.has_replacement(&name) => { - let nodes = vec![Node::Lit(name.into()), Node::Assert(Assertion::End)]; - Part::Name(lower(Node::Seq(nodes), Text::UTF16)) - } - it => it, - }); - Expansion::new(parts.collect()) - }; - globs.iter().map(expansion).collect() -} - -// ───────────────────────────── matching ───────────────────────────── - -const MAX_GLOBSTAR_RECURSION: usize = 200; - -/// `#matchOne`. `partial`: it is enough that the path matches the start of the pattern. -fn match_plain(file: &[&[u8]], pattern: &[Part], partial: bool, dot: bool) -> bool { - let common = file.len().min(pattern.len()); - let fits = match (file.len() == common, pattern.len() == common) { - (true, true) => true, - (true, false) => partial, - // `a/*` matches `a/b/`. - _ => common + 1 == file.len() && file[common].is_empty(), - }; - // From the end: paths differ less in what they start with. - fits && (file[..common].iter().zip(&pattern[..common]).rev()) - .all(|(name, part)| part.test(name, dot)) -} - -/// `#matchGlobStarBodySections` -struct Sections<'a> { - file: &'a [&'a [u8]], - /// What is between the `**`, each with the last position at which it is looked for. - sections: &'a [(&'a [Part], isize)], - partial: bool, - dot: bool, - saw_tail: bool, - /// Where the last name is that stops a `**`. - last_stop: Option, - /// What `(section, position)` gave. 0: not known. Made when the first `Some(false)` comes back. - memo: Vec, -} - -impl Sections<'_> { - /// Every position in `from..to`, which a loop has passed, would have gone the same way from there on. - fn remember(&mut self, k: usize, from: usize, to: usize, result: Option) -> Option { - let width = self.file.len() + 2; - if let Some(cells) = self - .memo - .get_mut(k * width + from..k * width + to.min(width)) - { - cells.fill(match result { - Some(false) => 1, - Some(true) => 2, - None => 3, - }); - } - result - } - - /// `None`: no match, and no later position can match either. Recursion: one level for a section, at most `MAX_GLOBSTAR_RECURSION`. - fn run(&mut self, k: usize, from: usize) -> Option { - let (file, width) = (self.file, self.file.len() + 2); - let Some(&(section, last)) = self.sections.get(k) else { - return Some(match from < file.len() { - true => self.last_stop.is_none_or(|it| it < from), - false => self.saw_tail, - }); - }; - let mut at = from; - while at as isize <= last { - match self.memo.get(k * width + at) { - None | Some(0) => {} - Some(known) => { - let result = (*known != 3).then_some(*known == 2); - return self.remember(k, from, at, result); - } - } - let end = (at + section.len()).min(file.len()); - if match_plain(&file[at.min(end)..end], section, self.partial, self.dot) - && k < MAX_GLOBSTAR_RECURSION - { - let rest = self.run(k + 1, at + section.len()); - if rest != Some(false) { - return self.remember(k, from, at + 1, rest); - } - if self.memo.is_empty() { - self.memo = - vec![0; self.sections.len().min(MAX_GLOBSTAR_RECURSION + 1) * width]; - } - } - if file.get(at).is_some_and(|name| is_left_out(name, self.dot)) { - return self.remember(k, from, at + 1, Some(false)); - } - at += 1; - } - self.remember(k, from, at + 1, self.partial.then_some(true)) - } -} - -/// `#matchGlobstar` -fn match_globstar( - candidate: &Candidate<'_>, - pattern: &[Part], - (first, last): (usize, usize), - partial: bool, - dot: bool, -) -> bool { - let candidate = candidate.split(); - let file = &candidate.names[..]; - let is_globstar = |part: &Part| matches!(part, Part::GlobStar); - if !partial && first + (pattern.len() - 1 - last) > file.len() { - return false; - } - let (head, body, tail) = match partial { - true => (&pattern[..first], &pattern[first + 1..], &pattern[..0]), - false => ( - &pattern[..first], - &pattern[(first + 1).min(last)..last], - &pattern[last + 1..], - ), - }; - if !head.is_empty() && !match_plain(&file[..head.len().min(file.len())], head, partial, dot) { - return false; - } - let at = head.len(); - let mut tail_len = 0; - if !tail.is_empty() { - if tail.len() + at > file.len() { - return false; - } - let start = file.len() - tail.len(); - if match_plain(&file[start..], tail, partial, dot) { - tail_len = tail.len(); - } else { - // `a/**/*` matches `a/b/`. - if file.last().is_some_and(|it| !it.is_empty()) || at + tail.len() == file.len() { - return false; - } - if !match_plain(&file[start - 1..], tail, partial, dot) { - return false; - } - tail_len = tail.len() + 1; - } - } - if body.is_empty() { - let between = &file[at.min(file.len() - tail_len)..file.len() - tail_len]; - return !between.iter().any(|name| is_left_out(name, dot)) - && (partial || tail_len > 0 || !between.is_empty()); - } - let sections: SmallVec<[&[Part]; 4]> = body.split(is_globstar).collect(); - // How many parts are before each section. - let mut before: SmallVec<[usize; 4]> = SmallVec::new(); - let mut count = 0; - for section in §ions { - before.push(count); - count += section.len(); - } - // The last position at which each section is looked for. minimatch takes the counts in reverse order. - let file_len = (file.len() - tail_len) as isize; - let with_last: SmallVec<[(&[Part], isize); 4]> = (sections.iter().zip(before.iter().rev())) - .map(|(section, before)| (*section, file_len - (before + section.len()) as isize)) - .collect(); - let mut sections = Sections { - file, - sections: &with_last, - partial, - dot, - saw_tail: tail_len > 0, - last_stop: match dot { - true => candidate.last_dots, - false => candidate.last_hidden, - }, - memo: Vec::new(), - }; - sections.run(0, at) == Some(true) -} - -/// The same with the option `matchBase`: an expansion of one name is about the last name of the path that is not empty. -pub(crate) fn matches_base( - set: &[Expansion], - path: &Candidate<'_>, - partial: bool, - dot: bool, -) -> bool { - let last = path.split().names.iter().rfind(|it| !it.is_empty()); - let name = Candidate::new(last.copied().unwrap_or_default()); - set.iter().any(|it| { - let path = if it.parts.len() == 1 { &name } else { path }; - let text = path.text.filter(|_| !partial); - text.is_none_or(|text| it.can_match(text)) && it.matches_names(path, partial, dot) - }) -} - -/// Whether one of `set` matches. `partial`: the argument of `match`. -#[inline] -pub(crate) fn matches(set: &[Expansion], path: &Candidate<'_>, partial: bool, dot: bool) -> bool { - match path.text.filter(|_| !partial) { - Some(text) => { - let name = path.is_plain.then(|| &text[path.name_at..]); - set.iter().any(|it| it.matches_text(path, text, name, dot)) - } - None => set.iter().any(|it| it.matches_names(path, partial, dot)), - } -} diff --git a/src/glob/sets.rs b/src/glob/sets.rs deleted file mode 100644 index 0fff3c88e6a0..000000000000 --- a/src/glob/sets.rs +++ /dev/null @@ -1,411 +0,0 @@ -//! Groups, repeats and lookaheads: sets of instructions, read from the end of the text. Instructions x (bytes + 1) steps. - -use crate::class::Class; -use crate::node::{Assertion, Node}; -use crate::unit::{Subject, Text}; -use bun_collections::smallvec::SmallVec; - -/// `out`: where to go on. -#[derive(Copy, Clone)] -enum Inst { - Unit { - unit: u32, - out: u32, - }, - /// Not a `/`. - Any { - out: u32, - }, - Dot { - newlines: bool, - out: u32, - }, - Class { - index: u32, - out: u32, - }, - Split { - out: u32, - other: u32, - }, - Assert { - assertion: Assertion, - out: u32, - }, - Look { - table: u32, - negated: bool, - out: u32, - }, - Fail, - Match, -} - -const SLASH: u32 = b'/' as u32; - -pub(crate) struct Program { - insts: Box<[Inst]>, - /// The lookaheads, the inner ones first, and the main one last: for each its entry. - programs: Box<[u32]>, - classes: Box<[Class]>, - text: Text, - /// What the main program looks for may end anywhere. Otherwise at the end of the text. - floats: bool, -} - -struct Compiler { - insts: Vec, - programs: Vec, - classes: Vec, - text: Text, - /// It is written to be read from the start of the text to its end, for `run_prefix`. - forwards: bool, -} - -impl Compiler { - fn emit(&mut self, inst: Inst) -> u32 { - self.insts.push(inst); - self.insts.len() as u32 - 1 - } - - /// Writes `nodes` as a program of its own, and returns its number. - fn program(&mut self, nodes: &[Node]) -> u32 { - let entry = self.emit(Inst::Match); - let entry = self.sequence(nodes, entry); - self.programs.push(entry); - self.programs.len() as u32 - 1 - } - - /// What is written last is read first. - fn sequence(&mut self, nodes: &[Node], next: u32) -> u32 { - match self.forwards { - true => nodes - .iter() - .rev() - .fold(next, |next, it| self.build(it, next)), - false => nodes.iter().fold(next, |next, it| self.build(it, next)), - } - } - - /// Where to start so that `node` is read, from its end to its start, and then `next`. As deep as the tree. - fn build(&mut self, node: &Node, mut next: u32) -> u32 { - match node { - Node::Fail => self.emit(Inst::Fail), - Node::Empty => next, - Node::Lit(bytes) if self.forwards => { - let mut at = bytes.len(); - while let Some((unit, len)) = self.text.prev(Subject::of(bytes), at) { - next = self.emit(Inst::Unit { unit, out: next }); - at = at.saturating_sub(len); - } - next - } - Node::Lit(bytes) => { - let mut at = 0; - while let Some((unit, len)) = self.text.next(Subject::of(bytes), at) { - next = self.emit(Inst::Unit { unit, out: next }); - at += len; - } - next - } - Node::Any => self.emit(Inst::Any { out: next }), - Node::Dot { newlines } => self.emit(Inst::Dot { - newlines: *newlines, - out: next, - }), - Node::Class(class) => { - self.classes.push(class.clone()); - self.emit(Inst::Class { - index: self.classes.len() as u32 - 1, - out: next, - }) - } - Node::Star => self.build(&Node::repeat(Node::Any, 0, true), next), - Node::Plus => self.build(&Node::repeat(Node::Any, 1, true), next), - Node::Deep { - empty_names, - newlines, - } => { - let slash = Node::Lit(vec![b'/']); - let newlines = *newlines; - self.build( - &match empty_names { - // `(?:.*\/)?` - true => { - let all = Node::repeat(Node::Dot { newlines }, 0, true); - Node::repeat(Node::Seq(vec![all, slash]), 0, false) - } - // `(?:[^/]+\/)*` - false => Node::repeat(Node::Seq(vec![Node::Plus, slash]), 0, true), - }, - next, - ) - } - Node::Rest { min, newlines } => { - let dot = Node::Dot { - newlines: *newlines, - }; - self.build(&Node::repeat(dot, *min, true), next) - } - Node::Assert(assertion) => self.emit(Inst::Assert { - assertion: *assertion, - out: next, - }), - Node::Seq(nodes) => self.sequence(nodes, next), - Node::Alt(nodes) => { - let mut entry = None; - for it in nodes { - let other = self.build(it, next); - entry = Some(match entry { - Some(out) => self.emit(Inst::Split { out, other }), - None => other, - }); - } - entry.unwrap_or_else(|| self.emit(Inst::Fail)) - } - Node::Repeat { - node, - min, - unbounded: false, - } => { - let other = self.build(node, next); - match min { - 0 => self.emit(Inst::Split { out: next, other }), - _ => other, - } - } - // The body is written once, whatever `min` is. - Node::Repeat { node, min, .. } => { - let again = self.emit(Inst::Split { - out: next, - other: 0, - }); - let body = self.build(node, again); - if let Some(Inst::Split { other, .. }) = self.insts.get_mut(again as usize) { - *other = body; - } - if *min == 0 { again } else { body } - } - // What it asks lies behind the end of the text of which `run_prefix` is asked. - Node::Look { .. } if self.forwards => next, - Node::Look { negated, node } => { - let table = self.program(std::slice::from_ref(&**node)); - self.emit(Inst::Look { - table, - negated: *negated, - out: next, - }) - } - } - } -} - -type List = SmallVec<[u32; 64]>; - -/// A program on a text. -struct Run<'r> { - program: &'r Program, - subject: Subject<'r>, - /// For each instruction the position at which it was last put into a list, and one more. - mark: SmallVec<[u32; 64]>, - /// For each lookahead `words` words: a bit for each position, whether it matches something that starts there. - tables: SmallVec<[u64; 8]>, - words: usize, - stack: List, - /// `Match` has been reached at the position that is being filled. - matched: bool, - /// The text is the start of a longer one. - is_prefix: bool, -} - -impl Run<'_> { - /// Puts `pc` and what follows from it without taking a unit into `list`. - fn add(&mut self, list: &mut List, pc: u32, pos: usize) { - self.stack.push(pc); - while let Some(pc) = self.stack.pop() { - let at = pc as usize; - let (Some(mark), Some(inst)) = (self.mark.get_mut(at), self.program.insts.get(at)) - else { - continue; - }; - if *mark == pos as u32 + 1 { - continue; - } - *mark = pos as u32 + 1; - match *inst { - Inst::Split { out, other } => self.stack.extend([other, out]), - Inst::Assert { assertion, out } if self.holds(assertion, pos) => { - self.stack.push(out); - } - Inst::Look { - table, - negated, - out, - } if self.looked(table, pos) != negated => self.stack.push(out), - Inst::Assert { .. } | Inst::Look { .. } => {} - Inst::Fail if !self.is_prefix => {} - Inst::Match => self.matched = true, - _ => list.push(pc), - } - } - } - - fn holds(&self, assertion: Assertion, pos: usize) -> bool { - match self.is_prefix { - true if assertion == Assertion::Start => pos == 0, - // What looks beyond the end of the text holds. - true if pos + 3 >= self.subject.len() => true, - _ => assertion.holds(self.subject, pos), - } - } - - fn looked(&self, table: u32, pos: usize) -> bool { - let word = self.tables.get(table as usize * self.words + pos / 64); - word.is_some_and(|it| it & (1 << (pos % 64)) != 0) - } - - /// One program over the whole text. `table`: the table to fill. `None` for the main program, of which only position 0 is asked. - fn run_one(&mut self, entry: u32, floats: bool, table: Option) -> bool { - let (program, n) = (self.program, self.subject.len()); - let (mut current, mut following) = (List::new(), List::new()); - self.matched = false; - let mut pos = n; - loop { - if floats || pos == n { - self.add(&mut current, entry, pos); - } - if self.matched - && let Some(table) = table - && let Some(word) = self.tables.get_mut(table * self.words + pos / 64) - { - *word |= 1 << (pos % 64); - } - if current.is_empty() && !floats && pos > 0 { - return false; - } - let Some((unit, len)) = program.text.prev(self.subject, pos) else { - return self.matched; - }; - pos = pos.saturating_sub(len); - following.clear(); - self.matched = false; - for pc in ¤t { - if let Some(out) = program.taken(*pc, unit) { - self.add(&mut following, out, pos); - } - } - // `add(current, entry, pos)` at the top goes on filling the same list for the same position. - std::mem::swap(&mut current, &mut following); - } - } -} - -impl Program { - fn compiled(nodes: &[Node], text: Text, forwards: bool) -> Program { - let mut compiler = Compiler { - insts: Vec::new(), - programs: Vec::new(), - classes: Vec::new(), - text, - forwards, - }; - compiler.program(nodes); - Program { - insts: compiler.insts.into(), - programs: compiler.programs.into(), - classes: compiler.classes.into(), - text, - floats: true, - } - } - - pub(crate) fn new(nodes: &[Node], text: Text) -> Program { - // An end that is the last thing of all is not an instruction: the main program is then entered at the end of the text only. - match nodes { - [main @ .., Node::Assert(Assertion::End)] => Program { - floats: false, - ..Program::compiled(main, text, false) - }, - _ => Program::compiled(nodes, text, false), - } - } - - /// The same tree written forwards, for `run_prefix`. Lookaheads are left out of it. - pub(crate) fn new_forwards(nodes: &[Node], text: Text) -> Program { - Program::compiled(nodes, text, true) - } - - /// Where to go on from the instruction at `pc`, if it takes `unit`. - #[inline] - fn taken(&self, pc: u32, unit: u32) -> Option { - match *self.insts.get(pc as usize)? { - Inst::Unit { unit: wanted, out } if wanted == unit => Some(out), - Inst::Any { out } if unit != SLASH => Some(out), - Inst::Dot { newlines, out } - if newlines || !bun_core::strings::is_js_line_terminator(unit) => - { - Some(out) - } - Inst::Class { index, out } if self.classes.get(index as usize)?.has(unit) => Some(out), - _ => None, - } - } - - fn on<'r>(&'r self, subject: Subject<'r>, tables: usize, is_prefix: bool) -> Run<'r> { - let words = subject.len() / 64 + 1; - Run { - program: self, - subject, - mark: SmallVec::from_elem(0, self.insts.len()), - tables: SmallVec::from_elem(0, tables * words), - words, - stack: List::new(), - matched: false, - is_prefix, - } - } - - /// Whether `subject`, a directory with its `/`, can be the start of a match. Never `false` if it can. - pub(crate) fn run_prefix(&self, subject: Subject<'_>) -> bool { - let Some(entry) = self.programs.first() else { - return true; - }; - if subject.len() >= u32::MAX as usize { - return true; - } - let mut run = self.on(subject, 0, true); - let (mut current, mut following) = (List::new(), List::new()); - run.add(&mut current, *entry, 0); - let mut pos = 0; - while let Some((unit, len)) = self.text.next(subject, pos) { - if current.is_empty() { - return false; - } - pos += len; - following.clear(); - run.matched = false; - for pc in ¤t { - if let Some(out) = self.taken(*pc, unit) { - run.add(&mut following, out, pos); - } - } - std::mem::swap(&mut current, &mut following); - } - !current.is_empty() || run.matched - } - - /// Whether the tree matches from the start of `subject`. - pub(crate) fn run(&self, subject: Subject<'_>) -> bool { - let Some((main, looks)) = self.programs.split_last() else { - return false; - }; - if subject.len() >= u32::MAX as usize { - return false; - } - let mut run = self.on(subject, looks.len(), false); - for (table, entry) in looks.iter().enumerate() { - run.run_one(*entry, true, Some(table)); - } - run.run_one(*main, self.floats, None) - } -} diff --git a/src/glob/unit.rs b/src/glob/unit.rs deleted file mode 100644 index dc0da9e84f83..000000000000 --- a/src/glob/unit.rs +++ /dev/null @@ -1,285 +0,0 @@ -//! What a unit of a text is, and folding. The text stays UTF-8 bytes and a position is a byte offset. - -use std::borrow::Cow; - -#[derive(Copy, Clone, PartialEq, Eq)] -pub(crate) enum Unit { - /// git, globset. - Byte, - /// A name of minimatch with a POSIX class. - CodePoint, - /// minimatch, picomatch, npm `ignore`. - Utf16, -} - -#[derive(Copy, Clone, PartialEq, Eq)] -pub(crate) struct Text { - pub(crate) unit: Unit, - /// `Canonicalize` of ECMAScript without the flag `u`. With `Unit::Byte`: ASCII only. - pub(crate) folds: bool, -} - -/// The text, and whether it goes on with a `/`. -#[derive(Copy, Clone)] -pub(crate) struct Subject<'p> { - pub(crate) bytes: &'p [u8], - pub(crate) slash: bool, -} - -impl<'p> Subject<'p> { - pub(crate) fn of(bytes: &'p [u8]) -> Subject<'p> { - Subject { - bytes, - slash: false, - } - } - - #[inline] - pub(crate) fn len(self) -> usize { - self.bytes.len() + usize::from(self.slash) - } - - #[inline] - pub(crate) fn get(self, at: usize) -> Option { - match self.bytes.get(at) { - Some(byte) => Some(*byte), - None if self.slash && at == self.bytes.len() => Some(b'/'), - None => None, - } - } - - /// The byte that ends at `at`. - #[inline] - pub(crate) fn before(self, at: usize) -> Option { - self.get(at.checked_sub(1)?) - } -} - -const REPLACEMENT: u32 = 0xFFFD; - -fn is_continuation(byte: u8) -> bool { - byte & 0xC0 == 0x80 -} - -/// The length of the valid sequence of UTF-8 that starts at `at`. 0: there is none. -fn sequence_len(bytes: &[u8], at: usize) -> usize { - let Some(&lead) = bytes.get(at) else { - return 0; - }; - // 0 is no continuation. - let after = |i: usize| bytes.get(at + i).copied().unwrap_or(0); - // How long it is, and what its second byte may be. - let (len, low, high) = match lead { - 0..0x80 => return 1, - 0xC2..=0xDF => (2, 0x80, 0xBF), - 0xE0 => (3, 0xA0, 0xBF), - 0xE1..=0xEC | 0xEE | 0xEF => (3, 0x80, 0xBF), - 0xED => (3, 0x80, 0x9F), - 0xF0 => (4, 0x90, 0xBF), - 0xF1..=0xF3 => (4, 0x80, 0xBF), - 0xF4 => (4, 0x80, 0x8F), - _ => return 0, - }; - let is_valid = (low..=high).contains(&after(1)) && (2..len).all(|i| is_continuation(after(i))); - if is_valid { len } else { 0 } -} - -/// As JavaScript reads a file: U+FFFD for each byte that is in no sequence. The readers take the length of a character from its first byte. -pub(crate) fn well_formed(bytes: &[u8]) -> Cow<'_, [u8]> { - if bytes.is_ascii() { - return Cow::Borrowed(bytes); - } - let mut out = Vec::new(); - let (mut at, mut copied) = (0, 0); - while at < bytes.len() { - let len = sequence_len(bytes, at); - if len == 0 { - out.extend_from_slice(&bytes[copied..at]); - out.extend_from_slice("\u{FFFD}".as_bytes()); - copied = at + 1; - } - at += len.max(1); - } - if copied == 0 { - return Cow::Borrowed(bytes); - } - out.extend_from_slice(&bytes[copied..]); - Cow::Owned(out) -} - -/// The character of `len` bytes at `at`, where `sequence_len` has found it. `len` is 2, 3 or 4. -fn code_point_at(bytes: &[u8], at: usize, len: usize) -> u32 { - let lead = bytes.get(at).copied().unwrap_or(0); - let rest = bytes.get(at + 1..at + len).unwrap_or_default(); - let first = u32::from(lead) & (0x7F >> len); - rest.iter() - .fold(first, |c, byte| (c << 6) | u32::from(byte & 0x3F)) -} - -fn high_half(c: u32) -> u32 { - 0xD800 + (c.wrapping_sub(0x10000) >> 10) -} - -fn low_half(c: u32) -> u32 { - 0xDC00 + (c.wrapping_sub(0x10000) & 0x3FF) -} - -/// The one character that `mapped` is, if it is one UTF-16 unit. -fn one_unit(mut mapped: impl Iterator) -> Option { - match (mapped.next(), mapped.next()) { - (Some(one), None) if u32::from(one) <= 0xFFFF => Some(u32::from(one)), - _ => None, - } -} - -/// `char::to_lowercase`, if that is one UTF-16 unit. What else `Text::fold` takes to `unit`, for a class. -pub(crate) fn unfold(unit: u32) -> u32 { - match char::from_u32(unit) { - Some(c) if unit <= 0xFFFF => one_unit(c.to_lowercase()).unwrap_or(unit), - _ => unit, - } -} - -impl Text { - pub(crate) const UTF16: Text = Text { - unit: Unit::Utf16, - folds: false, - }; - - /// Whether a unit of it is U+FFFD, which a bad byte of a path is too: then bytes do not say whether two texts are the same. - pub(crate) fn has_replacement(self, literal: &[u8]) -> bool { - if self.unit == Unit::Byte || literal.is_ascii() { - return false; - } - let mut at = 0; - while let Some((unit, len)) = self.plain().next(Subject::of(literal), at) { - if unit == REPLACEMENT { - return true; - } - at += len; - } - false - } - - /// The same without folding. - pub(crate) fn plain(self) -> Text { - Text { - unit: self.unit, - folds: false, - } - } - - /// `char::to_uppercase`, if that is one UTF-16 unit, and not from beyond ASCII into ASCII. - pub(crate) fn fold(self, unit: u32) -> u32 { - if !self.folds { - return unit; - } - if unit < 0x80 { - return u32::from((unit as u8).to_ascii_uppercase()); - } - match char::from_u32(unit) { - Some(c) if self.unit != Unit::Byte && unit <= 0xFFFF => one_unit(c.to_uppercase()) - .filter(|folded| *folded >= 0x80) - .unwrap_or(unit), - _ => unit, - } - } - - #[inline] - fn ascii(self, byte: u8) -> (u32, usize) { - let folded = if self.folds { - byte.to_ascii_uppercase() - } else { - byte - }; - (u32::from(folded), 1) - } - - /// The unit that starts at `at`, folded, and its length in bytes. - #[inline] - pub(crate) fn next(self, subject: Subject<'_>, at: usize) -> Option<(u32, usize)> { - let byte = subject.get(at)?; - Some(match self.unit { - _ if byte < 0x80 => self.ascii(byte), - Unit::Byte => (u32::from(byte), 1), - _ => self.next_wide(subject.bytes, at), - }) - } - - fn next_wide(self, bytes: &[u8], at: usize) -> (u32, usize) { - let is_utf16 = self.unit == Unit::Utf16; - match sequence_len(bytes, at) { - 0 => match at.checked_sub(2) { - // The second half of four bytes. - Some(start) if is_utf16 && sequence_len(bytes, start) == 4 => { - (low_half(code_point_at(bytes, start, 4)), 2) - } - _ => (REPLACEMENT, 1), - }, - 4 if is_utf16 => (high_half(code_point_at(bytes, at, 4)), 2), - len => (self.fold(code_point_at(bytes, at, len)), len), - } - } - - /// The unit that ends at `at`, folded, and its length in bytes. - #[inline] - pub(crate) fn prev(self, subject: Subject<'_>, at: usize) -> Option<(u32, usize)> { - let byte = subject.before(at)?; - Some(match self.unit { - _ if byte < 0x80 => self.ascii(byte), - Unit::Byte => (u32::from(byte), 1), - _ => self.prev_wide(subject.bytes, at), - }) - } - - /// A byte that starts a sequence is never inside of another one, so what `next` steps over is found from behind as well. - fn prev_wide(self, bytes: &[u8], at: usize) -> (u32, usize) { - let is_utf16 = self.unit == Unit::Utf16; - for back in 1..=4 { - let Some(start) = at.checked_sub(back) else { - break; - }; - if bytes.get(start).is_some_and(|it| is_continuation(*it)) { - continue; - } - return match sequence_len(bytes, start) { - 4 if is_utf16 && back == 4 => (low_half(code_point_at(bytes, start, 4)), 2), - 4 if is_utf16 && back == 2 => (high_half(code_point_at(bytes, start, 4)), 2), - len if len == back => (self.fold(code_point_at(bytes, start, len)), len), - _ => (REPLACEMENT, 1), - }; - } - (REPLACEMENT, 1) - } - - /// How many units `bytes` are. - pub(crate) fn count_units(self, bytes: &[u8]) -> usize { - if bytes.is_ascii() { - return bytes.len(); - } - let (mut at, mut count) = (0, 0); - while let Some((_, len)) = self.next(Subject::of(bytes), at) { - at += len; - count += 1; - } - count - } -} - -/// `c` in UTF-8. A half of a pair is written as if it were a character. -pub(crate) fn push_utf8(out: &mut Vec, c: u32) { - let continuation = |shift: u32| 0x80 | ((c >> shift) & 0x3F) as u8; - match c { - 0..0x80 => out.push(c as u8), - 0x80..0x800 => out.extend_from_slice(&[0xC0 | (c >> 6) as u8, continuation(0)]), - 0x800..0x10000 => { - out.extend_from_slice(&[0xE0 | (c >> 12) as u8, continuation(6), continuation(0)]); - } - _ => out.extend_from_slice(&[ - 0xF0 | (c >> 18) as u8, - continuation(12), - continuation(6), - continuation(0), - ]), - } -} diff --git a/src/lint/CLAUDE.md b/src/lint/CLAUDE.md index 7b12b99ca1af..e17ece4a76f8 100644 --- a/src/lint/CLAUDE.md +++ b/src/lint/CLAUDE.md @@ -82,16 +82,21 @@ bar(); ``` - **Every example is run.** Each `js` / `jsx` / `ts` / `tsx` block needs a marker on the line before it: - | Marker | Meaning | - | ---------------------------------------- | -------------------------------------------------- | - | `{/* bad */}` | The rule reports this code | - | `{/* good */}` | The rule reports nothing here, and the code parses | - | `{/* bad {"options": ["smart"]} */}` | With these options | - | `{/* bad {"count": 2} */}` | Exactly two reports | - | `{/* good {"filename": "a.test.ts"} */}` | The file has this name | - | `{/* file "other.js" */}` | Another file next to the next example | - | `{/* skip */}` | Not run. For a fragment that cannot stand alone | - + | Marker | Meaning | + | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | + | `{/* bad */}` | The rule reports this code | + | `{/* good */}` | The rule reports nothing here, and the code parses | + | `{/* bad {"options": ["smart"]} */}` | With these options | + | `{/* bad {"count": 2} */}` | Exactly two reports | + | `{/* good {"filename": "a.test.ts"} */}` | The file has this name | + | `{/* file "other.js" */}` | Another file next to the next example | + | `{/* bad {"root": true} */}` | The example is its own project and working directory. For options that are paths, like `src/client/**` | + | `{/* bad {"root": true, "packages": {"prettier": "3.9.9"}} */}` | These packages are installed next to it | + | `{/* bad {"config": "oxlint", "settings": {..}} */}` | Run next to an `.oxlintrc.json`. For a rule that only reports there | + | `{/* skip */}` | Not run. For a fragment that cannot stand alone | + | `{/* never reports */}` | On its own line: the rule reports nothing, so the page needs no `bad` example. Say why in the text | + +- Prettier skips `docs/lint/rules` (`.prettierignore`): it would reformat the examples, and many rules are about exactly that. - Say the problem, not "This rule enforces ..". Use normal programmer English. Be succinct. Small realistic examples (`user`, `items`, `save()`, not `foo`). The good example shows how to fix the bad one. - Every claim is shown by an example or read in the source. Do not write what you remember about the rule from another linter, and do not copy another tool's docs. - An options table (`Option | Default | What it does`) plus an example for each option that is not obvious. diff --git a/src/lint/driver/Cargo.toml b/src/lint/driver/Cargo.toml index 62c9f7dbaddf..12fb1abe5bfb 100644 --- a/src/lint/driver/Cargo.toml +++ b/src/lint/driver/Cargo.toml @@ -29,7 +29,9 @@ bun_core.workspace = true bun_md.workspace = true bun_paths.workspace = true bun_sys.workspace = true +bun_text_diff.workspace = true bun_threading.workspace = true +bun_url.workspace = true bun_zstd.workspace = true bstr.workspace = true diff --git a/src/lint/driver/bunfig.rs b/src/lint/driver/bunfig.rs index bfa99eca03f1..59c5b6a081b0 100644 --- a/src/lint/driver/bunfig.rs +++ b/src/lint/driver/bunfig.rs @@ -15,6 +15,7 @@ use bun_ast::{Expr, Loc}; use bun_clap::Values; use bun_core::strings; use bun_lint::context::Severity; +use bun_lint::linter::Step; use bun_lint::linter::config::glob_refusal; use bun_lint::linter::write_json; use bun_lint::options::Json; @@ -682,7 +683,7 @@ const LINT: Table = Table { (b"categories", b"warn"), (b"categories", b"deny"), ], - without_flag: &[], + without_flag: &[b"settings"], actions: &[ b"fix", b"fix-dry-run", @@ -765,17 +766,81 @@ const _: () = assert!( bunfig.toml: add it to `flags`, to docs/runtime/bunfig.mdx and to the test. If it says what one run does: `actions`." ); -/// `off`, `warn` or `error`, or the number for it. -fn severity_of(value: &Expr) -> Result { - let text = match bun_lint::json::from_parsed(value) { - Some(Json::String(text)) => text, - Some(Json::Number(number)) => number_to_string(number), - _ => return expected("string", value), - }; - crate::cli::severity(b"", &text).or_else(|_| { - let expected: &[u8] = b"expected \"off\", \"warn\" or \"error\" but received \""; - refuse(value.loc, &[expected, &text, b"\""]) - }) +/// `off`, `warn` or `error`, or the number for it. `of`: the rule or the category. +fn severity_of(of: &[u8], value: &Expr) -> Result { + let written = json_of(value)?; + let severity = bun_lint::linter::severity_of(&written); + severity.map_or_else( + || refuse(value.loc, &[&crate::native::invalid_severity(of, &written)]), + Ok, + ) +} + +/// Where a value is written in bunfig.toml, and the values in it. +#[derive(Clone, Debug, Default)] +pub struct Place { + pub at: usize, + pub items: Vec, + /// Each key, where it is written, and its value. + pub entries: Vec<(Vec, usize, Place)>, +} + +impl Place { + fn of(value: &Expr) -> Place { + let mut place = Place { + at: value.loc.start.max(0) as usize, + ..Place::default() + }; + if !bun_core::StackCheck::init().is_safe_to_recurse() { + return place; + } + match &value.data { + Data::EArray(items) => place.items = items.slice().iter().map(Place::of).collect(), + Data::EObject(_) => { + let entries = entries(value).unwrap_or_default().into_iter(); + let entries = entries + .map(|(key, value)| (key.name, key.at.start.max(0) as usize, Place::of(value))); + place.entries = entries.collect(); + } + _ => {} + } + place + } + + /// Where the value is that `path` leads to, or else the last on the way. `is_about_key`: where its key is. + pub(crate) fn find(&self, path: &[Step], is_about_key: bool) -> usize { + let (mut place, mut at) = (self, self.at); + for (depth, step) in path.iter().enumerate() { + let next = match step { + Step::Index(index) => place.items.get(*index).map(|it| (it.at, it)), + Step::Key(key) => { + let mut entries = place.entries.iter(); + entries.find(|it| it.0 == *key).map(|it| (it.1, &it.2)) + } + }; + let Some((key_at, next)) = next else { + break; + }; + let is_last = depth + 1 == path.len(); + (place, at) = ( + next, + if is_last && is_about_key { + key_at + } else { + next.at + }, + ); + } + at + } +} + +/// An entry of `[lint.rules]`. +#[derive(Clone, Debug)] +pub struct RuleOfBunfig { + pub key_at: usize, + pub setting: Json, + pub place: Place, } fn json_of(value: &Expr) -> Result { @@ -859,7 +924,7 @@ pub fn lint(file: &Expr) -> Result, Refusal> { if !is_known { return refuse(name.at, &[b"unknown category \"", &name.name, b"\""]); } - let flag: &[u8] = match severity_of(value)? { + let flag: &[u8] = match severity_of(&name.name, value)? { Severity::Off => b"allow", Severity::Warn => b"warn", Severity::Error => b"deny", @@ -874,8 +939,19 @@ pub fn lint(file: &Expr) -> Result, Refusal> { Data::EArray(items) => items.slice().first().unwrap_or(setting), _ => setting, }; - severity_of(first)?; - options.add_rules(vec![(rule.name, json_of(setting)?)]); + severity_of(&rule.name, first)?; + let written = json_of(setting)?; + options.rules_of_bunfig.push(RuleOfBunfig { + key_at: rule.at.start.max(0) as usize, + setting: written.clone(), + place: Place::of(setting), + }); + options.add_rules(vec![(rule.name, written)]); + } + } + b"settings" => { + for (name, value) in entries(value)? { + options.settings.push((name.name, json_of(value)?)); } } b"parserOptions" => { diff --git a/src/lint/driver/cli.rs b/src/lint/driver/cli.rs index 341c7090f1b6..927fc567780e 100644 --- a/src/lint/driver/cli.rs +++ b/src/lint/driver/cli.rs @@ -310,7 +310,7 @@ const ONLY_OF_ESLINT: [&[u8]; 36] = [ ]; /// The other way round, beside `--import-plugin` and the like. -const ONLY_OF_OXLINT: [&[u8]; 11] = [ +const ONLY_OF_OXLINT: [&[u8]; 10] = [ b"allow", b"warn", b"deny", @@ -321,7 +321,6 @@ const ONLY_OF_OXLINT: [&[u8]; 11] = [ b"fix-dangerously", b"type-check", b"rules", - b"lsp", ]; /// Those of [`ONLY_OF_ESLINT`] that nothing can be done about with a configuration of oxlint. @@ -419,10 +418,12 @@ pub struct Options { pub global: Vec>, pub parser: Option>, pub parser_options: Vec<(Vec, Json)>, + /// `[lint.settings]` of bunfig.toml: ESLint's and oxlint's `settings`. No flag sets them. + pub settings: Vec<(Vec, Json)>, pub plugin: Vec>, pub rule: Vec<(Vec, Json)>, - /// How many of them, the first, are in `[lint.rules]` of bunfig.toml. - pub rules_of_bunfig: usize, + /// For the first of them, which are in `[lint.rules]` of bunfig.toml: where. + pub rules_of_bunfig: Vec, pub fix: bool, pub fix_dry_run: bool, pub fix_type: Option>, @@ -469,6 +470,8 @@ pub struct Options { pub fix_safely: bool, /// oxlint's `--rules`: lists the rules, and lints nothing. pub rules: bool, + /// Serve an editor: [`crate::lsp`]. + pub lsp: bool, /// Prints the page about this rule, and lints nothing. pub explain: Option>, /// oxlint's `--import-plugin` and `--disable-oxc-plugin`, ..: a plugin by the name that it has @@ -529,9 +532,10 @@ impl Default for Options { global: Vec::new(), parser: None, parser_options: Vec::new(), + settings: Vec::new(), plugin: Vec::new(), rule: Vec::new(), - rules_of_bunfig: 0, + rules_of_bunfig: Vec::new(), fix: false, fix_dry_run: false, fix_type: None, @@ -569,6 +573,7 @@ impl Default for Options { fix_dangerously: false, fix_safely: false, rules: false, + lsp: false, explain: None, plugins: Vec::new(), type_aware: None, @@ -813,6 +818,7 @@ impl Options { // Type errors are what `bun check` reports. b"type-check" => self.type_check = is_on, b"rules" => self.rules = is_on, + b"lsp" => self.lsp = is_on, b"explain" => self.explain = owned(), _ if is_on && name.ends_with(b"-plugin") => { let plugin = &name[..name.len() - b"-plugin".len()]; @@ -852,7 +858,6 @@ impl Options { pub(crate) fn forget_whose_flags_it_has(&mut self) { self.of_one_tool.clear(); self.has_type_aware_flag = false; - self.rules_of_bunfig = self.rule.len(); } /// Whether the command line has a flag that `tool` has and the other tool has not. diff --git a/src/lint/driver/configs.rs b/src/lint/driver/configs.rs index 45791b07ef60..877e0be22d82 100644 --- a/src/lint/driver/configs.rs +++ b/src/lint/driver/configs.rs @@ -307,6 +307,8 @@ pub(crate) struct Loader<'l> { named: Guarded>>, /// [`Loader::refused`] refused: OnceLock>, + /// [`Loader::mistake`] + mistake: OnceLock>, /// `options.typeAware` of the configuration of the working directory, if that is one of oxlint. wants_types: OnceLock>, /// The project is one of Vite+ without a configuration file of a linter: [`NAMES_OF_VITE`] are the files that count. @@ -323,7 +325,7 @@ fn strings_of(items: &[Vec]) -> Json { Json::Array(items.iter().cloned().map(Json::String).collect()) } -fn severity_name(severity: Severity) -> Json { +pub(crate) fn severity_name(severity: Severity) -> Json { Json::String(match severity { Severity::Off => b"off".to_vec(), Severity::Warn => b"warn".to_vec(), @@ -521,6 +523,7 @@ impl<'l> Loader<'l> { lacks_file: OnceLock::new(), named: Default::default(), refused: OnceLock::new(), + mistake: OnceLock::new(), wants_types: OnceLock::new(), is_of_vite: OnceLock::new(), searched_from: Default::default(), @@ -704,6 +707,9 @@ impl<'l> Loader<'l> { first.push((&b"languageOptions"[..], object(language_options))); } first.push((b"rules", Json::Object(self.rules_of_the_command_line()))); + if !options.settings.is_empty() { + first.push((b"settings", Json::Object(options.settings.clone()))); + } let mut linter_options = Vec::new(); if options.report_unused_disable_directives { linter_options.push(( @@ -874,6 +880,9 @@ impl<'l> Loader<'l> { Json::Object(options.parser_options.clone()), )); } + if !options.settings.is_empty() { + last.push((b"settings", Json::Object(options.settings.clone()))); + } if last.len() > 1 { put(b"overrides", vec![object(last)]); } @@ -978,7 +987,11 @@ impl<'l> Loader<'l> { })); } self.check_flags(Flavor::BuiltIn, None)?; - native::check_rules(self.linter.registry(), self.options)?; + if let Err(mistake) = native::check_rules(self.linter.registry(), self.options) { + let colors = self.environment.stderr.colors; + let _ = self.mistake.set(mistake.print(self.cwd(), colors)); + return Err(Fatal(mistake.message)); + } // Every file is in it, on whatever drive. let mut objects = vec![built_in_json(self.options.type_aware == Some(true))]; objects.extend(native::categories(self.linter.registry(), self.options)); @@ -1101,7 +1114,8 @@ impl<'l> Loader<'l> { json }, true, - )? + ) + .map_err(|Fatal(why)| Fatal(self.name_place(Some(path), &why)))? } Flavor::Oxlint => self.rc(path, json)?, }; @@ -1340,6 +1354,26 @@ impl<'l> Loader<'l> { Err(Fatal(self.refused.get_or_init(|| refusal).clone())) } + /// `why`, the words of ESLint about a configuration, below the name of what the mistake is written in. `file`: the + /// configuration file. + pub(crate) fn name_place(&self, file: Option<&[u8]>, why: &[u8]) -> Vec { + let is_about = + |id: &[u8]| strings::contains(why, &[b"Key \"rules\": Key \"", id, b"\""].concat()); + let rule = self.options.rule.iter().position(|it| is_about(&it.0)); + let place = match (rule, file) { + (Some(at), _) if at < self.options.rules_of_bunfig.len() => b"bunfig.toml".to_vec(), + (Some(_), _) => b"--rule".to_vec(), + (None, Some(file)) => paths::relative(self.cwd(), file), + (None, None) => return why.to_vec(), + }; + [b"Invalid config in ", &place[..], b"\n", why].concat() + } + + /// What is wrong with `[lint.rules]` of bunfig.toml or with `--rule`, where no configuration file counts: as it is printed. + pub(crate) fn mistake(&self) -> Option<&[u8]> { + self.mistake.get().map(Vec::as_slice) + } + /// What [`Loader::check_flags`] has failed with. Neither tool has an exit code for it. pub(crate) fn refused(&self) -> Option<&[u8]> { self.refused.get().map(Vec::as_slice) @@ -1492,6 +1526,9 @@ impl<'l> Loader<'l> { if !options.plugin.is_empty() { entries.push((b"plugins", strings_of(&options.plugin))); } + if !options.settings.is_empty() { + entries.push((b"settings", Json::Object(options.settings.clone()))); + } let rules = self.rules_of_the_command_line(); if !rules.is_empty() { entries.push((b"rules", Json::Object(rules))); @@ -1581,7 +1618,7 @@ impl<'l> Loader<'l> { &mut load, &mut load_plugin, ) - .map_err(|error| Fatal(error.message))?; + .map_err(|error| Fatal(self.name_place(file.as_deref(), &error.message)))?; for note in config.notes() { self.warn(&[note]); } diff --git a/src/lint/driver/explain.rs b/src/lint/driver/explain.rs index fb2b6a9f0f6b..d9c2b0e3be88 100644 --- a/src/lint/driver/explain.rs +++ b/src/lint/driver/explain.rs @@ -298,7 +298,7 @@ fn find(registry: &Registry, name: &[u8]) -> Result<&'static Meta, Vec> { .concat()); } } - .ok_or_else(|| native::unknown_rule(registry, name, None)) + .ok_or_else(|| native::unknown_rule(registry, name)) } /// `--explain `: the page about the rule that `name` is a name of. `Err`: what to say instead. diff --git a/src/lint/driver/fmt/editorconfig.rs b/src/lint/driver/fmt/editorconfig.rs index 4a4de890c831..b6f579cf1ff4 100644 --- a/src/lint/driver/fmt/editorconfig.rs +++ b/src/lint/driver/fmt/editorconfig.rs @@ -7,7 +7,7 @@ use crate::{fs, paths}; use bun_core::strings; -use bun_glob::{Candidate, How, Pattern}; +use bun_glob::{Candidate, Pattern}; use bun_lint::options::Json; /// What a property is set to. @@ -188,7 +188,7 @@ fn glob_of_globset(written: &[u8]) -> Option { } at += 1; } - Some(Pattern::new(&glob, bun_glob::Options::BUN)) + Some(Pattern::new(&glob)) } /// A number of JSON. @@ -426,7 +426,7 @@ pub(crate) fn options_for<'f>(files: impl Iterator, path: &[u8] for file in files.filter(|it| paths::inside(&it.directory, path).is_some()) { let is_for_it = |it: &&Section| { let glob = it.glob.as_ref(); - glob.is_some_and(|it| it.matches_candidate(&candidate, How::default())) + glob.is_some_and(|it| it.matches_candidate(&candidate)) }; for section in file.sections.iter().filter(is_for_it) { for (key, value) in §ion.properties { diff --git a/src/lint/driver/gitignore.rs b/src/lint/driver/gitignore.rs index ce96172e4cd1..584107a6aa0b 100644 --- a/src/lint/driver/gitignore.rs +++ b/src/lint/driver/gitignore.rs @@ -7,7 +7,7 @@ use crate::{fs, paths}; use bun_core::strings; -use bun_glob::ignore::{IgnoreOptions, IgnoreRules, IgnoreSyntax, Verdict}; +use bun_glob::ignore::{IgnoreOptions, IgnoreRules, Verdict}; use bun_sema::util::FxHashSet; use std::sync::Arc; @@ -39,14 +39,14 @@ pub(crate) type Chain = Option>; /// As oxlint and oxfmt read the lines, with the crate `ignore`: `{a,b}` is `a` or `b`. const OF_OXC: IgnoreOptions = IgnoreOptions { - syntax: IgnoreSyntax::Globset, + has_braces: true, ignores_case: false, }; /// As Prettier reads them, with the package `ignore`: the braces are taken as they are, and /// `readme.md` is `README.md` too. const OF_PRETTIER: IgnoreOptions = IgnoreOptions { - syntax: IgnoreSyntax::Npm705, + has_braces: false, ignores_case: true, }; @@ -89,7 +89,7 @@ pub(crate) fn with_lines<'l>( /// The first of `lines` that oxlint and oxfmt do not take. pub(crate) fn refused_line<'l>(lines: impl IntoIterator) -> Option> { let rules = IgnoreRules::from_lines(lines, OF_OXC); - rules.refused().first().map(|it| it.line.clone()) + rules.refused().first().cloned() } /// `chain` and `file`, whose lines are relative to `directory`. diff --git a/src/lint/driver/lib.rs b/src/lint/driver/lib.rs index d2966cebf3be..71520c4cffab 100644 --- a/src/lint/driver/lib.rs +++ b/src/lint/driver/lib.rs @@ -6,6 +6,7 @@ //! - [`bunfig`]: their defaults in bunfig.toml. //! - [`run`]: `bun lint`, given the flags and an [`Environment`]. //! - [`fmt`]: the same for `bun format`. +//! - [`lsp`]: both for an editor. #![forbid(unsafe_code)] @@ -29,6 +30,7 @@ mod gitignore; mod inferred; mod init; mod lint; +pub mod lsp; mod native; mod print_config; mod processor; diff --git a/src/lint/driver/lsp/answers.rs b/src/lint/driver/lsp/answers.rs new file mode 100644 index 000000000000..8127a1b5e082 --- /dev/null +++ b/src/lint/driver/lsp/answers.rs @@ -0,0 +1,286 @@ +//! What the linter and the formatter say, in the words of the protocol: diagnostics, code actions and edits. + +use super::rpc::{number, object, text}; +use super::text::{Lines, Range, range_to_json}; +use crate::embedded::Framework; +use crate::format::oxlint::Offsets; +use bun_core::strings; +use bun_lint::context::Severity; +use bun_lint::fix::Fix; +use bun_lint::linter::{LintMessage, RuleId}; +use bun_lint::options::Json; +use bun_lint::rule::{Meta, Plugin}; + +pub(crate) const QUICK_FIX: &[u8] = b"quickfix"; +pub(crate) const FIX_ALL: &[u8] = b"source.fixAll.bun"; +const FIX_ALL_TITLE: &[u8] = b"Fix all auto-fixable problems"; + +/// Whether a client that asks for the kinds `only` wants `kind`: `source` is for `source.fixAll.bun` too. `None`: it wants what +/// goes with a place in the text. +pub(crate) fn wants(only: Option<&[Vec]>, kind: &[u8]) -> bool { + match only { + None => kind == QUICK_FIX, + Some(only) => only.iter().any(|it| { + (kind.strip_prefix(&it[..])).is_some_and(|rest| rest.is_empty() || rest[0] == b'.') + }), + } +} + +/// `DiagnosticTag` +#[derive(Copy, Clone)] +enum Tag { + Unnecessary = 1, + Deprecated = 2, +} + +/// How an editor shows what the rule reports: faded, or struck out. +fn tag_of(meta: &Meta) -> Option { + match (meta.plugin, meta.name) { + ( + Plugin::Eslint | Plugin::TypeScript, + "no-unused-vars" + | "no-unused-labels" + | "no-unused-private-class-members" + | "no-unreachable" + | "no-unreachable-loop" + | "no-unused-expressions", + ) => Some(Tag::Unnecessary), + (Plugin::TypeScript, "no-deprecated") | (Plugin::Node, "no-deprecated-api") => { + Some(Tag::Deprecated) + } + _ => None, + } +} + +fn meta_of(message: &LintMessage) -> Option<&'static Meta> { + match message.rule_id.as_ref()? { + RuleId::Known(meta) | RuleId::Named(meta, _) => Some(meta), + RuleId::Js(_) | RuleId::Unknown(_) => None, + } +} + +/// A document, with what it takes to say where something is in it. +pub(crate) struct Places<'t> { + pub(crate) uri: &'t [u8], + pub(crate) version: &'t Json, + pub(crate) path: &'t [u8], + text: &'t [u8], + pub(crate) lines: Lines<'t>, + /// For the lines and columns of a message. + offsets: Offsets<'t>, + /// The client takes `documentChanges`, which name the version that they are for. + pub(crate) has_versioned_edits: bool, +} + +impl<'t> Places<'t> { + pub(crate) fn new( + (uri, version, path): (&'t [u8], &'t Json, &'t [u8]), + lines: Lines<'t>, + text: &'t [u8], + has_versioned_edits: bool, + ) -> Places<'t> { + Places { + uri, + version, + path, + text, + lines, + offsets: Offsets::new(text, None), + has_versioned_edits, + } + } + + /// Where `message` starts and ends, in bytes. One without a place is at the start. + fn span_of(&self, message: &LintMessage) -> (usize, usize) { + if message.line == 0 { + return (0, 0); + } + let start = self.offsets.at(message.line, message.column).0; + let end = message + .end + .map(|(line, column)| self.offsets.at(line, column).0); + (start, end.unwrap_or(start).max(start)) + } + + fn range_of(&self, message: &LintMessage) -> Range { + let (start, end) = self.span_of(message); + self.lines.range(start, end) + } + + pub(crate) fn diagnostic(&self, message: &LintMessage) -> Json { + let is_error = message.is_fatal || message.severity == Severity::Error; + let mut entries = vec![ + ("range", range_to_json(self.range_of(message))), + ("severity", number(if is_error { 1 } else { 2 })), + ]; + if let Some(id) = &message.rule_id { + entries.push(("code", Json::String(id.to_vec()))); + } + if let Some(meta) = meta_of(message) { + let page = object([("href", Json::String(meta.docs_url()))]); + entries.push(("codeDescription", page)); + if let Some(tag) = tag_of(meta) { + entries.push(("tags", Json::Array(vec![number(tag as u32)]))); + } + } + entries.push(("source", text(b"bun"))); + entries.push(("message", text(&message.message))); + let entries = entries.into_iter(); + Json::Object( + entries + .map(|(key, value)| (key.as_bytes().to_vec(), value)) + .collect(), + ) + } + + fn edit(&self, (start, end): (usize, usize), new: &[u8]) -> Json { + object([ + ("range", range_to_json(self.lines.range(start, end))), + ("newText", text(new)), + ]) + } + + fn edit_of(&self, fix: &Fix) -> Json { + self.edit((fix.span.start as usize, fix.span.end as usize), &fix.text) + } + + /// The edits that make `new` of the text, line by line. `within`: only those that touch these bytes. + pub(crate) fn edits_to(&self, new: &[u8], within: Option<(usize, usize)>) -> Vec { + let hunks = bun_text_diff::diff_lines(self.text, new).into_iter(); + let hunks = + hunks.filter(|it| within.is_none_or(|(start, end)| it.a_lo <= end && start <= it.a_hi)); + hunks + .map(|it| self.edit((it.a_lo, it.a_hi), &new[it.b_lo..it.b_hi])) + .collect() + } + + /// `WorkspaceEdit` + fn in_this_document(&self, edits: Vec) -> Json { + let edits = Json::Array(edits); + if !self.has_versioned_edits { + return object([("changes", Json::Object(vec![(self.uri.to_vec(), edits)]))]); + } + let document = object([("uri", text(self.uri)), ("version", self.version.clone())]); + let change = object([("textDocument", document), ("edits", edits)]); + object([("documentChanges", Json::Array(vec![change]))]) + } + + fn action( + &self, + title: &[u8], + kind: &[u8], + about: Option<&LintMessage>, + edits: Vec, + ) -> Json { + let mut entries = vec![("title", text(title)), ("kind", text(kind))]; + if let Some(message) = about { + entries.push(("diagnostics", Json::Array(vec![self.diagnostic(message)]))); + } + entries.push(("edit", self.in_this_document(edits))); + let entries = entries.into_iter(); + Json::Object( + entries + .map(|(key, value)| (key.as_bytes().to_vec(), value)) + .collect(), + ) + } + + /// `kind`: [`QUICK_FIX`] or [`FIX_ALL`]. `fixed`: the text with all fixes. + pub(crate) fn fix_all(&self, kind: &[u8], fixed: &[u8]) -> Json { + self.action(FIX_ALL_TITLE, kind, None, self.edits_to(fixed, None)) + } + + /// A comment above the line of `message` that turns `rule` off for it. One that is there already gets one more rule. + fn disable_for_line(&self, message: &LintMessage, rule: &[u8], tool: &[u8]) -> Json { + let line = self.range_of(message).0.line; + let (start, end) = self.lines.line(line); + let comment = [b"// ", tool, b"-disable-next-line "].concat(); + if let Some(above) = line.checked_sub(1) { + let (_, end) = self.lines.line(above); + let written = self.text[self.lines.line(above).0..end].trim_ascii(); + if written.starts_with(&comment) && !strings::contains(written, b" -- ") { + let end = end - (self.text[..end].len() - self.text[..end].trim_ascii_end().len()); + return self.edit((end, end), &[b", ", rule].concat()); + } + } + let content = &self.text[start..end]; + let indentation = &content[..content.len() - content.trim_ascii_start().len()]; + let new = [indentation, &comment, rule, self.lines.line_break()].concat(); + self.edit((start, start), &new) + } + + /// A comment at the top that turns `rule` off. + fn disable_for_file(&self, rule: &[u8], tool: &[u8]) -> Json { + let after_mark = self.text.len() - strings::without_utf8_bom(self.text).len(); + let at = match self.text[after_mark..].starts_with(b"#!") { + true => self.lines.line(1).0, + false => after_mark, + }; + let line_break = self.lines.line_break(); + // The interpreter's line is the last. + let before: &[u8] = + match at == self.text.len() && at > after_mark && self.lines.line(0).1 == at { + true => line_break, + false => b"", + }; + let new = [before, b"/* ", tool, b"-disable ", rule, b" */", line_break].concat(); + self.edit((at, at), &new) + } + + /// The quick fixes for those of `messages` that touch `range`. `is_oxlint`: the configuration is one of oxlint. + pub(crate) fn quick_fixes( + &self, + messages: &[LintMessage], + range: Range, + is_oxlint: bool, + ) -> Vec { + let tool: &[u8] = if is_oxlint { b"oxlint" } else { b"eslint" }; + let touches = |it: &&LintMessage| { + let (start, end) = self.range_of(it); + start <= range.1 && range.0 <= end + }; + // In a component a script's comment is not one everywhere. + let takes_comments = Framework::of(self.path).is_none(); + let mut actions = Vec::new(); + // The rules that are turned off for a line, and for the file. + let (mut for_line, mut for_file) = (Vec::new(), Vec::new()); + for message in messages.iter().filter(touches) { + let rule = message.rule_id.as_ref().map(RuleId::to_vec); + if let Some(fix) = &message.fix { + let title = match &rule { + Some(rule) => [b"Fix ", &rule[..]].concat(), + None => b"Remove unused comment".to_vec(), + }; + let mut action = + self.action(&title, QUICK_FIX, Some(message), vec![self.edit_of(fix)]); + if let Json::Object(entries) = &mut action { + entries.push((b"isPreferred".to_vec(), Json::Bool(true))); + } + actions.push(action); + } + for suggestion in &message.suggestions { + let edits = vec![self.edit_of(&suggestion.fix)]; + actions.push(self.action(&suggestion.message, QUICK_FIX, Some(message), edits)); + } + let Some(rule) = rule.filter(|_| takes_comments) else { + continue; + }; + let line = (self.range_of(message).0.line, rule); + if !for_line.contains(&line) { + let title = [b"Disable ", &line.1[..], b" for this line"].concat(); + let edits = vec![self.disable_for_line(message, &line.1, tool)]; + for_line.push(line); + actions.push(self.action(&title, QUICK_FIX, Some(message), edits)); + } + } + for (_, rule) in for_line { + if !for_file.contains(&rule) { + let title = [b"Disable ", &rule[..], b" for this file"].concat(); + let edits = vec![self.disable_for_file(&rule, tool)]; + actions.push(self.action(&title, QUICK_FIX, None, edits)); + for_file.push(rule); + } + } + actions + } +} diff --git a/src/lint/driver/lsp/mod.rs b/src/lint/driver/lsp/mod.rs new file mode 100644 index 000000000000..ece07d9c707e --- /dev/null +++ b/src/lint/driver/lsp/mod.rs @@ -0,0 +1,1170 @@ +//! `bun lint --lsp`: a language server on standard input and output. +//! +//! The thread that reads the messages keeps the documents. Each workspace folder has a thread that lints and formats what is +//! in it, with the folder as the working directory and its `bunfig.toml`: as `bun lint` does there. It keeps the +//! configurations that it has read until one of their files changes. One more thread is for the documents that are in no +//! folder: nothing of theirs is run. +//! +//! - [`rpc`]: the messages. +//! - [`text`]: places in a document. +//! - [`session`]: a text through the linter. +//! - [`answers`]: diagnostics, code actions, edits. + +mod answers; +mod rpc; +mod session; +mod text; + +use crate::bunfig; +use crate::cli::{Options, UsageError}; +use crate::results::FileResult; +use crate::run::{Environment, Fatal, Script, Stream}; +use crate::{fs, paths}; +use answers::{FIX_ALL, Places, QUICK_FIX, wants}; +use bun_core::strings; +use bun_lint::js_plugin::{Engine, Vm}; +use bun_lint::json::Notation; +use bun_lint::linter::LintMessage; +use bun_lint::options::Json; +use bun_sema::util::FxHashMap; +use bun_threading::{Condition, Guarded}; +use rpc::{ErrorCode, Frame, Incoming, Reader, Writer, dig, number, object}; +use session::{How, Session, Weight}; +use std::collections::VecDeque; +use std::sync::{Arc, OnceLock}; +use text::{Encoding, Lines, Range}; + +/// What the server takes from the process, beside the [`Environment`]. +pub struct Process<'p> { + /// Reads some of what the client sends. 0: there is no more. + pub read: &'p (dyn Fn(&mut [u8]) -> usize + Sync), + /// Writes all of it to the client. + pub write: &'p (dyn Fn(&[u8]) + Sync), + /// Calls the function with an engine that nothing else uses, and frees it afterwards. + pub with_engine: &'p (dyn Fn(&mut dyn FnMut(&dyn Engine)) + Sync), + /// Ends the process at once, whatever its threads are doing. + pub exit: &'p (dyn Fn(u8) + Sync), +} + +/// For what is in no workspace folder. +struct NoEngine; + +impl Engine for NoEngine { + fn with_vm(&self, _: usize, _: &mut dyn FnMut(&mut dyn Vm)) -> Result<(), Vec> { + Err(b"Plugins only run for files in a workspace folder".to_vec()) + } +} + +/// A document as it was at one moment. +struct Document { + uri: Vec, + /// What the client calls this state of it. + version: Json, + /// What the server calls it: higher for a later one, of whatever document. + serial: u64, + /// What it is linted and formatted as. + path: Vec, + text: Vec, +} + +enum Job { + Lint { + document: Arc, + with_types: bool, + }, + /// `textDocument/codeAction` + Actions { + id: Json, + document: Arc, + range: Range, + only: Option>>, + }, + /// `textDocument/formatting`, and with a range `textDocument/rangeFormatting`. + Format { + id: Json, + document: Arc, + range: Option, + }, +} + +#[derive(Default)] +struct Queue { + /// What the client waits for goes first. + requests: VecDeque, + lints: VecDeque, + /// It goes up when a configuration may have changed. + generation: u64, + is_closed: bool, +} + +/// A workspace folder, or what is in none. +struct Root { + /// `/` for what is in no folder. + path: Vec, + is_folder: bool, + queue: Guarded, + has_work: Condition, +} + +impl Root { + fn change(&self, change: impl FnOnce(&mut Queue)) { + change(&mut self.queue.lock()); + self.has_work.notify_one(); + } + + /// Waits for a job. With it: [`Queue::generation`]. `None`: there are no more. + fn take(&self) -> Option<(Job, u64)> { + let mut queue = self.queue.lock(); + loop { + if queue.is_closed { + return None; + } + let job = queue.requests.pop_front(); + if let Some(job) = job.or_else(|| queue.lints.pop_front()) { + return Some((job, queue.generation)); + } + self.has_work.wait_guarded(&mut queue); + } + } +} + +/// What the client can do. +struct Client { + encoding: Encoding, + has_versioned_edits: bool, + watches_files: bool, +} + +/// What the user has set in the editor. +#[derive(Copy, Clone)] +struct Settings { + lints: bool, + formats: bool, + /// Not only when a document is opened or saved. + lints_on_type: bool, +} + +impl Settings { + /// `{ lint: { enable, run }, format: { enable } }`. What is not there stays as it is. + fn read(&mut self, json: &Json) { + let flag = |path: &[&str]| dig(json, path).and_then(Json::as_bool); + self.lints = flag(&["lint", "enable"]).unwrap_or(self.lints); + self.formats = flag(&["format", "enable"]).unwrap_or(self.formats); + match dig(json, &["lint", "run"]).and_then(Json::as_str) { + Some(b"onType") => self.lints_on_type = true, + Some(b"onSave") => self.lints_on_type = false, + _ => {} + } + } +} + +/// The problems of a document, for its code actions. +struct Known { + serial: u64, + had_types: bool, + is_oxlint: bool, + messages: Vec, +} + +/// What all threads share. +struct Server<'s> { + /// What follows `lint` on the command line. + args: &'s [&'s [u8]], + environment: &'s Environment<'s>, + process: &'s Process<'s>, + out: Writer<'s>, + client: OnceLock, + settings: Guarded, + folders: Guarded>>, + /// [`Document::serial`] of each open document, by its URI. + latest: Guarded, u64>>, + /// The requests that are not answered yet, and whether the client has taken each back. + pending: Guarded>, +} + +/// The files that say how a file is linted or formatted, or whether it is at all. +const WATCHED: [&str; 13] = [ + "**/bunfig.toml", + "**/package.json", + "**/{eslint,oxlint,prettier,vite}.config.{js,mjs,cjs,ts,mts,cts}", + "**/.eslintrc", + "**/.eslintrc.{js,cjs,json,yaml,yml}", + "**/.oxlintrc.{json,jsonc}", + "**/.oxfmtrc.{json,jsonc}", + "**/.prettierrc", + "**/.prettierrc.{js,mjs,cjs,ts,mts,cts,json,json5,yaml,yml,toml}", + "**/.editorconfig", + "**/{.gitignore,.eslintignore,.prettierignore}", + "**/{tsconfig,jsconfig}.json", + "**/tsconfig.*.json", +]; + +/// Whether a file called `name` is one of [`WATCHED`]: for a client that does not watch. +fn is_watched(name: &[u8]) -> bool { + let stem = strings::split_once_char(name, b'.').map_or(name, |it| it.0); + matches!( + name, + b"bunfig.toml" + | b"package.json" + | b".editorconfig" + | b".gitignore" + | b".eslintignore" + | b".prettierignore" + ) || [&b".eslintrc"[..], b".oxlintrc", b".oxfmtrc", b".prettierrc"] + .iter() + .any(|it| name.starts_with(it)) + || (matches!(stem, b"eslint" | b"oxlint" | b"prettier" | b"vite") + && strings::contains(name, b".config.")) + || (matches!(stem, b"tsconfig" | b"jsconfig") && name.ends_with(b".json")) +} + +/// The path that a `file:` URI names, with `/` between its parts. `None`: it is another kind of URI. +fn path_of(uri: &[u8]) -> Option> { + let rest = uri.strip_prefix(b"file://")?; + let (host, path) = rest.split_at(strings::index_of_char_usize(rest, b'/')?); + let path = bun_url::PercentEncoding::decode_alloc(path).ok()?; + Some(match (host, &path[..]) { + (b"" | b"localhost", [b'/', drive, b':', ..]) + if cfg!(windows) && drive.is_ascii_alphabetic() => + { + path[1..].to_vec() + } + (b"" | b"localhost", _) => path.into_vec(), + (host, _) => [b"//", host, &path].concat(), + }) +} + +/// What files in the language `id` end in. +fn extension_of(id: &[u8]) -> &'static [u8] { + match id { + b"javascript" => b".js", + b"javascriptreact" => b".jsx", + b"typescript" => b".ts", + b"typescriptreact" => b".tsx", + b"json" => b".json", + b"jsonc" => b".jsonc", + b"json5" => b".json5", + b"css" => b".css", + b"scss" => b".scss", + b"less" => b".less", + b"html" => b".html", + b"vue" => b".vue", + b"svelte" => b".svelte", + b"astro" => b".astro", + b"markdown" => b".md", + b"mdx" => b".mdx", + b"yaml" => b".yaml", + b"graphql" => b".graphql", + b"handlebars" => b".hbs", + b"toml" => b".toml", + _ => b"", + } +} + +/// What a document that is no file is called: `Untitled-1.ts` for `untitled:Untitled-1` in TypeScript. +fn name_of(uri: &[u8], language: &[u8]) -> Vec { + let after_scheme = strings::split_once_char(uri, b':').map_or(uri, |it| it.1); + let end = strings::index_of_any(after_scheme, b"?#").unwrap_or(after_scheme.len()); + let decoded = bun_url::PercentEncoding::decode_alloc(&after_scheme[..end]); + let path = decoded.as_deref().unwrap_or(&after_scheme[..end]); + let name = paths::basename(path); + match paths::extname(name) { + b"" => [name, extension_of(language)].concat(), + _ => name.to_vec(), + } +} + +/// What `read` makes of the `bunfig.toml` in `directory`. `Ok(None)`: nothing, or there is no such file. +fn read_bunfig(directory: &[u8], read: bunfig::Read) -> Result, Vec> { + let Ok(text) = fs::read(&paths::join(directory, b"bunfig.toml")) else { + return Ok(None); + }; + let found = bun_lint::json::with_parsed(Notation::Toml, &text, read).map_err(|it| it.message); + let found = found.and_then(|it| it.map_err(|refusal| refusal.message)); + found.map_err(|why| [b"Cannot use bunfig.toml: ", &why[..]].concat()) +} + +impl Server<'_> { + fn client(&self) -> &Client { + self.client.get_or_init(|| Client { + encoding: Encoding::Utf16, + has_versioned_edits: false, + watches_files: false, + }) + } + + /// `type`: 1 for an error, 2 for a warning, 3 for a note. + fn tell(&self, method: &str, kind: u32, message: &[u8]) { + let params = object([("type", number(kind)), ("message", rpc::text(message))]); + self.out.notify(method, params); + } + + /// Puts an error before the user. + fn show_error(&self, message: &[u8]) { + self.tell("window/showMessage", 1, &[b"bun lint: ", message].concat()); + } + + fn is_in_a_folder(&self, path: &[u8]) -> bool { + (self.folders.lock().iter()).any(|it| paths::is_at_or_inside(it, path)) + } + + fn is_latest(&self, document: &Document) -> bool { + self.latest.lock().get(&document.uri) == Some(&document.serial) + } + + fn send_diagnostics(&self, document: &Document, diagnostics: Vec) { + self.out.notify( + "textDocument/publishDiagnostics", + object([ + ("uri", rpc::text(&document.uri)), + ("version", document.version.clone()), + ("diagnostics", Json::Array(diagnostics)), + ]), + ); + } + + /// Unless the document has changed or is closed. + fn publish(&self, document: &Document, diagnostics: Vec) { + let latest = self.latest.lock(); + if latest.get(&document.uri) == Some(&document.serial) { + self.send_diagnostics(document, diagnostics); + } + } + + /// Nothing is said about `document` any more. + fn close(&self, document: &Document) { + let mut latest = self.latest.lock(); + latest.remove(&document.uri); + self.send_diagnostics(document, Vec::new()); + } + + /// Whether the request `id`, which is about `document`, is still to be answered. If not it is answered here. + fn is_wanted(&self, id: &Json, document: &Document) -> bool { + let is_cancelled = (self.pending.lock().iter()).any(|it| it.0 == *id && it.1); + let is_latest = self.is_latest(document); + if is_cancelled { + self.cancel(id); + } else if !is_latest { + self.finish( + id, + Err((ErrorCode::ContentModified, "The document has changed")), + ); + } + !is_cancelled && is_latest + } + + fn cancel(&self, id: &Json) { + self.finish( + id, + Err((ErrorCode::RequestCancelled, "The request was cancelled")), + ); + } + + fn finish(&self, id: &Json, answer: Result) { + self.pending.lock().retain(|it| it.0 != *id); + match answer { + Ok(result) => self.out.reply(id.clone(), result), + Err((code, message)) => self.out.fail(id.clone(), code, message), + } + } + + fn places<'d>(&self, document: &'d Document) -> Places<'d> { + let client = self.client(); + Places::new( + (&document.uri, &document.version, &document.path), + Lines::new(&document.text, client.encoding), + &document.text, + client.has_versioned_edits, + ) + } + + /// The flags of `bun lint` in `root`. + fn options_of(&self, root: &Root) -> Result> { + let defaults = match root.is_folder { + true => read_bunfig(&root.path, bunfig::lint)?, + false => None, + }; + match defaults { + Some(defaults) => Options::parse_over(defaults, self.args), + None => Options::parse(self.args), + } + .map_err(|UsageError(message)| message) + } + + /// What the thread of `root` does. + fn work(&self, root: &Root) { + let mut next = root.take(); + while let Some((first, generation)) = next.take() { + let mut first = Some(first); + let mut serve = |engine: &dyn Engine| { + if let Some(first) = first.take() { + next = self.work_with(root, engine, (first, generation)); + } + }; + match root.is_folder { + true => (self.process.with_engine)(&mut serve), + false => serve(&NoEngine), + } + } + } + + /// Does `first` and what follows it, as long as the configurations are those of `generation`. Returns the first job that + /// is for later ones. + fn work_with( + &self, + root: &Root, + engine: &dyn Engine, + (first, generation): (Job, u64), + ) -> Option<(Job, u64)> { + let run_script = |script: &Script| { + match self.is_in_a_folder(script.path) { + true => (self.environment.run_script)(script), + false => Err(b"It is outside the workspace folders, so it was not run\n Open its folder in the editor to use it".to_vec()), + } + }; + let environment = &Environment { + cwd: root.path.clone(), + stdout: Stream::default(), + stderr: Stream::default(), + is_ai_agent: false, + is_github_action: false, + run_script: &run_script, + js_engine: engine, + ..*self.environment + }; + let mut worker = Worker { + server: self, + root, + environment, + known: FxHashMap::default(), + told: Vec::new(), + }; + let all = |session: Option<&Session>| { + let mut job = first; + loop { + worker.handle(session, job); + match root.take() { + Some((taken, now)) if now == generation => job = taken, + other => return other, + } + } + }; + match self.options_of(root) { + Ok(options) => session::with_session(&options, environment, |session| { + let refusal = session.refusal(); + if let Some(refusal) = &refusal { + self.show_error(refusal); + } + all(refusal.is_none().then_some(session)) + }), + Err(why) => { + self.show_error(&why); + all(None) + } + } + } +} + +/// The thread of a [`Root`], while its configurations stay as they are. +struct Worker<'w> { + server: &'w Server<'w>, + root: &'w Root, + environment: &'w Environment<'w>, + /// By URI. + known: FxHashMap, Known>, + /// What the user has been told, which is not said again. + told: Vec>, +} + +impl Worker<'_> { + fn tell_once(&mut self, line: Vec, tell: impl FnOnce(&[u8])) { + if !self.told.contains(&line) { + tell(&line); + self.told.push(line); + } + } + + /// Lints `document`. `None`: it cannot be, which the user is told. + fn lint( + &mut self, + session: &Session, + document: &Document, + how: How, + ) -> Option> { + let server = self.server; + let linted = session.lint(&document.path, document.text.clone(), how); + for (weight, line) in session.notes() { + let kind = if weight == Weight::Warning { 2 } else { 3 }; + self.tell_once(line, |line| server.tell("window/logMessage", kind, line)); + } + match linted { + Ok(result) => Some(result), + Err(Fatal(why)) => { + self.tell_once(why, |why| server.show_error(why)); + None + } + } + } + + /// The problems of `document` as they are known, or as they are found now. + fn problems(&mut self, session: &Session, document: &Document) -> Option<&Known> { + if self + .known + .get(&document.uri) + .is_none_or(|it| it.serial != document.serial) + { + let result = self.lint(session, document, How::default())?; + self.note(document, result); + } + self.known.get(&document.uri) + } + + fn note(&mut self, document: &Document, result: Option) { + let known = Known { + serial: document.serial, + had_types: result.as_ref().is_some_and(|it| it.had_types), + is_oxlint: (result.as_ref().and_then(|it| it.linted.as_ref())) + .is_some_and(|it| it.config.language.is_oxlint), + messages: result.map(|it| it.messages).unwrap_or_default(), + }; + self.known.insert(document.uri.clone(), known); + } + + fn handle(&mut self, session: Option<&Session>, job: Job) { + let server = self.server; + // Of the documents that are closed nothing is kept. + { + let latest = server.latest.lock(); + self.known.retain(|uri, _| latest.contains_key(uri)); + } + match job { + Job::Lint { + document, + with_types, + } => { + if !server.is_latest(&document) { + return; + } + let how = How { + with_types, + fixes: false, + }; + let Some(result) = session.and_then(|it| self.lint(it, &document, how)) else { + self.known.remove(&document.uri); + return server.publish(&document, Vec::new()); + }; + // They take as long as it takes to check the program: when there is nothing else to do. + let lacks_types = !with_types + && (session.zip(result.as_ref())).is_some_and(|(session, result)| { + session.wants_types(&document.path, result) + }); + let places = server.places(&document); + self.note(&document, result); + let known = self.known.get(&document.uri); + let messages = known.map(|it| &it.messages[..]).unwrap_or_default(); + let diagnostics = messages.iter().map(|it| places.diagnostic(it)); + server.publish(&document, diagnostics.collect()); + if lacks_types { + self.root.change(|queue| { + queue.lints.push_back(Job::Lint { + document, + with_types: true, + }); + }); + } + } + Job::Actions { + id, + document, + range, + only, + } => { + if server.is_wanted(&id, &document) { + let actions = + session.map(|it| self.actions(it, &document, range, only.as_deref())); + server.finish(&id, Ok(Json::Array(actions.unwrap_or_default()))); + } + } + Job::Format { + id, + document, + range, + } => { + if server.is_wanted(&id, &document) { + let edits = self.format(&document, range); + server.finish(&id, Ok(edits.map_or(Json::Null, Json::Array))); + } + } + } + } + + fn actions( + &mut self, + session: &Session, + document: &Document, + range: Range, + only: Option<&[Vec]>, + ) -> Vec { + let places = self.server.places(document); + let Some(known) = self.problems(session, document) else { + return Vec::new(); + }; + let with_types = known.had_types; + let fixable = known.messages.iter().filter(|it| it.fix.is_some()).count(); + let mut actions = match wants(only, QUICK_FIX) { + true => places.quick_fixes(&known.messages, range, known.is_oxlint), + false => Vec::new(), + }; + // Beside the fix of one problem, if there are others. + let has_a_fix = actions.iter().any(|it| it.get(b"isPreferred").is_some()); + let kinds = [ + (QUICK_FIX, has_a_fix && fixable > 1), + (FIX_ALL, fixable > 0 && wants(only, FIX_ALL)), + ]; + if kinds.iter().any(|it| it.1) { + let how = How { + with_types, + fixes: true, + }; + let fixed = self.lint(session, document, how).flatten(); + if let Some(fixed) = fixed + .as_ref() + .filter(|it| it.is_fixed) + .and_then(FileResult::written) + { + let kinds = kinds.iter().filter(|it| it.1); + actions.extend(kinds.map(|it| places.fix_all(it.0, fixed))); + } + } + actions + } + + /// The edits that format `document`. `None`: it cannot be formatted, or is not to be. + fn format(&mut self, document: &Document, range: Option) -> Option> { + let server = self.server; + let defaults = match self.root.is_folder { + true => read_bunfig(&self.root.path, bunfig::format), + false => Ok(None), + }; + let mut options = match defaults { + Ok(defaults) => defaults.unwrap_or_default(), + Err(why) => { + self.tell_once(why, |why| server.show_error(why)); + return None; + } + }; + let places = server.places(document); + let range = + range.map(|(start, end)| (places.lines.offset(start), places.lines.offset(end))); + if let Some((start, end)) = range { + // Prettier counts code units of UTF-16. + let index = |offset: usize| { + strings::wtf8_len_utf16(&document.text[..offset]) + .to_string() + .into_bytes() + }; + options + .format + .retain(|it| !matches!(it.0, b"rangeStart" | b"rangeEnd")); + options.format.push((&b"rangeStart"[..], index(start))); + options.format.push((&b"rangeEnd"[..], index(end))); + } + options.stdin_filepath = Some(paths::to_native(document.path.clone())); + let outcome = crate::fmt::format_text(&options, self.environment, document.text.clone()); + if !outcome.stderr.is_empty() { + server.tell("window/logMessage", 2, outcome.stderr.trim_ascii_end()); + } + (outcome.exit_code == 0).then(|| places.edits_to(&outcome.stdout, range)) + } +} + +/// Where the server is in its life. +#[derive(Copy, Clone, PartialEq, Eq)] +enum Phase { + /// Before `initialize`. + New, + Running, + /// After `shutdown`. + Down, +} + +/// What only the thread has that reads the messages. +struct Desk<'d, 'scope, 's> { + server: &'d Server<'s>, + scope: &'scope std::thread::Scope<'scope, 'd>, + phase: Phase, + /// By URI. + documents: FxHashMap, Arc>, + roots: Vec>, + /// [`Document::serial`] of the next. + serial: u64, +} + +/// As the threads of the pool: the rules walk a tree by recursion. +const STACK: usize = 8 * bun_threading::thread_pool::DEFAULT_THREAD_STACK_SIZE as usize; + +fn uri_in(params: &Json) -> Option<&[u8]> { + dig(params, &["textDocument", "uri"])?.as_str() +} + +impl<'d> Desk<'d, '_, '_> { + /// The root of the file at `path`: the innermost folder that it is in. Its thread is started when it is first asked for. + fn root_of(&mut self, path: &[u8]) -> Option> { + let folders = self.server.folders.lock(); + let inside = folders.iter().filter(|it| paths::is_at_or_inside(it, path)); + let folder = inside.max_by_key(|it| it.len()).cloned(); + drop(folders); + let is_folder = folder.is_some(); + let path = folder.unwrap_or_else(|| b"/".to_vec()); + let known = |it: &&Arc| it.path == path && it.is_folder == is_folder; + if let Some(root) = self.roots.iter().find(known) { + return Some(Arc::clone(root)); + } + let root = Arc::new(Root { + path, + is_folder, + queue: Guarded::default(), + has_work: Condition::default(), + }); + let (server, of_thread) = (self.server, Arc::clone(&root)); + let thread = std::thread::Builder::new().stack_size(STACK); + let spawned = thread.spawn_scoped(self.scope, move || server.work(&of_thread)); + if spawned.is_err() { + server.show_error(b"Could not start a thread"); + return None; + } + self.roots.push(Arc::clone(&root)); + Some(root) + } + + fn lint(&mut self, document: &Arc) { + if !self.server.settings.lock().lints { + return; + } + if let Some(root) = self.root_of(&document.path) { + let document = Arc::clone(document); + root.change(|queue| { + // One that is overtaken is not even begun. + queue.lints.retain(|it| !matches!(it, Job::Lint { document: other, .. } if other.uri == document.uri)); + queue.lints.push_back(Job::Lint { + document, + with_types: false, + }); + }); + } + } + + /// The configurations are read again, and every document is linted with them. + fn look_again(&mut self) { + for root in &self.roots { + root.change(|queue| queue.generation += 1); + } + let lints = self.server.settings.lock().lints; + let documents: Vec> = self.documents.values().cloned().collect(); + for document in &documents { + match lints { + true => self.lint(document), + false => self.server.publish(document, Vec::new()), + } + } + } + + /// Takes in a state of the document at `uri`. + fn keep(&mut self, uri: &[u8], version: Json, path: Vec, text: Vec) -> Arc { + self.serial += 1; + let document = Arc::new(Document { + uri: uri.to_vec(), + version, + serial: self.serial, + path, + text, + }); + self.server.latest.lock().insert(uri.to_vec(), self.serial); + self.documents.insert(uri.to_vec(), Arc::clone(&document)); + document + } + + fn set_folders(&mut self, added: &[Json], removed: &[Json]) { + let path = |it: &Json| path_of(it.get(b"uri")?.as_str()?).map(|it| paths::normalize(&it)); + let removed: Vec> = removed.iter().filter_map(path).collect(); + let mut folders = self.server.folders.lock(); + folders.retain(|it| !removed.contains(it)); + for folder in added.iter().filter_map(path) { + if !folders.contains(&folder) { + folders.push(folder); + } + } + drop(folders); + let is_gone = |it: &Arc| it.is_folder && removed.contains(&it.path); + for root in self.roots.iter().filter(|it| is_gone(it)) { + let mut unanswered = VecDeque::new(); + root.change(|queue| { + queue.is_closed = true; + unanswered = std::mem::take(&mut queue.requests); + }); + for job in unanswered { + if let Job::Actions { id, .. } | Job::Format { id, .. } = job { + self.server.cancel(&id); + } + } + } + self.roots.retain(|it| !is_gone(it)); + } + + fn initialize(&mut self, params: &Json) -> Json { + let server = self.server; + let capability = |path: &[&str]| dig(params, &[&["capabilities"][..], path].concat()); + let is_on = |path: &[&str]| capability(path).and_then(Json::as_bool) == Some(true); + let encodings = capability(&["general", "positionEncodings"]).and_then(Json::as_array); + let takes_utf8 = encodings + .unwrap_or_default() + .iter() + .any(|it| it.as_str() == Some(b"utf-8")); + let client = server.client.get_or_init(|| Client { + encoding: if takes_utf8 { + Encoding::Utf8 + } else { + Encoding::Utf16 + }, + has_versioned_edits: is_on(&["workspace", "workspaceEdit", "documentChanges"]), + watches_files: is_on(&["workspace", "didChangeWatchedFiles", "dynamicRegistration"]), + }); + match params.get(b"workspaceFolders").and_then(Json::as_array) { + Some(folders) => self.set_folders(folders, &[]), + None => { + let uri = params + .get(b"rootUri") + .filter(|it| it.as_str().is_some()) + .cloned(); + let path = params.get(b"rootPath").and_then(Json::as_str); + match (uri, path) { + (Some(uri), _) => self.set_folders(&[object([("uri", uri)])], &[]), + (None, Some(path)) => { + let path = paths::normalize(&paths::from_native(path)); + server.folders.lock().push(path); + } + (None, None) => {} + } + } + } + if let Some(settings) = params.get(b"initializationOptions") { + server.settings.lock().read(settings); + } + let formats = Json::Bool(server.settings.lock().formats); + let kinds = [QUICK_FIX, FIX_ALL].map(rpc::text).to_vec(); + let capabilities = object([ + ( + "positionEncoding", + rpc::text(match client.encoding { + Encoding::Utf8 => b"utf-8", + Encoding::Utf16 => b"utf-16", + }), + ), + ( + "textDocumentSync", + object([ + ("openClose", Json::Bool(true)), + // Incremental. + ("change", number(2)), + ("save", Json::Bool(true)), + ]), + ), + ( + "codeActionProvider", + object([("codeActionKinds", Json::Array(kinds))]), + ), + ("documentFormattingProvider", formats.clone()), + ("documentRangeFormattingProvider", formats), + ( + "workspace", + object([( + "workspaceFolders", + object([ + ("supported", Json::Bool(true)), + ("changeNotifications", Json::Bool(true)), + ]), + )]), + ), + ]); + let info = object([ + ("name", rpc::text(b"bun")), + ("version", rpc::text(server.environment.version)), + ]); + object([("capabilities", capabilities), ("serverInfo", info)]) + } + + /// Hands a request about a document to the thread of its root. + fn hand_over( + &mut self, + id: Json, + params: &Json, + job: impl FnOnce(Json, Arc) -> Option, + ) { + let server = self.server; + let document = uri_in(params) + .and_then(|uri| self.documents.get(uri)) + .cloned(); + let root = document.as_ref().and_then(|it| self.root_of(&it.path)); + let job = document.and_then(|it| job(id.clone(), it)); + let (Some(root), Some(job)) = (root, job) else { + return (server.out).fail( + id, + ErrorCode::InvalidParams, + "The request is about no open document", + ); + }; + server.pending.lock().push((id, false)); + root.change(|queue| queue.requests.push_back(job)); + } + + fn request(&mut self, id: Json, method: &[u8], params: &Json) { + let server = self.server; + match (self.phase, method) { + (Phase::New, b"initialize") => { + self.phase = Phase::Running; + server.out.reply(id, self.initialize(params)); + } + (Phase::New, _) => { + (server.out).fail( + id, + ErrorCode::ServerNotInitialized, + "The first request has to be initialize", + ); + } + (Phase::Running, b"shutdown") => { + self.phase = Phase::Down; + server.out.reply(id, Json::Null); + } + (Phase::Down, _) | (_, b"initialize") => { + server.out.fail( + id, + ErrorCode::InvalidRequest, + "The request comes at the wrong time", + ); + } + (_, b"textDocument/codeAction") if !server.settings.lock().lints => { + server.out.reply(id, Json::Array(Vec::new())); + } + (_, b"textDocument/codeAction") => self.hand_over(id, params, |id, document| { + let only = dig(params, &["context", "only"]).and_then(Json::as_array); + let kinds = |all: &[Json]| { + all.iter() + .filter_map(|it| Some(it.as_str()?.to_vec())) + .collect() + }; + Some(Job::Actions { + id, + document, + range: text::range_from_json(params.get(b"range")?)?, + only: only.map(kinds), + }) + }), + (_, b"textDocument/formatting" | b"textDocument/rangeFormatting") + if !server.settings.lock().formats => + { + server.out.reply(id, Json::Null); + } + (_, b"textDocument/formatting") => self.hand_over(id, params, |id, document| { + Some(Job::Format { + id, + document, + range: None, + }) + }), + (_, b"textDocument/rangeFormatting") => self.hand_over(id, params, |id, document| { + Some(Job::Format { + id, + document, + range: Some(text::range_from_json(params.get(b"range")?)?), + }) + }), + _ => server.out.fail( + id, + ErrorCode::MethodNotFound, + "The server does not have this method", + ), + } + } + + fn notification(&mut self, method: &[u8], params: &Json) { + let server = self.server; + if method == b"exit" { + return (server.process.exit)(u8::from(self.phase != Phase::Down)); + } + if self.phase != Phase::Running { + return; + } + match method { + b"initialized" if server.client().watches_files => { + let watcher = |it: &str| object([("globPattern", rpc::text(it.as_bytes()))]); + let registration = object([ + ("id", rpc::text(b"bun-watched-files")), + ("method", rpc::text(b"workspace/didChangeWatchedFiles")), + ( + "registerOptions", + object([("watchers", Json::Array(WATCHED.map(watcher).to_vec()))]), + ), + ]); + let params = object([("registrations", Json::Array(vec![registration]))]); + (server.out).request(b"bun-register", "client/registerCapability", params); + } + b"textDocument/didOpen" => { + let part = |key: &str| dig(params, &["textDocument", key]); + let (Some(uri), Some(text)) = (uri_in(params), part("text").and_then(Json::as_str)) + else { + return; + }; + let language = part("languageId") + .and_then(Json::as_str) + .unwrap_or_default(); + // What is no file is as good as one at the top of the first folder. + let path = path_of(uri) + .map(|it| paths::normalize(&it)) + .unwrap_or_else(|| { + let folders = server.folders.lock(); + let directory = folders.first().map_or(&b"/"[..], |it| &it[..]); + paths::join(directory, &name_of(uri, language)) + }); + let version = part("version").cloned().unwrap_or(Json::Null); + let document = self.keep(uri, version, path, text.to_vec()); + self.lint(&document); + } + b"textDocument/didChange" => { + let Some(before) = uri_in(params) + .and_then(|uri| self.documents.get(uri)) + .cloned() + else { + return; + }; + let changes = params.get(b"contentChanges").and_then(Json::as_array); + let encoding = server.client().encoding; + let Some(text) = + text::apply_changes(before.text.clone(), changes.unwrap_or_default(), encoding) + else { + return; + }; + let version = dig(params, &["textDocument", "version"]).cloned(); + let document = self.keep( + &before.uri, + version.unwrap_or(Json::Null), + before.path.clone(), + text, + ); + if server.settings.lock().lints_on_type { + self.lint(&document); + } + } + b"textDocument/didSave" => { + let Some(document) = uri_in(params) + .and_then(|uri| self.documents.get(uri)) + .cloned() + else { + return; + }; + if !server.client().watches_files && is_watched(paths::basename(&document.path)) { + self.look_again(); + } else if !server.settings.lock().lints_on_type { + self.lint(&document); + } + } + b"textDocument/didClose" => { + if let Some(document) = uri_in(params).and_then(|uri| self.documents.remove(uri)) { + server.close(&document); + } + } + b"workspace/didChangeWatchedFiles" => self.look_again(), + b"workspace/didChangeConfiguration" => { + if let Some(settings) = dig(params, &["settings", "bun"]) { + server.settings.lock().read(settings); + } + self.look_again(); + } + b"workspace/didChangeWorkspaceFolders" => { + let list = |key: &str| { + dig(params, &["event", key]) + .and_then(Json::as_array) + .unwrap_or_default() + }; + self.set_folders(list("added"), list("removed")); + self.look_again(); + } + b"$/cancelRequest" => { + if let Some(id) = params.get(b"id") { + let mut pending = server.pending.lock(); + (pending.iter_mut().filter(|it| it.0 == *id)).for_each(|it| it.1 = true); + } + } + _ => {} + } + } + + fn run(&mut self) { + let server = self.server; + let mut reader = Reader::new(server.process.read); + loop { + let message = match reader.next() { + Frame::Message(message) => message, + Frame::Malformed => { + (server.out).fail( + Json::Null, + ErrorCode::ParseError, + "The header has no Content-Length", + ); + continue; + } + // The client is gone. + Frame::End => return (server.process.exit)(u8::from(self.phase != Phase::Down)), + }; + match Incoming::parse(&message) { + Incoming::Request { id, method, params } => self.request(id, &method, ¶ms), + Incoming::Notification { method, params } => self.notification(&method, ¶ms), + Incoming::Response => {} + Incoming::Unreadable => { + server + .out + .fail(Json::Null, ErrorCode::ParseError, "The message is not JSON"); + } + Incoming::Invalid { id } => { + server.out.fail( + id, + ErrorCode::InvalidRequest, + "The message is not a request", + ); + } + } + } + } +} + +/// Serves a client until it says `exit` or is gone, which ends the process. `args`: what follows `lint` on the command line. +pub fn serve(args: &[&[u8]], environment: &Environment, process: &Process) { + let server = &Server { + args, + environment, + process, + out: Writer::new(process.write), + client: OnceLock::new(), + settings: Guarded::new(Settings { + lints: true, + formats: true, + lints_on_type: true, + }), + folders: Guarded::default(), + latest: Guarded::default(), + pending: Guarded::default(), + }; + std::thread::scope(|scope| { + let mut desk = Desk { + server, + scope, + phase: Phase::New, + documents: FxHashMap::default(), + roots: Vec::new(), + serial: 0, + }; + desk.run(); + for root in &desk.roots { + root.change(|queue| queue.is_closed = true); + } + }); +} diff --git a/src/lint/driver/lsp/rpc.rs b/src/lint/driver/lsp/rpc.rs new file mode 100644 index 000000000000..93633eadc278 --- /dev/null +++ b/src/lint/driver/lsp/rpc.rs @@ -0,0 +1,213 @@ +//! JSON-RPC 2.0 as the Language Server Protocol frames it: `Content-Length: `, an empty line, the message. + +use bun_core::strings; +use bun_lint::linter::write_json; +use bun_lint::options::Json; +use bun_threading::Guarded; + +/// The codes of errors that are used here. +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub(crate) enum ErrorCode { + ParseError = -32700, + InvalidRequest = -32600, + MethodNotFound = -32601, + InvalidParams = -32602, + ServerNotInitialized = -32002, + /// The document has changed since the request was made. + ContentModified = -32801, + RequestCancelled = -32800, +} + +/// What the client has sent. +pub(crate) enum Frame { + Message(Vec), + /// A header without a length. What follows it is read as the next header. + Malformed, + /// There is nothing more to read. + End, +} + +/// [`Process::read`](super::Process::read) +pub(crate) type Read<'r> = &'r (dyn Fn(&mut [u8]) -> usize + Sync); + +pub(crate) struct Reader<'r> { + read: Read<'r>, + /// What is read and not yet returned. + buffer: Vec, +} + +/// `Content-Length` of a header, without the empty line. +fn length_in(header: &[u8]) -> Option { + strings::split(header, b"\r\n").find_map(|line| { + let (name, value) = strings::split_once_char(line, b':')?; + (name.trim_ascii().eq_ignore_ascii_case(b"content-length")) + .then(|| bun_core::fmt::parse_decimal(value.trim_ascii()))? + }) +} + +impl<'r> Reader<'r> { + pub(crate) fn new(read: Read<'r>) -> Reader<'r> { + Reader { + read, + buffer: Vec::new(), + } + } + + /// Reads some more. `false`: there is no more. + fn fill(&mut self) -> bool { + let had = self.buffer.len(); + self.buffer.resize(had + (64 << 10), 0); + let count = (self.read)(&mut self.buffer[had..]).min(64 << 10); + self.buffer.truncate(had + count); + count > 0 + } + + pub(crate) fn next(&mut self) -> Frame { + const END: &[u8] = b"\r\n\r\n"; + // Where the end of the header has been looked for already. + let mut searched = 0; + let header = loop { + if let Some(at) = strings::index_of(&self.buffer[searched..], END) { + break searched + at; + } + searched = self.buffer.len().saturating_sub(END.len() - 1); + if !self.fill() { + return Frame::End; + } + }; + let length = length_in(&self.buffer[..header]); + self.buffer.drain(..header + END.len()); + let Some(length) = length else { + return Frame::Malformed; + }; + while self.buffer.len() < length { + if !self.fill() { + return Frame::End; + } + } + let rest = self.buffer.split_off(length); + Frame::Message(std::mem::replace(&mut self.buffer, rest)) + } +} + +/// What a message is. +pub(crate) enum Incoming { + Request { + id: Json, + method: Vec, + params: Json, + }, + Notification { + method: Vec, + params: Json, + }, + /// The answer to a request of the server. + Response, + /// Not JSON. + Unreadable, + /// JSON, and no message. `id`: `null` if it has none. + Invalid { + id: Json, + }, +} + +fn take(entries: &mut Vec<(Vec, Json)>, key: &[u8]) -> Option { + let at = entries.iter().position(|it| it.0 == key)?; + Some(entries.swap_remove(at).1) +} + +impl Incoming { + pub(crate) fn parse(message: &[u8]) -> Incoming { + let mut entries = match bun_lint::json::parse(message) { + Some(Json::Object(entries)) => entries, + Some(_) => return Incoming::Invalid { id: Json::Null }, + None => return Incoming::Unreadable, + }; + let id = + take(&mut entries, b"id").filter(|it| matches!(it, Json::Number(_) | Json::String(_))); + let params = take(&mut entries, b"params").unwrap_or(Json::Null); + match (take(&mut entries, b"method"), id) { + (Some(Json::String(method)), Some(id)) => Incoming::Request { id, method, params }, + (Some(Json::String(method)), None) => Incoming::Notification { method, params }, + (None, Some(_)) => Incoming::Response, + (_, id) => Incoming::Invalid { + id: id.unwrap_or(Json::Null), + }, + } + } +} + +pub(crate) fn text(bytes: &[u8]) -> Json { + Json::String(bytes.to_vec()) +} + +pub(crate) fn number(value: u32) -> Json { + Json::Number(f64::from(value)) +} + +pub(crate) fn object(entries: [(&str, Json); N]) -> Json { + let entries = entries.into_iter(); + Json::Object( + entries + .map(|(key, value)| (key.as_bytes().to_vec(), value)) + .collect(), + ) +} + +/// `value.a.b` for `path` = `["a", "b"]`. +pub(crate) fn dig<'j>(value: &'j Json, path: &[&str]) -> Option<&'j Json> { + path.iter() + .try_fold(value, |value, key| value.get(key.as_bytes())) +} + +/// Writes messages, one at a time. +pub(crate) struct Writer<'w> { + write: &'w (dyn Fn(&[u8]) + Sync), + turn: Guarded<()>, +} + +impl<'w> Writer<'w> { + pub(crate) fn new(write: &'w (dyn Fn(&[u8]) + Sync)) -> Writer<'w> { + Writer { + write, + turn: Guarded::new(()), + } + } + + fn send(&self, mut entries: Vec<(Vec, Json)>) { + entries.insert(0, (b"jsonrpc".to_vec(), text(b"2.0"))); + let mut body = Vec::new(); + write_json(&mut body, &Json::Object(entries)); + let mut frame = format!("Content-Length: {}\r\n\r\n", body.len()).into_bytes(); + frame.append(&mut body); + let _turn = self.turn.lock(); + (self.write)(&frame); + } + + pub(crate) fn notify(&self, method: &str, params: Json) { + self.send(vec![ + (b"method".to_vec(), text(method.as_bytes())), + (b"params".to_vec(), params), + ]); + } + + pub(crate) fn request(&self, id: &[u8], method: &str, params: Json) { + self.send(vec![ + (b"id".to_vec(), text(id)), + (b"method".to_vec(), text(method.as_bytes())), + (b"params".to_vec(), params), + ]); + } + + pub(crate) fn reply(&self, id: Json, result: Json) { + self.send(vec![(b"id".to_vec(), id), (b"result".to_vec(), result)]); + } + + pub(crate) fn fail(&self, id: Json, code: ErrorCode, message: &str) { + let error = object([ + ("code", Json::Number(f64::from(code as i32))), + ("message", text(message.as_bytes())), + ]); + self.send(vec![(b"id".to_vec(), id), (b"error".to_vec(), error)]); + } +} diff --git a/src/lint/driver/lsp/session.rs b/src/lint/driver/lsp/session.rs new file mode 100644 index 000000000000..a580decab6d5 --- /dev/null +++ b/src/lint/driver/lsp/session.rs @@ -0,0 +1,179 @@ +//! Lints the text of a document the way `bun lint --stdin --stdin-filename ` does in the workspace folder. What is +//! read of the configuration is kept from one text to the next. + +use crate::cli::Options; +use crate::configs::{Flavor, Loader}; +use crate::inferred::Inferred; +use crate::lint::Context; +use crate::results::FileResult; +use crate::run::{Environment, Fatal, Pool, Run, Timing, lint_again}; +use crate::{Linter, discover, paths}; +use bun_lint::js_plugin::Host; +use bun_threading::Guarded; + +/// How a text is linted. +#[derive(Copy, Clone, Default)] +pub(crate) struct How { + /// The rules that need types run, if the configuration asks for them. + pub(crate) with_types: bool, + /// As `--fix-dry-run`: [`FileResult::written`] is the text with all fixes. + pub(crate) fixes: bool, +} + +/// How much a line for the user matters. +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub(crate) enum Weight { + Warning, + Note, +} + +/// The configurations of a workspace folder, as they were when they were first asked for. +pub(crate) struct Session<'s> { + options: &'s Options, + environment: &'s Environment<'s>, + linter: &'s Linter, + pool: &'s Pool, + js_plugins: &'s Host<'s>, + loader: &'s Loader<'s>, +} + +/// Calls `then` with a session for `environment.cwd`. +pub(crate) fn with_session( + options: &Options, + environment: &Environment, + then: impl FnOnce(&Session) -> R, +) -> R { + let registry = crate::rules::registry(); + let linter = Linter::new(registry.answering_for(options.native_plugin_rules.clone())); + bun_sema_driver::use_a_pool_of_their_own("Bun Lint"); + let pool = Pool::new(options.threads); + let cwd = paths::to_native(environment.cwd.clone()); + let js_plugins = Host::with_engine(environment.js_engine, &cwd); + let loader = Loader::new(&linter, options, environment, &js_plugins); + then(&Session { + options, + environment, + linter: &linter, + pool: &pool, + js_plugins: &js_plugins, + loader: &loader, + }) +} + +impl Session<'_> { + /// Why nothing can be linted in the folder. + pub(crate) fn refusal(&self) -> Option> { + if let Some((of_eslint, of_oxlint)) = self.loader.undecided() { + let (of_eslint, of_oxlint) = (paths::basename(of_eslint), paths::basename(of_oxlint)); + return Some( + [ + b"Found both ", + of_eslint, + b" and ", + of_oxlint, + b"\n Choose one with flavor = \"eslint\" or flavor = \"oxlint\" under [lint] in bunfig.toml", + ] + .concat(), + ); + } + let _ = self.loader.for_directory(&self.environment.cwd); + self.loader.refused().map(<[u8]>::to_vec) + } + + /// What there is to tell the user since this was last asked. + pub(crate) fn notes(&self) -> Vec<(Weight, Vec)> { + let take = |lines: &Guarded>>| std::mem::take(&mut *lines.lock()); + let warnings = take(&self.loader.warnings).into_iter(); + let warnings = warnings.chain(take(&self.loader.unsupported)); + let notes = take(&self.loader.advice).into_iter(); + (warnings.map(|it| (Weight::Warning, it))) + .chain(notes.map(|it| (Weight::Note, it))) + .collect() + } + + /// Whether `result`, which is about the file at `path`, lacks what the rules that need types have to say. + pub(crate) fn wants_types(&self, path: &[u8], result: &FileResult) -> bool { + let (Some(linted), Ok(loaded)) = ( + result.linted.as_ref().filter(|_| !result.had_types), + self.loader.for_directory(paths::dirname(path)), + ) else { + return false; + }; + self.loader.wants_types(&loaded, &linted.config) + && linted.config.has_enabled(|it| it.requires_types) + } + + /// `None`: the file at `path` is not one to lint. + pub(crate) fn lint( + &self, + path: &[u8], + text: Vec, + how: How, + ) -> Result, Fatal> { + let (environment, loader, pool) = (self.environment, self.loader, self.pool); + let options = &Options { + stdin: true, + stdin_filename: Some(paths::to_native(path.to_vec())), + fix_dry_run: how.fixes, + warn_ignored: false, + ..self.options.clone() + }; + let of_cwd = loader.for_directory(&environment.cwd).ok(); + // What the other files export is read anew: they change too. + let store = bun_lint_graph::Store::new(&environment.cwd); + let modules = bun_lint_graph::Graph::new(&store); + modules.list_files_by(Box::new(|patterns: &[&[u8]], extensions: &[&[u8]]| { + let of_the_run = (self.linter, environment, self.js_plugins); + discover::list_as_eslint_8(of_the_run, pool, patterns, extensions) + })); + // Every name that is ever typed would stay in one that is kept. + let names = [bun_sema::session::Session::new()]; + let atoms = bun_sema::atom::InternerPerThread::new_in(&names); + let memory = bun_sema::session::Session::new(); + let (skipped_in_comments, out_of_stack) = + (Guarded::new(Vec::new()), Guarded::new(Vec::new())); + let (broken_fixes, handed_back) = (Guarded::new(Vec::new()), Guarded::new(Vec::new())); + let (unread, invalid_tsconfigs) = + (Guarded::new(Vec::new()), Guarded::new(Default::default())); + let formatter = crate::fmt::ForRules::new(environment, options.of_bun_format.as_deref()); + let by_options = options.infer_globals.is_none() && options.infers_globals_by_options; + let inferred = (options.infer_globals == Some(true) || by_options) + .then(|| Inferred::new(environment, pool.threads(), by_options)); + let timing = Timing::default(); + let context = Context { + skipped_in_comments: &skipped_in_comments, + out_of_stack: &out_of_stack, + broken_fixes: &broken_fixes, + handed_back: &handed_back, + unread: &unread, + invalid_tsconfigs: &invalid_tsconfigs, + memory: &memory, + atoms: &atoms, + linter: self.linter, + options, + cwd: &environment.cwd, + of_oxlint: (of_cwd.as_deref()).filter(|it| it.flavor == Flavor::Oxlint), + checks_types: false, + keeps_text: false, + reads_fixes: true, + reads_texts_of_fixes: true, + reads_help: false, + help_on_demand: false, + reads_suppressions: false, + js_plugins: self.js_plugins, + modules: &modules, + formatter: &formatter, + inferred: inferred.as_ref(), + timing: &timing, + }; + let run = Run::new(options, environment); + let mut results = run + .lint_text(loader, &context, text, how.with_types)? + .results; + lint_again(&context, pool, &mut results)?; + match results.iter_mut().find_map(|it| it.thrown.take()) { + Some(thrown) => Err(Fatal(thrown)), + None => Ok(results.pop()), + } + } +} diff --git a/src/lint/driver/lsp/text.rs b/src/lint/driver/lsp/text.rs new file mode 100644 index 000000000000..eb6a83f2a0d4 --- /dev/null +++ b/src/lint/driver/lsp/text.rs @@ -0,0 +1,173 @@ +//! Places in a document as the protocol counts them, and the changes that an editor sends. + +use super::rpc::{number, object}; +use bun_core::strings; +use bun_lint::options::Json; +use bun_lint::source::LineStarts; +use std::cell::Cell; + +/// In what a `character` is counted: `positionEncoding`. +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub(crate) enum Encoding { + /// Bytes. + Utf8, + /// Code units: what every client has. + Utf16, +} + +/// A line and a character in it, both from 0. +#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Debug, Default)] +pub(crate) struct Position { + pub(crate) line: u32, + pub(crate) character: u32, +} + +pub(crate) type Range = (Position, Position); + +impl Position { + pub(crate) fn from_json(json: &Json) -> Option { + let part = |key: &[u8]| match json.get(key) { + Some(Json::Number(it)) if *it >= 0.0 => Some(*it as u32), + _ => None, + }; + Some(Position { + line: part(b"line")?, + character: part(b"character")?, + }) + } + + fn to_json(self) -> Json { + object([ + ("line", number(self.line)), + ("character", number(self.character)), + ]) + } +} + +pub(crate) fn range_from_json(json: &Json) -> Option { + Some(( + Position::from_json(json.get(b"start")?)?, + Position::from_json(json.get(b"end")?)?, + )) +} + +pub(crate) fn range_to_json((start, end): Range) -> Json { + object([("start", start.to_json()), ("end", end.to_json())]) +} + +/// The lines of a text as the protocol has them: they end with `\n`, `\r\n` or `\r`. +pub(crate) struct Lines<'t> { + text: &'t [u8], + starts: LineStarts, + encoding: Encoding, + /// Where a line starts, an offset in it, and the `character` of that: who asks next is mostly further on in the line. + last: Cell<(u32, u32, u32)>, +} + +impl<'t> Lines<'t> { + pub(crate) fn new(text: &'t [u8], encoding: Encoding) -> Lines<'t> { + let (mut starts, mut at) = (vec![0], 0); + while let Some(found) = strings::index_of_any(&text[at..], b"\r\n") { + at += found + 1; + if text[at - 1] == b'\r' && text.get(at) == Some(&b'\n') { + at += 1; + } + starts.push(at as u32); + } + Lines { + text, + starts: LineStarts::at(starts), + // A byte is a code unit. + encoding: match strings::first_non_ascii(text) { + None => Encoding::Utf8, + Some(_) => encoding, + }, + last: Cell::new((0, 0, 0)), + } + } + + pub(crate) fn position(&self, offset: usize) -> Position { + let offset = offset.min(self.text.len()) as u32; + let (line, start) = self.starts.line_at(offset); + let character = match self.encoding { + Encoding::Utf8 => offset - start, + Encoding::Utf16 => { + let (from, before) = match self.last.get() { + (of, from, before) if of == start && from <= offset => (from, before), + _ => (start, 0), + }; + before + strings::wtf8_len_utf16(&self.text[from as usize..offset as usize]) + } + }; + self.last.set((start, offset, character)); + Position { + line: line - 1, + character, + } + } + + pub(crate) fn range(&self, start: usize, end: usize) -> Range { + (self.position(start), self.position(end)) + } + + /// Where the line `line`, from 0, starts, and where it ends, before its line break. After the last line: the end of the text. + pub(crate) fn line(&self, line: u32) -> (usize, usize) { + let Some(start) = self.starts.start_of(line.saturating_add(1)) else { + return (self.text.len(), self.text.len()); + }; + let next = self.starts.start_of(line.saturating_add(2)); + let whole = &self.text[start as usize..next.map_or(self.text.len(), |it| it as usize)]; + let content = match whole { + [content @ .., b'\r', b'\n'] | [content @ .., b'\r' | b'\n'] | content => content, + }; + (start as usize, start as usize + content.len()) + } + + /// A `character` beyond the end of its line is the end of the line. + pub(crate) fn offset(&self, position: Position) -> usize { + let (start, end) = self.line(position.line); + let line = &self.text[start..end]; + start + + match self.encoding { + Encoding::Utf16 => strings::wtf8_offset_of_utf16_index(line, position.character), + Encoding::Utf8 => { + let mut at = (position.character as usize).min(line.len()); + // Not in the middle of a character. + while at > 0 && line.get(at).is_some_and(|it| it & 0xC0 == 0x80) { + at -= 1; + } + at + } + } + } + + /// What ends the lines of the text: that of the first. + pub(crate) fn line_break(&self) -> &'static [u8] { + match self.text.get(self.line(0).1..) { + Some([b'\r', b'\n', ..]) => b"\r\n", + Some([b'\r', ..]) => b"\r", + _ => b"\n", + } + } +} + +/// Makes the changes of a `textDocument/didChange`, one after the other. `None`: one of them is not a change. +pub(crate) fn apply_changes( + mut text: Vec, + changes: &[Json], + encoding: Encoding, +) -> Option> { + for change in changes { + let new = change.get(b"text")?.as_str()?; + match change.get(b"range") { + None | Some(Json::Null) => text = new.to_vec(), + Some(range) => { + let (start, end) = range_from_json(range)?; + let lines = Lines::new(&text, encoding); + let (start, end) = (lines.offset(start), lines.offset(end)); + text.splice(start.min(end)..end, new.iter().copied()); + } + } + } + Some(text) +} diff --git a/src/lint/driver/native.rs b/src/lint/driver/native.rs index 159d6f943df8..6bfd92a30c35 100644 --- a/src/lint/driver/native.rs +++ b/src/lint/driver/native.rs @@ -2,43 +2,83 @@ //! Every rule that is built in can be named, by any of its names, and a name of no rule is a mistake. use crate::cli::Options; -use crate::configs::{CATEGORIES, is_category}; +use crate::configs::{CATEGORIES, is_category, severity_name}; use crate::print_config::object; -use crate::run::Fatal; +use crate::{fs, paths}; +use bun_core::strings; use bun_lint::context::Severity; -use bun_lint::linter::{Missing, Registry, RuleId, oxlint_category, severity_of}; +use bun_lint::linter::{ + Missing, Registry, RuleId, Step, mistake_in_options, oxlint_category, severity_of, write_json, +}; use bun_lint::options::Json; use bun_lint::rule::{Meta, Plugin}; -/// What is wrong with `id`, the name of no rule: what follows `error: `. `place`: where it is written. -pub(crate) fn unknown_rule(registry: &Registry, id: &[u8], place: Option<&[u8]>) -> Vec { - let place = place.map_or_else(Vec::new, |it| [b" in ", it].concat()); +/// What is wrong with what is said about a rule. +pub(crate) struct Mistake { + /// What follows `error: `. + pub(crate) message: Vec, + /// Where it is in bunfig.toml, if it is there. + at: Option, +} + +impl Mistake { + /// As Bun prints an error, with the line of the bunfig.toml of `cwd` that has it. + pub(crate) fn print(&self, cwd: &[u8], colors: bool) -> Vec { + let text = fs::read(&paths::join(cwd, b"bunfig.toml")).unwrap_or_default(); + let text = strings::without_utf8_bom(&text); + let location = self.at.and_then(|at| { + let before = text.get(..at)?; + let start = strings::last_index_of_char(before, b'\n').map_or(0, |it| it + 1); + let line = text.get(start..)?; + let end = strings::index_of_char_usize(line, b'\n').unwrap_or(line.len()); + Some(bun_ast::Location { + file: b"bunfig.toml".to_vec().into(), + line: strings::count_char(before, b'\n') as i32 + 1, + column: (at - start) as i32 + 1, + line_text: Some(line.get(..end)?.to_vec().into()), + ..Default::default() + }) + }); + let message = bun_ast::Msg { + kind: bun_ast::Kind::Err, + data: bun_ast::Data { + text: self.message.clone().into(), + location, + }, + ..Default::default() + }; + let mut printed = String::new(); + let _ = match colors { + true => message.write_format::(&mut printed), + false => message.write_format::(&mut printed), + }; + printed.push('\n'); + printed.into_bytes() + } +} + +/// What is wrong with `id`, the name of no rule: what follows `error: `. +pub(crate) fn unknown_rule(registry: &Registry, id: &[u8]) -> Vec { match registry.why_missing(id) { Missing::Formatting => [ b"\"", id, - b"\"", - &place, - b" is a formatting rule\n bun format does that", + b"\" is a formatting rule\n bun format does that", ] .concat(), Missing::OfPluginWithoutRules(plugin) => [ b"\"", id, - b"\"", - &place, - b" is not supported\n bun lint has no ", + b"\" is not supported\n bun lint has no ", plugin.prefix().as_bytes(), b" rules", ] .concat(), - Missing::Unknown(None) => [b"Unknown rule \"", id, b"\"", &place].concat(), + Missing::Unknown(None) => [b"Unknown rule \"", id, b"\""].concat(), Missing::Unknown(Some(close)) => [ b"Unknown rule \"", id, - b"\"", - &place, - b"\n Did you mean \"", + b"\"\n Did you mean \"", &close, b"\"?", ] @@ -46,24 +86,62 @@ pub(crate) fn unknown_rule(registry: &Registry, id: &[u8], place: Option<&[u8]>) } } -/// Fails at the first name in `[lint.rules]` or after `--rule` that is the name of no rule. To turn off what was left out on -/// purpose asks for nothing. -pub(crate) fn check_rules(registry: &Registry, options: &Options) -> Result<(), Fatal> { +/// What is wrong with `written`, which is where the severity of `rule`, or of a category, is expected. +pub(crate) fn invalid_severity(rule: &[u8], written: &Json) -> Vec { + let mut message = Vec::new(); + write_json(&mut message, written); + message.extend_from_slice(b" is not a valid severity for "); + message.extend_from_slice(rule); + message.extend_from_slice(b"\n Expected \"off\", \"warn\", or \"error\""); + message +} + +/// The first mistake in `[lint.rules]` and after `--rule`: the name of no rule, no severity, options that the rule does not +/// take. To turn off what was left out on purpose asks for nothing. +pub(crate) fn check_rules(registry: &Registry, options: &Options) -> Result<(), Mistake> { for (at, (id, setting)) in options.rule.iter().enumerate() { - if registry.find_by_any_name(id).is_some() || registry.base_of(id).is_some() { - continue; + // What `--rule` says about the same rule has taken its place. + let written = options.rules_of_bunfig.get(at); + let written = written.filter(|it| it.setting == *setting); + let parts: &[Json] = match setting { + Json::Array(items) => items, + setting => std::slice::from_ref(setting), + }; + let severity = parts.first().and_then(severity_of); + let found = registry.find_by_any_name(id); + if found.or_else(|| registry.base_of(id)).is_none() { + let is_left_out = !matches!(registry.why_missing(id), Missing::Unknown(_)); + if is_left_out && severity == Some(Severity::Off) { + continue; + } + return Err(Mistake { + message: unknown_rule(registry, id), + at: written.map(|it| it.key_at), + }); } - let severity = match setting { - Json::Array(items) => items.first().and_then(severity_of), - setting => severity_of(setting), + let Some(severity) = severity else { + let place = written.map(|it| it.place.items.first().unwrap_or(&it.place)); + return Err(Mistake { + message: invalid_severity(id, parts.first().unwrap_or(&Json::Null)), + at: place.map(|it| it.at), + }); }; - let is_a_mistake = matches!(registry.why_missing(id), Missing::Unknown(_)); - if is_a_mistake || severity != Some(Severity::Off) { - let place: &[u8] = match at < options.rules_of_bunfig { - true => b"bunfig.toml", - false => b"--rule", - }; - return Err(Fatal(unknown_rule(registry, id, Some(place)))); + let given = parts.get(1..).unwrap_or_default(); + let entry = found.filter(|_| severity != Severity::Off); + if let Some(mistake) = entry.and_then(|it| mistake_in_options(it.meta, id, given)) { + let after_severity = mistake + .path + .iter() + .enumerate() + .map(|(depth, step)| match step { + Step::Index(index) if depth == 0 => Step::Index(index + 1), + step => step.clone(), + }); + let path: Vec = after_severity.collect(); + return Err(Mistake { + message: mistake.message, + at: written.map(|it| it.place.find(&path, mistake.is_about_key)), + }); } } Ok(()) @@ -83,31 +161,24 @@ pub(crate) fn categories(registry: &Registry, options: &Options) -> Vec { if !options.filters.iter().any(|it| is_category(&it.1)) { return Vec::new(); } - let name = |severity: Severity| { - Json::String(match severity { - Severity::Off => b"off".to_vec(), - Severity::Warn => b"warn".to_vec(), - Severity::Error => b"error".to_vec(), - }) - }; let id = |meta: &'static Meta| RuleId::Known(meta).to_vec(); let (mut everywhere, mut in_typescript) = (Vec::new(), Vec::new()); for meta in registry.all().iter().map(|it| it.meta) { - let is_hidden = - (registry.find_by_any_name(&id(meta))).is_none_or(|it| it.meta.plugin != meta.plugin); + // Another rule stands in its place. + let is_hidden = registry.names_of(meta).id != id(meta); let category = oxlint_category(meta.plugin, meta.name).filter(|_| !is_hidden); let Some(severity) = category.and_then(|it| severity_in(options, it.as_bytes())) else { continue; }; if meta.plugin == Plugin::TypeScript { - in_typescript.push((id(meta), name(severity))); + in_typescript.push((id(meta), severity_name(severity))); continue; } - everywhere.push((id(meta), name(severity))); + everywhere.push((id(meta), severity_name(severity))); let extension = registry.get_preferring(meta.plugin, meta.name.as_bytes(), true); if let Some(extension) = extension.filter(|it| it.meta.plugin != meta.plugin) { - in_typescript.push((id(meta), name(Severity::Off))); - in_typescript.push((id(extension.meta), name(severity))); + in_typescript.push((id(meta), severity_name(Severity::Off))); + in_typescript.push((id(extension.meta), severity_name(severity))); } } let typescript = Json::Array(vec![Json::String(b"**/*.{ts,mts,cts,tsx}".to_vec())]); diff --git a/src/lint/driver/run.rs b/src/lint/driver/run.rs index b514682de36e..daa58808d61a 100644 --- a/src/lint/driver/run.rs +++ b/src/lint/driver/run.rs @@ -1,7 +1,7 @@ //! `bun lint`: ESLint's `cli.execute`. use crate::cli::{Options, Tool}; -use crate::configs::{self, Flavor, Loader}; +use crate::configs::{self, Flavor, Loaded, Loader}; use crate::discover::{self, Status, Target}; use crate::doctor::{self, Modules}; use crate::format::{self, Format}; @@ -142,6 +142,15 @@ fn is_agent_for_oxlint() -> bool { || has(b"TERM_PROGRAM", b"kiro")) } +/// What is said about `error`, which is what is wrong with the configuration of a file that has `loaded`. What stands in for +/// oxlint says it on standard output, in its words. +fn invalid_config(loader: &Loader, loaded: &Loaded, error: &[u8]) -> Vec { + match loaded.flavor { + Flavor::Eslint | Flavor::EslintRc => loader.name_place(loaded.file.as_deref(), error), + Flavor::Oxlint | Flavor::BuiltIn => error.to_vec(), + } +} + /// Why nothing can be linted: the message. ESLint throws, and exits with 2. #[derive(Clone, PartialEq, Eq, Debug)] pub(crate) struct Fatal(pub(crate) Vec); @@ -477,7 +486,7 @@ impl Run<'_> { let route = loaded.routes(config, &path); // ESLint says itself what is wrong with a configuration for what it lints. if let Some(error) = config.error.as_ref().filter(|_| route != Route::Eslint) { - return Err(Fatal(error.clone())); + return Err(Fatal(invalid_config(loader, &loaded, error))); } let shown = if path.ends_with(b"__placeholder__.js") { b"".to_vec() @@ -608,7 +617,10 @@ impl Run<'_> { } // ESLint finds out when it comes to the file. let invalid = targets.iter().find_map(|target| match &target.status { - Status::Matched(config) if target.route() != Route::Eslint => config.error.clone(), + Status::Matched(config) if target.route() != Route::Eslint => { + let error = config.error.as_ref()?; + Some(invalid_config(loader, &target.loaded, error)) + } _ => None, }); if let Some(error) = invalid { @@ -921,6 +933,11 @@ impl Run<'_> { if let Some(refusal) = loader.refused() { return self.refuse_flag(refusal); } + if let Some(mistake) = loader.mistake() { + self.out.stderr.extend_from_slice(mistake); + self.out.exit_code = 2; + return self.out; + } let is_native = (of_cwd.as_ref()).is_some_and(|it| it.flavor == Flavor::BuiltIn); let format = match self.format(is_oxlint, is_native) { Ok(format) => format, @@ -1196,6 +1213,11 @@ impl Run<'_> { if let Some(refusal) = loader.refused() { return self.refuse_flag(refusal); } + if let Some(mistake) = loader.mistake() { + self.out.stderr.extend_from_slice(mistake); + self.out.exit_code = 2; + return self.out; + } let Linted { mut results, files, diff --git a/src/lint/eslint/rules/no_restricted_imports.rs b/src/lint/eslint/rules/no_restricted_imports.rs index d4ecc23ffcc1..de3277ec89ec 100644 --- a/src/lint/eslint/rules/no_restricted_imports.rs +++ b/src/lint/eslint/rules/no_restricted_imports.rs @@ -1,5 +1,5 @@ use bun_core::strings; -pub use bun_glob::ignore::{IgnoreOptions, IgnoreRules, IgnoreSyntax}; +pub use bun_glob::ignore::{IgnoreOptions, IgnoreRules}; use bun_lint::prelude::*; use bun_lint::tokens::token_len; use rustc_hash::{FxHashMap, FxHashSet}; @@ -230,9 +230,9 @@ impl Globs { } } -/// `ignore({ allowRelativePaths: true, ignoreCase }).add(group)` of the version of the package that `syntax` names. -pub fn ignore_rules(group: &[&str], ignores_case: bool, syntax: IgnoreSyntax) -> IgnoreRules { - IgnoreRules::from_lines(group.iter().map(|it| it.as_bytes()), IgnoreOptions { syntax, ignores_case }) +/// `ignore({ allowRelativePaths: true, ignoreCase }).add(group)` +pub fn ignore_rules(group: &[&str], ignores_case: bool) -> IgnoreRules { + IgnoreRules::from_lines(group.iter().map(|it| it.as_bytes()), IgnoreOptions { has_braces: false, ignores_case }) } /// Which modules a restriction is about. @@ -675,7 +675,7 @@ impl Restrictions { if patterns.first().is_some_and(|it| it.as_str().is_some()) { let strings = patterns.iter().filter_map(|it| std::str::from_utf8(it.as_str()?).ok()); let group: Vec<&str> = strings.collect(); - let ignore = Box::new(ignore_rules(&group, true, IgnoreSyntax::Npm5)); + let ignore = Box::new(ignore_rules(&group, true)); let matcher = Matcher::Group(ignore, Globs::of_strings(&group)); all.push(Restriction::new(matcher, Object::default())); return Restrictions { all }; @@ -691,7 +691,7 @@ impl Restrictions { } None => object.has("group").then(|| { let group = object.strings("group"); - let ignore = Box::new(ignore_rules(&group, !is_case_sensitive, IgnoreSyntax::Npm5)); + let ignore = Box::new(ignore_rules(&group, !is_case_sensitive)); Matcher::Group(ignore, Globs::of_group(&group, !is_case_sensitive)) }), }; diff --git a/src/lint/eslint/rules/no_restricted_modules.rs b/src/lint/eslint/rules/no_restricted_modules.rs index 66b974085826..7918eb9233d2 100644 --- a/src/lint/eslint/rules/no_restricted_modules.rs +++ b/src/lint/eslint/rules/no_restricted_modules.rs @@ -1,5 +1,5 @@ use bun_core::strings; -use super::no_restricted_imports::{IgnoreRules, IgnoreSyntax, ignore_rules}; +use super::no_restricted_imports::{IgnoreRules, ignore_rules}; use bun_lint::prelude::*; /// Disallow specified modules when loaded by `require`. @@ -41,7 +41,7 @@ impl Rule for NoRestrictedModules { }); NoRestrictedModules { paths: paths.collect(), - patterns: (!patterns.is_empty()).then(|| ignore_rules(&patterns, true, IgnoreSyntax::Npm5)), + patterns: (!patterns.is_empty()).then(|| ignore_rules(&patterns, true)), } } diff --git a/src/lint/graph/lib.rs b/src/lint/graph/lib.rs index d82f82878ee4..314fd933d39f 100644 --- a/src/lint/graph/lib.rs +++ b/src/lint/graph/lib.rs @@ -19,7 +19,7 @@ #![forbid(unsafe_code)] use bun_core::strings; -use bun_glob::{BunGlobWalker, Options as GlobOptions, Pattern}; +use bun_glob::{BunGlobWalker, Pattern}; use bun_lint::ast::File; use bun_lint::language::{LanguageOptions, Parser}; use bun_lint::modules::{ @@ -226,7 +226,7 @@ impl Scope { if !strings::contains_any(last, b".*?") { path.extend_from_slice(b"/**/*"); } - (Pattern::new(&path, GlobOptions::BUN), path.ends_with(b"*")) + (Pattern::new(&path), path.ends_with(b"*")) }; let patterns = |written: &[Vec]| written.iter().map(pattern).collect::>(); let written = &project.written; diff --git a/src/lint/linter/config/ignore_lines.rs b/src/lint/linter/config/ignore_lines.rs index c1eb2cee2b08..676ea68bbb69 100644 --- a/src/lint/linter/config/ignore_lines.rs +++ b/src/lint/linter/config/ignore_lines.rs @@ -1,7 +1,7 @@ //! `ignorePatterns` of an `.oxlintrc.json` and of the configuration files of ESLint 8: lines in the format of a `.gitignore`. //! What a line matches is said by [`bun_glob::ignore`]. Here is who asks what. -use bun_glob::ignore::{IgnoreOptions, IgnoreRules, IgnoreSyntax, Verdict}; +use bun_glob::ignore::{IgnoreOptions, IgnoreRules, Verdict}; pub(super) struct IgnoreLines { rules: IgnoreRules, @@ -13,7 +13,7 @@ impl IgnoreLines { /// `GitignoreBuilder::add_line` for each. oxlint says nothing about a line that is refused: it is left out. pub(super) fn of_oxlint(lines: &[&[u8]]) -> IgnoreLines { let options = IgnoreOptions { - syntax: IgnoreSyntax::Globset, + has_braces: true, ignores_case: false, }; IgnoreLines { @@ -25,7 +25,7 @@ impl IgnoreLines { /// `ignore({ allowRelativePaths: true }).add(lines)`: `IgnorePattern.createIgnore` of `@eslint/eslintrc`. pub(super) fn of_eslint_8(lines: &[&[u8]]) -> IgnoreLines { let options = IgnoreOptions { - syntax: IgnoreSyntax::Npm5, + has_braces: false, ignores_case: true, }; IgnoreLines { diff --git a/src/lint/linter/config/mod.rs b/src/lint/linter/config/mod.rs index fdc8f30e61db..f24d13cc636a 100644 --- a/src/lint/linter/config/mod.rs +++ b/src/lint/linter/config/mod.rs @@ -75,7 +75,7 @@ use crate::options::Json; use crate::paths; use crate::rule::{Meta, Plugin}; use crate::runner::RuleEntry; -use bun_glob::{Candidate, How}; +use bun_glob::Candidate; use cache::Cache; pub use eslintrc::{Eslint8, LegacyFailure, LegacyFile, LegacyKind, LegacyOptions, LoadLegacy}; pub use flat::{ConfigError, LoadLocatedPlugin}; @@ -147,13 +147,12 @@ impl Pattern { /// `flip_negate`: a `!` at the start of the pattern is left out of the answer. Only ESLint asks for that. fn matches(&self, path: &Candidate, flip_negate: bool) -> bool { - let as_it_is = || self.matcher.matches_candidate(path, How::default()); - let hit = match path.path().strip_suffix(b"/") { + let hit = match path.without_slash() { Some(directory) => { - self.asks_without_slash && self.matcher.matches(directory) - || self.asks_with_slash && as_it_is() + self.asks_without_slash && self.matcher.matches_candidate(&directory) + || self.asks_with_slash && self.matcher.matches_candidate(path) } - None => as_it_is(), + None => self.matcher.matches_candidate(path), }; !self.is_comment && hit != (self.is_inverted && !flip_negate) } diff --git a/src/lint/plugins/import_minimatch.rs b/src/lint/plugins/import_minimatch.rs index eedf0f841529..fc136c198a5b 100644 --- a/src/lint/plugins/import_minimatch.rs +++ b/src/lint/plugins/import_minimatch.rs @@ -45,7 +45,7 @@ impl Glob { false => Cow::Borrowed(&written[bangs..]), }; let read = || match asked.noext { - true => Pattern::new(&text, bun_glob::Options::BUN), + true => Pattern::new(&text), false => Pattern::with_extglob(&text), }; Glob { diff --git a/src/lint/typescript/rules/no_restricted_imports.rs b/src/lint/typescript/rules/no_restricted_imports.rs index 563b0602cd7f..ef29c749ff5d 100644 --- a/src/lint/typescript/rules/no_restricted_imports.rs +++ b/src/lint/typescript/rules/no_restricted_imports.rs @@ -1,7 +1,7 @@ use bun_lint::prelude::*; use bun_lint_eslint::rules::no_restricted_imports::{ - Dialect, IgnoreRules, IgnoreSyntax, Restrictions, STATEMENTS, SideEffectImports, ignore_rules, - import_source, is_type_only, restricted_paths, restricted_patterns, + Dialect, IgnoreRules, Restrictions, STATEMENTS, SideEffectImports, ignore_rules, import_source, + is_type_only, restricted_paths, restricted_patterns, }; /// Disallow specified modules when loaded by `import`. @@ -51,7 +51,7 @@ impl Rule for NoRestrictedImports { let is_case_sensitive = pattern.bool_or("caseSensitive", false); if pattern.has("group") { let group = pattern.strings("group"); - let matcher = ignore_rules(&group, !is_case_sensitive, IgnoreSyntax::Npm7012); + let matcher = ignore_rules(&group, !is_case_sensitive); rule.allowed_type_import_matchers.push(matcher); } if let Some(regex) = pattern.str("regex").filter(|it| !it.is_empty()) diff --git a/src/lint/utils/mod.rs b/src/lint/utils/mod.rs index cb32e7707504..503eaf7f38ef 100644 --- a/src/lint/utils/mod.rs +++ b/src/lint/utils/mod.rs @@ -19,7 +19,7 @@ //! | `./utils/string-utils`, `../shared/string-utils` | [`string_utils`] | `upper_case_first`, `get_grapheme_count`, `graphemes`, `LETTER_PATTERN`: `find_letter`, `contains_letter`, `is_letter` | //! | `../shared/naming`, `../shared/directives` | [`naming`], [`directives`] | `normalize_package_name`, `get_shorthand_name`, `get_namespace_from_term`, `directivesPattern`: `match_directives_pattern(text)` | //! | `require("natural-compare")`, `esutils.keyword.isIdentifierES5/ES6`, `require("escape-string-regexp")` | [`text`] | `text::natural_compare(a, b)`, `text::is_identifier_es6(name)`, `bun_core::strings::escape_reg_exp(s, &mut out)` | -//! | `require("ignore")` | `bun_glob::ignore` | `IgnoreRules::from_lines(patterns, IgnoreOptions { syntax: IgnoreSyntax::Npm5, ignores_case }).ignores(path)` | +//! | `require("ignore")` | `bun_glob::ignore` | `IgnoreRules::from_lines(patterns, IgnoreOptions { has_braces: false, ignores_case }).ignores(path)` | //! | `node.type`, `node.range`, `node.parent`, `ChainExpression`, `SequenceExpression`, patterns in assignments | [`estree_compat`], re-exported from here | `utils::estree_type_name(node)`, `utils::estree_type_at(file, offset)`, `utils::Target` | //! | `array.sort((a, b) => a > b ? 1 : -1)`, a comparison that never answers 0 | [`array`] | `utils::array_sort_by(&mut items, \|a, b\| ..)` | //! | `a.localeCompare(b)`, `new Intl.Collator("en", { numeric: true, sensitivity: "base" })` | `bun_core::strings` | `strings::locale_compare(a, b)`, `strings::locale_compare_numeric_base(a, b)` | diff --git a/src/runtime/cli/init/react-app/src/APITester.tsx b/src/runtime/cli/init/react-app/src/APITester.tsx index fd2af48601f8..667782c32035 100644 --- a/src/runtime/cli/init/react-app/src/APITester.tsx +++ b/src/runtime/cli/init/react-app/src/APITester.tsx @@ -28,12 +28,23 @@ export function APITester() { - + - \n", ""], - "white-space-further-down-in-pre": ["
    a\n  b
    \n", "
    a b
    "], - "children-of-what-counts-as-void": ["a\n", ""], - "this-and-a-slash": ["{{this/a }}\n", "{{a}}"], - "this-behind-an-at": ["{{@this.a }}\n", "{{@a}}"], - "this-in-brackets": ["{{[this] }}\n", "{{this.this}}"], - "empty-brackets": ["{{[].a }}\n", "{{a}}"], - "arguments-of-a-literal": ['{{"a" b}}\n', '{{"a"}}'], - "hash-that-is-called": ["{{(a=b) c}}\n", "{{ c}}"], - "raw-block": ["{{{{a}}}} {{b}} {{{{/a}}}}\n", "{{#a}} \\{{b}} {{/a}}"], - "inverted-block": ["{{^a }}b{{else}}c{{/a}}\n", "{{#a}}c{{else}}b{{/a}}"], - "key-in-brackets": ["{{a [b c]=d}}\n", "{{a b c=d}}"], - "literal-in-brackets": ["{{a [true]}}\n", "{{a true}}"], - "number-in-brackets": ["{{a [-1]}}\n", "{{a -1}}"], - "else-in-brackets": ["{{[else] }}\n", "{{else}}"], - "argument-in-brackets": ["{{a [@b]}}\n", "{{a @b}}"], - "bracket-in-brackets": ["{{a [b\\]]}}\n", "{{a [b]]}}"], - "line-break-in-brackets": ["{{a [b\nc]}}\n", "{{a [[b\nc]]}}"], - "block-parameter-in-brackets": ["{{#a as |[b] c|}}{{/a}}\n", "{{#a as |[b] c|}}{{/a}}"], - "private-name": ["{{a.#b }}\n", "{{a.[#b]}}"], - "number-with-an-exponent": ["{{a 1000000000000000000000}}\n", "{{a 1e+21}}"], - "backslash-at-the-end-of-a-string": ['{{a "b\\"}}\n', '{{a "b\\"}}'], - "tilde-in-three-braces": ["{{{a }~}}\n", "{{{a~}}}"], - "three-braces-around-a-modifier": ["
    \n", ""], - "tilde-at-the-end-of-a-comment": ["{{!-- a ~--}}\n", "{{! a ~}}"], - "dashes-at-the-start-of-a-comment": ["{{!---- a --}}\n", "{{!-- a }}"], - "rest-of-the-name-behind-else": ["{{#a }}{{else if.b c}}{{/a}}\n", "{{#a}}{{else if c}}{{/a}}"], + "backslash-before-a-mustache-in-a-value": [ + '\n', + '', + '', + ], + "backslash-before-a-comment": ["a\\\\{{! b }}\n", "a\\\\{{! b }}", "a\\{{! b }}"], + "backslash-before-the-end-of-a-block": ["{{#a }}b\\\\{{/a}}\n", "{{#a}}b\\\\{{/a}}", "{{#a}}b\\{{/a}}"], + "both-quotes-in-a-value": ["\n", '', ''], + "lines-of-a-script": [ + "\n", + "", + "", + ], + "lines-of-a-textarea": [ + "\n", + "", + "", + ], + "white-space-further-down-in-pre": [ + "
    a\n  b
    \n", + "
    a\n  b
    ", + "
    a b
    ", + ], + "children-of-what-counts-as-void": ["a\n", "a", ""], + "this-and-a-slash": ["{{this/a }}\n", "{{this/a}}", "{{a}}"], + "this-behind-an-at": ["{{@this.a }}\n", "{{@this.a}}", "{{@a}}"], + "this-in-brackets": ["{{[this] }}\n", "{{[this]}}", "{{this.this}}"], + "empty-brackets": ["{{[].a }}\n", "{{[].a}}", "{{a}}"], + "arguments-of-a-literal": ['{{"a" b}}\n', '{{"a" b}}', '{{"a"}}'], + "hash-that-is-called": ["{{(a=b) c}}\n", "{{(a=b) c}}", "{{ c}}"], + "raw-block": ["{{{{a}}}} {{b}} {{{{/a}}}}\n", "{{{{a}}}} {{b}} {{{{/a}}}}", "{{#a}} \\{{b}} {{/a}}"], + "key-in-brackets": ["{{a [b c]=d}}\n", "{{a [b c]=d}}", "{{a b c=d}}"], + "literal-in-brackets": ["{{a [true]}}\n", "{{a [true]}}", "{{a true}}"], + "number-in-brackets": ["{{a [-1]}}\n", "{{a [-1]}}", "{{a -1}}"], + "else-in-brackets": ["{{[else] }}\n", "{{[else]}}", "{{else}}"], + "argument-in-brackets": ["{{a [@b]}}\n", "{{a [@b]}}", "{{a @b}}"], + "bracket-in-brackets": ["{{a [b\\]]}}\n", "{{a [b\\]]}}", "{{a [b]]}}"], + "line-break-in-brackets": ["{{a [b\nc]}}\n", "{{a [b\nc]}}", "{{a [[b\nc]]}}"], + "private-name": ["{{a.#b }}\n", "{{a.#b}}", "{{a.[#b]}}"], + "number-with-an-exponent": ["{{a 1000000000000000000000}}\n", "{{a 1000000000000000000000}}", "{{a 1e+21}}"], + "tilde-in-three-braces": ["{{{a }~}}\n", "{{{a}~}}", "{{{a~}}}"], + "three-braces-around-a-modifier": ["\n", "", ""], + "tilde-at-the-end-of-a-comment": ["{{!-- a ~--}}\n", "{{!-- a ~--}}", "{{! a ~}}"], + "dashes-at-the-start-of-a-comment": ["{{!---- a --}}\n", "{{!---- a --}}", "{{!-- a }}"], + "rest-of-the-name-behind-else": [ + "{{#a }}{{else if.b c}}{{/a}}\n", + "{{#a}}{{else if.b c}}{{/a}}", + "{{#a}}{{else if c}}{{/a}}", + ], "tilde-of-an-if-that-is-all-that-follows-else": [ "{{#a }}{{else}}{{~#if b}}c{{/if}}{{/a}}\n", + "{{#a}}{{else}}{{~#if b}}c{{/if}}{{/a}}", "{{#a}}{{else if b}}c{{/a}}", ], - "line-separator-behind-a-line-break": ["a\n\u2028{{b }}\n", "a \u2028{{b}}"], "indented-mark-of-a-document-in-front-matter": [ "---\n ---\na: b\n---\n

    \n", + "---\n ---\na: b\n---\n\n

    ", "---\n---\na: b\n---\n\n

    ", ], + "no-break-space-that-is-all-of-the-template": ["\u00a0\n", "\u00a0", ""], + "white-space-that-is-all-of-a-block-in-a-textarea": [ + "\n", + "", + "", + ], + "white-space-that-is-all-of-a-block-in-pre": [ + "
    {{#a}} {{/a}}
    \n", + "
    {{#a}} {{/a}}
    ", + "
    {{#a}}{{/a}}
    ", + ], + "tilde-of-an-else-if-in-an-if-that-is-all-that-follows-else": [ + "{{#z }}y{{else}}{{#if a}}b{{~else if c}}d {{/if}}{{/z}}\n", + "{{#z}}y{{else}}{{#if a}}b{{~else if c}}d {{/if}}{{/z}}", + "{{#z}}y{{else if a}}b{{~else if c}}d {{/z}}", + ], + "name-in-brackets-behind-else": [ + "{{#[a b] }}{{else [a b] c}}{{/[a b]}}\n", + "{{#[a b]}}{{else [a b] c}}{{/[a b]}}", + "{{#[a b]}}{{else a b c}}{{/[a b]}}", + ], }; - const files = Object.fromEntries(Object.entries(damaged).map(([name, it]) => [`${name}.hbs`, it[0]])); - const written = Object.fromEntries(Object.entries(damaged).map(([name, it]) => [`${name}.hbs`, it[1]])); - const error = (name: string) => `error: ${name}: formatting like Prettier would change its meaning.`; - const errorsIn = (stderr: string) => - stderr - .split("\n") - .filter(line => line.startsWith("error: ")) - .sort(); + // These look like it, but Handlebars reads what Prettier prints as it reads the template. + const harmless: Record = { + "inverted-block": ["{{^a }}b{{else}}c{{/a}}\n", "{{#a}}c{{else}}b{{/a}}"], + "block-parameter-in-brackets": ["{{#a as |[b] c|}}{{/a}}\n", "{{#a as |[b] c|}}{{/a}}"], + "backslash-at-the-end-of-a-string": ['{{a "b\\"}}\n', '{{a "b\\"}}'], + "line-separator-behind-a-line-break": ["a\n\u2028{{b }}\n", "a \u2028{{b}}"], + }; + const all = Object.entries({ ...templates, ...harmless }); + const files = Object.fromEntries(all.map(([name, it]) => [`${name}.hbs`, it[0]])); + const written = Object.fromEntries(all.map(([name, it]) => [`${name}.hbs`, it[1]])); - test("is written as Prettier writes it", async () => { + test("says the same afterwards", async () => { + for (const [, after, ofPrettier] of Object.values(templates)) expect(after).not.toBe(ofPrettier); const result = await format(files, [], { reads: Object.keys(files) }); expect(result.files).toEqual(written); expect(result.exitCode).toBe(0); }); - test.each([[[] as string[]], [["--check"]], [["-l"]]])("--debug-check reports it: %j", async args => { - const all = { ...files, "fine.hbs": "{{a }}" }; - const result = await format(all, ["--debug-check", ...args], { reads: Object.keys(all) }); - expect(result.files).toEqual(all); - expect(errorsIn(result.stderr)).toEqual(Object.keys(files).map(error).sort()); - expect(result.exitCode).toBe(2); + test.each([ + ["no config", {}], + [".prettierrc", { ".prettierrc": "{}\n" }], + [".oxfmtrc.json", { ".oxfmtrc.json": "{}\n" }], + ])("with %s", async (_, config: Record) => { + const result = await format({ ...config, ...files }, Object.keys(files), { reads: Object.keys(files) }); + expect(result.files).toEqual(written); + expect(result.exitCode).toBe(0); + }); + + test("--debug-check has nothing to say about it", async () => { + const result = await format(files, ["--debug-check"], { reads: Object.keys(files) }); + expect(result.files).toEqual(files); + expect(result.stderr).toBe(""); + expect(result.stdout.split("\n").sort()).toEqual(Object.keys(files).sort()); + expect(result.exitCode).toBe(0); + }); + + test("what is written is formatted", async () => { + expect(await different(written, [])).toEqual([]); + }); + + test("where white space does not count, a word and an escaped mustache stay two words", async () => { + // Prettier 3.9.9 prints `p\\{{a}} {{q}}`. + const flags = ["--html-whitespace-sensitivity=ignore"]; + const result = await format({ "a.hbs": "p \\{{a}} {{q }}\n" }, flags, { reads: ["a.hbs"] }); + expect(result.files).toEqual({ "a.hbs": "p \\{{a}} {{q}}" }); + expect(result.exitCode).toBe(0); }); test("standard input", async () => { - const [before, after] = damaged.doctype; + const [before, after] = templates.doctype; const result = await format({}, ["--stdin-filepath", "a.hbs"], { stdin: before }); expect(result.raw).toBe(after); expect(result.exitCode).toBe(0); - const checked = await format({}, ["--debug-check", "--stdin-filepath", "a.hbs"], { stdin: before }); - expect(checked.raw).toBe(""); - expect(errorsIn(checked.stderr)).toEqual([error("a.hbs")]); - expect(checked.exitCode).toBe(2); }); test("in a block of code in Markdown", async () => { const text = "# a\n\n```hbs\n\n

    \n```\n\n```hbs\n

    \n```\n"; const result = await format({ "a.md": text }, [], { reads: ["a.md"] }); - expect(result.files).toEqual({ "a.md": "# a\n\n```hbs\n

    \n```\n\n```hbs\n

    \n```\n" }); + expect(result.files).toEqual({ + "a.md": "# a\n\n```hbs\n\n

    \n```\n\n```hbs\n

    \n```\n", + }); expect(result.exitCode).toBe(0); }); }); + describe("Markdown of which Prettier changes the meaning", () => { + // The text, what Bun makes of it, and what Prettier 3.9.9 makes of it: it gives two emphases that touch the same marker, + // which makes one of them, or none. + test.each([ + ["x *a*_b_\n", "x *a*_b_\n", "x _a__b_\n"], + ["x _a_*b*\n", "x _a_*b*\n", "x _a__b_\n"], + ["x **a**__b__\n", "x **a**__b__\n", "x **a****b**\n"], + ["x ***a******b***\n", "x ***a******b***\n", "x _**a**__**b**_\n"], + ["- *a*_b_*c*\n", "- *a*_b_*c*\n", "- _a__b__c_\n"], + ])("%j", async (before, after, ofPrettier) => { + expect(after).not.toBe(ofPrettier); + const result = await format({ "a.md": before, "b.md": after }, [], { reads: ["a.md", "b.md"] }); + expect(result.files).toEqual({ "a.md": after, "b.md": after }); + expect(result.exitCode).toBe(0); + }); + }); + + describe("front matter that would end early", () => { + // Prettier 3.9.9 formats the YAML, which puts the second `---` at the start of its line: there the front matter ends. + const frontMatter = "---\n ---\na: b\n---\n"; + test.each([ + ["a.md", "\n# c\n", "\n# c\n"], + ["a.mdx", "\n# c\n", "\n# c\n"], + ["a.css", "a{b:c}\n", "\na {\n b: c;\n}\n"], + ["a.scss", "a{b:c}\n", "\na {\n b: c;\n}\n"], + ["a.html", "

    c

    \n", "\n

    c

    \n"], + ])("%s", async (name, before, after) => { + const result = await format({ [name]: frontMatter + before }, [], { reads: [name] }); + expect(result.files).toEqual({ [name]: frontMatter + after }); + expect(result.exitCode).toBe(0); + expect(await different({ [name]: frontMatter + after }, [])).toEqual([]); + }); + }); + test("HTML, Vue, Angular templates, MJML", async () => { const files = { "a.html": '

    c

    \n', @@ -996,14 +1131,24 @@ describe.concurrent("bun format", () => { expect(result.exitCode).toBe(2); }); - describe("HTML of which Prettier loses something", () => { - // Prettier 3.9.9 writes a media query in lower case, which makes a `k` of the Kelvin sign. - const lossy = "\n"; - const written = "\n"; + describe("a style sheet of which Prettier changes a letter", () => { + // Prettier 3.9.9 writes a media query in lower case, which makes a `k` of the Kelvin sign: `@media (ka)`. To CSS only + // the letters of ASCII are the same in both cases. + const lossy = "\n"; + const written = "\n"; - test("is written as Prettier writes it", async () => { - const result = await format({ "a.html": lossy }, [], { reads: ["a.html"] }); - expect(result.files).toEqual({ "a.html": written }); + test("keeps it", async () => { + const files = { "a.html": lossy, "b.css": "@media (\u212AA){a{b:c}}\n" }; + const result = await format(files, [], { reads: ["a.html", "b.css"] }); + expect(result.files).toEqual({ "a.html": written, "b.css": "@media (\u212Aa) {\n a {\n b: c;\n }\n}\n" }); + expect(result.exitCode).toBe(0); + }); + + test("--debug-check has nothing to say about it", async () => { + const result = await format({ "a.html": lossy }, ["--debug-check"], { reads: ["a.html"] }); + expect(result.files).toEqual({ "a.html": lossy }); + expect(result.stderr).toBe(""); + expect(result.stdout).toBe("a.html"); expect(result.exitCode).toBe(0); }); @@ -1034,8 +1179,7 @@ describe.concurrent("bun format", () => { }); }); - // What Prettier 3.9.9 does with the flag. oxfmt 0.72 does not have it. The comparison is Bun's own: Prettier's lets the Kelvin - // sign pass. + // What Prettier 3.9.9 does with the flag. oxfmt 0.72 does not have it. The comparison is Bun's own. describe.each([ ["no config", {}, "error: "], [".prettierrc", { ".prettierrc": "{}\n" }, "[error] "], @@ -1043,7 +1187,9 @@ describe.concurrent("bun format", () => { ])("--debug-check with %s", (_, config: Record, prefix) => { const files = { "a.js": formatted, "b.js": ugly }; const names = Object.keys(files); - const lossy = "\n"; + // Without the parentheses, which neither Prettier nor Bun writes, it means the same, but the tree is another. Prettier's + // check reports it too. + const lossy = "(a)();\n"; test("every file that passes is named, formatted or not, and nothing is written", async () => { const result = await format({ ...config, ...files }, ["--debug-check", ...names], { reads: names }); @@ -1054,12 +1200,12 @@ describe.concurrent("bun format", () => { }); test("a file that would say something else is an error", async () => { - const all = { "a.html": lossy, "b.js": ugly }; - const result = await format({ ...config, ...all }, ["--debug-check", "a.html", "b.js"], { - reads: ["a.html", "b.js"], + const all = { "a.ts": lossy, "b.js": ugly }; + const result = await format({ ...config, ...all }, ["--debug-check", "a.ts", "b.js"], { + reads: ["a.ts", "b.js"], }); expect(result.files).toEqual(all); - expect(result.stderr.trim()).toBe(`${prefix}a.html: formatting like Prettier would change its contents.`); + expect(result.stderr.trim()).toBe(`${prefix}a.ts: formatting would change what the code does.`); expect(result.stdout).toBe("b.js"); expect(result.exitCode).toBe(2); }); @@ -1089,7 +1235,7 @@ describe.concurrent("bun format", () => { format(config, ["--debug-check", "--stdin-filepath", "x.js"], { stdin: ugly }), format(config, ["--debug-check", "--parser", "babel"], { stdin: ugly }), format(config, ["--debug-check", "--check", "--stdin-filepath", "x.js"], { stdin: ugly }), - format(config, ["--debug-check", "--stdin-filepath", "x.html"], { stdin: lossy }), + format(config, ["--debug-check", "--stdin-filepath", "x.ts"], { stdin: lossy }), ]); // The path, and no line break. expect(named.raw).toEndWith(`${sep}x.js`); @@ -1100,7 +1246,7 @@ describe.concurrent("bun format", () => { expect(looks.raw).toBe(".oxfmtrc.json" in config ? "" : "(stdin)\n"); expect(looks.exitCode).toBe(1); expect(lost.raw).toBe(""); - expect(lost.stderr.trim()).toBe(`${prefix}x.html: formatting like Prettier would change its contents.`); + expect(lost.stderr.trim()).toBe(`${prefix}x.ts: formatting would change what the code does.`); expect(lost.exitCode).toBe(2); }); }); @@ -1141,20 +1287,34 @@ describe.concurrent("bun format", () => { expect(result.exitCode).toBe(0); }); - test("a template whose element Prettier closes gets the expression twice, and oxfmt leaves it as it is", async () => { - const text = "const a = html`<${b}>c`;\n"; - const [prettier, oxfmt, checked] = await Promise.all([ - format({ "a.js": text }, [], { reads: ["a.js"] }), - format({ "a.js": text, ".oxfmtrc.json": "{}\n" }, [], { reads: ["a.js"] }), - format({ "a.js": text }, ["--debug-check"], { reads: ["a.js"] }), - ]); - expect(prettier.files).toEqual({ "a.js": "const a = html`<${b}>c`;\n" }); - expect(prettier.exitCode).toBe(0); - expect(oxfmt.files).toEqual({ "a.js": text }); - expect(oxfmt.exitCode).toBe(0); - expect(checked.files).toEqual({ "a.js": text }); - expect(checked.stderr).toContain("error: a.js: formatting would change what the code does."); - expect(checked.exitCode).toBe(2); + describe("HTML in a template of which Prettier changes the substitutions", () => { + // The code, what Bun makes of it, and what Prettier 3.9.9 makes of it: it closes the element, so `b` is evaluated twice, + // and it does not find what stands for `${b}` again once that is in lower case. oxfmt 0.72 leaves both as they are. + test.each([ + ["const a = html`<${b}>c`;\n", "const a = html`<${b}>c`;\n", "const a = html`<${b}>c`;\n"], + [ + "const a = html`

    e

    d`;\n", + "const a = html`

    e

    d`;\n", + "const a = html`

    e

    \n d`;\n", + ], + [ + "const a = html``;\n", + "const a = html``;\n", + "const a = html``;\n", + ], + ])("%j", async (before, after, ofPrettier) => { + expect(after).not.toBe(ofPrettier); + const [written, again, checked] = await Promise.all([ + format({ "a.js": before }, [], { reads: ["a.js"] }), + different({ "a.js": after }, []), + format({ "a.js": before }, ["--debug-check"], { reads: ["a.js"] }), + ]); + expect(written.files).toEqual({ "a.js": after }); + expect(written.exitCode).toBe(0); + expect(again).toEqual([]); + expect(checked.stderr).toBe(""); + expect(checked.exitCode).toBe(0); + }); }); test("HTML and Vue in blocks of code in Markdown", async () => { @@ -1188,7 +1348,7 @@ describe.concurrent("bun format", () => { const left = { "a.astro": "

    a

    \n", "b.astro": "

    b

    \n" }; const files = { ".prettierrc": '{ "plugins": ["prettier-plugin-astro"] }\n', ...left, "c.js": ugly }; const reads = [...Object.keys(left), "c.js"]; - const text = "2 files were not formatted because bun format does not support their language yet: 2 .astro"; + const text = "2 files were not formatted because bun fmt does not support their language yet: 2 .astro"; const result = await format(files, [], { reads }); expect(result.files).toEqual({ ...left, "c.js": formatted }); expect(result.stderr.trimEnd().split("\n").slice(-2)).toEqual([ @@ -1373,8 +1533,8 @@ exports.format = async (text, { parser }) => { expect(result.raw).toBe(""); expect(result.stderr.split("\n")).toEqual([ "[warn] Not installed: prettier, prettier-plugin-svelte", - "[warn] Install prettier and every plugin in your config, and bun format will use them for these files.", - "[error] 1 file was not formatted because bun format does not support its language yet: 1 svelte", + "[warn] Install prettier and every plugin in your config, and bun fmt will use them for these files.", + "[error] 1 file was not formatted because bun fmt does not support its language yet: 1 svelte", "[error] Use --allow-unsupported to make this a warning.", ]); expect(result.exitCode).toBe(2); @@ -1406,13 +1566,13 @@ exports.format = async (text, { parser }) => { ...Object.fromEntries(installed.map(name => [name, "{}"])), }; const stdin = "

    c

    \n"; - const unsupported = "1 file was not formatted because bun format does not support its language yet: 1 .svelte"; + const unsupported = "1 file was not formatted because bun fmt does not support its language yet: 1 .svelte"; test.each([[[]], [["--check"]], [["--list-different"]]])("%j", async flags => { const result = await format(project, [...flags, "--stdin-filepath", "c.svelte"], { stdin }); expect(result.raw).toBe(""); expect(result.stderr.split("\n")).toEqual([ `[warn] Not installed: ${missing}`, - "[warn] Install prettier and every plugin in your config, and bun format will use them for these files.", + "[warn] Install prettier and every plugin in your config, and bun fmt will use them for these files.", `[error] ${unsupported}`, "[error] Use --allow-unsupported to make this a warning.", ]); @@ -1557,7 +1717,7 @@ ${packages["node_modules/prettier/index.cjs"]}`; const result = await format(others, [], { reads: ["a.svelte"] }); expect(result.files["a.svelte"]).toBe(files["a.svelte"]); expect(result.stderr).toContain( - "[warn] Not installed: prettier-plugin-svelte\n[warn] Install prettier and every plugin in your config, and bun format will use them for these files.", + "[warn] Not installed: prettier-plugin-svelte\n[warn] Install prettier and every plugin in your config, and bun fmt will use them for these files.", ); expect(result.stderr).toContain("does not support its language yet: 1 .svelte\n"); expect(result.exitCode).toBe(2); @@ -1706,7 +1866,7 @@ ${packages["node_modules/prettier/index.cjs"]}`; [], ], [{ ".prettierrc.mjs": `export default { printWidth: Infinity, rangeStart: 0, rangeEnd: Infinity };` }, []], - [{ "bunfig.toml": `[format]\nprintWidth = 100000\n` }, []], + [{ "bunfig.toml": `[fmt]\nprintWidth = 100000\n` }, []], [{}, ["--print-width", "65536"]], [{}, ["--print-width=100000"]], [{}, ["--print-width=9007199254740991"]], @@ -1772,25 +1932,25 @@ ${packages["node_modules/prettier/index.cjs"]}`; const result = await format(files, [], { reads }); expect(result.files).toEqual({ "a.js": ugly, "b.astro": files["b.astro"], "other/c.js": formatted }); expect(result.stderr).toContain( - "[error] 2 files were not formatted because the Prettier config uses plugins that bun format does not include: prettier-plugin-brace-style, ./own.js\n[error] Use --allow-unsupported to format them without the plugins.", + "[error] 2 files were not formatted because the Prettier config uses plugins that bun fmt does not include: prettier-plugin-brace-style, ./own.js\n[error] Use --allow-unsupported to format them without the plugins.", ); expect(result.stderr).toContain( - "[error] 1 file was not formatted because bun format does not support its language yet", + "[error] 1 file was not formatted because bun fmt does not support its language yet", ); expect(result.exitCode).toBe(2); const allowed = await format(files, ["--allow-unsupported"], { reads }); expect(allowed.files).toEqual({ "a.js": formatted, "b.astro": files["b.astro"], "other/c.js": formatted }); - expect(allowed.stderr).toContain("has no effect because bun format does not load Prettier plugins"); + expect(allowed.stderr).toContain("has no effect because bun fmt does not load Prettier plugins"); expect(allowed.exitCode).toBe(0); const flag = await format({ "a.js": ugly }, ["--plugin", "prettier-plugin-brace-style"], { reads: ["a.js"] }); expect(flag.files["a.js"]).toBe(ugly); - expect(flag.stderr).toContain("bun format does not include the plugin prettier-plugin-brace-style\n"); + expect(flag.stderr).toContain("bun fmt does not include the plugin prettier-plugin-brace-style\n"); expect(flag.exitCode).toBe(2); for (const flags of [[], ["--check"], ["--list-different"]]) { const piped = await format(files, [...flags, "--stdin-filepath", "d.js"], { stdin: ugly }); expect(piped.raw).toBe(""); expect(piped.stderr).toBe( - "[error] 1 file was not formatted because the Prettier config uses plugins that bun format does not include: prettier-plugin-brace-style, ./own.js\n[error] Use --allow-unsupported to format it without the plugins.", + "[error] 1 file was not formatted because the Prettier config uses plugins that bun fmt does not include: prettier-plugin-brace-style, ./own.js\n[error] Use --allow-unsupported to format it without the plugins.", ); expect(piped.exitCode).toBe(2); } @@ -1804,7 +1964,7 @@ ${packages["node_modules/prettier/index.cjs"]}`; const result = await format(files, [], { reads: ["a.js", "legacy/b.js"] }); expect(result.files).toEqual({ "a.js": formatted, "legacy/b.js": ugly }); expect(result.stderr).toContain( - "[error] 1 file was not formatted because the Prettier config uses plugins that bun format does not include: prettier-plugin-brace-style\n", + "[error] 1 file was not formatted because the Prettier config uses plugins that bun fmt does not include: prettier-plugin-brace-style\n", ); expect(result.exitCode).toBe(2); // An override that empties the list takes its files out. @@ -2032,7 +2192,7 @@ ${packages["node_modules/prettier/index.cjs"]}`; const files = { [`sub/${name}`]: ` const seen = JSON.stringify([process.argv[0] === process.execPath, ...process.argv.slice(1)]); - export default { semi: seen !== ${JSON.stringify(JSON.stringify([true, "format", ...args]))} };\n`, + export default { semi: seen !== ${JSON.stringify(JSON.stringify([true, "fmt", ...args]))} };\n`, "sub/a.js": "a;\n", }; const result = await format(files, args, { reads: ["sub/a.js"] }); @@ -2079,7 +2239,7 @@ ${packages["node_modules/prettier/index.cjs"]}`; const result = await format({ ...files, ".prettierrc": config }, [], { reads }); expect(result.files).toEqual({ ...files, "d.js": "d;\n" }); expect(result.stderr).toContain( - "3 files were not formatted because bun format does not support their language yet: 1 .astro, 1 .foo, 1 .js", + "3 files were not formatted because bun fmt does not support their language yet: 1 .astro, 1 .foo, 1 .js", ); expect(result.exitCode).toBe(2); const checked = await format({ ...files, ".prettierrc": config, "d.js": "d;\n" }, ["--check", ...reads], { @@ -2087,7 +2247,7 @@ ${packages["node_modules/prettier/index.cjs"]}`; }); expect(checked.files).toEqual({ ...files, "d.js": "d;\n" }); expect(checked.stderr).toContain( - "3 files were not formatted because bun format does not support their language yet", + "3 files were not formatted because bun fmt does not support their language yet", ); expect(checked.exitCode).toBe(2); }); @@ -2095,9 +2255,7 @@ ${packages["node_modules/prettier/index.cjs"]}`; test("at the top of a .prettierrc: every file is left as it is", async () => { const result = await format({ ...files, ".prettierrc": '{ "parser": "nonsense" }\n' }, [], { reads }); expect(result.files).toEqual(files); - expect(result.stderr).toContain( - "files were not formatted because bun format does not support their language yet", - ); + expect(result.stderr).toContain("files were not formatted because bun fmt does not support their language yet"); expect(result.exitCode).toBe(2); }); @@ -2108,7 +2266,7 @@ ${packages["node_modules/prettier/index.cjs"]}`; }); expect(result.raw).toBe(""); expect(result.stderr).toBe( - "[error] 1 file was not formatted because bun format does not support its language yet: 1 .astro\n[error] Use --allow-unsupported to make this a warning.", + "[error] 1 file was not formatted because bun fmt does not support its language yet: 1 .astro\n[error] Use --allow-unsupported to make this a warning.", ); expect(result.exitCode).toBe(2); }); @@ -3729,7 +3887,7 @@ try { test("requirePragma and checkIgnorePragma", async () => { const files = { "none.js": ugly, - // Not `format.js`: that is what `bun format` would run. + // Not `format.js`: that is what `bun fmt` would run. "at-format.js": `/** @format */\n${ugly}`, "prettier.js": `#!/usr/bin/env bun\n/**\n * Text.\n * @prettier\n */\n${ugly}`, "in-text.js": `/** see @format */\n${ugly}`, @@ -4132,7 +4290,7 @@ try { print({}, "--stdin-filepath", "my_node_modules/a.js"), print({}, "--stdin-filepath", ".git/a.js"), print({}, "--with-node-modules", "--stdin-filepath", "node_modules/x/a.js"), - print({ "bunfig.toml": "[format]\nwithNodeModules = true\n" }, "--stdin-filepath", "node_modules/x/a.js"), + print({ "bunfig.toml": "[fmt]\nwithNodeModules = true\n" }, "--stdin-filepath", "node_modules/x/a.js"), print({ ".oxfmtrc.json": "{}" }, "--stdin-filepath", "node_modules/x/a.js"), print({}, "--flavor=oxfmt", "--stdin-filepath", "node_modules/x/a.js"), ]); @@ -4160,7 +4318,7 @@ try { const js = `function f(){return "a"}\n`; const printed = `function f() {\n return "a";\n}\n`; const withoutSemi = `function f() {\n return "a"\n}\n`; - /** Runs `bun format --parser babel` with `js` on standard input. */ + /** Runs `bun fmt --parser babel` with `js` on standard input. */ const pipe = (project: Record, flags: string[] = [], options: Options = {}) => format(project, ["--parser", "babel", ...flags], { stdin: js, ...options }); @@ -4280,10 +4438,7 @@ try { "overrides in .oxfmtrc.json", { ".oxfmtrc.json": `{ "overrides": [{ "files": $, "options": { "singleQuote": true } }] }` }, ], - [ - "[[format.overrides]]", - { "bunfig.toml": `[[format.overrides]]\nfiles = $\noptions = { singleQuote = true }\n` }, - ], + ["[[fmt.overrides]]", { "bunfig.toml": `[[fmt.overrides]]\nfiles = $\noptions = { singleQuote = true }\n` }], ])("there's no file name for %s to match", async (_, project) => { const [[name, text]] = Object.entries(project); const globs = `["*", "**", "**/*", "*.js", "stdin", "(stdin)"]`; @@ -4518,7 +4673,7 @@ try { expect(result.raw).toBe(""); expect(result.stderr.split("\n")).toEqual([ `error: --parser ${parser} would read every file in "${directory}" as ${parser}`, - ` Name the files, or use a glob: bun format --parser ${parser} "${glob}"`, + ` Name the files, or use a glob: bun fmt --parser ${parser} "${glob}"`, ]); expect(result.exitCode).toBe(2); }); @@ -4533,7 +4688,7 @@ try { expect(result.raw).toBe(""); expect(result.stderr.split("\n")).toEqual([ `[error] --parser markdown would read every file in "." as markdown`, - `[error] Name the files, or use a glob: bun format --parser markdown "**/*.md"`, + `[error] Name the files, or use a glob: bun fmt --parser markdown "**/*.md"`, ]); expect(result.exitCode).toBe(2); }); @@ -4598,7 +4753,7 @@ try { test("--help", async () => { const result = await format({}, ["--help"]); - expect(result.stdout).toContain("bun format"); + expect(result.stdout).toContain("bun fmt"); expect(result.stdout).toContain("--check"); expect(result.exitCode).toBe(0); }); @@ -5288,10 +5443,10 @@ describe.concurrent("upper and lower case in the name of a file", () => { }); }); -describe.concurrent("a format script in package.json", () => { +describe.concurrent("a fmt script in package.json", () => { test("wins over the formatter", async () => { const result = await format( - { "package.json": JSON.stringify({ scripts: { format: "echo the script" } }), "a.js": ugly }, + { "package.json": JSON.stringify({ scripts: { fmt: "echo the script" } }), "a.js": ugly }, [], { reads: ["a.js"] }, ); @@ -5299,9 +5454,19 @@ describe.concurrent("a format script in package.json", () => { expect(result.files).toEqual({ "a.js": ugly }); }); - test("in that script, bun format is the formatter", async () => { - const script = `"${bunExe().replaceAll("\\", "/")}" format a.js`; - const result = await format({ "package.json": JSON.stringify({ scripts: { format: script } }), "a.js": ugly }, [], { + // Most projects call theirs `format`. + test("a format script does not", async () => { + const scripts = { format: "echo the script", preformat: "echo before", postformat: "echo after" }; + const files = { "package.json": JSON.stringify({ scripts }, null, 2) + "\n", "a.js": ugly }; + const result = await format(files, [], { reads: ["a.js"] }); + expect(result.stdout).toBe("a.js"); + expect(result.files).toEqual({ "a.js": formatted }); + expect(result.exitCode).toBe(0); + }); + + test("in that script, bun fmt is the formatter", async () => { + const script = `"${bunExe().replaceAll("\\", "/")}" fmt a.js`; + const result = await format({ "package.json": JSON.stringify({ scripts: { fmt: script } }), "a.js": ugly }, [], { reads: ["a.js"], }); expect(result.files).toEqual({ "a.js": formatted }); @@ -5309,11 +5474,11 @@ describe.concurrent("a format script in package.json", () => { }); }); -// What `bun format` meant before there was a formatter. -describe.concurrent("what else is called format in the project", () => { +// What `bun fmt` meant before there was a formatter. +describe.concurrent("what else is called fmt in the project", () => { test.each([ - ["a file", { "format.ts": 'console.log("the file");' }, "the file"], - ["the index of a directory", { "format/index.js": 'console.log("the index");' }, "the index"], + ["a file", { "fmt.ts": 'console.log("the file");' }, "the file"], + ["the index of a directory", { "fmt/index.js": 'console.log("the index");' }, "the index"], ])("%s wins over the formatter, and nothing is rewritten", async (_, files, printed) => { const result = await format({ ...files, "a.js": ugly }, [], { reads: ["a.js"] }); expect(result.stdout).toBe(printed); @@ -5322,10 +5487,10 @@ describe.concurrent("what else is called format in the project", () => { }); test.skipIf(isWindows)("an executable of a package wins over the formatter, and nothing is rewritten", async () => { - const files = { "node_modules/.bin/format": "#!/bin/sh\necho the executable\n", "a.js": ugly }; + const files = { "node_modules/.bin/fmt": "#!/bin/sh\necho the executable\n", "a.js": ugly }; const result = await format(files, [], { reads: ["a.js"], - before: dir => chmodSync(join(dir, "node_modules/.bin/format"), 0o755), + before: dir => chmodSync(join(dir, "node_modules/.bin/fmt"), 0o755), }); expect(result.stdout).toBe("the executable"); expect(result.files).toEqual({ "a.js": ugly }); @@ -5333,14 +5498,14 @@ describe.concurrent("what else is called format in the project", () => { }); test("a directory with nothing to run in it does not", async () => { - const result = await format({ "format/notes.txt": "x", "a.js": ugly }, [], { reads: ["a.js"] }); + const result = await format({ "fmt/notes.txt": "x", "a.js": ugly }, [], { reads: ["a.js"] }); expect(result.files).toEqual({ "a.js": formatted }); expect(result.exitCode).toBe(0); }); // On macOS a directory that can be searched is executable for `which`. test("nor beside package.json", async () => { - const files = { "package.json": "{}\n", "format/notes.txt": "x", "a.js": ugly }; + const files = { "package.json": "{}\n", "fmt/notes.txt": "x", "a.js": ugly }; const result = await format(files, [], { reads: ["a.js"] }); expect(result.files).toEqual({ "a.js": formatted }); expect(result.exitCode).toBe(0); @@ -5348,8 +5513,8 @@ describe.concurrent("what else is called format in the project", () => { }); // What the formatter takes from the process that it runs in: the terminal, the arguments, the streams, the stack, a way to run a -// configuration file that is a program. Where `bun format` is developed something else is in Bun's place for each of these. -describe.concurrent("what bun format takes from Bun", () => { +// configuration file that is a program. Where `bun fmt` is developed something else is in Bun's place for each of these. +describe.concurrent("what bun fmt takes from Bun", () => { /** `bun ...args` in a directory with `files`. */ async function bun(files: Record, args: string[], variables: Record = {}) { using dir = tempDir("bun-format-process", files); @@ -5382,12 +5547,12 @@ describe.concurrent("what bun format takes from Bun", () => { test("--cwd behind format, before it, and in BUN_OPTIONS, whose other flags are not the formatter's", async () => { const files = { "a.js": ugly, "sub/b.js": ugly }; const results = await Promise.all([ - bun(files, ["format", "--cwd", "sub", "-l"]), - bun(files, ["format", "--cwd=sub", "-l"]), - bun(files, ["--cwd=sub", "format", "-l"]), - bun(files, ["format", "-l"], { BUN_OPTIONS: "--cwd=sub" }), - bun(files, ["--silent", "format", "-l", "sub"]), - bun(files, ["format", "-l", "sub"], { BUN_OPTIONS: "--silent --no-install" }), + bun(files, ["fmt", "--cwd", "sub", "-l"]), + bun(files, ["fmt", "--cwd=sub", "-l"]), + bun(files, ["--cwd=sub", "fmt", "-l"]), + bun(files, ["fmt", "-l"], { BUN_OPTIONS: "--cwd=sub" }), + bun(files, ["--silent", "fmt", "-l", "sub"]), + bun(files, ["fmt", "-l", "sub"], { BUN_OPTIONS: "--silent --no-install" }), ]); expect(results.map(it => [it.stdout.replaceAll("\\", "/"), it.stderr, it.exitCode])).toEqual([ ...Array(4).fill(["b.js\n", "", 1]), @@ -5398,7 +5563,7 @@ describe.concurrent("what bun format takes from Bun", () => { // The process that runs the configuration file starts in that directory. With BUN_OPTIONS it would look for `sub` in `sub`. test("--cwd in BUN_OPTIONS, with a configuration file that is a program", async () => { const files = { "sub/.prettierrc.mjs": "export default { semi: false };\n", "sub/b.js": "b;\n" }; - const result = await bun(files, ["format", "-l", "b.js"], { BUN_OPTIONS: "--cwd=sub" }); + const result = await bun(files, ["fmt", "-l", "b.js"], { BUN_OPTIONS: "--cwd=sub" }); expect(result).toEqual({ stdout: "b.js\n", stderr: "", exitCode: 1 }); }); @@ -5408,25 +5573,25 @@ describe.concurrent("what bun format takes from Bun", () => { "prettier.config.mjs": "setInterval(() => {}, 1000);\nexport default { semi: false };\n", "b.js": "b;\n", }; - expect(await bun(files, ["format", "-l", "b.js"])).toEqual({ stdout: "b.js\n", stderr: "", exitCode: 1 }); + expect(await bun(files, ["fmt", "-l", "b.js"])).toEqual({ stdout: "b.js\n", stderr: "", exitCode: 1 }); }); test("what the bunfig.toml of the project has for other commands is not read", async () => { - const result = await bun({ "bunfig.toml": "[install]\nglobalDir = 1\n", "a.js": ugly }, ["format", "-l"]); + const result = await bun({ "bunfig.toml": "[install]\nglobalDir = 1\n", "a.js": ugly }, ["fmt", "-l"]); expect(result).toEqual({ stdout: "a.js\n", stderr: "", exitCode: 1 }); }); test("--config= before format names another file than bunfig.toml", async () => { const files = { - "bunfig.toml": "[format]\nsemi = false\n", - "other.toml": "[format]\nsingleQuote = true\n", + "bunfig.toml": "[fmt]\nsemi = false\n", + "other.toml": "[fmt]\nsingleQuote = true\n", "a.js": "a('b');\n", }; const results = await Promise.all([ - bun(files, ["format", "-l"]), - bun(files, ["--config=other.toml", "format", "-l"]), - bun(files, ["-c=other.toml", "format", "-l"]), - bun(files, ["--config=none.toml", "format", "-l"]), + bun(files, ["fmt", "-l"]), + bun(files, ["--config=other.toml", "fmt", "-l"]), + bun(files, ["-c=other.toml", "fmt", "-l"]), + bun(files, ["--config=none.toml", "fmt", "-l"]), ]); expect(results.map(it => [it.stdout, it.exitCode])).toEqual([ ["a.js\n", 1], @@ -5437,9 +5602,9 @@ describe.concurrent("what bun format takes from Bun", () => { expect(results[3].stderr).toContain(`while reading config "`); }); - test("what is wrong with [format] is shown where it is, as for every other section", async () => { - const files = { "bunfig.toml": `[format]\nsemi = false\nprintWidth = "wide"\n`, "a.js": ugly }; - const result = await bun(files, ["format", "-l"]); + test("what is wrong with [fmt] is shown where it is, as for every other section", async () => { + const files = { "bunfig.toml": `[fmt]\nsemi = false\nprintWidth = "wide"\n`, "a.js": ugly }; + const result = await bun(files, ["fmt", "-l"]); expect(result.stderr).toContain(`3 | printWidth = "wide"\n`); expect(result.stderr).toMatch(/error: expected number but received string\n\s+at .*bunfig\.toml:3:14\n/); expect(result.stdout).toBe(""); @@ -5481,7 +5646,7 @@ describe.concurrent("what bun format takes from Bun", () => { writeFileSync(name, ugly); const run = async (argument: string) => { await using proc = spawn({ - cmd: ["sh", "-c", `exec "$0" format ${argument}`, bunExe()], + cmd: ["sh", "-c", `exec "$0" fmt ${argument}`, bunExe()], env, cwd: String(dir), stdout: "pipe", @@ -5526,12 +5691,12 @@ describe.concurrent("what bun format takes from Bun", () => { ); }); -// What differs between Windows, macOS and Linux, for `bun format`: how a path is written, which names are the same file, how a line +// What differs between Windows, macOS and Linux, for `bun fmt`: how a path is written, which names are the same file, how a line // ends, what a link is, what can be written. Nothing here is passed through `normalizeBunSnapshot`, which makes `/` of every `\` // and `\n` of every `\r\n`: what is printed is compared as it is printed. // // A test that can only tell something on one system runs on all of them where it can: there it is a test that nothing else breaks. -describe("bun format on Windows, macOS and Linux", () => { +describe("bun fmt on Windows, macOS and Linux", () => { const env = { ...bunEnv, AGENT: "0", CLAUDECODE: undefined, NO_COLOR: "1", FORCE_COLOR: undefined }; // A drive letter that a test has taken would stay, for every process of the session, if the test ran out of time. @@ -5561,10 +5726,10 @@ describe("bun format on Windows, macOS and Linux", () => { type Directory = string | { toString(): string }; - /** Runs `bun format ` in `cwd`. What it prints is returned as it is. */ + /** Runs `bun fmt ` in `cwd`. What it prints is returned as it is. */ async function format(cwd: Directory, args: string[], stdin?: string, before: string[] = []) { const proc = spawn({ - cmd: [...before, bunExe(), "format", ...args], + cmd: [...before, bunExe(), "fmt", ...args], env, cwd: String(cwd), stdin: stdin === undefined ? "ignore" : Buffer.from(stdin), @@ -5724,7 +5889,7 @@ describe("bun format on Windows, macOS and Linux", () => { test( "a project in a directory with blanks, `#`, `%41` and letters that are not ASCII", async () => { - using dir = tempDir("bun format #1 %41 é 日本", { + using dir = tempDir("bun fmt #1 %41 é 日本", { ".prettierrc.mjs": `import shared from "./my configs/shared#1.mjs";\nexport default { ...shared };\n`, "my configs/shared#1.mjs": "export default { semi: false };\n", "sources é/ü 100%.js": formatted, @@ -6133,8 +6298,8 @@ describe("bun format on Windows, macOS and Linux", () => { }); }); -// A key of `[format]` is the default of the flag of the same name. -describe.concurrent("[format] in bunfig.toml", () => { +// A key of `[fmt]` is the default of the flag of the same name. +describe.concurrent("[fmt] in bunfig.toml", () => { /** What a run leaves behind: what it prints but for the times, how it ends, and the files. */ async function run(files: Record, args: string[]) { const reads = Object.keys(files).filter(name => name !== "bunfig.toml"); @@ -6184,7 +6349,7 @@ describe.concurrent("[format] in bunfig.toml", () => { test.each(options)("%s", async (key, value, other, name, source) => { const files = { [name]: source }; - const bunfig = { "bunfig.toml": `[format]\n${key} = ${toml(value)}\n` }; + const bunfig = { "bunfig.toml": `[fmt]\n${key} = ${toml(value)}\n` }; const rc = { ".prettierrc": JSON.stringify({ [key]: other }) }; const [plain, byKey, byFlag, underFlag, byOtherFlag, overFile, flagOverFile] = await Promise.all([ run(files, [name]), @@ -6275,7 +6440,7 @@ describe.concurrent("[format] in bunfig.toml", () => { ]; test.each(others)("%s", async (line, flags, opposite, files, args) => { - const bunfig = { "bunfig.toml": `[format]\n${line}\n` }; + const bunfig = { "bunfig.toml": `[fmt]\n${line}\n` }; // bunfig.toml gets formatted too, so every run has one to keep the outputs comparable. const empty = { "bunfig.toml": "# Nothing.\n" }; const [plain, byKey, byFlag, underFlag, byOtherFlag] = await Promise.all([ @@ -6293,9 +6458,9 @@ describe.concurrent("[format] in bunfig.toml", () => { test("threads", async () => { const files = { "a.js": ugly, "b.js": ugly }; const [byKey, byFlag, zero] = await Promise.all([ - run({ ...files, "bunfig.toml": "[format]\nthreads = 1\n" }, ["a.js", "b.js"]), + run({ ...files, "bunfig.toml": "[fmt]\nthreads = 1\n" }, ["a.js", "b.js"]), run(files, ["--threads=1", "a.js", "b.js"]), - run({ ...files, "bunfig.toml": "[format]\nthreads = 0\n" }, ["a.js", "b.js"]), + run({ ...files, "bunfig.toml": "[fmt]\nthreads = 0\n" }, ["a.js", "b.js"]), ]); expect(byKey).toEqual(byFlag); expect(zero.stderr).toContain(`--threads takes a number above zero, not "0"`); @@ -6305,7 +6470,7 @@ describe.concurrent("[format] in bunfig.toml", () => { test.each(["67108864", "9007199254740991"])("%s threads are as many as there can be", async count => { const files = { "a.js": ugly, "b.js": ugly }; const [byKey, byFlag, few] = await Promise.all([ - run({ ...files, "bunfig.toml": `[format]\nthreads = ${count}\n` }, ["a.js", "b.js"]), + run({ ...files, "bunfig.toml": `[fmt]\nthreads = ${count}\n` }, ["a.js", "b.js"]), run(files, [`--threads=${count}`, "a.js", "b.js"]), run(files, ["--threads=1", "a.js", "b.js"]), ]); @@ -6314,12 +6479,12 @@ describe.concurrent("[format] in bunfig.toml", () => { }); test("a list on the command line is added to the list of the file", async () => { - const files = { "a.js": ugly, "b.js": ugly, "c.js": ugly, "bunfig.toml": `[format]\nignorePatterns = ["b.js"]\n` }; + const files = { "a.js": ugly, "b.js": ugly, "c.js": ugly, "bunfig.toml": `[fmt]\nignorePatterns = ["b.js"]\n` }; expect(await different(files, ["--ignore-pattern=c.js", "."])).toEqual(["a.js"]); }); test("with configPrecedence = prefer-file the section counts only where there is no configuration file", async () => { - const bunfig = `[format]\nconfigPrecedence = "prefer-file"\nsemi = false\n`; + const bunfig = `[fmt]\nconfigPrecedence = "prefer-file"\nsemi = false\n`; const [alone, beside] = await Promise.all([ run({ "bunfig.toml": bunfig, "a.js": "a;\n" }, ["a.js"]), run({ "bunfig.toml": bunfig, ".prettierrc": "{}", "a.js": "a;\n" }, ["a.js"]), @@ -6328,7 +6493,7 @@ describe.concurrent("[format] in bunfig.toml", () => { }); test("the file is that of the working directory, after --cwd", async () => { - const files = { "bunfig.toml": "[format]\nsemi = false\n", "sub/bunfig.toml": "[format]\nsingleQuote = true\n" }; + const files = { "bunfig.toml": "[fmt]\nsemi = false\n", "sub/bunfig.toml": "[fmt]\nsingleQuote = true\n" }; const source = { "a.js": `a("b");\n`, "sub/a.js": `a("b");\n` }; const [above, below, inside] = await Promise.all([ run({ ...files, ...source }, ["sub/a.js"]), @@ -6356,7 +6521,7 @@ describe.concurrent("[format] in bunfig.toml", () => { [`cwd = "sub"`, "--cwd"], ])("%s is refused, and nothing is written", async (line, flag) => { const key = line.split(" ")[0]; - const result = await run({ "bunfig.toml": `[format]\n${line}\n`, "a.js": ugly }, ["a.js"]); + const result = await run({ "bunfig.toml": `[fmt]\n${line}\n`, "a.js": ugly }, ["a.js"]); expect(result.stderr).toContain( `error: "${key}" cannot be set in bunfig.toml\n Use ${flag} on the command line\n`, ); @@ -6379,25 +6544,25 @@ describe.concurrent("[format] in bunfig.toml", () => { [`flavor = "biome"`, `Invalid --flavor value. Expected "oxfmt" or "prettier", but received "biome".`], [`logLevel = "loud"`, `Invalid --log-level value.`], [`configPrecedence = "mine"`, `Invalid --config-precedence value.`], - ["semicolons = false", `Unknown key "semicolons" in [format]\n`], - ["printwidth = 80", `Unknown key "printwidth" in [format]\n Did you mean "printWidth"?`], - [`plugins = ["a"]`, `Unknown key "plugins" in [format]\n`], - [`parser = "babel"`, `Unknown key "parser" in [format]\n`], + ["semicolons = false", `Unknown key "semicolons" in [fmt]\n`], + ["printwidth = 80", `Unknown key "printwidth" in [fmt]\n Did you mean "printWidth"?`], + [`plugins = ["a"]`, `Unknown key "plugins" in [fmt]\n`], + [`parser = "babel"`, `Unknown key "parser" in [fmt]\n`], ])("%s is refused: %s", async (line, message) => { - const result = await run({ "bunfig.toml": `[format]\n${line}\n`, "a.js": ugly }, ["a.js"]); + const result = await run({ "bunfig.toml": `[fmt]\n${line}\n`, "a.js": ugly }, ["a.js"]); expect(result.stderr).toContain(message); expect(result.files["a.js"]).toBe(ugly); expect(result.exitCode).toBe(2); }); test("a file that is not TOML", async () => { - const result = await run({ "bunfig.toml": "[format\nsemi = false\n", "a.js": ugly }, ["a.js"]); + const result = await run({ "bunfig.toml": "[fmt\nsemi = false\n", "a.js": ugly }, ["a.js"]); expect(result.files["a.js"]).toBe(ugly); expect(result.exitCode).toBe(2); }); - test("format that is no table", async () => { - const result = await run({ "bunfig.toml": `format = "prettier"\n`, "a.js": ugly }, ["a.js"]); + test("fmt that is no table", async () => { + const result = await run({ "bunfig.toml": `fmt = "prettier"\n`, "a.js": ugly }, ["a.js"]); expect(result.stderr).toContain("expected object but received string"); expect(result.exitCode).toBe(2); }); @@ -6412,7 +6577,7 @@ describe.concurrent("[format] in bunfig.toml", () => { const key = positive === "ignore-pattern" ? "ignorePatterns" : positive.replace(/-(\w)/g, (_, it) => it.toUpperCase()); // Of a type that no key takes: one that exists says what it expects. - const { stderr } = await run({ "bunfig.toml": `[format]\n${key} = [[]]\n` }, ["--list-files"]); + const { stderr } = await run({ "bunfig.toml": `[fmt]\n${key} = [[]]\n` }, ["--list-files"]); return [flag, /expected [\w ]+ but received array|cannot be set in bunfig\.toml/.test(stderr)]; }), ); diff --git a/test/cli/format/glob.test.ts b/test/cli/format/glob.test.ts index a73a9670ecce..2123534ad327 100644 --- a/test/cli/format/glob.test.ts +++ b/test/cli/format/glob.test.ts @@ -10,7 +10,7 @@ const ugly = "if (a) {\nb( 1 )\n}\n"; async function format(files: Record, args: string[], stdin?: string) { using dir = tempDir("bun-format-glob", files); await using proc = spawn({ - cmd: [bunExe(), "format", ...args], + cmd: [bunExe(), "fmt", ...args], env, cwd: String(dir), stdin: stdin === undefined ? "ignore" : Buffer.from(stdin), diff --git a/test/cli/format/line-endings.cases.ts b/test/cli/format/line-endings.cases.ts index 342c61d9c31f..b1e9d8a819c6 100644 --- a/test/cli/format/line-endings.cases.ts +++ b/test/cli/format/line-endings.cases.ts @@ -1,4 +1,4 @@ -// Small inputs in every language that `bun format` reads, with line breaks wherever they mean something: in comments, in +// Small inputs in every language that `bun fmt` reads, with line breaks wherever they mean something: in comments, in // `prettier-ignore`d code, in strings, templates and block scalars, in embedded code, between things that keep an empty line. // They are written with \n. `format.test.ts` gives them other line breaks and a byte order mark. export const inputs: Record = { diff --git a/test/cli/format/oracle/fuzz/README.md b/test/cli/format/oracle/fuzz/README.md index f17738b2a3c5..e9ce0b801ac6 100644 --- a/test/cli/format/oracle/fuzz/README.md +++ b/test/cli/format/oracle/fuzz/README.md @@ -1,8 +1,8 @@ # Fuzzers -They run `bun format` on texts that they make, and ask the npm package of Prettier about each. Neither runs in CI: they need Prettier. +They run `bun fmt` on texts that they make, and ask the npm package of Prettier about each. Neither runs in CI: they need Prettier. | file | what it makes | | --- | --- | -| `handlebars/damage.ts` | Handlebars: the templates whose syntax tree, as Prettier compares trees in its own tests, is another one after formatting, and of which `bun format` does not say that it would damage them | -| `near.ts` | every language: Prettier's fixtures, or real files (`--files`), each a little changed: a token deleted, doubled, swapped with the next, or the text cut off: a file while it is typed. It runs `bun format --write` itself, with its checks, and asks Prettier, or oxfmt (`--oxfmt`), with the defaults or a set of options (`--options`). Two tables: how many have the same bytes, and which check refused what the other tool prints, rightly or not. `bun near.ts --command="bun format" --prettier= --language=js` (`css`, `yaml`, `graphql`, `json`, `handlebars`, `markdown`, `html`) | +| `handlebars/damage.ts` | Handlebars: the templates whose syntax tree, as Prettier compares trees in its own tests, is another one after formatting, and of which `bun fmt` does not say that it would damage them | +| `near.ts` | every language: Prettier's fixtures, or real files (`--files`), each a little changed: a token deleted, doubled, swapped with the next, or the text cut off: a file while it is typed. It runs `bun fmt --write` itself, with its checks, and asks Prettier, or oxfmt (`--oxfmt`), with the defaults or a set of options (`--options`). Two tables: how many have the same bytes, and which check refused what the other tool prints, rightly or not. `bun near.ts --command="bun fmt" --prettier= --language=js` (`css`, `yaml`, `graphql`, `json`, `handlebars`, `markdown`, `html`) | diff --git a/test/cli/format/oracle/fuzz/handlebars/damage.ts b/test/cli/format/oracle/fuzz/handlebars/damage.ts index 43a0eb335cd6..5f98130eb2cb 100644 --- a/test/cli/format/oracle/fuzz/handlebars/damage.ts +++ b/test/cli/format/oracle/fuzz/handlebars/damage.ts @@ -1,8 +1,8 @@ -// Prettier changes what some templates mean. `bun format` prints the same, but says so and leaves the file alone. This looks for templates +// Prettier changes what some templates mean. `bun fmt` prints the same, but says so and leaves the file alone. This looks for templates // that it does not say it of: those whose syntax tree, as Prettier compares trees in its own tests, is another one after formatting, or that // cannot be parsed any more. // -// bun damage.ts "bun format" [-show=10] [-told] +// bun damage.ts "bun fmt" [-show=10] [-told] // // `-told`: also prints the templates that it says it of although the trees are the same: what the parsers drop is in neither tree. import { readFileSync, readdirSync } from "node:fs"; diff --git a/test/cli/format/oracle/fuzz/near.ts b/test/cli/format/oracle/fuzz/near.ts index 0393835d164f..276f01a89a09 100644 --- a/test/cli/format/oracle/fuzz/near.ts +++ b/test/cli/format/oracle/fuzz/near.ts @@ -1,8 +1,8 @@ // Texts that are nearly normal: Prettier's fixtures, or real files, each a little changed: a token deleted, two neighbours deleted, a token -// doubled, swapped with the next, or the text cut off after it. That is what a file looks like while it is typed. `bun format --write` runs on +// doubled, swapped with the next, or the text cut off after it. That is what a file looks like while it is typed. `bun fmt --write` runs on // them, with the checks that it makes before it writes, and the npm package of Prettier, or oxfmt, is asked about each. // -// bun near.ts --command="bun format" --prettier= --language=css +// bun near.ts --command="bun fmt" --prettier= --language=css // [--oxfmt=] the flavor of oxfmt, against oxfmt // [--options] each text with one of SETS in the place of the defaults // [--files=] real files in the place of the fixtures, one change each. More than once: more directories @@ -14,7 +14,7 @@ // Languages: js (with jsx, ts, tsx), css (with less, scss), yaml, graphql, json, handlebars, markdown (with mdx), html (with vue, angular, mjml). // Nothing is stored: what is expected is asked when it is run. The same seed makes the same texts. // -// It prints two tables. The second is about refusals: `bun format` leaves a file alone if what it has printed does not say what the file says. +// It prints two tables. The second is about refusals: `bun fmt` leaves a file alone if what it has printed does not say what the file says. // Where the other tool prints something, that is a true alarm if the other tool has lost or added a letter or a digit (in HTML and Handlebars also // a `<` or a `{`), and a false one if not. import { spawnSync } from "node:child_process"; @@ -82,7 +82,7 @@ if (process.argv[2] == "--child") { const endings = ENDINGS[language]; if (!endings || !flags.has("command") || !(flags.has("prettier") || isOxfmt)) { throw new Error( - `usage: bun near.ts --command="bun format" --prettier= --language=<${Object.keys(ENDINGS).join("|")}> ..`, + `usage: bun near.ts --command="bun fmt" --prettier= --language=<${Object.keys(ENDINGS).join("|")}> ..`, ); } diff --git a/test/cli/format/oracle/sort-imports/make-fixtures.mjs b/test/cli/format/oracle/sort-imports/make-fixtures.mjs index 2d1251315ca1..ecbd193ab792 100644 --- a/test/cli/format/oracle/sort-imports/make-fixtures.mjs +++ b/test/cli/format/oracle/sort-imports/make-fixtures.mjs @@ -1,10 +1,10 @@ // Makes test/cli/format/sort-imports/*.json of the cases that fixtures.mjs, fuzz.mjs and oxfmt.mjs make. // -// node make-fixtures.mjs --out= --sample=150 [--bin="bun format"] +// node make-fixtures.mjs --out= --sample=150 [--bin="bun fmt"] // // All cases of the tools' own tests are taken, and of the others (`fuzz/..`) every n-th, so that `--sample` are left of -// each file. Left out: what the tool itself fails on, syntax that bun format does not read, and, of the cases for oxfmt, -// those that bun format and oxfmt format differently without sorting imports (`plain`): they are not about imports. +// each file. Left out: what the tool itself fails on, syntax that bun fmt does not read, and, of the cases for oxfmt, +// those that bun fmt and oxfmt format differently without sorting imports (`plain`): they are not about imports. import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; @@ -28,7 +28,7 @@ for (const file of positional) { { ...it, options, output: it.plain }, { ...it, options, input: it.output }, ]); - const [bin, ...args] = (named.bin ?? "bun format").split(" "); + const [bin, ...args] = (named.bin ?? "bun fmt").split(" "); const differs = new Set(); // One run for all the cases with the same options. for (const [options, group] of Map.groupBy(plain, it => JSON.stringify(it.options))) { @@ -48,7 +48,7 @@ for (const file of positional) { for (const [index, it] of cases.entries()) { if (index % step !== 0 || UNSUPPORTED.some(pattern => pattern.test(it.name)) || /\bassert\s*\{/.test(it.input) || REFUSED.test(it.input)) continue; const { parser, flavor, ...options } = it.options; - // `bun format` refuses the syntax of TypeScript in a JavaScript file, as Prettier does, unless such a parser is asked for. + // `bun fmt` refuses the syntax of TypeScript in a JavaScript file, as Prettier does, unless such a parser is asked for. if (/\.[cm]?jsx?$/.test(it.filename) && ["typescript", "babel-ts", "flow", "babel-flow"].includes(parser) && /^tests\/(Angular|Typescript)/.test(it.name)) { options.overrides = [{ files: "*.js", options: { parser } }]; } diff --git a/test/cli/format/oracle/sort-imports/oxfmt.mjs b/test/cli/format/oracle/sort-imports/oxfmt.mjs index daa8ca27254d..da4811f9600d 100644 --- a/test/cli/format/oracle/sort-imports/oxfmt.mjs +++ b/test/cli/format/oracle/sort-imports/oxfmt.mjs @@ -5,7 +5,7 @@ // // - `tests`: every `assert_format(input, config, expected)` of crates/oxc_formatter/tests/ir_transform/sort_imports/*.rs. // - `tokens`, `lines`: as in fuzz.mjs. -// A case also has `plain`: what oxfmt prints without `sortImports`. Where bun format and oxfmt differ on that, the +// A case also has `plain`: what oxfmt prints without `sortImports`. Where bun fmt and oxfmt differ on that, the // difference is not about imports. import fs from "node:fs"; import { createRequire } from "node:module"; diff --git a/test/cli/format/own/README.md b/test/cli/format/own/README.md index b9cedef67092..8d11251dba0a 100644 --- a/test/cli/format/own/README.md +++ b/test/cli/format/own/README.md @@ -1,9 +1,9 @@ # Inputs of our own -Small programs on which `bun format` once differed from Prettier, and which no test of Prettier or of oxfmt has: most were found in real code. They are in the form of oxfmt's tests (`../oxfmt/README.md`), as plain files: +Small programs on which `bun fmt` once differed from Prettier, and which no test of Prettier or of oxfmt has: most were found in real code. They are in the form of oxfmt's tests (`../oxfmt/README.md`), as plain files: - `cases/**/.input`: the input. `` says what language it is. `.input` keeps everything else from taking it for code. -- `.prettier.snap`: what Prettier prints, which is what `bun format` has to print. +- `.prettier.snap`: what Prettier prints, which is what `bun fmt` has to print. - `.snap`: what oxfmt prints, which is what it has to print for whoever has an `.oxfmtrc.json`. Where it does not yet, the file is called `.snap.todo` and is not compared. - `options.json`: the sets of options for the inputs of a directory. Each is run at `printWidth` 80 and 100 too. @@ -13,4 +13,4 @@ To add one, write the input and run `sync.ts`, which asks both: bun test/cli/format/own/sync.ts ``` -**Nothing here fails**, so there is no list of what is expected to: `../conformance.test.ts` wants no line that starts with `FAIL`. An input comes in the commit that makes `bun format` print what Prettier prints for it. If the runner says `FAIL oxfmt` for it, rename its `.snap` to `.snap.todo`. +**Nothing here fails**, so there is no list of what is expected to: `../conformance.test.ts` wants no line that starts with `FAIL`. An input comes in the commit that makes `bun fmt` print what Prettier prints for it. If the runner says `FAIL oxfmt` for it, rename its `.snap` to `.snap.todo`. diff --git a/test/cli/format/own/sync.ts b/test/cli/format/own/sync.ts index f82c099893cc..ffea350ce708 100644 --- a/test/cli/format/own/sync.ts +++ b/test/cli/format/own/sync.ts @@ -34,7 +34,7 @@ for (const [path, bytes] of files) { rmSync(directory, { recursive: true }); } writeFileSync(join(import.meta.dir, `${name}.prettier.snap`), render(asPrettier)); - // `.todo`: `bun format` does not print that yet, and it is not compared. + // `.todo`: `bun fmt` does not print that yet, and it is not compared. const snapshot = join(import.meta.dir, `${name}.snap`); if (asOxfmt.length > 0) writeFileSync(existsSync(`${snapshot}.todo`) ? `${snapshot}.todo` : snapshot, render(asOxfmt)); } diff --git a/test/cli/format/oxfmt-cli/oxfmt-cli.test.ts b/test/cli/format/oxfmt-cli/oxfmt-cli.test.ts index 975fc0bda7d0..07b3ebc23619 100644 --- a/test/cli/format/oxfmt-cli/oxfmt-cli.test.ts +++ b/test/cli/format/oxfmt-cli/oxfmt-cli.test.ts @@ -11,17 +11,17 @@ import { cases, fixtures } from "./cases.json"; afterAll(endChildren); type Files = Record; -// A fixture has `experimentalTailwindcss`, which has no effect here. That `bun format` says so is tested elsewhere. -const command = [bunExe(), "format", "--allow-unsupported"]; +// A fixture has `experimentalTailwindcss`, which has no effect here. That `bun fmt` says so is tested elsewhere. +const command = [bunExe(), "fmt", "--allow-unsupported"]; const env = { NO_COLOR: "1", AGENT: "0", CLAUDECODE: undefined }; -describe.concurrent("bun format does what oxfmt does", () => { +describe.concurrent("bun fmt does what oxfmt does", () => { for (const it of cases) { const files = { ...(fixtures as Record)[it.name], ...it.gitignore } as Files; const links = Object.entries(files).filter(([, file]) => typeof file !== "string") as [string, { link: string }][]; test.skipIf(isWindows && links.length > 0)(`${it.name}/${it.index}: ${it.args.join(" ")}`, async () => { const texts = Object.entries(files).filter(([, file]) => typeof file === "string") as [string, string][]; - // Without a configuration file `bun format` is like Prettier. An empty one above the fixtures makes it like oxfmt. + // Without a configuration file `bun fmt` is like Prettier. An empty one above the fixtures makes it like oxfmt. using dir = tempDir("oxfmt-cli", { ".oxfmtrc.json": "{}\n", ...Object.fromEntries(texts.map(([name, text]) => [`fixtures/${name}`, text])), diff --git a/test/cli/format/oxfmt/README.md b/test/cli/format/oxfmt/README.md index c31262112df8..67d1abcf052a 100644 --- a/test/cli/format/oxfmt/README.md +++ b/test/cli/format/oxfmt/README.md @@ -10,7 +10,7 @@ which Prettier. oxc is under the MIT license: `LICENSE`. Every input is formatted with each set of options of the nearest `options.json`, at `printWidth` 80 and 100, as oxc's test harness does. -- `.prettier.snap` is what `bun format` has to print. `oxfmt-ignore` says nothing to Prettier, and nothing to `bun format` in its flavor. +- `.prettier.snap` is what `bun fmt` has to print. `oxfmt-ignore` says nothing to Prettier, and nothing to `bun fmt` in its flavor. - `.snap` is what it has to print for whoever has an `.oxfmtrc.json` (`flavor: oxfmt`). ```sh diff --git a/test/cli/format/prettier-cli/prettier-cli.test.ts b/test/cli/format/prettier-cli/prettier-cli.test.ts index 8fd061347818..7247a9c80e7b 100644 --- a/test/cli/format/prettier-cli/prettier-cli.test.ts +++ b/test/cli/format/prettier-cli/prettier-cli.test.ts @@ -1,6 +1,6 @@ // The command lines of Prettier's own integration tests (`runCli("cli/..", [..])` in `tests/integration/__tests__/*.js` of // prettier/prettier, MIT: see LICENSE), with what Prettier 3.9 does in each: the exit code, the files afterwards, the files that -// `--check` and `--list-different` name, and what is printed for standard input. `bun format` writes unless told otherwise, so +// `--check` and `--list-different` name, and what is printed for standard input. `bun fmt` writes unless told otherwise, so // Prettier was given `--write` where the command line only prints. // `cases.json` is written by `test/cli/lint/oracle/driver/prettier-fixtures.mjs --record`. import { afterAll, describe, expect, test } from "bun:test"; @@ -13,12 +13,12 @@ import { cases, fixtures } from "./cases.json"; afterAll(endChildren); type Files = Record; -const command = [bunExe(), "format"]; -// The languages that `bun format` leaves alone. +const command = [bunExe(), "fmt"]; +// The languages that `bun fmt` leaves alone. const notCompared = /\.mdx$/; const env = { ...bunEnv, NO_COLOR: "1", AGENT: "0", CLAUDECODE: undefined }; -describe.concurrent("bun format does what Prettier does", () => { +describe.concurrent("bun fmt does what Prettier does", () => { for (const it of cases) { const top = it.directory.split("/").slice(0, 2).join("/"); const files = (fixtures as Record)[top]; diff --git a/test/cli/format/prettier/README.md b/test/cli/format/prettier/README.md index 519cbc3b793b..447fe4d68208 100644 --- a/test/cli/format/prettier/README.md +++ b/test/cli/format/prettier/README.md @@ -1,6 +1,6 @@ # Prettier's tests -`bun format` prints what [Prettier](https://github.com/prettier/prettier) prints. `bundle.zst` holds `tests/format/{js,jsx,typescript,flow,json,css,less,scss,graphql,yaml,markdown,mdx,handlebars,html,vue,angular,lwc,mjml,misc}` of Prettier: all 19 directories, unchanged: the inputs, and the snapshots +`bun fmt` prints what [Prettier](https://github.com/prettier/prettier) prints. `bundle.zst` holds `tests/format/{js,jsx,typescript,flow,json,css,less,scss,graphql,yaml,markdown,mdx,handlebars,html,vue,angular,lwc,mjml,misc}` of Prettier: all 19 directories, unchanged: the inputs, and the snapshots (`__snapshots__/format.test.js.snap`), which also say with which options each input is formatted. One file after the other, compressed with zstd: see `../bundle.ts`. `sync.ts` writes it, and `version.json` says from which commit. Prettier is under the MIT license: `LICENSE`. diff --git a/test/cli/format/sort-imports.test.ts b/test/cli/format/sort-imports.test.ts index b53376d8adfa..2e57c1a7b64f 100644 --- a/test/cli/format/sort-imports.test.ts +++ b/test/cli/format/sort-imports.test.ts @@ -1,4 +1,4 @@ -// Tests for `sortImports` in bunfig.toml's `[format]` and for `--sort-imports`. On top of oxfmt's `sortImports`, they sort the +// Tests for `sortImports` in bunfig.toml's `[fmt]` and for `--sort-imports`. On top of oxfmt's `sortImports`, they sort the // names inside braces and merge imports from the same module. Neither has an option. import { afterAll, describe, expect, test } from "bun:test"; import { bunEnv, bunExe, tempDir } from "harness"; @@ -10,7 +10,7 @@ afterAll(endChildren); async function run(cwd: string, args: string[]) { await using proc = spawn({ - cmd: [bunExe(), "format", "--log-level=warn", ...args], + cmd: [bunExe(), "fmt", "--log-level=warn", ...args], env: bunEnv, cwd, stdout: "pipe", @@ -33,7 +33,7 @@ async function format(files: Record, args: string[] = []) { return { ...first, files: after, second: { ...second, files: read() } }; } -const bunfig = { "bunfig.toml": "[format]\nsortImports = true\n" }; +const bunfig = { "bunfig.toml": "[fmt]\nsortImports = true\n" }; const cases: [name: string, input: string, output: string][] = [ [ @@ -748,7 +748,7 @@ describe.concurrent("sortImports sources", () => { test("the key, the table and the flag all sort names and merge", async () => { const [key, table, flag] = await Promise.all([ format({ ...bunfig, "a.ts": input }), - format({ "bunfig.toml": "[format.sortImports]\nnewlinesBetween = false\n", "a.ts": input }), + format({ "bunfig.toml": "[fmt.sortImports]\nnewlinesBetween = false\n", "a.ts": input }), format({ "a.ts": input }, ["--sort-imports"]), ]); expect([key.files["a.ts"], table.files["a.ts"], flag.files["a.ts"]]).toEqual([tidy, tidy, tidy]); @@ -762,7 +762,7 @@ describe.concurrent("sortImports sources", () => { }); test("sortSideEffects = true lets side-effect imports move", async () => { - const result = await format({ "bunfig.toml": "[format.sortImports]\nsortSideEffects = true\n", "a.ts": input }); + const result = await format({ "bunfig.toml": "[fmt.sortImports]\nsortSideEffects = true\n", "a.ts": input }); expect(result.files["a.ts"]).toBe( `import { d } from "a";\nimport { c } from "x";\nimport { a, b } from "x";\n\nimport "./setup";\n`, ); diff --git a/test/cli/format/sort-imports/sort-imports.test.ts b/test/cli/format/sort-imports/sort-imports.test.ts index 07110108afac..b4ccc0f12f84 100644 --- a/test/cli/format/sort-imports/sort-imports.test.ts +++ b/test/cli/format/sort-imports/sort-imports.test.ts @@ -26,7 +26,7 @@ const tools = { async function format(files: Record, names: string[], flags: string[] = []) { using dir = tempDir("bun-format-sort-imports", files); await using proc = spawn({ - cmd: [bunExe(), "format", "--log-level=warn", ...flags, "."], + cmd: [bunExe(), "fmt", "--log-level=warn", ...flags, "."], env: bunEnv, cwd: String(dir), stdout: "pipe", diff --git a/test/cli/format/tailwind/tailwind.test.ts b/test/cli/format/tailwind/tailwind.test.ts index abffb5041af3..f2c36a411611 100644 --- a/test/cli/format/tailwind/tailwind.test.ts +++ b/test/cli/format/tailwind/tailwind.test.ts @@ -41,7 +41,7 @@ exports.createContext = config => { async function formatIn(cwd: string, names: string[], flags: string[] = ["."], stdin?: string) { await using proc = spawn({ - cmd: [bunExe(), "format", "--log-level=warn", ...flags], + cmd: [bunExe(), "fmt", "--log-level=warn", ...flags], env: bunEnv, cwd, stdin: stdin === undefined ? "ignore" : Buffer.from(stdin), @@ -95,7 +95,7 @@ if (now.join() !== before.join()) throw new Error("There is " + now.filter(it => }); const before = listed(String(dir)); await using proc = spawn({ - cmd: [bunExe(), "format", "--log-level=warn", "."], + cmd: [bunExe(), "fmt", "--log-level=warn", "."], env: { ...bunEnv, FILES_OF_THE_TEST: JSON.stringify(before) }, cwd: String(dir), stdout: "pipe", @@ -236,7 +236,7 @@ if (now.join() !== before.join()) throw new Error("There is " + now.filter(it => expect(Object.fromEntries(Object.keys(files).map(name => [name, read(name)]))).toEqual(expected); await using proc = spawn({ - cmd: [bunExe(), "format", "--stdin-filepath", "a.jsx"], + cmd: [bunExe(), "fmt", "--stdin-filepath", "a.jsx"], env: bunEnv, cwd: String(dir), stdin: Buffer.from(files["latin1.jsx"], "latin1"), diff --git a/test/cli/format/toml-layout.test.ts b/test/cli/format/toml-layout.test.ts index de85e0454a46..2b7144ca81d3 100644 --- a/test/cli/format/toml-layout.test.ts +++ b/test/cli/format/toml-layout.test.ts @@ -9,7 +9,7 @@ afterAll(endChildren); async function run(cwd: string, args: string[]) { await using proc = spawn({ - cmd: [bunExe(), "format", "--log-level=warn", ...args], + cmd: [bunExe(), "fmt", "--log-level=warn", ...args], env: { ...bunEnv, NO_COLOR: "1" }, cwd, stdout: "pipe", @@ -477,7 +477,7 @@ describe("TOML layout", () => { test("respects trailingComma, useTabs and endOfLine in a multi-line table", async () => { using dir = tempDir("bun-format-toml", { - "bunfig.toml": `[format]\ntrailingComma = "none"\nuseTabs = true\nendOfLine = "crlf"\n`, + "bunfig.toml": `[fmt]\ntrailingComma = "none"\nuseTabs = true\nendOfLine = "crlf"\n`, "a.toml": `a = ["x", {\n b = 1, # c\n d = 2,\n}]\n`, }); const result = await run(String(dir), ["a.toml"]); @@ -486,33 +486,45 @@ describe("TOML layout", () => { }); }); -describe.concurrent("a table with a comment that oxfmt joins", () => { - // oxfmt 0.72 joins these lines, which comments out the second entry. +describe.concurrent("a table with a comment", () => { + // oxfmt 0.72 joins these lines, which comments out the second entry: `{ b = 1 # c, d = 2 },`. const commented = `a = ["x", {\n b = 1, # c\n d = 2,\n}]\n`; - const joined = `a = [\n "x",\n { b = 1 # c, d = 2 },\n]\n`; + const expanded = `a = [\n "x",\n {\n b = 1, # c\n d = 2,\n },\n]\n`; - test("with an oxfmt config Bun writes what oxfmt writes", async () => { - using dir = tempDir("bun-format-toml", { ".oxfmtrc.json": "{}\n", "a.toml": commented }); + test("with an oxfmt config it has one entry per line", async () => { + using dir = tempDir("bun-format-toml", { ".oxfmtrc.json": "{}\n", "a.toml": commented, "b.toml": expanded }); const result = await run(String(dir), ["."]); expect(result.stderr).toBe(""); - expect(readFileSync(join(String(dir), "a.toml"), "utf8")).toBe(joined); + expect(readFileSync(join(String(dir), "a.toml"), "utf8")).toBe(expanded); + expect(readFileSync(join(String(dir), "b.toml"), "utf8")).toBe(expanded); expect(result.exitCode).toBe(0); }); - test.each([["--check"], ["--list-different"]])("%s says that it needs formatting", async flag => { - using dir = tempDir("bun-format-toml", { ".oxfmtrc.json": "{}\n", "a.toml": commented }); - const result = await run(String(dir), [flag, "."]); + // The table, what Bun makes of it, and what oxfmt 0.72 makes of it. + test.each([ + ["a = { # c\n b = 1 }\n", "a = { # c\n b = 1,\n}\n", "a = { # c b = 1 }\n"], + ["a = { b = 1 # c\n, d = 2 }\n", "a = {\n b = 1, # c\n d = 2,\n}\n", "a = { b = 1 # c, d = 2 }\n"], + ["a = { b = 1,\n # c\n d = 2 }\n", "a = {\n b = 1,\n # c\n d = 2,\n}\n", "a = { b = 1 # c, d = 2 }\n"], + ["a = { b = 1, d = 2 # c\n }\n", "a = {\n b = 1,\n d = 2, # c\n}\n", "a = { b = 1, d = 2 # c }\n"], + ])("wherever the comment is: %j", async (before, after, ofOxfmt) => { + expect(after).not.toBe(ofOxfmt); + using dir = tempDir("bun-format-toml", { ".oxfmtrc.json": "{}\n", "a.toml": before, "b.toml": after }); + const checked = await run(String(dir), ["--debug-check", "."]); + expect(checked.stderr).toBe(""); + expect(checked.exitCode).toBe(0); + const result = await run(String(dir), ["."]); expect(result.stderr).toBe(""); - expect(readFileSync(join(String(dir), "a.toml"), "utf8")).toBe(commented); - expect(result.exitCode).toBe(1); + expect(readFileSync(join(String(dir), "a.toml"), "utf8")).toBe(after); + expect(readFileSync(join(String(dir), "b.toml"), "utf8")).toBe(after); + expect(result.exitCode).toBe(0); }); - test("--debug-check reports it and writes nothing", async () => { + test("--debug-check has nothing to say about it", async () => { using dir = tempDir("bun-format-toml", { ".oxfmtrc.json": "{}\n", "a.toml": commented }); const result = await run(String(dir), ["--debug-check", "."]); - expect(result.stderr).toContain("a.toml: formatting would change its contents."); + expect(result.stderr).toBe(""); expect(readFileSync(join(String(dir), "a.toml"), "utf8")).toBe(commented); - expect(result.exitCode).toBe(2); + expect(result.exitCode).toBe(0); }); test("without an oxfmt config the same file is already formatted", async () => { diff --git a/test/cli/format/toml.test.ts b/test/cli/format/toml.test.ts index 0fe3efc1cebc..52c595af1387 100644 --- a/test/cli/format/toml.test.ts +++ b/test/cli/format/toml.test.ts @@ -10,7 +10,7 @@ afterAll(endChildren); async function format(files: Record, config: object, args: string[] = []) { using dir = tempDir("bun-format-toml", { ...files, ".oxfmtrc.json": JSON.stringify(config) }); await using proc = spawn({ - cmd: [bunExe(), "format", ...args], + cmd: [bunExe(), "fmt", ...args], env: { ...bunEnv, NO_COLOR: "1" }, cwd: String(dir), stdout: "pipe", diff --git a/test/cli/format/words.test.ts b/test/cli/format/words.test.ts index 4f0413014837..86a88a262a0c 100644 --- a/test/cli/format/words.test.ts +++ b/test/cli/format/words.test.ts @@ -1,4 +1,4 @@ -// Tests for what `bun format` prints when there's no Prettier or oxfmt config. +// Tests for what `bun fmt` prints when there's no Prettier or oxfmt config. import { afterAll, describe, expect, test } from "bun:test"; import { bunEnv, bunExe, tempDir } from "harness"; import { readFileSync } from "node:fs"; @@ -14,7 +14,7 @@ const formatted = "a;\n"; async function format(files: Record, args: string[], more: Record = {}) { using dir = tempDir("bun-format-words", files); await using proc = spawn({ - cmd: [bunExe(), "format", ...args], + cmd: [bunExe(), "fmt", ...args], env: { ...env, ...more }, cwd: String(dir), stdout: "pipe", @@ -25,12 +25,12 @@ async function format(files: Record, args: string[], more: Recor return { stdout, stderr: stderr.replace(/ \[\d+(\.\d+)?m?s\]/g, ""), exitCode, files: Object.fromEntries(after) }; } -describe.concurrent("bun format --check", () => { +describe.concurrent("bun fmt --check", () => { test("lists the files that need formatting, writes nothing and exits with 1", async () => { const files = { "a.js": ugly, "b.js": formatted, "src/c.ts": ugly, "d.json": "{}\n" }; expect(await format(files, ["--check"])).toEqual({ stdout: "a.js\nsrc/c.ts\n", - stderr: "2 files need formatting. Run bun format to fix them.\n", + stderr: "2 files need formatting. Run bun fmt to fix them.\n", exitCode: 1, files, }); @@ -40,7 +40,7 @@ describe.concurrent("bun format --check", () => { const files = { "a.js": ugly, "b.js": formatted }; expect(await format(files, ["--check"])).toEqual({ stdout: "a.js\n", - stderr: "1 file needs formatting. Run bun format to fix it.\n", + stderr: "1 file needs formatting. Run bun fmt to fix it.\n", exitCode: 1, files, }); @@ -64,20 +64,20 @@ describe.concurrent("bun format --check", () => { expect(stderr).toStartWith("error: broken.js: SyntaxError: "); expect(stderr).toContain("\n> 1 | const = 1;\n"); expect(stderr).not.toContain("[error]"); - expect(stderr).toEndWith("\n1 file needs formatting. Run bun format to fix it.\nCould not check 1 file.\n"); + expect(stderr).toEndWith("\n1 file needs formatting. Run bun fmt to fix it.\nCould not check 1 file.\n"); expect(exitCode).toBe(2); }); - test("[format] in bunfig.toml and an .editorconfig don't change the wording", async () => { + test("[fmt] in bunfig.toml and an .editorconfig don't change the wording", async () => { const files = { "a.js": "a;\n", "b.js": "if (a) {\n b\n}\n", - "bunfig.toml": "[format]\nsemi = false\n", + "bunfig.toml": "[fmt]\nsemi = false\n", ".editorconfig": "[*]\nindent_size = 4\n", }; expect(await format(files, ["--check", "a.js", "b.js"])).toMatchObject({ stdout: "a.js\n", - stderr: "1 file needs formatting. Run bun format to fix it.\n", + stderr: "1 file needs formatting. Run bun fmt to fix it.\n", exitCode: 1, }); }); @@ -91,7 +91,7 @@ describe.concurrent("bun format --check", () => { expect(error).toMatchObject({ stdout: "", stderr: "", exitCode: 1 }); expect(warn).toMatchObject({ stdout: "a.js\n", - stderr: "1 file needs formatting. Run bun format to fix it.\n", + stderr: "1 file needs formatting. Run bun fmt to fix it.\n", exitCode: 1, }); }); @@ -119,7 +119,7 @@ describe.concurrent("bun format --check", () => { ]); expect(different).toMatchObject({ stdout: "Checking formatting...\n", - stderr: "[warn] a.js\n[warn] b.js\n[warn] Code style issues found in 2 files. Run bun format to fix.\n", + stderr: "[warn] a.js\n[warn] b.js\n[warn] Code style issues found in 2 files. Run bun fmt to fix.\n", exitCode: 1, }); expect(same).toMatchObject({ @@ -130,7 +130,7 @@ describe.concurrent("bun format --check", () => { }); }); -describe.concurrent("bun format", () => { +describe.concurrent("bun fmt", () => { test("errors start with Bun's `error:`, or Prettier's `[error]` with a .prettierrc", async () => { const [missing, ofPrettier] = await Promise.all([ format({ "a.js": formatted }, ["nothing.js"]), @@ -149,9 +149,9 @@ describe.concurrent("bun format", () => { format({ "a.js": formatted }, [...flags, "--allow-unsupported"]), format({ "a.js": formatted, ".prettierrc": "{}\n" }, flags), ]); - expect(refused.stderr).toStartWith("error: bun format does not include the plugin prettier-plugin-x\n"); + expect(refused.stderr).toStartWith("error: bun fmt does not include the plugin prettier-plugin-x\n"); expect(allowed.stderr).toStartWith("warn: --plugin has no effect "); - expect(ofPrettier.stderr).toStartWith("[error] bun format does not include the plugin prettier-plugin-x\n"); + expect(ofPrettier.stderr).toStartWith("[error] bun fmt does not include the plugin prettier-plugin-x\n"); expect([refused.exitCode, allowed.exitCode, ofPrettier.exitCode]).toEqual([2, 0, 2]); }); diff --git a/test/cli/init/init.test.ts b/test/cli/init/init.test.ts index 291a6fc55a99..afb800eb01b8 100644 --- a/test/cli/init/init.test.ts +++ b/test/cli/init/init.test.ts @@ -61,7 +61,7 @@ const initEnv = { ...bunEnv, BUN_AGENT_RULE_DISABLED: "1" }; expect(readme).toInclude("index.ts"); expect(readme).toInclude("bun check"); expect(readme).toInclude("`bun lint`"); - expect(readme).toInclude("`bun format`"); + expect(readme).toInclude("`bun fmt`"); expect(fs.existsSync(path.join(temp, "index.ts"))).toBe(true); expect(fs.existsSync(path.join(temp, ".gitignore"))).toBe(true); @@ -497,7 +497,7 @@ const initEnv = { ...bunEnv, BUN_AGENT_RULE_DISABLED: "1" }; ]); expect({ checkStdout, checkStderr, checkExited }).toMatchObject({ checkStdout: "", checkExited: 0 }); - // `bun lint` reports nothing, not even a warning, and `bun format` has nothing to change. + // `bun lint` reports nothing, not even a warning, and `bun fmt` has nothing to change. await using lint = Bun.spawn({ cmd: [bunExe(), "lint", "-f", "json"], cwd: temp, @@ -513,7 +513,7 @@ const initEnv = { ...bunEnv, BUN_AGENT_RULE_DISABLED: "1" }; expect({ problems, lintStderr, lintExited }).toMatchObject({ problems: [], lintExited: 0 }); await using format = Bun.spawn({ - cmd: [bunExe(), "format", "--check"], + cmd: [bunExe(), "fmt", "--check"], cwd: temp, stdio: ["ignore", "pipe", "pipe"], env: bunEnv, @@ -547,7 +547,7 @@ const initEnv = { ...bunEnv, BUN_AGENT_RULE_DISABLED: "1" }; ); test.skipIf(isWindows)( - "`bun format --check` passes on the generated CLAUDE.md", + "`bun fmt --check` passes on the generated CLAUDE.md", async () => { await using temp = tempDir("bun-init-claude-md", { "bin/claude": "#!/bin/sh\n" }); fs.chmodSync(path.join(temp, "bin/claude"), 0o755); @@ -561,12 +561,12 @@ const initEnv = { ...bunEnv, BUN_AGENT_RULE_DISABLED: "1" }; expect(fs.readFileSync(path.join(temp, "app/CLAUDE.md"), "utf8")).toStartWith("Default to using Bun"); await using format = Bun.spawn({ - cmd: [bunExe(), "format", "--check", "CLAUDE.md"], + cmd: [bunExe(), "fmt", "--check", "CLAUDE.md"], cwd: path.join(temp, "app"), stdio: ["ignore", "pipe", "pipe"], env: bunEnv, }); - const [stdout, stderr, exitCode] = await Promise.all([format.stdout.text(), format.stderr.text(), format.exited]); + const [stdout, stderr, exitCode] = await Promise.all([fmt.stdout.text(), format.stderr.text(), format.exited]); expect({ stdout, stderr, exitCode }).toMatchObject({ exitCode: 0 }); }, 30_000, diff --git a/test/cli/lint/bunfig.test.ts b/test/cli/lint/bunfig.test.ts index 2c22186424e6..7a24d93489ec 100644 --- a/test/cli/lint/bunfig.test.ts +++ b/test/cli/lint/bunfig.test.ts @@ -379,8 +379,8 @@ describe.concurrent("[lint] in bunfig.toml", () => { expect(byFlag.stdout).toBe(plain.stdout); }); - test("bun/format compares a file with bun format's output, including [format] options", async () => { - const files = { "a.js": "a('b')\n", "bunfig.toml": "[format]\nsemi = false\nsingleQuote = true\n" }; + test("bun/format compares a file with bun fmt's output, including [fmt] options", async () => { + const files = { "a.js": "a('b')\n", "bunfig.toml": "[fmt]\nsemi = false\nsingleQuote = true\n" }; const rule = (...options: unknown[]) => ({ ".oxlintrc.json": JSON.stringify({ rules: { "bun/format": ["error", ...options] } }), }); diff --git a/test/cli/lint/config-files.test.ts b/test/cli/lint/config-files.test.ts index dacbc9ce6fa6..ff02c096ab27 100644 --- a/test/cli/lint/config-files.test.ts +++ b/test/cli/lint/config-files.test.ts @@ -4941,7 +4941,7 @@ describe.concurrent("whose configuration files count", () => { test.each([ [["-D", "no-vra"], `Unknown rule "no-vra"\n Did you mean "no-var"?`], [["-A", "no-vra"], `Unknown rule "no-vra"\n Did you mean "no-var"?`], - [["-W", "indent"], `"indent" is a formatting rule\n bun format does that`], + [["-W", "indent"], `"indent" is a formatting rule\n bun fmt does that`], ])("%j is rejected without a config file", async (flags, why) => { const { problems, stderr, exitCode } = await lint({ "a.js": code }, [...flags, "a.js"]); expect({ problems, stderr, exitCode }).toEqual({ problems: [], stderr: `error: ${why}`, exitCode: 2 }); @@ -5639,7 +5639,7 @@ describe.concurrent("what the configuration asks for and cannot be done", () => }); }); -// ESLint has deprecated its rules about layout, and eslint-plugin-react its `jsx-space-before-closing`. They are not built in: `bun format` +// ESLint has deprecated its rules about layout, and eslint-plugin-react its `jsx-space-before-closing`. They are not built in: `bun fmt` // does their work. The configurations that people share still turn them on. describe.concurrent("the deprecated rules about layout", () => { const rules = { diff --git a/test/cli/lint/doctor.test.ts b/test/cli/lint/doctor.test.ts index 8659b220260f..46c92ed2ceeb 100644 --- a/test/cli/lint/doctor.test.ts +++ b/test/cli/lint/doctor.test.ts @@ -114,7 +114,7 @@ describe.concurrent("bun lint --doctor", () => { eslint.config.js 1 2 rules do not run 1 is not supported: no-such-rule - 1 is a formatting rule, use bun format: indent + 1 is a formatting rule, use bun fmt: indent You can remove: eslint, eslint-plugin-never-imported Still needed: eslint-plugin-first, eslint-plugin-second (their rules run in JavaScript) @@ -239,7 +239,7 @@ Not checked: other tools' configs, CI files, Git hooks expect(text.stdout).toContain(` 8 rules do not run 4 are not supported: no-such-rule, nor-any, nor-that, nor-this - 4 are formatting rules, use bun format: comma-dangle, indent, quotes, .. + 4 are formatting rules, use bun fmt: comma-dangle, indent, quotes, .. `); expect(JSON.parse(json.stdout).rules.not_run).toContainEqual({ rule: "semi", reason: "formatting" }); expect(text.exitCode).toBe(0); diff --git a/test/cli/lint/git.test.ts b/test/cli/lint/git.test.ts index 234e0db9cc6d..b7dea1a2244b 100644 --- a/test/cli/lint/git.test.ts +++ b/test/cli/lint/git.test.ts @@ -1,4 +1,4 @@ -// Tests for `--staged` and `--changed` in `bun lint` and `bun format`. +// Tests for `--staged` and `--changed` in `bun lint` and `bun fmt`. import { afterAll, describe, expect, test } from "bun:test"; import { bunEnv, bunExe, isWindows, tempDir } from "harness"; import { @@ -82,12 +82,13 @@ async function selected(root: string, cwd: string, args: string[], more?: Env) { return names.map(it => relative(root, resolve(cwd, it)).replaceAll("\\", "/")).sort(); } -const commands = ["lint", "format"] as const; -// Code that both `--fix` and `bun format` change, and what each turns it into. +const commands = ["lint", "fmt"] as const; +// Code that both `--fix` and `bun fmt` change, and what each turns it into. const before = "let a = 1;\nexport { a };\n"; -const after = { lint: "const a = 1;\nexport { a };\n", format: "let a = 1;\nexport { a };\n" }; -const done = { lint: "fixed", format: "formatted" }; -const writing = { lint: ["lint", "--fix", "--rule", "prefer-const: error"], format: ["format"] }; +const after = { lint: "const a = 1;\nexport { a };\n", fmt: "let a = 1;\nexport { a };\n" }; +const done = { lint: "fixed", fmt: "formatted" }; +const verb = { lint: "lint", fmt: "format" }; +const writing = { lint: ["lint", "--fix", "--rule", "prefer-const: error"], fmt: ["fmt"] }; const tidy = "export const a = 1;\n"; describe.concurrent.each(commands)("bun %s", command => { @@ -227,19 +228,19 @@ describe.concurrent.each(commands)("bun %s", command => { const { root } = repo; expect(await bun(root, [command, "--staged"])).toEqual({ stdout: "", - stderr: `No staged files to ${command}\n`, + stderr: `No staged files to ${verb[command]}\n`, exitCode: 0, }); expect(await bun(root, [command, "--changed"])).toEqual({ stdout: "", - stderr: `No changed files to ${command}\n`, + stderr: `No changed files to ${verb[command]}\n`, exitCode: 0, }); // A file is staged, but the argument names a different one. write(root, "b.js", before); await git(root, "add", "b.js"); const { stderr, exitCode } = await bun(root, [command, "--staged", "a.js"]); - expect({ stderr, exitCode }).toEqual({ stderr: `No staged files to ${command}\n`, exitCode: 0 }); + expect({ stderr, exitCode }).toEqual({ stderr: `No staged files to ${verb[command]}\n`, exitCode: 0 }); expect(read(root, "a.js")).toBe(before); }); @@ -306,7 +307,7 @@ describe.concurrent.each(commands)("bun %s", command => { using repo = await repository({ "src/a.js": tidy, "src/partly.js": tidy, "b.js": tidy }); const { root } = repo; using outside = tempDir("bun-lint-git-link", {}); - // The link is above the repository. `bun format` doesn't take an argument that is itself a link, like Prettier. + // The link is above the repository. `bun fmt` doesn't take an argument that is itself a link, like Prettier. symlinkSync(join(root, ".."), join(String(outside), "link")); const link = join(realpathSync(String(outside)), "link", basename(root)); for (const name of ["src/a.js", "src/partly.js"]) write(root, name, before); @@ -402,7 +403,7 @@ describe.concurrent.each(commands)("bun %s", command => { expect(read(root, "sub/partly.js")).toBe(more); // A run that doesn't write files has nothing to reject. - const looking = command === "lint" ? ["lint", "--rule", "prefer-const: error"] : ["format", "--check"]; + const looking = command === "lint" ? ["lint", "--rule", "prefer-const: error"] : ["fmt", "--check"]; const three = await bun(root, [...looking, "--staged"]); expect(three.stderr).not.toContain("unstaged"); expect(three.exitCode).toBe(1); @@ -551,14 +552,14 @@ describe.concurrent("bun lint --staged --fix still reports the problems in a par }); }); -test.concurrent("bun format --staged doesn't count a file it skips", async () => { +test.concurrent("bun fmt --staged doesn't count a file it skips", async () => { using repo = await repository({ "partly.js": tidy, "whole.js": tidy, "tidy.js": tidy }); const { root } = repo; for (const name of ["partly.js", "whole.js"]) write(root, name, before); write(root, "tidy.js", tidy + "\n" + tidy.replace("a", "b")); await git(root, "add", "-A"); write(root, "partly.js", before + tidy.replace("a", "b")); - const { stdout, stderr, exitCode } = await bun(root, ["format", "--staged"]); + const { stdout, stderr, exitCode } = await bun(root, ["fmt", "--staged"]); expect(stdout).toBe("whole.js\n"); expect(stderr).toContain("Formatted 1 file, 1 unchanged "); expect(stderr).toContain("partly.js has both staged and unstaged changes, so it was not formatted\n"); diff --git a/test/cli/lint/help.test.ts b/test/cli/lint/help.test.ts index 4496f95f449f..16ca6353d64a 100644 --- a/test/cli/lint/help.test.ts +++ b/test/cli/lint/help.test.ts @@ -47,7 +47,7 @@ describe.concurrent.each([ ], }, { - command: "format", + command: "fmt", source: "src/lint/driver/fmt/cli.rs", common: [ "check", @@ -72,7 +72,7 @@ describe.concurrent.each([ expect(Object.keys(listed(stdout))).toEqual(common); expect(lines.filter(line => line.startsWith(` bun ${command}`))).toHaveLength(3); expect(lines.slice(-2)).toEqual([ - `Full documentation is available at https://bun.com/docs/runtime/${command}`, + `Full documentation is available at https://bun.com/docs/runtime/${command === "fmt" ? "format" : command}`, `See every flag with bun ${command} --help --all`, ]); expect(lines.length).toBeLessThanOrEqual(25); @@ -126,7 +126,7 @@ note: package.json has a "${command}" script, so bun ${command} runs that script ["-v"], ["-V"], ["src", "-v"], - ...(command === "format" ? [["--explain", "no-var"], ["--explain=no-var"]] : []), + ...(command === "fmt" ? [["--explain", "no-var"], ["--explain=no-var"]] : []), ])("%s %s is passed to a package.json script of that name, and its exit code counts", async (...args) => { const files = { "package.json": JSON.stringify({ scripts: { [command]: "echo the script && exit 3 && echo" } }) }; const { stdout, exitCode } = await runIn(files, command, ...args); @@ -145,7 +145,7 @@ note: package.json has a "${command}" script, so bun ${command} runs that script test("flags of the command that stand before it count", async () => { const [flags, text, after, code] = - command === "format" + command === "fmt" ? [["--check", "-c", "--list-different", "-l"], "const a=1\n", "const a=1\n", 1] : [["--fix"], "let a = 1;\nexport { a };\n", "const a = 1;\nexport { a };\n", 0]; using dir = tempDir("bun-flags-before", { "a.ts": text }); @@ -230,9 +230,16 @@ note: package.json has a "${command}" script, so bun ${command} runs that script }); }); -test("bun format --all is rejected without --help", async () => { - const { stdout, stderr, exitCode } = await run("format", "--all"); +test("bun fmt --all is rejected without --help", async () => { + const { stdout, stderr, exitCode } = await run("fmt", "--all"); expect(stderr).toContain("error: --all has no effect without --help"); expect(stdout).toBe(""); expect(exitCode).toBe(2); }); + +test("bun format is not a command, and says which one is", async () => { + const { stdout, stderr, exitCode } = await runIn({ "a.js": "a = 1\n" }, "format"); + expect(stderr).toBe('error: Script not found "format"\nnote: The formatter is bun fmt\n'); + expect(stdout).toBe(""); + expect(exitCode).toBe(1); +}); diff --git a/test/cli/lint/init.test.ts b/test/cli/lint/init.test.ts index 0f72d3d266a2..bf42d13944b2 100644 --- a/test/cli/lint/init.test.ts +++ b/test/cli/lint/init.test.ts @@ -1,4 +1,4 @@ -// Tests for `bun lint --init` and `bun format --init`. +// Tests for `bun lint --init` and `bun fmt --init`. import { afterAll, describe, expect, test } from "bun:test"; import { bunEnv, bunExe, isWindows, tempDir } from "harness"; import { chmodSync, existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; @@ -19,7 +19,7 @@ const env = { ESLINT_USE_FLAT_CONFIG: undefined, }; -type Command = "lint" | "format"; +type Command = "lint" | "fmt"; async function run(cwd: string, command: Command, ...args: string[]) { await using proc = spawn({ cmd: [bunExe(), command, ...args], env, cwd, stdout: "pipe", stderr: "pipe" }); @@ -35,7 +35,7 @@ const sections = { # "no-console" = "warn" # "eqeqeq" = ["error", "smart"] `, - format: `[format] + fmt: `[fmt] # printWidth = 100 # semi = false `, @@ -43,9 +43,9 @@ const sections = { const utf16 = (text: string) => Buffer.from("" + text, "utf16le").toString("latin1"); -describe.concurrent.each(["lint", "format"] as const)("bun %s --init", command => { +describe.concurrent.each(["lint", "fmt"] as const)("bun %s --init", command => { const section = sections[command]; - const other = command === "lint" ? "format" : "lint"; + const other = command === "lint" ? "fmt" : "lint"; test("creates bunfig.toml", async () => { using dir = tempDir("bun-init-section", { "a.js": "a;\n" }); @@ -237,12 +237,12 @@ describe.concurrent("writes nothing if another tool's config file is in use", () ["lint", { "config/mine.mjs": flat }, ["-c", "config/mine.mjs"], "config/mine.mjs"], ["lint", { "eslint.config.js": flat, ".oxlintrc.json": "{}" }, ["--flavor=oxlint"], ".oxlintrc.json"], ["lint", { "eslint.config.js": flat, ".oxlintrc.json": "{}" }, ["--flavor=eslint"], "eslint.config.js"], - ["format", { ".prettierrc": "{}" }, [], ".prettierrc"], - ["format", { ".prettierrc.toml": "semi = false\n", "sub/a.js": "" }, ["--cwd", "sub"], "../.prettierrc.toml"], - ["format", { "package.json": '{ "prettier": {} }' }, [], "prettier in package.json"], - ["format", { ".oxfmtrc.json": "{}" }, [], ".oxfmtrc.json"], - ["format", { ".oxfmtrc.jsonc": "{}", "sub/a.js": "" }, ["--cwd", "sub"], "../.oxfmtrc.jsonc"], - ["format", { "config/mine.json": "{}" }, ["--config", "config/mine.json"], "config/mine.json"], + ["fmt", { ".prettierrc": "{}" }, [], ".prettierrc"], + ["fmt", { ".prettierrc.toml": "semi = false\n", "sub/a.js": "" }, ["--cwd", "sub"], "../.prettierrc.toml"], + ["fmt", { "package.json": '{ "prettier": {} }' }, [], "prettier in package.json"], + ["fmt", { ".oxfmtrc.json": "{}" }, [], ".oxfmtrc.json"], + ["fmt", { ".oxfmtrc.jsonc": "{}", "sub/a.js": "" }, ["--cwd", "sub"], "../.oxfmtrc.jsonc"], + ["fmt", { "config/mine.json": "{}" }, ["--config", "config/mine.json"], "config/mine.json"], ])("bun %s: %j %j", async (command, files, args, shown) => { using dir = tempDir("bun-init-section", files); expect(await run(String(dir), command, "--init", ...args)).toEqual({ @@ -259,9 +259,9 @@ describe.concurrent("writes nothing if another tool's config file is in use", () ["lint", { "package.json": '{ "devDependencies": { "oxlint": "1.0.0" } }' }], ["lint", { "eslint.config.js": flat, "args": "--flavor=oxlint" }], ["lint", { "eslint.config.js": flat, "args": "--no-config-lookup" }], - ["format", { "package.json": '{ "devDependencies": { "prettier": "3.0.0" } }' }], - ["format", { ".editorconfig": "root = true\n" }], - ["format", { ".prettierrc": "{}", "args": "--no-config" }], + ["fmt", { "package.json": '{ "devDependencies": { "prettier": "3.0.0" } }' }], + ["fmt", { ".editorconfig": "root = true\n" }], + ["fmt", { ".prettierrc": "{}", "args": "--no-config" }], ])("still writes bunfig.toml, bun %s: %j", async (command, { args, ...files }) => { using dir = tempDir("bun-init-section", files); const result = await run(String(dir), command, "--init", ...(args ? [args] : [])); @@ -271,7 +271,7 @@ describe.concurrent("writes nothing if another tool's config file is in use", () test("a broken config file is an error", async () => { using dir = tempDir("bun-init-section", { "eslint.config.js": "throw new Error('no');\n", ".prettierrc": "{" }); - for (const command of ["lint", "format"] as const) { + for (const command of ["lint", "fmt"] as const) { const result = await run(String(dir), command, "--init"); expect(result.stderr).toContain(command === "lint" ? "eslint.config.js" : ".prettierrc"); expect({ stdout: result.stdout, exitCode: result.exitCode }).toEqual({ stdout: "", exitCode: 2 }); @@ -310,10 +310,10 @@ describe.concurrent("the commented-out lines work when uncommented", () => { }); const names = Array.from({ length: 8 }, (_, at) => `argument${at}`); - test.each(variants(sections.format))("bun format: %j", async (keys, bunfig) => { + test.each(variants(sections.fmt))("bun fmt: %j", async (keys, bunfig) => { expect(["printWidth", "semi"]).toEqual(expect.arrayContaining([...keys])); using dir = tempDir("bun-init-section", { "a.js": `call(${names.join(",")})\n`, "bunfig.toml": bunfig }); - const result = await run(String(dir), "format", "a.js"); + const result = await run(String(dir), "fmt", "a.js"); const call = keys.includes("printWidth") ? `call(${names.join(", ")})` : `call(\n${names.map(name => ` ${name},\n`).join("")})`; diff --git a/test/cli/lint/lint.test.ts b/test/cli/lint/lint.test.ts index 3cd0bc4b24a1..0b9a8b13d0f1 100644 --- a/test/cli/lint/lint.test.ts +++ b/test/cli/lint/lint.test.ts @@ -5344,6 +5344,58 @@ describe.concurrent("bun lint", () => { }); }); + // typescript-eslint's scope manager doesn't visit the type of a `catch` parameter or the options of an import type: for it there + // are no scopes, no variables and no references in them. oxc visits both. For both, `["a"]` in an enum declares `a`. + describe("what only one of the two scope analyses visits", () => { + const sources = { + "catch-type.ts": `type Shape = unknown;\nconst width = 1;\ntry {} catch (e: Shape) { e; }\ntry {} catch (e: typeof width) { e; }\ntry {} catch (e: typeof missing) { e; }\ntry {} catch (e: (width: number) => void) { e; }\nexport {};\n`, + "import-type.ts": `const kind = "a";\nexport type A = import("./m", { with: { k: kind } });\nexport type B = import("./m", { with: { k: function (left: unknown) { const right = 1; return missing; } } });\nexport type C = import("./m", { with: { k: function (kind: unknown) { return kind; } } });\nexport type D = import("./m", { with: { k: class kind {} } });\n`, + }; + const rules = (prefix: string) => ({ + [`${prefix}no-unused-vars`]: "error", + [`${prefix}no-shadow`]: "error", + "no-undef": "error", + }); + const reported = async (files: Record) => { + const names = Object.keys(files).filter(it => it.endsWith(".ts")); + const { stdout } = await lint(files, ["-f", "unix", ...names]); + const found = [...stdout.matchAll(/([\w-]+\.ts:\d+:\d+): .*?([\w-]+)\)?\]$/gm)]; + return found.map(it => `${it[1]} ${it[2]}`).sort(); + }; + + test("as ESLint 10.12.0 with typescript-eslint 8.71.1", async () => { + const configuration = `export default [{ + files: ["**/*.ts"], + plugins: { "@typescript-eslint": { meta: { name: "@typescript-eslint/eslint-plugin" } } }, + languageOptions: { parser: { meta: { name: "typescript-eslint/parser" } } }, + rules: ${JSON.stringify(rules("@typescript-eslint/"))}, + }];`; + expect(await reported({ "eslint.config.mjs": configuration, ...sources })).toEqual([ + "catch-type.ts:1:6 no-unused-vars", + "import-type.ts:1:7 no-unused-vars", + ]); + }); + + test("as oxlint 1.87", async () => { + const configuration = JSON.stringify({ categories: { correctness: "off" }, rules: rules("") }); + const enums = { + "enum-shadows.ts": `const size = 1;\nenum E { ["size"] = 1, other = size as any }\nexport { E };\n`, + "enum-declares.ts": `enum E { ["size"] = 1, other = size as any }\nenum F { a = 1, other = size as any }\nexport { E, F };\n`, + }; + expect(await reported({ ".oxlintrc.json": configuration, ...sources, ...enums })).toEqual([ + "catch-type.ts:5:25 no-undef", + "enum-declares.ts:2:25 no-undef", + "enum-shadows.ts:1:7 no-unused-vars", + "enum-shadows.ts:2:11 no-shadow", + "import-type.ts:3:54 no-unused-vars", + "import-type.ts:3:77 no-unused-vars", + "import-type.ts:3:95 no-undef", + "import-type.ts:4:54 no-shadow", + "import-type.ts:5:50 no-shadow", + ]); + }); + }); + // `namespace A.B { .. }` is one node, and what is between the braces is its body: a rule that looks at the statements beside a // statement says there what it says in `namespace B { .. }` and in `namespace A { export namespace B { .. } }`. describe("the statements of a namespace with dots in its name", () => { @@ -9331,7 +9383,7 @@ describe.concurrent("a lint script in package.json", () => { }); // What the linter takes from the process that it runs in. Where `bun lint` is developed something else is in Bun's place for each -// of these. See "what bun format takes from Bun" in test/cli/format/format.test.ts. +// of these. See "what bun fmt takes from Bun" in test/cli/format/format.test.ts. describe.concurrent("what bun lint takes from Bun", () => { const files = { "eslint.config.js": basic, @@ -14174,6 +14226,30 @@ describe("TypeScript grammar errors under .oxlintrc.json", () => { ["let ...a;", "Unexpected token @4+3"], ["let\n@d class A {}", "Unexpected token @4+1"], ["let\n1;", "Unexpected token @4+1"], + [ + "await !x;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @7+0 help: Try inserting a semicolon here", + ], + [ + "await as any;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @8+0 help: Try inserting a semicolon here", + ], + [ + "await ~x;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @5+0 help: Try inserting a semicolon here", + ], + [ + "await ++x;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @8+0 help: Try inserting a semicolon here", + ], + [ + "await { a: 1 };", + "Expected a semicolon or an implicit semicolon after a statement, but found none @5+0 help: Try inserting a semicolon here", + ], + [ + "await of;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @5+0 help: Try inserting a semicolon here", + ], ], "d.ts": [ ["const a: number = 1;", "TS(1039) Initializers are not allowed in ambient contexts. @18+1"], @@ -14382,6 +14458,10 @@ describe("TypeScript grammar errors under .oxlintrc.json", () => { ["f(a b);", 'Expected `,` or `)` but found `Identifier` @4+1 "`,` or `)` expected", 1+1 "Opened here"'], ["[a b];", 'Expected `,` or `]` but found `Identifier` @3+1 "`,` or `]` expected", 0+1 "Opened here"'], ["a ? b;", 'Expected `:` but found `;` @5+1 "`:` expected", 2+1 "Conditional starts here"'], + [ + "await !x;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @7+0 help: Try inserting a semicolon here", + ], ], "tsx": [ ["function f<>() {}", "TS(1098) Type parameter list cannot be empty. @10+2"], @@ -16447,6 +16527,145 @@ describe("TypeScript grammar errors under .oxlintrc.json", () => { ["let a,\nfunction f() {}", "Identifier expected. 'function' is a reserved word that cannot be used here. @7+8"], ["let @ = 1;", "Unexpected token @4+1"], ["let 1;", "Unexpected token @4+1"], + ["await; import.meta;", "Unexpected token @5+1"], + ["await.y; import.meta;", "Unexpected token @5+1"], + ["await = 1; import.meta;", "Unexpected token @6+1"], + [ + "await / x / g; import.meta;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @11+0 help: Try inserting a semicolon here", + ], + ["{ await(); } import.meta;", "Empty parenthesized expression @7+2"], + ["{ class A extends await {} } import.meta;", "Cannot use `await` as an identifier in an async context @18+5"], + ["function w() { await (x); } import.meta;", "The keyword 'await' is reserved @15+5"], + ["const w = () => await (x); import.meta;", "The keyword 'await' is reserved @16+5"], + ["var await; import.meta;", "The keyword 'await' is reserved @4+5"], + ["x = { await }; import.meta;", "The keyword 'await' is reserved @6+5"], + ["a a`. Those of `file` have to be /// somewhere in `after`. +#[optimize(size)] fn find_html_comments<'f>(file: &'f File<'f>, after: &[u8]) -> Result<(), Difference> { let text = file.text(); if !file.is_javascript() @@ -108,6 +114,7 @@ fn find_html_comments<'f>(file: &'f File<'f>, after: &[u8]) -> Result<(), Differ } /// What is between `/**` and `*/`, if `comment` is a JSDoc comment. +#[optimize(size)] fn jsdoc(comment: &[u8]) -> Option<&[u8]> { comment .strip_prefix(b"/**")? @@ -116,6 +123,7 @@ fn jsdoc(comment: &[u8]) -> Option<&[u8]> { } /// Whether the JSDoc comment says more than the name of one tag. One that does not can be left out. +#[optimize(size)] fn says_something(inner: &[u8]) -> bool { let is_part_of_word = |it: &u8| it.is_ascii_alphanumeric() || !it.is_ascii(); let mut words = inner @@ -128,6 +136,7 @@ fn says_something(inner: &[u8]) -> bool { } /// The name or the keyword that follows the comment, if it is one that follows. +#[optimize(size)] fn word_after(text: &[u8], comment: (u32, u32)) -> &[u8] { let rest = text .get(skip_trivia(text, comment.1) as usize..) @@ -141,6 +150,7 @@ fn word_after(text: &[u8], comment: (u32, u32)) -> &[u8] { /// Allowed, if JSDoc comments are formatted: anything in them, and those that say nothing are left out. If none is, /// each is before the same word. +#[optimize(size)] fn compare_without_jsdoc(before: Comments<'_>, after: Comments<'_>) -> Result<(), Difference> { type Lists = (Vec<(u32, u32)>, Vec<(u32, u32)>); // Those that are not JSDoc comments, and those that are. @@ -176,6 +186,7 @@ fn compare_without_jsdoc(before: Comments<'_>, after: Comments<'_>) -> Result<() } /// `file`: the program before. `formats_jsdoc`: the option `jsdoc` is set. +#[optimize(size)] pub(super) fn compare<'f>( file: &'f File<'f>, before: Comments<'_>, diff --git a/src/format/verify/tree.rs b/src/format/verify/tree.rs index 6582335981fc..eb40353cedaf 100644 --- a/src/format/verify/tree.rs +++ b/src/format/verify/tree.rs @@ -36,6 +36,7 @@ macro_rules! lists { impl<'a> Program<'a> { /// `text`: what `hir` has been parsed from. `atoms`: what the atoms in `hir` are of. + #[optimize(size)] pub fn new(hir: &'a FileIn, text: &'a [u8], atoms: &'a dyn Intern) -> Self { Program { $($field: &hir.$field[..],)* @@ -98,26 +99,31 @@ lists! { impl<'a> Program<'a> { #[inline] + #[optimize(size)] fn ids_of(&self, list: IdList) -> &'a [u32] { self.ids.get(list.range()).unwrap_or_default() } #[inline] + #[optimize(size)] fn slice(&self, start: u32, end: u32) -> &'a [u8] { self.text .get(start as usize..end.max(start) as usize) .unwrap_or_default() } + #[optimize(size)] fn from(&self, start: u32) -> &'a [u8] { self.text.get(start as usize..).unwrap_or_default() } + #[optimize(size)] fn is_parenthesized(&self, e: ExprId) -> bool { self.parens.binary_search_by_key(&e.0, |it| it.0.0).is_ok() } /// Whether `e` is all there is between the braces of a `{e}` in JSX. + #[optimize(size)] fn is_in_braces(&self, e: ExprId) -> bool { self.jsx_expressions .binary_search_by_key(&e.0, |it| it.0.0) @@ -125,6 +131,7 @@ impl<'a> Program<'a> { } /// The end of `e` with the parentheses around it. + #[optimize(size)] fn outer_end(&self, e: ExprId) -> u32 { let after = self.parens.partition_point(|it| it.0.0 <= e.0); match after.checked_sub(1).and_then(|last| self.parens.get(last)) { @@ -133,11 +140,13 @@ impl<'a> Program<'a> { } } + #[optimize(size)] fn end_of_last(&self, types: IdList) -> Option { Some(self.types.get(*self.ids_of(types).last()? as usize)?.end) } /// Where `extends A` ends. + #[optimize(size)] fn end_of_extends(&self, class: &Class) -> Option { match self.end_of_last(class.extends_args) { Some(end) => Some(skip_trivia(self.text, end) + 1), @@ -145,6 +154,7 @@ impl<'a> Program<'a> { } } + #[optimize(size)] fn param_modifiers(&self, param: ParamId) -> Span { self.modifiers_of_params .get(param.idx()) @@ -152,6 +162,7 @@ impl<'a> Program<'a> { .unwrap_or(Span::EMPTY) } + #[optimize(size)] fn prop_modifiers(&self, prop: PropId) -> Span { if self.modifiers_of_props.is_empty() { return Span::EMPTY; @@ -165,6 +176,7 @@ impl<'a> Program<'a> { .map_or(Span::EMPTY, |it| it.1) } + #[optimize(size)] fn is_empty_statement(&self, id: u32) -> bool { matches!( self.stmts.get(id as usize), @@ -176,12 +188,14 @@ impl<'a> Program<'a> { } /// Whether the statement is a string that is not in parentheses. + #[optimize(size)] fn is_directive(&self, id: u32) -> bool { matches!(self.stmts.get(id as usize), Some(Stmt { kind: StmtKind::Expr(e), .. }) if matches!(self.exprs.get(e.idx()), Some(Expr { kind: ExprKind::String(_), pos, .. }) if self.text.get(*pos as usize) != Some(&b'`') && !self.is_parenthesized(*e))) } + #[optimize(size)] fn is_import(&self, id: u32) -> bool { matches!( self.stmts.get(id as usize), @@ -194,6 +208,7 @@ impl<'a> Program<'a> { /// The operator of `e`, if that is `&&`, `||` or `??`. #[inline] + #[optimize(size)] fn logical_operator(&self, e: ExprId) -> Option { match self.exprs.get(e.idx())?.kind { ExprKind::Binary { @@ -206,6 +221,7 @@ impl<'a> Program<'a> { /// Appends the operands of the chain of `op` that `e` is to `out`, in order: those of /// `(a && b) && (c && d)` are `a`, `b`, `c` and `d`. + #[optimize(size)] fn operands_of_chain(&self, e: ExprId, op: BinOp, out: &mut Vec) { // What is not taken apart yet, the last first. let mut rest = vec![e]; @@ -222,6 +238,7 @@ impl<'a> Program<'a> { } /// `ty`, or if that is a union or an intersection of one type, that type. + #[optimize(size)] fn without_lone_operator(&self, mut ty: TypeNodeId) -> TypeNodeId { while let Some(TypeNode { kind: TypeNodeKind::Union(types) | TypeNodeKind::Intersection(types), @@ -268,6 +285,7 @@ struct Walk<'a, 'f> { /// Whether the same words are in `a` and in `b`, in whatever order and however often. #[cold] +#[optimize(size)] fn has_same_words(a: &[u8], b: &[u8]) -> bool { let words = |text: &[u8]| -> Vec> { let mut words: Vec> = (text.split(u8::is_ascii_whitespace)) @@ -283,6 +301,7 @@ fn has_same_words(a: &[u8], b: &[u8]) -> bool { /// Whether `after`, which is what formatting with `options` has made of `before`, is the same program. /// `file`: `before`. +#[optimize(size)] pub fn compare<'f>( file: &'f File<'f>, before: &Program<'_>, @@ -369,6 +388,7 @@ macro_rules! both { impl Walk<'_, '_> { /// Compares each expression, statement, type and pattern with the one that has the same id. With `by_id` only. + #[optimize(size)] fn all_nodes(&mut self) -> Same { let (a, b) = (self.a, self.b); let lengths = |it: &Program<'_>| { @@ -399,18 +419,21 @@ impl Walk<'_, '_> { impl Walk<'_, '_> { #[cold] + #[optimize(size)] fn differ(&mut self, what: &'static str, before: u32, after: u32) -> Same { self.difference = Some((what, before, after)); Err(()) } #[inline] + #[optimize(size)] fn is_same_id(a: u32, b: u32) -> Same { if a == b { Ok(()) } else { Err(()) } } /// `Program::ids` are the same. #[inline] + #[optimize(size)] fn is_same_list(a: IdList, b: IdList) -> Same { if a.len == b.len && (a.start == b.start || a.len == 0) { Ok(()) @@ -421,12 +444,14 @@ impl Walk<'_, '_> { /// For a difference whose place is not known. The caller that knows one fills it in. #[cold] + #[optimize(size)] fn differ_somewhere(&mut self, what: &'static str) -> Same { self.differ(what, u32::MAX, u32::MAX) } /// `result`, with a place for a difference that has none yet. #[inline] + #[optimize(size)] fn at(&mut self, result: Same, before: u32, after: u32) -> Same { if result.is_err() { self.set_place(before, after); @@ -435,6 +460,7 @@ impl Walk<'_, '_> { } #[cold] + #[optimize(size)] fn set_place(&mut self, before: u32, after: u32) { if let Some(difference) = &mut self.difference { if difference.1 == u32::MAX { @@ -447,6 +473,7 @@ impl Walk<'_, '_> { } #[inline] + #[optimize(size)] fn check(&mut self, is_same: bool, what: &'static str) -> Same { match is_same { true => Ok(()), @@ -458,6 +485,7 @@ impl Walk<'_, '_> { /// Each interner has one atom for a text. So the texts of two atoms are compared once. #[inline] + #[optimize(size)] fn is_same_atom(&mut self, a: Atom, b: Atom) -> bool { match self.scratch.atoms.get(a.0 as usize) { Some(&known) if known == b.0 => true, @@ -467,6 +495,7 @@ impl Walk<'_, '_> { #[cold] #[inline(never)] + #[optimize(size)] fn is_same_atom_by_text(&mut self, a: Atom, b: Atom) -> bool { if a.is_none() || b.is_none() { return a == b; @@ -491,6 +520,7 @@ impl Walk<'_, '_> { /// Whether `b` can be what sorting classes has made of `a`. They have passed for the same. #[inline] + #[optimize(size)] fn is_sorted(&self, a: Atom, b: Atom) -> bool { let has_white_space = |text: &[u8]| text.iter().any(u8::is_ascii_whitespace); self.classes_can_move @@ -498,6 +528,7 @@ impl Walk<'_, '_> { } #[inline] + #[optimize(size)] fn atom(&mut self, a: Atom, b: Atom, what: &'static str) -> Same { match self.is_same_atom(a, b) { true => Ok(()), @@ -505,6 +536,7 @@ impl Walk<'_, '_> { } } + #[optimize(size)] fn entity_name(&mut self, a: Span, b: Span) -> Same { let (xs, ys) = ( self.a.names.get(a.range()).unwrap_or_default(), @@ -521,6 +553,7 @@ impl Walk<'_, '_> { /// Allowed: a name gets or loses its quotes, and a number is written in another way. The text of /// the name is the same then. + #[optimize(size)] fn name_kind(&mut self, a: NameKind, b: NameKind) -> Same { let is_plain = |it| { matches!( @@ -532,10 +565,12 @@ impl Walk<'_, '_> { } /// `at`: where the keys are. + #[optimize(size)] fn key(&mut self, a: PropKey, b: PropKey, at: (u32, u32)) -> Same { match (a, b) { // The tree has no name for a `bigint`. (PropKey::None, PropKey::None) => { + #[optimize(size)] fn word(text: &[u8]) -> &[u8] { let len = text .iter() @@ -567,6 +602,7 @@ impl Walk<'_, '_> { /// but for what is allowed for a string and for a number. Allowed: a string without escapes gets or loses /// its quotes. #[inline] + #[optimize(size)] fn name_as_written(&mut self, at: (u32, u32)) -> Same { let is_plain = |program: &Program<'_>, at: u32| { program @@ -585,6 +621,7 @@ impl Walk<'_, '_> { /// Allowed: the keywords that are next to each other are put in order, but for `export`, `default`, /// `async` and `accessor`, which stay where they are, as decorators do. + #[optimize(size)] fn modifiers(&mut self, a: Span, b: Span) -> Same { if a.is_empty() && b.is_empty() { return Ok(()); @@ -594,6 +631,7 @@ impl Walk<'_, '_> { .union(Flags::ASYNC) .union(Flags::ACCESSOR); /// Takes the keywords that `list` starts with and that can be put in order. + #[optimize(size)] fn take_keywords(list: &mut &[Modifier]) -> Flags { let mut all = Flags::empty(); while let [ @@ -636,6 +674,7 @@ impl Walk<'_, '_> { // ───────────────────────────── the program ───────────────────────────── + #[optimize(size)] fn program(&mut self) -> Same { let (a, b) = (self.a, self.b); self.diagnostics()?; @@ -660,6 +699,7 @@ impl Walk<'_, '_> { /// What the parser accepts and has something to say about, like the comma in `f()`, is not in the /// tree. Allowed: there is less of it. + #[optimize(size)] fn diagnostics(&mut self) -> Same { if self.b.diagnostics.is_empty() { return Ok(()); @@ -687,6 +727,7 @@ impl Walk<'_, '_> { } /// The values of module specifiers are in the tree. Here they are compared as they are written. + #[optimize(size)] fn module_specifiers(&mut self) -> Same { if self.imports_can_move { return Ok(()); @@ -711,7 +752,9 @@ impl Walk<'_, '_> { } /// `with { .. }`, `assert { .. }` + #[optimize(size)] fn import_attributes(&mut self, a: (u32, ExprId), b: (u32, ExprId)) -> Same { + #[optimize(size)] fn keyword(text: &[u8]) -> &[u8] { text.get( ..text @@ -728,6 +771,7 @@ impl Walk<'_, '_> { } /// Allowed, if the formatter sorts imports: they, and the names in them, are in another order. + #[optimize(size)] fn moved_imports(&mut self) -> Same { let sorted = |program: &Program<'_>, ids: &[u32]| { let mut all: Vec<_> = ids.iter().map(|&id| import_as_text(program, id)).collect(); @@ -769,6 +813,7 @@ impl Walk<'_, '_> { // ───────────────────────────── statements ───────────────────────────── /// The statements of a program or of a function, which start with its directives. + #[optimize(size)] fn body(&mut self, a: IdList, b: IdList) -> Same { let directives = |program: &Program<'_>, list| { program @@ -786,6 +831,7 @@ impl Walk<'_, '_> { /// Allowed: empty statements in a list are left out. #[inline] + #[optimize(size)] fn stmt_list(&mut self, a: IdList, b: IdList) -> Same { match self.by_id { true => Self::is_same_list(a, b), @@ -793,6 +839,7 @@ impl Walk<'_, '_> { } } + #[optimize(size)] fn stmt_list_in_depth(&mut self, a: IdList, b: IdList) -> Same { let (xs, ys) = (self.a.ids_of(a), self.b.ids_of(b)); let (mut i, mut j) = (0, 0); @@ -831,6 +878,7 @@ impl Walk<'_, '_> { } #[inline] + #[optimize(size)] fn stmt(&mut self, a: StmtId, b: StmtId) -> Same { match self.by_id { true => Self::is_same_id(a.0, b.0), @@ -838,6 +886,7 @@ impl Walk<'_, '_> { } } + #[optimize(size)] fn stmt_in_depth(&mut self, a: StmtId, b: StmtId) -> Same { let (x, y) = both!(self, stmts, a, b, "a statement is missing"); if !self.stack.is_safe_to_recurse() { @@ -853,6 +902,7 @@ impl Walk<'_, '_> { self.at(result, x.start, y.start) } + #[optimize(size)] fn stmt_kind(&mut self, x: StmtKind, y: StmtKind) -> Same { use StmtKind::*; match (x, y) { @@ -1170,6 +1220,7 @@ impl Walk<'_, '_> { /// The parser says nothing about a comma after the last element of a list that must not end with /// one: `let a,`, `class A extends B, {}`. `end`: where the last element ends in the program after. + #[optimize(size)] fn no_comma_follows(&mut self, end: Option) -> Same { match end.map(|end| skip_trivia(self.b.text, end)) { Some(next) if self.b.text.get(next as usize) == Some(&b',') => { @@ -1179,6 +1230,7 @@ impl Walk<'_, '_> { } } + #[optimize(size)] fn var_decl(&mut self, a: VarDeclId, b: VarDeclId) -> Same { let (x, y) = both!(self, var_decls, a, b, "a declaration is missing"); self.check( @@ -1193,6 +1245,7 @@ impl Walk<'_, '_> { // ───────────────────────────── patterns ───────────────────────────── #[inline] + #[optimize(size)] fn pat(&mut self, a: PatId, b: PatId) -> Same { match self.by_id { true => Self::is_same_id(a.0, b.0), @@ -1200,12 +1253,14 @@ impl Walk<'_, '_> { } } + #[optimize(size)] fn pat_in_depth(&mut self, a: PatId, b: PatId) -> Same { let (x, y) = both!(self, pats, a, b, "a pattern is missing"); let result = self.pat_kind(x.kind, y.kind); self.at(result, x.pos, y.pos) } + #[optimize(size)] fn pat_kind(&mut self, x: PatKind, y: PatKind) -> Same { match (x, y) { (PatKind::Missing, PatKind::Missing) => Ok(()), @@ -1216,6 +1271,7 @@ impl Walk<'_, '_> { } } + #[optimize(size)] fn pat_props(&mut self, a: Span, b: Span) -> Same { self.check(a.len() == b.len(), "the number of properties of a pattern")?; for (p, q) in a.iter().zip(b.iter()) { @@ -1239,6 +1295,7 @@ impl Walk<'_, '_> { Ok(()) } + #[optimize(size)] fn pat_elems(&mut self, a: Span, b: Span) -> Same { self.check(a.len() == b.len(), "the number of elements of a pattern")?; for (p, q) in a.iter().zip(b.iter()) { @@ -1255,6 +1312,7 @@ impl Walk<'_, '_> { // ───────────────────────────── functions and classes ───────────────────────────── + #[optimize(size)] fn type_params(&mut self, a: Span, b: Span) -> Same { self.check(a.len() == b.len(), "the number of type parameters")?; a.iter() @@ -1262,6 +1320,7 @@ impl Walk<'_, '_> { .try_for_each(|(x, y)| self.type_param(x, y)) } + #[optimize(size)] fn type_param(&mut self, a: TypeParamId, b: TypeParamId) -> Same { let (x, y) = both!(self, type_params, a, b, "a type parameter is missing"); let result = (self.atom(x.name, y.name, "the name of a type parameter")) @@ -1272,6 +1331,7 @@ impl Walk<'_, '_> { self.at(result, x.start, y.start) } + #[optimize(size)] fn param(&mut self, a: ParamId, b: ParamId) -> Same { let (x, y) = both!(self, params, a, b, "a parameter is missing"); let result = (self.check(x.flags == y.flags, "the modifiers of a parameter")) @@ -1282,12 +1342,14 @@ impl Walk<'_, '_> { self.at(result, x.pos, y.pos) } + #[optimize(size)] fn func(&mut self, a: FnId, b: FnId) -> Same { let (x, y) = both!(self, fns, a, b, "a function is missing"); let result = self.func_parts(&x, &y); self.at(result, x.start, y.start) } + #[optimize(size)] fn func_parts(&mut self, x: &Func, y: &Func) -> Same { self.check( (function_kind(x), without_name_kind(x.flags)) @@ -1308,6 +1370,7 @@ impl Walk<'_, '_> { } } + #[optimize(size)] fn members(&mut self, a: Span, b: Span) -> Same { self.check(a.len() == b.len(), "the number of members")?; a.iter() @@ -1315,6 +1378,7 @@ impl Walk<'_, '_> { .try_for_each(|(x, y)| self.member(x, y)) } + #[optimize(size)] fn member(&mut self, a: MemberId, b: MemberId) -> Same { let (x, y) = both!(self, members, a, b, "a member is missing"); let form = |it: &Member, program: &Program<'_>| { @@ -1346,6 +1410,7 @@ impl Walk<'_, '_> { self.at(result, x.start, y.start) } + #[optimize(size)] fn class(&mut self, a: ClassId, b: ClassId) -> Same { let (x, y) = both!(self, classes, a, b, "a class is missing"); let result = (self.atom(x.name, y.name, "the name of a class")) @@ -1366,6 +1431,7 @@ impl Walk<'_, '_> { /// The parser says nothing about `[a: string]: B,` in a class either. `members`: those of a class in the /// program after. + #[optimize(size)] fn no_member_ends_with_a_comma(&mut self, members: Span) -> Same { let ends_with_a_comma = |it: &&Member| { it.kind == MemberKind::IndexSignature @@ -1392,6 +1458,7 @@ impl Walk<'_, '_> { // ───────────────────────────── expressions ───────────────────────────── #[inline] + #[optimize(size)] fn expr_list(&mut self, a: IdList, b: IdList) -> Same { match self.by_id { true => Self::is_same_list(a, b), @@ -1399,6 +1466,7 @@ impl Walk<'_, '_> { } } + #[optimize(size)] fn expr_list_in_depth(&mut self, a: IdList, b: IdList) -> Same { let (xs, ys) = (self.a.ids_of(a), self.b.ids_of(b)); self.check(xs.len() == ys.len(), "the number of expressions in a list")?; @@ -1407,6 +1475,7 @@ impl Walk<'_, '_> { .try_for_each(|(&x, &y)| self.expr(ExprId(x), ExprId(y))) } + #[optimize(size)] fn props(&mut self, a: Span, b: Span) -> Same { self.check(a.len() == b.len(), "the number of properties")?; for (p, q) in a.iter().zip(b.iter()) { @@ -1428,6 +1497,7 @@ impl Walk<'_, '_> { Ok(()) } + #[optimize(size)] fn call(&mut self, a: CallId, b: CallId) -> Same { let (x, y) = both!(self, calls, a, b, "a call is missing"); self.check(x.chain == y.chain, "the `?.` of a call")?; @@ -1438,6 +1508,7 @@ impl Walk<'_, '_> { } #[inline] + #[optimize(size)] fn expr(&mut self, a: ExprId, b: ExprId) -> Same { match self.by_id { true => Self::is_same_id(a.0, b.0), @@ -1445,6 +1516,7 @@ impl Walk<'_, '_> { } } + #[optimize(size)] fn expr_in_depth(&mut self, a: ExprId, b: ExprId) -> Same { let (x, y) = both!(self, exprs, a, b, "an expression is missing"); let result = self.expr_kind((a, &x), (b, &y)); @@ -1452,6 +1524,7 @@ impl Walk<'_, '_> { } #[inline] + #[optimize(size)] fn expr_kind(&mut self, (a, x): (ExprId, &Expr), (b, y): (ExprId, &Expr)) -> Same { use ExprKind::*; match (x.kind, y.kind) { @@ -1688,6 +1761,7 @@ impl Walk<'_, '_> { } #[inline] + #[optimize(size)] fn has_stack_left(&mut self) -> Same { match self.by_id || self.stack.is_safe_to_recurse() { true => Ok(()), @@ -1697,6 +1771,7 @@ impl Walk<'_, '_> { /// [`Walk::expr`] where code can be nested without end. #[inline] + #[optimize(size)] fn nested(&mut self, a: ExprId, b: ExprId) -> Same { self.has_stack_left()?; self.expr(a, b) @@ -1704,6 +1779,7 @@ impl Walk<'_, '_> { /// Allowed: `a && (b && c)` is `a && b && c`, and the same for `||` and `??`. #[cold] + #[optimize(size)] fn logical_chain(&mut self, a: ExprId, b: ExprId, op: BinOp) -> Same { self.has_stack_left()?; let (first, first2) = (self.scratch.operands.0.len(), self.scratch.operands.1.len()); @@ -1730,6 +1806,7 @@ impl Walk<'_, '_> { result } + #[optimize(size)] fn number(&mut self, a: u32, b: u32) -> Same { let bits = |program: &Program<'_>, at: u32| { program.numbers.get(at as usize).map(|it| it.to_bits()) @@ -1737,6 +1814,7 @@ impl Walk<'_, '_> { self.check(bits(self.a, a) == bits(self.b, b), "a number") } + #[optimize(size)] fn bigint(&mut self, a: Atom, b: Atom) -> Same { let is_same = self.is_same_atom(a, b) || (a.is_some() @@ -1750,6 +1828,7 @@ impl Walk<'_, '_> { } /// Allowed: the flags are sorted. + #[optimize(size)] fn regex(&mut self, a: &[u8], b: &[u8]) -> Same { if a == b { return Ok(()); @@ -1768,6 +1847,7 @@ impl Walk<'_, '_> { // ───────────────────────────── templates ───────────────────────────── /// The source of each piece of text of the template at `start` whose substitutions are `exprs`. + #[optimize(size)] fn template_texts<'p>( program: &'p Program<'p>, start: u32, @@ -1793,6 +1873,7 @@ impl Walk<'_, '_> { }) } + #[optimize(size)] fn template( &mut self, (a, x, p): (ExprId, &Expr, IdList), @@ -1807,6 +1888,7 @@ impl Walk<'_, '_> { return Ok(()); } // Allowed: other line breaks. In a template, all of them stand for `\n`. + #[optimize(size)] fn with_one_kind_of_line_break(text: &[u8]) -> impl Iterator { (text.iter().zip(text.iter().skip(1).map(Some).chain([None]))).filter_map( |(&byte, next)| match (byte, next) { @@ -1834,6 +1916,7 @@ impl Walk<'_, '_> { /// Whether the formatter may make `after` of `before`, the texts of the template `e`. #[cold] + #[optimize(size)] fn can_template_change<'t>( &self, e: ExprId, @@ -1856,6 +1939,7 @@ impl Walk<'_, '_> { // ───────────────────────────── JSX ───────────────────────────── + #[optimize(size)] fn jsx(&mut self, a: JsxId, b: JsxId) -> Same { let (x, y) = both!(self, jsx, a, b, "an element is missing"); self.check( @@ -1869,6 +1953,7 @@ impl Walk<'_, '_> { self.jsx_children(&x, &y) } + #[optimize(size)] fn jsx_attribute_value(&mut self, a: ExprId, b: ExprId) -> Same { let (Some(x), Some(y)) = (jsx_string(self.a, a), jsx_string(self.b, b)) else { self.check( @@ -1899,6 +1984,7 @@ impl Walk<'_, '_> { } /// Allowed: the white space in JSX text, and `{" "}`. The words are the same. + #[optimize(size)] fn jsx_children(&mut self, a: &Jsx, b: &Jsx) -> Same { let (mut xs, mut ys) = (JsxChildren::new(self.a, a), JsxChildren::new(self.b, b)); loop { @@ -1922,6 +2008,7 @@ impl Walk<'_, '_> { // ───────────────────────────── types ───────────────────────────── #[inline] + #[optimize(size)] fn type_list(&mut self, a: IdList, b: IdList) -> Same { match self.by_id { true => Self::is_same_list(a, b), @@ -1929,6 +2016,7 @@ impl Walk<'_, '_> { } } + #[optimize(size)] fn type_list_in_depth(&mut self, a: IdList, b: IdList) -> Same { let (xs, ys) = (self.a.ids_of(a), self.b.ids_of(b)); self.check(xs.len() == ys.len(), "the number of types in a list")?; @@ -1938,6 +2026,7 @@ impl Walk<'_, '_> { } #[inline] + #[optimize(size)] fn ty(&mut self, a: TypeNodeId, b: TypeNodeId) -> Same { match self.by_id { true => Self::is_same_id(a.0, b.0), @@ -1945,12 +2034,14 @@ impl Walk<'_, '_> { } } + #[optimize(size)] fn ty_in_depth(&mut self, a: TypeNodeId, b: TypeNodeId) -> Same { let (x, y) = both!(self, types, a, b, "a type is missing"); let result = self.type_kind((a, x.kind), (b, y.kind)); self.at(result, x.pos, y.pos) } + #[optimize(size)] fn type_kind( &mut self, (a, x): (TypeNodeId, TypeNodeKind), @@ -2209,6 +2300,7 @@ impl Walk<'_, '_> { } /// Whether the keys that are not plain names, each in a program at a position, are written the same. +#[optimize(size)] fn is_literal_name_same(a: (&Program<'_>, u32), b: (&Program<'_>, u32)) -> bool { // `["a"]` let inside = |program: &Program<'_>, at: u32| match program.text.get(at as usize) { @@ -2242,6 +2334,7 @@ fn is_literal_name_same(a: (&Program<'_>, u32), b: (&Program<'_>, u32)) -> bool /// Everything about the import `id` that is compared, in a form that does not depend on the order /// of its names, but for its attributes, which come with it. +#[optimize(size)] fn import_as_text(program: &Program<'_>, id: u32) -> (Vec, Option<(u32, ExprId)>) { let mut out = Vec::new(); let Some(Stmt { @@ -2306,6 +2399,7 @@ fn import_as_text(program: &Program<'_>, id: u32) -> (Vec, Option<(u32, Expr /// The text of the string `e`, if that is not between braces: JSX text, or the value of an /// attribute with its quotes. +#[optimize(size)] fn jsx_string<'p>(program: &'p Program<'p>, e: ExprId) -> Option<&'p [u8]> { match program.exprs.get(e.idx()) { Some(Expr { @@ -2318,6 +2412,7 @@ fn jsx_string<'p>(program: &'p Program<'p>, e: ExprId) -> Option<&'p [u8]> { } /// The string that `text` starts with, with its quotes. +#[optimize(size)] fn string_at_start(text: &[u8]) -> Option<&[u8]> { let quote = *text.first().filter(|it| matches!(it, b'"' | b'\''))?; let mut len = 1; @@ -2331,6 +2426,7 @@ fn string_at_start(text: &[u8]) -> Option<&[u8]> { } /// The number that `text` starts with. +#[optimize(size)] fn number_at_start(text: &[u8]) -> Option<&[u8]> { text.first() .filter(|it| it.is_ascii_digit() || **it == b'.')?; @@ -2354,17 +2450,21 @@ fn number_at_start(text: &[u8]) -> Option<&[u8]> { /// Whether two strings, with their quotes, are written in the same way. Allowed: other quotes, a `\` before /// a quote, and another line break after a `\`. #[inline] +#[optimize(size)] fn is_same_string(a: &[u8], b: &[u8]) -> bool { a == b || is_same_string_but_for_quotes(a, b) } +#[optimize(size)] fn is_same_string_but_for_quotes(a: &[u8], b: &[u8]) -> bool { + #[optimize(size)] fn content(text: &[u8]) -> Option<&[u8]> { match text { [b'"' | b'\'', content @ .., b'"' | b'\''] => Some(content), _ => None, } } + #[optimize(size)] fn without_quote_escapes(content: &[u8]) -> Vec { let mut out = Vec::with_capacity(content.len()); let mut bytes = content.iter().copied().peekable(); @@ -2400,16 +2500,19 @@ fn is_same_string_but_for_quotes(a: &[u8], b: &[u8]) -> bool { /// Whether two numbers are written in the same way. Allowed: what the formatter does to a number: `0XAB`, `1.0`, /// `.5`, `1E5`. #[inline] +#[optimize(size)] fn is_same_number(a: &[u8], b: &[u8]) -> bool { a == b || format_trimmed_number(a) == format_trimmed_number(b) } /// Allowed: see `Walk::name_kind`. +#[optimize(size)] fn without_name_kind(flags: Flags) -> Flags { flags - (Flags::LITERAL_NAME | Flags::STRING_NAME) } /// Allowed: `"constructor"() {}` in a class is `constructor() {}`, which is what it means. +#[optimize(size)] fn function_kind(function: &Func) -> FnKind { match function.kind { FnKind::Method @@ -2447,6 +2550,7 @@ struct JsxChildren<'p> { } impl<'p> JsxChildren<'p> { + #[optimize(size)] fn new(program: &'p Program<'p>, element: &Jsx) -> Self { JsxChildren { program, @@ -2468,6 +2572,7 @@ impl<'p> JsxChildren<'p> { impl<'p> Iterator for JsxChildren<'p> { type Item = JsxChild<'p>; + #[optimize(size)] fn next(&mut self) -> Option> { loop { self.text = self.text.trim_ascii_start(); diff --git a/src/format/yaml/mod.rs b/src/format/yaml/mod.rs index dda7861e0028..c079974ca514 100644 --- a/src/format/yaml/mod.rs +++ b/src/format/yaml/mod.rs @@ -120,6 +120,8 @@ fn write_document( comment_in_brackets: None, printed_empty_lines: vec![false; text.len() + 1], last_group_id: 0, + key_depth: 0, + start_of_content: (text.len() - text.trim_ascii_start().len()) as u32, out, stack: &stack, }; @@ -173,7 +175,8 @@ pub fn format( if has_bom { out.extend_from_slice(BOM); } - if bun_core::strings::trim_js_whitespace(&text).is_empty() { + // Prettier goes by what is white space in JavaScript, and makes nothing of a no-break space. + if text.trim_ascii().is_empty() { return Ok(()); } // It has to be YAML in any case. diff --git a/src/format/yaml/printer.rs b/src/format/yaml/printer.rs index e6427fe529a1..c218f256ee80 100644 --- a/src/format/yaml/printer.rs +++ b/src/format/yaml/printer.rs @@ -30,6 +30,10 @@ pub(crate) struct Printer<'t, 'a> { /// `printedEmptyLineCache`: for each position, whether a node ends there whose next line has been looked at. pub(crate) printed_empty_lines: Vec, pub(crate) last_group_id: u32, + /// How many keys are around what is being printed. + pub(crate) key_depth: u32, + /// Where the first character of the text is that is no white space. + pub(crate) start_of_content: u32, /// The document. pub(crate) out: &'t mut Elements, pub(crate) stack: &'t Stack, @@ -491,8 +495,18 @@ impl<'t, 'a: 't> Printer<'t, 'a> { }; let (head, body) = (self.node(head_id), self.node(body_id)); let mut has_head = false; + // At the start of a line, where Prettier puts it, it would be a marker. + let looks_like_marker = self.first_child(body).is_some_and(|it| { + it.kind == Kind::Plain + && it.tag.is_none() + && it.anchor.is_none() + && it.leading_comments.is_empty() + && matches!(self.source(it), [b'-', b'-', b'-', rest @ ..] | [b'.', b'.', b'.', rest @ ..] + if rest.first().is_none_or(u8::is_ascii_whitespace)) + }); // `shouldPrintDocumentHeadEndMarker` if node.directives_end_marker + || looks_like_marker || !head.children.is_empty() || !head.end_comments.is_empty() || head.trailing_comment.is_some() @@ -509,9 +523,12 @@ impl<'t, 'a: 't> Printer<'t, 'a> { has_head = true; } if !body.children.is_empty() || !body.end_comments.is_empty() { - if has_head { + if has_head && (!looks_like_marker || head.trailing_comment.is_some()) { self.out.hard_line(); } + if looks_like_marker { + self.out.token(" "); + } self.print(body_id, is_last_descendant); } } @@ -853,6 +870,12 @@ impl<'t, 'a: 't> Printer<'t, 'a> { merged.push(Words::Slices(words)); continue; } + // For Prettier the empty lines at the end of a scalar that keeps them are lines with an empty word here, so + // it does not take the last for the line break that ends the scalar, and there is one more. + if words.is_empty() && node.chomping == Chomping::Keep { + merged.push(Words::None); + continue; + } if self.prose_wrap == ProseWrap::Never { merged.push(Words::Joined(words.join(&b" "[..]))); continue; @@ -976,7 +999,10 @@ impl<'t, 'a: 't> Printer<'t, 'a> { out.end_indent(); } } - } else if node.chomping == Chomping::Keep && is_last_descendant && self.is_last_document + } else if node.chomping == Chomping::Keep + && is_last_descendant + && self.is_last_document + && self.key_depth == 0 { out.start_indent(IndentCommand::DedentToRoot); match words.is_empty() { @@ -1090,6 +1116,33 @@ impl<'t, 'a: 't> Printer<'t, 'a> { } /// `printMappingItem` + /// To Prettier a key is the last of what it is in if the pair is. So it ends a scalar in it that keeps its line breaks as + /// it ends one at the end of the file, with one line break more. + #[inline] + fn print_key(&mut self, key: Id, is_last_descendant: bool) { + self.key_depth += 1; + self.print(key, is_last_descendant); + self.key_depth -= 1; + } + + /// Whether a scalar in `node` is on more than one line. + fn has_scalar_over_several_lines(&self, node: Option<&Node<'a>>) -> bool { + let tree = self.tree; + let mut left: Vec = node.map_or(Vec::new(), |it| tree.items(it.children).collect()); + while let Some(id) = left.pop() { + let node = self.node(id); + if matches!( + node.kind, + Kind::Plain | Kind::QuoteDouble | Kind::QuoteSingle + ) && node.position.start.line != node.position.end.line + { + return true; + } + left.extend(tree.items(node.children)); + } + false + } + fn print_mapping_item(&mut self, node: &'t Node<'a>, is_last_descendant: bool) { let (Some(key_id), Some(value_id), 2) = ( node.children.first(), @@ -1124,11 +1177,15 @@ impl<'t, 'a: 't> Printer<'t, 'a> { b"" }; + // At the start of the file, U+FEFF is a byte order mark. Prettier puts it there. + let would_be_byte_order_mark = node.position.start.offset == self.start_of_content + && key_content.is_some_and(|it| self.source(it).starts_with(text::BOM)); + if is_empty_value { if node.kind == Kind::FlowMappingItem && parent.is_some_and(|it| it.kind == Kind::FlowMapping) { - return self.print(key_id, is_last_descendant); + return self.print_key(key_id, is_last_descendant); } let is_in_set = parent .and_then(|it| it.tag) @@ -1137,14 +1194,15 @@ impl<'t, 'a: 't> Printer<'t, 'a> { && self.is_absolutely_printed_as_single_line(key_content) && key_content.is_none_or(|it| it.trailing_comment.is_none()) && !is_in_set + && !would_be_byte_order_mark { - self.print(key_id, is_last_descendant); + self.print_key(key_id, is_last_descendant); self.out.text(space_before_colon); return self.out.token(":"); } self.out.token("? "); self.start_align(2); - self.print(key_id, is_last_descendant); + self.print_key(key_id, is_last_descendant); return self.out.end_indent(); } @@ -1156,10 +1214,13 @@ impl<'t, 'a: 't> Printer<'t, 'a> { } // An explicit key. - if !value.leading_comments.is_empty() || !is_inline_node(key_content) { + if !value.leading_comments.is_empty() + || !is_inline_node(key_content) + || would_be_byte_order_mark + { self.out.token("? "); self.start_align(2); - self.print(key_id, is_last_descendant); + self.print_key(key_id, is_last_descendant); self.out.end_indent(); self.out.hard_line(); let tree = self.tree; @@ -1197,7 +1258,7 @@ impl<'t, 'a: 't> Printer<'t, 'a> { && self.is_absolutely_printed_as_single_line(value_content) && self.is_absolutely_printed_as_single_line(key_content) { - self.print(key_id, is_last_descendant); + self.print_key(key_id, is_last_descendant); self.out.text(space_before_colon); self.out.token(": "); if value.end_comments.is_empty() { @@ -1248,7 +1309,7 @@ impl<'t, 'a: 't> Printer<'t, 'a> { }); // A key that is on one line for sure is implicit, however long it is. if self.is_absolutely_printed_as_single_line(key_content) && key_has_no_comments { - self.print(key_id, is_last_descendant); + self.print_key(key_id, is_last_descendant); self.start_align(self.tab_width); write_colon(self.out); self.print(value_id, is_last_descendant); @@ -1256,20 +1317,26 @@ impl<'t, 'a: 't> Printer<'t, 'a> { return self.out.end_group(); } - // Explicit if the key breaks, implicit otherwise. + // Explicit if the key breaks, implicit otherwise. The line break in a scalar in a pair in `{ }` breaks no group + // around the pair, so Prettier prints a key over several lines that is implicit, which cannot be read. + let should_break = self.has_scalar_over_several_lines(key_content); self.last_group_id += 1; let group_id = self.last_group_id; - self.out.start_group(Group::default()); + self.out.start_group(Group { + should_break, + ..Group::default() + }); self.out.start_if_break(0); self.out.token("? "); self.out.otherwise(); self.out.end_if_break(); self.out.start_group(Group { id: group_id, + should_break, ..Group::default() }); self.start_align(2); - self.print(key_id, is_last_descendant); + self.print_key(key_id, is_last_descendant); self.out.end_indent(); self.out.end_group(); self.out.end_group(); diff --git a/src/glob/matcher.rs b/src/glob/matcher.rs index 90fb6c465d00..81fdf738c50a 100644 --- a/src/glob/matcher.rs +++ b/src/glob/matcher.rs @@ -23,9 +23,9 @@ // THE SOFTWARE. use bun_collections::smallvec::SmallVec; -use bun_core::strings; -use std::sync::OnceLock; +use bun_core::{Once, strings}; use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU32, Ordering}; +use std::sync::{Arc, OnceLock}; /// Result of [`match`](r#match). Two independent bits: whether the path matched /// (after applying any leading `!` negation), and whether the pattern was negated. @@ -60,16 +60,48 @@ impl MatchResult { } } -#[derive(Copy, Clone, Default)] +#[derive(Copy, Clone)] struct State { path_index: u32, glob_index: u32, wildcard: Wildcard, globstar: Wildcard, + + seen: Seen, + /// No `[` at or after this index is closed. + no_class_from: u32, + /// What looks like a group at this index isn't one. + not_group_at: u32, + /// How the loop has ended. + outcome: Outcome, } impl State { + #[inline(always)] + fn at(glob_index: u32, path_index: u32, seen: Seen) -> State { + State { + path_index, + glob_index, + wildcard: Wildcard::default(), + globstar: Wildcard::default(), + seen, + no_class_from: u32::MAX, + not_group_at: u32::MAX, + outcome: Outcome::No, + } + } + + /// Goes back to the last `*` or `**`. Returns false if there's none that can take more. + #[inline(always)] + fn goes_back(&mut self, path: &[u8]) -> bool { + let can = self.wildcard.path_index > 0 && self.wildcard.path_index as usize <= path.len(); + if can { + self.backtrack(); + } + can + } + #[inline(always)] fn backtrack(&mut self) { self.path_index = self.wildcard.path_index; @@ -77,7 +109,8 @@ impl State { } #[inline(always)] - fn skip_to_separator(&mut self, path: &[u8], is_end_invalid: bool) { + fn skip_to_separator(&mut self, path: &[u8], is_end_invalid: bool, is_windows: bool) { + let is_separator = |it: u8| it == b'/' || is_windows && it == b'\\'; if self.path_index as usize == path.len() { self.wildcard.path_index += 1; self.globstar = self.wildcard; @@ -85,9 +118,7 @@ impl State { } let mut path_index = self.path_index; - while (path_index as usize) < path.len() - && !is_separator::(path[path_index as usize]) - { + while (path_index as usize) < path.len() && !is_separator(path[path_index as usize]) { path_index += 1; } @@ -161,13 +192,7 @@ pub fn r#match(glob: &[u8], path: &[u8]) -> MatchResult { /// Use `match_on::` for text that isn't a file path and for paths already normalized to `/`. #[inline] pub fn match_on(glob: &[u8], path: &[u8]) -> MatchResult { - if differs_at_once(glob, path) { - return MatchResult::NO; - } - match WINDOWS { - true => match_for_windows(glob, None, path, Dots::Match), - false => match_for_posix(glob, None, path, Dots::Match), - } + match_from_first_byte::(glob, None, path, Dots::Match) } /// How wildcards treat path segments that start with `.`. @@ -190,360 +215,600 @@ pub fn match_prepared( path: &[u8], dots: Dots, ) -> MatchResult { - if differs_at_once(glob, path) { + match_from_first_byte::(glob, Some(shape), path, dots) +} + +/// This is at the call site. Most paths that a pattern is asked about differ from it in the first byte. +#[inline(always)] +fn match_from_first_byte( + glob: &[u8], + shape: Option<&Shape>, + path: &[u8], + dots: Dots, +) -> MatchResult { + let Some(&first) = glob.first() else { + return MatchResult { + matches: path.is_empty(), + negated: false, + }; + }; + let is_plain = IS_PLAIN[first as usize]; + if is_plain && path.first() != Some(&first) { return MatchResult::NO; } - match WINDOWS { - true => match_for_windows(glob, Some(shape), path, dots), - false => match_for_posix(glob, Some(shape), path, dots), + if let Some(shape) = shape { + let uses = shape.uses.load(Ordering::Relaxed); + // From now on a `Walk` decides. + if uses == Shape::WALKS { + return match WINDOWS { + true => match_warm_for_windows(glob, shape, path, dots), + false => match_warm_for_posix(glob, shape, path, dots), + }; + } + shape.count(glob, uses); + } + match first { + _ if is_plain => match WINDOWS { + true => match_literal_for_windows(glob, shape, path, dots), + false => match_literal_for_posix(glob, shape, path, dots), + }, + b'!' | b'\\' | b'?' | b'[' | 0x80.. => match WINDOWS { + true => match_escapes_for_windows(glob, shape, path, Start::of(dots)), + false => match_escapes_for_posix(glob, shape, path, Start::of(dots)), + }, + _ => match WINDOWS { + true => match_for_windows(glob, shape, path, Start::of(dots), None), + false => match_for_posix(glob, shape, path, Start::of(dots), None), + }, } } -/// Most paths that a pattern is asked about differ from it in the first byte. This is checked at the call site. -#[inline(always)] -fn differs_at_once(glob: &[u8], path: &[u8]) -> bool { - glob.first() - .is_some_and(|first| IS_PLAIN[*first as usize] && path.first() != Some(first)) +// None of these is generic, so that the binary has them once per platform and not once per crate that calls them. +#[inline(never)] +fn match_literal_for_posix( + glob: &[u8], + shape: Option<&Shape>, + path: &[u8], + dots: Dots, +) -> MatchResult { + match_literal::(glob, shape, path, dots) +} + +#[inline(never)] +fn match_literal_for_windows( + glob: &[u8], + shape: Option<&Shape>, + path: &[u8], + dots: Dots, +) -> MatchResult { + match_literal::(glob, shape, path, dots) +} + +#[inline(never)] +fn match_escapes_for_posix( + glob: &[u8], + shape: Option<&Shape>, + path: &[u8], + start: Start, +) -> MatchResult { + match_escapes::(glob, shape, path, start) } -// Not generic, so that the binary has the loop once per platform and not once per crate that calls it. #[inline(never)] -fn match_for_posix(glob: &[u8], shape: Option<&Shape>, path: &[u8], dots: Dots) -> MatchResult { - match_as::(glob, shape, path, dots) +fn match_escapes_for_windows( + glob: &[u8], + shape: Option<&Shape>, + path: &[u8], + start: Start, +) -> MatchResult { + match_escapes::(glob, shape, path, start) } #[inline(never)] -fn match_for_windows(glob: &[u8], shape: Option<&Shape>, path: &[u8], dots: Dots) -> MatchResult { - match_as::(glob, shape, path, dots) +fn match_warm_for_posix(glob: &[u8], shape: &Shape, path: &[u8], dots: Dots) -> MatchResult { + match_warm::(glob, shape, path, dots) } -#[inline(always)] -fn match_as( +#[inline(never)] +fn match_warm_for_windows(glob: &[u8], shape: &Shape, path: &[u8], dots: Dots) -> MatchResult { + match_warm::(glob, shape, path, dots) +} + +#[inline(never)] +fn match_for_posix( glob: &[u8], shape: Option<&Shape>, path: &[u8], - dots: Dots, + start: Start, + resumed: Option<&mut State>, ) -> MatchResult { - let glob_start = negations(glob) as u32; - let negated = glob_start % 2 == 1; - let mut front = Front { - glob_start, - glob_index: glob_start, - path_index: 0, - seen: Seen::OnlyRegular, - uses: shape.map_or(0, |it| it.uses.load(Ordering::Relaxed)), - }; - let decided = match front.uses == Shape::WALKS { - true => None, - false => front.take::(glob, path, dots), - }; - #[cfg(debug_assertions)] - if let Some(matched) = decided - && path.len() < Program::IS_LONG - { - let mut parsed = Parsed::new(); - parsed.read(glob); - debug_assert!( - !parsed.is_regular - || matched == match_groups::(glob, glob_start, path, &parsed, dots, false), - "the front and the walk differ" - ); - } - let matched = match decided { - Some(matched) => matched, - None => match_rest::(glob, shape, path, dots, front), - }; - MatchResult { - matches: matched != negated, - negated, - } + match_more::(glob, shape, path, start, resumed) } -/// What the start of a pattern has taken of a path. +#[inline(never)] +fn match_for_windows( + glob: &[u8], + shape: Option<&Shape>, + path: &[u8], + start: Start, + resumed: Option<&mut State>, +) -> MatchResult { + match_more::(glob, shape, path, start, resumed) +} + +/// Where the loop starts. #[derive(Copy, Clone)] -struct Front { +struct Start { /// Index of the pattern after the leading `!`s. glob_start: u32, /// Indexes of the pattern and of the path after what has been taken. glob_index: u32, path_index: u32, seen: Seen, - /// See [`Shape::uses`]. - uses: u8, + dots: Dots, } -impl Front { - /// Takes the tokens at the start of the pattern that take one character of the path whatever follows: literal and escaped - /// bytes, `/`, `?` and classes. Returns the answer if that decides the match, or if [`match_tail`] does. +impl Start { #[inline(always)] - fn take(&mut self, glob: &[u8], path: &[u8], dots: Dots) -> Option { - let (mut at, mut taken) = (self.glob_index as usize, 0); - while let Some(&wanted) = glob.get(at) { - let found = path.get(taken).copied(); - let takes = if IS_PLAIN[wanted as usize] { - found == Some(wanted) - } else { - match wanted { - b'/' => found.is_some_and(is_separator::), - b'?' | b'[' if dots != Dots::Match || found.is_some_and(|it| it >= 128) => { - break; - } - b'?' if glob.get(at + 1) == Some(&b'(') => break, - b'?' => found.is_some_and(|it| !is_separator::(it)), - b'[' => { - let Some((has, close)) = ascii_class(glob, at, found.unwrap_or(0)) else { - break; - }; - (at, self.seen) = (close, Seen::Odd); - has && found.is_some() - } - b'\\' if glob.get(at + 1).is_some_and(|it| *it < 128 && *it != b'/') => { - let (mut escaped, mut index) = (wanted, at as u32); - unescape(&mut escaped, glob, &mut index); - (at, self.seen) = (at + 1, Seen::Odd); - found == Some(escaped) - } - // The loop compares as many bytes as the first one announces, whatever they are. - 0x80.. => { - let len = usize::from(strings::wtf8_byte_sequence_length(wanted)); - let takes = (0..len).all(|i| { - let wanted = glob.get(at + i); - wanted.is_some() && wanted == path.get(taken + i) - }); - (at, taken) = (at + len - 1, taken + len - 1); - takes - } - _ => break, - } - }; - if !takes { - return Some(false); - } - at += 1; - taken += 1; - } - (self.glob_index, self.path_index) = (at as u32, taken as u32); - if at == glob.len() { - return Some(taken == path.len()); - } - if dots != Dots::Match { - return None; + fn of(dots: Dots) -> Start { + Start { + glob_start: 0, + glob_index: 0, + path_index: 0, + seen: Seen::OnlyRegular, + dots, } - let starts_segment = at == self.glob_start as usize || glob[at - 1] == b'/'; - match_tail::(&glob[at..], starts_segment, &path[taken..]) } } -/// Decides whether `tail`, the end of a pattern, matches `rest`, the end of a path, if `tail` is `**`, or if it's `**/`, `*` or -/// `**/*` followed only by tokens that take one character: literal bytes, `?`, classes and, without the `*`, `/`. -/// Then those tokens have to match the end of `rest`, and only what's before that is left to look at. +/// Takes the literal bytes that the pattern starts with. They need nothing of what follows. #[inline(always)] -fn match_tail( - mut tail: &[u8], - starts_segment: bool, - rest: &[u8], -) -> Option { - let mut any_depth = false; - while starts_segment && let Some(after) = tail.strip_prefix(b"**") { - match after { - [] => return Some(true), - [b'/', after @ ..] => (tail, any_depth) = (after, true), - _ => return None, +fn match_literal( + glob: &[u8], + shape: Option<&Shape>, + path: &[u8], + dots: Dots, +) -> MatchResult { + let mut taken = 0; + while let Some(&wanted) = glob.get(taken) { + let is_same = match path.get(taken) { + Some(&found) if IS_PLAIN[wanted as usize] => found == wanted, + Some(&found) if wanted == b'/' => is_separator::(found), + None if IS_PLAIN[wanted as usize] || wanted == b'/' => false, + _ => break, + }; + if !is_same { + return MatchResult::NO; } + taken += 1; } - let any_start = tail.first() == Some(&b'*'); - if any_start { - tail = &tail[1..]; - } - // `**/*` has a rule of its own. - if !any_depth && !any_start - || any_depth && tail.is_empty() - || any_start && tail.first() == Some(&b'(') - { - return None; + if taken == glob.len() { + return MatchResult { + matches: taken == path.len(), + negated: false, + }; } - let (width, is_literal) = width_of(tail, !any_start)?; - let Some(before) = rest.len().checked_sub(width) else { - return Some(false); + let start = Start { + glob_index: taken as u32, + path_index: taken as u32, + ..Start::of(dots) }; - // Each of the last `width` bytes has to be a character. - if !is_literal && !rest.is_ascii() { - return None; - } - let (before, end) = rest.split_at(before); - let mut at = 0; - for &found in end { - let wanted = tail[at]; - let takes = match wanted { - b'/' => is_separator::(found), - b'?' => !is_separator::(found), - b'[' => { - let (has, close) = ascii_class(tail, at, found)?; - at = close; - has - } - _ => found == wanted, - }; - if !takes { - return Some(false); - } - at += 1; + match (matches!(glob[taken], b'\\' | b'?' | b'[' | 0x80..), WINDOWS) { + (true, true) => match_escapes_for_windows(glob, shape, path, start), + (true, false) => match_escapes_for_posix(glob, shape, path, start), + (false, true) => match_for_windows(glob, shape, path, start, None), + (false, false) => match_for_posix(glob, shape, path, start, None), } - Some(match (any_depth, any_start) { - (true, true) => true, - (true, false) => before.last().is_none_or(|it| is_separator::(*it)), - (false, _) => !before.iter().any(|it| is_separator::(*it)), - }) } -/// Returns how many characters `tokens` takes, and whether it's all literal, if it has only tokens that take one character. +/// The same for a pattern that starts with `!`, or has other tokens among those bytes that take one character whatever follows: +/// escapes, `?` and classes. #[inline(always)] -fn width_of(tokens: &[u8], may_have_slash: bool) -> Option<(usize, bool)> { - let (mut width, mut is_literal) = (0, true); - let mut at = 0; - while let Some(&ch) = tokens.get(at) { - if !IS_PLAIN[ch as usize] { - match ch { - b'/' if may_have_slash => {} - b'?' if tokens.get(at + 1) != Some(&b'(') => is_literal = false, - // With a class that can match a separator, the loop doesn't find every match. - b'[' => { - let (has_slash, close) = ascii_class(tokens, at, b'/')?; - if has_slash || ascii_class(tokens, at, b'\\')?.0 { - return None; - } - (at, is_literal) = (close, false); - } - _ => return None, +fn match_escapes( + glob: &[u8], + shape: Option<&Shape>, + path: &[u8], + start: Start, +) -> MatchResult { + let dots = start.dots; + let glob_start = match glob[0] { + b'!' => negations(glob), + _ => 0, + }; + let negated = glob_start % 2 == 1; + let (mut index, mut path_index) = ( + glob_start.max(start.glob_index as usize), + start.path_index as usize, + ); + let mut seen = Seen::OnlyRegular; + while let Some(&wanted) = glob.get(index) { + let found = path.get(path_index).copied(); + let is_same = if IS_PLAIN[wanted as usize] { + found == Some(wanted) + } else if wanted == b'/' { + found.is_some_and(is_separator::) + } else if wanted >= 128 { + // The loop compares as many bytes as the first one announces. That's the same unless they go on into something else. + let len = usize::from(strings::wtf8_byte_sequence_length(wanted)); + let Some(bytes) = glob + .get(index..index + len) + .filter(|it| !it.iter().any(u8::is_ascii)) + else { + break; + }; + index += len - 1; + path_index += len - 1; + path.get(path_index + 1 - len..=path_index) == Some(bytes) + } else if wanted == b'\\' + && glob + .get(index + 1) + .is_some_and(|it| *it < 128 && *it != b'/') + { + let (mut escaped, mut at) = (wanted, index as u32); + unescape(&mut escaped, glob, &mut at); + index += 1; + if glob[index] == b'\\' { + seen = Seen::Odd; + } + found == Some(escaped) + } else if dots != Dots::Match || found.is_some_and(|it| it >= 128) { + break; + } else if wanted == b'?' && glob.get(index + 1) != Some(&b'(') { + found.is_some_and(|it| !is_separator::(it)) + } else if wanted == b'[' + && let Some((has, close, is_regular)) = ascii_class(glob, index, found.unwrap_or(0)) + { + index = close; + if !is_regular { + seen = Seen::Odd; } + has && found.is_some() + } else { + break; + }; + if !is_same { + return MatchResult { + matches: negated, + negated, + }; } - at += 1; - width += 1; + index += 1; + path_index += 1; + } + if index == glob.len() { + return MatchResult { + matches: (path_index == path.len()) != negated, + negated, + }; + } + let start = Start { + glob_start: glob_start as u32, + glob_index: index as u32, + path_index: path_index as u32, + seen, + dots, + }; + match WINDOWS { + true => match_for_windows(glob, shape, path, start, None), + false => match_for_posix(glob, shape, path, start, None), } - Some((width, is_literal)) } /// [`match_class`] for an ASCII character and a class that has neither escapes nor characters that aren't ASCII. -/// Returns the result and the index of the `]`, or `None` for any other class. +/// Returns the result, the index of the `]`, and whether a regular pattern may have the class. Returns `None` for any other class. #[inline(always)] -fn ascii_class(glob: &[u8], open: usize, c: u8) -> Option<(bool, usize)> { +fn ascii_class(glob: &[u8], open: usize, c: u8) -> Option<(bool, usize, bool)> { let mut at = open + 1; let negated = matches!(glob.get(at), Some(b'^' | b'!')); at += usize::from(negated); - let (mut is_first, mut has) = (true, false); + let first = at; + let mut has = false; + let mut is_regular = !negated && glob.get(at) != Some(&b'('); loop { let low = *glob.get(at)?; - if low == b']' && !is_first { - return Some((has != negated, at)); + if low == b']' && at > first { + return Some((has != negated, at, is_regular)); + } + if low == b'\\' || low >= 128 { + return None; } - let mut high = low; if glob.get(at + 1) == Some(&b'-') - && let Some(&it) = glob.get(at + 2) - && it != b']' + && let Some(&high) = glob.get(at + 2) + && high != b']' { - high = it; - at += 2; - } - if low == b'\\' || high == b'\\' || low >= 128 || high >= 128 { - return None; + if high == b'\\' || high >= 128 { + return None; + } + has |= low <= c && c <= high; + is_regular &= !(low <= b'/' && b'/' <= high) && !(low <= b'\\' && b'\\' <= high); + at += 3; + } else { + has |= low == c; + is_regular &= low != b'/'; + at += 1; } - has |= low <= c && c <= high; - is_first = false; - at += 1; } } -/// Matches what [`Front::take`] has left. -#[inline(never)] -fn match_rest( +/// Result of [`Cache::answer`]. +enum Answer { + Yes, + No, + /// A step is missing. + Unknown, + /// The cache has no steps for bytes that aren't ASCII. + NotAscii, +} + +/// Matches a pattern that has been matched often. This is where such a pattern spends its time. +#[inline(always)] +fn match_warm( glob: &[u8], - shape: Option<&Shape>, + shape: &Shape, path: &[u8], dots: Dots, - front: Front, -) -> bool { - let (glob_start, uses) = (front.glob_start, front.uses); - // Many patterns are matched once. Keeping what a match finds out costs more than finding it out a second time. - let keeps = - |it: &&Shape| uses > 0 || path.len() >= Program::IS_LONG || it.known.get().is_some(); - let mut groups = Groups::new(shape.filter(keeps), dots); - groups.seen = front.seen; - // glob_start must point past the consumed `!` prefix so that a pattern-initial - // `**` is still recognized as being at the start of a path segment. - let matched = match shape { - // A `Cache` has no steps for bytes that aren't ASCII. On those the loop is faster, if it can decide. - Some(shape) - if uses == Shape::WALKS && (shape.parsed(glob).has_group || path.is_ascii()) => - { - match_groups::(glob, glob_start, path, shape.parsed(glob), dots, true) - } - // On a long path a cache pays off at once. - _ if path.len() >= Program::IS_LONG && groups.parsed(glob).may_walk() => { - let is_kept = shape.is_some(); - match_groups::(glob, glob_start, path, groups.parsed(glob), dots, is_kept) +) -> MatchResult { + let parsed = shape.parsed(glob); + let negated = parsed.top % 2 == 1; + let found = |matched: bool| MatchResult { + matches: matched != negated, + negated, + }; + let mut is_ascii = true; + let Some(first) = parsed.take_front(glob, parsed.top, path, WINDOWS) else { + return found(false); + }; + if dots == Dots::Match + && let Some(cache) = parsed.program.get().and_then(|it| it.warm_cache(WINDOWS)) + { + match cache.answer(&path[first as usize..]) { + Answer::Yes => return found(true), + Answer::No => return found(false), + Answer::Unknown => {} + Answer::NotAscii => is_ascii = false, } - _ => { - let taken = (front.glob_index, front.path_index); - let matched = - glob_match_impl::(glob, glob_start, taken, path, &mut groups); - if let Some(shape) = shape { - shape.count(glob, uses); + } + let walked = match is_ascii || path.len() >= Program::IS_LONG { + true => match_at_once(glob, parsed.top, Some(shape), path, dots, WINDOWS), + false => None, + }; + match walked { + Some(matched) => found(matched), + // On those bytes the loop is faster. + None => { + let start = Start { + glob_start: parsed.top, + glob_index: parsed.top, + ..Start::of(dots) + }; + match WINDOWS { + true => match_for_windows(glob, Some(shape), path, start, None), + false => match_for_posix(glob, Some(shape), path, start, None), } - match groups.has_group || matched && groups.is_beside_dot && dots != Dots::Match { - true => match_fully::(glob, glob_start, path, &mut groups), - false => matched, + } + } +} + +/// With `resumed`, only runs the loop from that state on, and leaves the state and the outcome there. See [`match_slowly`]. +#[inline(always)] +fn match_more( + glob: &[u8], + shape: Option<&Shape>, + path: &[u8], + start: Start, + resumed: Option<&mut State>, +) -> MatchResult { + let Start { + glob_start, dots, .. + } = start; + let negated = glob_start % 2 == 1; + let found = |matched: bool| MatchResult { + matches: matched != negated, + negated, + }; + let mut state = State::at(start.glob_index, start.path_index, start.seen); + match &resumed { + Some(it) => state = **it, + None => { + if path.len() >= Program::IS_LONG + && let Some(matched) = match_at_once(glob, glob_start, shape, path, dots, WINDOWS) + { + return found(matched); } } + } + let outcome = run::(glob, glob_start, path, dots, &mut state, WINDOWS); + if let Some(it) = resumed { + state.outcome = outcome; + *it = state; + return MatchResult::NO; + } + let matched = match outcome { + Outcome::No => false, + Outcome::Yes if dots == Dots::Match => true, + _ => { + state.outcome = outcome; + // A first match keeps nothing. + let keeps = |it: &&Shape| { + it.uses.load(Ordering::Relaxed) > 1 + || path.len() >= Program::IS_LONG + || it.known.get().is_some() + }; + let resume: Resume = match WINDOWS { + true => match_for_windows, + false => match_for_posix, + }; + let shape = shape.filter(keeps); + match_slowly( + glob, + glob_start, + shape, + path, + dots, + state, + (WINDOWS, resume), + ) + } }; // The loop and a walk have to agree wherever the loop finds every match. #[cfg(debug_assertions)] - if !groups.has_group && path.len() < Program::IS_LONG { + if path.len() < Program::IS_LONG && std::str::from_utf8(glob).is_ok() { let mut parsed = Parsed::new(); parsed.read(glob); debug_assert!( !parsed.is_regular - || parsed.has_group - || matched == match_groups::(glob, glob_start, path, &parsed, dots, false), + || matched == match_groups(glob, glob_start, path, &parsed, dots, false, WINDOWS), "the loop and the walk differ" ); } - matched + found(matched) } -/// Finishes a match that the fast loop can't decide. +/// Matches with a [`Walk`] if that's the faster way: the pattern has been matched often, or the path is long, so that a cache pays +/// off at once. +#[inline(never)] +fn match_at_once( + glob: &[u8], + glob_start: u32, + shape: Option<&Shape>, + path: &[u8], + dots: Dots, + is_windows: bool, +) -> Option { + let mut groups = Groups::new(shape); + let parsed = groups.parsed(glob); + // A `Cache` has no steps for bytes that aren't ASCII. On those the loop is faster, if it can decide. + let walks = parsed.may_walk() && (path.len() >= Program::IS_LONG || path.is_ascii()); + walks.then(|| { + match_groups( + glob, + glob_start, + path, + parsed, + dots, + shape.is_some(), + is_windows, + ) + }) +} + +/// [`match_for_posix`] or [`match_for_windows`]. +type Resume = fn(&[u8], Option<&Shape>, &[u8], Start, Option<&mut State>) -> MatchResult; + +/// Goes on where the loop has stopped. What's found out about the pattern is kept in `shape`. #[cold] #[inline(never)] -fn match_fully( +fn match_slowly( glob: &[u8], glob_start: u32, + shape: Option<&Shape>, path: &[u8], - groups: &mut Groups<'_>, + dots: Dots, + mut state: State, + (is_windows, resume): (bool, Resume), ) -> bool { - // The fast loop ignores the rules for dot segments. Rerun with them only if the path has such a segment. - if !groups.has_group { - if !has_closed_segment::(path, groups.dots) - || glob_match_impl::(glob, glob_start, (glob_start, 0), path, groups) - { - return true; + let mut groups = Groups::new(shape); + let mut is_strict = false; + let start = Start { + glob_start, + ..Start::of(dots) + }; + loop { + let goes_on = match state.outcome { + Outcome::No => return false, + // The loop ignores the rules for dot segments. Rerun with them only if the path has such a segment. + Outcome::Yes => { + if is_strict + || dots == Dots::Match + || !has_closed_segment_on(path, dots, is_windows) + { + return true; + } + is_strict = true; + state = State::at(glob_start, 0, state.seen); + true + } + Outcome::Odd => { + if !groups.parsed(glob).is_regular { + break; + } + state.seen = Seen::Odd; + true + } + Outcome::Unclosed => { + state.no_class_from = state.glob_index; + true + } + Outcome::Group => { + let at = At { + is_strict, + seen: state.seen, + }; + let (index, path_index) = (state.glob_index, state.path_index); + let tried = try_group(glob, index, path, path_index, at, &mut groups, is_windows); + // Once the pattern has been read, the loop doesn't take anything for a `Choice` by itself. + if groups.is_parsed() { + state.seen = Seen::Odd; + } + match tried { + None => { + state.not_group_at = state.glob_index; + true + } + Some(Tried::IsGroup) => break, + Some(Tried::Failed) => state.goes_back(path), + Some(Tried::Took(glob_index, path_index)) => { + (state.glob_index, state.path_index) = (glob_index, path_index); + true + } + // A `*` before the group was part of the segment. + Some(Tried::TookSegment(glob_index, path_index)) => { + (state.glob_index, state.path_index) = (glob_index, path_index); + (state.wildcard, state.seen) = (state.globstar, Seen::Choice); + true + } + Some(Tried::FailedSegment) => { + (state.wildcard, state.seen) = (state.globstar, Seen::Choice); + state.goes_back(path) + } + } + } + }; + if !goes_on { + return false; + } + match is_strict { + true => { + state.outcome = run_strictly(glob, glob_start, path, dots, &mut state, is_windows) + } + false => _ = resume(glob, None, path, start, Some(&mut state)), } } - let (dots, is_kept) = (groups.dots, groups.prepared.is_some()); - groups.has_group - && match_groups::(glob, glob_start, path, groups.parsed(glob), dots, is_kept) + let is_kept = groups.prepared.is_some(); + match_groups( + glob, + glob_start, + path, + groups.parsed(glob), + dots, + is_kept, + is_windows, + ) } /// Matches with a [`Walk`] instead of the loop. With `is_kept`, what's found out about the pattern stays with `parsed`. #[inline(never)] -fn match_groups( +fn match_groups( glob: &[u8], glob_start: u32, path: &[u8], parsed: &Parsed, dots: Dots, is_kept: bool, + is_windows: bool, ) -> bool { // Most paths that don't match differ in their first bytes, which a walk from the end would see last. - let Some(after_front) = parsed.take_front::(glob, glob_start, path) else { + let Some(after_front) = parsed.take_front(glob, glob_start, path, is_windows) else { return false; }; if parsed.first_group != u32::MAX @@ -571,31 +836,29 @@ fn match_groups( program } }; - let dots = match dots != Dots::Match && has_closed_segment::(path, dots) { + let dots = match dots != Dots::Match && has_closed_segment_on(path, dots, is_windows) { true => dots, false => Dots::Match, }; - match program.cache(glob, WINDOWS, path.len()) { + match program.cache(glob, is_windows, path.len()) { Some(cache) => cache.matches(program, glob, path, after_front as usize, dots), None => program - .on(glob, path, after_front as usize, WINDOWS, dots) + .on(glob, path, after_front as usize, is_windows, dots) .walk(), } } -/// Returns true if `path` has a `.` at `index` or right before it. -#[inline(always)] -fn is_beside_dot(path: &[u8], index: u32) -> bool { - let index = index as usize; - path.get(index) == Some(&b'.') || index > 0 && path[index - 1] == b'.' +/// Returns true if `path` has a `.` or `..` segment or, with [`Dots::Hide`], a segment that starts with `.`. +#[inline] +pub(crate) fn has_closed_segment(path: &[u8], dots: Dots) -> bool { + has_closed_segment_on(path, dots, WINDOWS) } -/// Returns true if `path` has a `.` or `..` segment or, with [`Dots::Hide`], a segment that starts with `.`. #[inline(never)] -pub(crate) fn has_closed_segment(path: &[u8], dots: Dots) -> bool { +fn has_closed_segment_on(path: &[u8], dots: Dots, is_windows: bool) -> bool { let mut from = 0; while let Some(dot) = strings::index_of_char_pos(path, b'.', from) { - if is_closed_segment_at(path, dot as u32, dots, WINDOWS) { + if is_closed_segment_at(path, dot as u32, dots, is_windows) { return true; } from = dot + 1; @@ -642,25 +905,108 @@ static IS_PLAIN: [bool; 256] = { table }; +#[cold] +#[inline(never)] +fn run_strictly( + glob: &[u8], + glob_start: u32, + path: &[u8], + dots: Dots, + state: &mut State, + is_windows: bool, +) -> Outcome { + run::(glob, glob_start, path, dots, state, is_windows) +} + +/// After the last `**/` of a pattern, `tail` is what's left of the pattern and `rest` what's left of the path. If `tail` matches as +/// many segments as it has itself, returns where in `rest` it has to start, or `None` if `rest` has fewer segments. +/// Returns `Err` if it can't tell. +#[inline(never)] +fn last_segments(tail: &[u8], rest: &[u8], is_windows: bool) -> Result, ()> { + // `**/*` has a rule of its own. + if tail == b"*" { + return Err(()); + } + let mut slashes = 0; + // How many `{` and `(` are open. + let (mut braces, mut parentheses) = (0_u32, 0_u32); + let mut at = 0; + while let Some(&ch) = tail.get(at) { + at += 1; + match ch { + _ if IS_TEXT[ch as usize] => {} + b'/' if braces + parentheses > 0 => return Err(()), + b'/' => slashes += 1, + b'{' => braces += 1, + b'(' => parentheses += 1, + b'}' => braces = braces.saturating_sub(1), + b')' => parentheses = parentheses.saturating_sub(1), + b'*' if tail.get(at) == Some(&b'*') => return Err(()), + b'[' => match AsciiClass::read(tail, at - 1) { + Some(class) if class.is_regular(tail, at - 1) => at = class.end as usize, + _ => return Err(()), + }, + b'\\' => match tail.get(at) { + Some(b'/' | b'\\' | 0x80..) | None => return Err(()), + Some(_) => at += 1, + }, + 0x80.. => return Err(()), + _ => {} + } + } + for (index, &found) in rest.iter().enumerate().rev() { + if found == b'/' || is_windows && found == b'\\' { + if slashes == 0 { + return Ok(Some(index + 1)); + } + slashes -= 1; + } + } + Ok((slashes == 0).then_some(0)) +} + +/// How [`run`] ends. +#[derive(Copy, Clone, PartialEq, Eq)] +enum Outcome { + No, + Yes, + /// It's at a group that isn't a [`Choice`]. + Group, + /// It has taken a [`Choice`], and is at a token that a regular pattern may not have. + Odd, + /// It's at a `[` that isn't closed. + Unclosed, +} + /// The loop for patterns without groups, and for groups that are a [`Choice`]. It keeps one `*` and one `**` to go back to. -/// At any other group it sets `groups.has_group` and returns false: see [`Program`]. -/// `glob_start` is the index where the pattern starts. `STRICT` applies the rules for dot segments (see `match_fully`). -/// The caller has matched the pattern up to index `taken.0` with the first `taken.1` bytes of the path (see [`Front`]). +/// `glob_start` is the index where the pattern starts. `STRICT` applies the rules for dot segments (see `match_slowly`). #[inline(always)] -fn glob_match_impl( +fn run( glob: &[u8], glob_start: u32, - taken: (u32, u32), path: &[u8], - groups: &mut Groups<'_>, -) -> bool { - let mut state = State { - glob_index: taken.0, - path_index: taken.1, - ..State::default() - }; - let state = &mut state; - let dots = groups.dots; + dots: Dots, + state: &mut State, + is_windows: bool, +) -> Outcome { + let is_separator = |it: u8| it == b'/' || is_windows && it == b'\\'; + let (no_class_from, not_group_at) = (state.no_class_from, state.not_group_at); + let mut kept = Kept::NOTHING; + // Stops at the current token. + macro_rules! stop { + ($outcome:expr) => { + return $outcome + }; + } + // The loop is at a token that a regular pattern may not have. + macro_rules! see_odd_token { + () => {{ + if state.seen == Seen::Choice { + stop!(Outcome::Odd); + } + state.seen = Seen::Odd; + }}; + } 'main_loop: while (state.glob_index as usize) < glob.len() || (state.path_index as usize) < path.len() { @@ -668,17 +1014,112 @@ fn glob_match_impl( 'fallthrough: { let ch = glob[state.glob_index as usize]; if IS_PLAIN[ch as usize] { - if path.get(state.path_index as usize) == Some(&ch) { - state.glob_index += 1; - state.path_index += 1; - continue 'main_loop; + // All the literal bytes that follow, in a loop of their own. + let (mut index, mut path_index) = + (state.glob_index as usize, state.path_index as usize); + while let Some(&wanted) = glob.get(index) + && IS_PLAIN[wanted as usize] + { + if path.get(path_index) != Some(&wanted) { + break 'fallthrough; + } + index += 1; + path_index += 1; } - break 'fallthrough; + state.glob_index = index as u32; + state.path_index = path_index as u32; + continue 'main_loop; + } + // A `*` before a literal goes to the next place in this segment where that occurs, and takes it. + if !STRICT + && ch == b'*' + && let Some(&wanted) = glob.get(state.glob_index as usize + 1) + && IS_TEXT[wanted as usize] + { + let mut index = state.path_index as usize; + while let Some(&found) = path.get(index) + && found != wanted + && !is_separator(found) + { + index += 1; + } + match path.get(index) { + Some(&found) if found == wanted => { + state.wildcard.glob_index = state.glob_index; + state.wildcard.path_index = index as u32 + 1; + state.glob_index += 2; + state.path_index = index as u32 + 1; + continue 'main_loop; + } + Some(_) => { + state.wildcard = state.globstar; + break 'fallthrough; + } + None => return Outcome::No, + } + } + // A `*` before a `/` or at the end of the pattern takes the rest of the segment. + if !STRICT + && ch == b'*' + && glob + .get(state.glob_index as usize + 1) + .is_none_or(|it| *it == b'/') + { + let mut index = state.path_index as usize; + while let Some(&found) = path.get(index) + && !is_separator(found) + { + index += 1; + } + let is_last = state.glob_index as usize + 1 == glob.len(); + if index == path.len() { + return match is_last { + true => Outcome::Yes, + false => Outcome::No, + }; + } + state.wildcard = state.globstar; + if is_last { + break 'fallthrough; + } + state.glob_index += 2; + state.path_index = index as u32 + 1; + continue 'main_loop; + } + // Back at a `**/`: on to the next segment, or to the next one that starts with the literal after it. + if !STRICT + && ch == b'*' + && state.globstar.glob_index == state.glob_index + && state.globstar.path_index == state.path_index + && state.path_index > 0 + && (state.path_index as usize) < path.len() + && let Some(&wanted) = glob.get(state.glob_index as usize + 3) + && !matches!(wanted, b'{' | b'@' | b'+') + { + let is_literal = IS_PLAIN[wanted as usize] || wanted >= 128; + let mut start = state.path_index as usize; + let next = loop { + let mut end = start; + while let Some(&found) = path.get(end) + && !is_separator(found) + { + end += 1; + } + if !is_literal || end >= path.len() || path[start] == wanted { + break end + 1; + } + start = end + 1; + }; + state.glob_index += 3; + state.path_index = start as u32; + state.globstar.path_index = next as u32; + state.wildcard = state.globstar; + continue 'main_loop; } // `**` can still match nothing, so it's handled below. if STRICT && matches!(ch, b'*' | b'?' | b'[') - && is_closed_segment_at(path, state.path_index, dots, WINDOWS) + && is_closed_segment_at(path, state.path_index, dots, is_windows) && !glob[state.glob_index as usize..].starts_with(b"**") && glob.get(state.glob_index as usize + 1) != Some(&b'(') { @@ -689,44 +1130,80 @@ fn glob_match_impl( b'*' | b'?' | b'+' | b'@' | b'!' | b'{' if (ch == b'{' || glob.get(state.glob_index as usize + 1) == Some(&b'(')) - && let Some(tried) = try_group::( + && state.glob_index != not_group_at => + { + if !STRICT + && state.seen != Seen::Odd + && let Some(taken) = take_plain_choice( glob, state.glob_index, path, state.path_index, - state.wildcard.path_index == 0, - groups, - ) => - { - match tried { - Tried::IsGroup => { - groups.has_group = true; - return false; - } - Tried::Decided(is_match) => return is_match, - Tried::Failed => break 'fallthrough, - Tried::Took(glob_index, path_index) => { - state.glob_index = glob_index; - state.path_index = path_index; - continue 'main_loop; - } - // A `*` before the group was part of the segment. - Tried::TookSegment(glob_index, path_index) => { - state.glob_index = glob_index; - state.path_index = path_index; - state.wildcard = state.globstar; - continue 'main_loop; + is_windows, + if state.wildcard.path_index == 0 { + Pending::Nothing + } else if state.wildcard.path_index == state.globstar.path_index + { + Pending::Globstar + } else if state.wildcard.glob_index + 1 == state.glob_index { + Pending::StarBefore + } else { + Pending::Star + }, + ) + { + state.seen = Seen::Choice; + match taken { + Taken::To(glob_index, path_index) => { + state.glob_index = glob_index; + state.path_index = path_index; + continue 'main_loop; + } + Taken::Nothing => break 'fallthrough, } - Tried::FailedSegment => { - state.wildcard = state.globstar; - break 'fallthrough; + } + if !STRICT + && state.seen != Seen::Odd + && let Some(written) = match_written_out( + glob, + state.glob_index as usize, + &Text { + path, + path_index: state.path_index as usize, + is_committed: state.wildcard.path_index == 0, + is_windows, + matches: match is_windows { + true => |pattern, text| { + match_on::(pattern, text).matches() + }, + false => |pattern, text| { + match_on::(pattern, text).matches() + }, + }, + }, + ) + { + #[cfg(debug_assertions)] + written.check(glob, state.glob_index as usize, path, is_windows); + match written { + Written::Rest(true) => return Outcome::Yes, + Written::Rest(false) => return Outcome::No, + // A `*` before the group was part of the segment. + Written::Segment(is_match, glob_index, path_index) => { + state.seen = Seen::Choice; + state.wildcard = state.globstar; + if !is_match { + break 'fallthrough; + } + state.glob_index = glob_index; + state.path_index = path_index; + continue 'main_loop; + } } } + stop!(Outcome::Group); } b'*' => { - if dots != Dots::Match { - groups.is_beside_dot |= is_beside_dot(path, state.path_index); - } // `**(a)` is `*` followed by `*(a)`. let is_globstar = (state.glob_index as usize) + 1 < glob.len() && glob[state.glob_index as usize + 1] == b'*' @@ -737,29 +1214,53 @@ fn glob_match_impl( let at = state.glob_index as usize; is_whole = (at <= glob_start as usize || glob[at - 1] == b'/') && matches!(glob.get(at + 2), None | Some(b'/')); - if !is_whole && !groups.sees_odd_token(glob) { - return false; + if !is_whole { + see_odd_token!(); } // After this it's the last `**` of `**/**`, which is a whole segment as well. if glob.get(at + 3) == Some(&b'*') { skip_globstars(glob, &mut state.glob_index); } + // When the loop gets here for the first time. + if !STRICT + && is_whole + && (state.globstar.path_index == 0 + || state.globstar.glob_index != state.glob_index) + && !glob.ends_with(b"**") + && let Some(tail) = glob.get(state.glob_index as usize + 3..) + && let Ok(start) = last_segments( + tail, + &path[state.path_index as usize..], + is_windows, + ) + { + let Some(start) = start else { + return Outcome::No; + }; + state.glob_index += 3; + state.path_index += start as u32; + state.wildcard = Wildcard::default(); + state.globstar = Wildcard::default(); + continue 'main_loop; + } } let is_closed = STRICT - && is_closed_segment_at(path, state.path_index, dots, WINDOWS); + && is_closed_segment_at(path, state.path_index, dots, is_windows); let before = state.wildcard; // False if the group after the wildcard can't start where the wildcard stops. let mut can_start = true; // Skip ahead to the next place in this segment where the literal after the `*` occurs. - if !is_globstar - && let Some(&wanted) = glob.get(state.glob_index as usize + 1) + // A `**` that isn't a whole segment is like a `*`. + if (!is_globstar || !is_whole) + && let Some(&wanted) = glob + .get(state.glob_index as usize + 1 + usize::from(is_globstar)) && IS_PLAIN[wanted as usize] { while let Some(&found) = path.get(state.path_index as usize) && found != wanted - && !is_separator::(found) + && !is_separator(found) { state.path_index += 1; } @@ -770,50 +1271,20 @@ fn glob_match_impl( { // A `*` at the end of the pattern or before a `/` takes the rest of the segment. while let Some(&found) = path.get(state.path_index as usize) - && !is_separator::(found) + && !is_separator(found) { state.path_index += 1; } - } else if !is_globstar - && dots == Dots::Match - && let Some(&wanted) = glob.get(state.glob_index as usize + 1) - && wanted >= 128 - { - // The same for a literal that isn't ASCII. - while let Some(&found) = path.get(state.path_index as usize) - && found != wanted - && !is_separator::(found) - { - state.path_index += - u32::from(rune_len_at(path, state.path_index as usize)); - } - } else if !is_globstar - && dots == Dots::Match - && glob.get(state.glob_index as usize + 1) == Some(&b'[') - { - // Skip ahead to the next character of the class after the `*`. - while let Some(&found) = path.get(state.path_index as usize) - && !is_separator::(found) - { - let (c, len) = decode_rune_at(path, state.path_index as usize); - if class_has(glob, state.glob_index + 1, c) != Some(false) { - break; - } - state.path_index += u32::from(len); - } - } else if !is_globstar - && matches!( - glob.get(state.glob_index as usize + 1), - Some(b'{' | b'@' | b'+') - ) - { - let skipped = skip_to_start::( + } else if !is_globstar { + let skipped = skip_ahead( glob, state.glob_index + 1, path, state.path_index, - false, - groups, + dots == Dots::Match, + before.path_index == 0 || before.glob_index != state.glob_index, + &mut kept, + is_windows, ); state.path_index = skipped.path_index; can_start = skipped.can_start; @@ -837,7 +1308,7 @@ fn glob_match_impl( if is_end_invalid && state.path_index as usize == path.len() && glob.len() - state.glob_index as usize == 2 - && is_separator::(glob[state.glob_index as usize]) + && is_separator(glob[state.glob_index as usize]) && glob[state.glob_index as usize + 1] == b'*' { continue 'main_loop; @@ -855,12 +1326,11 @@ fn glob_match_impl( // A trailing `**` matches the rest of the path. if !STRICT && !is_end_invalid { - groups.is_beside_dot = true; - return true; + return Outcome::Yes; } // skip to separator - state.skip_to_separator::(path, is_end_invalid); + state.skip_to_separator(path, is_end_invalid, is_windows); in_globstar = true; // Skip segments that don't start with the literal after `**/`. @@ -868,17 +1338,17 @@ fn glob_match_impl( && let Some(&wanted) = glob.get(state.glob_index as usize) && (IS_PLAIN[wanted as usize] || wanted >= 128) { - // It stops at segments that start with a `.`, and may go past them later. - groups.is_beside_dot |= wanted == b'.'; while let Some(&found) = path.get(state.path_index as usize) && found != wanted && state.wildcard.path_index as usize <= path.len() { - groups.is_beside_dot |= found == b'.'; state.path_index = state.wildcard.path_index; state.wildcard.path_index += 1; - state - .skip_to_separator::(path, is_end_invalid); + state.skip_to_separator( + path, + is_end_invalid, + is_windows, + ); } } else if !STRICT && matches!( @@ -887,22 +1357,25 @@ fn glob_match_impl( ) { // Same for a group after `**/`. - let skipped = skip_to_start::( + let skipped = skip_to_start( glob, state.glob_index, path, state.path_index, true, - groups, + &mut kept.firsts, + is_windows, ); can_start = skipped.can_start; let start = skipped.path_index; if start != state.path_index { - groups.is_beside_dot = true; state.path_index = start; state.wildcard.path_index = start + 1; - state - .skip_to_separator::(path, is_end_invalid); + state.skip_to_separator( + path, + is_end_invalid, + is_windows, + ); } } if is_closed { @@ -921,7 +1394,7 @@ fn glob_match_impl( if !in_globstar && (state.path_index as usize) < path.len() - && is_separator::(path[state.path_index as usize]) + && is_separator(path[state.path_index as usize]) { state.wildcard = state.globstar; } @@ -932,11 +1405,8 @@ fn glob_match_impl( continue 'main_loop; } b'?' => { - if dots != Dots::Match { - groups.is_beside_dot |= is_beside_dot(path, state.path_index); - } if (state.path_index as usize) < path.len() { - if !is_separator::(path[state.path_index as usize]) { + if !is_separator(path[state.path_index as usize]) { state.glob_index += 1; state.path_index += u32::from(rune_len_at(path, state.path_index as usize)); @@ -947,30 +1417,32 @@ fn glob_match_impl( break 'to_else; } } - b'[' if state.glob_index < groups.no_class_from => { - if !groups.sees_odd_token(glob) { - return false; - } - if dots != Dots::Match { - groups.is_beside_dot |= is_beside_dot(path, state.path_index); - } + b'[' if state.glob_index < no_class_from => { if (state.path_index as usize) < path.len() { // source unicode char to match against the target + its byte length in `path` let (c, len) = decode_rune_at(path, state.path_index as usize); - match match_class(glob, state.glob_index, c) { - Some((true, end)) => { - state.glob_index = end; - state.path_index += u32::from(len); - continue 'main_loop; - } - Some((false, _)) => break 'fallthrough, + let class = match kept.class_at(glob, state.glob_index) { + Some(class) => Some((class.has(c), class.end)), + None => class_has_at(glob, state.glob_index, c), + }; + let is_regular = + kept.class_index == state.glob_index && kept.is_regular_class; + let Some((has, end)) = class else { // An unclosed `[` is literal. - None => { - groups.no_class_from = state.glob_index; - continue 'main_loop; - } + see_odd_token!(); + stop!(Outcome::Unclosed); + }; + if !is_regular { + see_odd_token!(); + } + if !has { + break 'fallthrough; } + state.glob_index = end; + state.path_index += u32::from(len); + continue 'main_loop; } else { + see_odd_token!(); break 'to_else; } } @@ -978,15 +1450,19 @@ fn glob_match_impl( } } if (state.path_index as usize) < path.len() { - if ch == b'\\' && !groups.sees_odd_token(glob) { - return false; + if ch == b'\\' + && glob + .get(state.glob_index as usize + 1) + .is_none_or(|it| *it == b'\\') + { + see_odd_token!(); } let mut cc: u8 = ch; unescape(&mut cc, glob, &mut state.glob_index); let cc_len = strings::wtf8_byte_sequence_length(cc); let is_match = if cc == b'/' { - is_separator::(path[state.path_index as usize]) + is_separator(path[state.path_index as usize]) } else if cc_len > 1 { let pi = state.path_index as usize; let gi = state.glob_index as usize; @@ -1012,15 +1488,14 @@ fn glob_match_impl( } } - if state.wildcard.path_index > 0 && state.wildcard.path_index as usize <= path.len() { - state.backtrack(); + if state.goes_back(path) { continue; } - return false; + return Outcome::No; } - true + Outcome::Yes } /// Matches `c` against the class that starts at `open`. Returns the result and the index after the `]`, @@ -1107,6 +1582,11 @@ fn class_shape_slowly(glob: &[u8], open: usize) -> Option<(usize, bool)> { Some((class_end(glob, open)?, has(b'/') || has(b'\\'))) } +#[inline(never)] +fn class_has_at(glob: &[u8], open: u32, c: u32) -> Option<(bool, u32)> { + match_class(glob, open, c) +} + /// [`match_class`] for callers outside the loop. #[inline(never)] fn class_has(glob: &[u8], open: u32, c: u32) -> Option { @@ -1203,37 +1683,21 @@ enum Seen { Choice, } -/// Per-match state of the loop. +/// What a match has found out about the groups of its pattern. struct Groups<'s> { /// What's found out about the pattern is kept here, if the caller passed a shape in and this isn't its first match. prepared: Option<&'s Shape>, unprepared: Option, unprepared_segment: Option>>, - /// The alternatives of the group that [`Groups::plain_at`] has read last. - plain: SmallVec<[Alternative; 8]>, - seen: Seen, - /// The loop reached a group that it can't handle. - has_group: bool, - /// No `[` at or after this index is closed. - no_class_from: u32, - /// A wildcard was evaluated next to a `.` in the path, so `match_as` has to check for `.` and `..` segments. - is_beside_dot: bool, - dots: Dots, } impl<'s> Groups<'s> { #[inline(always)] - fn new(prepared: Option<&'s Shape>, dots: Dots) -> Groups<'s> { + fn new(prepared: Option<&'s Shape>) -> Groups<'s> { Groups { prepared, unprepared: None, unprepared_segment: None, - plain: SmallVec::new(), - seen: Seen::OnlyRegular, - has_group: false, - no_class_from: u32::MAX, - is_beside_dot: false, - dots, } } @@ -1241,62 +1705,6 @@ impl<'s> Groups<'s> { self.unprepared.is_some() || self.prepared.is_some_and(|it| it.parsed_so_far().is_some()) } - /// Returns the alternatives of the group at `open` if it's a `{a,b}`, `@(a|b)` or `?(a)` of plain literals. Such a group is one - /// whatever the rest of the pattern is. `choice` is right if the pattern is regular. - #[inline(never)] - fn plain_at(&mut self, glob: &[u8], open: u32) -> Option<&[Alternative]> { - let (first, separator, closer) = brackets_at(glob, open as usize)?; - if self - .plain - .first() - .is_some_and(|it| it.start as usize == first) - { - return Some(&self.plain[..]); - } - let alternative = |start: usize| Alternative { - start: start as u32, - end: 0, - next: u32::MAX, - close: 0, - choice: Choice::No, - }; - self.plain.clear(); - self.plain.push(alternative(first)); - let mut close = first; - loop { - let ch = glob.get(close).copied().filter(|it| IS_PLAIN[*it as usize]); - if ch == Some(separator) || ch == Some(closer) { - let count = self.plain.len(); - self.plain[count - 1].end = close as u32; - if ch == Some(closer) { - break; - } - self.plain[count - 1].next = count as u32; - self.plain.push(alternative(close + 1)); - } else if ch.is_none() { - self.plain.clear(); - return None; - } - close += 1; - } - let is_optional = glob[open as usize] == b'?'; - // With one alternative it may be a sequence like `{1..3}`. - if self.plain.len() == 1 && !is_optional { - self.plain.clear(); - return None; - } - let len = self.plain[0].end - self.plain[0].start; - let has_one_length = self.plain.iter().all(|it| it.end - it.start == len); - self.plain[0].close = close as u32; - self.plain[0].choice = match glob.get(close + 1) { - None => Choice::RestOfPath, - Some(b'/') => Choice::RestOfSegment, - Some(_) if !is_optional && len > 0 && has_one_length => Choice::OneLength, - Some(_) => Choice::No, - }; - Some(&self.plain[..]) - } - /// Returns the segment of the pattern that `index` is in, read by itself, if it's a [`Segment`] as far as that shows without /// reading the whole pattern. All the loop has seen before `index` are regular tokens and [`Choice`]s. /// Only one such segment is kept per pattern. @@ -1310,22 +1718,6 @@ impl<'s> Groups<'s> { .filter(|it| it.start <= index && index < it.end) } - /// The loop is at a token that a regular pattern may not have. Returns false if it can't go on. - #[inline(always)] - fn sees_odd_token(&mut self, glob: &[u8]) -> bool { - match std::mem::replace(&mut self.seen, Seen::Odd) { - Seen::Choice => self.is_regular(glob), - _ => true, - } - } - - #[cold] - #[inline(never)] - fn is_regular(&mut self, glob: &[u8]) -> bool { - self.has_group = !self.parsed(glob).is_regular; - !self.has_group - } - /// The pattern's groups. They are read when the loop gets to something that it can't judge by itself. #[inline(always)] fn parsed(&mut self, glob: &[u8]) -> &Parsed { @@ -1345,8 +1737,6 @@ impl<'s> Groups<'s> { enum Tried { /// It's a group that the loop can't handle. IsGroup, - /// This is the answer for the whole pattern. - Decided(bool), Failed, /// The group is a [`Choice`] and matched. Go on at this pattern index and this path index. Took(u32, u32), @@ -1371,86 +1761,42 @@ enum Choice { RestOfPath, } -/// Looks at the group that starts at `index`. Returns `None` if no group starts there. -/// With `is_committed`, the loop has no `*` or `**` to go back to. +/// Where the loop is when it asks [`try_group`]. +#[derive(Copy, Clone)] +struct At { + /// It applies the rules for dot segments. + is_strict: bool, + seen: Seen, +} + +/// Looks at the group that starts at `index`, which the loop hasn't taken for a [`Choice`]. +/// Returns `None` if no group starts there. #[cold] #[inline(never)] -fn try_group( +fn try_group( glob: &[u8], index: u32, path: &[u8], path_index: u32, - is_committed: bool, + at: At, groups: &mut Groups<'_>, + is_windows: bool, ) -> Option { + let is_separator = |it: u8| it == b'/' || is_windows && it == b'\\'; // As long as the pattern looks regular, the loop goes on without reading all of it. - if !STRICT && groups.seen != Seen::Odd && !groups.is_parsed() { - let plain = groups.plain_at(glob, index); - if let Some(alternatives) = plain - && alternatives[0].choice != Choice::No - { - let taken = take_choice::(alternatives, 0, glob, path, path_index); - groups.seen = Seen::Choice; - #[cfg(debug_assertions)] - { - let mut parsed = Parsed::new(); - parsed.read(glob); - let first = brackets_at(glob, index as usize); - let first = first.and_then(|it| parsed.group_at(it.0 as u32)); - let choice = first.and_then(|it| { - take_choice::(&parsed.alternatives, it, glob, path, path_index) - }); - debug_assert!(!parsed.is_regular || taken == choice); - } - return Some(match taken { - Some((glob_index, path_index)) => Tried::Took(glob_index, path_index), - None => Tried::Failed, - }); - } + if !at.is_strict && at.seen != Seen::Odd && !groups.is_parsed() { // The segment of the path that the loop is in. - let is_inside = |it: &&u8| !is_separator::(**it); + let is_inside = |it: &&u8| !is_separator(**it); let (before, after) = path.split_at(path_index as usize); let from = before.len() - before.iter().rev().take_while(is_inside).count(); let to = before.len() + after.iter().take_while(is_inside).count(); let walks = |it: &Segment| { let pattern = &glob[it.start as usize..it.end as usize]; - let on = (it.program).on(pattern, &path[..to], from, WINDOWS, Dots::Match); + let on = (it.program).on(pattern, &path[..to], from, is_windows, Dots::Match); may_end_with(pattern, &path[from..to]) && on.walk() }; - let text = Text { - rest: &path[from..], - segment_len: to - from, - is_committed, - matches: |pattern, text| match_on::(pattern, text).matches(), - }; - let written = match_written_out(glob, index as usize, plain, &text); - #[cfg(debug_assertions)] - match written { - Some(Written::Segment(is_match, end)) => { - let it = Segment::of(glob, index as usize); - let it = it.as_deref(); - debug_assert!(it.is_some_and(|it| (is_match, end) == (walks(it), it.end))); - } - Some(Written::Rest(is_match)) => { - let (mut parsed, start) = (Parsed::new(), negations(glob) as u32); - parsed.read(glob); - let walked = - match_groups::(glob, start, path, &parsed, Dots::Match, false); - debug_assert!(is_match == walked); - } - None => {} - } - let found = match written { - Some(Written::Rest(is_match)) => { - groups.is_beside_dot = true; - return Some(Tried::Decided(is_match)); - } - Some(Written::Segment(is_match, end)) => Some((is_match, end)), - None => groups.segment_at(glob, index).map(|it| (walks(it), it.end)), - }; + let found = groups.segment_at(glob, index).map(|it| (walks(it), it.end)); if let Some((is_match, end)) = found { - // It may have wildcards. - (groups.seen, groups.is_beside_dot) = (Seen::Choice, true); return Some(match is_match { true => Tried::TookSegment(end, to as u32), false => Tried::FailedSegment, @@ -1458,20 +1804,26 @@ fn try_group( } } let start = index + if glob[index as usize] == b'{' { 1 } else { 2 }; - let seen = groups.seen; let shape = groups.parsed(glob); - if seen == Seen::Choice && !shape.is_regular { + if at.seen == Seen::Choice && !shape.is_regular { return Some(Tried::IsGroup); } let first = shape.group_at(start)?; - if STRICT || shape.has_group { + if at.is_strict || shape.has_group { return Some(match shape.may_start_at(first, glob, path, path_index) { true => Tried::IsGroup, false => Tried::Failed, }); } Some( - match take_choice::(&shape.alternatives, first, glob, path, path_index) { + match take_choice( + &shape.alternatives, + first, + glob, + path, + path_index, + is_windows, + ) { Some((glob_index, path_index)) => Tried::Took(glob_index, path_index), None => Tried::Failed, }, @@ -1480,12 +1832,12 @@ fn try_group( /// What [`match_written_out`] matches on. struct Text<'a> { - /// The path from the start of the segment that the loop is in. - rest: &'a [u8], - /// The length of that segment. - segment_len: usize, - /// See [`try_group`]. + path: &'a [u8], + /// Where the loop is. + path_index: usize, + /// The loop has no `*` or `**` to go back to. is_committed: bool, + is_windows: bool, /// [`match_on`] for the platform. matches: fn(&[u8], &[u8]) -> bool, } @@ -1493,49 +1845,75 @@ struct Text<'a> { /// Result of [`match_written_out`]. #[derive(Copy, Clone)] enum Written { - /// Whether the segment of the pattern that the group is in matches the segment of the path, and the index where it ends. - Segment(bool, u32), + /// Whether the segment of the pattern that the group is in matches the segment of the path that the loop is in, and the indexes + /// where the two end. + Segment(bool, u32, u32), /// Whether the pattern, from that segment on, matches the rest of the path. Rest(bool), } +#[cfg(debug_assertions)] +impl Written { + /// A walk has to come to the same. + fn check(self, glob: &[u8], open: usize, path: &[u8], is_windows: bool) { + let is_separator = |it: u8| it == b'/' || is_windows && it == b'\\'; + match self { + Written::Segment(is_match, end, to) => { + // A segment that starts with `!` can't be read on its own. + let Some(it) = Segment::of(glob, open) else { + return; + }; + let pattern = &glob[it.start as usize..it.end as usize]; + let to = to as usize; + let inside = path[..to].iter().rev(); + let from = to - inside.take_while(|it| !is_separator(**it)).count(); + let on = (it.program).on(pattern, &path[..to], from, is_windows, Dots::Match); + let walked = may_end_with(pattern, &path[from..to]) && on.walk(); + debug_assert!((is_match, end) == (walked, it.end)); + } + Written::Rest(is_match) => { + let (mut parsed, start) = (Parsed::new(), negations(glob) as u32); + parsed.read(glob); + let walked = + match_groups(glob, start, path, &parsed, Dots::Match, false, is_windows); + debug_assert!(is_match == walked); + } + } + } +} + /// Matches without a [`Program`]: writes the pattern out once for each alternative of the group at `open`, and matches that. /// It writes out the segment that the group is in, which has to be a [`Segment`]. If the group has a `/` or a `**`, it's the rest of /// the pattern. Returns `None` unless that is short and has few groups and alternatives, which makes this the cheaper way. -/// `plain` is what [`Groups::plain_at`] says about the group. #[inline(never)] -fn match_written_out( - glob: &[u8], - open: usize, - plain: Option<&[Alternative]>, - text: &Text<'_>, -) -> Option { +fn match_written_out(glob: &[u8], open: usize, text: &Text<'_>) -> Option { if glob[open] != b'{' { return None; } // Where the alternatives but the last one end, and where that one does. - let mut commas = [0; 3]; + let mut commas = [0; 4]; let (mut count, mut close) = (0, 0); // How many groups are open, which of those have had a `,`, and how many groups there have been. let (mut depth, mut has_comma, mut groups) = (1, 0_u32, 1); let (mut is_rest, mut has_globstar) = (false, false); let mut end = open + 1; - if let Some([first, others @ ..]) = plain { - count = others.len(); - for (comma, it) in commas.get_mut(..count)?.iter_mut().zip(others) { - *comma = it.start as usize - 1; - } - close = first.close as usize; - (depth, end) = (0, close + 1); - } while let Some(&ch) = glob.get(end) { match ch { + _ if IS_TEXT[ch as usize] => {} + // The loop compares as many bytes as the first one announces. They must not go on into something else. + 0x80.. => { + let len = usize::from(strings::wtf8_byte_sequence_length(ch)); + if glob.get(end..end + len)?.iter().any(u8::is_ascii) { + return None; + } + end += len - 1; + } b'/' if depth == 0 && !is_rest => break, b'/' => is_rest = true, b',' if depth > 0 => { has_comma |= 1 << depth; if close == 0 && depth == 1 { - *commas.get_mut(count)? = end; + *commas[..3].get_mut(count)? = end; count += 1; } } @@ -1555,140 +1933,446 @@ fn match_written_out( (is_rest, has_globstar) = (true, true); end += 1; } - b'*' | b'?' => {} - b'[' => { - let (after, may_be_separator) = class_shape(glob, end)?; - if may_be_separator || glob[end + 1] == b'(' { + b'*' | b'?' => {} + b'[' => { + let (after, may_be_separator) = class_shape(glob, end)?; + if may_be_separator || glob[end + 1] == b'(' { + return None; + } + end = after - 1; + } + _ => return None, + } + end += 1; + } + // A `**` right after the group is never a whole segment. + if depth > 0 || is_rest && !text.is_committed || glob[close + 1..].starts_with(b"**") { + return None; + } + // The segment of the path that the loop is in. + let is_inside = |it: &&u8| **it != b'/' && !(text.is_windows && **it == b'\\'); + let (left, right) = text.path.split_at(text.path_index); + // What the loop has taken for good doesn't have to be matched again. + let (start, path_start) = match text.is_committed { + true => (open, left.len()), + false => { + let before = glob[negations(glob)..open].iter().rev(); + ( + open - before.take_while(|it| **it != b'/').count(), + left.len() - left.iter().rev().take_while(is_inside).count(), + ) + } + }; + let path_end = match is_rest { + true => text.path.len(), + false => left.len() + right.iter().take_while(is_inside).count(), + }; + let path = &text.path[path_start..path_end]; + let found = |is_match: bool| match is_rest { + true => Written::Rest(is_match), + false => Written::Segment(is_match, end as u32, path_end as u32), + }; + let (before, after) = (&glob[start..open], &glob[close + 1..end]); + // An alternative with nothing around it is a pattern as it stands. + let is_written = end - start > close + 1 - open; + let mut written = [0; 64]; + if is_written && end - start > written.len() { + return None; + } + if !after.is_empty() && !may_end_with(after, path) { + return Some(found(false)); + } + // What's after the group stays at the end of the buffer. The rest is written to the left of it. + let seam = written.len() - after.len(); + if is_written { + written[seam..].copy_from_slice(after); + } + commas[count] = close; + let mut from = open + 1; + for &to in &commas[..=count] { + let alternative = &glob[from..to]; + from = to + 1; + // Most alternatives start with a literal that the path doesn't go on with. + if before.is_empty() + && let Some(first) = alternative.first() + && IS_PLAIN[*first as usize] + && path.first() != Some(first) + { + continue; + } + let mut pattern = alternative; + if is_written { + let other_seam = seam - alternative.len(); + written[other_seam..seam].copy_from_slice(alternative); + let start = other_seam - before.len(); + written[start..other_seam].copy_from_slice(before); + // Two `*` that come to stand side by side would be a `**`. + let is_globstar = + |at: usize| at > start && written[at - 1] == b'*' && written.get(at) == Some(&b'*'); + if is_globstar(seam) || is_globstar(other_seam) { + return None; + } + pattern = &written[start..]; + } + // A leading `!` would mean something else in a pattern of its own, and so would a leading `**` that doesn't start a segment. + if has_globstar && !has_plain_globstars(pattern) + || pattern.first() == Some(&b'!') + || pattern.starts_with(b"**") + && start > 0 + && glob[start - 1] != b'/' + && start != negations(glob) + { + return None; + } + if (text.matches)(pattern, path) { + return Some(found(true)); + } + } + Some(found(false)) +} + +/// Returns true if every `**` in `pattern` is a whole segment of it, and it doesn't end with `**/*`, which has a rule of its own. +fn has_plain_globstars(pattern: &[u8]) -> bool { + let mut at = 0; + while at + 1 < pattern.len() { + if pattern[at] != b'*' || pattern[at + 1] != b'*' { + at += 1; + continue; + } + let starts_segment = at == 0 || pattern[at - 1] == b'/'; + if !starts_segment || !matches!(pattern.get(at + 2), None | Some(b'/')) { + return false; + } + at += 2; + } + !pattern.ends_with(b"**/*") +} + +/// Returns false if `pattern` ends with literal bytes that `text` doesn't end with. +fn may_end_with(pattern: &[u8], text: &[u8]) -> bool { + let literal = pattern.iter().rev(); + let len = literal + .take_while(|it| IS_TEXT[**it as usize] && **it != b']') + .count(); + let (before, mut literal) = pattern.split_at(pattern.len() - len); + // After a `\` the first one stands for something else. + if before.last() == Some(&b'\\') && !literal.is_empty() { + literal = &literal[1..]; + } + // A byte that isn't ASCII may stand for a character together with the bytes that follow it. + text.ends_with(literal) || before.iter().rev().take(3).any(|it| *it >= 128) +} + +/// What tells where a group of literals can start. The loop keeps this for the last group that it has asked about. +struct Firsts { + /// Index of the group. + index: u32, + /// It's a `{a,b}` or `@(a|b)` of plain literals. + is_literal: bool, + /// The first byte of each alternative, and how many there are. 0 if there are too many, or if one is empty. + count: u8, + bytes: [u8; 8], + /// The length of the longest alternative, if the group ends a segment of the pattern. + longest_at_end: Option, +} + +impl Firsts { + const NONE: Firsts = Firsts { + index: u32::MAX, + is_literal: false, + count: 0, + bytes: [0; 8], + longest_at_end: None, + }; + + #[inline] + fn read(&mut self, glob: &[u8], index: u32) { + *self = Firsts { + index, + ..Firsts::NONE + }; + // A `{` or `@(` that's followed by plain literals and its closing bracket is a group, whatever the rest of the pattern is. + let Some(Brackets { + first, + separator, + closer, + }) = brackets_at(glob, index as usize) + else { + return; + }; + if glob[index as usize] == b'?' { + return; + } + let (mut count, mut has_firsts) = (0, true); + let (mut start, mut longest) = (first, 0); + let mut at = first; + loop { + let Some(&ch) = glob.get(at) else { + return; + }; + if ch == separator || ch == closer { + has_firsts &= at > start; + longest = longest.max(at - start); + if ch == closer { + break; + } + start = at + 1; + } else if !IS_PLAIN[ch as usize] { + return; + } else if at == start { + match self.bytes.get_mut(count) { + Some(it) if IS_TEXT[ch as usize] => *it = ch, + _ => has_firsts = false, + } + count += 1; + } + at += 1; + } + // With one alternative it may be a sequence like `{1..3}`. + if start == first { + return; + } + self.is_literal = true; + if has_firsts { + self.count = count as u8; + } + if matches!(glob.get(at + 1), None | Some(b'/')) { + self.longest_at_end = Some(longest as u32); + } + } +} + +/// After a `*`: returns the first index at or after `path_index` where the token at `index` could match, or the end of the segment. +#[inline(never)] +fn skip_ahead( + glob: &[u8], + index: u32, + path: &[u8], + path_index: u32, + ignores_dots: bool, + is_first_time: bool, + kept: &mut Kept, + is_windows: bool, +) -> Skipped { + let is_separator = |it: u8| it == b'/' || is_windows && it == b'\\'; + let next = glob[index as usize]; + if matches!(next, b'{' | b'@' | b'+') { + return skip_to_start( + glob, + index, + path, + path_index, + false, + &mut kept.firsts, + is_windows, + ); + } + let mut at = path_index as usize; + if !ignores_dots { + } else if is_first_time + && let Some((width, may_take_separator)) = fixed_width(&glob[index as usize..]) + { + // The `*` takes all but that much of the segment. + let rest = &path[at..]; + let len = rest + .iter() + .position(|it| is_separator(*it)) + .unwrap_or(rest.len()); + if rest[..len].is_ascii() && (!may_take_separator || len == rest.len()) { + at += len - width.min(len); + } + } else if next >= 128 { + while let Some(&found) = path.get(at) + && found != next + && !is_separator(found) + { + at += usize::from(rune_len_at(path, at)); + } + } else if next == b'[' { + let class = kept.class_at(glob, index); + while let Some(&found) = path.get(at) + && !is_separator(found) + { + let (c, len) = decode_rune_at(path, at); + let has = match &class { + Some(class) => Some(class.has(c)), + None => class_has(glob, index, c), + }; + if has != Some(false) { + break; + } + at += usize::from(len); + } + } else if next == b'?' { + // The `?`s take as many characters. What's after them says where they can be. + let rest = &glob[index as usize..]; + let mut count = rest.iter().take_while(|it| **it == b'?').count(); + count -= usize::from(rest.get(count) == Some(&b'(')); + let wanted = rest.get(count).copied(); + if wanted.is_none_or(|it| it == b'/' || IS_PLAIN[it as usize]) { + while let Some(window) = path.get(at..at + count) + && window.is_ascii() + && !is_separator(path[at]) + { + let found = path.get(at + count).copied(); + let fits = match wanted { + Some(b'/') | None => found.is_none_or(is_separator), + Some(_) => found == wanted, + }; + if fits || found.is_some_and(|it| it >= 128) { + break; + } + at += 1; + } + } + } + Skipped { + path_index: at as u32, + can_start: true, + } +} + +/// If `tokens` has only tokens that take one character up to the end of the segment, returns how many there are, and whether one of +/// them can take a separator. +#[inline(always)] +fn fixed_width(tokens: &[u8]) -> Option<(usize, bool)> { + // Most of the time another `*` or a group follows. + let mut is_escaped = false; + for &ch in tokens { + if ch == b'/' { + break; + } + if matches!(ch, b'*' | b'{' | 0x80..) && !is_escaped { + return None; + } + is_escaped = ch == b'\\' && !is_escaped; + } + let (mut at, mut width) = (0, 0); + let mut may_take_separator = false; + while let Some(&ch) = tokens.get(at) + && ch != b'/' + { + at = match ch { + b'?' | b'+' | b'@' | b'!' if tokens.get(at + 1) == Some(&b'(') => return None, + b'*' | b'{' | 0x80.. => return None, + b'[' => { + let class = AsciiClass::read(tokens, at)?; + may_take_separator |= !class.is_regular(tokens, at); + class.end as usize + } + b'\\' => match tokens.get(at + 1) { + Some(b'/' | b'\\' | 0x80..) | None => return None, + Some(_) => at + 2, + }, + _ => at + 1, + }; + width += 1; + } + (!may_take_separator || at == tokens.len()).then_some((width, may_take_separator)) +} + +/// A class that has neither escapes nor characters that aren't ASCII. +struct AsciiClass { + /// One bit for each ASCII character that it matches. + members: u128, + /// It starts with `!` or `^`, so it matches every character that isn't ASCII. + negated: bool, + /// Index after the `]`. + end: u32, +} + +impl AsciiClass { + /// Returns `None` for any other class, and for one that isn't closed. + #[inline(always)] + fn read(glob: &[u8], open: usize) -> Option { + let mut at = open + 1; + let negated = matches!(glob.get(at), Some(b'^' | b'!')); + at += usize::from(negated); + let first = at; + let mut members = 0_u128; + loop { + let low = *glob.get(at)?; + if low == b']' && at > first { + break; + } + if low == b'\\' || low >= 128 { + return None; + } + if glob.get(at + 1) == Some(&b'-') + && let Some(&high) = glob.get(at + 2) + && high != b']' + { + if high == b'\\' || high >= 128 { return None; } - end = after - 1; + if low <= high { + members |= (u128::MAX >> (127 - (high - low))) << low; + } + at += 3; + } else { + members |= 1 << low; + at += 1; } - _ if IS_TEXT[ch as usize] => {} - _ => return None, } - end += 1; - } - let before = glob[negations(glob)..open].iter().rev(); - let start = open - before.take_while(|it| **it != b'/').count(); - let mut written = [0; 64]; - // A leading `!` would mean something else in a pattern of its own. A `**` right after the group is never a whole segment. - if depth > 0 - || end - start > written.len() - || glob[start] == b'!' - || is_rest && !text.is_committed - || glob[close + 1..].starts_with(b"**") - { - return None; - } - let path = match is_rest { - true => text.rest, - false => &text.rest[..text.segment_len], - }; - let found = |is_match: bool| match is_rest { - true => Written::Rest(is_match), - false => Written::Segment(is_match, end as u32), - }; - if !may_end_with(&glob[start..end], path) { - return Some(found(false)); + Some(AsciiClass { + members: if negated { !members } else { members }, + negated, + end: at as u32 + 1, + }) } - let (before, after) = (&glob[start..open], &glob[close + 1..end]); - written[..before.len()].copy_from_slice(before); - let mut from = open + 1; - for to in commas[..count].iter().copied().chain([close]) { - let (alternative, rest) = written[before.len()..].split_at_mut(to - from); - alternative.copy_from_slice(&glob[from..to]); - rest[..after.len()].copy_from_slice(after); - let seams = [before.len(), before.len() + to - from]; - let pattern = &written[..seams[1] + after.len()]; - // Two `*` that come to stand side by side would be a `**`. - if seams - .iter() - .any(|it| *it > 0 && pattern[*it - 1] == b'*' && pattern.get(*it) == Some(&b'*')) - || has_globstar && !has_plain_globstars(pattern) - { - return None; - } - if (text.matches)(pattern, path) { - return Some(found(true)); + + #[inline(always)] + fn has(&self, c: u32) -> bool { + match c < 128 { + true => self.members >> c & 1 == 1, + false => self.negated, } - from = to + 1; } - Some(found(false)) -} -/// Returns true if every `**` in `pattern` is a whole segment of it, and it doesn't end with `**/*`, which has a rule of its own. -fn has_plain_globstars(pattern: &[u8]) -> bool { - let mut from = 0; - while let Some(at) = strings::index_of(&pattern[from..], b"**").map(|it| it + from) { - let starts_segment = at == 0 || pattern[at - 1] == b'/'; - if !starts_segment || !matches!(pattern.get(at + 2), None | Some(b'/')) { - return false; - } - from = at + 2; + /// See [`Parsed::is_regular`]. + #[inline(always)] + fn is_regular(&self, glob: &[u8], open: usize) -> bool { + self.members & (1 << b'/' | 1 << b'\\') == 0 && glob[open + 1] != b'(' } - !pattern.ends_with(b"**/*") } -/// Returns false if `pattern` ends with literal bytes that `text` doesn't end with. -fn may_end_with(pattern: &[u8], text: &[u8]) -> bool { - let literal = pattern.iter().rev(); - let len = literal - .take_while(|it| IS_TEXT[**it as usize] && **it != b']') - .count(); - let (before, literal) = pattern.split_at(pattern.len() - len); - // A byte that isn't ASCII may stand for a character together with the bytes that follow it. - text.ends_with(literal) || before.iter().rev().take(3).any(|it| *it >= 128) +/// What the loop keeps about the tokens that it has looked at last, because it often comes back to them. +struct Kept { + firsts: Firsts, + /// Index of `class`. + class_index: u32, + class: AsciiClass, + /// See [`AsciiClass::is_regular`]. + is_regular_class: bool, } -/// How the alternatives of a group start. -enum Starts<'p> { - /// There are only a few, and all start with a literal. These are the first `count` bytes of `firsts`. - Firsts { firsts: [u8; 8], count: usize }, - /// Each alternative has to be asked. `group` is the index of the first one in `alternatives`. - Alternatives { - alternatives: &'p [Alternative], - group: u32, - }, -} +impl Kept { + const NOTHING: Kept = Kept { + firsts: Firsts::NONE, + class_index: u32::MAX, + class: AsciiClass { + members: 0, + negated: false, + end: 0, + }, + is_regular_class: false, + }; -impl<'p> Starts<'p> { - /// For the group at `index`. Returns `None` if there's no group, or if how it starts says nothing. - #[inline] - fn of(glob: &[u8], index: u32, groups: &'p mut Groups<'_>) -> Option> { - let (alternatives, group) = match groups.is_parsed() { - false => (groups.plain_at(glob, index)?, 0), - true => { - let start = match glob[index as usize] { - b'{' => index + 1, - _ if glob.get(index as usize + 1) == Some(&b'(') => index + 2, - _ => return None, - }; - let parsed = groups.parsed(glob); - (&parsed.alternatives[..], parsed.group_at(start)?) - } - }; - if alternatives[group as usize].next == Alternative::IS_SEQUENCE { - return None; - } - let mut firsts = [0_u8; 8]; - let mut count = 0; - let mut next = group as usize; - while let Some(it) = alternatives.get(next) - && count < firsts.len() - && it.start < it.end - && IS_PLAIN[glob[it.start as usize] as usize] - { - firsts[count] = glob[it.start as usize]; - count += 1; - next = it.next as usize; + /// Returns the class at `open`, if it's an [`AsciiClass`]. + #[inline(always)] + fn class_at(&mut self, glob: &[u8], open: u32) -> Option<&AsciiClass> { + if self.class_index != open { + self.read_class(glob, open)?; } - Some(match alternatives.get(next) { - None => Starts::Firsts { firsts, count }, - Some(_) => Starts::Alternatives { - alternatives, - group, - }, - }) + Some(&self.class) + } + + #[inline(never)] + fn read_class(&mut self, glob: &[u8], open: u32) -> Option<()> { + self.class = AsciiClass::read(glob, open as usize)?; + self.is_regular_class = self.class.is_regular(glob, open as usize); + self.class_index = open; + Some(()) } } @@ -1703,72 +2387,56 @@ struct Skipped { /// or the end of the segment. With `by_segment`, only looks at the start of each segment and returns the last one /// if none fits. #[inline(never)] -fn skip_to_start( +fn skip_to_start( glob: &[u8], index: u32, path: &[u8], mut path_index: u32, by_segment: bool, - groups: &mut Groups<'_>, + firsts: &mut Firsts, + is_windows: bool, ) -> Skipped { - let unknown = |path_index| Skipped { - path_index, - can_start: true, - }; - let Some(starts) = Starts::of(glob, index, groups) else { - return unknown(path_index); - }; - let (alternatives, group) = match starts { - Starts::Alternatives { - alternatives, - group, - } => (alternatives, group), - Starts::Firsts { firsts, count } => { - let firsts = &firsts[..count]; - while let Some(found) = path.get(path_index as usize) - && (by_segment || !is_separator::(*found)) - && !firsts.contains(found) - { - if !by_segment { - path_index += u32::from(rune_len_at(path, path_index as usize)); - continue; - } - let rest = &path[path_index as usize..]; - match rest.iter().position(|it| is_separator::(*it)) { - Some(separator) => path_index += separator as u32 + 1, - None => break, - } - } - let can_start = path - .get(path_index as usize) - .is_some_and(|it| firsts.contains(it)); - return Skipped { - path_index, - can_start, - }; - } - }; - while let Some(&found) = path.get(path_index as usize) - && (by_segment || !is_separator::(found)) + let is_separator = |it: u8| it == b'/' || is_windows && it == b'\\'; + if firsts.index != index { + firsts.read(glob, index); + } + // A group that ends a segment of the pattern ends one of the path. + if !by_segment && let Some(longest) = firsts.longest_at_end { + let rest = &path[path_index as usize..]; + let len = rest + .iter() + .position(|it| is_separator(*it)) + .unwrap_or(rest.len()); + path_index += (len as u32).saturating_sub(longest); + } + if firsts.count == 0 { + return Skipped { + path_index, + can_start: true, + }; + } + let firsts = &firsts.bytes[..usize::from(firsts.count)]; + while let Some(found) = path.get(path_index as usize) + && (by_segment || !is_separator(*found)) + && !firsts.contains(found) { - let mut next = group as usize; - while let Some(it) = alternatives.get(next) { - if it.may_start_at(glob, path, path_index) { - return unknown(path_index); - } - next = it.next as usize; - } if !by_segment { path_index += u32::from(rune_len_at(path, path_index as usize)); continue; } let rest = &path[path_index as usize..]; - match rest.iter().position(|it| is_separator::(*it)) { + match rest.iter().position(|it| is_separator(*it)) { Some(separator) => path_index += separator as u32 + 1, None => break, } } - unknown(path_index) + let can_start = path + .get(path_index as usize) + .is_some_and(|it| firsts.contains(it)); + Skipped { + path_index, + can_start, + } } /// One alternative of a group. @@ -1938,7 +2606,7 @@ impl Sequence { /// What [`match_prepared`] knows about a pattern. It's found out when a match first needs it, and kept. pub struct Shape { /// Empty until a match needs it, so that a shape costs next to nothing to make and to drop. - known: OnceLock>, + known: Once>, /// How many matches the loop has decided, or one of the two constants. uses: AtomicU8, } @@ -1961,14 +2629,14 @@ impl Shape { #[inline] pub fn of(_glob: &[u8]) -> Shape { Shape { - known: OnceLock::new(), + known: Once::new(), uses: AtomicU8::new(0), } } fn known(&self) -> &Known { self.known.get_or_init(|| { - Box::new(Known { + Arc::new(Known { parsed: OnceLock::new(), segment: OnceLock::new(), }) @@ -1981,14 +2649,11 @@ impl Shape { self.known.get()?.parsed.get() } - /// Counts a match that the loop has decided. `uses` is the count so far. + /// Counts a match that is left to the loop. `uses` is the count so far. #[inline(always)] fn count(&self, glob: &[u8], uses: u8) { if uses < Shape::WALKS_AFTER { - // Unless the match has read the pattern, which settles it. - if self.uses.load(Ordering::Relaxed) == uses { - self.uses.store(uses + 1, Ordering::Relaxed); - } + self.uses.store(uses + 1, Ordering::Relaxed); } else if uses == Shape::WALKS_AFTER { self.parse(glob); } @@ -2046,16 +2711,425 @@ impl Shape { } } -/// For a `{`, `@(` or `?(` at `open`: where the first alternative starts, what separates alternatives, and what closes the group. +/// Where the first alternative of a group starts, what separates its alternatives, and what closes it. +struct Brackets { + first: usize, + separator: u8, + closer: u8, +} + +/// For a `{`, `@(` or `?(` at `open`. #[inline(always)] -fn brackets_at(glob: &[u8], open: usize) -> Option<(usize, u8, u8)> { +fn brackets_at(glob: &[u8], open: usize) -> Option { match glob[open] { - b'{' => Some((open + 1, b',', b'}')), - b'@' | b'?' if glob.get(open + 1) == Some(&b'(') => Some((open + 2, b'|', b')')), + b'{' => Some(Brackets { + first: open + 1, + separator: b',', + closer: b'}', + }), + b'@' | b'?' if glob.get(open + 1) == Some(&b'(') => Some(Brackets { + first: open + 2, + separator: b'|', + closer: b')', + }), _ => None, } } +/// What the loop can go back to when it gets to a group. +#[derive(Copy, Clone, PartialEq, Eq)] +enum Pending { + Nothing, + /// Only a `**`, so in this segment the group can only start where the loop is. + Globstar, + /// A `*` right before the group. + StarBefore, + /// A `*` further to the left in this segment. + Star, +} + +/// Result of [`take_plain_choice`]. +#[derive(Copy, Clone)] +enum Taken { + /// The pattern index and the path index after the group. + To(u32, u32), + /// It doesn't match here. + Nothing, +} + +/// Matches the group that starts at `open` if it's a [`Choice`] of a regular pattern, as far as that shows without reading the whole +/// pattern. Other groups of literals will do as well if the group can only start here, and what follows it in the pattern leaves +/// only one alternative. +#[inline(never)] +fn take_plain_choice( + glob: &[u8], + open: u32, + path: &[u8], + path_index: u32, + is_windows: bool, + pending: Pending, +) -> Option { + let Brackets { + first, + separator, + closer, + } = brackets_at(glob, open as usize)?; + let is_optional = glob[open as usize] == b'?'; + let is_separator = |it: u8| it == b'/' || is_windows && it == b'\\'; + let rest = &path[path_index as usize..]; + // The lengths of the alternatives that `rest` starts with. `?(a)` has an empty one. + let mut lens = [0; 4]; + let mut count = usize::from(is_optional); + let (mut has_one_length, mut has_slash) = (!is_optional, false); + let mut shortest = if is_optional { 0 } else { usize::MAX }; + let mut first_end = 0; + // Where the alternative being read starts. + let mut start = first; + let mut at = first; + 'group: loop { + // As far as `rest` goes along. + while let Some(&ch) = glob.get(at) + && IS_TEXT[ch as usize] + && rest.get(at - start) == Some(&ch) + { + at += 1; + } + let mut is_start = true; + loop { + let ch = *glob.get(at)?; + if ch == separator || ch == closer { + if first_end == 0 { + first_end = at; + } + has_one_length &= at - start == first_end - first; + shortest = shortest.min(at - start); + if is_start { + *lens.get_mut(count)? = at - start; + count += 1; + } + at += 1; + if ch == closer { + break 'group; + } + start = at; + continue 'group; + } + if IS_PLAIN[ch as usize] { + is_start = is_start && rest.get(at - start) == Some(&ch); + } else if ch == b'/' && pending == Pending::Nothing { + has_slash = true; + is_start = is_start && rest.get(at - start).is_some_and(|it| is_separator(*it)); + } else if matches!(ch, b'?' | b'[' | b'{') + && matches!(pending, Pending::Nothing | Pending::Globstar) + { + let is_committed = pending == Pending::Nothing; + return take_fixed_choice(glob, open, path, path_index, is_windows, is_committed); + } else { + return None; + } + at += 1; + // The rest of an alternative that differs. + while !is_start + && let Some(&ch) = glob.get(at) + && IS_TEXT[ch as usize] + { + at += 1; + } + } + } + // With one alternative it may be a sequence like `{1..3}`. + if !is_optional && start == first { + return None; + } + let (lens, after) = (&lens[..count], at); + let mut taken = None; + match glob.get(after) { + None => { + for &len in lens { + if len == rest.len() { + taken = Some(len); + } + } + } + Some(b'/') => { + for &len in lens { + if rest.get(len).is_none_or(|it| is_separator(*it)) { + if taken.is_some_and(|it| it != len) { + return None; + } + taken = Some(len); + } + } + } + // What looks for the start of a segment of the pattern later on must not find that `/`. + Some(_) if has_slash => return None, + Some(_) if has_one_length && first_end > first => taken = lens.first().copied(), + Some(_) if pending == Pending::Star => return None, + Some(b'*') if !matches!(glob.get(after + 1), Some(b'*' | b'(')) => { + // The `*` makes up for what a shorter alternative leaves. + taken = lens.iter().copied().min(); + // After a `*`, an alternative that starts further to the right may end further to the left. + if pending == Pending::StarBefore + && let Some(len) = taken + && len > shortest + 1 + { + return take_between_stars(glob, open, path, path_index, is_windows); + } + } + Some(_) if pending == Pending::StarBefore => return None, + Some(_) => { + for &len in lens { + let mut index = 0; + let fits = loop { + match glob.get(after + index) { + Some(wanted) if IS_PLAIN[*wanted as usize] => { + if rest.get(len + index) != Some(wanted) { + break false; + } + index += 1; + } + _ => break true, + } + }; + if fits { + if taken.is_some_and(|it| it != len) { + return None; + } + taken = Some(len); + } + } + } + } + Some(match taken { + Some(len) => Taken::To(after as u32, path_index + len as u32), + None => Taken::Nothing, + }) +} + +/// A group that [`take_fixed_choice`] is in. +#[derive(Copy, Clone, Default)] +struct Level { + /// Where its alternatives can start, and where the ones that have been read can end: one bit for each number of bytes. + starts: u64, + ends: u64, + /// The least and the most that its alternative of the group around it takes before it. + before: (u32, u32), + /// The least and the most that the alternatives which have been read take. + widths: (u32, u32), + has_separator: bool, +} + +/// [`take_plain_choice`] for a group whose alternatives have other tokens that take one character, or groups of such alternatives. +/// The group can only start here. +#[inline(never)] +fn take_fixed_choice( + glob: &[u8], + open: u32, + path: &[u8], + path_index: u32, + is_windows: bool, + is_committed: bool, +) -> Option { + let Brackets { + first, + separator, + closer, + } = brackets_at(glob, open as usize)?; + let is_separator = |it: u8| it == b'/' || is_windows && it == b'\\'; + let rest = &path[path_index as usize..]; + let mut levels = [Level::default(); 4]; + let mut depth = 0; + levels[0] = Level { + starts: 1, + widths: (u32::MAX, 0), + ..Level::default() + }; + // `?(a)` has an empty alternative. + if glob[open as usize] == b'?' { + levels[0].ends = 1; + levels[0].widths = (0, 0); + levels[0].has_separator = true; + } + // Where what has been read of the alternative can end, and the least and the most that it takes. + let mut ends = 1_u64; + let mut widths = (0, 0); + let mut has_slash = false; + let mut at = first; + loop { + let ch = *glob.get(at)?; + at += 1; + if IS_TEXT[ch as usize] || ch == b'/' || ch == b'?' || ch == b'[' { + let mut class_end = 0; + match ch { + b'/' if !is_committed || depth > 0 => return None, + b'/' => has_slash = true, + b'?' if glob.get(at) == Some(&b'(') => return None, + b'[' => { + let (_, close, is_regular) = ascii_class(glob, at - 1, 0)?; + if !is_regular { + return None; + } + class_end = close + 1; + } + _ => {} + } + let (mut from, mut to) = (ends, 0); + while from != 0 { + let len = from.trailing_zeros() as usize; + from &= from - 1; + let Some(&found) = rest.get(len) else { + continue; + }; + let is_same = match ch { + b'/' => is_separator(found), + b'?' | b'[' if found >= 128 => return None, + b'?' => !is_separator(found), + b'[' => ascii_class(glob, at - 1, found)?.0, + _ => found == ch, + }; + if is_same { + to |= 2_u64.checked_shl(len as u32)?; + } + } + ends = to; + widths = (widths.0 + 1, widths.1 + 1); + if ch == b'[' { + at = class_end; + } + continue; + } + let level = &mut levels[depth]; + let (separator, closer) = match depth { + 0 => (separator, closer), + _ => (b',', b'}'), + }; + if ch == separator || ch == closer { + level.ends |= ends; + level.widths = (level.widths.0.min(widths.0), level.widths.1.max(widths.1)); + if ch == separator { + level.has_separator = true; + ends = level.starts; + widths = (0, 0); + continue; + } + // With one alternative it may be a sequence like `{1..3}`. + if !level.has_separator { + return None; + } + ends = level.ends; + widths = ( + level.before.0 + level.widths.0, + level.before.1 + level.widths.1, + ); + if depth == 0 { + break; + } + depth -= 1; + } else if ch == b'{' && closer == b'}' { + depth += 1; + *levels.get_mut(depth)? = Level { + starts: ends, + before: widths, + widths: (u32::MAX, 0), + ..Level::default() + }; + widths = (0, 0); + } else { + return None; + } + } + let after = at; + let shortest = widths.0 as usize; + let mut taken = None; + let mut lens = ends; + match glob.get(after) { + None => { + if rest.len() < 64 && ends >> rest.len() & 1 == 1 { + taken = Some(rest.len()); + } + } + Some(b'/') => { + while lens != 0 { + let len = lens.trailing_zeros() as usize; + lens &= lens - 1; + if rest.get(len).is_none_or(|it| is_separator(*it)) { + if taken.is_some() { + return None; + } + taken = Some(len); + } + } + } + // What looks for the start of a segment of the pattern later on must not find that `/`. + Some(_) if has_slash => return None, + Some(_) if widths.0 == widths.1 && widths.0 > 0 => { + taken = (ends != 0).then_some(shortest); + } + Some(b'*') if !matches!(glob.get(after + 1), Some(b'*' | b'(')) => { + // The `*` makes up for what a shorter alternative leaves. + taken = (ends != 0).then(|| ends.trailing_zeros() as usize); + } + Some(_) => { + while lens != 0 { + let len = lens.trailing_zeros() as usize; + lens &= lens - 1; + let mut index = 0; + let fits = loop { + match glob.get(after + index) { + Some(wanted) if IS_PLAIN[*wanted as usize] => { + if rest.get(len + index) != Some(wanted) { + break false; + } + index += 1; + } + _ => break true, + } + }; + if fits { + if taken.is_some() { + return None; + } + taken = Some(len); + } + } + } + } + Some(match taken { + Some(len) => Taken::To(after as u32, path_index + len as u32), + None => Taken::Nothing, + }) +} + +/// [`take_plain_choice`] for a group between two `*`s: what counts is where in the segment it can end first. +#[cold] +#[inline(never)] +fn take_between_stars( + glob: &[u8], + open: u32, + path: &[u8], + path_index: u32, + is_windows: bool, +) -> Option { + let mut first = Taken::Nothing; + let (mut at, mut end) = (path_index, u32::MAX); + while at < end { + let taken = take_plain_choice(glob, open, path, at, is_windows, Pending::Globstar)?; + if let Taken::To(_, it) = taken + && it < end + { + (first, end) = (taken, it); + } + if path + .get(at as usize) + .is_none_or(|it| *it == b'/' || is_windows && *it == b'\\') + { + break; + } + at += 1; + } + Some(first) +} + /// A segment of a regular pattern that has a group which isn't a [`Choice`], and that can only match one whole segment of a path: it /// has no `/` and no `**`, and no `!(a)`, whose meaning depends on what follows the segment. For the loop it's like a `Choice` /// that takes the rest of a segment: it has a [`Program`] of its own, which says whether it matches the segment of the path. @@ -2110,6 +3184,12 @@ struct Parsed { is_matched_often: bool, /// Number of `*`, `**`, `?` and classes, up to 2. wildcards: u8, + /// A [`Cache`] has no steps for bytes that aren't ASCII. + is_ascii: bool, + /// All it has besides literal bytes is a `*` or `**` at the end, which the loop doesn't have to look at the path for. + is_decided_at_once: bool, + /// Index after the leading `!`s. + top: u32, /// Index where the front of the pattern ends: the literal bytes and, in a regular pattern, the [`Choice`]s that it starts with. /// What the front takes of a path doesn't depend on the rest of the pattern. front_end: u32, @@ -2157,6 +3237,9 @@ impl Parsed { is_one_segment: false, is_matched_often: false, wildcards: 0, + is_ascii: true, + is_decided_at_once: false, + top: 0, front_end: 0, first_group: u32::MAX, program: OnceLock::new(), @@ -2165,7 +3248,10 @@ impl Parsed { /// Returns true if a [`Walk`] gives the same answers as the loop, and is faster once it has a [`Cache`]. fn may_walk(&self) -> bool { - self.is_regular && (self.has_group || self.has_choice || self.wildcards >= 2) + self.is_regular + && self.is_ascii + && !self.is_decided_at_once + && (self.has_group || self.has_choice || self.wildcards >= 1) } /// Finds all groups in one pass over `glob`. @@ -2351,6 +3437,11 @@ impl Parsed { self.is_one_segment = is_one_segment; self.has_choice = has_choice; self.wildcards = wildcards.min(2) as u8; + self.is_ascii = glob.is_ascii(); + let first = strings::index_of_any(glob, b"*?["); + self.is_decided_at_once = !has_group + && !has_choice + && first.is_some_and(|it| matches!(&glob[it..], b"*" | b"**")); let mut group = 0; while let Some(it) = found.get(group) && it.close > 0 @@ -2380,28 +3471,37 @@ impl Parsed { } } self.front_end = front_end as u32; + self.top = top as u32; } /// Returns the index of the path after what the front of the pattern takes. #[inline(always)] - fn take_front( + fn take_front( &self, glob: &[u8], glob_start: u32, path: &[u8], + is_windows: bool, ) -> Option { + let is_separator = |it: u8| it == b'/' || is_windows && it == b'\\'; let (mut i, mut path_index) = (glob_start, 0); while i < self.front_end { let wanted = glob[i as usize]; if !IS_PLAIN[wanted as usize] && wanted != b'/' { let start = i + if wanted == b'{' { 1 } else { 2 }; let first = self.group_at(start)?; - (i, path_index) = - take_choice::(&self.alternatives, first, glob, path, path_index)?; + (i, path_index) = take_choice( + &self.alternatives, + first, + glob, + path, + path_index, + is_windows, + )?; continue; } let found = *path.get(path_index as usize)?; - if wanted != found && !(wanted == b'/' && is_separator::(found)) { + if wanted != found && !(wanted == b'/' && is_separator(found)) { return None; } i += 1; @@ -2476,18 +3576,20 @@ impl Parsed { /// Matches the [`Choice`] whose first alternative is number `first`. Returns the pattern index and the path index after it. #[inline(never)] -fn take_choice( +fn take_choice( alternatives: &[Alternative], first: u32, glob: &[u8], path: &[u8], path_index: u32, + is_windows: bool, ) -> Option<(u32, u32)> { + let is_separator = |it: u8| it == b'/' || is_windows && it == b'\\'; let group = alternatives[first as usize]; let rest = &path[path_index as usize..]; let taken = match group.choice { Choice::RestOfSegment => { - let len = rest.iter().take_while(|it| !is_separator::(**it)); + let len = rest.iter().take_while(|it| !is_separator(**it)); &rest[..len.count()] } Choice::RestOfPath => rest, @@ -3229,6 +4331,19 @@ impl Program { /// A path of this length makes up for it by itself. const IS_LONG: usize = 512; + /// Returns the cache in use, if there's one. + #[inline(always)] + fn warm_cache(&self, is_windows: bool) -> Option<&Cache> { + let cache = match (self.caches[0].get(), self.caches[1].get()) { + (_, Some(large)) => large.as_deref(), + (Some(small), None) => small + .as_deref() + .filter(|it| !it.is_full.load(Ordering::Relaxed)), + (None, None) => None, + }; + cache.filter(|it| it.is_windows == is_windows) + } + /// A pattern gets a cache when it has been matched often enough to make up for the cost, or on a long path. #[inline(always)] fn cache(&self, glob: &[u8], is_windows: bool, path_len: usize) -> Option<&Cache> { @@ -3837,6 +4952,29 @@ impl Cache { Some(name(index)) } + /// Returns whether the first node matches at the start of `text`, if every step for that is known. + #[inline(always)] + fn answer(&self, text: &[u8]) -> Answer { + let mut state = self.end; + let (steps, columns) = (&*self.steps, &self.columns); + for byte in text.iter().rev() { + state = + steps[state as usize + columns[*byte as usize] as usize].load(Ordering::Relaxed); + if state <= Cache::NOTHING { + return match state { + Cache::NOTHING => Answer::No, + _ if *byte >= 128 => Answer::NotAscii, + _ => Answer::Unknown, + }; + } + } + match steps[state as usize - 1].load(Ordering::Relaxed) { + Cache::UNKNOWN => Answer::Unknown, + Cache::YES => Answer::Yes, + _ => Answer::No, + } + } + /// `first` is the index of the path where the first node has to match. `dots` is `Dots::Match` unless the path has a closed /// segment. #[inline(always)] diff --git a/src/jsc/bindings/LintHost.h b/src/jsc/bindings/LintHost.h index 88c73ce2f91d..a58565300f5e 100644 --- a/src/jsc/bindings/LintHost.h +++ b/src/jsc/bindings/LintHost.h @@ -40,17 +40,15 @@ class LintHost final : public JSC::JSDestructibleObject { static JSC::Structure* createStructure(JSC::VM&, JSC::JSGlobalObject*); static void destroy(JSC::JSCell*); - // `globalObject` is what a host function gets that `addLintHostFunctions` or a plugin's `sourceCode` has. That's the global object the function - // was made in, whoever calls it, so it's the Zig::GlobalObject with the host. A custom getter starts with `realmOfHolder` instead. + // For a host function, which gets the global object it was made in, whoever calls it. A custom getter starts with `realmOfHolder`. static LintHost* from(JSC::JSGlobalObject* globalObject) { return uncheckedDowncast(uncheckedDowncast(globalObject)->m_lintHost.getInitializedOnMainThread(globalObject)); } - // The global object for a custom getter to work with, from its `thisValue`. Every custom getter here is for a `CustomValue`, so that's the object - // that has the property, and not the receiver, which a plugin chooses. The object was made here, so its realm has the host. What the getter - // makes belongs there too, as if it had been in the property all along. The global object that JSC passes isn't used: a plugin can read a - // node in a `node:vm` context, whose global object is of another class. Null if `holder` is none of this: then there's nothing to return. + // The global object for a custom getter to use, in place of the one it's passed: a plugin can read a node in a `node:vm` context, whose global + // object is of another class. `holder`: its `thisValue`. For a `CustomValue`, which all here are, that's the object with the property, not the + // receiver. Null if that isn't an object made here. static Zig::GlobalObject* realmOfHolder(JSC::EncodedJSValue holder) { JSC::JSObject* object = JSC::JSValue::decode(holder).getObject(); diff --git a/src/lint/ast/mod.rs b/src/lint/ast/mod.rs index d41a3993426f..91edee62e55d 100644 --- a/src/lint/ast/mod.rs +++ b/src/lint/ast/mod.rs @@ -191,7 +191,7 @@ pub(crate) struct Lazy { pub(crate) sequence_roots: OnceCell>, /// Where the `a.b` of each `` and `` starts and ends, in order. jsx_tags_with_dots: OnceCell>, - pub(crate) references: OnceCell, + pub(crate) references: crate::semantic::ReferenceIndex, pub(crate) by_kind: crate::runner::ByKind, pub(crate) code_paths: crate::code_path::Store, pub(crate) linter: crate::linter::PerFile, diff --git a/src/lint/context.rs b/src/lint/context.rs index af35ffb736a8..cdc784e6e749 100644 --- a/src/lint/context.rs +++ b/src/lint/context.rs @@ -1,7 +1,7 @@ //! What a listener of a rule is given besides the node: the file, the state of the rule, and the //! means to report. -use crate::ast::{File, Ident, Name, Packed}; +use crate::ast::{File, Ident, Name, Node, Packed}; use crate::fix::{Fix, Fixer, IntoFix, SuggestionKind}; use crate::oxlint_help::{self, Edits, Found, Help, Part}; use crate::oxlint_messages; @@ -27,8 +27,11 @@ pub struct Diagnostic { pub message_id: &'static str, pub message: Vec, pub span: Span, - /// The node that was reported, or the one that [`Report::listened_on`] was given. `None`: it was a token, a name or a span. + /// The node that was reported, or the one that [`Report::called_with`] or [`Report::listened_on`] was given. `None`: it was a + /// token, a name or a span. pub node: Option, + /// `node` is not what was reported. + pub states_node: bool, /// ESLint was given a position, not a range: it reports no `endLine` and `endColumn`. pub has_no_end: bool, pub when: When, @@ -98,6 +101,18 @@ pub(crate) struct Sink { pub(crate) drops_messages: Cell, /// What a rule has thrown: of all the first in the text, which ESLint comes to first. pub(crate) thrown: RefCell>, + /// [`File::calls_in_turns`] + pub(crate) calls_in_turns: OnceCell>, +} + +impl File<'_> { + /// For a rule that oxlint calls with the calls of Jest, when it reports under a configuration of oxlint. oxlint goes through + /// these calls after the nodes, in an order of its own. `calls` is asked once per file: the calls by where they start, the + /// longer first, each with the how manyth it is for oxlint. + #[inline] + pub fn calls_in_turns(&self, calls: impl FnOnce() -> Box<[(Span, u32)]>) { + self.sink.calls_in_turns.get_or_init(calls); + } } /// [`Cx::throw`], [`Cx::throw_for_selector`] @@ -256,6 +271,7 @@ impl<'a> CxBase<'a> { message: Vec::new(), span, node, + states_node: false, has_no_end: false, when: self.meta.reports, details: None, @@ -384,6 +400,30 @@ impl<'a> Report<'a> { } if let Some(diagnostic) = &mut self.diagnostic { diagnostic.node = node.packed_node(); + diagnostic.states_node = true; + } + self + } + + /// oxlint calls its rule with `node`, or with something around it, when it reports this. It prints what it reports in the order + /// in which it comes to the nodes, and what it reports about one node in the order in which the rule reports. To be said where + /// what is reported is not in that node, where a node in between is of a kind that oxlint also calls the rule with, and + /// where many tokens or names are reported, for which the node is searched otherwise. It means nothing to ESLint. + #[inline] + pub fn called_with(mut self, node: impl Into>) -> Self { + if let Some(diagnostic) = &mut self.diagnostic { + diagnostic.node = Some(node.into().packed()); + diagnostic.states_node = true; + } + self + } + + /// [`Report::called_with`] where oxlint calls its rule with just what is reported, which is no node here: a name. + #[inline] + pub fn called_with_the_name(mut self) -> Self { + if let Some(diagnostic) = &mut self.diagnostic { + diagnostic.node = None; + diagnostic.states_node = true; } self } diff --git a/src/lint/driver/bunfig.rs b/src/lint/driver/bunfig.rs index 4a220dd6c7d3..c9a58c2b63bf 100644 --- a/src/lint/driver/bunfig.rs +++ b/src/lint/driver/bunfig.rs @@ -523,6 +523,12 @@ impl Feature { /// What `[fmt]` of `file` says. pub fn format(file: &Expr) -> Result, Refusal> { + if let Some(section) = file.get(b"format") { + return refuse( + section.loc, + &[b"Unknown section [format]\n Did you mean [fmt]?"], + ); + } let Some(section) = file.get(b"fmt") else { return Ok(None); }; diff --git a/src/lint/driver/doctor.rs b/src/lint/driver/doctor.rs index 88a7cfa6e643..833d50608192 100644 --- a/src/lint/driver/doctor.rs +++ b/src/lint/driver/doctor.rs @@ -227,6 +227,7 @@ struct Rules { impl Rules { /// `wants_types` says whether rules that need types run under `config`. + #[optimize(size)] fn add(&mut self, config: &ResolvedConfig, wants_types: bool) { for rule in config .configured() @@ -333,6 +334,7 @@ fn kind_of(flavor: Flavor) -> (&'static [u8], &'static str) { } /// Builds the `bun lint --doctor` report. +#[optimize(size)] pub(crate) fn lint( (loader, options): (&Loader, &Options), pool: &Pool, diff --git a/src/lint/driver/doctor/packages.rs b/src/lint/driver/doctor/packages.rs index 331bb2ec78ad..ae9e51e6e022 100644 --- a/src/lint/driver/doctor/packages.rs +++ b/src/lint/driver/doctor/packages.rs @@ -163,6 +163,7 @@ pub(crate) struct Packages { impl Packages { /// Reads the `package.json` closest to `cwd`. `is_of_a_tool` says whether a package only exists to serve the replaced /// tool. `need` says why a run still needs a package, if it does. + #[optimize(size)] pub(crate) fn new( cwd: &[u8], is_of_a_tool: &dyn Fn(&[u8]) -> bool, @@ -211,6 +212,7 @@ impl Packages { packages } + #[optimize(size)] pub(crate) fn json(&self) -> Json { let needed = self.needed.iter().map(|(name, need)| { object(vec![ @@ -227,6 +229,7 @@ impl Packages { ]) } + #[optimize(size)] pub(crate) fn write(&self, out: &mut Vec, colors: bool) { if self.removable.is_empty() && self.needed.is_empty() { return; diff --git a/src/lint/driver/explain.rs b/src/lint/driver/explain.rs index 8614091abb2e..147cb419f104 100644 --- a/src/lint/driver/explain.rs +++ b/src/lint/driver/explain.rs @@ -54,6 +54,7 @@ fn page_of(meta: &Meta) -> Option<&'static [u8]> { struct Defaults(FxHashMap<(Plugin, &'static str), (Severity, Arc<[Json]>)>); impl Defaults { + #[optimize(size)] fn new(registry: &Registry) -> Defaults { let mut on = FxHashMap::default(); let config = configs::defaults(registry); @@ -108,6 +109,7 @@ fn in_native_mode(registry: &Registry, meta: &'static Meta, id: &[u8]) -> &'stat } /// `--rules -f json`. oxlint's keys come first, with oxlint's values except for `docs_url`. Bun's own keys follow. +#[optimize(size)] pub(crate) fn write_rules_as_json(out: &mut Vec, registry: &Registry) { let (descriptions, defaults) = (descriptions(), Defaults::new(registry)); let rules = listed(registry).into_iter().map(|(scope, meta)| { @@ -204,6 +206,7 @@ pub(crate) fn write_rules(out: &mut Vec, registry: &Registry) { } /// `--rules` in a terminal: rules grouped by plugin, each with its default severity and description. +#[optimize(size)] pub(crate) fn write_rules_by_plugin(out: &mut Vec, registry: &Registry, colors: bool) { let (descriptions, defaults) = (descriptions(), Defaults::new(registry)); let all = listed(registry); @@ -320,6 +323,7 @@ fn find(registry: &Registry, name: &[u8]) -> Result<&'static Meta, Vec> { } /// `--explain `. Returns the rendered docs page for `name`, or the error message to print. +#[optimize(size)] pub(crate) fn explain( registry: &Registry, name: &[u8], diff --git a/src/lint/driver/fmt/doctor.rs b/src/lint/driver/fmt/doctor.rs index f6bad187b807..067af14873d9 100644 --- a/src/lint/driver/fmt/doctor.rs +++ b/src/lint/driver/fmt/doctor.rs @@ -44,6 +44,7 @@ struct Plugins { } impl Plugins { + #[optimize(size)] fn add(&mut self, name: &[u8]) { let list = if config::is_built_in_plugin(name) { &mut self.built_in @@ -58,6 +59,7 @@ impl Plugins { } } +#[optimize(size)] pub(super) fn report( (configs, cwd): (&Configs, &[u8]), pool: &Pool, diff --git a/src/lint/driver/for_tests.rs b/src/lint/driver/for_tests.rs index 2e975a7c221d..2086c62277c0 100644 --- a/src/lint/driver/for_tests.rs +++ b/src/lint/driver/for_tests.rs @@ -34,6 +34,7 @@ impl Host for Tester<'_> { self.linter.registry() } + #[optimize(size)] fn lint(&self, case: &Case<'_>) -> Option> { // Nothing here hands out the file before it is linted. if case.prepare.is_some() { @@ -233,6 +234,7 @@ fn configuration_case(registry: &Registry, case: &Json) -> Json { } /// The file at `file` has a list of what [`configuration_case`] is given. Returns the list of what it answers. +#[optimize(size)] fn configuration_cases(file: &[u8]) -> Option> { let cases = bun_lint::json::parse(&crate::fs::read(file).ok()?)?; let registry = registry(); @@ -246,6 +248,7 @@ fn configuration_cases(file: &[u8]) -> Option> { /// `bun lint --run-path-tests ( )..`, or `configurations `: `args` is what follows the /// flag. Returns what the functions answer, a line each: the paths of Windows are tested on every system. `None`: no such function. +#[optimize(size)] pub fn run_path_tests(args: &[&[u8]]) -> Option> { let (style, mut rest) = match args { [b"posix", rest @ ..] => (Style::Posix, rest), @@ -276,6 +279,7 @@ pub fn run_path_tests(args: &[&[u8]]) -> Option> { /// `bun lint --run-eslint-tests ..`: `args` is what follows the flag. Prints the cases that /// fail and the totals. Returns whether the tests could be run. +#[optimize(size)] pub fn run_eslint_tests(args: &[&[u8]], environment: &Environment) -> bool { bun_lint_conformance::run_from_command_line( args, diff --git a/src/lint/driver/format/summary.rs b/src/lint/driver/format/summary.rs index ec00c507b5f0..b19a85a67217 100644 --- a/src/lint/driver/format/summary.rs +++ b/src/lint/driver/format/summary.rs @@ -152,6 +152,7 @@ pub(crate) fn about_suppressions(has_unused: bool, has_new: bool) -> Option { impl<'e> Files<'e> { /// `writes` is true if the run writes files. + #[optimize(size)] pub(crate) fn read( only: &'e Only, writes: bool, @@ -299,6 +300,7 @@ impl<'e> Files<'e> { /// Explains why `files` (absolute paths, all [partly staged](Files::is_partly_staged)) weren't written. `done` is /// what didn't happen to them, like "fixed". + #[optimize(size)] pub(crate) fn refusal_to_write(&self, files: &mut [Vec], done: &str) -> Vec { bun_lint::utils::sort::sort_unstable(files); let shown = files diff --git a/src/lint/driver/init.rs b/src/lint/driver/init.rs index 72aab9d9ff56..ccfb6f34010d 100644 --- a/src/lint/driver/init.rs +++ b/src/lint/driver/init.rs @@ -105,6 +105,7 @@ impl Section { } /// `other` is another tool's config file, if one applies in the working directory. + #[optimize(size)] pub(crate) fn add(&self, other: Option<&[u8]>, environment: &Environment) -> Outcome { let mut out = Outcome::default(); match self.try_add(other, &environment.cwd) { diff --git a/src/lint/driver/lib.rs b/src/lint/driver/lib.rs index 12094453741a..c5d6a695c8fe 100644 --- a/src/lint/driver/lib.rs +++ b/src/lint/driver/lib.rs @@ -8,6 +8,9 @@ //! - [`fmt`]: the same for `bun fmt`. #![forbid(unsafe_code)] +// For what a flag like `--doctor`, `--rules` or `--init` runs once: `#[optimize(size)]` roughly halves it, and how fast it is +// doesn't matter. Nothing that runs for each file has it. +#![feature(optimize_attribute)] mod args; pub mod bunfig; diff --git a/src/lint/driver/lint.rs b/src/lint/driver/lint.rs index f09c8404aeba..f18f36f54149 100644 --- a/src/lint/driver/lint.rs +++ b/src/lint/driver/lint.rs @@ -136,33 +136,19 @@ pub(crate) fn unused_directives_as_oxlint(result: &mut LintResult, is_script: bo } } -/// Puts the messages that have fixes in the order in which the rules of oxlint report at one node: by the plugin, then by the name. -/// Of two fixes with the same range the first is applied. +/// Puts the messages, which are in the order in which oxlint reports them, in the order that its `Fixer::fix` gives them if one has +/// a fix: by the range of the fix, those without one first. It applies the fixes in that order, and prints what is left in it. +/// +/// Its sort is not stable, which shows from 33 messages on: those without a fix do not keep their order. Here they do. pub(crate) fn order_fixes_as_oxlint(messages: &mut [LintMessage]) { - const PLUGINS: [Plugin; 13] = [ - Plugin::Import, - Plugin::Eslint, - Plugin::TypeScript, - Plugin::Jest, - Plugin::React, - Plugin::ReactPerf, - Plugin::Unicorn, - Plugin::JsxA11y, - Plugin::Oxc, - Plugin::Nextjs, - Plugin::Promise, - Plugin::Vitest, - Plugin::Node, - ]; - bun_lint::utils::sort::sort_by_key(messages, |it| match (&it.fix, &it.rule_id) { - (None, _) => (0, ""), - (Some(_), Some(RuleId::Known(meta))) => { - let plugin = meta.plugin.in_oxlint(); - let rank = PLUGINS.iter().position(|it| *it == plugin); - (rank.unwrap_or(PLUGINS.len()), meta.name) - } - (Some(_), _) => (PLUGINS.len(), ""), - }); + if messages.iter().all(|it| it.fix.is_none()) { + return; + } + let range = |it: &LintMessage| it.fix.as_ref().map(|it| (it.span.start, it.span.end)); + bun_lint::utils::sort::sort_by_key(messages, |it| range(it).unwrap_or_default()); + for (it, reported) in messages.iter_mut().zip(1..) { + it.reported = reported; + } } impl Context<'_, '_> { @@ -394,6 +380,7 @@ impl Context<'_, '_> { let previous = LintResult { messages, suppressed: std::mem::take(&mut result.suppressed), + run_keys: std::mem::take(&mut result.run_keys), ..LintResult::default() }; let again = Again { @@ -637,7 +624,7 @@ impl Context<'_, '_> { return self.result(path, verify(&text), text, was_fixed, config); } result.messages = fixed.remaining; - // They are by place now. oxlint prints what is left in the order in which it has run its rules. + // They are by place now. bun_lint::utils::sort::sort_by_key(&mut result.messages, |it| it.reported); let is_fixed = fixed.is_fixed || was_fixed; let mut result = self.result(path, result, text, is_fixed, config); @@ -698,6 +685,7 @@ impl Context<'_, '_> { counts, text: (is_fixed || (self.keeps_text && is_reported)).then_some(text), line_starts: result.line_starts.filter(|_| !is_fixed && is_reported), + run_keys: result.run_keys, messages: result.messages, suppressed: result.suppressed, thrown: result.thrown, diff --git a/src/lint/driver/print_config.rs b/src/lint/driver/print_config.rs index 48a6d2b70fd7..32ebfc64bebe 100644 --- a/src/lint/driver/print_config.rs +++ b/src/lint/driver/print_config.rs @@ -24,6 +24,7 @@ pub(crate) fn object(entries: Vec<(&[u8], Json)>) -> Json { /// What ESLint prints for a file that has `config`, but for the default options of the rules, which /// ESLint fills in. `None`: the file is not linted. +#[optimize(size)] pub(crate) fn print(config: Option<&ResolvedConfig>) -> Vec { let Some(config) = config else { return b"undefined".to_vec(); diff --git a/src/lint/driver/results.rs b/src/lint/driver/results.rs index abe99566bf3e..d07590f62209 100644 --- a/src/lint/driver/results.rs +++ b/src/lint/driver/results.rs @@ -62,6 +62,8 @@ pub(crate) struct FileResult { pub(crate) text: Option>, /// Where the lines of `text` start, if that was looked for while it was linted. pub(crate) line_starts: Option>, + /// [`LintResult::run_keys`](bun_lint::linter::LintResult::run_keys), for when the file is linted again. + pub(crate) run_keys: Vec<(u64, u128)>, /// The text has been changed by fixes: `text` is ESLint's `output`. pub(crate) is_fixed: bool, /// Number of fixes applied. @@ -115,6 +117,7 @@ impl FileResult { had_types: false, text: None, line_starts: None, + run_keys: Vec::new(), is_fixed: false, fixes: 0, fixed_text: None, diff --git a/src/lint/driver/suppressions.rs b/src/lint/driver/suppressions.rs index 476e326050b9..6803d45938f5 100644 --- a/src/lint/driver/suppressions.rs +++ b/src/lint/driver/suppressions.rs @@ -102,6 +102,7 @@ impl Suppressions { } /// `save`: `stringify(suppressions, { space: 2 })` of `json-stable-stringify`. + #[optimize(size)] pub(crate) fn save(&mut self, path: &[u8]) -> Result<(), Fatal> { bun_lint::utils::sort::sort_by(&mut self.0, |a, b| a.0.cmp(&b.0)); let mut out = b"{".to_vec(); diff --git a/src/lint/driver/typed.rs b/src/lint/driver/typed.rs index 4a857603cb66..fbfddea66e53 100644 --- a/src/lint/driver/typed.rs +++ b/src/lint/driver/typed.rs @@ -735,7 +735,7 @@ pub(crate) fn lint( }); result.messages = fixed.remaining; if file.config.language.is_oxlint { - // They are by place now. oxlint prints what is left in the order in which it has run its rules. + // They are by place now. bun_lint::utils::sort::sort_by_key(&mut result.messages, |it| it.reported); } if !fixed.is_fixed { diff --git a/src/lint/eslint/rules/block_scoped_var.rs b/src/lint/eslint/rules/block_scoped_var.rs index 573dd8cb9b83..60b9e13b3223 100644 --- a/src/lint/eslint/rules/block_scoped_var.rs +++ b/src/lint/eslint/rules/block_scoped_var.rs @@ -29,19 +29,21 @@ impl BlockScopedVar { /// The innermost block, loop, `switch` or static block around `statement`. `None` at the top /// level of the file, which nothing is outside of. fn binding_context<'a>(statement: Stmt<'a>, cx: &mut Cx<'a, Self>) -> Option { - cx.state.binding_contexts.find(Node::Stmt(statement), |_, ancestor| match ancestor { - Node::Func(func) if func.kind() == FnKind::StaticBlock => Some(func.owner().span()), - Node::Func(func) => func.body_span(), - Node::Stmt(it) => match it.kind() { - StmtKind::Block(_) - | StmtKind::For { .. } - | StmtKind::ForIn { .. } - | StmtKind::ForOf { .. } - | StmtKind::Switch { .. } => Some(it.span()), + cx.state + .binding_contexts + .find(Node::Stmt(statement), |_, ancestor| match ancestor { + Node::Func(func) if func.kind() == FnKind::StaticBlock => Some(func.owner().span()), + Node::Func(func) => func.body_span(), + Node::Stmt(it) => match it.kind() { + StmtKind::Block(_) + | StmtKind::For { .. } + | StmtKind::ForIn { .. } + | StmtKind::ForOf { .. } + | StmtKind::Switch { .. } => Some(it.span()), + _ => None, + }, _ => None, - }, - _ => None, - }) + }) } } @@ -60,13 +62,19 @@ impl Rule for BlockScopedVar { fn stmt<'a>(&self, statement: Stmt<'a>, cx: &mut Cx<'a, Self>) { // `const`, `let` - if matches!(cx.text().get(statement.span().start as usize), Some(b'c' | b'l')) { + if matches!( + cx.text().get(statement.span().start as usize), + Some(b'c' | b'l') + ) { return; } let Some(declarations) = statement.as_var() else { return; }; - let Some(first) = declarations.first().filter(|it| it.var_kind() == VarKind::Var) else { + let Some(first) = declarations + .first() + .filter(|it| it.var_kind() == VarKind::Var) + else { return; }; let Some(context) = Self::binding_context(statement, cx) else { @@ -74,7 +82,8 @@ impl Rule for BlockScopedVar { }; // oxlint goes through them all. let is_oxlint = cx.language().is_oxlint; - let has_one_name = is_oxlint || first.pat().tag() == PatTag::Ident && declarations.iter().nth(1).is_none(); + let has_one_name = + is_oxlint || first.pat().tag() == PatTag::Ident && declarations.iter().nth(1).is_none(); let mut check_binding = |pat: Pat<'a>| { let Some(symbol) = pat.symbol() else { return; @@ -94,6 +103,7 @@ impl Rule for BlockScopedVar { } let definition = *definition.get_or_insert_with(|| cx.position(pat.span().start)); cx.report(identifier, OUT_OF_SCOPE) + .called_with(statement) .data("name", reference.name()) .data("definitionLine", definition.line) .data("definitionColumn", definition.column + 1) @@ -102,7 +112,8 @@ impl Rule for BlockScopedVar { // A declaration that gives the variable a value. if matches!(reference.node(), Node::Pat(_)) { labels.first("it is redeclared here"); - return labels.push(pattern_of(pat), format!("'{name}' is first declared here")); + return labels + .push(pattern_of(pat), format!("'{name}' is first declared here")); } labels.first(format!("'{name}' is used here")); labels.push(pattern_of(pat), "It is declared in a different scope here"); @@ -112,14 +123,19 @@ impl Rule for BlockScopedVar { // They are in source order, but for those in one pattern, which is not partly in a block. // So those outside the block are at the two ends. let mut references = symbol.references(); - if references.any(&mut is_inside) { + if is_oxlint { + // In the order of the text. + for reference in references { + is_inside(reference); + } + } else if references.any(&mut is_inside) { let _ = references.rfind(|&it| is_inside(it)); } if !is_oxlint || symbol.declaration_count() < 2 { return; } // For oxlint another declaration is a use. One that gives the variable a value is a reference here. - let mut is_declared_inside = |declaration: Declaration<'a>| { + let is_declared_inside = |declaration: Declaration<'a>| { let Some(name) = declaration.name_span() else { return false; }; @@ -130,7 +146,8 @@ impl Rule for BlockScopedVar { return true; } if cx.file().reference_at(name.start).is_none() { - cx.report(name, OUT_OF_SCOPE).data("name", symbol.name()).labels_with(|labels| { + let report = cx.report(name, OUT_OF_SCOPE).called_with(statement); + report.data("name", symbol.name()).labels_with(|labels| { labels.first("it is redeclared here"); let name = bstr::BStr::new(symbol.name().bytes()); labels.push(pattern_of(pat), format!("'{name}' is first declared here")); @@ -138,9 +155,8 @@ impl Rule for BlockScopedVar { } false }; - let mut declarations = symbol.declarations(); - if declarations.any(&mut is_declared_inside) { - let _ = declarations.rfind(|&it| is_declared_inside(it)); + for declaration in symbol.declarations() { + is_declared_inside(declaration); } }; for declaration in declarations { diff --git a/src/lint/eslint/rules/class_methods_use_this.rs b/src/lint/eslint/rules/class_methods_use_this.rs index 824a23a22a2f..bc21ecae55d4 100644 --- a/src/lint/eslint/rules/class_methods_use_this.rs +++ b/src/lint/eslint/rules/class_methods_use_this.rs @@ -47,8 +47,14 @@ pub struct State<'a> { impl<'a> State<'a> { pub fn methods_without_this(&self) -> impl Iterator> { - let unused = self.methods.iter().zip(&self.is_using_this).filter(|it| !it.1); - unused.map(|it| *it.0).filter(|func| !self.uses_this.contains(func)) + let unused = self + .methods + .iter() + .zip(&self.is_using_this) + .filter(|it| !it.1); + unused + .map(|it| *it.0) + .filter(|func| !self.uses_this.contains(func)) } } @@ -82,7 +88,10 @@ impl Checker { let except_methods = options.strings("exceptMethods"); Checker { enforce_for_class_fields: options.bool_or("enforceForClassFields", true), - except_methods: except_methods.iter().map(|name| name.as_bytes().to_vec()).collect(), + except_methods: except_methods + .iter() + .map(|name| name.as_bytes().to_vec()) + .collect(), ignore_override_methods: options.bool_or("ignoreOverrideMethods", false), ignore_classes_with_implements, are_private_names_public, @@ -100,23 +109,38 @@ impl Checker { let inside = |func: Func<'a>| { let whole = func.span(); let start = match func.open_paren() { - Some(open) => func.type_params().first().map_or(open, |it| it.span().start), + Some(open) => func + .type_params() + .first() + .map_or(open, |it| it.span().start), None => whole.start, }; Span::new(start, whole.end) }; - let methods = state.methods.iter().enumerate().map(|(index, &func)| (inside(func), func, index, false)); - let others = state.others.iter().map(|&func| (inside(func), func, usize::MAX, true)); + let methods = state + .methods + .iter() + .enumerate() + .map(|(index, &func)| (inside(func), func, index, false)); + let others = state + .others + .iter() + .map(|&func| (inside(func), func, usize::MAX, true)); let mut methods: Vec<_> = methods.chain(others).collect(); - methods.sort_unstable_by_key(|it| it.0.start); + bun_lint::utils::sort::sort_by_key(&mut methods, |it| it.0.start); // The last that starts before the `this`. let mut at = 0; - for e in file.exprs_of_kind(ExprTag::This).chain(file.exprs_of_kind(ExprTag::Super)) { + for e in file + .exprs_of_kind(ExprTag::This) + .chain(file.exprs_of_kind(ExprTag::Super)) + { let start = e.span().start; let is_at = methods.get(at).is_some_and(|it| it.0.start <= start) && methods.get(at + 1).is_none_or(|it| start < it.0.start); if !is_at { - at = methods.partition_point(|it| it.0.start <= start).wrapping_sub(1); + at = methods + .partition_point(|it| it.0.start <= start) + .wrapping_sub(1); } let around = methods.get_mut(at).filter(|it| start < it.0.end); if around.as_ref().is_some_and(|it| it.3) { @@ -144,8 +168,10 @@ impl Checker { return None; } // For oxlint a declaration without a body is a function as well. - let is_function = - |func: Func<'a>| func.has_body() || is_oxlint && !matches!(func.owner(), Node::Member(it) if it.is_signature()); + let is_function = |func: Func<'a>| { + func.has_body() + || is_oxlint && !matches!(func.owner(), Node::Member(it) if it.is_signature()) + }; let owner = state.owners_of_this.find(Node::Expr(e), |child, ancestor| match ancestor { Node::Func(func) => match func.kind() { FnKind::StaticBlock => Some(None), @@ -188,7 +214,9 @@ impl Checker { Some(arrow) => Some(arrow.end), None => func.body_span().map(|it| it.start), }; - owner.flatten().filter(|&func| !is_oxlint || body_start(func).is_some_and(|start| start <= e.span().start)) + owner.flatten().filter(|&func| { + !is_oxlint || body_start(func).is_some_and(|start| start <= e.span().start) + }) } /// Notes the functions of the members of `class` that have to use `this`, and the others. @@ -197,7 +225,9 @@ impl Checker { let before = state.methods.len(); self.check_member(member, class, state); if state.methods.len() == before { - state.others.extend(member.func().filter(|it| it.has_body())); + state + .others + .extend(member.func().filter(|it| it.has_body())); } } } @@ -216,7 +246,10 @@ impl Checker { // oxlint sees no function in parentheses, and none in a key. let is_oxlint = member.file().language().is_oxlint; let value = match is_field { - true => member.init().filter(|it| !(is_oxlint && it.is_parenthesized())).and_then(Expr::as_fn), + true => member + .init() + .filter(|it| !(is_oxlint && it.is_parenthesized())) + .and_then(Expr::as_fn), false => member.func(), }; let value = value.filter(|func| func.has_body()); @@ -249,7 +282,9 @@ impl Checker { return; } } - state.methods.extend(value.into_iter().chain(function_in_key)); + state + .methods + .extend(value.into_iter().chain(function_in_key)); } } @@ -282,6 +317,7 @@ impl Rule for ClassMethodsUseThis { self.checker.mark_this_used(cx.file(), &mut cx.state); for func in cx.state.methods_without_this() { cx.report(ast_utils::get_function_head_loc(func), MISSING_THIS) + .called_with(func.owner()) .data("name", ast_utils::get_function_name_with_kind(func)); } } diff --git a/src/lint/eslint/rules/curly.rs b/src/lint/eslint/rules/curly.rs index 66204b54861d..2f9a39c7a2bb 100644 --- a/src/lint/eslint/rules/curly.rs +++ b/src/lint/eslint/rules/curly.rs @@ -249,7 +249,7 @@ impl Curly { false => None, }; let report = |message: Message| match chain { - Some(first) => cx.report(body, message).listened_on(first), + Some(first) => cx.report(body, message).called_with(first), None => cx.report(body, message), }; match check.expected { diff --git a/src/lint/eslint/rules/eqeqeq.rs b/src/lint/eslint/rules/eqeqeq.rs index 76341bddbbf5..55aabf0de5e2 100644 --- a/src/lint/eslint/rules/eqeqeq.rs +++ b/src/lint/eslint/rules/eqeqeq.rs @@ -58,10 +58,17 @@ impl Eqeqeq { let is_oxlint = cx.language().is_oxlint; // oxlint sees nothing in parentheses. let is_seen = |it: Expr<'a>| !(is_oxlint && it.is_parenthesized()); - let is_null = [left, right].into_iter().any(|it| it.tag() == ExprTag::Null && is_seen(it)); - let is_typeof = [left, right].into_iter().any(|it| is_typeof(it) && is_seen(it)); - let literal_type: fn(Expr<'a>) -> Option<&'static str> = - if is_oxlint { literal_type_of_oxlint } else { literal_type }; + let is_null = [left, right] + .into_iter() + .any(|it| it.tag() == ExprTag::Null && is_seen(it)); + let is_typeof = [left, right] + .into_iter() + .any(|it| is_typeof(it) && is_seen(it)); + let literal_type: fn(Expr<'a>) -> Option<&'static str> = if is_oxlint { + literal_type_of_oxlint + } else { + literal_type + }; let are_literals_of_same_type = literal_type(left).is_some() && literal_type(left) == literal_type(right); let expected = match op { @@ -91,6 +98,7 @@ impl Eqeqeq { let is_whole = is_oxlint && !matches!(op, BinOp::EqEq | BinOp::NotEq); let report = cx .report(if is_whole { e.span() } else { operator }, UNEXPECTED) + .called_with(e) .data("expectedOperator", expected) .data("actualOperator", actual); if is_whole { @@ -98,7 +106,10 @@ impl Eqeqeq { } // oxlint replaces all that is between the operands. let change = |fixer: Fixer<'a>| match is_oxlint { - true => fixer.replace(left.outer_span().between(right.outer_span()), [" ", expected, " "].concat()), + true => fixer.replace( + left.outer_span().between(right.outer_span()), + [" ", expected, " "].concat(), + ), false => fixer.replace(operator, expected), }; // The change is safe if both sides are known to have the same type. diff --git a/src/lint/eslint/rules/func_names.rs b/src/lint/eslint/rules/func_names.rs index 925c3a2151fd..214579737fd4 100644 --- a/src/lint/eslint/rules/func_names.rs +++ b/src/lint/eslint/rules/func_names.rs @@ -72,7 +72,9 @@ fn guess_function_name<'a>(func: Func<'a>, known: &mut GuessedNames<'a>) -> Opti } _ => None, }); - found.flatten().filter(|name| is_valid_identifier_name(name.bytes())) + found + .flatten() + .filter(|name| is_valid_identifier_name(name.bytes())) } /// oxlint's fix: the function gets the name, unless that means something in the function. @@ -105,9 +107,7 @@ fn has_inferred_name(e: Expr) -> bool { Node::Prop(it) => it.value() == Some(e) && !it.is_jsx_attribute(), Node::Member(it) => it.init() == Some(e) && !it.flags().contains(Flags::ACCESSOR), Node::Expr(parent) => match parent.kind() { - ExprKind::Assign { target, value, .. } => { - value == e && target.tag() == ExprTag::Ident - } + ExprKind::Assign { target, value, .. } => value == e && target.tag() == ExprTag::Ident, _ => false, }, Node::Param(it) => it.default() == Some(e) && is_identifier(it.pat()), @@ -168,7 +168,10 @@ impl FuncNames { !e.is_parenthesized() && matches!(e.parent(), Node::Expr(it) if it.as_call().is_some_and(|it| it.callee() == e)) }; - if symbol.references().any(|it| it.expr().is_some_and(is_called)) { + if symbol + .references() + .any(|it| it.expr().is_some_and(is_called)) + { return; } NAMED @@ -183,12 +186,16 @@ impl FuncNames { let head = ast_utils::get_function_head_loc(func); // For oxlint it starts with the function, not with the member that the function is the value of. let (start, name) = match cx.language().is_oxlint { - true => (func.estree_span().start, utils::oxlint::get_function_name_with_kind(func)), + true => ( + func.estree_span().start, + utils::oxlint::get_function_name_with_kind(func), + ), false => (head.start, ast_utils::get_function_name_with_kind(func)), }; // `"FunctionExpression:exit"` let report = cx .report(Span::new(start, head.end), message) + .called_with(func) .on_exit(func.kind() == FnKind::Expr) .data("name", name); if cx.language().is_oxlint { diff --git a/src/lint/eslint/rules/func_style.rs b/src/lint/eslint/rules/func_style.rs index 179eaf5bace9..8bcd1774fdea 100644 --- a/src/lint/eslint/rules/func_style.rs +++ b/src/lint/eslint/rules/func_style.rs @@ -38,10 +38,10 @@ pub struct State<'a> { fn statement_list_owner(statement: Stmt<'_>) -> Option> { match statement.parent() { parent @ (Node::File(_) | Node::Func(_)) => Some(parent), - Node::Case(case) => Some(case.parent()).filter(|it| it.as_stmt().is_some_and(|it| it.tag() == StmtTag::Switch)), - Node::Stmt(parent) => { - (parent.as_block().is_some() || parent.tag() == StmtTag::Module).then_some(Node::Stmt(parent)) - } + Node::Case(case) => Some(case.parent()) + .filter(|it| it.as_stmt().is_some_and(|it| it.tag() == StmtTag::Switch)), + Node::Stmt(parent) => (parent.as_block().is_some() || parent.tag() == StmtTag::Module) + .then_some(Node::Stmt(parent)), _ => None, } } @@ -53,7 +53,11 @@ fn overloads<'a>(file: &'a File<'a>) -> FxHashSet<(Node<'a>, Name<'a>, bool)> { if func.has_body() || flags.contains(Flags::EXPORT | Flags::DEFAULT) { return None; } - Some((statement_list_owner(statement)?, func.name()?.name(), flags.contains(Flags::EXPORT))) + Some(( + statement_list_owner(statement)?, + func.name()?.name(), + flags.contains(Flags::EXPORT), + )) }); signatures.collect() } @@ -69,9 +73,10 @@ fn is_overloaded_function<'a>( let file = cx.file(); let overloads = cx.state.overloads.get_or_insert_with(|| overloads(file)); !overloads.is_empty() - && func.name().zip(statement_list_owner(statement)).is_some_and(|(name, owner)| { - overloads.contains(&(owner, name.name(), is_exported)) - }) + && func + .name() + .zip(statement_list_owner(statement)) + .is_some_and(|(name, owner)| overloads.contains(&(owner, name.name(), is_exported))) } impl FuncStyle { @@ -90,7 +95,9 @@ impl FuncStyle { return; } let expects_declaration = match self.named_exports { - Some(style) if declarator.flags().contains(Flags::EXPORT) => style == Style::Declaration, + Some(style) if declarator.flags().contains(Flags::EXPORT) => { + style == Style::Declaration + } _ => self.enforce_declarations, }; if !expects_declaration || self.allow_type_annotation && declarator.ty().is_some() { @@ -105,23 +112,26 @@ impl FuncStyle { fn check_this_or_super<'a>(&self, e: Expr<'a>, cx: &mut Cx<'a, Self>) { // A static block and the initializer of a property are not functions to ESLint. - let function = cx.state.functions.find(Node::Expr(e), |_, ancestor| match ancestor { - Node::Func(func) - if matches!( - func.kind(), - FnKind::Decl - | FnKind::Expr - | FnKind::Arrow - | FnKind::Method - | FnKind::Getter - | FnKind::Setter - | FnKind::Constructor - ) => - { - Some(func) - } - _ => None, - }); + let function = cx + .state + .functions + .find(Node::Expr(e), |_, ancestor| match ancestor { + Node::Func(func) + if matches!( + func.kind(), + FnKind::Decl + | FnKind::Expr + | FnKind::Arrow + | FnKind::Method + | FnKind::Getter + | FnKind::Setter + | FnKind::Constructor + ) => + { + Some(func) + } + _ => None, + }); if let Some(func) = function && func.is_arrow() { @@ -194,9 +204,14 @@ impl Rule for FuncStyle { _ => !self.enforce_declarations, }; // oxlint leaves alone what is declared more than once: with a namespace, an interface, a type of the same name. - let is_merged = cx.language().is_oxlint && func.symbol().is_some_and(|it| it.declaration_count() > 1); - if expects_expression && !is_merged && !is_overloaded_function(statement, func, is_exported, cx) { - cx.report(statement.span_without_export(), EXPRESSION); + let is_merged = + cx.language().is_oxlint && func.symbol().is_some_and(|it| it.declaration_count() > 1); + if expects_expression + && !is_merged + && !is_overloaded_function(statement, func, is_exported, cx) + { + cx.report(statement.span_without_export(), EXPRESSION) + .called_with(func); } } diff --git a/src/lint/eslint/rules/id_length.rs b/src/lint/eslint/rules/id_length.rs index 340459de9d61..42183645c323 100644 --- a/src/lint/eslint/rules/id_length.rs +++ b/src/lint/eslint/rules/id_length.rs @@ -59,7 +59,10 @@ fn is_left(e: Expr) -> bool { match e.parent() { Node::Expr(parent) => parent.as_assign().is_some_and(|(_, target, _)| target == e), Node::Stmt(parent) => match parent.kind() { - StmtKind::Expr(_) => parent.parent().as_stmt().is_some_and(|it| is_head_of(parent, it)), + StmtKind::Expr(_) => parent + .parent() + .as_stmt() + .is_some_and(|it| is_head_of(parent, it)), StmtKind::ForIn { left, .. } | StmtKind::ForOf { left, .. } => { left.as_expr() == Some(e) } @@ -95,7 +98,10 @@ fn is_definition(member: Member) -> bool { && !member.is_signature() && match member.kind() { MemberKind::Property => !flags.contains(Flags::ACCESSOR), - MemberKind::Method | MemberKind::Getter | MemberKind::Setter | MemberKind::Constructor => true, + MemberKind::Method + | MemberKind::Getter + | MemberKind::Setter + | MemberKind::Constructor => true, _ => false, } } @@ -104,13 +110,16 @@ impl IdLength { /// Whether the name that `text` starts with has the right length by [`IdLength::enough`]. #[inline] fn is_right_by_its_start(&self, text: &[u8]) -> bool { - text.get(..self.enough).is_some_and(|it| it.iter().all(u8::is_ascii_alphabetic)) + text.get(..self.enough) + .is_some_and(|it| it.iter().all(u8::is_ascii_alphabetic)) } /// The same for the name that is written at `start`. #[inline] fn is_right_at(&self, file: &File, start: u32) -> bool { - file.text().get(start as usize..).is_some_and(|it| self.is_right_by_its_start(it)) + file.text() + .get(start as usize..) + .is_some_and(|it| self.is_right_by_its_start(it)) } /// What to report about an identifier named `name`, which is without its `#`, and the limit that the message names. @@ -120,9 +129,25 @@ impl IdLength { } let length = get_grapheme_count(name) as f64; let problem = if length < self.min { - (if is_private { TOO_SHORT_PRIVATE } else { TOO_SHORT }, "min", self.min) + ( + if is_private { + TOO_SHORT_PRIVATE + } else { + TOO_SHORT + }, + "min", + self.min, + ) } else if length > self.max { - (if is_private { TOO_LONG_PRIVATE } else { TOO_LONG }, "max", self.max) + ( + if is_private { + TOO_LONG_PRIVATE + } else { + TOO_LONG + }, + "max", + self.max, + ) } else { return None; }; @@ -135,7 +160,10 @@ impl IdLength { /// to report it, or `None` if it is not in a place that the rule looks at. #[inline] fn check<'a>(&self, cx: &mut Cx<'a, Self>, name: Name<'a>, at: impl Fn() -> Option) { - if cx.state.get_or_insert_with(name, || self.problem_of(name).1.is_some()) { + if cx + .state + .get_or_insert_with(name, || self.problem_of(name).1.is_some()) + { self.report(cx, name, &at); } } @@ -155,12 +183,21 @@ impl IdLength { if let (name, Some((message, limit, value))) = self.problem_of(name) && let Some(at) = at() { - cx.report(at, message).data("name", name).data(limit, text::number_to_string(value)); + let report = cx.report(at, message).called_with_the_name(); + report + .data("name", name) + .data(limit, text::number_to_string(value)); } } /// The value `value`, named `name`, of a property of an object pattern with the key `key`. - fn property_of_pattern<'a>(&self, file: &File<'a>, key: Key<'a>, name: Name<'a>, value: Span) -> Option { + fn property_of_pattern<'a>( + &self, + file: &File<'a>, + key: Key<'a>, + name: Name<'a>, + value: Span, + ) -> Option { match key_identifier(key) == Some(name) { true => self.properties.then(|| key.inner_span(file)), false => Some(value), @@ -178,7 +215,8 @@ impl IdLength { Node::Param(param) if param.is_rest() => Some(pat.span()), Node::Param(param) => { let is_supported = param.default().is_some() - || !param.is_parameter_property() && param.func().is_some_and(is_function_with_body); + || !param.is_parameter_property() + && param.func().is_some_and(is_function_with_body); is_supported.then(|| param.binding_span()) } Node::PatElem(_) => Some(pat.span()), @@ -271,7 +309,11 @@ impl IdLength { }; let (file, keyword) = (cx.file(), attributes.keyword_span()); let is_assert = file.slice(keyword) == b"assert"; - self.check(cx, file.name_of(if is_assert { "assert" } else { "with" }), || Some(keyword)); + self.check( + cx, + file.name_of(if is_assert { "assert" } else { "with" }), + || Some(keyword), + ); for key in attributes.entries().iter().filter_map(Prop::key) { if let KeyKind::Ident(name) = key.kind() { self.check(cx, name, || Some(key.span(file))); @@ -289,7 +331,9 @@ impl IdLength { } else if let Some(keyword) = member.constructor_keyword() && !keyword.is_string() { - self.check(cx, keyword.name(), || is_definition(member).then(|| keyword.span())); + self.check(cx, keyword.name(), || { + is_definition(member).then(|| keyword.span()) + }); } } } @@ -320,10 +364,20 @@ impl IdLength { return; }; self.check(cx, name, || match pat.parent() { - Node::Param(param) if param.func().is_some_and(|it| it.kind() == FnKind::IndexSignature) => None, + Node::Param(param) + if param + .func() + .is_some_and(|it| it.kind() == FnKind::IndexSignature) => + { + None + } Node::Param(_) if name.is("this") => None, // `{ a }` - Node::PatProp(prop) if prop.is_shorthand() && prop.default().is_none() && !self.properties => None, + Node::PatProp(prop) + if prop.is_shorthand() && prop.default().is_none() && !self.properties => + { + None + } _ => Some(pat.span()), }); } @@ -412,12 +466,19 @@ impl IdLength { }; match cx.language().is_oxlint { true => self.check_member_expression_as_oxlint(e, name, cx), - false => self.check(cx, name.name(), || is_assigned_member(e).then(|| name.span())), + false => self.check(cx, name.name(), || { + is_assigned_member(e).then(|| name.span()) + }), } } /// `a.b`: a private name, or what an assignment or a property of an object pattern assigns to. - fn check_member_expression_as_oxlint<'a>(&self, e: Expr<'a>, name: Ident<'a>, cx: &mut Cx<'a, Self>) { + fn check_member_expression_as_oxlint<'a>( + &self, + e: Expr<'a>, + name: Ident<'a>, + cx: &mut Cx<'a, Self>, + ) { self.check(cx, name.name(), || { let is_assigned_to = || match e.parent() { Node::Expr(parent) => parent.as_assign().is_some_and(|(_, target, _)| target == e), @@ -471,7 +532,12 @@ impl IdLength { impl Rule for IdLength { const META: Meta = Meta::eslint("id-length", Kind::Suggestion); const ON: On = On::new() - .exprs(&[ExprTag::Ident, ExprTag::Dot, ExprTag::Assign, ExprTag::PrivateIdentifier]) + .exprs(&[ + ExprTag::Ident, + ExprTag::Dot, + ExprTag::Assign, + ExprTag::PrivateIdentifier, + ]) .stmts(&[ StmtTag::Interface, StmtTag::TypeAlias, @@ -512,7 +578,10 @@ impl Rule for IdLength { exceptions: (options.strings("exceptions").iter()) .map(|it| it.as_bytes().into()) .collect(), - exception_patterns: patterns.iter().filter_map(|it| Regex::new(it, "u").ok()).collect(), + exception_patterns: patterns + .iter() + .filter_map(|it| Regex::new(it, "u").ok()) + .collect(), check_generic: options.bool_or("checkGeneric", true), enough: match max == f64::INFINITY { true => min.ceil() as usize, @@ -540,7 +609,13 @@ impl Rule for IdLength { ]) .members() .enum_members() - .types(&[TypeTag::Import, TypeTag::Tuple, TypeTag::Ref, TypeTag::Typeof, TypeTag::Predicate]); + .types(&[ + TypeTag::Import, + TypeTag::Tuple, + TypeTag::Ref, + TypeTag::Typeof, + TypeTag::Predicate, + ]); if self.check_generic { on = on.type_params(); } @@ -562,7 +637,10 @@ impl Rule for IdLength { .stmts(&[StmtTag::Import]) .import_specs(); if self.properties { - on = on.props().types(&[TypeTag::Import]).exprs(&[ExprTag::Assign]); + on = on + .props() + .types(&[TypeTag::Import]) + .exprs(&[ExprTag::Assign]); } on } @@ -610,7 +688,14 @@ impl Rule for IdLength { } match (ty.tag(), cx.language().is_oxlint) { (TypeTag::Import, false) => self.check_import_type(ty, cx), - (TypeTag::Import | TypeTag::Tuple | TypeTag::Ref | TypeTag::Typeof | TypeTag::Predicate, true) => { + ( + TypeTag::Import + | TypeTag::Tuple + | TypeTag::Ref + | TypeTag::Typeof + | TypeTag::Predicate, + true, + ) => { self.check_type_as_oxlint(ty, cx); } _ => {} diff --git a/src/lint/eslint/rules/max_lines_per_function.rs b/src/lint/eslint/rules/max_lines_per_function.rs index dfa5eec6bbcb..8c52d0d2fea8 100644 --- a/src/lint/eslint/rules/max_lines_per_function.rs +++ b/src/lint/eslint/rules/max_lines_per_function.rs @@ -21,10 +21,16 @@ fn get_comment_only_lines<'a>(file: &'a File<'a>) -> Vec { for comment in file.comments() { let mut start = file.line_of(comment.start()); let mut end = file.line_of(comment.end()); - if file.token_before(comment).is_some_and(|before| file.line_of(before.end()) == start) { + if file + .token_before(comment) + .is_some_and(|before| file.line_of(before.end()) == start) + { start += 1; } - if file.token_after(comment).is_some_and(|after| file.line_of(after.start()) == end) { + if file + .token_after(comment) + .is_some_and(|after| file.line_of(after.start()) == end) + { end -= 1; } for line in start..=end { @@ -42,12 +48,14 @@ pub(super) fn count_comment_lines_as_oxlint<'a>(file: &'a File<'a>) -> Vec<(u32, let mut count = 0; let counts = file.comments().map(|comment| { let (first, last) = (file.line_of(comment.start()), file.line_of(comment.end())); - let is_first = - strings::is_all_js_whitespace(file.slice(Span::new(file.line_span(first).start, comment.start()))); + let is_first = strings::is_all_js_whitespace( + file.slice(Span::new(file.line_span(first).start, comment.start())), + ); count += match comment.kind() { TokenKind::Block => { - let is_last = - strings::is_all_js_whitespace(file.slice(Span::new(comment.end(), file.line_span(last).end))); + let is_last = strings::is_all_js_whitespace( + file.slice(Span::new(comment.end(), file.line_span(last).end)), + ); (last + u32::from(is_last)).saturating_sub(first + u32::from(!is_first)) } _ => u32::from(is_first && comment.text().starts_with(b"//")), @@ -72,7 +80,10 @@ fn has_other_line_breaks(text: &[u8]) -> bool { #[inline] fn is_method(func: Func) -> bool { - matches!(func.kind(), FnKind::Method | FnKind::Getter | FnKind::Setter | FnKind::Constructor) + matches!( + func.kind(), + FnKind::Method | FnKind::Getter | FnKind::Setter | FnKind::Constructor + ) } /// What is counted once in a file, when it is needed. @@ -109,7 +120,8 @@ impl MaxLinesPerFunction { fn count_line_breaks<'a>(span: Span, cx: &mut Cx<'a, Self>) -> Option { let (file, text) = (cx.file(), cx.slice(span)); let in_file = &mut cx.state.has_other_line_breaks; - let has_others = *in_file.get_or_insert_with(|| has_other_line_breaks(file.text())) && has_other_line_breaks(text); + let has_others = *in_file.get_or_insert_with(|| has_other_line_breaks(file.text())) + && has_other_line_breaks(text); (!has_others).then(|| strings::count_char(text, b'\n') as u32) } } @@ -122,7 +134,10 @@ impl Rule for MaxLinesPerFunction { fn new(options: &Options) -> Self { let object = options.object(0); MaxLinesPerFunction { - max: object.number("max").or_else(|| options.number(0)).map_or(50, |max| max as u32), + max: object + .number("max") + .or_else(|| options.number(0)) + .map_or(50, |max| max as u32), skips_comments: object.bool_or("skipComments", false), skips_blank_lines: object.bool_or("skipBlankLines", false), counts_iifes: object.bool_or("IIFEs", false), @@ -172,7 +187,9 @@ impl MaxLinesPerFunction { let is_oxlint = cx.language().is_oxlint; let span = match (func.owner(), func.kind()) { (Node::Member(member), _) if !is_oxlint => member.span(), - (Node::Expr(e), FnKind::Method | FnKind::Getter | FnKind::Setter) if !is_oxlint => e.parent().span(), + (Node::Expr(e), FnKind::Method | FnKind::Getter | FnKind::Setter) if !is_oxlint => { + e.parent().span() + } _ => func.estree_span(), }; if span.len() < self.max { @@ -189,9 +206,16 @@ impl MaxLinesPerFunction { } /// `span`: what of `func` is counted. `line_breaks`: how many are in it, if that is known. - fn check<'a>(&self, func: Func<'a>, span: Span, line_breaks: Option, cx: &mut Cx<'a, Self>) { + fn check<'a>( + &self, + func: Func<'a>, + span: Span, + line_breaks: Option, + cx: &mut Cx<'a, Self>, + ) { let (file, is_oxlint) = (cx.file(), cx.language().is_oxlint); - let line_breaks = line_breaks.unwrap_or_else(|| file.line_of(span.end) - file.line_of(span.start)); + let line_breaks = + line_breaks.unwrap_or_else(|| file.line_of(span.end) - file.line_of(span.start)); if line_breaks < self.max { return; } @@ -202,12 +226,18 @@ impl MaxLinesPerFunction { let up_to = |line: u32| counted.get(line as usize).map_or(0, |&it| it); line_count = up_to(last).saturating_sub(up_to(first.saturating_sub(1))); if is_oxlint && self.skips_comments { - let comments = cx.state.comments.get_or_insert_with(|| count_comment_lines_as_oxlint(file)); + let comments = cx + .state + .comments + .get_or_insert_with(|| count_comment_lines_as_oxlint(file)); let before = |offset: u32| { let at = comments.partition_point(|it| it.0 < offset); - at.checked_sub(1).and_then(|it| comments.get(it)).map_or(0, |it| it.1) + at.checked_sub(1) + .and_then(|it| comments.get(it)) + .map_or(0, |it| it.1) }; - line_count = line_count.saturating_sub(before(span.end).saturating_sub(before(span.start))); + line_count = + line_count.saturating_sub(before(span.end).saturating_sub(before(span.start))); } if line_count <= self.max { return; @@ -215,7 +245,14 @@ impl MaxLinesPerFunction { } // oxlint points at the function. let (name, place) = match cx.language().is_oxlint { - true => ([&b"The "[..], &utils::oxlint::get_function_name_with_kind(func)].concat(), func.estree_span()), + true => ( + [ + &b"The "[..], + &utils::oxlint::get_function_name_with_kind(func), + ] + .concat(), + func.estree_span(), + ), false => { let name = ast_utils::get_function_name_with_kind(func); // Before ESLint 10 it is all whose lines are counted. @@ -227,6 +264,7 @@ impl MaxLinesPerFunction { } }; cx.report(place, EXCEED) + .called_with(func) .data("name", name) .data("lineCount", line_count) .data("maxLines", self.max); diff --git a/src/lint/eslint/rules/max_params.rs b/src/lint/eslint/rules/max_params.rs index c2b9c681d28a..0fa1c287898d 100644 --- a/src/lint/eslint/rules/max_params.rs +++ b/src/lint/eslint/rules/max_params.rs @@ -27,7 +27,10 @@ impl Config { pub fn new(options: &Options) -> Config { let object = options.object(0); let max = match object.has("maximum") || object.has("max") { - true => object.usize("maximum").filter(|it| *it != 0).or_else(|| object.usize("max")), + true => object + .usize("maximum") + .filter(|it| *it != 0) + .or_else(|| object.usize("max")), false => Some(options.number(0).map_or(3, |it| it as usize)), }; let count_this = match (object.str("countThis"), object.bool("countVoidThis")) { @@ -69,7 +72,8 @@ impl Config { // Without a body it is a `TSEmptyBodyFunctionExpression` or a `TSMethodSignature`. The first is a function // for oxlint. FnKind::Method | FnKind::Getter | FnKind::Setter | FnKind::Constructor => { - func.has_body() || is_oxlint && matches!(func.owner(), Node::Member(it) if !it.is_signature()) + func.has_body() + || is_oxlint && matches!(func.owner(), Node::Member(it) if !it.is_signature()) } _ => false, }; @@ -83,18 +87,24 @@ impl Config { }); // oxlint does not count a rest parameter. let has_uncounted_rest = is_oxlint && func.params().last().is_some_and(Param::is_rest); - let count = func.params().len() + usize::from(counts_this) - usize::from(has_uncounted_rest); + let count = + func.params().len() + usize::from(counts_this) - usize::from(has_uncounted_rest); if count > max { // oxlint knows the name that the function itself has. let name = match (is_oxlint, func.name()) { - (false, _) => text::upper_case_first(&ast_utils::get_function_name_with_kind(func)).into_owned(), + (false, _) => text::upper_case_first(&ast_utils::get_function_name_with_kind(func)) + .into_owned(), (true, _) if func.is_arrow() => b"Arrow function".to_vec(), (true, Some(name)) => [&b"Function '"[..], name.bytes(), b"'"].concat(), (true, None) => b"Function".to_vec(), }; // oxlint points at the parameters. if is_oxlint && let Some(params) = func.params_span() { - cx.report(params, EXCEED).data("name", name).data("count", count).data("max", max); + let report = cx.report(params, EXCEED).called_with(func); + report + .data("name", name) + .data("count", count) + .data("max", max); return; } let mut head = ast_utils::get_function_head_loc(func); @@ -103,7 +113,9 @@ impl Config { && func.is_arrow() && func.arrow_span() != Some(head) && func.params().len() == 1 - && func.this_param().is_some_and(|this| this.ty().is_some_and(|ty| ty.is_keyword(Keyword::Void))) + && func + .this_param() + .is_some_and(|this| this.ty().is_some_and(|ty| ty.is_keyword(Keyword::Void))) && let Some(only) = func.params().first() { head.end = only.span().start; @@ -115,7 +127,10 @@ impl Config { { head = Span::new(func.estree_span().start, paren); } - cx.report(head, EXCEED).data("name", name).data("count", count).data("max", max); + cx.report(head, EXCEED) + .data("name", name) + .data("count", count) + .data("max", max); } } } diff --git a/src/lint/eslint/rules/new_cap.rs b/src/lint/eslint/rules/new_cap.rs index c722e686fd81..321c3ba4a83d 100644 --- a/src/lint/eslint/rules/new_cap.rs +++ b/src/lint/eslint/rules/new_cap.rs @@ -22,16 +22,26 @@ const UPPER: Message = Message::new( "upper", "A function with a name starting with an uppercase letter should only be used as a constructor.", ); -const LOWER: Message = Message::new("lower", "A constructor name should not start with a lowercase letter."); +const LOWER: Message = Message::new( + "lower", + "A constructor name should not start with a lowercase letter.", +); const CAPS_ALLOWED: [&str; 11] = [ - "Array", "Boolean", "Date", "Error", "Function", "Number", "Object", "RegExp", "String", "Symbol", "BigInt", + "Array", "Boolean", "Date", "Error", "Function", "Number", "Object", "RegExp", "String", + "Symbol", "BigInt", ]; const GLOBAL_OBJECT_NAMES: [&str; 4] = ["global", "globalThis", "self", "window"]; /// Before ESLint 10 the exceptions are the keys of an object, which has these too: `new constructor()`. The others do /// not start with a letter. const KEYS_OF_EVERY_OBJECT: [&str; 7] = [ - "constructor", "hasOwnProperty", "isPrototypeOf", "propertyIsEnumerable", "toLocaleString", "toString", "valueOf", + "constructor", + "hasOwnProperty", + "isPrototypeOf", + "propertyIsEnumerable", + "toLocaleString", + "toString", + "valueOf", ]; #[derive(Copy, Clone, PartialEq, Eq)] @@ -49,7 +59,9 @@ fn get_cap(name: &[u8]) -> Cap { Some(0x80..) => {} _ => return Cap::NonAlpha, } - let end = strings::wtf8_codepoints(name).nth(1).map_or(name.len(), |next| next.0); + let end = strings::wtf8_codepoints(name) + .nth(1) + .map_or(name.len(), |next| next.0); let first = name.get(..end).unwrap_or_default(); let lower = text::to_lower_case(first); if lower == text::to_upper_case(first) { @@ -76,7 +88,9 @@ fn is_global_built_in(e: Expr, name: &[u8]) -> bool { ExprKind::Ident(it) => it.bytes() == name && ast_utils::is_global_reference(e), ExprKind::Dot { obj, .. } | ExprKind::Index { obj, .. } => { ast_utils::get_static_property_name(e).is_some_and(|it| *it == *name) - && obj.as_ident().is_some_and(|it| it.is_any(&GLOBAL_OBJECT_NAMES)) + && obj + .as_ident() + .is_some_and(|it| it.is_any(&GLOBAL_OBJECT_NAMES)) && ast_utils::is_global_reference(obj) } _ => false, @@ -86,8 +100,15 @@ fn is_global_built_in(e: Expr, name: &[u8]) -> bool { impl Exceptions { fn new(options: Object, names: &str, pattern: &str) -> Exceptions { Exceptions { - names: options.strings(names).into_iter().map(|it| it.as_bytes().to_vec()).collect(), - pattern: options.str(pattern).filter(|it| !it.is_empty()).and_then(|it| Regex::new(it, "u").ok()), + names: options + .strings(names) + .into_iter() + .map(|it| it.as_bytes().to_vec()) + .collect(), + pattern: options + .str(pattern) + .filter(|it| !it.is_empty()) + .and_then(|it| Regex::new(it, "u").ok()), } } @@ -103,8 +124,14 @@ impl NewCap { let is_before_10 = callee.file().language().eslint_major < 10; if exceptions.has(name) || exceptions.has(source) - || exceptions.pattern.as_ref().is_some_and(|it| it.test(source)) - || (is_before_10 && KEYS_OF_EVERY_OBJECT.iter().any(|it| it.as_bytes() == name || it.as_bytes() == source)) + || exceptions + .pattern + .as_ref() + .is_some_and(|it| it.test(source)) + || (is_before_10 + && KEYS_OF_EVERY_OBJECT + .iter() + .any(|it| it.as_bytes() == name || it.as_bytes() == source)) { return true; } @@ -132,7 +159,7 @@ impl NewCap { && get_cap(&name) == Cap::Lower && !self.is_cap_allowed(&self.new_is_cap_exceptions, callee, &name) { - report(callee, LOWER, cx); + report(e, callee, LOWER, cx); } } @@ -146,7 +173,7 @@ impl NewCap { && !(CAPS_ALLOWED.iter().any(|it| it.as_bytes() == &*name) && (goes_by_the_name(cx.language()) || is_global_built_in(callee, &name))) { - report(callee, UPPER, cx); + report(e, callee, UPPER, cx); } } } @@ -155,19 +182,21 @@ impl NewCap { fn callee_of(call: Call<'_>) -> Expr<'_> { let callee = call.callee(); match callee.file().language().is_oxlint { - true => Some(get_inner_expression(callee)).filter(|it| it.tag() == ExprTag::Ident).unwrap_or(callee), + true => Some(get_inner_expression(callee)) + .filter(|it| it.tag() == ExprTag::Ident) + .unwrap_or(callee), false => callee, } } -/// At the property, or at the callee. -fn report<'a>(callee: Expr<'a>, message: Message, cx: &Cx<'a, NewCap>) { +/// At the property, or at the callee, of the call `e`. +fn report<'a>(e: Expr<'a>, callee: Expr<'a>, message: Message, cx: &Cx<'a, NewCap>) { let at = match callee.kind() { ExprKind::Dot { name, .. } => name.span(), ExprKind::Index { index, .. } => index.span(), _ => callee.span(), }; - cx.report(at, message); + cx.report(at, message).called_with(e); } impl Rule for NewCap { @@ -180,8 +209,16 @@ impl Rule for NewCap { NewCap { new_is_cap: options.bool_or("newIsCap", true), cap_is_new: options.bool_or("capIsNew", true), - new_is_cap_exceptions: Exceptions::new(options, "newIsCapExceptions", "newIsCapExceptionPattern"), - cap_is_new_exceptions: Exceptions::new(options, "capIsNewExceptions", "capIsNewExceptionPattern"), + new_is_cap_exceptions: Exceptions::new( + options, + "newIsCapExceptions", + "newIsCapExceptionPattern", + ), + cap_is_new_exceptions: Exceptions::new( + options, + "capIsNewExceptions", + "capIsNewExceptionPattern", + ), skips_properties: !options.bool_or("properties", true), } } @@ -201,11 +238,17 @@ impl Rule for NewCap { let tag = e.tag(); // The first byte of a name as it is written is nearly always that of the name. if let Some(callee) = e.callee() - && let Some(start) = callee.member_name_start().or_else(|| callee.as_ident().map(|_| callee.span().start)) + && let Some(start) = callee + .member_name_start() + .or_else(|| callee.as_ident().map(|_| callee.span().start)) && let Some(first) = cx.text().get(start as usize) && first.is_ascii() && *first != b'\\' - && !(if tag == ExprTag::New { first.is_ascii_lowercase() } else { first.is_ascii_uppercase() }) + && !(if tag == ExprTag::New { + first.is_ascii_lowercase() + } else { + first.is_ascii_uppercase() + }) { return; } diff --git a/src/lint/eslint/rules/no_magic_numbers.rs b/src/lint/eslint/rules/no_magic_numbers.rs index 05acbb83d052..a82eaa1840ef 100644 --- a/src/lint/eslint/rules/no_magic_numbers.rs +++ b/src/lint/eslint/rules/no_magic_numbers.rs @@ -529,8 +529,8 @@ fn report_magic<'a, R: Rule>(at: Span, sign: Option, literal: Literal<'a>, c let report = match literal.number { Some(number) if at == unsigned => cx.report(number, NO_MAGIC), // oxlint's rule is called with the number. - Some(number) if cx.language().is_oxlint => cx.report(at, NO_MAGIC).listened_on(number), - _ => cx.report(at, NO_MAGIC), + Some(number) => cx.report(at, NO_MAGIC).called_with(number), + None => cx.report(at, NO_MAGIC), }; report.data("raw", raw); } diff --git a/src/lint/eslint/rules/no_multi_assign.rs b/src/lint/eslint/rules/no_multi_assign.rs index abb0056bc976..78319668a4f7 100644 --- a/src/lint/eslint/rules/no_multi_assign.rs +++ b/src/lint/eslint/rules/no_multi_assign.rs @@ -32,10 +32,8 @@ impl Rule for NoMultiAssign { _ => false, }; // oxlint's rule is called with what the assignment is the value of. - match (is_chained, cx.language().is_oxlint) { - (true, true) => drop(cx.report(e, UNEXPECTED_CHAIN).listened_on(e.parent())), - (true, false) => drop(cx.report(e, UNEXPECTED_CHAIN)), - _ => {} + if is_chained { + cx.report(e, UNEXPECTED_CHAIN).called_with(e.parent()); } } } diff --git a/src/lint/eslint/rules/no_shadow.rs b/src/lint/eslint/rules/no_shadow.rs index 3b9e1014b10b..c8263b4223bb 100644 --- a/src/lint/eslint/rules/no_shadow.rs +++ b/src/lint/eslint/rules/no_shadow.rs @@ -113,11 +113,15 @@ fn class_declaration_with_the_name_of<'a>( impl<'a> Variable<'a> { fn new(symbol: Symbol<'a>, known: &mut Known<'a>) -> Option { let mut definition = symbol.declarations().find(|it| it.name_span().is_some())?; - // The name of an enum member in quotes is not an identifier. + // The name of an enum member in quotes is not an identifier. oxlint goes by the symbol. + let is_oxlint = symbol.file().language().is_oxlint; let mut with_identifier = symbol.declarations().find(|it| match it { - Declaration::EnumMember(member) => member - .key() - .is_some_and(|key| matches!(key.kind(), KeyKind::Ident(_))), + Declaration::EnumMember(member) => { + is_oxlint + || member + .key() + .is_some_and(|key| matches!(key.kind(), KeyKind::Ident(_))) + } _ => it.name_span().is_some(), })?; let class = class_declaration_with_the_name_of(definition, symbol.name()); diff --git a/src/lint/eslint/rules/no_underscore_dangle.rs b/src/lint/eslint/rules/no_underscore_dangle.rs index 1f3b03654b7f..7036cd0a44ca 100644 --- a/src/lint/eslint/rules/no_underscore_dangle.rs +++ b/src/lint/eslint/rules/no_underscore_dangle.rs @@ -31,7 +31,10 @@ fn has_dangling_underscore(identifier: &[u8]) -> bool { #[inline] fn may_dangle(text: &[u8], written: Span) -> bool { matches!(text.get(written.start as usize), Some(b'_' | b'#' | b'\\')) - || matches!(text.get(written.end.wrapping_sub(1) as usize), Some(b'_' | b'f' | b'F' | b'}')) + || matches!( + text.get(written.end.wrapping_sub(1) as usize), + Some(b'_' | b'f' | b'F' | b'}') + ) } /// ESTree's `object` and `property.name` of a `MemberExpression`. In `a[b]` the name is `b`. @@ -69,8 +72,14 @@ impl NoUnderscoreDangle { && (func.has_body() || cx.language().is_oxlint) { // Here and below, oxlint points at the name. - let place = if cx.language().is_oxlint { name.span() } else { statement.span_without_export() }; - cx.report(place, UNEXPECTED_UNDERSCORE).data("identifier", name); + let place = if cx.language().is_oxlint { + name.span() + } else { + statement.span_without_export() + }; + cx.report(place, UNEXPECTED_UNDERSCORE) + .called_with(func) + .data("identifier", name); } } @@ -80,15 +89,20 @@ impl NoUnderscoreDangle { && !param.is_parameter_property() && param.func().is_some_and(ast_utils::is_function_with_body) { - let place = if cx.language().is_oxlint { param.pat().span() } else { param.span_without_modifiers() }; - cx.report(place, UNEXPECTED_UNDERSCORE).data("identifier", name); + let place = if cx.language().is_oxlint { + param.pat().span() + } else { + param.span_without_modifiers() + }; + cx.report(place, UNEXPECTED_UNDERSCORE) + .called_with(param.pat()) + .data("identifier", name); } } #[inline(never)] fn check_variable<'a>(&self, declaration: VarDecl<'a>, cx: &mut Cx<'a, Self>) { - let is_declarator = - || matches!(declaration.parent(), Node::Stmt(statement) if statement.tag() == StmtTag::Var); + let is_declarator = || matches!(declaration.parent(), Node::Stmt(statement) if statement.tag() == StmtTag::Var); let pat = declaration.pat(); if let PatKind::Ident(name) = pat.kind() { let is_using = matches!(declaration.var_kind(), VarKind::Using | VarKind::AwaitUsing); @@ -96,12 +110,20 @@ impl NoUnderscoreDangle { && is_declarator() && !(is_using && self.allow_in_using_declarations) { - let place = if cx.language().is_oxlint { pat.span() } else { declaration.span() }; - cx.report(place, UNEXPECTED_UNDERSCORE).data("identifier", name); + let place = if cx.language().is_oxlint { + pat.span() + } else { + declaration.span() + }; + cx.report(place, UNEXPECTED_UNDERSCORE) + .called_with(pat) + .data("identifier", name); } return; } - if self.allow_in_array_destructuring && self.allow_in_object_destructuring || !is_declarator() { + if self.allow_in_array_destructuring && self.allow_in_object_destructuring + || !is_declarator() + { return; } // A name that is bound twice is one variable, which goes by its first binding. @@ -119,8 +141,14 @@ impl NoUnderscoreDangle { _ => false, }; if !is_allowed_here && !self.is_allowed(name.bytes()) { - let place = if cx.language().is_oxlint { binding.span() } else { declaration.span() }; - cx.report(place, UNEXPECTED_UNDERSCORE).data("identifier", name); + let place = if cx.language().is_oxlint { + binding.span() + } else { + declaration.span() + }; + cx.report(place, UNEXPECTED_UNDERSCORE) + .called_with(binding) + .data("identifier", name); } }); } @@ -146,7 +174,9 @@ impl NoUnderscoreDangle { None if cx.language().is_oxlint => return, _ => e.span(), }; - cx.report(place, UNEXPECTED_UNDERSCORE).data("identifier", identifier); + cx.report(place, UNEXPECTED_UNDERSCORE) + .called_with(e) + .data("identifier", identifier); } /// `A.b` after `implements`, or after the `extends` of an interface, is a `MemberExpression` @@ -155,7 +185,8 @@ impl NoUnderscoreDangle { let Some((name, _)) = ty.as_ref() else { return; }; - let is_unexpected = |part: &Ident<'a>| self.is_unexpected(part.bytes()) && part.bytes() != b"__proto__"; + let is_unexpected = + |part: &Ident<'a>| self.is_unexpected(part.bytes()) && part.bytes() != b"__proto__"; if name.len() < 2 || !name.parts().skip(1).any(|part| is_unexpected(&part)) { return; } @@ -168,7 +199,8 @@ impl NoUnderscoreDangle { return; }; for part in name.parts().skip(1).filter(is_unexpected) { - cx.report(first.span().to(part.span()), UNEXPECTED_UNDERSCORE).data("identifier", part); + cx.report(first.span().to(part.span()), UNEXPECTED_UNDERSCORE) + .data("identifier", part); } } @@ -189,14 +221,18 @@ impl NoUnderscoreDangle { true => cx .report(key.inner_span(cx.file()), UNEXPECTED_UNDERSCORE) .data("identifier", strings::without_prefix(name.bytes(), b"#")), - false => cx.report(at, UNEXPECTED_UNDERSCORE).data("identifier", name), + false => cx + .report(at, UNEXPECTED_UNDERSCORE) + .data("identifier", name), }; } } fn check_class_member<'a>(&self, member: Member<'a>, cx: &mut Cx<'a, Self>) { let is_enforced = match member.kind() { - MemberKind::Method | MemberKind::Getter | MemberKind::Setter => self.enforce_in_method_names, + MemberKind::Method | MemberKind::Getter | MemberKind::Setter => { + self.enforce_in_method_names + } MemberKind::Property => { self.enforce_in_class_fields && !member.flags().contains(Flags::ACCESSOR) } @@ -235,7 +271,11 @@ impl Rule for NoUnderscoreDangle { fn new(options: &Options) -> Self { let options = options.object(0); NoUnderscoreDangle { - allow: options.strings("allow").into_iter().map(|it| it.as_bytes().into()).collect(), + allow: options + .strings("allow") + .into_iter() + .map(|it| it.as_bytes().into()) + .collect(), allow_after_super: options.bool_or("allowAfterSuper", false), allow_after_this: options.bool_or("allowAfterThis", false), allow_after_this_constructor: options.bool_or("allowAfterThisConstructor", false), @@ -249,7 +289,10 @@ impl Rule for NoUnderscoreDangle { } fn narrow<'a>(&self, file: &'a File<'a>) -> On { - let mut on = On::new().stmts(&[StmtTag::Fn]).var_decls().exprs(&[ExprTag::Dot]); + let mut on = On::new() + .stmts(&[StmtTag::Fn]) + .var_decls() + .exprs(&[ExprTag::Dot]); if !file.language().is_oxlint { on = on.exprs(&[ExprTag::Index]); } diff --git a/src/lint/eslint/rules/no_use_before_define.rs b/src/lint/eslint/rules/no_use_before_define.rs index fddf3005d563..6a77f4fe295b 100644 --- a/src/lint/eslint/rules/no_use_before_define.rs +++ b/src/lint/eslint/rules/no_use_before_define.rs @@ -8,8 +8,10 @@ pub struct NoUseBeforeDefine { config: Config, } -const USED_BEFORE_DEFINED: Message = - Message::new("usedBeforeDefined", "'{{name}}' was used before it was defined."); +const USED_BEFORE_DEFINED: Message = Message::new( + "usedBeforeDefined", + "'{{name}}' was used before it was defined.", +); /// The options, which are those of the rule of typescript-eslint as well. pub struct Config { @@ -61,10 +63,14 @@ pub fn end_of_definition(definition: Declaration) -> Option { }; let end = declarator.span().end; match declarator.parent().parent() { - Node::Stmt(parent) if matches!(parent.tag(), StmtTag::ForIn | StmtTag::ForOf) => match parent.kind() { - StmtKind::ForIn { expr, .. } | StmtKind::ForOf { expr, .. } => Some(end.max(expr.span().end)), - _ => None, - }, + Node::Stmt(parent) if matches!(parent.tag(), StmtTag::ForIn | StmtTag::ForOf) => { + match parent.kind() { + StmtKind::ForIn { expr, .. } | StmtKind::ForOf { expr, .. } => { + Some(end.max(expr.span().end)) + } + _ => None, + } + } _ => Some(end), } } @@ -109,7 +115,9 @@ pub fn first_use(variable: Symbol) -> Option { } } let is_settled = first == u32::MAX - || initialized != 0 && first > initialized && variable.declaration_kinds() == DeclarationKinds::VARIABLE; + || initialized != 0 + && first > initialized + && variable.declaration_kinds() == DeclarationKinds::VARIABLE; (!is_settled).then_some(first) } @@ -166,7 +174,8 @@ fn is_initializer_of<'a>(child: Node<'a>, parent: Node<'a>) -> bool { Node::PatProp(prop) => prop.default() == Some(child), Node::Param(param) => param.default() == Some(child), Node::Expr(e) => { - matches!(e.kind(), ExprKind::Assign { value, .. } if value == child) && utils::is_assignment_target(e) + matches!(e.kind(), ExprKind::Assign { value, .. } if value == child) + && utils::is_assignment_target(e) } _ => false, } @@ -184,7 +193,9 @@ impl<'a> Initializers<'a> { let end = self.ends.find(name, |child, parent| match parent { Node::VarDecl(_) => Some(parent), // A signature or a function type does not end the search. - Node::Func(func) if func.has_body() && func.kind() != FnKind::StaticBlock => Some(child), + Node::Func(func) if func.has_body() && func.kind() != FnKind::StaticBlock => { + Some(child) + } // Around a statement is nothing but functions, classes, namespaces and the file. Node::Class(_) | Node::Stmt(_) => Some(child), _ => None, @@ -208,16 +219,19 @@ impl<'a> Initializers<'a> { /// Whether `reference` is in an initializer of the variable. pub fn has(&mut self, declared: Declared, reference: Reference<'a>) -> bool { let location = reference.span().end; - if declared.iterated.is_some_and(|it| is_in_range(it, location)) { + if declared + .iterated + .is_some_and(|it| is_in_range(it, location)) + { return true; } if !is_in_range(declared.within, location) { return false; } let mut at = reference.node(); - while let Some(owner) = - (self.owners).find(at, |child, parent| is_initializer_of(child, parent).then_some(parent)) - { + while let Some(owner) = (self.owners).find(at, |child, parent| { + is_initializer_of(child, parent).then_some(parent) + }) { let span = owner.span(); if !declared.within.contains(span) { return false; @@ -240,15 +254,20 @@ pub fn is_class_ref_in_class_decorator(definition: Declaration, identifier: Span /// ESLint's `isInClassStaticInitializerRange`. fn is_in_class_static_initializer_range(class: Class, location: u32) -> bool { // `location` is the end of a name. - class.members().around(location.saturating_sub(1)).is_some_and(|member| match member.kind() { - MemberKind::StaticBlock => is_in_range(member.span(), location), - MemberKind::Property => { - member.is_static() - && !member.flags().intersects(Flags::ACCESSOR | Flags::ABSTRACT) - && member.init().is_some_and(|value| is_in_range(value.span(), location)) - } - _ => false, - }) + class + .members() + .around(location.saturating_sub(1)) + .is_some_and(|member| match member.kind() { + MemberKind::StaticBlock => is_in_range(member.span(), location), + MemberKind::Property => { + member.is_static() + && !member.flags().intersects(Flags::ACCESSOR | Flags::ABSTRACT) + && member + .init() + .is_some_and(|value| is_in_range(value.span(), location)) + } + _ => false, + }) } /// ESLint's `isClassStaticInitializerScope`. @@ -316,14 +335,17 @@ fn parent_of_identifier(reference: Reference, is_in_type_query: bool) -> Parent _ => Parent::Other, }, // `typeof a.b` - Node::Expr(e) if is_in_type_query && matches!(e.parent(), Node::Expr(_)) => Parent::QualifiedName, + Node::Expr(e) if is_in_type_query && matches!(e.parent(), Node::Expr(_)) => { + Parent::QualifiedName + } _ => Parent::Other, } } impl NoUseBeforeDefine { fn check<'a>(&self, variable: Symbol<'a>, cx: &mut Cx<'a, Self>) { - let (Some(first), Some(definition)) = (first_use(variable), variable.declarations().next()) else { + let (Some(first), Some(definition)) = (first_use(variable), variable.declarations().next()) + else { return; }; let end = match definition { @@ -331,7 +353,8 @@ impl NoUseBeforeDefine { _ => end_of_definition(definition), }; if end.is_none_or(|end| first <= end) { - let is_defined = |it: Reference<'a>| starts_after(it, end) || it.is_init() || it.is_jsx_pragma(); + let is_defined = + |it: Reference<'a>| starts_after(it, end) || it.is_init() || it.is_jsx_pragma(); self.check_references(variable, definition, &is_defined, cx); } } @@ -347,7 +370,9 @@ impl NoUseBeforeDefine { ) { let config = &self.config; let references = variable.references(); - let (Some(kind), Some(definition_end)) = (definition.kind(), definition_name_end(definition)) else { + let (Some(kind), Some(definition_end)) = + (definition.kind(), definition_name_end(definition)) + else { return; }; if !config.functions && kind == DeclarationKind::FunctionName @@ -357,15 +382,16 @@ impl NoUseBeforeDefine { return; } let declared = cx.state.declared(definition); - let is_in_initializer = |reference: Reference<'a>, initializers: &mut Initializers<'a>| match definition { - // The binding of a class is initialized before its static initializers run. - Declaration::Class(class) => { - let location = reference.span().end; - is_in_range(class.estree_span(), location) - && !is_in_class_static_initializer_range(class, location) - } - _ => declared.is_some_and(|it| initializers.has(it, reference)), - }; + let is_in_initializer = + |reference: Reference<'a>, initializers: &mut Initializers<'a>| match definition { + // The binding of a class is initialized before its static initializers run. + Declaration::Class(class) => { + let location = reference.span().end; + is_in_range(class.estree_span(), location) + && !is_in_class_static_initializer_range(class, location) + } + _ => declared.is_some_and(|it| initializers.has(it, reference)), + }; for reference in references { // What JSX makes of `React` is a reference whose identifier is the declaration. if is_defined(reference) { @@ -389,14 +415,19 @@ impl NoUseBeforeDefine { } let is_in_type_query = reference_contains_type_query(reference); let parent = parent_of_identifier(reference, is_in_type_query); - if config.ignore_type_references && (is_in_type_query || parent == Parent::TypeReference) { + if config.ignore_type_references + && (is_in_type_query || parent == Parent::TypeReference) + { continue; } - if parent != Parent::QualifiedName && is_class_ref_in_class_decorator(definition, identifier) { + if parent != Parent::QualifiedName + && is_class_ref_in_class_decorator(definition, identifier) + { continue; } if is_before || !is_separate && parent != Parent::TypeReference { - cx.report(identifier, USED_BEFORE_DEFINED).data("name", reference.name()); + cx.report(reference, USED_BEFORE_DEFINED) + .data("name", reference.name()); } } } diff --git a/src/lint/eslint/rules/no_useless_assignment.rs b/src/lint/eslint/rules/no_useless_assignment.rs index 21e67047b8ed..382d85d68dcf 100644 --- a/src/lint/eslint/rules/no_useless_assignment.rs +++ b/src/lint/eslint/rules/no_useless_assignment.rs @@ -209,6 +209,9 @@ impl<'a> Binding<'a> { /// What `reference`, which is one to `symbol`, refers to. fn of(symbol: Symbol<'a>, reference: Reference<'a>) -> Binding<'a> { + if !(symbol.declaration_kinds()).contains(DeclarationKinds::CLASS_NAME) { + return Binding::outer(symbol); + } let class = match symbol.variable_in(reference.scope()).kind() { VariableKind::NameInClass { class, .. } => Some(class), _ => None, diff --git a/src/lint/eslint/rules/no_var.rs b/src/lint/eslint/rules/no_var.rs index e148767d0151..0c53b2c6daf5 100644 --- a/src/lint/eslint/rules/no_var.rs +++ b/src/lint/eslint/rules/no_var.rs @@ -8,17 +8,22 @@ use std::cell::RefCell; /// Require `let` or `const` instead of `var`. pub struct NoVar; -const UNEXPECTED_VAR: Message = Message::new("unexpectedVar", "Unexpected var, use let or const instead."); +const UNEXPECTED_VAR: Message = + Message::new("unexpectedVar", "Unexpected var, use let or const instead."); /// ESLint's `getEnclosingFunctionScope`. fn get_enclosing_function_scope(scope: Scope<'_>) -> Option> { - scope.chain().find(|it| matches!(it.kind(), ScopeKind::Function | ScopeKind::Global)) + scope + .chain() + .find(|it| matches!(it.kind(), ScopeKind::Function | ScopeKind::Global)) } /// ESLint's `isReferencedInClosure`. fn is_referenced_in_closure(variable: Symbol<'_>) -> bool { let enclosing = get_enclosing_function_scope(variable.scope()); - variable.references().any(|reference| get_enclosing_function_scope(reference.scope()) != enclosing) + variable + .references() + .any(|reference| get_enclosing_function_scope(reference.scope()) != enclosing) } /// ESLint's `isLoopAssignee`. @@ -49,7 +54,9 @@ fn scope_node_span(statement: Stmt<'_>) -> Span { return it.span(); } // The body of a function is a `BlockStatement`. A `StaticBlock` is not a scope node. - Node::Func(func) if func.kind() != FnKind::StaticBlock && matches!(child, Node::Stmt(_)) => { + Node::Func(func) + if func.kind() != FnKind::StaticBlock && matches!(child, Node::Stmt(_)) => + { if let Some(body) = func.body_span() { return body; } @@ -66,31 +73,34 @@ fn has_self_reference_in_tdz(declarator: VarDecl<'_>) -> bool { return false; }; let init_span = (!ast_utils::is_function(init)).then(|| init.span()); - Node::VarDecl(declarator).declared_symbols().into_iter().any(|variable| { - let Some(first) = variable.declarations().next() else { - return false; - }; - let Some(id) = first.name_span() else { - return false; - }; - let default_value = match first { - Declaration::Var(pat) | Declaration::Param(pat) => match pat.parent() { - Node::PatProp(prop) => prop.default(), - Node::PatElem(element) => element.default(), - Node::Param(param) => param.default(), + Node::VarDecl(declarator) + .declared_symbols() + .into_iter() + .any(|variable| { + let Some(first) = variable.declarations().next() else { + return false; + }; + let Some(id) = first.name_span() else { + return false; + }; + let default_value = match first { + Declaration::Var(pat) | Declaration::Param(pat) => match pat.parent() { + Node::PatProp(prop) => prop.default(), + Node::PatElem(element) => element.default(), + Node::Param(param) => param.default(), + _ => None, + }, _ => None, - }, - _ => None, - }; - let default_span = default_value.map(Expr::span); - variable.references().any(|reference| { - let at = reference.span(); - !reference.is_init() - && (at.start < id.start - || default_span.is_some_and(|it| it.contains(at)) - || init_span.is_some_and(|it| it.contains(at))) + }; + let default_span = default_value.map(Expr::span); + variable.references().any(|reference| { + let at = reference.span(); + !reference.is_init() + && (at.start < id.start + || default_span.is_some_and(|it| it.contains(at)) + || init_span.is_some_and(|it| it.contains(at))) + }) }) - }) } /// What the fixes of a file find out. @@ -106,24 +116,36 @@ pub struct State<'a> { impl<'a> State<'a> { /// Where the first call of `function` starts. fn first_call(&self, function: Symbol<'a>) -> u32 { - *self.first_calls.borrow_mut().entry(function).or_insert_with(|| { - let calls = function.references().filter(|it| it.expr().is_some_and(ast_utils::is_callee)); - calls.map(|it| it.span().start).min().unwrap_or(u32::MAX) - }) + *self + .first_calls + .borrow_mut() + .entry(function) + .or_insert_with(|| { + let calls = function + .references() + .filter(|it| it.expr().is_some_and(ast_utils::is_callee)); + calls.map(|it| it.span().start).min().unwrap_or(u32::MAX) + }) } fn is_in_loop(&self, statement: Stmt<'a>) -> bool { - let is_in_loop = self.loops.borrow_mut().find(statement.into(), |_, parent| { - match ast_utils::is_function(parent) { - true => Some(false), - false => ast_utils::is_loop(parent).then_some(true), - } - }); + let is_in_loop = + self.loops.borrow_mut().find( + statement.into(), + |_, parent| match ast_utils::is_function(parent) { + true => Some(false), + false => ast_utils::is_loop(parent).then_some(true), + }, + ); is_in_loop == Some(true) } /// ESLint's `hasUnsafeHoistedFunctionReference`. `declaration_start`: where the declarator starts. - fn has_unsafe_hoisted_function_reference(&self, variable: Symbol<'a>, declaration_start: u32) -> bool { + fn has_unsafe_hoisted_function_reference( + &self, + variable: Symbol<'a>, + declaration_start: u32, + ) -> bool { let home = variable.scope(); for reference in variable.references() { if reference.is_init() || reference.span().start < declaration_start { @@ -135,7 +157,9 @@ impl<'a> State<'a> { && func.kind() == FnKind::Decl && func.has_body() && let Some(name) = func.name() - && let Some(function) = current.parent().and_then(|upper| upper.get_name(name.name())) + && let Some(function) = current + .parent() + .and_then(|upper| upper.get_name(name.name())) && self.first_call(function) < declaration_start { return true; @@ -170,7 +194,8 @@ impl<'a> State<'a> { || variable.declarations().len() >= 2 || variable.name().is("let") }; - if variables.iter().any(is_never_let) || declarations.iter().any(has_self_reference_in_tdz) { + if variables.iter().any(is_never_let) || declarations.iter().any(has_self_reference_in_tdz) + { return false; } @@ -183,11 +208,14 @@ impl<'a> State<'a> { { return true; } - let Some(declarator) = variable.declarations().next().and_then(Declaration::node) else { + let Some(declarator) = variable.declarations().next().and_then(Declaration::node) + else { return true; }; let declaration_start = declarator.span().start; - variable.references().any(|reference| !reference.is_init() && reference.span().start < declaration_start) + variable + .references() + .any(|reference| !reference.is_init() && reference.span().start < declaration_start) || self.has_unsafe_hoisted_function_reference(variable, declaration_start) }; if variables.iter().any(|&variable| cannot_be_let(variable)) { @@ -195,7 +223,10 @@ impl<'a> State<'a> { } if self.is_in_loop(statement) { - if variables.iter().any(|&variable| is_referenced_in_closure(variable)) { + if variables + .iter() + .any(|&variable| is_referenced_in_closure(variable)) + { return false; } if !is_loop_assignee(statement) && !declarations.iter().all(|it| it.init().is_some()) { @@ -213,7 +244,10 @@ fn var_keyword(statement: Stmt<'_>) -> Option { Some(last) => skip_trivia(source, last.span().end), None => statement.span().start, }; - source.get(start as usize..)?.starts_with(b"var").then(|| Span::new(start, start + 3)) + source + .get(start as usize..)? + .starts_with(b"var") + .then(|| Span::new(start, start + 3)) } /// oxlint's `is_written_to`: something is assigned to one of the variables after the declaration. A default value @@ -224,7 +258,10 @@ fn oxlint_is_written_to(pat: Pat) -> bool { match pat.kind() { PatKind::Missing => {} PatKind::Ident(_) => { - if pat.symbol().is_some_and(|it| it.references().any(|it| it.is_write() && !it.is_init())) { + if pat + .symbol() + .is_some_and(|it| it.references().any(|it| it.is_write() && !it.is_init())) + { return true; } } @@ -238,7 +275,12 @@ fn oxlint_is_written_to(pat: Pat) -> bool { if elems.iter().any(|it| it.default().is_some()) { return true; } - pending.extend(elems.iter().filter(|it| !it.is_rest()).filter_map(PatElem::pat)); + pending.extend( + elems + .iter() + .filter(|it| !it.is_rest()) + .filter_map(PatElem::pat), + ); } } } @@ -248,7 +290,11 @@ fn oxlint_is_written_to(pat: Pat) -> bool { /// The fix of oxlint 1.87. There is none if a variable is referred to outside of what the declaration is in. It is /// `const` if nothing is assigned later and all have a value. All of the declaration is replaced, so that no other fix /// changes it in the same pass. -fn fix_as_oxlint<'a>(fixer: Fixer<'a>, statement: Stmt<'a>, declarations: List<'a, VarDecl<'a>>) -> Option { +fn fix_as_oxlint<'a>( + fixer: Fixer<'a>, + statement: Stmt<'a>, + declarations: List<'a, VarDecl<'a>>, +) -> Option { let var = var_keyword(statement)?; // A variable does not leave the file. The default value of a parameter is outside of the body of the function. let around = match statement.parent() { @@ -265,7 +311,8 @@ fn fix_as_oxlint<'a>(fixer: Fixer<'a>, statement: Stmt<'a>, declarations: List<' declaration.pat().for_each_binding(&mut |pat| { // That a parameter gets its default value is no reference for it. leaves |= pat.symbol().is_some_and(|it| { - it.references().any(|it| !around.contains(it.span()) && !(is_body && it.is_init())) + it.references() + .any(|it| !around.contains(it.span()) && !(is_body && it.is_init())) }); }); } @@ -274,7 +321,9 @@ fn fix_as_oxlint<'a>(fixer: Fixer<'a>, statement: Stmt<'a>, declarations: List<' } } let is_let = statement.flags().contains(Flags::AMBIENT) - || declarations.iter().any(|it| it.init().is_none() || oxlint_is_written_to(it.pat())); + || declarations + .iter() + .any(|it| it.init().is_none() || oxlint_is_written_to(it.pat())); let (file, span) = (fixer.file(), statement.span_without_export()); let before = file.slice(Span::before(span.start, var)); let after = file.slice(Span::after(var, span.end)); @@ -299,13 +348,19 @@ impl Rule for NoVar { fn stmt<'a>(&self, statement: Stmt<'a>, cx: &mut Cx<'a, Self>) { // `const`, `let` - if matches!(cx.text().get(statement.span().start as usize), Some(b'c' | b'l')) { + if matches!( + cx.text().get(statement.span().start as usize), + Some(b'c' | b'l') + ) { return; } let Some(declarations) = statement.as_var() else { return; }; - if declarations.first().is_none_or(|it| it.var_kind() != VarKind::Var) { + if declarations + .first() + .is_none_or(|it| it.var_kind() != VarKind::Var) + { return; } if let Node::Stmt(parent) = statement.parent() @@ -318,16 +373,25 @@ impl Rule for NoVar { let span = statement.span_without_export(); // oxlint says nothing about ambient declarations, and points at the keyword. let is_oxlint = cx.language().is_oxlint; - if is_oxlint && cx.state.ambient.has_ambient_typescript_ancestor(statement.into()) { + if is_oxlint + && cx + .state + .ambient + .has_ambient_typescript_ancestor(statement.into()) + { return; } let place = var_keyword(statement).filter(|_| is_oxlint).unwrap_or(span); - cx.report(place, UNEXPECTED_VAR).fix(|fixer| { - if is_oxlint { - return fix_as_oxlint(fixer, statement, declarations); - } - let var = var_keyword(statement)?; - cx.state.can_fix(statement, declarations).then(|| fixer.replace(var, "let")) - }); + cx.report(place, UNEXPECTED_VAR) + .called_with(statement) + .fix(|fixer| { + if is_oxlint { + return fix_as_oxlint(fixer, statement, declarations); + } + let var = var_keyword(statement)?; + cx.state + .can_fix(statement, declarations) + .then(|| fixer.replace(var, "let")) + }); } } diff --git a/src/lint/eslint/rules/prefer_destructuring.rs b/src/lint/eslint/rules/prefer_destructuring.rs index 5eb66d424f7b..f0fff426e226 100644 --- a/src/lint/eslint/rules/prefer_destructuring.rs +++ b/src/lint/eslint/rules/prefer_destructuring.rs @@ -82,7 +82,10 @@ impl Config { pub fn new(options: &Options) -> Config { let types = options.object(0); let (variable_declarator, assignment_expression) = if options.get(0).is_none() { - let all = Enabled { array: true, object: true }; + let all = Enabled { + array: true, + object: true, + }; (all, all) } else if types.has("array") || types.has("object") { (Enabled::new(types), Enabled::new(types)) @@ -95,7 +98,9 @@ impl Config { Config { variable_declarator, assignment_expression, - enforce_for_renamed_properties: options.object(1).bool_or("enforceForRenamedProperties", false), + enforce_for_renamed_properties: options + .object(1) + .bool_or("enforceForRenamedProperties", false), } } @@ -115,7 +120,8 @@ impl Config { { return enabled.array.then_some("array"); } - let is_template = matches!(property, Property::Computed(index) if index.tag() == ExprTag::Template); + let is_template = + matches!(property, Property::Computed(index) if index.tag() == ExprTag::Template); if !enabled.object || is_port_of_oxlint && is_template { return None; } @@ -125,7 +131,12 @@ impl Config { /// ESLint's listener for a `VariableDeclarator`. `can_fix`: whether to offer the fix. #[inline(never)] - pub fn check_variable_declarator<'a, R: Rule>(&self, declaration: VarDecl<'a>, cx: &Cx<'a, R>, can_fix: bool) { + pub fn check_variable_declarator<'a, R: Rule>( + &self, + declaration: VarDecl<'a>, + cx: &Cx<'a, R>, + can_fix: bool, + ) { let Some((object, property)) = declaration.init().and_then(member_expression) else { return; }; @@ -134,7 +145,9 @@ impl Config { } let left = declaration.pat().as_ident(); let is_port_of_oxlint = cx.language().is_oxlint && R::META.plugin == Plugin::Eslint; - let Some(kind) = self.perform_check(self.variable_declarator, left, property, is_port_of_oxlint) else { + let Some(kind) = + self.perform_check(self.variable_declarator, left, property, is_port_of_oxlint) + else { return; }; let has_same_name = property.is_called(left); @@ -144,7 +157,12 @@ impl Config { Some(init) if is_port_of_oxlint => init.outer_span(), _ => declaration.span(), }; - let report = cx.report(place, PREFER_DESTRUCTURING).data("type", type_in_message(kind, cx)).data("kind", kind); + let report = cx + .report(place, PREFER_DESTRUCTURING) + .called_with(declaration); + let report = report + .data("type", type_in_message(kind, cx)) + .data("kind", kind); // oxlint's port also fixes `let x = a["x"]`, does not look for comments, and leaves most parentheses. if is_port_of_oxlint { if kind == "object" && has_same_name && declaration.ty().is_none() { @@ -157,10 +175,21 @@ impl Config { let needs_no_parentheses = !object.is_chain_root() && matches!( object.tag(), - ExprTag::Call | ExprTag::Ident | ExprTag::Dot | ExprTag::Index | ExprTag::This + ExprTag::Call + | ExprTag::Ident + | ExprTag::Dot + | ExprTag::Index + | ExprTag::This ); - let object = if needs_no_parentheses { object.span() } else { object.outer_span() }; - fixer.replace(declaration, [&b"{"[..], name, b"} = ", file.slice(object)].concat()) + let object = if needs_no_parentheses { + object.span() + } else { + object.outer_span() + }; + fixer.replace( + declaration, + [&b"{"[..], name, b"} = ", file.slice(object)].concat(), + ) }); } return; @@ -208,7 +237,10 @@ impl Config { usize::from(self.enforce_for_renamed_properties && enabled.object) + usize::from(left == Some(name)), ), - _ => (0, usize::from(self.enforce_for_renamed_properties && enabled.object)), + _ => ( + 0, + usize::from(self.enforce_for_renamed_properties && enabled.object), + ), }, }; if arrays + objects == 0 || utils::is_assignment_target(e) { @@ -216,7 +248,9 @@ impl Config { } let arrays = std::iter::repeat_n(("Array", "array"), arrays); for (said, kind) in arrays.chain(std::iter::repeat_n(("Object", "object"), objects)) { - cx.report(e, PREFER_DESTRUCTURING).data("type", said).data("kind", kind); + cx.report(e, PREFER_DESTRUCTURING) + .data("type", said) + .data("kind", kind); } return; } @@ -224,7 +258,9 @@ impl Config { // A default value in a pattern is not an assignment. && !utils::is_assignment_target(e) { - cx.report(e, PREFER_DESTRUCTURING).data("type", type_in_message(kind, cx)).data("kind", kind); + cx.report(e, PREFER_DESTRUCTURING) + .data("type", type_in_message(kind, cx)) + .data("kind", kind); } } } @@ -239,7 +275,8 @@ fn is_enabled(it: Enabled) -> bool { } impl Rule for PreferDestructuring { - const META: Meta = Meta::eslint("prefer-destructuring", Kind::Suggestion).fixable(Fixable::Code); + const META: Meta = + Meta::eslint("prefer-destructuring", Kind::Suggestion).fixable(Fixable::Code); const ON: On = On::new().exprs(&[ExprTag::Assign]).var_decls(); no_state!(); @@ -275,7 +312,8 @@ impl Rule for PreferDestructuring { } let has_type_annotation = cx.language().is_oxlint && declaration.ty().is_some(); if !has_type_annotation || self.enforce_for_declaration_with_type_annotation { - self.config.check_variable_declarator(declaration, cx, !has_type_annotation); + self.config + .check_variable_declarator(declaration, cx, !has_type_annotation); } } } diff --git a/src/lint/jest/jest.rs b/src/lint/jest/jest.rs index 44ddb2f04e5d..aaf6a6373047 100644 --- a/src/lint/jest/jest.rs +++ b/src/lint/jest/jest.rs @@ -33,8 +33,17 @@ pub(crate) struct Ctx<'a, 'r> { pub(crate) report: &'r dyn Fn(Span, Message) -> Report<'a>, } +/// To be called before a rule that oxlint calls with the calls of Jest reports: [`File::calls_in_turns`]. +#[inline] +pub(crate) fn note_turns<'a>(file: &'a File<'a>, runner: Runner) { + if file.language().is_oxlint { + file.calls_in_turns(|| OxlintOrder::turns(file, runner)); + } +} + impl<'a> Ctx<'a, '_> { pub(crate) fn report(&self, at: impl Spanned, message: Message) -> Report<'a> { + note_turns(self.file, self.runner); (self.report)(at.span(), message) } @@ -1848,6 +1857,24 @@ impl<'a> OxlintOrder<'a> { nodes.iter().copied().min_by_key(|it| order.key(*it)) } + /// [`File::calls_in_turns`] + #[cold] + fn turns(file: &'a File<'a>, runner: Runner) -> Box<[(Span, u32)]> { + let order = OxlintOrder::new(file); + let nodes: Vec> = iter_possible_jest_call_node(file, runner).collect(); + let mut by_turn: Vec = (0..nodes.len() as u32).collect(); + utils::sort::sort_by_cached_key(&mut by_turn, |it| { + nodes.get(*it as usize).map(|it| order.key(*it)) + }); + let mut calls: Box<[(Span, u32)]> = nodes.iter().map(|it| (it.node.span(), 0)).collect(); + for (turn, at) in (0..).zip(by_turn) { + if let Some(call) = calls.get_mut(at as usize) { + call.1 = turn; + } + } + calls + } + /// The place of `name` in the table. pub(crate) fn rank(&self, name: Name<'a>) -> u32 { self.names.rank(name) diff --git a/src/lint/jest/jest_hooks.rs b/src/lint/jest/jest_hooks.rs index 791aab0b039d..00ad8724e31e 100644 --- a/src/lint/jest/jest_hooks.rs +++ b/src/lint/jest/jest_hooks.rs @@ -236,10 +236,7 @@ pub(crate) mod require_hook { around: Node<'a>, ctx: &Ctx<'a, '_>, ) { - let report = |at: Span| match ctx.file.language().is_oxlint { - true => drop(ctx.report(at, USE_HOOK).listened_on(around)), - false => drop(ctx.report(at, USE_HOOK)), - }; + let report = |at: Span| drop(ctx.report(at, USE_HOOK).called_with(around)); for stmt in statements { if let Some(expr) = stmt.as_expr() { if let Some(call_expr) = expr.as_call() diff --git a/src/lint/jest/rules/jest_no_untyped_mock_factory.rs b/src/lint/jest/rules/jest_no_untyped_mock_factory.rs index 9dc612505e69..65139a53ad39 100644 --- a/src/lint/jest/rules/jest_no_untyped_mock_factory.rs +++ b/src/lint/jest/rules/jest_no_untyped_mock_factory.rs @@ -1,17 +1,20 @@ -use bun_lint_oxlint::ast_util::{as_member_expression, static_property_info}; -use bun_lint_oxlint::module_record::debug; use crate::jest::{self, PossibleJestNode}; use bun_lint::prelude::*; use bun_lint::rule::Plugin; +use bun_lint_oxlint::ast_util::{as_member_expression, static_property_info}; +use bun_lint_oxlint::module_record::debug; /// This rule triggers a warning if `mock()` or `doMock()` is used without a generic type parameter or return type. pub struct NoUntypedMockFactory; -const ADD_TYPE_PARAMETER_TO_MODULE_MOCK: Message = - Message::new("", "`jest.mock()` factories should not be used without an explicit type parameter."); +const ADD_TYPE_PARAMETER_TO_MODULE_MOCK: Message = Message::new( + "", + "`jest.mock()` factories should not be used without an explicit type parameter.", +); impl Rule for NoUntypedMockFactory { - const META: Meta = Meta::oxlint(Plugin::Jest, "no-untyped-mock-factory", Kind::Suggestion).fixable(Fixable::Code); + const META: Meta = Meta::oxlint(Plugin::Jest, "no-untyped-mock-factory", Kind::Suggestion) + .fixable(Fixable::Code); const ON: On = On::new().finish(); type State<'a> = (); @@ -20,34 +23,51 @@ impl Rule for NoUntypedMockFactory { } fn start<'a>(&self, file: &'a File<'a>) -> Option<()> { - (!file.is_javascript() && jest::mentions_any(file, &const { jest::mention_bits(["mock", "doMock"]) }) && jest::is_test(file, jest::JEST)).then_some(()) + (!file.is_javascript() + && jest::mentions_any(file, &const { jest::mention_bits(["mock", "doMock"]) }) + && jest::is_test(file, jest::JEST)) + .then_some(()) } fn finish(&self, cx: &mut Cx<'_, Self>) { - jest::iter_possible_jest_call_node(cx.file(), jest::Runner::Jest).for_each(|node| run(node, cx)); + jest::iter_possible_jest_call_node(cx.file(), jest::Runner::Jest) + .for_each(|node| run(node, cx)); } } fn run<'a>(possible_jest_node: PossibleJestNode<'a>, cx: &Cx<'a, NoUntypedMockFactory>) { if let Some(call_expr) = possible_jest_node.node.as_call() - && let Some((property_span, property_name)) = as_member_expression(call_expr.callee()).and_then(static_property_info) + && let Some((property_span, property_name)) = + as_member_expression(call_expr.callee()).and_then(static_property_info) && property_name.is_any(&["mock", "doMock"]) && call_expr.args().len() == 2 && call_expr.type_args().is_empty() - && let (Some(name_node), Some(factory_node)) = (call_expr.args().first(), call_expr.args().get(1)) - && !(factory_node.as_fn()).is_some_and(|it| !factory_node.is_parenthesized() && it.return_type().is_some()) + && let (Some(name_node), Some(factory_node)) = + (call_expr.args().first(), call_expr.args().get(1)) + && !(factory_node.as_fn()) + .is_some_and(|it| !factory_node.is_parenthesized() && it.return_type().is_some()) && !name_node.is_parenthesized() { match name_node.kind() { ExprKind::String(value) => { + jest::note_turns(cx.file(), jest::Runner::Jest); let report = cx.report(property_span, ADD_TYPE_PARAMETER_TO_MODULE_MOCK); - report.data("module_name", debug(value.bytes())).fix(|fixer| { - let code = [b"".as_slice()].concat(); - fixer.insert_after(call_expr.callee(), code) - }); + report + .data("module_name", debug(value.bytes())) + .fix(|fixer| { + let code = [ + b"".as_slice(), + ] + .concat(); + fixer.insert_after(call_expr.callee(), code) + }); } ExprKind::Ident(name) => { - cx.report(property_span, ADD_TYPE_PARAMETER_TO_MODULE_MOCK).data("module_name", debug(name.bytes())); + jest::note_turns(cx.file(), jest::Runner::Jest); + cx.report(property_span, ADD_TYPE_PARAMETER_TO_MODULE_MOCK) + .data("module_name", debug(name.bytes())); } _ => {} } diff --git a/src/lint/jest/rules/vitest_consistent_each_for.rs b/src/lint/jest/rules/vitest_consistent_each_for.rs index a553758f48f2..1c7efd890616 100644 --- a/src/lint/jest/rules/vitest_consistent_each_for.rs +++ b/src/lint/jest/rules/vitest_consistent_each_for.rs @@ -8,8 +8,10 @@ pub struct ConsistentEachFor { not_allowed_methods: [Option<&'static str>; 4], } -const CONSISTENT_EACH_FOR: Message = - Message::new("", "`{{fn_kind}}` can not be used with `.{{method_used}}` to create parameterized test."); +const CONSISTENT_EACH_FOR: Message = Message::new( + "", + "`{{fn_kind}}` can not be used with `.{{method_used}}` to create parameterized test.", +); const FUNCTIONS: [&str; 4] = ["describe", "it", "test", "suite"]; @@ -30,29 +32,49 @@ impl Rule for ConsistentEachFor { } fn start<'a>(&self, file: &'a File<'a>) -> Option<()> { - (self.not_allowed_methods.iter().flatten().any(|it| file.mentions(it)) && jest::is_test(file, jest::VITEST)).then_some(()) + (self + .not_allowed_methods + .iter() + .flatten() + .any(|it| file.mentions(it)) + && jest::is_test(file, jest::VITEST)) + .then_some(()) } fn finish(&self, cx: &mut Cx<'_, Self>) { - jest::iter_possible_jest_call_node(cx.file(), jest::Runner::Vitest).for_each(|node| self.run(node, cx)); + jest::iter_possible_jest_call_node(cx.file(), jest::Runner::Vitest) + .for_each(|node| self.run(node, cx)); } } impl ConsistentEachFor { fn run<'a>(&self, possible_jest_node: PossibleJestNode<'a>, cx: &Cx<'a, Self>) { - let Some(jest_fn_call) = jest::parse_general_jest_fn_call(cx.file(), possible_jest_node) else { + let Some(jest_fn_call) = jest::parse_general_jest_fn_call(cx.file(), possible_jest_node) + else { return; }; - if matches!(jest_fn_call.kind, JestFnKind::General(JestGeneralFnKind::Describe | JestGeneralFnKind::Test)) - && let Some(fn_kind) = FUNCTIONS.iter().position(|it| it.as_bytes() == jest_fn_call.name) + if matches!( + jest_fn_call.kind, + JestFnKind::General(JestGeneralFnKind::Describe | JestGeneralFnKind::Test) + ) && let Some(fn_kind) = FUNCTIONS + .iter() + .position(|it| it.as_bytes() == jest_fn_call.name) && let Some(Some(not_allowed_method)) = self.not_allowed_methods.get(fn_kind) && let Some(last_method) = jest_fn_call.members.last() && last_method.is_name_equal(not_allowed_method) { + jest::note_turns(cx.file(), jest::Runner::Vitest); cx.report(last_method.span, CONSISTENT_EACH_FOR) .data("fn_kind", jest_fn_call.name) .data("method_used", *not_allowed_method) - .data("method", if *not_allowed_method == "each" { "for" } else { "each" }); + .data( + "method", + if *not_allowed_method == "each" { + "for" + } else { + "each" + }, + ); } } } diff --git a/src/lint/jest/rules/vitest_hoisted_apis_on_top.rs b/src/lint/jest/rules/vitest_hoisted_apis_on_top.rs index 9d3f9022e22f..815bcc68b5b8 100644 --- a/src/lint/jest/rules/vitest_hoisted_apis_on_top.rs +++ b/src/lint/jest/rules/vitest_hoisted_apis_on_top.rs @@ -1,17 +1,21 @@ -use bun_lint_oxlint::import::import_entries; use crate::jest::{self, AstKind, JestFnKind, JestGeneralFnKind, PossibleJestNode}; use bun_lint::prelude::*; use bun_lint::rule::Plugin; +use bun_lint_oxlint::import::import_entries; /// Requires hoisted Vitest APIs (`vi.mock`, `vi.unmock`, and `vi.hoisted`) to appear at the top level of the file. pub struct HoistedApisOnTop; -const HOISTED_APIS_ON_TOP: Message = Message::new("", "Hoisted API cannot be used in a runtime location in this file."); +const HOISTED_APIS_ON_TOP: Message = Message::new( + "", + "Hoisted API cannot be used in a runtime location in this file.", +); const MOVE_TO_THE_TOP: Message = Message::new("", "Moving hoisted methods to the top of the file"); const REPLACE_WITH_DO_MOCK: Message = Message::new("", "Replace `mock` with `doMock`."); impl Rule for HoistedApisOnTop { - const META: Meta = Meta::oxlint(Plugin::Vitest, "hoisted-apis-on-top", Kind::Problem).has_suggestions(); + const META: Meta = + Meta::oxlint(Plugin::Vitest, "hoisted-apis-on-top", Kind::Problem).has_suggestions(); const ON: On = On::new().finish(); type State<'a> = (); @@ -20,11 +24,18 @@ impl Rule for HoistedApisOnTop { } fn start<'a>(&self, file: &'a File<'a>) -> Option<()> { - (jest::mentions_any(file, &const { jest::mention_bits(["vi", "vitest"]) }) && jest::mentions_any(file, &const { jest::mention_bits(["mock", "hoisted", "unmock"]) }) && jest::is_test(file, jest::VITEST)).then_some(()) + (jest::mentions_any(file, &const { jest::mention_bits(["vi", "vitest"]) }) + && jest::mentions_any( + file, + &const { jest::mention_bits(["mock", "hoisted", "unmock"]) }, + ) + && jest::is_test(file, jest::VITEST)) + .then_some(()) } fn finish(&self, cx: &mut Cx<'_, Self>) { - jest::iter_possible_jest_call_node(cx.file(), jest::Runner::Vitest).for_each(|node| run(node, cx)); + jest::iter_possible_jest_call_node(cx.file(), jest::Runner::Vitest) + .for_each(|node| run(node, cx)); } } @@ -39,7 +50,10 @@ fn run<'a>(possible_jest_node: PossibleJestNode<'a>, cx: &Cx<'a, HoistedApisOnTo let Some(member) = vitest_fn.members.first() else { return; }; - let Some(member_name) = member.name().filter(|it| matches!(*it, b"mock" | b"hoisted" | b"unmock")) else { + let Some(member_name) = member + .name() + .filter(|it| matches!(*it, b"mock" | b"hoisted" | b"unmock")) + else { return; }; let parent = AstKind::Expr(node).parent(); @@ -48,7 +62,10 @@ fn run<'a>(possible_jest_node: PossibleJestNode<'a>, cx: &Cx<'a, HoistedApisOnTo // `vi.hoisted()` can also be awaited, and be what a variable is declared with. let mut statement = parent; if member_name == b"hoisted" { - if statement.as_expr().is_some_and(|it| it.tag() == ExprTag::Await) { + if statement + .as_expr() + .is_some_and(|it| it.tag() == ExprTag::Await) + { statement = statement.parent(); } if matches!(statement, AstKind::Other(Node::VarDecl(_))) { @@ -64,16 +81,27 @@ fn run<'a>(possible_jest_node: PossibleJestNode<'a>, cx: &Cx<'a, HoistedApisOnTo return; } - let report = cx.report(node, HOISTED_APIS_ON_TOP).suggest(MOVE_TO_THE_TOP, |fixer| { - let last_import = import_entries(fixer.file()).last().map(|it| it.declaration.stmt().span().end); - let before = if last_import.is_some() { "\n" } else { "" }; - let new_code = [before.as_bytes(), node.text(), b";\n".as_slice()].concat(); - [ - if is_expression_statement { fixer.remove(node) } else { fixer.replace(node, "undefined") }, - fixer.insert_after(Span::empty(last_import.unwrap_or(0)), new_code), - ] - }); + jest::note_turns(cx.file(), jest::Runner::Vitest); + let report = cx + .report(node, HOISTED_APIS_ON_TOP) + .suggest(MOVE_TO_THE_TOP, |fixer| { + let last_import = import_entries(fixer.file()) + .last() + .map(|it| it.declaration.stmt().span().end); + let before = if last_import.is_some() { "\n" } else { "" }; + let new_code = [before.as_bytes(), node.text(), b";\n".as_slice()].concat(); + [ + if is_expression_statement { + fixer.remove(node) + } else { + fixer.replace(node, "undefined") + }, + fixer.insert_after(Span::empty(last_import.unwrap_or(0)), new_code), + ] + }); if member_name == b"mock" { - report.suggest(REPLACE_WITH_DO_MOCK, |fixer| fixer.replace(member.span, "doMock")); + report.suggest(REPLACE_WITH_DO_MOCK, |fixer| { + fixer.replace(member.span, "doMock") + }); } } diff --git a/src/lint/jest/rules/vitest_prefer_import_in_mock.rs b/src/lint/jest/rules/vitest_prefer_import_in_mock.rs index 321a15c287c7..68aa1642e955 100644 --- a/src/lint/jest/rules/vitest_prefer_import_in_mock.rs +++ b/src/lint/jest/rules/vitest_prefer_import_in_mock.rs @@ -1,7 +1,7 @@ -use bun_lint_oxlint::ast_util::callee_name; use crate::jest::{self, PossibleJestNode}; use bun_lint::prelude::*; use bun_lint::rule::Plugin; +use bun_lint_oxlint::ast_util::callee_name; /// This rule enforces using a dynamic `import()` in `vi.mock()` or `vi.doMock()`, which improves type information and /// IntelliSense for the mocked module. @@ -12,22 +12,31 @@ pub struct PreferImportInMock { const PREFER_IMPORT_IN_MOCK: Message = Message::new("", "Mocked modules must be dynamic imported."); impl Rule for PreferImportInMock { - const META: Meta = Meta::oxlint(Plugin::Vitest, "prefer-import-in-mock", Kind::Suggestion).fixable(Fixable::Code); + const META: Meta = Meta::oxlint(Plugin::Vitest, "prefer-import-in-mock", Kind::Suggestion) + .fixable(Fixable::Code); const ON: On = On::new().finish(); type State<'a> = (); fn new(options: &Options) -> Self { // Options that are not valid are as none. let config = options.object(0); - PreferImportInMock { fixable: config.entries().len() != 1 || config.bool_or("fixable", true) } + PreferImportInMock { + fixable: config.entries().len() != 1 || config.bool_or("fixable", true), + } } fn start<'a>(&self, file: &'a File<'a>) -> Option<()> { - (jest::mentions_any(file, &const { jest::mention_bits(["jest", "vi", "vitest"]) }) && jest::mentions_any(file, &const { jest::mention_bits(["mock", "doMock"]) }) && jest::is_test(file, jest::VITEST)).then_some(()) + (jest::mentions_any( + file, + &const { jest::mention_bits(["jest", "vi", "vitest"]) }, + ) && jest::mentions_any(file, &const { jest::mention_bits(["mock", "doMock"]) }) + && jest::is_test(file, jest::VITEST)) + .then_some(()) } fn finish<'a>(&self, cx: &mut Cx<'a, Self>) { - jest::iter_possible_jest_call_node(cx.file(), jest::Runner::Vitest).for_each(|node| self.run(node, cx)); + jest::iter_possible_jest_call_node(cx.file(), jest::Runner::Vitest) + .for_each(|node| self.run(node, cx)); } } @@ -35,14 +44,27 @@ impl PreferImportInMock { fn run<'a>(&self, possible_jest_node: PossibleJestNode<'a>, cx: &Cx<'a, Self>) { if let Some(call_expr) = possible_jest_node.node.as_call() && callee_name(call_expr).is_some_and(|it| it.is_any(&["mock", "doMock"])) - && let (Some(import_value), Some(last)) = (call_expr.args().first(), call_expr.args().last()) - && let Some(value) = import_value.as_string().filter(|_| !import_value.is_parenthesized()) + && let (Some(import_value), Some(last)) = + (call_expr.args().first(), call_expr.args().last()) + && let Some(value) = import_value + .as_string() + .filter(|_| !import_value.is_parenthesized()) && jest::parse_general_jest_fn_call(cx.file(), possible_jest_node).is_some() { - let report = - cx.report(import_value.span().to(last.outer_span()), PREFER_IMPORT_IN_MOCK).data("path", value); + jest::note_turns(cx.file(), jest::Runner::Vitest); + let report = cx + .report( + import_value.span().to(last.outer_span()), + PREFER_IMPORT_IN_MOCK, + ) + .data("path", value); if self.fixable { - report.fix(|fixer| fixer.replace(import_value, [b"import('".as_slice(), value.bytes(), b"')".as_slice()].concat())); + report.fix(|fixer| { + fixer.replace( + import_value, + [b"import('".as_slice(), value.bytes(), b"')".as_slice()].concat(), + ) + }); } } } diff --git a/src/lint/jest/rules/vitest_prefer_to_be_object.rs b/src/lint/jest/rules/vitest_prefer_to_be_object.rs index bab6031a7fd1..0953bde61a1c 100644 --- a/src/lint/jest/rules/vitest_prefer_to_be_object.rs +++ b/src/lint/jest/rules/vitest_prefer_to_be_object.rs @@ -5,10 +5,12 @@ use bun_lint::rule::Plugin; /// This rule enforces using `toBeObject()` to check if a value is of type `Object`. pub struct PreferToBeObject; -const PREFER_TO_BE_OBJECT: Message = Message::new("", "Prefer `toBeObject()` for object assertions"); +const PREFER_TO_BE_OBJECT: Message = + Message::new("", "Prefer `toBeObject()` for object assertions"); impl Rule for PreferToBeObject { - const META: Meta = Meta::oxlint(Plugin::Vitest, "prefer-to-be-object", Kind::Suggestion).fixable(Fixable::Code); + const META: Meta = Meta::oxlint(Plugin::Vitest, "prefer-to-be-object", Kind::Suggestion) + .fixable(Fixable::Code); const ON: On = On::new().finish(); type State<'a> = (); @@ -17,11 +19,15 @@ impl Rule for PreferToBeObject { } fn start<'a>(&self, file: &'a File<'a>) -> Option<()> { - (file.mentions("expectTypeOf") && file.mentions("Object") && jest::is_test(file, jest::VITEST)).then_some(()) + (file.mentions("expectTypeOf") + && file.mentions("Object") + && jest::is_test(file, jest::VITEST)) + .then_some(()) } fn finish(&self, cx: &mut Cx<'_, Self>) { - jest::iter_possible_jest_call_node(cx.file(), jest::Runner::Vitest).for_each(|node| run(node, cx)); + jest::iter_possible_jest_call_node(cx.file(), jest::Runner::Vitest) + .for_each(|node| run(node, cx)); } } @@ -31,16 +37,26 @@ fn is_object(e: Expr) -> bool { fn run<'a>(possible_vitest_node: PossibleJestNode<'a>, cx: &Cx<'a, PreferToBeObject>) { let node = possible_vitest_node.node; - let Some(ParsedJestFnCall::ExpectTypeOf(parsed_expect_call)) = jest::parse_jest_fn_call(cx.file(), possible_vitest_node) else { + let Some(ParsedJestFnCall::ExpectTypeOf(parsed_expect_call)) = + jest::parse_jest_fn_call(cx.file(), possible_vitest_node) + else { return; }; let Some(matcher) = parsed_expect_call.matcher() else { return; }; let is_falsy = match matcher.name() { - Some(b"toBeInstanceOf") if parsed_expect_call.args.len() == 1 && parsed_expect_call.args.first().is_some_and(is_object) => { - cx.report(matcher.span, PREFER_TO_BE_OBJECT) - .fix(|fixer| fixer.replace(Span::new(matcher.span.start, node.span().end), "toBeObject()")); + Some(b"toBeInstanceOf") + if parsed_expect_call.args.len() == 1 + && parsed_expect_call.args.first().is_some_and(is_object) => + { + jest::note_turns(cx.file(), jest::Runner::Vitest); + cx.report(matcher.span, PREFER_TO_BE_OBJECT).fix(|fixer| { + fixer.replace( + Span::new(matcher.span.start, node.span().end), + "toBeObject()", + ) + }); return; } Some(b"toBeTruthy") => false, @@ -50,17 +66,31 @@ fn run<'a>(possible_vitest_node: PossibleJestNode<'a>, cx: &Cx<'a, PreferToBeObj // `expectTypeOf(a instanceof Object).toBeTruthy()`. There can be one pair of parentheses around the argument. if let Some(parent_call_expr) = parsed_expect_call.head.parent && let Some(binary_expr) = parent_call_expr.as_call().and_then(|it| it.args().first()) - && let ExprKind::Binary { op: BinOp::Instanceof, left, right } = binary_expr.kind() + && let ExprKind::Binary { + op: BinOp::Instanceof, + left, + right, + } = binary_expr.kind() && binary_expr.parens().len() <= 1 && is_object(right) { + jest::note_turns(cx.file(), jest::Runner::Vitest); cx.report(matcher.span, PREFER_TO_BE_OBJECT).fix(|fixer| { let file = fixer.file(); - let not_modifier = parsed_expect_call.modifiers().any(|it| it.is_name_equal("not")); + let not_modifier = parsed_expect_call + .modifiers() + .any(|it| it.is_name_equal("not")); let code = [ file.slice(Span::new(node.span().start, left.outer_span().end)), - file.slice(Span::new(binary_expr.span().end, parent_call_expr.span().end)), - if is_falsy != not_modifier { b".not".as_slice() } else { b"".as_slice() }, + file.slice(Span::new( + binary_expr.span().end, + parent_call_expr.span().end, + )), + if is_falsy != not_modifier { + b".not".as_slice() + } else { + b"".as_slice() + }, b".toBeObject()".as_slice(), ]; fixer.replace(node, code.concat()) diff --git a/src/lint/jest/rules/vitest_require_awaited_expect_poll.rs b/src/lint/jest/rules/vitest_require_awaited_expect_poll.rs index 3a14711d563e..f17e888bce15 100644 --- a/src/lint/jest/rules/vitest_require_awaited_expect_poll.rs +++ b/src/lint/jest/rules/vitest_require_awaited_expect_poll.rs @@ -5,10 +5,12 @@ use bun_lint::rule::Plugin; /// This rule ensures that promises returned by `expect.poll` and `expect.element` calls are handled properly. pub struct RequireAwaitedExpectPoll; -const REQUIRE_AWAITED_EXPECT_POLL: Message = Message::new("", "`expect.{{member_name}}` must be awaited or returned"); +const REQUIRE_AWAITED_EXPECT_POLL: Message = + Message::new("", "`expect.{{member_name}}` must be awaited or returned"); impl Rule for RequireAwaitedExpectPoll { - const META: Meta = Meta::oxlint(Plugin::Vitest, "require-awaited-expect-poll", Kind::Problem).reports_on_exit(); + const META: Meta = Meta::oxlint(Plugin::Vitest, "require-awaited-expect-poll", Kind::Problem) + .reports_on_exit(); const ON: On = On::new().finish(); type State<'a> = (); @@ -17,30 +19,40 @@ impl Rule for RequireAwaitedExpectPoll { } fn start<'a>(&self, file: &'a File<'a>) -> Option<()> { - (jest::mentions_any(file, &const { jest::mention_bits(["expect", "expectTypeOf"]) }) && jest::mentions_any(file, &const { jest::mention_bits(["poll", "element"]) }) && jest::is_test(file, jest::VITEST)).then_some(()) + (jest::mentions_any( + file, + &const { jest::mention_bits(["expect", "expectTypeOf"]) }, + ) && jest::mentions_any(file, &const { jest::mention_bits(["poll", "element"]) }) + && jest::is_test(file, jest::VITEST)) + .then_some(()) } fn finish(&self, cx: &mut Cx<'_, Self>) { - jest::iter_possible_jest_call_node(cx.file(), jest::Runner::Vitest).for_each(|node| run(node, cx)) + jest::iter_possible_jest_call_node(cx.file(), jest::Runner::Vitest) + .for_each(|node| run(node, cx)) } } fn run<'a>(possible_jest_node: PossibleJestNode<'a>, cx: &Cx<'a, RequireAwaitedExpectPoll>) { let node = possible_jest_node.node; - let Some(expect) = jest::parse_expect_and_typeof_vitest_fn_call(cx.file(), possible_jest_node) else { + let Some(expect) = jest::parse_expect_and_typeof_vitest_fn_call(cx.file(), possible_jest_node) + else { return; }; let member_name = expect.members.first().and_then(|it| it.name()); let Some(member_name) = member_name.filter(|it| matches!(*it, b"poll" | b"element")) else { return; }; - let is_returned_or_awaited = match skip_sequence_expressions(skip_matchers_and_modifiers(node)).parent() { - AstKind::Stmt(statement) => statement.tag() == StmtTag::Return, - AstKind::Expr(e) => e.tag() == ExprTag::Await, - _ => false, - }; + let is_returned_or_awaited = + match skip_sequence_expressions(skip_matchers_and_modifiers(node)).parent() { + AstKind::Stmt(statement) => statement.tag() == StmtTag::Return, + AstKind::Expr(e) => e.tag() == ExprTag::Await, + _ => false, + }; if !is_returned_or_awaited { - cx.report(node, REQUIRE_AWAITED_EXPECT_POLL).data("member_name", member_name); + jest::note_turns(cx.file(), jest::Runner::Vitest); + cx.report(node, REQUIRE_AWAITED_EXPECT_POLL) + .data("member_name", member_name); } } @@ -52,7 +64,8 @@ fn skip_sequence_expressions(node: AstKind<'_>) -> AstKind<'_> { let is_skipped = match parent { AstKind::ParenthesizedExpression(..) => true, AstKind::Expr(sequence) => { - sequence.binary_op() == Some(BinOp::Comma) && sequence.right() == current_node.innermost_expr() + sequence.binary_op() == Some(BinOp::Comma) + && sequence.right() == current_node.innermost_expr() } _ => false, }; diff --git a/src/lint/jest/rules/vitest_require_local_test_context_for_concurrent_snapshots.rs b/src/lint/jest/rules/vitest_require_local_test_context_for_concurrent_snapshots.rs index 2fe171aadf9b..86970f0e5364 100644 --- a/src/lint/jest/rules/vitest_require_local_test_context_for_concurrent_snapshots.rs +++ b/src/lint/jest/rules/vitest_require_local_test_context_for_concurrent_snapshots.rs @@ -1,14 +1,16 @@ -use bun_lint_oxlint::ast_util::{get_inner_expression, static_property_name}; use crate::jest::{self, JestFnKind, JestGeneralFnKind}; use bun_lint::prelude::*; use bun_lint::rule::Plugin; use bun_lint::utils::ancestor_memo::AncestorMemo; +use bun_lint_oxlint::ast_util::{get_inner_expression, static_property_name}; /// The rule is intended to ensure that concurrent snapshot tests are executed within a properly configured local test context. pub struct RequireLocalTestContextForConcurrentSnapshots; -const REQUIRE_LOCAL_TEST_CONTEXT_FOR_CONCURRENT_SNAPSHOTS: Message = - Message::new("", "Require local Test Context for concurrent snapshot tests"); +const REQUIRE_LOCAL_TEST_CONTEXT_FOR_CONCURRENT_SNAPSHOTS: Message = Message::new( + "", + "Require local Test Context for concurrent snapshot tests", +); const SNAPSHOT_METHODS: [&str; 5] = [ "toMatchSnapshot", @@ -19,7 +21,11 @@ const SNAPSHOT_METHODS: [&str; 5] = [ ]; impl Rule for RequireLocalTestContextForConcurrentSnapshots { - const META: Meta = Meta::oxlint(Plugin::Vitest, "require-local-test-context-for-concurrent-snapshots", Kind::Problem); + const META: Meta = Meta::oxlint( + Plugin::Vitest, + "require-local-test-context-for-concurrent-snapshots", + Kind::Problem, + ); const ON: On = On::new().finish(); type State<'a> = (); @@ -28,19 +34,37 @@ impl Rule for RequireLocalTestContextForConcurrentSnapshots { } fn start<'a>(&self, file: &'a File<'a>) -> Option<()> { - (file.mentions("expect") && file.mentions("concurrent") && jest::mentions_any(file, &const { jest::mention_bits(SNAPSHOT_METHODS) }) && jest::is_test(file, jest::VITEST)).then_some(()) + (file.mentions("expect") + && file.mentions("concurrent") + && jest::mentions_any(file, &const { jest::mention_bits(SNAPSHOT_METHODS) }) + && jest::is_test(file, jest::VITEST)) + .then_some(()) } fn finish(&self, cx: &mut Cx<'_, Self>) { // Whether something is in a call of `it.concurrent` or the like. let mut in_concurrent = AncestorMemo::default(); - for possible_jest_node in jest::iter_possible_jest_call_node(cx.file(), jest::Runner::Vitest) { + for possible_jest_node in + jest::iter_possible_jest_call_node(cx.file(), jest::Runner::Vitest) + { let node = possible_jest_node.node; - let is_snapshot = node.callee().filter(|it| !it.is_parenthesized()).and_then(static_property_name); + let is_snapshot = node + .callee() + .filter(|it| !it.is_parenthesized()) + .and_then(static_property_name); if is_snapshot.is_some_and(|it| it.is_any(&SNAPSHOT_METHODS)) - && jest::is_type_of_jest_fn_call(cx.file(), possible_jest_node, &[JestFnKind::Expect]) - && in_concurrent.find(Node::Expr(node), |_, parent| is_test_or_describe_node(parent).then_some(())).is_some() + && jest::is_type_of_jest_fn_call( + cx.file(), + possible_jest_node, + &[JestFnKind::Expect], + ) + && in_concurrent + .find(Node::Expr(node), |_, parent| { + is_test_or_describe_node(parent).then_some(()) + }) + .is_some() { + jest::note_turns(cx.file(), jest::Runner::Vitest); cx.report(node, REQUIRE_LOCAL_TEST_CONTEXT_FOR_CONCURRENT_SNAPSHOTS); } } @@ -49,12 +73,20 @@ impl Rule for RequireLocalTestContextForConcurrentSnapshots { /// `it.concurrent(..)`, `describe["concurrent"](..)` fn is_test_or_describe_node(node: Node) -> bool { - let Some(member_expr) = node.as_expr().and_then(Expr::callee).filter(|it| !it.is_parenthesized()) else { + let Some(member_expr) = node + .as_expr() + .and_then(Expr::callee) + .filter(|it| !it.is_parenthesized()) + else { return false; }; node.as_expr().is_some_and(|it| it.tag() == ExprTag::Call) && static_property_name(member_expr).is_some_and(|it| it.is("concurrent")) - && (member_expr.object().map(get_inner_expression).and_then(Expr::as_ident)).is_some_and(|id| { + && (member_expr + .object() + .map(get_inner_expression) + .and_then(Expr::as_ident)) + .is_some_and(|id| { matches!( JestFnKind::from(id.bytes()), JestFnKind::General(JestGeneralFnKind::Describe | JestGeneralFnKind::Test) diff --git a/src/lint/lib.rs b/src/lint/lib.rs index 2973e83b7e39..7635f3f1ac5d 100644 --- a/src/lint/lib.rs +++ b/src/lint/lib.rs @@ -13,6 +13,9 @@ //! and `bun_lint_jest`. #![forbid(unsafe_code)] +// For what only words an error, like the message for a file that doesn't parse or for an option that is refused: +// `#[optimize(size)]` roughly halves it, and how fast it is doesn't matter. Nothing that runs for a file without errors has it. +#![feature(optimize_attribute)] pub mod ast; pub mod code_path; diff --git a/src/lint/linter/config/oxlint_options.rs b/src/lint/linter/config/oxlint_options.rs index 73ef58ca060d..f017bb737c64 100644 --- a/src/lint/linter/config/oxlint_options.rs +++ b/src/lint/linter/config/oxlint_options.rs @@ -301,6 +301,7 @@ pub(super) fn is_integer(value: f64) -> bool { } /// As serde_json prints it. +#[optimize(size)] pub(super) fn write_number(out: &mut Vec, value: f64) { if is_integer(value) { let _ = match value < 0.0 { @@ -328,6 +329,7 @@ pub(super) fn write_number(out: &mut Vec, value: f64) { } /// `{:?}` of a `str` +#[optimize(size)] pub(super) fn write_debug(out: &mut Vec, text: &[u8]) { let _ = match std::str::from_utf8(text) { Ok(text) => write!(out, "{text:?}"), @@ -336,6 +338,7 @@ pub(super) fn write_debug(out: &mut Vec, text: &[u8]) { } /// A string as serde_json prints it. +#[optimize(size)] fn write_string(out: &mut Vec, text: &[u8]) { out.push(b'"'); for &byte in text { @@ -355,6 +358,7 @@ fn write_string(out: &mut Vec, text: &[u8]) { } /// `is_spaced`: with blanks where `serde_json::to_string_pretty` has line breaks. +#[optimize(size)] fn write_value(out: &mut Vec, value: &Json, is_spaced: bool) { if !bun_core::StackCheck::init().is_safe_to_recurse() { return; @@ -395,6 +399,7 @@ fn write_value(out: &mut Vec, value: &Json, is_spaced: bool) { } /// `value` as oxlint shows what it has received: printed on several lines, and all white space made one blank, in strings too. +#[optimize(size)] fn shown(value: &Json) -> Vec { let mut printed = Vec::new(); write_value(&mut printed, value, true); @@ -439,6 +444,7 @@ fn unexpected(value: &Json) -> Vec { pub(super) type Checked = Result<(), Vec>; /// serde's `invalid_type` +#[optimize(size)] pub(super) fn wrong_type(value: &Json, expected: &[u8]) -> Vec { let found = unexpected(value); [b"invalid type: ", &found[..], b", expected ", expected].concat() @@ -453,6 +459,7 @@ fn too_long(len: usize) -> Checked { } /// serde's `OneOf`. `none`: what it says if there are no names. +#[optimize(size)] fn expected_one_of(names: &[&[u8]], none: &str) -> Vec { let names: Vec> = (names.iter().map(|it| [b"`", *it, b"`"].concat())).collect(); match &names[..] { diff --git a/src/lint/linter/config/rc.rs b/src/lint/linter/config/rc.rs index 2382db5daa43..fabeaa48640c 100644 --- a/src/lint/linter/config/rc.rs +++ b/src/lint/linter/config/rc.rs @@ -27,7 +27,7 @@ use crate::rule::{Meta, Plugin}; use bun_core::strings; use rustc_hash::FxHashMap; use std::borrow::Cow; -use std::sync::Arc; +use std::sync::{Arc, LazyLock}; /// Whether `word` is one of the words of `list`, which have spaces between them. fn has_word(list: &str, word: &[u8]) -> bool { @@ -57,18 +57,26 @@ fn printed_key(key: &[u8]) -> Vec { [plugin, b"/", name].concat() } +/// The category of each rule that oxlint has, by the plugin that it is of there and its name. +static CATEGORY_OF: LazyLock> = LazyLock::new(|| { + let mut all = FxHashMap::default(); + for (category, lists) in categories::CATEGORIES { + for (prefix, names) in *lists { + let Some(plugin) = Plugin::of_oxlint_prefix(prefix.as_bytes()) else { + continue; + }; + for name in strings::tokenize(names.as_bytes(), b" ") { + all.entry((plugin, name)).or_insert(*category); + } + } + } + all +}); + /// The category that oxlint has the rule in: `correctness`, `suspicious`, `pedantic`, `perf`, `style`, `restriction`, `nursery`. pub fn oxlint_category(plugin: Plugin, name: &str) -> Option<&'static str> { - let has = |lists: &[(&str, &str)]| { - (lists - .iter() - .filter(|it| Plugin::of_oxlint_prefix(it.0.as_bytes()) == Some(plugin.in_oxlint()))) - .any(|it| has_word(it.1, name.as_bytes())) - }; - categories::CATEGORIES - .iter() - .find(|it| has(it.1)) - .map(|it| it.0) + let all: &FxHashMap<(Plugin, &[u8]), &'static str> = &CATEGORY_OF; + all.get(&(plugin.in_oxlint(), name.as_bytes())).copied() } /// Whether oxlint could only run the rule as a rule of a plugin in JavaScript: it doesn't have it. Such a rule is a port of a diff --git a/src/lint/linter/config/rust_regex.rs b/src/lint/linter/config/rust_regex.rs index 63efc7947675..9140e19a0a20 100644 --- a/src/lint/linter/config/rust_regex.rs +++ b/src/lint/linter/config/rust_regex.rs @@ -103,6 +103,7 @@ struct Parser<'p> { later: Option, } +#[optimize(size)] fn is_meta_character(c: char) -> bool { matches!( c, @@ -126,10 +127,12 @@ fn is_meta_character(c: char) -> bool { ) } +#[optimize(size)] fn is_escapeable_character(c: char) -> bool { is_meta_character(c) || (c.is_ascii() && !c.is_ascii_alphanumeric() && c != '<' && c != '>') } +#[optimize(size)] fn is_capture_char(c: char, is_first: bool) -> bool { match is_first { true => c == '_' || c.is_alphabetic(), @@ -137,6 +140,7 @@ fn is_capture_char(c: char, is_first: bool) -> bool { } } +#[optimize(size)] fn error(span: Span, kind: &'static str) -> Parsed { Err(Error { kind, @@ -145,23 +149,28 @@ fn error(span: Span, kind: &'static str) -> Parsed { }) } +#[optimize(size)] fn scalar_of(hex: &str) -> Option { u32::from_str_radix(hex, 16).ok().and_then(char::from_u32) } impl<'p> Parser<'p> { + #[optimize(size)] fn rest(&self) -> &'p str { self.pattern.get(self.pos.offset..).unwrap_or_default() } + #[optimize(size)] fn char(&self) -> char { self.rest().chars().next().unwrap_or_default() } + #[optimize(size)] fn is_eof(&self) -> bool { self.pos.offset >= self.pattern.len() } + #[optimize(size)] fn bump(&mut self) -> bool { if self.is_eof() { return false; @@ -175,6 +184,7 @@ impl<'p> Parser<'p> { !self.is_eof() } + #[optimize(size)] fn bump_if(&mut self, prefix: &str) -> bool { let is_there = self.rest().starts_with(prefix); if is_there { @@ -183,10 +193,12 @@ impl<'p> Parser<'p> { is_there } + #[optimize(size)] fn is_lookaround_prefix(&mut self) -> bool { self.bump_if("?=") || self.bump_if("?!") || self.bump_if("?<=") || self.bump_if("? bool { if !self.bump() { return false; @@ -195,6 +207,7 @@ impl<'p> Parser<'p> { !self.is_eof() } + #[optimize(size)] fn bump_space(&mut self) { if !self.modes.ignore_whitespace { return; @@ -217,10 +230,12 @@ impl<'p> Parser<'p> { } } + #[optimize(size)] fn peek(&self) -> Option { self.rest().chars().nth(1) } + #[optimize(size)] fn peek_space(&self) -> Option { if !self.modes.ignore_whitespace { return self.peek(); @@ -243,6 +258,7 @@ impl<'p> Parser<'p> { }) } + #[optimize(size)] fn span(&self) -> Span { Span { start: self.pos, @@ -250,6 +266,7 @@ impl<'p> Parser<'p> { } } + #[optimize(size)] fn span_from(&self, start: Position) -> Span { Span { start, @@ -257,6 +274,7 @@ impl<'p> Parser<'p> { } } + #[optimize(size)] fn span_char(&self) -> Span { let c = self.char(); let end = match c { @@ -277,6 +295,7 @@ impl<'p> Parser<'p> { } } + #[optimize(size)] fn refuse_after_parsing(&mut self, span: Span, kind: &'static str) { if self.later.is_none() { self.later = Some(Error { @@ -288,12 +307,14 @@ impl<'p> Parser<'p> { } /// With `(?-u)` a pattern matches bytes, and must not match what is no UTF-8. + #[optimize(size)] fn refuse_later(&mut self, span: Span, kind: &'static str) { if !self.modes.unicode { self.refuse_after_parsing(span, kind); } } + #[optimize(size)] fn set_flags(&mut self, flags: Flags) { self.modes = Modes { ignore_whitespace: (flags.ignore_whitespace).unwrap_or(self.modes.ignore_whitespace), @@ -301,6 +322,7 @@ impl<'p> Parser<'p> { }; } + #[optimize(size)] fn push_group(&mut self) -> Parsed { match self.parse_group()? { Group::SetFlags(flags) => { @@ -318,6 +340,7 @@ impl<'p> Parser<'p> { } } + #[optimize(size)] fn pop_group(&mut self) -> Parsed { let mut top = self.stack_group.pop(); if matches!(top, Some(GroupState::Alternation)) { @@ -331,6 +354,7 @@ impl<'p> Parser<'p> { Ok(Last::Expression) } + #[optimize(size)] fn pop_group_end(&mut self) -> Parsed<()> { let mut top = self.stack_group.pop(); if matches!(top, Some(GroupState::Alternation)) { @@ -342,17 +366,20 @@ impl<'p> Parser<'p> { } } + #[optimize(size)] fn unclosed_class_error(&self) -> Parsed { let open = self.stack_class.iter().rev().find_map(|it| *it); error(open.map_or_else(|| self.span(), |it| it.0), CLASS_UNCLOSED) } + #[optimize(size)] fn pop_class_op(&mut self) { if matches!(self.stack_class.last(), Some(None)) { self.stack_class.pop(); } } + #[optimize(size)] fn parse(&mut self) -> Parsed<()> { let mut last = Last::Nothing; loop { @@ -385,6 +412,7 @@ impl<'p> Parser<'p> { self.pop_group_end() } + #[optimize(size)] fn parse_uncounted_repetition(&mut self, last: Last) -> Parsed { if last != Last::Expression { return error(self.span(), REPETITION_MISSING); @@ -395,6 +423,7 @@ impl<'p> Parser<'p> { Ok(Last::Expression) } + #[optimize(size)] fn parse_counted_repetition(&mut self, last: Last) -> Parsed { let start = self.pos; if last != Last::Expression { @@ -434,6 +463,7 @@ impl<'p> Parser<'p> { Ok(Last::Expression) } + #[optimize(size)] fn parse_group(&mut self) -> Parsed { let open_span = self.span_char(); self.bump(); @@ -465,6 +495,7 @@ impl<'p> Parser<'p> { } } + #[optimize(size)] fn parse_capture_name(&mut self) -> Parsed<()> { if self.is_eof() { return error(self.span(), NAME_EOF); @@ -502,6 +533,7 @@ impl<'p> Parser<'p> { } /// Whether there are none, and what they say. + #[optimize(size)] fn parse_flags(&mut self) -> Parsed<(bool, Flags)> { let mut items: Vec<(char, Span)> = Vec::new(); let mut last_was_negation = None; @@ -541,6 +573,7 @@ impl<'p> Parser<'p> { Ok((items.is_empty(), flags)) } + #[optimize(size)] fn parse_primitive(&mut self) -> Parsed<()> { match self.char() { '\\' => return self.parse_escape(false).map(|_| ()), @@ -551,6 +584,7 @@ impl<'p> Parser<'p> { Ok(()) } + #[optimize(size)] fn parse_escape(&mut self, is_in_class: bool) -> Parsed<(Primitive, Span)> { let start = self.pos; if !self.bump() { @@ -613,6 +647,7 @@ impl<'p> Parser<'p> { } } + #[optimize(size)] fn maybe_parse_special_word_boundary(&mut self, wb_start: Position) -> Parsed<()> { let is_valid_char = |c: char| c.is_ascii_alphabetic() || c == '-'; let start = self.pos; @@ -650,6 +685,7 @@ impl<'p> Parser<'p> { } /// The character, where the literal ends, and whether it is `\xFF`, which is a byte with `(?-u)`. + #[optimize(size)] fn parse_hex(&mut self) -> Parsed<(char, Position, bool)> { let digits = match self.char() { 'x' => 2, @@ -666,6 +702,7 @@ impl<'p> Parser<'p> { Ok((c, end, digits == 2)) } + #[optimize(size)] fn parse_hex_digits(&mut self, digits: usize) -> Parsed<(char, Position)> { let mut hex = String::new(); let start = self.pos; @@ -685,6 +722,7 @@ impl<'p> Parser<'p> { } } + #[optimize(size)] fn parse_hex_brace(&mut self) -> Parsed<(char, Position)> { let mut hex = String::new(); let brace_pos = self.pos; @@ -709,6 +747,7 @@ impl<'p> Parser<'p> { } } + #[optimize(size)] fn parse_decimal(&mut self) -> Parsed { let mut digits = String::new(); while !self.is_eof() && self.char().is_whitespace() { @@ -733,6 +772,7 @@ impl<'p> Parser<'p> { } /// `parse_decimal` in a counted repetition. + #[optimize(size)] fn parse_count(&mut self) -> Parsed { self.parse_decimal().map_err(|it| Error { kind: match it.kind { @@ -743,6 +783,7 @@ impl<'p> Parser<'p> { }) } + #[optimize(size)] fn parse_set_class(&mut self) -> Parsed<()> { loop { self.bump_space(); @@ -779,6 +820,7 @@ impl<'p> Parser<'p> { } } + #[optimize(size)] fn parse_set_class_range(&mut self) -> Parsed<()> { let (first, first_span) = self.parse_set_class_item()?; self.bump_space(); @@ -818,6 +860,7 @@ impl<'p> Parser<'p> { Ok(()) } + #[optimize(size)] fn parse_set_class_item(&mut self) -> Parsed<(Primitive, Span)> { if self.char() == '\\' { return self.parse_escape(true); @@ -831,6 +874,7 @@ impl<'p> Parser<'p> { } /// Where it is, and whether the class is negated. + #[optimize(size)] fn parse_set_class_open(&mut self) -> Parsed<(Span, bool)> { let start = self.pos; if !self.bump_and_bump_space() { @@ -854,6 +898,7 @@ impl<'p> Parser<'p> { } /// Whether `[:alpha:]` or the like was here. + #[optimize(size)] fn maybe_parse_ascii_class(&mut self) -> bool { let start = self.pos; let mut is_negated = false; @@ -900,6 +945,7 @@ impl<'p> Parser<'p> { } /// Whether there can be such a class. + #[optimize(size)] fn parse_unicode_class(&mut self) -> Parsed { if !self.bump_and_bump_space() { return error(self.span(), ESCAPE_EOF); @@ -924,6 +970,7 @@ impl<'p> Parser<'p> { } /// `str::lines` +#[optimize(size)] fn lines(pattern: &[u8]) -> Vec<&[u8]> { let mut all: Vec<&[u8]> = strings::split(pattern, b"\n").collect(); if pattern.is_empty() || pattern.ends_with(b"\n") { @@ -936,6 +983,7 @@ fn lines(pattern: &[u8]) -> Vec<&[u8]> { } /// `Display` of `regex_syntax::Error` +#[optimize(size)] fn describe(pattern: &[u8], error: &Error) -> Vec { let lines = lines(pattern); let line_count = lines.len() + usize::from(pattern.ends_with(b"\n")); @@ -1004,6 +1052,7 @@ fn describe(pattern: &[u8], error: &Error) -> Vec { } /// What `regex::Regex::new(pattern)` fails with, as it is printed. `None`: nothing is wrong with the syntax. +#[optimize(size)] pub(super) fn syntax_error(pattern: &[u8]) -> Option> { let text = std::str::from_utf8(pattern).ok()?; let mut parser = Parser { diff --git a/src/lint/linter/mod.rs b/src/lint/linter/mod.rs index cf6591116732..f66c0d777ea1 100644 --- a/src/lint/linter/mod.rs +++ b/src/lint/linter/mod.rs @@ -183,6 +183,9 @@ pub struct LintResult { pub thrown: Option>, /// [`File::known_line_starts`], for whoever lints to keep. pub line_starts: Option>, + /// With a configuration of oxlint that has a rule for which the file may be linted again: where in the run of oxlint each of + /// the messages of the rules is, by [`LintMessage::reported`], from 1. + pub run_keys: Vec<(u64, u128)>, } /// `S`: the rules that it has. @@ -204,6 +207,8 @@ struct Running<'r, S> { when: When, /// [`Slot::kinds`] kinds: Kinds, + /// [`Slot::oxlint_number`] + oxlint_number: u16, rule: RuleRef<'r, S>, /// [`ConfiguredRule::refusal`] refusal: Option>, @@ -410,6 +415,7 @@ impl Linter { number, when, kinds, + oxlint_number, severity, }; order.push((oxlint_number, slot, Arc::clone(instance))); @@ -430,6 +436,7 @@ impl Linter { rules: Arc::new(rules), refusal, typed: typed.into(), + needs_modules: config.rules.iter().any(|it| it.entry.meta.needs_modules), } } @@ -564,6 +571,7 @@ impl Linter { severity: rule.severity, when: slot.when, kinds: slot.kinds, + oxlint_number: slot.oxlint_number, rule: RuleRef::Shared(instance), refusal: self.refusal(rule).map(Cow::Borrowed), }); @@ -856,66 +864,81 @@ impl Linter { } } problems.reserve(diagnostics.len()); - // When oxlint runs each of the rules. - let of_place = |it: &Enabled| prepared.order.get(it.place as usize).map(|it| it.when); + // oxlint sorts nothing. It prints what its rules report in the order in which it runs them. let follows_run = wants_places && file.language().is_oxlint && !diagnostics.is_empty(); - let when: Vec = match (follows_run, is_plain) { - (false, _) => Vec::new(), - (true, true) => enabled.iter().filter_map(of_place).collect(), - (true, false) => running.iter().map(|it| it.when).collect(), + // When oxlint runs a rule, and with which kinds of node it calls it. + let slot_of = |rule: usize| match is_plain { + true => { + let place = enabled.get(rule)?.place as usize; + let slot = prepared.order.get(place)?; + Some((slot.when, slot.kinds, slot.oxlint_number)) + } + false => { + let it = running.get(rule)?; + Some((it.when, it.kinds, it.oxlint_number)) + } + }; + let wants_keys = prepared.needs_modules; + let about = |rule: u16| { + let meta = ran(rule as usize)?.reported_as(); + let (when, kinds, number) = slot_of(rule as usize)?; + Some(crate::oxlint_order::Ran { + when, + kinds, + once: crate::oxlint_order::once(meta), + is_port: crate::oxlint_order::is_port(meta), + number, + }) }; + let (by_run, run_keys) = match follows_run { + true => crate::oxlint_order::in_run_order(file, &diagnostics, &about, wants_keys), + false => (Vec::new(), Vec::new()), + }; + let of_rules = by_run.len() as u32; + // What was reported when the file was linted before, among which what is reported now belongs. + let keys_before = options + .again + .map_or(&[][..], |it| &it.previous.run_keys[..]); + let before_in_run = |step: u32| { + let key = run_keys.get(step as usize - 1); + key.map_or(0, |key| keys_before.partition_point(|it| it <= key) as u32) + }; + // Only the comments want them by their places. Without any they are made in the order in which they are printed. + let in_run_order = follows_run && !has_directives; + // Else, for each of them, the how manyth it is printed, from 1. + let mut ranks: Vec = vec![0; if in_run_order { 0 } else { by_run.len() }]; + for (at, rank) in by_run.iter().zip(1..) { + if let Some(it) = ranks.get_mut(*at as usize) { + *it = rank + before_in_run(rank); + } + } let order: Vec = match wants_places { _ if diagnostics.is_empty() => Vec::new(), - true => crate::runner::sorted(&diagnostics, &when), - false => (0..diagnostics.len() as u32).collect(), - }; - // oxlint sorts nothing. It prints what its rules report in the order in which it runs them: `run_once` of each rule, then - // the nodes in the order in which a walk enters them, each with the rules that are called with it, then the calls of - // Jest, then what tsgolint reports. For each place in `order`, the how manyth it is then, from 1. - let kinds_of = |rule: usize| match is_plain { + _ if in_run_order => by_run, true => { - let slot = enabled - .get(rule) - .and_then(|it| prepared.order.get(it.place as usize)); - slot.map(|it| it.kinds) + let rules = 0..if follows_run { + enabled.len().max(running.len()) + } else { + 0 + }; + let when: Vec = rules.map_while(|it| Some(slot_of(it)?.0)).collect(); + crate::runner::sorted(&diagnostics, &when) } - false => running.get(rule).map(|it| it.kinds), - }; - let ranks: Vec = match follows_run { - true => crate::oxlint_order::ranks(file, &diagnostics, &order, &|rule| { - let meta = ran(rule as usize)?.reported_as(); - Some(crate::oxlint_order::Ran { - when: *when.get(rule as usize)?, - kinds: kinds_of(rule as usize)?, - keeps_its_order: crate::oxlint_order::keeps_its_order(meta), - }) - }), - false => Vec::new(), + false => (0..diagnostics.len() as u32).collect(), }; // What was reported before comes after: plugins in JavaScript run last, and what is run again runs first. - let of_rules = ranks.len() as u32; for it in problems.iter_mut().skip(before_previous) { if it.reported != 0 && it.reported != u32::MAX { - it.reported += of_rules; + it.reported += match keys_before.get(it.reported as usize - 1) { + Some(key) => run_keys.partition_point(|it| it < key) as u32, + None => of_rules, + }; } } let places = wants_places.then_some(&locator); // What becomes of what the last rule and `messageId` change that change something. let mut changes = (None, config::OxlintChanges::default()); - // Only the comments want them by their places. Without any they are made in the order in which they are printed. - let in_run_order = follows_run && !has_directives; - let mut by_run: Vec = vec![0; if in_run_order { order.len() } else { 0 }]; - for (place, rank) in (0..).zip(&ranks) { - if let Some(it) = rank - .checked_sub(1) - .and_then(|it| by_run.get_mut(it as usize)) - { - *it = place; - } - } - for step in 0..order.len() { - let place = by_run.get(step).map_or(step, |it| *it as usize); - let at = order.get(place).copied().unwrap_or(0); + for (at, step) in order.into_iter().zip(1..) { let Some(diagnostic) = diagnostics.get_mut(at as usize) else { continue; }; @@ -932,7 +955,10 @@ impl Linter { ); } problems.push(LintMessage { - reported: ranks.get(place).copied().unwrap_or(0), + reported: match in_run_order { + true => step + before_in_run(step), + false => ranks.get(at as usize).copied().unwrap_or(0), + }, ..to_message(diagnostic, rule.reported_as(), places) }); let Some(message) = @@ -1088,12 +1114,10 @@ impl Linter { ); } - if file.language().is_oxlint - && (of_rules > 0 || of_js > 0 || has_directives) - && !problems.is_sorted_by_key(|it| it.reported) - { + if file.language().is_oxlint && !problems.is_sorted_by_key(|it| it.reported) { crate::utils::sort::sort_by_key(&mut problems, |it| it.reported); } + result.run_keys = run_keys; if !has_directives { result.messages = problems; } else { @@ -1239,9 +1263,7 @@ fn is_thrown_error(problem: &LintMessage) -> bool { fn stands_for_js_rule(id: &RuleId) -> bool { match id { RuleId::Js(_) => true, - RuleId::Known(meta) | RuleId::Named(meta, _) => { - config::oxlint_category(meta.plugin, meta.name).is_none() - } + RuleId::Known(meta) | RuleId::Named(meta, _) => config::oxlint_lacks(meta), RuleId::Unknown(_) => false, } } @@ -1669,6 +1691,7 @@ impl<'c, 'a, S: RuleSet> Inline<'_, 'c, 'a, S> { severity, when: entry.meta.reports, kinds: Kinds::default(), + oxlint_number: u16::MAX, rule, refusal, }; diff --git a/src/lint/linter/resolved.rs b/src/lint/linter/resolved.rs index 6c370cc8c814..1036d3c125a7 100644 --- a/src/lint/linter/resolved.rs +++ b/src/lint/linter/resolved.rs @@ -202,6 +202,8 @@ pub(super) struct Prepared { pub(super) refusal: Option>, /// Those that need types. pub(super) typed: Box<[&'static Meta]>, + /// One of them needs the modules, so that a file may be linted again. + pub(super) needs_modules: bool, } #[derive(Copy, Clone)] @@ -214,6 +216,8 @@ pub(super) struct Slot { pub(super) when: When, /// With a configuration of oxlint: the kinds of node that it calls the rule with. pub(super) kinds: Kinds, + /// With a configuration of oxlint: the number that it gives the rule. + pub(super) oxlint_number: u16, pub(super) severity: Severity, } diff --git a/src/lint/linter/syntax/espree.rs b/src/lint/linter/syntax/espree.rs index 65a96557fab2..9d9eda09c64b 100644 --- a/src/lint/linter/syntax/espree.rs +++ b/src/lint/linter/syntax/espree.rs @@ -530,15 +530,20 @@ pub(super) fn for_await_where_nothing_waits<'a>(file: &'a File<'a>) -> Vec| { - if !matches!(it.kind(), StmtKind::ForOf { is_await: true, .. }) { + let span = it.span(); + let keyword = skip_trivia(file.text(), span.start + 3) as usize; + if !file + .text() + .get(keyword..) + .is_some_and(|it| it.starts_with(b"await")) + { return None; } - let span = it.span(); let function = Node::Stmt(it).enclosing_function(); match function { Some(func) if func.kind() == FnKind::StaticBlock => { let message = b"Cannot use 'for await' in class static initialization block"; - return Some(SyntaxError::of_oxc(span, message.to_vec())); + return Some(SyntaxError::of_oxc(span, message)); } Some(func) if func.is_async() => return None, None if !is_script(file) => return None, @@ -560,7 +565,12 @@ pub(super) fn for_await_where_nothing_waits<'a>(file: &'a File<'a>) -> Vec error, }) }; - (file.stmts_of_kind(StmtTag::ForOf).filter_map(error)).collect() + // The others are an error after `for await`, which the parser goes on after. + let loops = [StmtTag::ForOf, StmtTag::ForIn, StmtTag::For]; + let loops = loops.into_iter().flat_map(|it| file.stmts_of_kind(it)); + let mut all: Vec<_> = loops.filter_map(error).collect(); + crate::utils::sort::sort_by_key(&mut all, |it| it.at); + all } /// `with` where the code is strict for OXC, and anywhere in TypeScript, in its words. @@ -693,10 +703,7 @@ impl<'a, 'c> Checks<'a, 'c> { /// For oxc: `name` is declared at `first` and again at `again`. fn redeclared(&mut self, what: &str, name: &[u8], first: Span, again: Span) -> SyntaxError { - let name = bstr::BStr::new(name); - SyntaxError::of_oxc(first, format!("{what} `{name}` has already been declared")) - .says(format!("`{name}` has already been declared here")) - .and(again, "It can not be redeclared here") + SyntaxError::redeclared(what, name, first, again) } /// Runs `check`, whose errors are those of oxc's parser. @@ -812,6 +819,7 @@ impl<'a, 'c> Checks<'a, 'c> { // ───────────────────────────── the words for an error of the parser ───────────────────────────── + #[optimize(size)] fn error_of_parser(&mut self, diagnostic: Option<&Diagnostic>, at: u32) { let text = self.file.text(); match diagnostic.map_or(0, |it| it.code) { diff --git a/src/lint/linter/syntax/espree/early.rs b/src/lint/linter/syntax/espree/early.rs index 589cff308fac..a9ff36534ea1 100644 --- a/src/lint/linter/syntax/espree/early.rs +++ b/src/lint/linter/syntax/espree/early.rs @@ -739,13 +739,7 @@ impl<'a> Checks<'a, '_> { _ if strings::contains_js_line_break(between) => {} _ if self.is_oxc => { let after = start + "let".len() as u32; - let message = "Expected a semicolon or an implicit semicolon after a statement, but found none"; - let label = crate::span::Span::new(after, after); - self.report( - SyntaxError::of_oxc(label, message) - .help("Try inserting a semicolon here") - .stage(Stage::Fatal), - ); + self.report(SyntaxError::semicolon_after(after)); } _ => self.unexpected(next), } @@ -1097,12 +1091,7 @@ impl<'a> Checks<'a, '_> { for (i, flag) in flags.iter().enumerate() { let at = first_flag + i as u32; if !flag.is_ascii() { - let message = "Expected a semicolon or an implicit semicolon after a statement, but found none"; - return self.report( - SyntaxError::of_oxc(Span::empty(at), message) - .help("Try inserting a semicolon here") - .stage(Stage::Fatal), - ); + return self.report(SyntaxError::semicolon_after(at)); } let (message, help) = if !strings::contains_char(b"gimsuydv", *flag) { let message = format!( @@ -1433,8 +1422,9 @@ impl<'a> Checks<'a, '_> { if is_in_block(Node::Expr(it)) { let start = it.span().start; let keyword = crate::span::Span::new(start, start + 5); - let message = b"Cannot use await in class static initialization block"; - self.fail_for(keyword, message.to_vec()); + let message = "Cannot use await in class static initialization block"; + // oxc says it when it has the operand. + self.report(SyntaxError::of_oxc(keyword, message).noticed_at(it.span().end)); } } for it in self.file.stmts_of_kind(StmtTag::Var) { diff --git a/src/lint/linter/syntax/mod.rs b/src/lint/linter/syntax/mod.rs index e15bc3c7ea95..3d2b300992dd 100644 --- a/src/lint/linter/syntax/mod.rs +++ b/src/lint/linter/syntax/mod.rs @@ -64,18 +64,61 @@ enum Stage { impl SyntaxError { /// An error of oxc whose first label is `label`. - fn of_oxc(label: Span, message: impl Into>) -> SyntaxError { + #[inline(always)] + fn of_oxc(label: Span, message: impl AsRef<[u8]>) -> SyntaxError { + Self::of_oxc_with(label, message.as_ref()) + } + + /// One body for the hundred places that make one. + #[cold] + #[inline(never)] + fn of_oxc_with(label: Span, message: &[u8]) -> SyntaxError { SyntaxError { at: label.start, end: match label.end == label.start { true => Diagnostic::NO_LENGTH, false => label.end, }, - message: message.into(), + message: message.to_vec(), of_oxc: OfOxc::default(), } } + /// "Expected `;` but found `Identifier`". `wanted`: in backticks. `found`: what oxc calls the token at `label`. + #[cold] + #[inline(never)] + fn expected(label: Span, wanted: &str, found: &[u8]) -> SyntaxError { + let texts: [&[u8]; 5] = [ + b"Expected ", + wanted.as_bytes(), + b" but found `", + found, + b"`", + ]; + Self::of_oxc_with(label, &texts.concat()).says([wanted, " expected"].concat()) + } + + /// oxc takes what ends at `after` for a statement, and gives up at what follows on the line. + #[cold] + #[inline(never)] + fn semicolon_after(after: u32) -> SyntaxError { + let message = + "Expected a semicolon or an implicit semicolon after a statement, but found none"; + Self::of_oxc(Span::empty(after), message) + .help("Try inserting a semicolon here") + .stage(Stage::Fatal) + } + + /// `name` is declared at `first` and again at `again`. + #[cold] + #[inline(never)] + fn redeclared(what: &str, name: &[u8], first: Span, again: Span) -> SyntaxError { + let name = bstr::BStr::new(name); + Self::of_oxc(first, format!("{what} `{name}` has already been declared")) + .says(format!("`{name}` has already been declared here")) + .and(again, "It can not be redeclared here") + } + /// `TS(code)` fn code(mut self, code: u16) -> SyntaxError { self.of_oxc.code = code; @@ -164,10 +207,19 @@ fn error_of_parser<'a>(file: &'a File<'a>) -> Option> { /// The errors of TypeScript's parser that count, in the words of oxc, up to the first at which oxc's parser gives up. Which of /// the diagnostics of the file that one is, and the token at which TypeScript's parser has it. fn errors_of_parser_for_oxc<'a>(file: &'a File<'a>) -> (Vec, Option<(usize, u32)>) { - if !file.has_parse_errors() { - return (Vec::new(), None); + match file.has_parse_errors() { + true => in_words_of_oxc(file, file.hir.diagnostics), + false => (Vec::new(), None), } - let of_parser = file.hir.diagnostics.iter().enumerate(); +} + +/// [`errors_of_parser_for_oxc`], of `diagnostics`. +#[optimize(size)] +fn in_words_of_oxc<'a>( + file: &'a File<'a>, + diagnostics: &[Diagnostic], +) -> (Vec, Option<(usize, u32)>) { + let of_parser = diagnostics.iter().enumerate(); let mut of_parser = of_parser.filter(|it| it.1.kind == DiagnosticKind::Parse); let mut all = Vec::new(); // After a character that it does not know TypeScript's parser is lost, where oxc's lexer goes on. Only what it says about other diff --git a/src/lint/linter/syntax/oxc.rs b/src/lint/linter/syntax/oxc.rs index e30d86883d6c..7807e57d9970 100644 --- a/src/lint/linter/syntax/oxc.rs +++ b/src/lint/linter/syntax/oxc.rs @@ -13,16 +13,17 @@ mod targets; use super::{Stage, SyntaxError, espree, oxc_typescript}; use crate::ast::{ - Class, File, FnBody, Func, Handle, ImportAttributes, KeyKind, Name, Node, Param, Pat, Stmt, - StmtTag, + Class, Expr, ExprTag, File, FnBody, Func, Handle, ImportAttributes, KeyKind, Name, Node, Param, + Pat, PropKind, Stmt, StmtTag, }; use crate::span::Span; use crate::tokens::{next_token, skip_trivia, token_len}; -use bun_sema::atom::Atom; +use crate::utils::oxlint::{has_module_syntax, is_either}; +use bun_sema::atom::{Atom, known}; use bun_sema::bind::{ClassOwner, Parent}; use bun_sema::hir::{ - Diagnostic, DiagnosticKind, Flags, FnKind, Modifier, ModifierKind, ParamId, PatKind, StmtId, - StmtKind, VarDecl, VarKind, rare, + Diagnostic, DiagnosticKind, ExprKind, Flags, FnKind, Modifier, ModifierKind, ParamId, PatKind, + StmtId, StmtKind, VarDecl, VarKind, rare, }; use smallvec::SmallVec; use std::borrow::Cow; @@ -123,15 +124,7 @@ fn declaration_list<'a>(file: &'a File<'a>) -> Vec { // Where oxc wants the name of a variable. let unexpected = |at: Span| { let error = match RESERVED.binary_search(&file.slice(at)).is_ok() { - true => SyntaxError::of_oxc( - at, - [ - b"Identifier expected. '", - file.slice(at), - b"' is a reserved word that cannot be used here.", - ] - .concat(), - ), + true => reserved_word(at, file.slice(at)), false => SyntaxError::of_oxc(at, "Unexpected token"), }; error.stage(Stage::Fatal) @@ -313,16 +306,8 @@ fn duplicate_parameters<'a>(file: &'a File<'a>) -> Vec { let rest = file.text().get(at as usize..).unwrap_or_default(); let name = rest.get(..token_len(rest)).unwrap_or_default(); let len = name.len() as u32; - let message = [b"Identifier `", name, b"` has already been declared"].concat(); - let here = format!("`{}` has already been declared here", bstr::BStr::new(name)); - all.push( - SyntaxError::of_oxc(Span::new(at, at + len), message) - .says(here) - .and( - Span::new(again, again + len), - "It can not be redeclared here", - ), - ); + let (first, again) = (Span::new(at, at + len), Span::new(again, again + len)); + all.push(SyntaxError::redeclared("Identifier", name, first, again)); } } all @@ -614,6 +599,7 @@ const RESERVED: [&[u8]; 36] = [ ]; /// What oxc calls the token `text` in "Expected `;` but found `Identifier`". +#[optimize(size)] pub(super) fn name_of_token(text: &[u8]) -> String { let has_any = |of: &[u8]| bun_core::strings::index_of_any(text, of).is_some(); let number = || match text { @@ -643,11 +629,15 @@ pub(super) fn name_of_token(text: &[u8]) -> String { } } +fn reserved_word(at: Span, word: &[u8]) -> SyntaxError { + let rest = b"' is a reserved word that cannot be used here."; + SyntaxError::of_oxc(at, [b"Identifier expected. '", word, rest].concat()) +} + /// "Expected `wanted` but found `..`", at the token `at`. fn expected<'a>(file: &'a File<'a>, wanted: &str, at: Span) -> SyntaxError { let found = name_of_token(file.slice(at)); - SyntaxError::of_oxc(at, format!("Expected `{wanted}` but found `{found}`")) - .says(format!("`{wanted}` expected")) + SyntaxError::expected(at, &["`", wanted, "`"].concat(), found.as_bytes()) } /// The token after `at`, or the end of the text. @@ -712,6 +702,7 @@ pub(super) fn private_name_as_operand<'a>(file: &'a File<'a>, name: Span) -> Opt } /// The name `closing` is in the closing tag of the element whose name is at `opening`. +#[optimize(size)] fn wrong_closing_tag<'a>(file: &'a File<'a>, closing: Span, opening: Span) -> SyntaxError { let name = bstr::BStr::new(file.slice(opening)); let message = format!("Expected corresponding JSX closing tag for '{name}'."); @@ -722,6 +713,7 @@ fn wrong_closing_tag<'a>(file: &'a File<'a>, closing: Span, opening: Span) -> Sy } /// An error of TypeScript's parser about JSX in the words of oxc's. It goes on after a closing tag with the wrong name. +#[optimize(size)] fn of_jsx<'a>(file: &'a File<'a>, it: &Diagnostic, label: Span) -> Option { let (hir, text) = (&file.hir, file.text()); let span_of = @@ -803,17 +795,14 @@ fn of_jsx<'a>(file: &'a File<'a>, it: &Diagnostic, label: Span) -> Option(file: &'a File<'a>, it: &Diagnostic, label: Span) -> Option { let slice = |at: Span| file.slice(at); let before = |at: Span| file.token_before(at).map(|it| it.span()); let text_before = |at: Span| before(at).map_or(&b""[..], slice); let after = |at: Span| token_after(file, at); let unexpected = |at: Span| SyntaxError::of_oxc(at, "Unexpected token"); - let semicolon = |after: u32| { - let message = - "Expected a semicolon or an implicit semicolon after a statement, but found none"; - SyntaxError::of_oxc(Span::new(after, after), message).help("Try inserting a semicolon here") - }; + let semicolon = SyntaxError::semicolon_after; let wanted = it.args.first().map_or(&b""[..], |it| &it[..]); let lacks_semicolon = matches!(it.code, 1434 | 1442) || it.code == 1005 && wanted == b";"; let is_unexpected = matches!(it.code, 1003 | 1109 | 1110 | 1128 | 1134 | 1138); @@ -1053,6 +1042,7 @@ enum Around { Head, } +#[optimize(size)] fn around_unterminated<'a>(file: &'a File<'a>, at: u32) -> Around { let (mut closed, mut around) = (0, Around::Nothing); if question_mark(file, at).is_some() { @@ -1118,6 +1108,7 @@ fn end_of_line(text: &[u8], from: u32) -> u32 { /// /// What the lexer reports and goes on after, oxlint prints before all that the parser reports. Where the parser cannot go on with /// the token that the lexer makes of it, the error of the lexer is the one at which the parser gives up. +#[optimize(size)] fn of_scanner<'a>(file: &'a File<'a>, it: &Diagnostic, label: Span) -> Option { let text = file.text(); let end_of_text = text.len() as u32; @@ -1284,6 +1275,7 @@ fn end_of_name(text: &[u8], mut at: u32) -> u32 { /// What oxc says about the backslash at `at`, which is in a name or where one can start, and is no escape that a name can have. Its /// lexer goes on, so what it reports is printed first. TypeScript's parser is lost. +#[optimize(size)] fn escape_in_name<'a>(file: &'a File<'a>, at: u32) -> Vec { let text = file.text(); let rest = text.get(at as usize + 1..).unwrap_or_default(); @@ -1314,13 +1306,7 @@ fn escape_in_name<'a>(file: &'a File<'a>, at: u32) -> Vec { all.push(SyntaxError::of_oxc(brace, "Unexpected token").stage(Stage::Fatal)); } // The parser has a name there. An operand before it or after it on the line is a statement of its own. - let semicolon = |after: u32| { - let message = - "Expected a semicolon or an implicit semicolon after a statement, but found none"; - SyntaxError::of_oxc(Span::empty(after), message) - .help("Try inserting a semicolon here") - .stage(Stage::Fatal) - }; + let semicolon = SyntaxError::semicolon_after; let is_on_one_line = |between: Span| !bun_core::strings::contains_js_line_break(file.slice(between)); let is_operand = |it: &[u8]| match it { @@ -1359,6 +1345,7 @@ fn is_where_operand_starts<'a>(file: &'a File<'a>, hash: u32) -> bool { /// ``: for TypeScript's parser the `` closes the `a`, and the `b` is not closed. For oxc's each closing tag /// closes the innermost element that is open. `None`: it is not known how oxc goes on after the first. +#[optimize(size)] fn closing_tags_of_others<'a>(file: &'a File<'a>, it: &Diagnostic) -> Option> { let (hir, text) = (&file.hir, file.text()); let name_of = |id: bun_sema::hir::ExprId| { @@ -1394,6 +1381,7 @@ fn closing_tags_of_others<'a>(file: &'a File<'a>, it: &Diagnostic) -> Option(file: &'a File<'a>, it: &Diagnostic) -> Vec { let rest = file.text().get(it.start as usize..).unwrap_or_default(); let label = Span::new( @@ -1438,12 +1426,9 @@ pub(super) fn of_typescript<'a>(file: &'a File<'a>, it: &Diagnostic) -> Vec(file: &'a File<'a>, it: &Diagnostic, label: Span) -> Option { - let semicolon = |after: u32| { - let message = - "Expected a semicolon or an implicit semicolon after a statement, but found none"; - SyntaxError::of_oxc(Span::new(after, after), message).help("Try inserting a semicolon here") - }; + let semicolon = SyntaxError::semicolon_after; let before = |at: Span| file.token_before(at).map_or(&b""[..], |it| it.text()); let args: SmallVec<[&[u8]; 3]> = it.args.iter().map(|it| &**it).collect(); let text = |it: &[u8]| bstr::BStr::new(it).to_string(); @@ -1460,10 +1445,7 @@ fn of_parser<'a>(file: &'a File<'a>, it: &Diagnostic, label: Span) -> Option { let head = opener(file, it.start, (b'(', b')')).filter(|it| before(*it) == b"for"); match head { - Some(_) => { - SyntaxError::of_oxc(label, format!("Expected `;` but found `{}`", text(found))) - .says("`;` expected") - } + Some(_) => SyntaxError::expected(label, "`;`", found), None => semicolon(file.end_of_token_before(it.start)), } } @@ -1478,6 +1460,11 @@ fn of_parser<'a>(file: &'a File<'a>, it: &Diagnostic, label: Span) -> Option semicolon(label.end), (1209, _) => SyntaxError::of_oxc(label, "Invalid optional chain from new expression."), + // The word is the keyword all the same. + (1260, _) => { + let error = SyntaxError::of_oxc(label, "Keywords cannot contain escape characters"); + return Some(error.stage(Stage::Parser)); + } (1109, _) if file.slice(label) == b""]; + // To TypeScript it says nothing about the file. + let is_global_name = |it: &Stmt| matches!(it.kind, StmtKind::ExportAsNamespace(..)); + let is_module = is_module_by_name + || file.has_module_syntax + || has_import_meta + || file.stmts.iter().any(is_global_name); + // The last: `await` with an escape is a name in a script. + let words: [&[u8]; 4] = [b"await", b"", b"\\u"]; if was_refused && (is_module || !words.iter().any(|it| bun_core::strings::contains(text, it))) { return file; } diff --git a/test/cli/format/format.test.ts b/test/cli/format/format.test.ts index 5d0dc4fe9cf3..0a7dd9aa3142 100644 --- a/test/cli/format/format.test.ts +++ b/test/cli/format/format.test.ts @@ -918,6 +918,31 @@ describe.concurrent("bun fmt", () => { "{{#[a b]}}{{else [a b] c}}{{/[a b]}}", "{{#[a b]}}{{else a b c}}{{/[a b]}}", ], + "empty-brackets-and-a-slash": ["{{[]/a }}\n", "{{[]/a}}", "{{a}}"], + "empty-brackets-behind-an-at": ["{{@[].a }}\n", "{{@[].a}}", "{{@a}}"], + "empty-brackets-at-the-end": ["{{a.[] }}\n", "{{a.[]}}", "{{a.}}"], + "empty-brackets-in-arguments": ["{{a [].b c=[].d}}\n", "{{a [].b c=[].d}}", "{{a b c=d}}"], + "doctype-with-identifiers": [ + '\n

    \n', + '\n

    ', + "

    ", + ], + "this-twice": ["{{this.this }}\n", "{{this.this}}", "{{this}}"], + "dots-before-a-name": ["{{a ...b}}\n", "{{a ...b}}", "{{a b}}"], + "slash-in-brackets": ["{{[a/b] }}\n", "{{[a/b]}}", "{{a/b}}"], + "hash-that-is-called-in-parentheses": ["{{a ((b=c) d)}}\n", "{{a ((b=c) d)}}", "{{a ( d)}}"], + "number-with-a-negative-exponent": ["{{a 0.0000001}}\n", "{{a 0.0000001}}", "{{a 1e-7}}"], + "mustache-where-a-comment-may-end": [ + "

    \n", + "

    ", + "{{b}}

    ", + ], + "blank-behind-less-than": ["< a>b

    \n", "< a>b

    \n", "b

    "], + "columns-behind-a-line-separator": [ + 'a\n\u2028{{~! b ~}}

    \n\u2029{{! prettier-ignore }} {{ i }} {{!-- j --~}}\n{{k\n\u2028 l}} {{~! m }}\n', + 'a \u2028{{~! b ~}}\n

    \n\n\u2029{{! prettier-ignore }}\n{{ i }}\n{{! j ~}}\n{{k l}}\n{{~! m }}', + 'a \u2028{{! b }}\n

    \n\n\u2029\u2029{{! prettier-ignore }\n {{ i }\n{{! j }}\n{{k l}}\n{{! m }}', + ], }; // These look like it, but Handlebars reads what Prettier prints as it reads the template. const harmless: Record = { @@ -999,6 +1024,118 @@ describe.concurrent("bun fmt", () => { expect(result.files).toEqual({ "a.md": after, "b.md": after }); expect(result.exitCode).toBe(0); }); + + // Known: Bun prints what Prettier 3.9.9 prints for these, and a CommonMark parser reads that as something else. All are + // emphasis three deep, or runs of `*` and `_` that are text in an emphasis. Telling them from `*foo_bar*`, of which + // `_foo_bar_` is right, takes the rules for delimiter runs. + test.each([ + ["**a****a**\n", "**a\\****a**\n"], + ["*b **a*b*** c*\n", "*b \\**a*b*** c*\n"], + ["*b _a_*b* c*\n", "*b *a**b* c*\n"], + ["*b _*a*_ c*\n", "*b _*a*\\_ c*\n"], + ["*b *_a_* c*\n", "_b **a** c_\n"], + ["**b __**a**__ c**\n", "**b __**a**\\__ c**\n"], + ["**__a__****__a__**\n", "****a********a****\n"], + ["*b a__b__c c*\n", "_b a__b_\\_c c_\n"], + ["*b __a__b c*\n", "_b \\__a_\\_b c_\n"], + ["**b __a__b c**\n", "**b \\__a_\\_b c**\n"], + ["*b **(a)**b c*\n", "*b **(a)*_b c_\n"], + ["*b _\u4e2d_a c*\n", "_b _\u4e2d_a c_\n"], + ["*b a_\u4e2d_ c*\n", "_b a_\u4e2d_ c_\n"], + ])("known: %j", async (before, ofPrettier) => { + const result = await format({ "a.md": before }, [], { reads: ["a.md"] }); + expect(result.files).toEqual({ "a.md": ofPrettier }); + }); + }); + + test("a folded scalar of YAML that keeps its line breaks has as many with --prose-wrap=never", async () => { + // Prettier 3.9.9 prints `k: >+\n a\n \n \n\nl: m\n`, which has one more. + const result = await format({ "a.yaml": "k: >+\n a\n\n\nl: m\n" }, ["--prose-wrap=never"], { + reads: ["a.yaml"], + }); + expect(result.files).toEqual({ "a.yaml": "k: >+\n a\n\n\nl: m\n" }); + expect(result.exitCode).toBe(0); + }); + + describe("an escaped blank at the end of the parameters of an at-rule", () => { + // Prettier 3.9.9 prints `@extend .a\\;`: the backslash escapes the `;`. + const names = ["extend", "include", "media", "supports", "container", "layer", "import", "custom"]; + test.each(["a.css", "a.scss", "a.less"])("%s", async file => { + const before = names.map(name => `x{@${name} .a\\ ;}\n@${name} .a\\ ;\n`).join("") + "@last .a\\ "; + const after = names.map(name => `x {\n @${name} .a\\ ;\n}\n@${name} .a\\ ;\n`).join("") + "@last .a\\ ;\n"; + const result = await format({ [file]: before }, [], { reads: [file] }); + expect(result.files).toEqual({ [file]: after }); + expect(result.exitCode).toBe(0); + expect(await different({ [file]: after }, [])).toEqual([]); + }); + + test("before a parenthesis, and not twice where Prettier has one", async () => { + // Prettier 3.9.9 prints `@media (a\\) {`. What it prints for the second is right. + const files = { "a.scss": "@media (a\\ ){b{c:d}}\n@each .a\\ ;\n" }; + const result = await format(files, [], { reads: ["a.scss"] }); + expect(result.files).toEqual({ "a.scss": "@media (a\\ ) {\n b {\n c: d;\n }\n}\n@each .a\\ ;\n" }); + expect(result.exitCode).toBe(0); + }); + }); + + describe("a selector without a namespace", () => { + // The selector, what Bun makes of it, and what Prettier 3.9.9 makes of it. + test.each([ + ["x |b", "x |b", "x|b"], + ["x,|b,y", "x,\n|b,\ny", "x,\n,|b,\ny"], + ])("%j", async (before, after, ofPrettier) => { + expect(after).not.toBe(ofPrettier); + const written = `${after} {\n c: d;\n}\n`; + const result = await format({ "a.css": `${before}{c:d}\n`, "b.css": written }, [], { reads: ["a.css", "b.css"] }); + expect(result.files).toEqual({ "a.css": written, "b.css": written }); + expect(result.exitCode).toBe(0); + }); + }); + + describe("a backslash in a value of a style sheet", () => { + // The value, what Bun makes of it, and what Prettier 3.9.9 makes of it: it writes nothing behind a word with a backslash. + test.each([ + ["Font\\ Awesome\\ 5", "Font\\ Awesome\\ 5", "Font\\Awesome\\5"], + ["a\\b c", "a\\b c", "a\\bc"], + ["d\\ e", "d\\ e", "d\\e"], + ["d\\ -5", "d\\ -5", "d\\-5"], + ["d\\ ", "d\\ ", "d\\"], + ["(a\\ )", "(a\\ )", "(a\\)"], + ["f(b a\\ )", "f(b a\\ )", "f(b a\\)"], + ["f(a\\ , b)", "f(a\\ , b)", "f(a\\, b)"], + ["a\\ , b", "a\\ , b", "a\\, b"], + // What the backslash escapes here is no blank, and Prettier is right. + ["\\@a 1\\/3", "\\@a 1\\/3", null], + ["d\\\\ e", "d\\\\ e", "d\\\\e"], + ["x d\\e f g", "x d\\e f g", "x d\\ef g"], + ])("%j", async (before, after, ofPrettier) => { + expect(after).not.toBe(ofPrettier); + const files = Object.fromEntries(["a.css", "b.scss", "c.less"].map(name => [name, `a{b:${before}}\n`])); + const result = await format(files, [], { reads: Object.keys(files) }); + const written = `a {\n b: ${after};\n}\n`; + expect(result.files).toEqual({ "a.css": written, "b.scss": written, "c.less": written }); + expect(result.exitCode).toBe(0); + expect(await different({ "a.css": written }, [])).toEqual([]); + }); + }); + + describe("YAML of which Prettier changes a value", () => { + // The text, what Bun makes of it, and what Prettier 3.9.9 makes of it: a key with one line break more, a scalar that has + // become a marker, a byte order mark that was the start of a key, and a key that cannot be read. + test.each([ + ["? |+\n a\n\n: m\n", "? |+\n a\n\n: m\n", "? |+\n a\n\n\n: m\n"], + ["k: l\n? >+\n a\n\n", "k: l\n? >+\n a\n\n", "k: l\n? >+\n a\n\n\n"], + ["--- ---\n", "--- ---\n", "---\n---\n"], + ["--- ... x\n", "--- ... x\n", "---\n... x\n"], + ["? \ufeffa\n: m\n", "? \ufeffa\n: m\n", "\ufeffa: m\n"], + ["? {a\n b: c}\n: m\n", "? { ? a\n b\n : c }\n: m\n", "{ ? a\n b\n : c }: m\n"], + ])("%j", async (before, after, ofPrettier) => { + expect(after).not.toBe(ofPrettier); + expect(Bun.YAML.parse(after)).toEqual(Bun.YAML.parse(before)); + const result = await format({ "a.yaml": before, "b.yaml": after }, [], { reads: ["a.yaml", "b.yaml"] }); + expect(result.files).toEqual({ "a.yaml": after, "b.yaml": after }); + expect(result.exitCode).toBe(0); + }); }); describe("front matter that would end early", () => { @@ -1131,6 +1268,28 @@ describe.concurrent("bun fmt", () => { expect(result.exitCode).toBe(2); }); + describe("a
     with an element in it and no end tag", () => {
    +    // The text, what Bun makes of it, and what Prettier 3.9.9 makes of it: all that follows the start tag is lost.
    +    test.each([
    +      ["
    a\n", "
    a\n", "
    >\n"],
    +      ["
    ac\n", "
    ac\n", "
    >\n"],
    +      [
    +        '

    x

    a\n  b\n',
    +        '

    x

    \n
    a\n  b\n',
    +        '

    x

    \n
    >\n',
    +      ],
    +      ["a\n", "a\n", ">\n"],
    +    ])("%j", async (before, after, ofPrettier) => {
    +      expect(after).not.toBe(ofPrettier);
    +      const result = await format({ "a.html": before, "b.html": after }, [], { reads: ["a.html", "b.html"] });
    +      expect(result.files).toEqual({ "a.html": after, "b.html": after });
    +      expect(result.exitCode).toBe(0);
    +      const checked = await format({ "a.html": before }, ["--debug-check"]);
    +      expect(checked.stderr).toBe("");
    +      expect(checked.exitCode).toBe(0);
    +    });
    +  });
    +
       describe("a style sheet of which Prettier changes a letter", () => {
         // Prettier 3.9.9 writes a media query in lower case, which makes a `k` of the Kelvin sign: `@media (ka)`. To CSS only
         // the letters of ASCII are the same in both cases.
    @@ -4914,7 +5073,8 @@ describe.concurrent("how a path is written", () => {
           commandLine: [],
         };
         await using proc = spawn({
    -      cmd: [bunExe(), "-e", `const given = ${JSON.stringify(given)};\n${source}`],
    +      // As there: nothing is fetched.
    +      cmd: [bunExe(), "--no-install", "-e", `const given = ${JSON.stringify(given)};\n${source}`],
           env,
           cwd: String(dir),
           stdin: "ignore",
    diff --git a/test/cli/format/own/README.md b/test/cli/format/own/README.md
    index 8d11251dba0a..f46e2dfbb73e 100644
    --- a/test/cli/format/own/README.md
    +++ b/test/cli/format/own/README.md
    @@ -14,3 +14,5 @@ bun test/cli/format/own/sync.ts  

    -
{{! prettier-ignore }} {{ i }} {{!-- j --~}} -{{k -
 l}} {{~! m }} diff --git a/test/cli/format/own/cases/handlebars/columns-behind-a-line-separator.hbs.prettier.snap b/test/cli/format/own/cases/handlebars/columns-behind-a-line-separator.hbs.prettier.snap deleted file mode 100644 index 794f461f0a26..000000000000 --- a/test/cli/format/own/cases/handlebars/columns-behind-a-line-separator.hbs.prettier.snap +++ /dev/null @@ -1,23 +0,0 @@ ------------------- -{ printWidth: 80 } ------------------- -a 
{{! b }} -

    - -

{{! prettier-ignore } - {{ i } -{{! j }} -{{k l}} -{{! m }} -------------------- -{ printWidth: 100 } -------------------- -a 
{{! b }} -

    - -

{{! prettier-ignore } - {{ i } -{{! j }} -{{k l}} -{{! m }} -===================== End ===================== diff --git a/test/cli/format/own/cases/handlebars/columns-behind-a-line-separator.hbs.snap b/test/cli/format/own/cases/handlebars/columns-behind-a-line-separator.hbs.snap deleted file mode 100644 index 794f461f0a26..000000000000 --- a/test/cli/format/own/cases/handlebars/columns-behind-a-line-separator.hbs.snap +++ /dev/null @@ -1,23 +0,0 @@ ------------------- -{ printWidth: 80 } ------------------- -a 
{{! b }} -

    - -

{{! prettier-ignore } - {{ i } -{{! j }} -{{k l}} -{{! m }} -------------------- -{ printWidth: 100 } -------------------- -a 
{{! b }} -

    - -

{{! prettier-ignore } - {{ i } -{{! j }} -{{k l}} -{{! m }} -===================== End ===================== diff --git a/test/cli/format/own/cases/handlebars/what-the-parsers-change.hbs.input b/test/cli/format/own/cases/handlebars/what-the-parsers-change.hbs.input new file mode 100644 index 000000000000..4ea2e146ce5b --- /dev/null +++ b/test/cli/format/own/cases/handlebars/what-the-parsers-change.hbs.input @@ -0,0 +1,11 @@ +{{@a/b}} {{a.[b].[c d]}} +{{&a}} +{{1.50}} {{-0}} {{007}} +{{^a}}b{{else}}c{{/a}} {{#a}}b{{^}}c{{/a}} + {{> b}} {{/if}} --> + +a\{{b}} c\\{{d}} e\\\{{f}} +{{#a}}{{else}}{{#if b}}{{/if}}{{/a}} {{#a}}{{else b}}{{else a}}{{/a}} {{#a}}{{~else b~}}c{{~/a~}} +{{a "b\"c" 'd\'e' g="h'i\"j"}} +<b> +{{a "f\"}} diff --git a/test/cli/format/own/cases/handlebars/what-the-parsers-drop-and-change.hbs.prettier.snap b/test/cli/format/own/cases/handlebars/what-the-parsers-change.hbs.prettier.snap similarity index 69% rename from test/cli/format/own/cases/handlebars/what-the-parsers-drop-and-change.hbs.prettier.snap rename to test/cli/format/own/cases/handlebars/what-the-parsers-change.hbs.prettier.snap index 77425422ed59..004909a840fc 100644 --- a/test/cli/format/own/cases/handlebars/what-the-parsers-drop-and-change.hbs.prettier.snap +++ b/test/cli/format/own/cases/handlebars/what-the-parsers-change.hbs.prettier.snap @@ -1,38 +1,20 @@ ------------------ { printWidth: 80 } ------------------ - -{{a}} -{{this.this}} -{{this}} -{{a b}} -{{a/b}} -{{a.[#b]}} {{@a/b}} {{a.b.[c d]}} {{{a}}} -{{"a"}} -{{ c}} -{{a ( d)}} {{1.5}} {{0}} {{7}} -{{1.2345678901234568e+29}} -{{1e-7}} {{#a}}c{{else}}b{{/a}} {{#a}}b{{else}}c{{/a}} -{{#raw a}} \{{c}} {{/raw}} - -{{a}} -{{b}} {{> b}} {{/if}} --> -b a\{{b}} c\\{{d}} e\\\{{f}} - {{#a}}{{else if b}}{{/a}} {{#a}}{{else}}{{#b}}{{else}}{{#a}}{{/a}}{{/b}}{{/a}} {{#a}}{{~else~}}{{~#b~}}c{{~/b~}}{{~/a~}} @@ -48,38 +30,20 @@ e\\\{{f}} ------------------- { printWidth: 100 } ------------------- - -{{a}} -{{this.this}} -{{this}} -{{a b}} -{{a/b}} -{{a.[#b]}} {{@a/b}} {{a.b.[c d]}} {{{a}}} -{{"a"}} -{{ c}} -{{a ( d)}} {{1.5}} {{0}} {{7}} -{{1.2345678901234568e+29}} -{{1e-7}} {{#a}}c{{else}}b{{/a}} {{#a}}b{{else}}c{{/a}} -{{#raw a}} \{{c}} {{/raw}} - -{{a}} -{{b}} {{> b}} {{/if}} --> -b a\{{b}} c\\{{d}} e\\\{{f}} - {{#a}}{{else if b}}{{/a}} {{#a}}{{else}}{{#b}}{{else}}{{#a}}{{/a}}{{/b}}{{/a}} {{#a}}{{~else~}}{{~#b~}}c{{~/b~}}{{~/a~}} diff --git a/test/cli/format/own/cases/handlebars/what-the-parsers-drop-and-change.hbs.snap b/test/cli/format/own/cases/handlebars/what-the-parsers-change.hbs.snap similarity index 69% rename from test/cli/format/own/cases/handlebars/what-the-parsers-drop-and-change.hbs.snap rename to test/cli/format/own/cases/handlebars/what-the-parsers-change.hbs.snap index 77425422ed59..004909a840fc 100644 --- a/test/cli/format/own/cases/handlebars/what-the-parsers-drop-and-change.hbs.snap +++ b/test/cli/format/own/cases/handlebars/what-the-parsers-change.hbs.snap @@ -1,38 +1,20 @@ ------------------ { printWidth: 80 } ------------------ - -{{a}} -{{this.this}} -{{this}} -{{a b}} -{{a/b}} -{{a.[#b]}} {{@a/b}} {{a.b.[c d]}} {{{a}}} -{{"a"}} -{{ c}} -{{a ( d)}} {{1.5}} {{0}} {{7}} -{{1.2345678901234568e+29}} -{{1e-7}} {{#a}}c{{else}}b{{/a}} {{#a}}b{{else}}c{{/a}} -{{#raw a}} \{{c}} {{/raw}} - -{{a}} -{{b}} {{> b}} {{/if}} --> -b a\{{b}} c\\{{d}} e\\\{{f}} - {{#a}}{{else if b}}{{/a}} {{#a}}{{else}}{{#b}}{{else}}{{#a}}{{/a}}{{/b}}{{/a}} {{#a}}{{~else~}}{{~#b~}}c{{~/b~}}{{~/a~}} @@ -48,38 +30,20 @@ e\\\{{f}} ------------------- { printWidth: 100 } ------------------- - -{{a}} -{{this.this}} -{{this}} -{{a b}} -{{a/b}} -{{a.[#b]}} {{@a/b}} {{a.b.[c d]}} {{{a}}} -{{"a"}} -{{ c}} -{{a ( d)}} {{1.5}} {{0}} {{7}} -{{1.2345678901234568e+29}} -{{1e-7}} {{#a}}c{{else}}b{{/a}} {{#a}}b{{else}}c{{/a}} -{{#raw a}} \{{c}} {{/raw}} - -{{a}} -{{b}} {{> b}} {{/if}} --> -b a\{{b}} c\\{{d}} e\\\{{f}} - {{#a}}{{else if b}}{{/a}} {{#a}}{{else}}{{#b}}{{else}}{{#a}}{{/a}}{{/b}}{{/a}} {{#a}}{{~else~}}{{~#b~}}c{{~/b~}}{{~/a~}} diff --git a/test/cli/format/own/cases/handlebars/what-the-parsers-drop-and-change.hbs.input b/test/cli/format/own/cases/handlebars/what-the-parsers-drop-and-change.hbs.input deleted file mode 100644 index 8e82ba90e251..000000000000 --- a/test/cli/format/own/cases/handlebars/what-the-parsers-drop-and-change.hbs.input +++ /dev/null @@ -1,14 +0,0 @@ - - -{{this/a}} {{[this]}} {{this.this}} {{a ...b}} {{[a/b]}} {{a.#b}} {{@a/b}} {{a.[b].[c d]}} -{{&a}} {{"a" b c=d}} {{(a=b) c}} {{a ((b=c) d)}} -{{1.50}} {{-0}} {{007}} {{123456789012345678901234567890}} {{0.0000001}} -{{^a}}b{{else}}c{{/a}} {{#a}}b{{^}}c{{/a}} -{{{{raw a}}}} {{c}} {{{{/raw}}}} - {{> b}} {{/if}} --> -< a>b -a\{{b}} c\\{{d}} e\\\{{f}} -{{#a}}{{else}}{{#if b}}{{/if}}{{/a}} {{#a}}{{else b}}{{else a}}{{/a}} {{#a}}{{~else b~}}c{{~/a~}} -{{a "b\"c" 'd\'e' g="h'i\"j"}} -<b> -{{a "f\"}} diff --git a/test/cli/format/own/cases/html/template-whose-tag-is-a-substitution-and-not-closed.js.input b/test/cli/format/own/cases/html/template-whose-tag-is-a-substitution-and-not-closed.js.input deleted file mode 100644 index 9948bc97cb33..000000000000 --- a/test/cli/format/own/cases/html/template-whose-tag-is-a-substitution-and-not-closed.js.input +++ /dev/null @@ -1,7 +0,0 @@ -a = html`<${t} >`; -b = html`<${t} ${a}>x`; -c = html``; -d = html`<${t} ${a}>x`; -e = open ? html`<${t} ${a}>` : html`<${t} ${a}>${k}`; -f = html`<${t} ${a} />`; -g = html`
    `; diff --git a/test/cli/format/own/cases/html/template-whose-tag-is-a-substitution-and-not-closed.js.prettier.snap b/test/cli/format/own/cases/html/template-whose-tag-is-a-substitution-and-not-closed.js.prettier.snap deleted file mode 100644 index e85e865a148c..000000000000 --- a/test/cli/format/own/cases/html/template-whose-tag-is-a-substitution-and-not-closed.js.prettier.snap +++ /dev/null @@ -1,23 +0,0 @@ ------------------- -{ printWidth: 80 } ------------------- -a = html`<${t}>`; -b = html`<${t} ${a}>x`; -c = html``; -d = html`<${t} ${a}>x`; -e = open ? html`<${t} ${a}>` : html`<${t} ${a}>${k}`; -f = html`<${t} ${a} />`; -g = html`
    `; - -------------------- -{ printWidth: 100 } -------------------- -a = html`<${t}>`; -b = html`<${t} ${a}>x`; -c = html``; -d = html`<${t} ${a}>x`; -e = open ? html`<${t} ${a}>` : html`<${t} ${a}>${k}`; -f = html`<${t} ${a} />`; -g = html`
    `; - -===================== End ===================== diff --git a/test/cli/format/own/cases/html/template-whose-tag-is-a-substitution-and-not-closed.js.snap b/test/cli/format/own/cases/html/template-whose-tag-is-a-substitution-and-not-closed.js.snap deleted file mode 100644 index 309abe1eee2b..000000000000 --- a/test/cli/format/own/cases/html/template-whose-tag-is-a-substitution-and-not-closed.js.snap +++ /dev/null @@ -1,23 +0,0 @@ ------------------- -{ printWidth: 80 } ------------------- -a = html`<${t} >`; -b = html`<${t} ${a}>x`; -c = html``; -d = html`<${t} ${a}>x`; -e = open ? html`<${t} ${a}>` : html`<${t} ${a}>${k}`; -f = html`<${t} ${a} />`; -g = html`
    `; - -------------------- -{ printWidth: 100 } -------------------- -a = html`<${t} >`; -b = html`<${t} ${a}>x`; -c = html``; -d = html`<${t} ${a}>x`; -e = open ? html`<${t} ${a}>` : html`<${t} ${a}>${k}`; -f = html`<${t} ${a} />`; -g = html`
    `; - -===================== End ===================== diff --git a/test/cli/format/own/cases/html/template-whose-tag-is-a-substitution.js.input b/test/cli/format/own/cases/html/template-whose-tag-is-a-substitution.js.input new file mode 100644 index 000000000000..29d53ef6d148 --- /dev/null +++ b/test/cli/format/own/cases/html/template-whose-tag-is-a-substitution.js.input @@ -0,0 +1,3 @@ +a = html`<${t} ${a}>${k}`; +b = html`<${t} ${a} />`; +c = html`
    `; diff --git a/test/cli/format/own/cases/html/template-whose-tag-is-a-substitution.js.prettier.snap b/test/cli/format/own/cases/html/template-whose-tag-is-a-substitution.js.prettier.snap new file mode 100644 index 000000000000..98cff6a5a94b --- /dev/null +++ b/test/cli/format/own/cases/html/template-whose-tag-is-a-substitution.js.prettier.snap @@ -0,0 +1,15 @@ +------------------ +{ printWidth: 80 } +------------------ +a = html`<${t} ${a}>${k}`; +b = html`<${t} ${a} />`; +c = html`
    `; + +------------------- +{ printWidth: 100 } +------------------- +a = html`<${t} ${a}>${k}`; +b = html`<${t} ${a} />`; +c = html`
    `; + +===================== End ===================== diff --git a/test/cli/format/own/cases/html/template-whose-tag-is-a-substitution.js.snap b/test/cli/format/own/cases/html/template-whose-tag-is-a-substitution.js.snap new file mode 100644 index 000000000000..98cff6a5a94b --- /dev/null +++ b/test/cli/format/own/cases/html/template-whose-tag-is-a-substitution.js.snap @@ -0,0 +1,15 @@ +------------------ +{ printWidth: 80 } +------------------ +a = html`<${t} ${a}>${k}`; +b = html`<${t} ${a} />`; +c = html`
    `; + +------------------- +{ printWidth: 100 } +------------------- +a = html`<${t} ${a}>${k}`; +b = html`<${t} ${a} />`; +c = html`
    `; + +===================== End ===================== diff --git a/test/cli/format/tailwind/cases.json b/test/cli/format/tailwind/cases.json index d973b4ee4f30..5dad5f4f8371 100644 --- a/test/cli/format/tailwind/cases.json +++ b/test/cli/format/tailwind/cases.json @@ -1025,8 +1025,8 @@ "options": { "sortTailwindcss": {} }, - "input": "
    \n
    \n
    \n
    \n
    \n
    \n\n{{foo class=\"p-4 flex\"}}\n", - "output": "
    \n
    \n
    \n
    \n
    \n
    \n\n{{foo class=\"p-4 flex\"}}" + "input": "
    \n
    \n
    \n
    \n
    \n\n{{foo class=\"p-4 flex\"}}\n", + "output": "
    \n
    \n
    \n
    \n
    \n\n{{foo class=\"p-4 flex\"}}" }, { "name": "Angular", diff --git a/test/cli/format/tailwind/tailwind.test.ts b/test/cli/format/tailwind/tailwind.test.ts index f2c36a411611..38aaa247292e 100644 --- a/test/cli/format/tailwind/tailwind.test.ts +++ b/test/cli/format/tailwind/tailwind.test.ts @@ -80,6 +80,59 @@ describe.concurrent("sortTailwindcss", () => { const listed = (root: string) => readdirSync(root, { recursive: true, encoding: "utf8" }).sort(); + describe("Handlebars: classes that oxfmt joins with what is next to them, or loses", () => { + // The template, what Bun makes of it, and what oxfmt 0.72 makes of it. It takes the blank at the end of a string away + // whatever follows, moves a class that touches what is before or behind the mustache, and loses a no-break space. + const rows = [ + [ + '\n', + '', + '', + ], + [ + '\n', + '', + '', + ], + [ + '\n', + ``, + ``, + ], + [ + '\n', + ``, + ``, + ], + [ + '\n', + ``, + ``, + ], + ['\n', '', ''], + // With white space in between nothing touches. + ['\n', ``, null], + ] as const; + const names = rows.map((_, index) => `${index}.hbs`); + const files = Object.fromEntries(rows.map((row, index) => [names[index], row[0]])); + const project = { ...version4, ".oxfmtrc.json": '{ "sortTailwindcss": {} }' }; + + test("stay apart", async () => { + for (const [, after, ofOxfmt] of rows) expect(after).not.toBe(ofOxfmt); + const result = await format({ ...project, ...files }, names); + expect(result.stderr).toBe(""); + expect(result.files).toEqual(rows.map(row => row[1])); + expect(result.exitCode).toBe(0); + }); + + test("--debug-check compares what would be written, and has nothing to say about it", async () => { + const result = await format({ ...project, ...files }, names, ["--debug-check"]); + expect(result.stderr).toBe(""); + expect(result.files).toEqual(rows.map(row => row[0])); + expect(result.exitCode).toBe(0); + }); + }); + test("nothing is written into the project, not even while Tailwind is asked", async () => { // The stand-in looks around when it is loaded, and refuses to answer if there is a file that the test has not made. const looksAround = `const { readdirSync } = require("node:fs"); diff --git a/test/cli/init/init.test.ts b/test/cli/init/init.test.ts index afb800eb01b8..113be9d6abcc 100644 --- a/test/cli/init/init.test.ts +++ b/test/cli/init/init.test.ts @@ -566,7 +566,7 @@ const initEnv = { ...bunEnv, BUN_AGENT_RULE_DISABLED: "1" }; stdio: ["ignore", "pipe", "pipe"], env: bunEnv, }); - const [stdout, stderr, exitCode] = await Promise.all([fmt.stdout.text(), format.stderr.text(), format.exited]); + const [stdout, stderr, exitCode] = await Promise.all([format.stdout.text(), format.stderr.text(), format.exited]); expect({ stdout, stderr, exitCode }).toMatchObject({ exitCode: 0 }); }, 30_000, diff --git a/test/cli/lint/js-plugins.test.ts b/test/cli/lint/js-plugins.test.ts index 5efe5ae2328b..4526f4a20c3a 100644 --- a/test/cli/lint/js-plugins.test.ts +++ b/test/cli/lint/js-plugins.test.ts @@ -307,8 +307,9 @@ describe.concurrent("bun lint with plugins in JavaScript", () => { timeout, ); + // As with oxlint 1.87: the mark is on the plugin's variable, and oxlint's own rules have their own. test( - "a variable that a rule marks as used is used for no-unused-vars", + "a variable that a rule marks as used is still unused for oxlint's no-unused-vars", async () => { const plugin = ` export default { @@ -337,6 +338,7 @@ describe.concurrent("bun lint with plugins in JavaScript", () => { ["-f", "unix"], ); expect(stdout.split("\n").filter(it => it.includes("no-unused-vars"))).toEqual([ + expect.stringMatching(/a\.js:1:7: .*'a'/), expect.stringMatching(/a\.js:2:7: .*'b'/), ]); }, @@ -3649,12 +3651,15 @@ export default { timeout, ); + // What oxlint 1.87 says. Its own no-unused-vars doesn't see the mark. test( "with an oxlint config markVariableAsUsed marks the variable it names in every kind of file", async () => { const plugin = `export default { meta: { name: "p" }, rules: { rule: { create: context => ({ Program(node) { - context.sourceCode.markVariableAsUsed("first", node); + const returned = context.sourceCode.markVariableAsUsed("first", node); + const declared = context.sourceCode.scopeManager.scopes.flatMap(it => it.variables).filter(it => it.defs.length); + context.report({ node, message: returned + " " + declared.map(it => it.name + "=" + it.eslintUsed).join(" ") }); }, }) } } };`; const code = "var first = 1;\nvar second = 2;\n"; @@ -3670,9 +3675,16 @@ export default { }; const { raw, exitCode } = await lint(files, ["-f", "json"]); const said = JSON.parse(raw).diagnostics.map( - (it: { filename: string; message: string }) => `${it.filename} ${it.message.match(/'(\w+)'/)?.[1]}`, + (it: { filename: string; message: string }) => + `${it.filename} ${it.message.match(/'(\w+)'/)?.[1] ?? it.message}`, + ); + expect(said.sort()).toEqual( + ["a.cjs", "b.js", "c.mjs"].flatMap(file => [ + `${file} first`, + `${file} second`, + `${file} true first=true second=false`, + ]), ); - expect(said.sort()).toEqual(["a.cjs second", "b.js second", "c.mjs second"]); expect(exitCode).toBe(1); }, timeout, @@ -4184,6 +4196,121 @@ export default { meta: { name: "lazy" }, rules: { rule, kinds } }; }, timeout, ); + + // `loc`, `decorators`, `ast.tokens` and the properties of `sourceCode` are made when they're read. It must not show who reads them first, in + // which realm or in which tier of the engine: they belong to the plugin's realm and stay the same objects. + // This is what oxlint 1.87 and ESLint 10.12 say, where all of it is plain data. + test.each(["oxlint", "eslint"])( + "what is made on first read is the same from a node:vm context and in hot code, with a config of %s", + async whose => { + const plugin = String.raw`import vm from "node:vm"; +const keys = ["decorators", "loc", "range"]; +// The same functions for each realm. A loop is as long as it takes the JIT compilers to get to it. +const source = + "({" + + keys + .map( + key => + "first_KEY(list, Base) { let mine = 0; const out = []; for (let i = 0; i < list.length; i++) { const it = list[i].KEY; mine += it instanceof Base; out.push(it); } return [mine, out]; }," + + "again_KEY(list, seen) { let changed = 0; for (let r = 0; r < 3; r++) for (let i = 0; i < list.length; i++) changed += list[i].KEY !== seen[i]; return changed; },", + ) + .map((text, i) => text.replaceAll("KEY", keys[i])) + .join("") + + "methods(sourceCode, list) { let mine = 0; for (let r = 0; r < 3; r++) for (let i = 0; i < list.length; i++) mine += (sourceCode.getAncestors(list[i]) instanceof Array) + (sourceCode.getTokens(list[i]) instanceof Array); return mine; }," + + "properties(sourceCode) { const { text, ast, lines, visitorKeys, scopeManager, parserServices } = sourceCode; let changed = 0; for (let r = 0; r < 20000; r++) changed += (sourceCode.text !== text) + (sourceCode.ast !== ast) + (sourceCode.lines !== lines) + (sourceCode.visitorKeys !== visitorKeys) + (sourceCode.scopeManager !== scopeManager) + (sourceCode.parserServices !== parserServices) + (sourceCode.hasBOM !== false); return [changed, lines instanceof Array, ast.tokens instanceof Array, ast.comments instanceof Array, ast.tokens[0].loc instanceof Object, scopeManager.scopes[0].variables instanceof Array].join(); }," + + "Array, Object })"; +const rule = { + create(context) { + const found = []; + return { + Identifier(node) { + found.push(node); + }, + "Program:exit"(program) { + const { sourceCode } = context; + const said = []; + for (const [realm, list, it] of [ + ["main", found.slice(0, found.length / 2), (0, eval)(source)], + ["vm", found.slice(found.length / 2), vm.runInContext(source, vm.createContext({}))], + ]) { + // One read each of a node that nothing has looked at. + const node = list.pop(); + const inContext = text => vm.runInNewContext(text, { node, sourceCode }); + if (realm === "vm") + said.push( + "cold " + + [ + inContext("Array.isArray(node.range) && !(node.range instanceof Array)"), + inContext("typeof node.loc.start.line"), + inContext("'decorators' in node ? node.decorators.length : 'none'"), + inContext("sourceCode.getText(node)"), + inContext("sourceCode.getFirstToken(node).value"), + inContext("sourceCode.text.length"), + inContext("Reflect.get(node, 'loc', 42) === node.loc"), + node.range instanceof Array, + node.loc instanceof Object, + ].join(), + ); + for (const key of keys) { + if (!(key in list[0])) continue; + const [Base, MainBase] = key === "loc" ? [it.Object, Object] : [it.Array, Array]; + const [ofReader, seen] = it["first_" + key](list, Base); + if (key === "decorators") for (const decorators of seen) decorators.push(1); + const ofMain = seen.filter(it => it instanceof MainBase).length; + const kept = key === "decorators" ? list.filter(it => it.decorators.length === 1).length : "-"; + said.push([realm, key, "reader's", ofReader, "main", ofMain, "changed", it["again_" + key](list, seen), "kept", kept].join(" ")); + } + said.push([realm, "methods", it.methods(sourceCode, list), "properties", it.properties(sourceCode)].join(" ")); + } + context.report({ node: program, message: said.join("\n") }); + }, + }; + }, +}; +export default { meta: { name: "p" }, rules: { rule } }; +`; + const expected = { + oxlint: [ + "main decorators reader's 1500 main 1500 changed 0 kept 1500", + "main loc reader's 1500 main 1500 changed 0 kept -", + "main range reader's 1500 main 1500 changed 0 kept -", + "main methods 9000 properties 0,true,true,true,true,true", + "cold true,number,0,v3001,v3001,19909,true,true,true", + "vm decorators reader's 0 main 1500 changed 0 kept 1500", + "vm loc reader's 0 main 1500 changed 0 kept -", + "vm range reader's 0 main 1500 changed 0 kept -", + "vm methods 0 properties 0,false,false,false,false,false", + ], + eslint: [ + "main loc reader's 1500 main 1500 changed 0 kept -", + "main range reader's 1500 main 1500 changed 0 kept -", + "main methods 9000 properties 0,true,true,true,true,true", + "cold true,number,none,v3001,v3001,19909,true,true,true", + "vm loc reader's 0 main 1500 changed 0 kept -", + "vm range reader's 0 main 1500 changed 0 kept -", + "vm methods 0 properties 0,false,false,false,false,false", + ], + }; + const configs = { + oxlint: { ".oxlintrc.json": oxlintrc({ jsPlugins: ["./plugin.mjs"], rules: { "p/rule": "error" } }) }, + eslint: { + "eslint.config.mjs": `import p from "./plugin.mjs"; + export default [{ files: ["**/*.js"], plugins: { p }, rules: { "p/rule": "error" } }];`, + }, + }; + const files = { + "plugin.mjs": plugin, + ...configs[whose as keyof typeof configs], + "a.js": "// c\n" + Array.from({ length: 3002 }, (_, i) => `v${i};\n`).join(""), + }; + const { raw, exitCode } = await lint(files, ["-f", "json", "a.js"]); + const message: string = + whose === "oxlint" ? JSON.parse(raw).diagnostics[0].message : JSON.parse(raw)[0].messages[0].message; + expect(message.split("\n")).toEqual(expected[whose as keyof typeof expected]); + expect(exitCode).toBe(1); + }, + timeout, + ); }); test.concurrent( diff --git a/test/cli/lint/lint.test.ts b/test/cli/lint/lint.test.ts index 0b9a8b13d0f1..21c789da07ab 100644 --- a/test/cli/lint/lint.test.ts +++ b/test/cli/lint/lint.test.ts @@ -5396,6 +5396,134 @@ describe.concurrent("bun lint", () => { }); }); + // oxlint analyses a file twice. Its own rules see the scopes of oxc, a rule of a plugin in JavaScript those of typescript-eslint. They + // differ about `namespace A.B {}`, about a function in a block of sloppy JavaScript, and about what is in the type of a `catch` + // parameter and in the options of an import type. A rule that is built in here and that oxlint lacks, as `no-catch-shadow`, is + // one that oxlint could only run from a plugin. What is expected is what oxlint 1.87 reports, and for `no-catch-shadow` what + // it reports for the plugin's copy of that rule. + describe("with an oxlint config, which scopes a rule sees doesn't depend on the other rules", () => { + const sources = { + "namespace.ts": `let Inner = 0;\nnamespace Outer.Inner { Inner = 1; export const x = Inner; try {} catch (Inner) {} }\nconsole.log(Inner, Outer);\n`, + "namespace-used.ts": `namespace Shapes.Round { export const sides = 0; }\nexport { Shapes };\n`, + "block.cjs": `{ function helper() {} }\nhelper();\ntry {} catch (helper) {}\n`, + "block.js": `{ function helper() {} }\nhelper();\ntry {} catch (helper) {}\n`, + "switch.cjs": `switch (kind) { case 1: function helper() {} }\nhelper;\nfunction outer() { { function inner() {} } inner(); try {} catch (inner) {} }\nouter();\n`, + "import-type.ts": `export type A = import("./m", { with: { k: function (left: unknown) { try {} catch (left) {} } } });\n`, + "catch-type.ts": `type Shape = unknown;\ntry {} catch (e: (v: Shape) => void) { e; }\nexport {};\n`, + "empty.mjs": `export default { meta: { name: "q" }, rules: {} };\n`, + "plugin.mjs": `const shadow = { create(context) { return { CatchClause(node) { + let scope = context.sourceCode.getScope(node); + if (scope.block === node) scope = scope.upper; + for (; scope; scope = scope.upper) if (scope.set.has(node.param.name)) return context.report({ node, message: "shadows" }); +} }; } }; +const scopes = { create(context) { return { Program(node) { + const declared = it => it.variables.filter(it => it.defs.length).map(it => it.name); + context.report({ node, message: context.sourceCode.scopeManager.scopes.map(it => it.type + "[" + declared(it) + "]").join(" ") }); +} }; } }; +export default { meta: { name: "q" }, rules: { shadow, scopes } };\n`, + }; + const ofOxlint = { + "no-undef": "error", + "no-unused-vars": "error", + "no-shadow": "error", + "prefer-const": "error", + "no-use-before-define": "error", + "no-useless-assignment": "error", + }; + const reported = async (more: object, rules: object) => { + const configuration = { + categories: { correctness: "off" }, + env: { node: true }, + globals: { kind: "readonly" }, + ignorePatterns: ["*.mjs"], + ...more, + rules: { ...ofOxlint, ...rules }, + }; + const { stdout } = await lint({ ".oxlintrc.json": JSON.stringify(configuration), ...sources }, ["-f", "unix"]); + const found = [...stdout.matchAll(/^([\w-]+\.\w+:\d+:\d+): (.*) \[Error\/(\w+)\(([\w-]+)\)\]$/gm)]; + const all = found.map(it => `${it[1]} ${it[4]}` + (it[3] === "q" ? ` ${it[2]}` : "")).sort(); + const isOfOxlint = (it: string) => it.split(" ")[1] in ofOxlint; + return { ofOxlint: all.filter(isOfOxlint), others: all.filter(it => !isOfOxlint(it)) }; + }; + const expected = [ + "block.cjs:3:15 no-shadow", + "block.cjs:3:15 no-unused-vars", + "block.js:3:15 no-shadow", + "block.js:3:15 no-unused-vars", + "import-type.ts:1:54 no-unused-vars", + "import-type.ts:1:85 no-shadow", + "import-type.ts:1:85 no-unused-vars", + "namespace.ts:1:5 prefer-const", + "namespace.ts:2:17 no-shadow", + "namespace.ts:2:74 no-shadow", + "namespace.ts:2:74 no-unused-vars", + "switch.cjs:3:67 no-shadow", + "switch.cjs:3:67 no-unused-vars", + ]; + + test("only oxlint's rules", async () => { + expect(await reported({}, {})).toEqual({ ofOxlint: expected, others: [] }); + }); + + test("beside a plugin without rules", async () => { + expect(await reported({ jsPlugins: ["./empty.mjs"] }, {})).toEqual({ ofOxlint: expected, others: [] }); + }); + + test("beside a plugin that asks the scopes", async () => { + const rules = { "q/shadow": "error", "q/scopes": "error" }; + expect(await reported({ jsPlugins: ["./plugin.mjs"] }, rules)).toEqual({ + ofOxlint: expected, + others: [ + "block.cjs:1:1 scopes global[] function[] block[helper] function[] block[] catch[helper] block[]", + "block.js:1:1 scopes global[] block[helper] function[] block[] catch[helper] block[]", + "catch-type.ts:1:1 scopes global[] module[Shape] block[] catch[e] block[]", + "import-type.ts:1:1 scopes global[] module[A]", + "namespace-used.ts:1:1 scopes global[] module[] tsModule[sides]", + "namespace.ts:1:1 scopes global[Inner] tsModule[x] block[] catch[Inner] block[]", + "namespace.ts:2:67 shadow shadows", + "switch.cjs:1:1 scopes global[] function[outer] switch[helper] function[] function[] block[inner] function[] block[] catch[inner] block[]", + ], + }); + }); + + // The mark is on the plugin's variable. + test("`markVariableAsUsed` in a plugin doesn't silence oxlint's no-unused-vars", async () => { + const files = { + ".oxlintrc.json": JSON.stringify({ + categories: { correctness: "off" }, + jsPlugins: ["./marks.mjs"], + ignorePatterns: ["*.mjs"], + rules: { "no-unused-vars": "error", "q/marks": "error", "q/marked": "error" }, + }), + "marks.mjs": `const marks = { create(context) { return { Program(node) { + for (const name of ["first", "work", "Kind"]) context.sourceCode.markVariableAsUsed(name, node); +} }; } }; +const marked = { create(context) { return { "Program:exit"(node) { + const all = context.sourceCode.scopeManager.scopes.flatMap(it => it.variables).filter(it => it.defs.length); + context.report({ node, message: all.map(it => it.name + "=" + !!it.eslintUsed).join(" ") }); +} }; } }; +export default { meta: { name: "q" }, rules: { marks, marked } };\n`, + "marked.ts": `const first = 1;\nconst second = 2;\nfunction work() {}\ntype Kind = 1;\nexport {};\n`, + }; + const { stdout } = await lint(files, ["-f", "unix"]); + const found = [...stdout.matchAll(/^marked\.ts:(\d+:\d+): (.*) \[Error\/(\w+)\(([\w-]+)\)\]$/gm)]; + expect(found.map(it => `${it[1]} ${it[4]}` + (it[3] === "q" ? ` ${it[2]}` : "")).sort()).toEqual([ + "1:1 marked first=true second=false work=true Kind=true", + "1:7 no-unused-vars", + "2:7 no-unused-vars", + "3:10 no-unused-vars", + "4:6 no-unused-vars", + ]); + }); + + test("beside a built-in rule that oxlint lacks", async () => { + expect(await reported({}, { "no-catch-shadow": "error" })).toEqual({ + ofOxlint: expected, + others: ["namespace.ts:2:67 no-catch-shadow"], + }); + }); + }); + // `namespace A.B { .. }` is one node, and what is between the braces is its body: a rule that looks at the statements beside a // statement says there what it says in `namespace B { .. }` and in `namespace A { export namespace B { .. } }`. describe("the statements of a namespace with dots in its name", () => { @@ -14250,2422 +14378,3920 @@ describe("TypeScript grammar errors under .oxlintrc.json", () => { "await of;", "Expected a semicolon or an implicit semicolon after a statement, but found none @5+0 help: Try inserting a semicolon here", ], - ], - "d.ts": [ - ["const a: number = 1;", "TS(1039) Initializers are not allowed in ambient contexts. @18+1"], - ["let a = 1;", "TS(1039) Initializers are not allowed in ambient contexts. @8+1"], - ['export const a = 1, b: string = "x";', "TS(1039) Initializers are not allowed in ambient contexts. @32+3"], - ["a;", "TS(1036) Statements are not allowed in ambient contexts. @0+2"], - [";", "TS(1036) Statements are not allowed in ambient contexts. @0+1"], - ["{}", "TS(1036) Statements are not allowed in ambient contexts. @0+2"], - ["if (1) {}", "TS(1036) Statements are not allowed in ambient contexts. @0+9"], - ["label: a;", "TS(1036) Statements are not allowed in ambient contexts. @0+9"], - ["debugger;", "TS(1036) Statements are not allowed in ambient contexts. @0+9"], + ["function w() { let !x; }", "Unexpected token @19+1"], + ["{ let !x; }", "Unexpected token @6+1"], + ["let !x;", "Unexpected token @4+1"], + ["function w() { let 1; }", "Unexpected token @19+1"], + ["function w() { let ~x; }", "Unexpected token @19+1"], + ["() => { let !x; };", "Unexpected token @12+1"], + ["class A { m() { let !x; } }", "Unexpected token @20+1"], [ - "declare namespace N { const a = 1; let b = 2; function f() {} ; class C { m() {} x = 1 } }", - "TS(1039) Initializers are not allowed in ambient contexts. @43+1", - "TS(1183) An implementation cannot be declared in ambient contexts. @59+0", - "TS(1036) Statements are not allowed in ambient contexts. @62+1", - "TS(1183) An implementation cannot be declared in ambient contexts. @78+0", - "TS(1039) Initializers are not allowed in ambient contexts. @85+1", + "static function () {};", + "Expected function name @16+1 help: Function name is required in function declaration or named export", + "'static' modifier cannot be used here. @0+6 help: Allowed modifiers are: declare, async", ], - ["namespace N { a; }", "TS(1036) Statements are not allowed in ambient contexts. @14+2"], + ["declare function () {};", "TS(1183) An implementation cannot be declared in ambient contexts. @20+0"], [ - 'class A { x = 1; readonly y = 1; readonly z: number = 1; static s = 1; static readonly t = "a"; declare u = 1 }', - "TS(1039) Initializers are not allowed in ambient contexts. @14+1", - "TS(1039) Initializers are not allowed in ambient contexts. @54+1", - "TS(1039) Initializers are not allowed in ambient contexts. @68+1", - "TS(1039) Initializers are not allowed in ambient contexts. @108+1", + "export function () {};", + "Expected function name @16+1 help: Function name is required in function declaration or named export", ], - ["using a: any;", "TS(1545) 'using' declarations are not allowed in ambient contexts. @6+1"], - ["for (;;) {}", "TS(1036) Statements are not allowed in ambient contexts. @0+11"], - ['"use strict"; a;', "TS(1036) Statements are not allowed in ambient contexts. @14+2"], - ["let a!: number;", "TS(1255) A definite assignment assertion '!' is not permitted in this context. @5+1"], [ - "class A { set a(v = 1); set b(v): void; set c(v?) }", - "A 'set' accessor cannot have an initializer. @15+7", - "TS(1095) A 'set' accessor cannot have a return type annotation. @32+6", - "TS(1051) A 'set' accessor cannot have an optional parameter. @46+2", + "async function () {};", + "Expected function name @15+1 help: Function name is required in function declaration or named export", ], - ['declare module "m" { namespace N { a; } }', "TS(1036) Statements are not allowed in ambient contexts. @35+2"], - ["interface A {};", "TS(1036) Statements are not allowed in ambient contexts. @14+1"], - ["declare function f(): void;;", "TS(1036) Statements are not allowed in ambient contexts. @27+1"], [ - "namespace N { module 'a' {} }", - "TS(2435) Ambient modules cannot be nested in other modules or namespaces. @14+13", + "static function* () {};", + "Expected function name @17+1 help: Function name is required in function declaration or named export", + "'static' modifier cannot be used here. @0+6 help: Allowed modifiers are: declare, async", ], [ - "function f<>() {}", - "TS(1098) Type parameter list cannot be empty. @10+2", - "TS(1183) An implementation cannot be declared in ambient contexts. @15+0", + "public function () {};", + "Expected function name @16+1 help: Function name is required in function declaration or named export", + "'public' modifier cannot be used here. @0+6 help: Allowed modifiers are: declare, async", ], [ - "export function f<>() {}", - "TS(1098) Type parameter list cannot be empty. @17+2", - "TS(1183) An implementation cannot be declared in ambient contexts. @22+0", + "function () {} let y: readonly number;", + "Expected function name @9+1 help: Function name is required in function declaration or named export", + "TS(1354) 'readonly' type modifier is only permitted on array and tuple literal types. @22+8", ], - ["declare function f<>(): void;", "TS(1098) Type parameter list cannot be empty. @18+2"], [ - "(function <>() {});", - "TS(1098) Type parameter list cannot be empty. @10+2", - "TS(1036) Statements are not allowed in ambient contexts. @0+19", + "function () {} let a = ;", + "Expected function name @9+1 help: Function name is required in function declaration or named export", + "Unexpected token @23+1", ], [ - "(function f<>() {});", - "TS(1098) Type parameter list cannot be empty. @11+2", - "TS(1036) Statements are not allowed in ambient contexts. @0+20", + "function* () {} let y: readonly number;", + "Expected function name @10+1 help: Function name is required in function declaration or named export", + "TS(1354) 'readonly' type modifier is only permitted on array and tuple literal types. @23+8", ], [ - "class A { m<>() {} }", - "TS(1098) Type parameter list cannot be empty. @11+2", - "TS(1183) An implementation cannot be declared in ambient contexts. @16+0", + "let zz; let zz; function () {}", + "Expected function name @25+1 help: Function name is required in function declaration or named export", ], [ - "class A { static m<>() {} }", - "TS(1098) Type parameter list cannot be empty. @18+2", - "TS(1183) An implementation cannot be declared in ambient contexts. @23+0", + "export function () {} let y: readonly number;", + "Expected function name @16+1 help: Function name is required in function declaration or named export", + "TS(1354) 'readonly' type modifier is only permitted on array and tuple literal types. @29+8", ], [ - "class A { m<>(): void; m() {} }", - "TS(1098) Type parameter list cannot be empty. @11+2", - "TS(1183) An implementation cannot be declared in ambient contexts. @27+0", + "function () {}", + "Expected function name @9+1 help: Function name is required in function declaration or named export", ], - ["abstract class A { abstract m<>(): void }", "TS(1098) Type parameter list cannot be empty. @29+2"], - ["const o = { m<>() {} };", "TS(1098) Type parameter list cannot be empty. @13+2"], - ["class A<> {}", "TS(1098) Type parameter list cannot be empty. @7+2"], [ - "(class <> {});", - "TS(1098) Type parameter list cannot be empty. @7+2", - "TS(1036) Statements are not allowed in ambient contexts. @0+14", + "function (a b) {}", + "Expected function name @9+1 help: Function name is required in function declaration or named export", + 'Expected `,` or `)` but found `Identifier` @12+1 "`,` or `)` expected", 9+1 "Opened here"', ], + ["function;", 'Expected `(` but found `;` @8+1 "`(` expected"'], + ["function {}", 'Expected `(` but found `{` @9+1 "`(` expected"'], [ - "(class A<> {});", - "TS(1098) Type parameter list cannot be empty. @8+2", - "TS(1036) Statements are not allowed in ambient contexts. @0+15", + "static function () {} let y: readonly number;", + "Expected function name @16+1 help: Function name is required in function declaration or named export", + "'static' modifier cannot be used here. @0+6 help: Allowed modifiers are: declare, async", + "TS(1354) 'readonly' type modifier is only permitted on array and tuple literal types. @29+8", ], - ["interface I<> {}", "TS(1098) Type parameter list cannot be empty. @11+2"], - ["type T<> = 1;", "TS(1098) Type parameter list cannot be empty. @6+2"], - ["interface I { <>(): void }", "TS(1098) Type parameter list cannot be empty. @14+2"], - ["interface I { new <>(): void }", "TS(1098) Type parameter list cannot be empty. @18+2"], - ["interface I { m<>(): void }", "TS(1098) Type parameter list cannot be empty. @15+2"], - ["type F = <>() => void;", "TS(1098) Type parameter list cannot be empty. @9+2"], - ["type F = new <>() => void;", "TS(1098) Type parameter list cannot be empty. @13+2"], + ["declare function* ();", "TS(1221) Generators are not allowed in an ambient context. @16+5"], + ["declare function;", 'Expected `(` but found `;` @16+1 "`(` expected"'], [ - "f<>();", - "TS(1099) Type argument list cannot be empty. @1+2", - "TS(1036) Statements are not allowed in ambient contexts. @0+6", + "declare namespace N { function (); }", + "Expected function name @31+1 help: Function name is required in function declaration or named export", ], + ["{ x = { await }; } import.meta;", "The keyword 'await' is reserved @8+5"], + ["({ await = 1 } = {}); import.meta;", "The keyword 'await' is reserved @3+5"], + ["{ ({ await = 1 } = {}); } import.meta;", "The keyword 'await' is reserved @5+5"], + ["class A { a = await !x; }", 'Expected `;` but found `Identifier` @21+1 "`;` expected"'], [ - "new A<>();", - "TS(1099) Type argument list cannot be empty. @5+2", - "TS(1036) Statements are not allowed in ambient contexts. @0+10", + "for await (const a of b) {} await !x;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @35+0 help: Try inserting a semicolon here", + "`for await` loops are only allowed within async functions and at the top levels of modules @4+5 help: Either remove this `await` or add the `async` keyword to the enclosing function", ], [ - "new A<>;", - "TS(1099) Type argument list cannot be empty. @5+2", - "TS(1036) Statements are not allowed in ambient contexts. @0+8", + "for await (const a of b) {} a b;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @29+0 help: Try inserting a semicolon here", + "`for await` loops are only allowed within async functions and at the top levels of modules @4+5 help: Either remove this `await` or add the `async` keyword to the enclosing function", ], + ["class A { [b c] = 1; }", 'Expected `]` but found `Identifier` @13+1 "`]` expected"'], + ["x = { [b c]: 1 };", 'Expected `]` but found `Identifier` @9+1 "`]` expected"'], [ - "f<>``;", - "TS(1099) Type argument list cannot be empty. @1+2", - "TS(1036) Statements are not allowed in ambient contexts. @0+6", + "for await (const a of b) {}", + "`for await` loops are only allowed within async functions and at the top levels of modules @4+5 help: Either remove this `await` or add the `async` keyword to the enclosing function", ], [ - "let x = f<>;", - "TS(1099) Type argument list cannot be empty. @9+2", - "TS(1039) Initializers are not allowed in ambient contexts. @8+3", + "for await (const a of b) {} await (x);", + "`for await` loops are only allowed within async functions and at the top levels of modules @4+5 help: Either remove this `await` or add the `async` keyword to the enclosing function", ], - ["let x: A<>;", "TS(1099) Type argument list cannot be empty. @8+2"], - ["let x: typeof f<>;", "TS(1099) Type argument list cannot be empty. @15+2"], - ['let x: import("a").B<>;', "TS(1099) Type argument list cannot be empty. @20+2"], - ["class A extends B<> {}", "TS(1099) Type argument list cannot be empty. @17+2"], - ["class A implements B<> {}", "TS(1099) Type argument list cannot be empty. @20+2"], - ["interface I extends B<> {}", "TS(1099) Type argument list cannot be empty. @21+2"], [ - "function f< >() {}", - "TS(1098) Type parameter list cannot be empty. @10+3", - "TS(1183) An implementation cannot be declared in ambient contexts. @16+0", + "for await (const a of b) {} await;", + "`for await` loops are only allowed within async functions and at the top levels of modules @4+5 help: Either remove this `await` or add the `async` keyword to the enclosing function", ], [ - "function f() {}", - "TS(1098) Type parameter list cannot be empty. @10+6", - "TS(1183) An implementation cannot be declared in ambient contexts. @19+0", + "for await (const a of b) { c d }", + "Expected a semicolon or an implicit semicolon after a statement, but found none @28+0 help: Try inserting a semicolon here", + "`for await` loops are only allowed within async functions and at the top levels of modules @4+5 help: Either remove this `await` or add the `async` keyword to the enclosing function", ], - ["let x: A< >;", "TS(1099) Type argument list cannot be empty. @8+3"], [ - "for (var a = 1 in {}) {}", - "TS(1039) Initializers are not allowed in ambient contexts. @13+1", - "TS(1036) Statements are not allowed in ambient contexts. @0+24", + "new.target;", + "Unexpected new.target expression @0+10 help: new.target is only allowed in constructors, functions, and class field initializers", ], - ["export = await;", "Unexpected token @14+1"], [ - "declare module 'a' { export = a; export const b: number; }", - "TS(2309) An export assignment cannot be used in a module with other exported elements @21+11 help: If you want to use `export =`, remove other `export`s and put all of them to the right hand value of `export =`. If you want to use `export`s, remove `export =` statement.", + "new.target; import.meta;", + "Unexpected new.target expression @0+10 help: new.target is only allowed in constructors, functions, and class field initializers", ], [ - "'use strict'; await as any = 1;", - "Expected a semicolon or an implicit semicolon after a statement, but found none @22+0 help: Try inserting a semicolon here", + "import.meta; new.target;", + "Unexpected new.target expression @13+10 help: new.target is only allowed in constructors, functions, and class field initializers", ], [ - "a b;", - "Expected a semicolon or an implicit semicolon after a statement, but found none @1+0 help: Try inserting a semicolon here", + "new.target; export {};", + "Unexpected new.target expression @0+10 help: new.target is only allowed in constructors, functions, and class field initializers", ], [ - "a; b c;", - "TS(1036) Statements are not allowed in ambient contexts. @0+2", - "Expected a semicolon or an implicit semicolon after a statement, but found none @4+0 help: Try inserting a semicolon here", + "export {}; new.target;", + "Unexpected new.target expression @11+10 help: new.target is only allowed in constructors, functions, and class field initializers", ], [ - "{} b c;", - "TS(1036) Statements are not allowed in ambient contexts. @0+2", - "Expected a semicolon or an implicit semicolon after a statement, but found none @4+0 help: Try inserting a semicolon here", + "await (x); new.target;", + "Unexpected new.target expression @11+10 help: new.target is only allowed in constructors, functions, and class field initializers", ], [ - "a\nb c;", - "TS(1036) Statements are not allowed in ambient contexts. @0+1", - "Expected a semicolon or an implicit semicolon after a statement, but found none @3+0 help: Try inserting a semicolon here", + "new.target; await (x);", + "Unexpected new.target expression @0+10 help: new.target is only allowed in constructors, functions, and class field initializers", ], [ - "if (a) b; c d;", - "TS(1036) Statements are not allowed in ambient contexts. @0+9", - "Expected a semicolon or an implicit semicolon after a statement, but found none @11+0 help: Try inserting a semicolon here", + "new.target; await (x); import.meta;", + "Unexpected new.target expression @0+10 help: new.target is only allowed in constructors, functions, and class field initializers", ], [ - "a; b; c d;", - "TS(1036) Statements are not allowed in ambient contexts. @0+2", - "Expected a semicolon or an implicit semicolon after a statement, but found none @7+0 help: Try inserting a semicolon here", + "new.target; await (x); export {};", + "Unexpected new.target expression @0+10 help: new.target is only allowed in constructors, functions, and class field initializers", ], - ["a; b;", "TS(1036) Statements are not allowed in ambient contexts. @0+2"], - ["a; b; c;", "TS(1036) Statements are not allowed in ambient contexts. @0+2"], - ["a; {} if (x) y;", "TS(1036) Statements are not allowed in ambient contexts. @0+2"], - ["a; declare let z: 1; b;", "TS(1036) Statements are not allowed in ambient contexts. @0+2"], [ - "namespace N { a; b; } c; d;", - "TS(1036) Statements are not allowed in ambient contexts. @14+2", - "TS(1036) Statements are not allowed in ambient contexts. @22+2", + "import.meta; await (x); new.target;", + "Unexpected new.target expression @24+10 help: new.target is only allowed in constructors, functions, and class field initializers", ], [ - "a; b; c; d e;", - "TS(1036) Statements are not allowed in ambient contexts. @0+2", - "Expected a semicolon or an implicit semicolon after a statement, but found none @10+0 help: Try inserting a semicolon here", + "await x; new.target;", + "Unexpected new.target expression @9+10 help: new.target is only allowed in constructors, functions, and class field initializers", ], - ["(await as any) = 1;", 'Expected `,` or `)` but found `any` @10+3 "`,` or `)` expected", 0+1 "Opened here"'], [ - "(yield as any)++;", - "A 'yield' expression is only allowed in a generator body. @1+5 help: Either remove this `yield` or change the enclosing function to a generator function (`function*`)", - 'Expected `,` or `)` but found `any` @10+3 "`,` or `)` expected", 0+1 "Opened here"', + "new.target; await x;", + "Unexpected new.target expression @0+10 help: new.target is only allowed in constructors, functions, and class field initializers", ], - ["f(a b);", 'Expected `,` or `)` but found `Identifier` @4+1 "`,` or `)` expected", 1+1 "Opened here"'], - ["[a b];", 'Expected `,` or `]` but found `Identifier` @3+1 "`,` or `]` expected", 0+1 "Opened here"'], - ["a ? b;", 'Expected `:` but found `;` @5+1 "`:` expected", 2+1 "Conditional starts here"'], [ - "await !x;", - "Expected a semicolon or an implicit semicolon after a statement, but found none @7+0 help: Try inserting a semicolon here", + "{ new.target; }", + "Unexpected new.target expression @2+10 help: new.target is only allowed in constructors, functions, and class field initializers", ], - ], - "tsx": [ - ["function f<>() {}", "TS(1098) Type parameter list cannot be empty. @10+2"], - ["export function f<>() {}", "TS(1098) Type parameter list cannot be empty. @17+2"], - ["declare function f<>(): void;", "TS(1098) Type parameter list cannot be empty. @18+2"], - ["(function <>() {});", "TS(1098) Type parameter list cannot be empty. @10+2"], - ["(function f<>() {});", "TS(1098) Type parameter list cannot be empty. @11+2"], - ["class A { m<>() {} }", "TS(1098) Type parameter list cannot be empty. @11+2"], - ["class A { static m<>() {} }", "TS(1098) Type parameter list cannot be empty. @18+2"], - ["class A { m<>(): void; m() {} }", "TS(1098) Type parameter list cannot be empty. @11+2"], - ["abstract class A { abstract m<>(): void }", "TS(1098) Type parameter list cannot be empty. @29+2"], - ["const o = { m<>() {} };", "TS(1098) Type parameter list cannot be empty. @13+2"], - ["class A<> {}", "TS(1098) Type parameter list cannot be empty. @7+2"], - ["(class <> {});", "TS(1098) Type parameter list cannot be empty. @7+2"], - ["(class A<> {});", "TS(1098) Type parameter list cannot be empty. @8+2"], - ["interface I<> {}", "TS(1098) Type parameter list cannot be empty. @11+2"], - ["type T<> = 1;", "TS(1098) Type parameter list cannot be empty. @6+2"], - ["interface I { <>(): void }", "TS(1098) Type parameter list cannot be empty. @14+2"], - ["interface I { new <>(): void }", "TS(1098) Type parameter list cannot be empty. @18+2"], - ["interface I { m<>(): void }", "TS(1098) Type parameter list cannot be empty. @15+2"], - ["type F = <>() => void;", "TS(1098) Type parameter list cannot be empty. @9+2"], - ["type F = new <>() => void;", "TS(1098) Type parameter list cannot be empty. @13+2"], - ["f<>();", "TS(1099) Type argument list cannot be empty. @1+2"], - ["new A<>();", "TS(1099) Type argument list cannot be empty. @5+2"], - ["new A<>;", "TS(1099) Type argument list cannot be empty. @5+2"], - ["f<>``;", "TS(1099) Type argument list cannot be empty. @1+2"], - ["let x = f<>;", "TS(1099) Type argument list cannot be empty. @9+2"], - ["let x: A<>;", "TS(1099) Type argument list cannot be empty. @8+2"], - ["let x: typeof f<>;", "TS(1099) Type argument list cannot be empty. @15+2"], - ['let x: import("a").B<>;', "TS(1099) Type argument list cannot be empty. @20+2"], - ["class A extends B<> {}", "TS(1099) Type argument list cannot be empty. @17+2"], - ["class A implements B<> {}", "TS(1099) Type argument list cannot be empty. @20+2"], - ["interface I extends B<> {}", "TS(1099) Type argument list cannot be empty. @21+2"], - ["function f< >() {}", "TS(1098) Type parameter list cannot be empty. @10+3"], - ["function f() {}", "TS(1098) Type parameter list cannot be empty. @10+6"], - ["let x: A< >;", "TS(1099) Type argument list cannot be empty. @8+3"], - ["a;", "Unexpected token @3+3"], - ["(a) => a;", "Unexpected token. Did you mean `{'>'}` or `>`? @8+0"], - ["a}b;", "Unexpected token. Did you mean `{'}'}` or `}`? @4+0"], - [";", "Identifiers in JSX cannot contain hyphens @3+3 help: Remove the hyphen from the identifier"], [ - "; let y: readonly number;", - "Identifiers in JSX cannot contain hyphens @3+3 help: Remove the hyphen from the identifier", + "() => new.target;", + "Unexpected new.target expression @6+10 help: new.target is only allowed in constructors, functions, and class field initializers", ], [ - "let zz; let zz; ;", - "Identifiers in JSX cannot contain hyphens @19+3 help: Remove the hyphen from the identifier", + "{ await (x); new.target; } import.meta;", + "Unexpected new.target expression @13+10 help: new.target is only allowed in constructors, functions, and class field initializers", ], - [";", "Identifiers in JSX cannot contain hyphens @5+3 help: Remove the hyphen from the identifier"], [ - ";", - "Identifiers in JSX cannot contain hyphens @3+3 help: Remove the hyphen from the identifier", + "await (x), new.target; import.meta;", + "Unexpected new.target expression @11+10 help: new.target is only allowed in constructors, functions, and class field initializers", ], - [";", "Identifiers in JSX cannot contain hyphens @3+3 help: Remove the hyphen from the identifier"], - [";", "Identifiers in JSX cannot contain hyphens @3+2 help: Remove the hyphen from the identifier"], - [";", "Unexpected token @4+1"], - [";", "Unexpected token @6+1"], - [";", "TS(17000) JSX attributes must only be assigned a non-empty 'expression'. @5+2"], [ - "; let y: readonly number;", - "TS(17000) JSX attributes must only be assigned a non-empty 'expression'. @5+2", - "TS(1354) 'readonly' type modifier is only permitted on array and tuple literal types. @19+8", + "f(await (x), new.target); import.meta;", + "Unexpected new.target expression @13+10 help: new.target is only allowed in constructors, functions, and class field initializers", ], - [";", "TS(17000) JSX attributes must only be assigned a non-empty 'expression'. @5+9"], [ - ";", - "TS(17000) JSX attributes must only be assigned a non-empty 'expression'. @5+2", - "TS(17000) JSX attributes must only be assigned a non-empty 'expression'. @10+2", + "new.target, await (x); import.meta;", + "Unexpected new.target expression @0+10 help: new.target is only allowed in constructors, functions, and class field initializers", ], - ["let zz; let zz; ;", "TS(17000) JSX attributes must only be assigned a non-empty 'expression'. @21+2"], - ["< />;", "Unexpected token @2+1"], - ], - "mts": [ - ["function f<>() {}", "TS(1098) Type parameter list cannot be empty. @10+2"], - ["export function f<>() {}", "TS(1098) Type parameter list cannot be empty. @17+2"], - ["declare function f<>(): void;", "TS(1098) Type parameter list cannot be empty. @18+2"], - ["(function <>() {});", "TS(1098) Type parameter list cannot be empty. @10+2"], - ["(function f<>() {});", "TS(1098) Type parameter list cannot be empty. @11+2"], - ["class A { m<>() {} }", "TS(1098) Type parameter list cannot be empty. @11+2"], - ["class A { static m<>() {} }", "TS(1098) Type parameter list cannot be empty. @18+2"], - ["class A { m<>(): void; m() {} }", "TS(1098) Type parameter list cannot be empty. @11+2"], - ["abstract class A { abstract m<>(): void }", "TS(1098) Type parameter list cannot be empty. @29+2"], - ["const o = { m<>() {} };", "TS(1098) Type parameter list cannot be empty. @13+2"], - ["class A<> {}", "TS(1098) Type parameter list cannot be empty. @7+2"], - ["(class <> {});", "TS(1098) Type parameter list cannot be empty. @7+2"], - ["(class A<> {});", "TS(1098) Type parameter list cannot be empty. @8+2"], - ["interface I<> {}", "TS(1098) Type parameter list cannot be empty. @11+2"], - ["type T<> = 1;", "TS(1098) Type parameter list cannot be empty. @6+2"], - ["interface I { <>(): void }", "TS(1098) Type parameter list cannot be empty. @14+2"], - ["interface I { new <>(): void }", "TS(1098) Type parameter list cannot be empty. @18+2"], - ["interface I { m<>(): void }", "TS(1098) Type parameter list cannot be empty. @15+2"], - ["type F = <>() => void;", "TS(1098) Type parameter list cannot be empty. @9+2"], - ["type F = new <>() => void;", "TS(1098) Type parameter list cannot be empty. @13+2"], - ["f<>();", "TS(1099) Type argument list cannot be empty. @1+2"], - ["new A<>();", "TS(1099) Type argument list cannot be empty. @5+2"], - ["new A<>;", "TS(1099) Type argument list cannot be empty. @5+2"], - ["f<>``;", "TS(1099) Type argument list cannot be empty. @1+2"], - ["let x = f<>;", "TS(1099) Type argument list cannot be empty. @9+2"], - ["let x: A<>;", "TS(1099) Type argument list cannot be empty. @8+2"], - ["let x: typeof f<>;", "TS(1099) Type argument list cannot be empty. @15+2"], - ['let x: import("a").B<>;', "TS(1099) Type argument list cannot be empty. @20+2"], - ["class A extends B<> {}", "TS(1099) Type argument list cannot be empty. @17+2"], - ["class A implements B<> {}", "TS(1099) Type argument list cannot be empty. @20+2"], - ["interface I extends B<> {}", "TS(1099) Type argument list cannot be empty. @21+2"], - ["function f< >() {}", "TS(1098) Type parameter list cannot be empty. @10+3"], - ["function f() {}", "TS(1098) Type parameter list cannot be empty. @10+6"], - ["let x: A< >;", "TS(1099) Type argument list cannot be empty. @8+3"], [ - "x;", - "TS(7059) This syntax is reserved in files with the .mts or .cts extension. Use an `as` expression instead. @0+4", + "new.target; a; await (x); import.meta;", + "Unexpected new.target expression @0+10 help: new.target is only allowed in constructors, functions, and class field initializers", ], + ["await; new.target; import.meta;", "Unexpected token @5+1"], + ["x = await; new.target; import.meta;", "Unexpected token @9+1"], [ - "() => 1;", - "TS(7060) This syntax is reserved in files with the .mts or .cts extension. Add a trailing comma or explicit constraint. @1+1", + "let await; new.target; import.meta;", + "Unexpected new.target expression @11+10 help: new.target is only allowed in constructors, functions, and class field initializers", ], + ["await: a; new.target; import.meta;", "Unexpected token @5+1"], [ - "() => 1;", - "TS(7060) This syntax is reserved in files with the .mts or .cts extension. Add a trailing comma or explicit constraint. @7+1", + "x = { await }; new.target; import.meta;", + "Unexpected new.target expression @15+10 help: new.target is only allowed in constructors, functions, and class field initializers", ], [ - "(a: T) => a;", - "TS(7060) This syntax is reserved in files with the .mts or .cts extension. Add a trailing comma or explicit constraint. @1+1", + "await (x); class A { m(): void; } import.meta;", + "TS(2391) Function implementation is missing or not immediately following the declaration. @21+1", ], + ["await (x); enum E { A = 'x', B } import.meta;", "Enum member must have initializer. @29+1"], [ - "async () => 1;", - "TS(7060) This syntax is reserved in files with the .mts or .cts extension. Add a trailing comma or explicit constraint. @7+1", + "let y: readonly number; await (x); import.meta;", + "TS(1354) 'readonly' type modifier is only permitted on array and tuple literal types. @7+8", ], [ - 'let x = ["a"];', - "TS(7059) This syntax is reserved in files with the .mts or .cts extension. Use an `as` expression instead. @8+12", + "type A = typeof await; new.target; import.meta;", + "Unexpected new.target expression @23+10 help: new.target is only allowed in constructors, functions, and class field initializers", ], - ["for (var a = 1 in {}) {}", "for-in loop variable declaration may not have an initializer @5+9"], - ["export = await;", "Unexpected token @14+1"], - ["static as any = 1;", "The keyword 'static' is reserved @0+6"], - ["namespace await {}", "The keyword 'await' is reserved @10+5"], - ["let x: A;", "The keyword 'await' is reserved @9+5"], - ["class A implements await {}", "The keyword 'await' is reserved @19+5"], - ], - "cts": [ - ["function f<>() {}", "TS(1098) Type parameter list cannot be empty. @10+2"], - ["export function f<>() {}", "TS(1098) Type parameter list cannot be empty. @17+2"], - ["declare function f<>(): void;", "TS(1098) Type parameter list cannot be empty. @18+2"], - ["(function <>() {});", "TS(1098) Type parameter list cannot be empty. @10+2"], - ["(function f<>() {});", "TS(1098) Type parameter list cannot be empty. @11+2"], - ["class A { m<>() {} }", "TS(1098) Type parameter list cannot be empty. @11+2"], - ["class A { static m<>() {} }", "TS(1098) Type parameter list cannot be empty. @18+2"], - ["class A { m<>(): void; m() {} }", "TS(1098) Type parameter list cannot be empty. @11+2"], - ["abstract class A { abstract m<>(): void }", "TS(1098) Type parameter list cannot be empty. @29+2"], - ["const o = { m<>() {} };", "TS(1098) Type parameter list cannot be empty. @13+2"], - ["class A<> {}", "TS(1098) Type parameter list cannot be empty. @7+2"], - ["(class <> {});", "TS(1098) Type parameter list cannot be empty. @7+2"], - ["(class A<> {});", "TS(1098) Type parameter list cannot be empty. @8+2"], - ["interface I<> {}", "TS(1098) Type parameter list cannot be empty. @11+2"], - ["type T<> = 1;", "TS(1098) Type parameter list cannot be empty. @6+2"], - ["interface I { <>(): void }", "TS(1098) Type parameter list cannot be empty. @14+2"], - ["interface I { new <>(): void }", "TS(1098) Type parameter list cannot be empty. @18+2"], - ["interface I { m<>(): void }", "TS(1098) Type parameter list cannot be empty. @15+2"], - ["type F = <>() => void;", "TS(1098) Type parameter list cannot be empty. @9+2"], - ["type F = new <>() => void;", "TS(1098) Type parameter list cannot be empty. @13+2"], - ["f<>();", "TS(1099) Type argument list cannot be empty. @1+2"], - ["new A<>();", "TS(1099) Type argument list cannot be empty. @5+2"], - ["new A<>;", "TS(1099) Type argument list cannot be empty. @5+2"], - ["f<>``;", "TS(1099) Type argument list cannot be empty. @1+2"], - ["let x = f<>;", "TS(1099) Type argument list cannot be empty. @9+2"], - ["let x: A<>;", "TS(1099) Type argument list cannot be empty. @8+2"], - ["let x: typeof f<>;", "TS(1099) Type argument list cannot be empty. @15+2"], - ['let x: import("a").B<>;', "TS(1099) Type argument list cannot be empty. @20+2"], - ["class A extends B<> {}", "TS(1099) Type argument list cannot be empty. @17+2"], - ["class A implements B<> {}", "TS(1099) Type argument list cannot be empty. @20+2"], - ["interface I extends B<> {}", "TS(1099) Type argument list cannot be empty. @21+2"], - ["function f< >() {}", "TS(1098) Type parameter list cannot be empty. @10+3"], - ["function f() {}", "TS(1098) Type parameter list cannot be empty. @10+6"], - ["let x: A< >;", "TS(1099) Type argument list cannot be empty. @8+3"], [ - "x;", - "TS(7059) This syntax is reserved in files with the .mts or .cts extension. Use an `as` expression instead. @0+4", + "let a: typeof await; new.target; import.meta;", + "Unexpected new.target expression @21+10 help: new.target is only allowed in constructors, functions, and class field initializers", ], [ - "() => 1;", - "TS(7060) This syntax is reserved in files with the .mts or .cts extension. Add a trailing comma or explicit constraint. @1+1", + "declare const a: typeof await; new.target; import.meta;", + "Unexpected new.target expression @31+10 help: new.target is only allowed in constructors, functions, and class field initializers", ], - ["await /k/;", "Unexpected token @9+1"], + ["namespace N { await (x); } new.target; import.meta;", "The keyword 'await' is reserved @14+5"], + ["enum E { A = await (x) } new.target; import.meta;", "The keyword 'await' is reserved @13+5"], [ - "await !k;", - "Expected a semicolon or an implicit semicolon after a statement, but found none @7+0 help: Try inserting a semicolon here", + "await (x); declare function f() {} import.meta;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @34+0 help: Try inserting a semicolon here", ], [ - "await k;", - "`await` is only allowed within async functions and at the top levels of modules @0+5 help: Either remove this `await` or add the `async` keyword to the enclosing function", + "await (x); import a = require('a'); new.target;", + "Unexpected new.target expression @36+10 help: new.target is only allowed in constructors, functions, and class field initializers", ], [ - "await (k); await k;", - "`await` is only allowed within async functions and at the top levels of modules @11+5 help: Either remove this `await` or add the `async` keyword to the enclosing function", + "await (x); declare module 'a' {} new.target;", + "Unexpected new.target expression @33+10 help: new.target is only allowed in constructors, functions, and class field initializers", ], [ - 'import a from "a"; await k;', - "`await` is only allowed within async functions and at the top levels of modules @19+5 help: Either remove this `await` or add the `async` keyword to the enclosing function", + "class A { a = new.target; } new.target; let zz; let zz;", + "Unexpected new.target expression @28+10 help: new.target is only allowed in constructors, functions, and class field initializers", ], [ - 'import a = require("a"); await k;', - "`await` is only allowed within async functions and at the top levels of modules @25+5 help: Either remove this `await` or add the `async` keyword to the enclosing function", + "class A { static { new.target; } } new.target; let zz; let zz;", + "Unexpected new.target expression @35+10 help: new.target is only allowed in constructors, functions, and class field initializers", ], [ - "export = a; await k;", - "`await` is only allowed within async functions and at the top levels of modules @12+5 help: Either remove this `await` or add the `async` keyword to the enclosing function", + "let y: readonly number; new.target; let zz; let zz;", + "TS(1354) 'readonly' type modifier is only permitted on array and tuple literal types. @7+8", + "Unexpected new.target expression @24+10 help: new.target is only allowed in constructors, functions, and class field initializers", ], - ['import a from "a"; let b = ;', "Unexpected token @27+1"], [ - "namespace N { await k; }", - "`await` is only allowed within async functions and at the top levels of modules @14+5 help: Either remove this `await` or add the `async` keyword to the enclosing function", + "function f(a?: number, b: number) {} new.target; let zz; let zz;", + "TS(1016) A required parameter cannot follow an optional parameter. @23+9", + "Unexpected new.target expression @37+10 help: new.target is only allowed in constructors, functions, and class field initializers", ], - ], - "js": [ [ - "let [...a,] = b;", - "A rest parameter or binding pattern may not have a trailing comma. @9+1 help: Remove the trailing comma here", + "class A { m(): void; } new.target; let zz; let zz;", + "Unexpected new.target expression @23+10 help: new.target is only allowed in constructors, functions, and class field initializers", ], [ - "[...a,] = b;", - "A rest parameter or binding pattern may not have a trailing comma. @5+1 help: Remove the trailing comma here", + "enum E { A = 'x', B } new.target; let zz; let zz;", + "Unexpected new.target expression @22+10 help: new.target is only allowed in constructors, functions, and class field initializers", ], [ - "switch (a) { default: default: }", - "TS(1113) A 'default' clause cannot appear more than once in a 'switch' statement. @13+8 \"First 'default' clause is here.\", 22+8 \"Another 'default' clause cannot appear here.\"", + "static function f() {} new.target; let zz; let zz;", + "'static' modifier cannot be used here. @0+6 help: Allowed modifiers are: declare, async", + "Unexpected new.target expression @23+10 help: new.target is only allowed in constructors, functions, and class field initializers", ], [ - "import.foo;", - "The only valid property accesses on import are `import.meta`, `import.source()`, and `import.defer()` @0+10", + "abstract class A { abstract m() {} } new.target; let zz; let zz;", + "TS(1245) Method 'm' cannot have an implementation because it is marked abstract. @28+1", + "Unexpected new.target expression @37+10 help: new.target is only allowed in constructors, functions, and class field initializers", ], [ - "a ?? b || c;", - "Logical expressions and coalesce expressions cannot be mixed @0+11 help: Wrap either expression by parentheses", + "interface A implements B {} new.target; let zz; let zz;", + "TS(1176) Interface declaration cannot have 'implements' clause. @12+10", + "Unexpected new.target expression @28+10 help: new.target is only allowed in constructors, functions, and class field initializers", ], [ - "a || b ?? c;", - "Logical expressions and coalesce expressions cannot be mixed @0+11 help: Wrap either expression by parentheses", + "class A { constructor(public a?, b) {} } new.target; let zz; let zz;", + "TS(1016) A required parameter cannot follow an optional parameter. @33+1", + "Unexpected new.target expression @41+10 help: new.target is only allowed in constructors, functions, and class field initializers", ], [ - "a && b ?? c;", - "Logical expressions and coalesce expressions cannot be mixed @0+11 help: Wrap either expression by parentheses", + "declare function f() {} new.target; let zz; let zz;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @23+0 help: Try inserting a semicolon here", ], [ - "a ?? b && c;", - "Logical expressions and coalesce expressions cannot be mixed @0+11 help: Wrap either expression by parentheses", + "let a?: number; new.target; let zz; let zz;", + "Optional declaration is not allowed here @5+1 help: Remove the `?`", + "Unexpected new.target expression @16+10 help: new.target is only allowed in constructors, functions, and class field initializers", ], [ - "a ?? b || c ?? d;", - "Logical expressions and coalesce expressions cannot be mixed @0+11 help: Wrap either expression by parentheses", + "let a!: number = 1; new.target; let zz; let zz;", + "TS(1263) Declarations with initializers cannot also have definite assignment assertions. @5+1", + "Unexpected new.target expression @20+10 help: new.target is only allowed in constructors, functions, and class field initializers", ], [ - "a || b ?? c || d;", - "Logical expressions and coalesce expressions cannot be mixed @0+16 help: Wrap either expression by parentheses", + "class A { declare a = 1; } new.target; let zz; let zz;", + "TS(1039) Initializers are not allowed in ambient contexts. @22+1", + "Unexpected new.target expression @27+10 help: new.target is only allowed in constructors, functions, and class field initializers", ], [ - "x = a ?? b || c;", - "Logical expressions and coalesce expressions cannot be mixed @4+11 help: Wrap either expression by parentheses", + "class A {} function f() {} new.target; let zz; let zz;", + "TS(1274) 'in' modifier can only appear on a type parameter of a class, interface or type alias. @28+2", + "Unexpected new.target expression @39+10 help: new.target is only allowed in constructors, functions, and class field initializers", ], [ - "a ?? (b) || c;", - "Logical expressions and coalesce expressions cannot be mixed @0+13 help: Wrap either expression by parentheses", + "function f(this) {} x = (this) => 1; new.target; let zz; let zz;", + "TS(2730) An arrow function cannot have a `this` parameter. @25+4 help: Arrow function does not bind `this` and inherits `this` from the outer scope", + "Unexpected new.target expression @37+10 help: new.target is only allowed in constructors, functions, and class field initializers", ], - ["a?.`x`;", "Tagged template expressions are not permitted in an optional chain @3+3"], - ["a?.b`x`;", "Tagged template expressions are not permitted in an optional chain @4+3"], - ["a?.b.c`x`;", "Tagged template expressions are not permitted in an optional chain @6+3"], - ["a?.()`x`;", "Tagged template expressions are not permitted in an optional chain @5+3"], - ["a?.[b]`x`;", "Tagged template expressions are not permitted in an optional chain @6+3"], - ["for (let a = 1 of []) {}", "for-of loop variable declaration may not have an initializer @5+9"], - ["for (var a = 1 of []) {}", "for-of loop variable declaration may not have an initializer @5+9"], - ["for (const a = 1 of []) {}", "for-of loop variable declaration may not have an initializer @5+11"], - ["for (let a = 1 in {}) {}", "for-in loop variable declaration may not have an initializer @5+9"], - ["for (const a = 1 in {}) {}", "for-in loop variable declaration may not have an initializer @5+11"], - ["for (var [a] = 1 in {}) {}", "for-in loop variable declaration may not have an initializer @5+11"], - ["for (var {a} = 1 in {}) {}", "for-in loop variable declaration may not have an initializer @5+11"], - ["'use strict'; for (var a = 1 in {}) {}", "for-in loop variable declaration may not have an initializer @19+9"], - ["export {}; for (var a = 1 in {}) {}", "for-in loop variable declaration may not have an initializer @16+9"], - ["for (let a, b of []) {}", "Only a single declaration is allowed in a `for...of` statement @5+8"], - ["for (using a = 1 of []) {}", "for-of loop variable declaration may not have an initializer @5+11"], - ["a\n=> 1;", "Line terminator not permitted before arrow @2+2 help: Remove the line break before here"], - ["(a, b)\n=> 1;", "Line terminator not permitted before arrow @7+2 help: Remove the line break before here"], - ["()\n=> 1;", "Line terminator not permitted before arrow @3+2 help: Remove the line break before here"], - ["async a\n=> 1;", "Line terminator not permitted before arrow @8+2 help: Remove the line break before here"], - ["async (a)\n=> 1;", "Line terminator not permitted before arrow @10+2 help: Remove the line break before here"], - ["(a) /*\n*/ => 1;", "Line terminator not permitted before arrow @10+2 help: Remove the line break before here"], - ["throw\n1;", 'Illegal newline after throw @0+5 "throw starts here", 6+1 "A newline is not expected here"'], [ - "throw /*\n*/ 1;", - 'Illegal newline after throw @0+5 "throw starts here", 12+1 "A newline is not expected here"', + "type A = { readonly m(): void }; new.target; let zz; let zz;", + "TS(1024) 'readonly' modifier can only appear on a property declaration or index signature. @11+8", + "Unexpected new.target expression @33+10 help: new.target is only allowed in constructors, functions, and class field initializers", ], - ["throw // c\n1;", 'Illegal newline after throw @0+5 "throw starts here", 11+1 "A newline is not expected here"'], - ["import();", "import() requires a specifier. @0+8"], - ["import(...a);", "TS(1325) Argument of dynamic import cannot be a spread element. @7+3"], - ['import("a", ...b);', "TS(1325) Argument of dynamic import cannot be a spread element. @12+3"], [ - 'import("a", "b", "c");', - "Dynamic imports can only accept a module specifier and an optional set of attributes as arguments @0+16", + "x = a; let y: readonly number; new.target; let zz; let zz;", + "TS(1354) 'readonly' type modifier is only permitted on array and tuple literal types. @17+8", + "Unexpected new.target expression @34+10 help: new.target is only allowed in constructors, functions, and class field initializers", ], - ['new import("a");', "Cannot use new with dynamic import @4+11 help: Wrap this with parenthesis"], - ['new import("a")();', "Cannot use new with dynamic import @4+13 help: Wrap this with parenthesis"], - ["import.defer();", "import() requires a specifier. @0+14"], [ - 'import.defer("a", "b", "c");', - "Dynamic imports can only accept a module specifier and an optional set of attributes as arguments @0+22", + "declare function f() {} a;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @23+0 help: Try inserting a semicolon here", ], + ["declare function f() {}", "TS(1183) An implementation cannot be declared in ambient contexts. @21+0"], + ["declare function f() {}\na;", "TS(1183) An implementation cannot be declared in ambient contexts. @21+0"], + ["declare function f() {};", "TS(1183) An implementation cannot be declared in ambient contexts. @21+0"], [ - "class A { static { return; } }", - "TS(18041) A 'return' statement cannot be used inside a class static block. @19+6", + "declare function f() {} declare function g() {}", + "Expected a semicolon or an implicit semicolon after a statement, but found none @23+0 help: Try inserting a semicolon here", ], [ - "class A { static { return 1; } }", - "TS(18041) A 'return' statement cannot be used inside a class static block. @19+6", + "declare namespace N { function f() {} a; }", + "TS(1183) An implementation cannot be declared in ambient contexts. @35+0", + "TS(1036) Statements are not allowed in ambient contexts. @38+2", ], [ - "class A { static { if (a) return; } }", - "TS(18041) A 'return' statement cannot be used inside a class static block. @26+6", + "declare namespace N { function f() {} }", + "TS(1183) An implementation cannot be declared in ambient contexts. @35+0", ], - ["`\\01`;", "Bad escape sequence in untagged template literal @1+3"], - ["`\\1`;", "Bad escape sequence in untagged template literal @1+2"], - ["`\\08`;", "Bad escape sequence in untagged template literal @1+3"], - ["`\\u`;", "Bad escape sequence in untagged template literal @1+2"], - ["`\\u{110000}`;", "Bad escape sequence in untagged template literal @1+10"], - ["`\\xg`;", "Bad escape sequence in untagged template literal @1+3"], - ["`\\u{}`;", "Bad escape sequence in untagged template literal @1+4"], - ["`${a}\\01`;", "Bad escape sequence in untagged template literal @5+3"], - ["`\\01${a}`;", "Bad escape sequence in untagged template literal @1+3"], [ - "for (async of []) {}", - "The left-hand side of a `for...of` statement may not be `async` @5+5 help: Did you mean to use a for await...of statement?", + "declare class A { m() {} n() {} }", + "TS(1183) An implementation cannot be declared in ambient contexts. @22+0", + "TS(1183) An implementation cannot be declared in ambient contexts. @29+0", ], [ - "for await (async of []) {}", - "`for await` loops are only allowed within async functions and at the top levels of modules @4+5 help: Either remove this `await` or add the `async` keyword to the enclosing function", + "export declare function f() {} a;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @30+0 help: Try inserting a semicolon here", ], - ["for (let of []) {}", 'Expected `;` but found `[` @12+1 "`;` expected"'], - ["for (let.a of []) {}", "The left-hand side of a `for...of` statement may not start with `let` @5+5"], - ['x = import("a");', "Unexpected token @10+1"], - ["for (let.a.b of []) {}", "The left-hand side of a `for...of` statement may not start with `let` @5+7"], - ["for (let().a of []) {}", "The left-hand side of a `for...of` statement may not start with `let` @5+7"], [ - "let zz; let zz; for (let.a of []) {}", - "The left-hand side of a `for...of` statement may not start with `let` @21+5", + "declare function f(): void {} a;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @29+0 help: Try inserting a semicolon here", ], [ - "class A { static { with (a) return; } }", - "TS(18041) A 'return' statement cannot be used inside a class static block. @28+6", + "declare async function f() {} a;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @29+0 help: Try inserting a semicolon here", ], [ - "class A { static { return; return; } }", - "TS(18041) A 'return' statement cannot be used inside a class static block. @19+6", - "TS(18041) A 'return' statement cannot be used inside a class static block. @27+6", + "declare function* f() {} a;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @24+0 help: Try inserting a semicolon here", ], [ - "function f() { throw\n}", - 'Illegal newline after throw @15+5 "throw starts here", 21+1 "A newline is not expected here"', - "Unexpected token @21+1", + "await (x); new.target; import a = require('a');", + "Unexpected new.target expression @11+10 help: new.target is only allowed in constructors, functions, and class field initializers", ], - ['import("a",,);', "Unexpected token @11+1"], [ - "let [a];", - "Missing initializer in destructuring declaration @4+3 help: Add an initializer (e.g. ` = undefined`) here", + "await (x); new.target; import type a = require('a');", + "Unexpected new.target expression @11+10 help: new.target is only allowed in constructors, functions, and class field initializers", ], [ - "let {a};", - "Missing initializer in destructuring declaration @4+3 help: Add an initializer (e.g. ` = undefined`) here", + "await (x); new.target; declare global {}", + "Unexpected new.target expression @11+10 help: new.target is only allowed in constructors, functions, and class field initializers", ], + ["await!; new.target; import.meta;", "Unexpected token @6+1"], [ - "const [a];", - "Missing initializer in destructuring declaration @6+3 help: Add an initializer (e.g. ` = undefined`) here", + "await as any; new.target; import.meta;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @8+0 help: Try inserting a semicolon here", ], [ - "var [a];", - "Missing initializer in destructuring declaration @4+3 help: Add an initializer (e.g. ` = undefined`) here", + "await satisfies any; new.target; import.meta;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @15+0 help: Try inserting a semicolon here", ], [ - "let [a], b;", - "Missing initializer in destructuring declaration @4+3 help: Add an initializer (e.g. ` = undefined`) here", + "let a = await as any; new.target; import.meta;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @16+0 help: Try inserting a semicolon here", ], [ - "let a, [b];", - "Missing initializer in destructuring declaration @7+3 help: Add an initializer (e.g. ` = undefined`) here", + "declare namespace N { a; } await (x); import.meta;", + "TS(1036) Statements are not allowed in ambient contexts. @22+2", ], [ - "for (let [a];;) {}", - "Missing initializer in destructuring declaration @9+3 help: Add an initializer (e.g. ` = undefined`) here", + "declare namespace N { await (x); } import.meta;", + "TS(1036) Statements are not allowed in ambient contexts. @22+10", ], [ - "export let [a];", - "Missing initializer in destructuring declaration @11+3 help: Add an initializer (e.g. ` = undefined`) here", + "declare namespace N { await (x); a; } import.meta;", + "TS(1036) Statements are not allowed in ambient contexts. @22+10", ], [ - "export @d default class {}", - "'default' modifier cannot be used here. @10+7 help: Allowed modifiers are: declare, abstract", + "declare namespace N { a; await (x); } import.meta;", + "TS(1036) Statements are not allowed in ambient contexts. @22+2", ], + ["await!; import.meta;", "Unexpected token @6+1"], [ - "export @d default class A {}", - "'default' modifier cannot be used here. @10+7 help: Allowed modifiers are: declare, abstract", + "await !x; import.meta;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @7+0 help: Try inserting a semicolon here", ], + ["await ![]; import.meta;", "Unexpected token @8+1"], [ - "@d export @e class A {}", - "Decorators may not appear after 'export' or 'export default' if they also appear before 'export'. @10+2", + "await !!x; import.meta;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @8+0 help: Try inserting a semicolon here", ], - ["import(...a, b);", "TS(1325) Argument of dynamic import cannot be a spread element. @7+3"], - ["import.defer(...a);", "TS(1325) Argument of dynamic import cannot be a spread element. @13+3"], [ - "let a: number = 1;", - "Expected a semicolon or an implicit semicolon after a statement, but found none @5+0 help: Try inserting a semicolon here", + "await !{}; import.meta;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @7+0 help: Try inserting a semicolon here", ], + ["await !.a; import.meta;", "Unexpected token @7+1"], + ["await!.a; import.meta;", "Unexpected token @6+1"], [ - "let a: number;", - "Expected a semicolon or an implicit semicolon after a statement, but found none @5+0 help: Try inserting a semicolon here", + "f(await !x); import.meta;", + 'Expected `,` or `)` but found `Identifier` @9+1 "`,` or `)` expected", 1+1 "Opened here"', ], + ["f(await!); import.meta;", "Unexpected token @8+1"], + ["f(await!, a); import.meta;", "Unexpected token @8+1"], [ - "const a: number = 1;", - "Missing initializer in const declaration @6+1 help: Add an initializer (e.g. ` = undefined`) here", - "Expected a semicolon or an implicit semicolon after a statement, but found none @7+0 help: Try inserting a semicolon here", + "await ! as any; import.meta;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @10+0 help: Try inserting a semicolon here", ], + ["await /k/; import.meta;", "Unexpected token @9+1"], + ["await /k/.test(a); import.meta;", "Unexpected token @9+1"], + ["await /=k/; import.meta;", "Unexpected token @10+1"], [ - "var a: string, b;", - "Expected a semicolon or an implicit semicolon after a statement, but found none @5+0 help: Try inserting a semicolon here", + "await (x) => 1; import.meta;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @12+0 help: Try inserting a semicolon here", ], [ - "let [a]: number[] = [];", - "Missing initializer in destructuring declaration @4+3 help: Add an initializer (e.g. ` = undefined`) here", - "Expected a semicolon or an implicit semicolon after a statement, but found none @7+0 help: Try inserting a semicolon here", + "await x y; import.meta;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @10+0 help: Try inserting a semicolon here", ], + ["f(await /k/); import.meta;", "Unexpected token @11+1"], + ["await /k/;", "Unexpected token @9+1"], [ - "let {a}: T = b;", - "Missing initializer in destructuring declaration @4+3 help: Add an initializer (e.g. ` = undefined`) here", - "Expected a semicolon or an implicit semicolon after a statement, but found none @7+0 help: Try inserting a semicolon here", + "function () {} let y: readonly number;", + "TS(1354) 'readonly' type modifier is only permitted on array and tuple literal types. @25+8", + ], + ["declare function ;", 'Expected `(` but found `;` @20+1 "`(` expected"'], + ["declare function ()\n{}", "TS(1183) An implementation cannot be declared in ambient contexts. @20+0"], + ["d\\u0065clare function f();", "Keywords cannot contain escape characters @0+12"], + ["t\\u0079pe A = 1;", "Keywords cannot contain escape characters @0+9"], + ["int\\u0065rface A {}", "Keywords cannot contain escape characters @0+14"], + ["n\\u0061mespace A {}", "Keywords cannot contain escape characters @0+14"], + ["\\u0061bstract class A {}", "Keywords cannot contain escape characters @0+13"], + ["class A { r\\u0065adonly a; }", "Keywords cannot contain escape characters @10+13"], + ["class A { p\\u0075blic a; }", "Keywords cannot contain escape characters @10+11"], + ["let a = b \\u0061s c;", "Keywords cannot contain escape characters @10+7"], + ["let a: k\\u0065yof b;", "Keywords cannot contain escape characters @7+10"], + ], + "d.ts": [ + ["const a: number = 1;", "TS(1039) Initializers are not allowed in ambient contexts. @18+1"], + ["let a = 1;", "TS(1039) Initializers are not allowed in ambient contexts. @8+1"], + ['export const a = 1, b: string = "x";', "TS(1039) Initializers are not allowed in ambient contexts. @32+3"], + ["a;", "TS(1036) Statements are not allowed in ambient contexts. @0+2"], + [";", "TS(1036) Statements are not allowed in ambient contexts. @0+1"], + ["{}", "TS(1036) Statements are not allowed in ambient contexts. @0+2"], + ["if (1) {}", "TS(1036) Statements are not allowed in ambient contexts. @0+9"], + ["label: a;", "TS(1036) Statements are not allowed in ambient contexts. @0+9"], + ["debugger;", "TS(1036) Statements are not allowed in ambient contexts. @0+9"], + [ + "declare namespace N { const a = 1; let b = 2; function f() {} ; class C { m() {} x = 1 } }", + "TS(1039) Initializers are not allowed in ambient contexts. @43+1", + "TS(1183) An implementation cannot be declared in ambient contexts. @59+0", + "TS(1036) Statements are not allowed in ambient contexts. @62+1", + "TS(1183) An implementation cannot be declared in ambient contexts. @78+0", + "TS(1039) Initializers are not allowed in ambient contexts. @85+1", ], + ["namespace N { a; }", "TS(1036) Statements are not allowed in ambient contexts. @14+2"], [ - "let a!: number;", - "Expected a semicolon or an implicit semicolon after a statement, but found none @5+0 help: Try inserting a semicolon here", + 'class A { x = 1; readonly y = 1; readonly z: number = 1; static s = 1; static readonly t = "a"; declare u = 1 }', + "TS(1039) Initializers are not allowed in ambient contexts. @14+1", + "TS(1039) Initializers are not allowed in ambient contexts. @54+1", + "TS(1039) Initializers are not allowed in ambient contexts. @68+1", + "TS(1039) Initializers are not allowed in ambient contexts. @108+1", ], - ["function f(a: number) {}", 'Expected `,` or `)` but found `:` @12+1 "`,` or `)` expected", 10+1 "Opened here"'], + ["using a: any;", "TS(1545) 'using' declarations are not allowed in ambient contexts. @6+1"], + ["for (;;) {}", "TS(1036) Statements are not allowed in ambient contexts. @0+11"], + ['"use strict"; a;', "TS(1036) Statements are not allowed in ambient contexts. @14+2"], + ["let a!: number;", "TS(1255) A definite assignment assertion '!' is not permitted in this context. @5+1"], [ - "function f(a?: number) {}", - 'Expected `,` or `)` but found `?` @12+1 "`,` or `)` expected", 10+1 "Opened here"', + "class A { set a(v = 1); set b(v): void; set c(v?) }", + "A 'set' accessor cannot have an initializer. @15+7", + "TS(1095) A 'set' accessor cannot have a return type annotation. @32+6", + "TS(1051) A 'set' accessor cannot have an optional parameter. @46+2", ], - ["function f(a?) {}", 'Expected `,` or `)` but found `?` @12+1 "`,` or `)` expected", 10+1 "Opened here"'], - ["function f(a, b?) {}", 'Expected `,` or `)` but found `?` @15+1 "`,` or `)` expected", 10+1 "Opened here"'], - ["function f(): void {}", "Expected function body @0+12 help: Add a function body (`{}`)."], - ["function f() {}", 'Expected `(` but found `<` @10+1 "`(` expected"'], - ["function f(a: T): T { return a; }", 'Expected `(` but found `<` @10+1 "`(` expected"'], - ["function f(this: T) {}", "Identifier expected. 'this' is a reserved word that cannot be used here. @11+4"], - ["function f(this) {}", "Identifier expected. 'this' is a reserved word that cannot be used here. @11+4"], + ['declare module "m" { namespace N { a; } }', "TS(1036) Statements are not allowed in ambient contexts. @35+2"], + ["interface A {};", "TS(1036) Statements are not allowed in ambient contexts. @14+1"], + ["declare function f(): void;;", "TS(1036) Statements are not allowed in ambient contexts. @27+1"], [ - "function f(...a: number[]) {}", - 'Expected `,` or `)` but found `:` @15+1 "`,` or `)` expected", 10+1 "Opened here"', + "namespace N { module 'a' {} }", + "TS(2435) Ambient modules cannot be nested in other modules or namespaces. @14+13", ], [ - "function f(a: number = 1) {}", - 'Expected `,` or `)` but found `:` @12+1 "`,` or `)` expected", 10+1 "Opened here"', + "function f<>() {}", + "TS(1098) Type parameter list cannot be empty. @10+2", + "TS(1183) An implementation cannot be declared in ambient contexts. @15+0", ], - ["function f({a}: T) {}", 'Expected `,` or `)` but found `:` @14+1 "`,` or `)` expected", 10+1 "Opened here"'], - ["function f();", "Expected function body @0+12 help: Add a function body (`{}`)."], - ["function f(); function f() {}", "Expected function body @0+12 help: Add a function body (`{}`)."], [ - "function f(private a) {}", - "TS(8012) Parameter modifiers can only be used in TypeScript files. @11+7 help: No modifiers are allowed here.", + "export function f<>() {}", + "TS(1098) Type parameter list cannot be empty. @17+2", + "TS(1183) An implementation cannot be declared in ambient contexts. @22+0", ], + ["declare function f<>(): void;", "TS(1098) Type parameter list cannot be empty. @18+2"], [ - "function f(readonly a) {}", - "TS(8012) Parameter modifiers can only be used in TypeScript files. @11+8 help: No modifiers are allowed here.", + "(function <>() {});", + "TS(1098) Type parameter list cannot be empty. @10+2", + "TS(1036) Statements are not allowed in ambient contexts. @0+19", ], - ["(a: number) => a;", 'Expected `,` or `)` but found `:` @2+1 "`,` or `)` expected", 0+1 "Opened here"'], - ["(a?: number) => a;", 'Expected `,` or `)` but found `?` @2+1 "`,` or `)` expected", 0+1 "Opened here"'], - ["(a?) => a;", 'Expected `,` or `)` but found `?` @2+1 "`,` or `)` expected", 0+1 "Opened here"'], [ - "(a): number => a;", - "Expected a semicolon or an implicit semicolon after a statement, but found none @3+0 help: Try inserting a semicolon here", + "(function f<>() {});", + "TS(1098) Type parameter list cannot be empty. @11+2", + "TS(1036) Statements are not allowed in ambient contexts. @0+20", ], - ["(a) => a;", 'Expected `(` but found `<` @0+1 "`(` expected"'], [ - "async (a) => a;", - "Expected a semicolon or an implicit semicolon after a statement, but found none @12+0 help: Try inserting a semicolon here", + "class A { m<>() {} }", + "TS(1098) Type parameter list cannot be empty. @11+2", + "TS(1183) An implementation cannot be declared in ambient contexts. @16+0", ], - ["async (a: number) => a;", 'Expected `,` or `)` but found `:` @8+1 "`,` or `)` expected", 6+1 "Opened here"'], [ - "async (a): number => a;", - "Expected a semicolon or an implicit semicolon after a statement, but found none @9+0 help: Try inserting a semicolon here", + "class A { static m<>() {} }", + "TS(1098) Type parameter list cannot be empty. @18+2", + "TS(1183) An implementation cannot be declared in ambient contexts. @23+0", ], [ - "let f = function (a: number) {};", - 'Expected `,` or `)` but found `:` @19+1 "`,` or `)` expected", 17+1 "Opened here"', + "class A { m<>(): void; m() {} }", + "TS(1098) Type parameter list cannot be empty. @11+2", + "TS(1183) An implementation cannot be declared in ambient contexts. @27+0", ], - ["let f = function () {};", 'Expected `(` but found `<` @17+1 "`(` expected"'], - ["let f = function (): void {};", 'Expected `{` but found `:` @19+1 "`{` expected"'], + ["abstract class A { abstract m<>(): void }", "TS(1098) Type parameter list cannot be empty. @29+2"], + ["const o = { m<>() {} };", "TS(1098) Type parameter list cannot be empty. @13+2"], + ["class A<> {}", "TS(1098) Type parameter list cannot be empty. @7+2"], [ - "interface A {}", - "Expected a semicolon or an implicit semicolon after a statement, but found none @9+0 help: Try inserting a semicolon here", + "(class <> {});", + "TS(1098) Type parameter list cannot be empty. @7+2", + "TS(1036) Statements are not allowed in ambient contexts. @0+14", ], [ - "interface A { a: number }", - "Expected a semicolon or an implicit semicolon after a statement, but found none @9+0 help: Try inserting a semicolon here", + "(class A<> {});", + "TS(1098) Type parameter list cannot be empty. @8+2", + "TS(1036) Statements are not allowed in ambient contexts. @0+15", ], + ["interface I<> {}", "TS(1098) Type parameter list cannot be empty. @11+2"], + ["type T<> = 1;", "TS(1098) Type parameter list cannot be empty. @6+2"], + ["interface I { <>(): void }", "TS(1098) Type parameter list cannot be empty. @14+2"], + ["interface I { new <>(): void }", "TS(1098) Type parameter list cannot be empty. @18+2"], + ["interface I { m<>(): void }", "TS(1098) Type parameter list cannot be empty. @15+2"], + ["type F = <>() => void;", "TS(1098) Type parameter list cannot be empty. @9+2"], + ["type F = new <>() => void;", "TS(1098) Type parameter list cannot be empty. @13+2"], [ - "interface A extends B {}", - "Expected a semicolon or an implicit semicolon after a statement, but found none @9+0 help: Try inserting a semicolon here", + "f<>();", + "TS(1099) Type argument list cannot be empty. @1+2", + "TS(1036) Statements are not allowed in ambient contexts. @0+6", ], [ - "interface A {}", - "Expected a semicolon or an implicit semicolon after a statement, but found none @9+0 help: Try inserting a semicolon here", + "new A<>();", + "TS(1099) Type argument list cannot be empty. @5+2", + "TS(1036) Statements are not allowed in ambient contexts. @0+10", ], - ["export interface A {}", "Unexpected token @7+9"], [ - "interface\nA {}", - "Expected a semicolon or an implicit semicolon after a statement, but found none @11+0 help: Try inserting a semicolon here", + "new A<>;", + "TS(1099) Type argument list cannot be empty. @5+2", + "TS(1036) Statements are not allowed in ambient contexts. @0+8", ], [ - "type A = number;", - "Expected a semicolon or an implicit semicolon after a statement, but found none @4+0 help: Try inserting a semicolon here", + "f<>``;", + "TS(1099) Type argument list cannot be empty. @1+2", + "TS(1036) Statements are not allowed in ambient contexts. @0+6", ], [ - "type A = T;", - "Expected a semicolon or an implicit semicolon after a statement, but found none @4+0 help: Try inserting a semicolon here", + "let x = f<>;", + "TS(1099) Type argument list cannot be empty. @9+2", + "TS(1039) Initializers are not allowed in ambient contexts. @8+3", ], - ["export type A = number;", "Unexpected token @7+4"], - ["enum A {}", "Unexpected token @0+4"], - ["enum A { a, b }", "Unexpected token @0+4"], - ["const enum A {}", "Identifier expected. 'enum' is a reserved word that cannot be used here. @6+4"], - ["export enum A {}", "Unexpected token @7+4"], - ["export const enum A {}", "Identifier expected. 'enum' is a reserved word that cannot be used here. @13+4"], + ["let x: A<>;", "TS(1099) Type argument list cannot be empty. @8+2"], + ["let x: typeof f<>;", "TS(1099) Type argument list cannot be empty. @15+2"], + ['let x: import("a").B<>;', "TS(1099) Type argument list cannot be empty. @20+2"], + ["class A extends B<> {}", "TS(1099) Type argument list cannot be empty. @17+2"], + ["class A implements B<> {}", "TS(1099) Type argument list cannot be empty. @20+2"], + ["interface I extends B<> {}", "TS(1099) Type argument list cannot be empty. @21+2"], [ - "declare enum A {}", - "Expected a semicolon or an implicit semicolon after a statement, but found none @7+0 help: Try inserting a semicolon here", + "function f< >() {}", + "TS(1098) Type parameter list cannot be empty. @10+3", + "TS(1183) An implementation cannot be declared in ambient contexts. @16+0", ], - ["enum;", "Unexpected token @0+4"], - ["a as b;", "TS(8016) Type assertion expressions can only be used in TypeScript files. @0+6"], - ["a as const;", "TS(8016) Type assertion expressions can only be used in TypeScript files. @0+10"], [ - "a as any as b;", - "TS(8016) Type assertion expressions can only be used in TypeScript files. @0+8", - "TS(8016) Type assertion expressions can only be used in TypeScript files. @0+13", + "function f() {}", + "TS(1098) Type parameter list cannot be empty. @10+6", + "TS(1183) An implementation cannot be declared in ambient contexts. @19+0", ], - ["(a as b);", "TS(8016) Type assertion expressions can only be used in TypeScript files. @1+6"], - ["let x = a as b;", "TS(8016) Type assertion expressions can only be used in TypeScript files. @8+6"], - ["f(a as b);", "TS(8016) Type assertion expressions can only be used in TypeScript files. @2+6"], - ["[a as b];", "TS(8016) Type assertion expressions can only be used in TypeScript files. @1+6"], - ["a satisfies b;", "TS(8037) Type satisfaction expressions can only be used in TypeScript files. @0+13"], - ["let x = a satisfies b;", "TS(8037) Type satisfaction expressions can only be used in TypeScript files. @8+13"], - ["f(a satisfies b);", "TS(8037) Type satisfaction expressions can only be used in TypeScript files. @2+13"], + ["let x: A< >;", "TS(1099) Type argument list cannot be empty. @8+3"], [ - "a!;", - "Expected a semicolon or an implicit semicolon after a statement, but found none @1+0 help: Try inserting a semicolon here", + "for (var a = 1 in {}) {}", + "TS(1039) Initializers are not allowed in ambient contexts. @13+1", + "TS(1036) Statements are not allowed in ambient contexts. @0+24", ], + ["export = await;", "Unexpected token @14+1"], [ - "a!.b;", - "Expected a semicolon or an implicit semicolon after a statement, but found none @1+0 help: Try inserting a semicolon here", + "declare module 'a' { export = a; export const b: number; }", + "TS(2309) An export assignment cannot be used in a module with other exported elements @21+11 help: If you want to use `export =`, remove other `export`s and put all of them to the right hand value of `export =`. If you want to use `export`s, remove `export =` statement.", ], [ - "a!();", - "Expected a semicolon or an implicit semicolon after a statement, but found none @1+0 help: Try inserting a semicolon here", + "'use strict'; await as any = 1;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @22+0 help: Try inserting a semicolon here", ], [ - "a![0];", + "a b;", "Expected a semicolon or an implicit semicolon after a statement, but found none @1+0 help: Try inserting a semicolon here", ], [ - "let x = a!;", - "Expected a semicolon or an implicit semicolon after a statement, but found none @9+0 help: Try inserting a semicolon here", + "a; b c;", + "TS(1036) Statements are not allowed in ambient contexts. @0+2", + "Expected a semicolon or an implicit semicolon after a statement, but found none @4+0 help: Try inserting a semicolon here", ], - ["f();", "Empty parenthesized expression @4+2"], - ["new F();", "Empty parenthesized expression @8+2"], - ["new F;", "Unexpected token @8+1"], - ["f;", "Unexpected token @4+1"], - ["a.b();", "Empty parenthesized expression @6+2"], - ["a?.b();", "Empty parenthesized expression @7+2"], - ["a?.();", "Identifier expected after '?.' @1+2 help: Add an identifier after '?.'"], [ - "declare let a;", - "Expected a semicolon or an implicit semicolon after a statement, but found none @7+0 help: Try inserting a semicolon here", + "{} b c;", + "TS(1036) Statements are not allowed in ambient contexts. @0+2", + "Expected a semicolon or an implicit semicolon after a statement, but found none @4+0 help: Try inserting a semicolon here", ], [ - "declare const a;", - "Expected a semicolon or an implicit semicolon after a statement, but found none @7+0 help: Try inserting a semicolon here", + "a\nb c;", + "TS(1036) Statements are not allowed in ambient contexts. @0+1", + "Expected a semicolon or an implicit semicolon after a statement, but found none @3+0 help: Try inserting a semicolon here", ], [ - "declare var a;", - "Expected a semicolon or an implicit semicolon after a statement, but found none @7+0 help: Try inserting a semicolon here", + "if (a) b; c d;", + "TS(1036) Statements are not allowed in ambient contexts. @0+9", + "Expected a semicolon or an implicit semicolon after a statement, but found none @11+0 help: Try inserting a semicolon here", ], [ - "declare function f();", + "a; b; c d;", + "TS(1036) Statements are not allowed in ambient contexts. @0+2", "Expected a semicolon or an implicit semicolon after a statement, but found none @7+0 help: Try inserting a semicolon here", ], + ["a; b;", "TS(1036) Statements are not allowed in ambient contexts. @0+2"], + ["a; b; c;", "TS(1036) Statements are not allowed in ambient contexts. @0+2"], + ["a; {} if (x) y;", "TS(1036) Statements are not allowed in ambient contexts. @0+2"], + ["a; declare let z: 1; b;", "TS(1036) Statements are not allowed in ambient contexts. @0+2"], [ - "declare function f(): void;", - "Expected a semicolon or an implicit semicolon after a statement, but found none @7+0 help: Try inserting a semicolon here", + "namespace N { a; b; } c; d;", + "TS(1036) Statements are not allowed in ambient contexts. @14+2", + "TS(1036) Statements are not allowed in ambient contexts. @22+2", ], [ - "declare class A {}", - "Expected a semicolon or an implicit semicolon after a statement, but found none @7+0 help: Try inserting a semicolon here", + "a; b; c; d e;", + "TS(1036) Statements are not allowed in ambient contexts. @0+2", + "Expected a semicolon or an implicit semicolon after a statement, but found none @10+0 help: Try inserting a semicolon here", ], + ["(await as any) = 1;", 'Expected `,` or `)` but found `any` @10+3 "`,` or `)` expected", 0+1 "Opened here"'], [ - 'declare module "a" {}', - "Expected a semicolon or an implicit semicolon after a statement, but found none @7+0 help: Try inserting a semicolon here", + "(yield as any)++;", + "A 'yield' expression is only allowed in a generator body. @1+5 help: Either remove this `yield` or change the enclosing function to a generator function (`function*`)", + 'Expected `,` or `)` but found `any` @10+3 "`,` or `)` expected", 0+1 "Opened here"', + ], + ["f(a b);", 'Expected `,` or `)` but found `Identifier` @4+1 "`,` or `)` expected", 1+1 "Opened here"'], + ["[a b];", 'Expected `,` or `]` but found `Identifier` @3+1 "`,` or `]` expected", 0+1 "Opened here"'], + ["a ? b;", 'Expected `:` but found `;` @5+1 "`:` expected", 2+1 "Conditional starts here"'], + [ + "await !x;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @7+0 help: Try inserting a semicolon here", + ], + [ + "function ();", + "Expected function name @9+1 help: Function name is required in function declaration or named export", + ], + [ + "export function ();", + "Expected function name @16+1 help: Function name is required in function declaration or named export", + ], + [ + "declare const a: 1; let y: readonly number; export {};", + "TS(1354) 'readonly' type modifier is only permitted on array and tuple literal types. @27+8", + ], + ["await (x);", "TS(1036) Statements are not allowed in ambient contexts. @0+10"], + ["a; import.meta;", "TS(1036) Statements are not allowed in ambient contexts. @0+2"], + ["a; export {};", "TS(1036) Statements are not allowed in ambient contexts. @0+2"], + ["a; await (x); import.meta;", "TS(1036) Statements are not allowed in ambient contexts. @0+2"], + ["await x;", "TS(1036) Statements are not allowed in ambient contexts. @0+8"], + ["await x; a;", "TS(1036) Statements are not allowed in ambient contexts. @0+8"], + [ + "let y: readonly number; await (x); export {};", + "TS(1354) 'readonly' type modifier is only permitted on array and tuple literal types. @7+8", + ], + [ + "declare const a: typeof await; let y: readonly number; export {};", + "TS(1354) 'readonly' type modifier is only permitted on array and tuple literal types. @38+8", + ], + ["function f() { new.target; }", "TS(1183) An implementation cannot be declared in ambient contexts. @13+0"], + ], + "tsx": [ + ["function f<>() {}", "TS(1098) Type parameter list cannot be empty. @10+2"], + ["export function f<>() {}", "TS(1098) Type parameter list cannot be empty. @17+2"], + ["declare function f<>(): void;", "TS(1098) Type parameter list cannot be empty. @18+2"], + ["(function <>() {});", "TS(1098) Type parameter list cannot be empty. @10+2"], + ["(function f<>() {});", "TS(1098) Type parameter list cannot be empty. @11+2"], + ["class A { m<>() {} }", "TS(1098) Type parameter list cannot be empty. @11+2"], + ["class A { static m<>() {} }", "TS(1098) Type parameter list cannot be empty. @18+2"], + ["class A { m<>(): void; m() {} }", "TS(1098) Type parameter list cannot be empty. @11+2"], + ["abstract class A { abstract m<>(): void }", "TS(1098) Type parameter list cannot be empty. @29+2"], + ["const o = { m<>() {} };", "TS(1098) Type parameter list cannot be empty. @13+2"], + ["class A<> {}", "TS(1098) Type parameter list cannot be empty. @7+2"], + ["(class <> {});", "TS(1098) Type parameter list cannot be empty. @7+2"], + ["(class A<> {});", "TS(1098) Type parameter list cannot be empty. @8+2"], + ["interface I<> {}", "TS(1098) Type parameter list cannot be empty. @11+2"], + ["type T<> = 1;", "TS(1098) Type parameter list cannot be empty. @6+2"], + ["interface I { <>(): void }", "TS(1098) Type parameter list cannot be empty. @14+2"], + ["interface I { new <>(): void }", "TS(1098) Type parameter list cannot be empty. @18+2"], + ["interface I { m<>(): void }", "TS(1098) Type parameter list cannot be empty. @15+2"], + ["type F = <>() => void;", "TS(1098) Type parameter list cannot be empty. @9+2"], + ["type F = new <>() => void;", "TS(1098) Type parameter list cannot be empty. @13+2"], + ["f<>();", "TS(1099) Type argument list cannot be empty. @1+2"], + ["new A<>();", "TS(1099) Type argument list cannot be empty. @5+2"], + ["new A<>;", "TS(1099) Type argument list cannot be empty. @5+2"], + ["f<>``;", "TS(1099) Type argument list cannot be empty. @1+2"], + ["let x = f<>;", "TS(1099) Type argument list cannot be empty. @9+2"], + ["let x: A<>;", "TS(1099) Type argument list cannot be empty. @8+2"], + ["let x: typeof f<>;", "TS(1099) Type argument list cannot be empty. @15+2"], + ['let x: import("a").B<>;', "TS(1099) Type argument list cannot be empty. @20+2"], + ["class A extends B<> {}", "TS(1099) Type argument list cannot be empty. @17+2"], + ["class A implements B<> {}", "TS(1099) Type argument list cannot be empty. @20+2"], + ["interface I extends B<> {}", "TS(1099) Type argument list cannot be empty. @21+2"], + ["function f< >() {}", "TS(1098) Type parameter list cannot be empty. @10+3"], + ["function f() {}", "TS(1098) Type parameter list cannot be empty. @10+6"], + ["let x: A< >;", "TS(1099) Type argument list cannot be empty. @8+3"], + ["a;", "Unexpected token @3+3"], + ["(a) => a;", "Unexpected token. Did you mean `{'>'}` or `>`? @8+0"], + ["a}b;", "Unexpected token. Did you mean `{'}'}` or `}`? @4+0"], + [";", "Identifiers in JSX cannot contain hyphens @3+3 help: Remove the hyphen from the identifier"], + [ + "; let y: readonly number;", + "Identifiers in JSX cannot contain hyphens @3+3 help: Remove the hyphen from the identifier", + ], + [ + "let zz; let zz; ;", + "Identifiers in JSX cannot contain hyphens @19+3 help: Remove the hyphen from the identifier", + ], + [";", "Identifiers in JSX cannot contain hyphens @5+3 help: Remove the hyphen from the identifier"], + [ + ";", + "Identifiers in JSX cannot contain hyphens @3+3 help: Remove the hyphen from the identifier", + ], + [";", "Identifiers in JSX cannot contain hyphens @3+3 help: Remove the hyphen from the identifier"], + [";", "Identifiers in JSX cannot contain hyphens @3+2 help: Remove the hyphen from the identifier"], + [";", "Unexpected token @4+1"], + [";", "Unexpected token @6+1"], + [";", "TS(17000) JSX attributes must only be assigned a non-empty 'expression'. @5+2"], + [ + "; let y: readonly number;", + "TS(17000) JSX attributes must only be assigned a non-empty 'expression'. @5+2", + "TS(1354) 'readonly' type modifier is only permitted on array and tuple literal types. @19+8", + ], + [";", "TS(17000) JSX attributes must only be assigned a non-empty 'expression'. @5+9"], + [ + ";", + "TS(17000) JSX attributes must only be assigned a non-empty 'expression'. @5+2", + "TS(17000) JSX attributes must only be assigned a non-empty 'expression'. @10+2", + ], + ["let zz; let zz; ;", "TS(17000) JSX attributes must only be assigned a non-empty 'expression'. @21+2"], + ["< />;", "Unexpected token @2+1"], + ["await ; import.meta;", "Unexpected token @10+1"], + ["await b; import.meta;", "Unterminated regular expression @11+18", "Unexpected token @11+18"], + [ + "await (x) => 1; import.meta;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @13+0 help: Try inserting a semicolon here", + ], + ], + "mts": [ + ["function f<>() {}", "TS(1098) Type parameter list cannot be empty. @10+2"], + ["export function f<>() {}", "TS(1098) Type parameter list cannot be empty. @17+2"], + ["declare function f<>(): void;", "TS(1098) Type parameter list cannot be empty. @18+2"], + ["(function <>() {});", "TS(1098) Type parameter list cannot be empty. @10+2"], + ["(function f<>() {});", "TS(1098) Type parameter list cannot be empty. @11+2"], + ["class A { m<>() {} }", "TS(1098) Type parameter list cannot be empty. @11+2"], + ["class A { static m<>() {} }", "TS(1098) Type parameter list cannot be empty. @18+2"], + ["class A { m<>(): void; m() {} }", "TS(1098) Type parameter list cannot be empty. @11+2"], + ["abstract class A { abstract m<>(): void }", "TS(1098) Type parameter list cannot be empty. @29+2"], + ["const o = { m<>() {} };", "TS(1098) Type parameter list cannot be empty. @13+2"], + ["class A<> {}", "TS(1098) Type parameter list cannot be empty. @7+2"], + ["(class <> {});", "TS(1098) Type parameter list cannot be empty. @7+2"], + ["(class A<> {});", "TS(1098) Type parameter list cannot be empty. @8+2"], + ["interface I<> {}", "TS(1098) Type parameter list cannot be empty. @11+2"], + ["type T<> = 1;", "TS(1098) Type parameter list cannot be empty. @6+2"], + ["interface I { <>(): void }", "TS(1098) Type parameter list cannot be empty. @14+2"], + ["interface I { new <>(): void }", "TS(1098) Type parameter list cannot be empty. @18+2"], + ["interface I { m<>(): void }", "TS(1098) Type parameter list cannot be empty. @15+2"], + ["type F = <>() => void;", "TS(1098) Type parameter list cannot be empty. @9+2"], + ["type F = new <>() => void;", "TS(1098) Type parameter list cannot be empty. @13+2"], + ["f<>();", "TS(1099) Type argument list cannot be empty. @1+2"], + ["new A<>();", "TS(1099) Type argument list cannot be empty. @5+2"], + ["new A<>;", "TS(1099) Type argument list cannot be empty. @5+2"], + ["f<>``;", "TS(1099) Type argument list cannot be empty. @1+2"], + ["let x = f<>;", "TS(1099) Type argument list cannot be empty. @9+2"], + ["let x: A<>;", "TS(1099) Type argument list cannot be empty. @8+2"], + ["let x: typeof f<>;", "TS(1099) Type argument list cannot be empty. @15+2"], + ['let x: import("a").B<>;', "TS(1099) Type argument list cannot be empty. @20+2"], + ["class A extends B<> {}", "TS(1099) Type argument list cannot be empty. @17+2"], + ["class A implements B<> {}", "TS(1099) Type argument list cannot be empty. @20+2"], + ["interface I extends B<> {}", "TS(1099) Type argument list cannot be empty. @21+2"], + ["function f< >() {}", "TS(1098) Type parameter list cannot be empty. @10+3"], + ["function f() {}", "TS(1098) Type parameter list cannot be empty. @10+6"], + ["let x: A< >;", "TS(1099) Type argument list cannot be empty. @8+3"], + [ + "x;", + "TS(7059) This syntax is reserved in files with the .mts or .cts extension. Use an `as` expression instead. @0+4", + ], + [ + "() => 1;", + "TS(7060) This syntax is reserved in files with the .mts or .cts extension. Add a trailing comma or explicit constraint. @1+1", + ], + [ + "() => 1;", + "TS(7060) This syntax is reserved in files with the .mts or .cts extension. Add a trailing comma or explicit constraint. @7+1", + ], + [ + "(a: T) => a;", + "TS(7060) This syntax is reserved in files with the .mts or .cts extension. Add a trailing comma or explicit constraint. @1+1", + ], + [ + "async () => 1;", + "TS(7060) This syntax is reserved in files with the .mts or .cts extension. Add a trailing comma or explicit constraint. @7+1", + ], + [ + 'let x = ["a"];', + "TS(7059) This syntax is reserved in files with the .mts or .cts extension. Use an `as` expression instead. @8+12", + ], + ["for (var a = 1 in {}) {}", "for-in loop variable declaration may not have an initializer @5+9"], + ["export = await;", "Unexpected token @14+1"], + ["static as any = 1;", "The keyword 'static' is reserved @0+6"], + ["namespace await {}", "The keyword 'await' is reserved @10+5"], + ["let x: A;", "The keyword 'await' is reserved @9+5"], + ["class A implements await {}", "The keyword 'await' is reserved @19+5"], + [ + "await (x); new.target; import.meta;", + "Unexpected new.target expression @11+10 help: new.target is only allowed in constructors, functions, and class field initializers", + ], + ], + "cts": [ + ["function f<>() {}", "TS(1098) Type parameter list cannot be empty. @10+2"], + ["export function f<>() {}", "TS(1098) Type parameter list cannot be empty. @17+2"], + ["declare function f<>(): void;", "TS(1098) Type parameter list cannot be empty. @18+2"], + ["(function <>() {});", "TS(1098) Type parameter list cannot be empty. @10+2"], + ["(function f<>() {});", "TS(1098) Type parameter list cannot be empty. @11+2"], + ["class A { m<>() {} }", "TS(1098) Type parameter list cannot be empty. @11+2"], + ["class A { static m<>() {} }", "TS(1098) Type parameter list cannot be empty. @18+2"], + ["class A { m<>(): void; m() {} }", "TS(1098) Type parameter list cannot be empty. @11+2"], + ["abstract class A { abstract m<>(): void }", "TS(1098) Type parameter list cannot be empty. @29+2"], + ["const o = { m<>() {} };", "TS(1098) Type parameter list cannot be empty. @13+2"], + ["class A<> {}", "TS(1098) Type parameter list cannot be empty. @7+2"], + ["(class <> {});", "TS(1098) Type parameter list cannot be empty. @7+2"], + ["(class A<> {});", "TS(1098) Type parameter list cannot be empty. @8+2"], + ["interface I<> {}", "TS(1098) Type parameter list cannot be empty. @11+2"], + ["type T<> = 1;", "TS(1098) Type parameter list cannot be empty. @6+2"], + ["interface I { <>(): void }", "TS(1098) Type parameter list cannot be empty. @14+2"], + ["interface I { new <>(): void }", "TS(1098) Type parameter list cannot be empty. @18+2"], + ["interface I { m<>(): void }", "TS(1098) Type parameter list cannot be empty. @15+2"], + ["type F = <>() => void;", "TS(1098) Type parameter list cannot be empty. @9+2"], + ["type F = new <>() => void;", "TS(1098) Type parameter list cannot be empty. @13+2"], + ["f<>();", "TS(1099) Type argument list cannot be empty. @1+2"], + ["new A<>();", "TS(1099) Type argument list cannot be empty. @5+2"], + ["new A<>;", "TS(1099) Type argument list cannot be empty. @5+2"], + ["f<>``;", "TS(1099) Type argument list cannot be empty. @1+2"], + ["let x = f<>;", "TS(1099) Type argument list cannot be empty. @9+2"], + ["let x: A<>;", "TS(1099) Type argument list cannot be empty. @8+2"], + ["let x: typeof f<>;", "TS(1099) Type argument list cannot be empty. @15+2"], + ['let x: import("a").B<>;', "TS(1099) Type argument list cannot be empty. @20+2"], + ["class A extends B<> {}", "TS(1099) Type argument list cannot be empty. @17+2"], + ["class A implements B<> {}", "TS(1099) Type argument list cannot be empty. @20+2"], + ["interface I extends B<> {}", "TS(1099) Type argument list cannot be empty. @21+2"], + ["function f< >() {}", "TS(1098) Type parameter list cannot be empty. @10+3"], + ["function f() {}", "TS(1098) Type parameter list cannot be empty. @10+6"], + ["let x: A< >;", "TS(1099) Type argument list cannot be empty. @8+3"], + [ + "x;", + "TS(7059) This syntax is reserved in files with the .mts or .cts extension. Use an `as` expression instead. @0+4", + ], + [ + "() => 1;", + "TS(7060) This syntax is reserved in files with the .mts or .cts extension. Add a trailing comma or explicit constraint. @1+1", + ], + ["await /k/;", "Unexpected token @9+1"], + [ + "await !k;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @7+0 help: Try inserting a semicolon here", + ], + [ + "await k;", + "`await` is only allowed within async functions and at the top levels of modules @0+5 help: Either remove this `await` or add the `async` keyword to the enclosing function", + ], + [ + "await (k); await k;", + "`await` is only allowed within async functions and at the top levels of modules @11+5 help: Either remove this `await` or add the `async` keyword to the enclosing function", + ], + [ + 'import a from "a"; await k;', + "`await` is only allowed within async functions and at the top levels of modules @19+5 help: Either remove this `await` or add the `async` keyword to the enclosing function", + ], + [ + 'import a = require("a"); await k;', + "`await` is only allowed within async functions and at the top levels of modules @25+5 help: Either remove this `await` or add the `async` keyword to the enclosing function", + ], + [ + "export = a; await k;", + "`await` is only allowed within async functions and at the top levels of modules @12+5 help: Either remove this `await` or add the `async` keyword to the enclosing function", + ], + ['import a from "a"; let b = ;', "Unexpected token @27+1"], + [ + "namespace N { await k; }", + "`await` is only allowed within async functions and at the top levels of modules @14+5 help: Either remove this `await` or add the `async` keyword to the enclosing function", + ], + [ + "await (x); new.target; import.meta;", + "Unexpected import.meta expression @23+11 help: import.meta is only allowed in module code", + ], + ], + "js": [ + [ + "let [...a,] = b;", + "A rest parameter or binding pattern may not have a trailing comma. @9+1 help: Remove the trailing comma here", + ], + [ + "[...a,] = b;", + "A rest parameter or binding pattern may not have a trailing comma. @5+1 help: Remove the trailing comma here", + ], + [ + "switch (a) { default: default: }", + "TS(1113) A 'default' clause cannot appear more than once in a 'switch' statement. @13+8 \"First 'default' clause is here.\", 22+8 \"Another 'default' clause cannot appear here.\"", + ], + [ + "import.foo;", + "The only valid property accesses on import are `import.meta`, `import.source()`, and `import.defer()` @0+10", + ], + [ + "a ?? b || c;", + "Logical expressions and coalesce expressions cannot be mixed @0+11 help: Wrap either expression by parentheses", + ], + [ + "a || b ?? c;", + "Logical expressions and coalesce expressions cannot be mixed @0+11 help: Wrap either expression by parentheses", + ], + [ + "a && b ?? c;", + "Logical expressions and coalesce expressions cannot be mixed @0+11 help: Wrap either expression by parentheses", + ], + [ + "a ?? b && c;", + "Logical expressions and coalesce expressions cannot be mixed @0+11 help: Wrap either expression by parentheses", + ], + [ + "a ?? b || c ?? d;", + "Logical expressions and coalesce expressions cannot be mixed @0+11 help: Wrap either expression by parentheses", + ], + [ + "a || b ?? c || d;", + "Logical expressions and coalesce expressions cannot be mixed @0+16 help: Wrap either expression by parentheses", + ], + [ + "x = a ?? b || c;", + "Logical expressions and coalesce expressions cannot be mixed @4+11 help: Wrap either expression by parentheses", + ], + [ + "a ?? (b) || c;", + "Logical expressions and coalesce expressions cannot be mixed @0+13 help: Wrap either expression by parentheses", + ], + ["a?.`x`;", "Tagged template expressions are not permitted in an optional chain @3+3"], + ["a?.b`x`;", "Tagged template expressions are not permitted in an optional chain @4+3"], + ["a?.b.c`x`;", "Tagged template expressions are not permitted in an optional chain @6+3"], + ["a?.()`x`;", "Tagged template expressions are not permitted in an optional chain @5+3"], + ["a?.[b]`x`;", "Tagged template expressions are not permitted in an optional chain @6+3"], + ["for (let a = 1 of []) {}", "for-of loop variable declaration may not have an initializer @5+9"], + ["for (var a = 1 of []) {}", "for-of loop variable declaration may not have an initializer @5+9"], + ["for (const a = 1 of []) {}", "for-of loop variable declaration may not have an initializer @5+11"], + ["for (let a = 1 in {}) {}", "for-in loop variable declaration may not have an initializer @5+9"], + ["for (const a = 1 in {}) {}", "for-in loop variable declaration may not have an initializer @5+11"], + ["for (var [a] = 1 in {}) {}", "for-in loop variable declaration may not have an initializer @5+11"], + ["for (var {a} = 1 in {}) {}", "for-in loop variable declaration may not have an initializer @5+11"], + ["'use strict'; for (var a = 1 in {}) {}", "for-in loop variable declaration may not have an initializer @19+9"], + ["export {}; for (var a = 1 in {}) {}", "for-in loop variable declaration may not have an initializer @16+9"], + ["for (let a, b of []) {}", "Only a single declaration is allowed in a `for...of` statement @5+8"], + ["for (using a = 1 of []) {}", "for-of loop variable declaration may not have an initializer @5+11"], + ["a\n=> 1;", "Line terminator not permitted before arrow @2+2 help: Remove the line break before here"], + ["(a, b)\n=> 1;", "Line terminator not permitted before arrow @7+2 help: Remove the line break before here"], + ["()\n=> 1;", "Line terminator not permitted before arrow @3+2 help: Remove the line break before here"], + ["async a\n=> 1;", "Line terminator not permitted before arrow @8+2 help: Remove the line break before here"], + ["async (a)\n=> 1;", "Line terminator not permitted before arrow @10+2 help: Remove the line break before here"], + ["(a) /*\n*/ => 1;", "Line terminator not permitted before arrow @10+2 help: Remove the line break before here"], + ["throw\n1;", 'Illegal newline after throw @0+5 "throw starts here", 6+1 "A newline is not expected here"'], + [ + "throw /*\n*/ 1;", + 'Illegal newline after throw @0+5 "throw starts here", 12+1 "A newline is not expected here"', + ], + ["throw // c\n1;", 'Illegal newline after throw @0+5 "throw starts here", 11+1 "A newline is not expected here"'], + ["import();", "import() requires a specifier. @0+8"], + ["import(...a);", "TS(1325) Argument of dynamic import cannot be a spread element. @7+3"], + ['import("a", ...b);', "TS(1325) Argument of dynamic import cannot be a spread element. @12+3"], + [ + 'import("a", "b", "c");', + "Dynamic imports can only accept a module specifier and an optional set of attributes as arguments @0+16", + ], + ['new import("a");', "Cannot use new with dynamic import @4+11 help: Wrap this with parenthesis"], + ['new import("a")();', "Cannot use new with dynamic import @4+13 help: Wrap this with parenthesis"], + ["import.defer();", "import() requires a specifier. @0+14"], + [ + 'import.defer("a", "b", "c");', + "Dynamic imports can only accept a module specifier and an optional set of attributes as arguments @0+22", + ], + [ + "class A { static { return; } }", + "TS(18041) A 'return' statement cannot be used inside a class static block. @19+6", + ], + [ + "class A { static { return 1; } }", + "TS(18041) A 'return' statement cannot be used inside a class static block. @19+6", + ], + [ + "class A { static { if (a) return; } }", + "TS(18041) A 'return' statement cannot be used inside a class static block. @26+6", + ], + ["`\\01`;", "Bad escape sequence in untagged template literal @1+3"], + ["`\\1`;", "Bad escape sequence in untagged template literal @1+2"], + ["`\\08`;", "Bad escape sequence in untagged template literal @1+3"], + ["`\\u`;", "Bad escape sequence in untagged template literal @1+2"], + ["`\\u{110000}`;", "Bad escape sequence in untagged template literal @1+10"], + ["`\\xg`;", "Bad escape sequence in untagged template literal @1+3"], + ["`\\u{}`;", "Bad escape sequence in untagged template literal @1+4"], + ["`${a}\\01`;", "Bad escape sequence in untagged template literal @5+3"], + ["`\\01${a}`;", "Bad escape sequence in untagged template literal @1+3"], + [ + "for (async of []) {}", + "The left-hand side of a `for...of` statement may not be `async` @5+5 help: Did you mean to use a for await...of statement?", + ], + [ + "for await (async of []) {}", + "`for await` loops are only allowed within async functions and at the top levels of modules @4+5 help: Either remove this `await` or add the `async` keyword to the enclosing function", + ], + ["for (let of []) {}", 'Expected `;` but found `[` @12+1 "`;` expected"'], + ["for (let.a of []) {}", "The left-hand side of a `for...of` statement may not start with `let` @5+5"], + ['x = import("a");', "Unexpected token @10+1"], + ["for (let.a.b of []) {}", "The left-hand side of a `for...of` statement may not start with `let` @5+7"], + ["for (let().a of []) {}", "The left-hand side of a `for...of` statement may not start with `let` @5+7"], + [ + "let zz; let zz; for (let.a of []) {}", + "The left-hand side of a `for...of` statement may not start with `let` @21+5", + ], + [ + "class A { static { with (a) return; } }", + "TS(18041) A 'return' statement cannot be used inside a class static block. @28+6", + ], + [ + "class A { static { return; return; } }", + "TS(18041) A 'return' statement cannot be used inside a class static block. @19+6", + "TS(18041) A 'return' statement cannot be used inside a class static block. @27+6", + ], + [ + "function f() { throw\n}", + 'Illegal newline after throw @15+5 "throw starts here", 21+1 "A newline is not expected here"', + "Unexpected token @21+1", + ], + ['import("a",,);', "Unexpected token @11+1"], + [ + "let [a];", + "Missing initializer in destructuring declaration @4+3 help: Add an initializer (e.g. ` = undefined`) here", + ], + [ + "let {a};", + "Missing initializer in destructuring declaration @4+3 help: Add an initializer (e.g. ` = undefined`) here", + ], + [ + "const [a];", + "Missing initializer in destructuring declaration @6+3 help: Add an initializer (e.g. ` = undefined`) here", + ], + [ + "var [a];", + "Missing initializer in destructuring declaration @4+3 help: Add an initializer (e.g. ` = undefined`) here", + ], + [ + "let [a], b;", + "Missing initializer in destructuring declaration @4+3 help: Add an initializer (e.g. ` = undefined`) here", + ], + [ + "let a, [b];", + "Missing initializer in destructuring declaration @7+3 help: Add an initializer (e.g. ` = undefined`) here", + ], + [ + "for (let [a];;) {}", + "Missing initializer in destructuring declaration @9+3 help: Add an initializer (e.g. ` = undefined`) here", + ], + [ + "export let [a];", + "Missing initializer in destructuring declaration @11+3 help: Add an initializer (e.g. ` = undefined`) here", + ], + [ + "export @d default class {}", + "'default' modifier cannot be used here. @10+7 help: Allowed modifiers are: declare, abstract", + ], + [ + "export @d default class A {}", + "'default' modifier cannot be used here. @10+7 help: Allowed modifiers are: declare, abstract", + ], + [ + "@d export @e class A {}", + "Decorators may not appear after 'export' or 'export default' if they also appear before 'export'. @10+2", + ], + ["import(...a, b);", "TS(1325) Argument of dynamic import cannot be a spread element. @7+3"], + ["import.defer(...a);", "TS(1325) Argument of dynamic import cannot be a spread element. @13+3"], + [ + "let a: number = 1;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @5+0 help: Try inserting a semicolon here", + ], + [ + "let a: number;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @5+0 help: Try inserting a semicolon here", + ], + [ + "const a: number = 1;", + "Missing initializer in const declaration @6+1 help: Add an initializer (e.g. ` = undefined`) here", + "Expected a semicolon or an implicit semicolon after a statement, but found none @7+0 help: Try inserting a semicolon here", + ], + [ + "var a: string, b;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @5+0 help: Try inserting a semicolon here", + ], + [ + "let [a]: number[] = [];", + "Missing initializer in destructuring declaration @4+3 help: Add an initializer (e.g. ` = undefined`) here", + "Expected a semicolon or an implicit semicolon after a statement, but found none @7+0 help: Try inserting a semicolon here", + ], + [ + "let {a}: T = b;", + "Missing initializer in destructuring declaration @4+3 help: Add an initializer (e.g. ` = undefined`) here", + "Expected a semicolon or an implicit semicolon after a statement, but found none @7+0 help: Try inserting a semicolon here", + ], + [ + "let a!: number;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @5+0 help: Try inserting a semicolon here", + ], + ["function f(a: number) {}", 'Expected `,` or `)` but found `:` @12+1 "`,` or `)` expected", 10+1 "Opened here"'], + [ + "function f(a?: number) {}", + 'Expected `,` or `)` but found `?` @12+1 "`,` or `)` expected", 10+1 "Opened here"', + ], + ["function f(a?) {}", 'Expected `,` or `)` but found `?` @12+1 "`,` or `)` expected", 10+1 "Opened here"'], + ["function f(a, b?) {}", 'Expected `,` or `)` but found `?` @15+1 "`,` or `)` expected", 10+1 "Opened here"'], + ["function f(): void {}", "Expected function body @0+12 help: Add a function body (`{}`)."], + ["function f() {}", 'Expected `(` but found `<` @10+1 "`(` expected"'], + ["function f(a: T): T { return a; }", 'Expected `(` but found `<` @10+1 "`(` expected"'], + ["function f(this: T) {}", "Identifier expected. 'this' is a reserved word that cannot be used here. @11+4"], + ["function f(this) {}", "Identifier expected. 'this' is a reserved word that cannot be used here. @11+4"], + [ + "function f(...a: number[]) {}", + 'Expected `,` or `)` but found `:` @15+1 "`,` or `)` expected", 10+1 "Opened here"', + ], + [ + "function f(a: number = 1) {}", + 'Expected `,` or `)` but found `:` @12+1 "`,` or `)` expected", 10+1 "Opened here"', + ], + ["function f({a}: T) {}", 'Expected `,` or `)` but found `:` @14+1 "`,` or `)` expected", 10+1 "Opened here"'], + ["function f();", "Expected function body @0+12 help: Add a function body (`{}`)."], + ["function f(); function f() {}", "Expected function body @0+12 help: Add a function body (`{}`)."], + [ + "function f(private a) {}", + "TS(8012) Parameter modifiers can only be used in TypeScript files. @11+7 help: No modifiers are allowed here.", + ], + [ + "function f(readonly a) {}", + "TS(8012) Parameter modifiers can only be used in TypeScript files. @11+8 help: No modifiers are allowed here.", + ], + ["(a: number) => a;", 'Expected `,` or `)` but found `:` @2+1 "`,` or `)` expected", 0+1 "Opened here"'], + ["(a?: number) => a;", 'Expected `,` or `)` but found `?` @2+1 "`,` or `)` expected", 0+1 "Opened here"'], + ["(a?) => a;", 'Expected `,` or `)` but found `?` @2+1 "`,` or `)` expected", 0+1 "Opened here"'], + [ + "(a): number => a;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @3+0 help: Try inserting a semicolon here", + ], + ["(a) => a;", 'Expected `(` but found `<` @0+1 "`(` expected"'], + [ + "async (a) => a;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @12+0 help: Try inserting a semicolon here", + ], + ["async (a: number) => a;", 'Expected `,` or `)` but found `:` @8+1 "`,` or `)` expected", 6+1 "Opened here"'], + [ + "async (a): number => a;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @9+0 help: Try inserting a semicolon here", + ], + [ + "let f = function (a: number) {};", + 'Expected `,` or `)` but found `:` @19+1 "`,` or `)` expected", 17+1 "Opened here"', + ], + ["let f = function () {};", 'Expected `(` but found `<` @17+1 "`(` expected"'], + ["let f = function (): void {};", 'Expected `{` but found `:` @19+1 "`{` expected"'], + [ + "interface A {}", + "Expected a semicolon or an implicit semicolon after a statement, but found none @9+0 help: Try inserting a semicolon here", + ], + [ + "interface A { a: number }", + "Expected a semicolon or an implicit semicolon after a statement, but found none @9+0 help: Try inserting a semicolon here", + ], + [ + "interface A extends B {}", + "Expected a semicolon or an implicit semicolon after a statement, but found none @9+0 help: Try inserting a semicolon here", + ], + [ + "interface A {}", + "Expected a semicolon or an implicit semicolon after a statement, but found none @9+0 help: Try inserting a semicolon here", + ], + ["export interface A {}", "Unexpected token @7+9"], + [ + "interface\nA {}", + "Expected a semicolon or an implicit semicolon after a statement, but found none @11+0 help: Try inserting a semicolon here", + ], + [ + "type A = number;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @4+0 help: Try inserting a semicolon here", + ], + [ + "type A = T;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @4+0 help: Try inserting a semicolon here", + ], + ["export type A = number;", "Unexpected token @7+4"], + ["enum A {}", "Unexpected token @0+4"], + ["enum A { a, b }", "Unexpected token @0+4"], + ["const enum A {}", "Identifier expected. 'enum' is a reserved word that cannot be used here. @6+4"], + ["export enum A {}", "Unexpected token @7+4"], + ["export const enum A {}", "Identifier expected. 'enum' is a reserved word that cannot be used here. @13+4"], + [ + "declare enum A {}", + "Expected a semicolon or an implicit semicolon after a statement, but found none @7+0 help: Try inserting a semicolon here", + ], + ["enum;", "Unexpected token @0+4"], + ["a as b;", "TS(8016) Type assertion expressions can only be used in TypeScript files. @0+6"], + ["a as const;", "TS(8016) Type assertion expressions can only be used in TypeScript files. @0+10"], + [ + "a as any as b;", + "TS(8016) Type assertion expressions can only be used in TypeScript files. @0+8", + "TS(8016) Type assertion expressions can only be used in TypeScript files. @0+13", + ], + ["(a as b);", "TS(8016) Type assertion expressions can only be used in TypeScript files. @1+6"], + ["let x = a as b;", "TS(8016) Type assertion expressions can only be used in TypeScript files. @8+6"], + ["f(a as b);", "TS(8016) Type assertion expressions can only be used in TypeScript files. @2+6"], + ["[a as b];", "TS(8016) Type assertion expressions can only be used in TypeScript files. @1+6"], + ["a satisfies b;", "TS(8037) Type satisfaction expressions can only be used in TypeScript files. @0+13"], + ["let x = a satisfies b;", "TS(8037) Type satisfaction expressions can only be used in TypeScript files. @8+13"], + ["f(a satisfies b);", "TS(8037) Type satisfaction expressions can only be used in TypeScript files. @2+13"], + [ + "a!;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @1+0 help: Try inserting a semicolon here", + ], + [ + "a!.b;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @1+0 help: Try inserting a semicolon here", + ], + [ + "a!();", + "Expected a semicolon or an implicit semicolon after a statement, but found none @1+0 help: Try inserting a semicolon here", + ], + [ + "a![0];", + "Expected a semicolon or an implicit semicolon after a statement, but found none @1+0 help: Try inserting a semicolon here", + ], + [ + "let x = a!;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @9+0 help: Try inserting a semicolon here", + ], + ["f();", "Empty parenthesized expression @4+2"], + ["new F();", "Empty parenthesized expression @8+2"], + ["new F;", "Unexpected token @8+1"], + ["f;", "Unexpected token @4+1"], + ["a.b();", "Empty parenthesized expression @6+2"], + ["a?.b();", "Empty parenthesized expression @7+2"], + ["a?.();", "Identifier expected after '?.' @1+2 help: Add an identifier after '?.'"], + [ + "declare let a;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @7+0 help: Try inserting a semicolon here", + ], + [ + "declare const a;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @7+0 help: Try inserting a semicolon here", + ], + [ + "declare var a;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @7+0 help: Try inserting a semicolon here", + ], + [ + "declare function f();", + "Expected a semicolon or an implicit semicolon after a statement, but found none @7+0 help: Try inserting a semicolon here", + ], + [ + "declare function f(): void;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @7+0 help: Try inserting a semicolon here", + ], + [ + "declare class A {}", + "Expected a semicolon or an implicit semicolon after a statement, but found none @7+0 help: Try inserting a semicolon here", + ], + [ + 'declare module "a" {}', + "Expected a semicolon or an implicit semicolon after a statement, but found none @7+0 help: Try inserting a semicolon here", + ], + [ + 'declare module "a";', + "Expected a semicolon or an implicit semicolon after a statement, but found none @7+0 help: Try inserting a semicolon here", + ], + [ + "declare module A {}", + "Expected a semicolon or an implicit semicolon after a statement, but found none @7+0 help: Try inserting a semicolon here", + ], + [ + "declare namespace A {}", + "Expected a semicolon or an implicit semicolon after a statement, but found none @7+0 help: Try inserting a semicolon here", + ], + [ + "declare global {}", + "Expected a semicolon or an implicit semicolon after a statement, but found none @7+0 help: Try inserting a semicolon here", + ], + [ + "declare interface A {}", + "Expected a semicolon or an implicit semicolon after a statement, but found none @7+0 help: Try inserting a semicolon here", + ], + [ + "declare type A = 1;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @7+0 help: Try inserting a semicolon here", + ], + ["export declare let a;", "Unexpected token @7+7"], + ["export declare const a;", "Unexpected token @7+7"], + ["export declare function f();", "Unexpected token @7+7"], + ["export declare class A {}", "Unexpected token @7+7"], + [ + "abstract class A {}", + "Expected a semicolon or an implicit semicolon after a statement, but found none @8+0 help: Try inserting a semicolon here", + ], + ["export abstract class A {}", "Unexpected token @7+8"], + [ + "class A { abstract m(); }", + "TS(8009) The 'abstract' modifier can only be used in TypeScript files. @10+8", + "Expected function body @20+2 help: Add a function body (`{}`).", + ], + ["class A { abstract a; }", "TS(8009) The 'abstract' modifier can only be used in TypeScript files. @10+8"], + [ + "class A { abstract m() {} }", + "TS(8009) The 'abstract' modifier can only be used in TypeScript files. @10+8", + "TS(1245) Method 'm' cannot have an implementation because it is marked abstract. @19+1", + ], + [ + "namespace A {}", + "Expected a semicolon or an implicit semicolon after a statement, but found none @9+0 help: Try inserting a semicolon here", + ], + [ + "namespace A.B {}", + "Expected a semicolon or an implicit semicolon after a statement, but found none @9+0 help: Try inserting a semicolon here", + ], + [ + "module A {}", + "Expected a semicolon or an implicit semicolon after a statement, but found none @6+0 help: Try inserting a semicolon here", + ], + [ + 'module "a" {}', + "Expected a semicolon or an implicit semicolon after a statement, but found none @6+0 help: Try inserting a semicolon here", + ], + ["export namespace A {}", "Unexpected token @7+9"], + [ + "namespace\nA {}", + "Expected a semicolon or an implicit semicolon after a statement, but found none @11+0 help: Try inserting a semicolon here", + ], + [ + "global {}", + "Expected a semicolon or an implicit semicolon after a statement, but found none @6+0 help: Try inserting a semicolon here", + ], + ["class A {}", 'Expected `{` but found `<` @7+1 "`{` expected"'], + ["class A extends B {}", "TS(8011) Type arguments can only be used in TypeScript files. @17+3"], + ["class A implements B {}", "TS(8005) 'implements' clauses can only be used in TypeScript files. @8+10"], + [ + "class A extends B implements C {}", + "TS(8005) 'implements' clauses can only be used in TypeScript files. @18+10", + ], + ["let x = class {};", 'Expected `{` but found `<` @13+1 "`{` expected"'], + ["let x = class A {};", 'Expected `{` but found `<` @15+1 "`{` expected"'], + ["let x = class implements B {};", "TS(8005) 'implements' clauses can only be used in TypeScript files. @14+10"], + ["class A { a: number; }", 'Expected `;` but found `:` @11+1 "`;` expected"'], + ["class A { a: number = 1; }", 'Expected `;` but found `:` @11+1 "`;` expected"'], + ["class A { a?: number; }", 'Expected `;` but found `:` @12+1 "`;` expected"'], + ["class A { a!: number; }", 'Expected `;` but found `:` @12+1 "`;` expected"'], + [ + "class A { a!; }", + "TS(1264) Declarations with definite assignment assertions must also have type annotations. @11+1", + ], + ["class A { private a; }", "TS(8009) The 'private' modifier can only be used in TypeScript files. @10+7"], + ["class A { private a = 1; }", "TS(8009) The 'private' modifier can only be used in TypeScript files. @10+7"], + ["class A { public a; }", "TS(8009) The 'public' modifier can only be used in TypeScript files. @10+6"], + ["class A { protected a; }", "TS(8009) The 'protected' modifier can only be used in TypeScript files. @10+9"], + ["class A { readonly a; }", "TS(8009) The 'readonly' modifier can only be used in TypeScript files. @10+8"], + ["class A { readonly a = 1; }", "TS(8009) The 'readonly' modifier can only be used in TypeScript files. @10+8"], + ["class A { declare a; }", "TS(8009) The 'declare' modifier can only be used in TypeScript files. @10+7"], + ["class A { override a; }", "TS(8009) The 'override' modifier can only be used in TypeScript files. @10+8"], + ["class A { override m() {} }", "TS(8009) The 'override' modifier can only be used in TypeScript files. @10+8"], + ["class A { private m() {} }", "TS(8009) The 'private' modifier can only be used in TypeScript files. @10+7"], + ["class A { public m() {} }", "TS(8009) The 'public' modifier can only be used in TypeScript files. @10+6"], + [ + "class A { public static m() {} }", + "TS(8009) The 'public' modifier can only be used in TypeScript files. @10+6", + ], + [ + "class A { static readonly a = 1; }", + "TS(8009) The 'readonly' modifier can only be used in TypeScript files. @17+8", + ], + ["class A { private static a; }", "TS(8009) The 'private' modifier can only be used in TypeScript files. @10+7"], + [ + "class A { private constructor() {} }", + "TS(8009) The 'private' modifier can only be used in TypeScript files. @10+7", + ], + ["class A { m(): void {} }", "Expected function body @11+2 help: Add a function body (`{}`)."], + ["class A { m() {} }", 'Expected `(` but found `<` @11+1 "`(` expected"'], + [ + "class A { m(a: number) {} }", + 'Expected `,` or `)` but found `:` @13+1 "`,` or `)` expected", 11+1 "Opened here"', + ], + ["class A { m(a?) {} }", 'Expected `,` or `)` but found `?` @13+1 "`,` or `)` expected", 11+1 "Opened here"'], + ["class A { m(); }", "Expected function body @11+2 help: Add a function body (`{}`)."], + ["class A { m(); m() {} }", "Expected function body @11+2 help: Add a function body (`{}`)."], + [ + "class A { constructor(private a) {} }", + "TS(8012) Parameter modifiers can only be used in TypeScript files. @22+7 help: No modifiers are allowed here.", + ], + [ + "class A { constructor(public a) {} }", + "TS(8012) Parameter modifiers can only be used in TypeScript files. @22+6 help: No modifiers are allowed here.", + ], + [ + "class A { constructor(readonly a) {} }", + "TS(8012) Parameter modifiers can only be used in TypeScript files. @22+8 help: No modifiers are allowed here.", + ], + ["class A { constructor(); }", "Expected function body @21+2 help: Add a function body (`{}`)."], + ["class A { [k: string]: number; }", "Unexpected token @12+1"], + ["class A { static [k: string]: number; }", "Unexpected token @19+1"], + ["class A { get a(): number { return 1; } }", "Expected function body @15+2 help: Add a function body (`{}`)."], + [ + "class A { set a(v: number) {} }", + 'Expected `,` or `)` but found `:` @17+1 "`,` or `)` expected", 15+1 "Opened here"', + ], + [ + "class A { accessor a: number; }", + "Expected a semicolon or an implicit semicolon after a statement, but found none @20+0 help: Try inserting a semicolon here", + ], + ["class A { static a: number; }", 'Expected `;` but found `:` @18+1 "`;` expected"'], + ["class A { #a: number; }", 'Expected `;` but found `:` @12+1 "`;` expected"'], + ["class A { private #a; }", "TS(8009) The 'private' modifier can only be used in TypeScript files. @10+7"], + [ + "class A { in a; }", + "TS(8009) The 'in' modifier can only be used in TypeScript files. @10+2", + "TS(1274) 'in' modifier can only appear on a type parameter of a class, interface or type alias. @10+2", + ], + [ + "class A { out a; }", + "TS(8009) The 'out' modifier can only be used in TypeScript files. @10+3", + "TS(1274) 'out' modifier can only appear on a type parameter of a class, interface or type alias. @10+3", + ], + [ + "class A { const a; }", + "TS(8009) The 'const' modifier can only be used in TypeScript files. @10+5", + "TS(1248) A class member cannot have the 'const' keyword. @10+5 help: Did you mean `readonly`?", + ], + ["class A { export a; }", "TS(1031) 'export' modifier cannot appear on class elements of this kind. @10+6"], + ["class A { async a; }", "'async' modifier cannot be used here. @10+5"], + ['import type A from "a";', 'Expected `from` but found `Identifier` @12+1 "`from` expected"'], + ['import type { A } from "a";', 'Expected `from` but found `{` @12+1 "`from` expected"'], + ['import type * as A from "a";', 'Expected `from` but found `*` @12+1 "`from` expected"'], + [ + 'import { type A } from "a";', + 'Expected `,` or `}` but found `Identifier` @14+1 "`,` or `}` expected", 7+1 "Opened here"', + ], + [ + 'import { type A as B } from "a";', + 'Expected `,` or `}` but found `Identifier` @14+1 "`,` or `}` expected", 7+1 "Opened here"', + ], + ['import { type as } from "a";', "Unexpected token @17+1"], + [ + 'import { type as as as } from "a";', + 'Expected `,` or `}` but found `as` @20+2 "`,` or `}` expected", 7+1 "Opened here"', + ], + [ + 'import a = require("a");', + "TS(8002) 'import ... =' can only be used in TypeScript files. @0+24 help: TypeScript transforms 'import ... =' to 'const ... ='", + ], + [ + "import a = b.c;", + "TS(8002) 'import ... =' can only be used in TypeScript files. @0+15 help: TypeScript transforms 'import ... =' to 'const ... ='", + ], + ['import type a = require("a");', 'Expected `from` but found `Identifier` @12+1 "`from` expected"'], + [ + 'export import a = require("a");', + "TS(8002) 'import ... =' can only be used in TypeScript files. @7+24 help: TypeScript transforms 'import ... =' to 'const ... ='", + ], + [ + "export import a = b.c;", + "TS(8002) 'import ... =' can only be used in TypeScript files. @7+15 help: TypeScript transforms 'import ... =' to 'const ... ='", + ], + ["export type { A };", "Unexpected token @7+4"], + ['export type { A } from "a";', "Unexpected token @7+4"], + ['export type * from "a";', "Unexpected token @7+4"], + ['export type * as A from "a";', "Unexpected token @7+4"], + [ + "export { type A };", + 'Expected `,` or `}` but found `Identifier` @14+1 "`,` or `}` expected", 7+1 "Opened here"', + ], + [ + 'export { type A } from "a";', + 'Expected `,` or `}` but found `Identifier` @14+1 "`,` or `}` expected", 7+1 "Opened here"', + ], + [ + "export { type A as B };", + 'Expected `,` or `}` but found `Identifier` @14+1 "`,` or `}` expected", 7+1 "Opened here"', + ], + ["export = a;", "Unexpected token @7+1"], + ["export as namespace A;", "Unexpected token @7+2"], + ["let o = { m(): void {} };", "Expected function body @11+2 help: Add a function body (`{}`)."], + ["let o = { m() {} };", 'Expected `(` but found `<` @11+1 "`(` expected"'], + [ + "let o = { m(a: number) {} };", + 'Expected `,` or `)` but found `:` @13+1 "`,` or `)` expected", 11+1 "Opened here"', + ], + ["let o = { m?() {} };", 'Expected `,` or `}` but found `?` @11+1 "`,` or `}` expected", 8+1 "Opened here"'], + ["let o = { a?: 1 };", 'Expected `,` or `}` but found `?` @11+1 "`,` or `}` expected", 8+1 "Opened here"'], + ["let o = { a!: 1 };", 'Expected `,` or `}` but found `!` @11+1 "`,` or `}` expected", 8+1 "Opened here"'], + ["let o = { get a(): number { return 1; } };", "Expected function body @15+2 help: Add a function body (`{}`)."], + [ + "let o = { private a: 1 };", + "'private' modifier cannot be used here. @10+7 help: No modifiers are allowed here.", + ], + [ + "let o = { readonly a: 1 };", + "'readonly' modifier cannot be used here. @10+8 help: No modifiers are allowed here.", + ], + ["let o = { async m() {} };", 'Expected `(` but found `<` @17+1 "`(` expected"'], + ["try {} catch (e: unknown) {}", 'Expected `)` but found `:` @15+1 "`)` expected"'], + ["try {} catch (e: any) {}", 'Expected `)` but found `:` @15+1 "`)` expected"'], + ["for (let a: number of []) {}", 'Expected `;` but found `:` @10+1 "`;` expected"'], + ["for (const a: number in {}) {}", 'Expected `;` but found `:` @12+1 "`;` expected"'], + ["for (let a: number = 0;;) {}", 'Expected `;` but found `:` @10+1 "`;` expected"'], + [ + "let x: typeof a;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @5+0 help: Try inserting a semicolon here", + ], + [ + "let x: A.B;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @5+0 help: Try inserting a semicolon here", + ], + [ + "let x: A;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @5+0 help: Try inserting a semicolon here", + ], + [ + "let x: { a: 1 };", + "Expected a semicolon or an implicit semicolon after a statement, but found none @5+0 help: Try inserting a semicolon here", + ], + [ + "let x: () => void;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @5+0 help: Try inserting a semicolon here", + ], + [ + "let x: [a, b];", + "Expected a semicolon or an implicit semicolon after a statement, but found none @5+0 help: Try inserting a semicolon here", + ], + [ + 'let x: "a";', + "Expected a semicolon or an implicit semicolon after a statement, but found none @5+0 help: Try inserting a semicolon here", + ], + [ + "let x: 1;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @5+0 help: Try inserting a semicolon here", + ], + [ + "let x: a | b;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @5+0 help: Try inserting a semicolon here", + ], + [ + "let x: a[];", + "Expected a semicolon or an implicit semicolon after a statement, but found none @5+0 help: Try inserting a semicolon here", + ], + ["function f(a): a is string {}", "Expected function body @0+13 help: Add a function body (`{}`)."], + ["function f(a): asserts a {}", "Expected function body @0+13 help: Add a function body (`{}`)."], + ["function* f(): T {}", "Expected function body @0+13 help: Add a function body (`{}`)."], + ["async function f(): Promise {}", "Expected function body @0+18 help: Add a function body (`{}`)."], + ["async function f() {}", 'Expected `(` but found `<` @16+1 "`(` expected"'], + ["export default function f(): void {}", "Expected function body @15+12 help: Add a function body (`{}`)."], + ["export default function () {}", 'Expected `(` but found `<` @24+1 "`(` expected"'], + ["export default class {}", 'Expected `{` but found `<` @20+1 "`{` expected"'], + [ + "export function f(a: number) {}", + 'Expected `,` or `)` but found `:` @19+1 "`,` or `)` expected", 17+1 "Opened here"', + ], + [ + "export const a: number = 1;", + "Missing initializer in const declaration @13+1 help: Add an initializer (e.g. ` = undefined`) here", + "Expected a semicolon or an implicit semicolon after a statement, but found none @14+0 help: Try inserting a semicolon here", + ], + ["export class A {}", 'Expected `{` but found `<` @14+1 "`{` expected"'], + ["export class A implements B {}", "TS(8005) 'implements' clauses can only be used in TypeScript files. @15+10"], + ["@d class A { @d a: number; }", 'Expected `;` but found `:` @17+1 "`;` expected"'], + ["class A { m(@d a) {} }", "Decorators are not valid here. @12+2"], + ["class A { constructor(@d a) {} }", "Decorators are not valid here. @22+2"], + ["function f(@d a) {}", "Decorators are not valid here. @11+2"], + [ + "accessor a;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @8+0 help: Try inserting a semicolon here", + ], + [ + "unique symbol;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @6+0 help: Try inserting a semicolon here", + ], + [ + "keyof a;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @5+0 help: Try inserting a semicolon here", + ], + [ + "using a: T = b;", + "Using declarations must have an initializer. @6+1 help: Add an initializer (e.g. ` = undefined`) here", + "Expected a semicolon or an implicit semicolon after a statement, but found none @7+0 help: Try inserting a semicolon here", + ], + [ + "let zz; let zz; let a: number = 1;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @21+0 help: Try inserting a semicolon here", + ], + [ + "let zz; let zz; interface A {}", + "Expected a semicolon or an implicit semicolon after a statement, but found none @25+0 help: Try inserting a semicolon here", + ], + [ + "let zz; let zz; class A { private a; } a ?? b || c;", + "TS(8009) The 'private' modifier can only be used in TypeScript files. @26+7", + "Logical expressions and coalesce expressions cannot be mixed @39+11 help: Wrap either expression by parentheses", + ], + [ + "let zz; let zz; a ?? b || c; class A { private a; }", + "Logical expressions and coalesce expressions cannot be mixed @16+11 help: Wrap either expression by parentheses", + "TS(8009) The 'private' modifier can only be used in TypeScript files. @39+7", + ], + [ + "let zz; let zz; a ?? b || c; let a: number;", + "Logical expressions and coalesce expressions cannot be mixed @16+11 help: Wrap either expression by parentheses", + "Expected a semicolon or an implicit semicolon after a statement, but found none @34+0 help: Try inserting a semicolon here", + ], + [ + "let zz; let zz; a ?? b || c; abstract class A {}", + "Logical expressions and coalesce expressions cannot be mixed @16+11 help: Wrap either expression by parentheses", + "Expected a semicolon or an implicit semicolon after a statement, but found none @37+0 help: Try inserting a semicolon here", + ], + [ + "let zz; let zz; a ?? b || c; declare let x;", + "Logical expressions and coalesce expressions cannot be mixed @16+11 help: Wrap either expression by parentheses", + "Expected a semicolon or an implicit semicolon after a statement, but found none @36+0 help: Try inserting a semicolon here", + ], + [ + "let zz; let zz; a ?? b || c; function f(a?) {}", + "Logical expressions and coalesce expressions cannot be mixed @16+11 help: Wrap either expression by parentheses", + 'Expected `,` or `)` but found `?` @41+1 "`,` or `)` expected", 39+1 "Opened here"', + ], + [ + "let zz; let zz; a ?? b || c; enum A {}", + "Logical expressions and coalesce expressions cannot be mixed @16+11 help: Wrap either expression by parentheses", + "Unexpected token @29+4", + ], + [ + "let zz; let zz; a ?? b || c; a as b;", + "Logical expressions and coalesce expressions cannot be mixed @16+11 help: Wrap either expression by parentheses", + "TS(8016) Type assertion expressions can only be used in TypeScript files. @29+6", + ], + [ + "let zz; let zz; a ?? b || c; f();", + "Logical expressions and coalesce expressions cannot be mixed @16+11 help: Wrap either expression by parentheses", + "Empty parenthesized expression @33+2", + ], + [ + "let zz; let zz; a ?? b || c; class A { constructor(private a) {} }", + "Logical expressions and coalesce expressions cannot be mixed @16+11 help: Wrap either expression by parentheses", + "TS(8012) Parameter modifiers can only be used in TypeScript files. @51+7 help: No modifiers are allowed here.", + ], + [ + "let zz; let zz; a ?? b || c; class A implements B {}", + "Logical expressions and coalesce expressions cannot be mixed @16+11 help: Wrap either expression by parentheses", + "TS(8005) 'implements' clauses can only be used in TypeScript files. @37+10", + ], + [ + 'let zz; let zz; a ?? b || c; import type A from "a";', + "Logical expressions and coalesce expressions cannot be mixed @16+11 help: Wrap either expression by parentheses", + 'Expected `from` but found `Identifier` @41+1 "`from` expected"', + ], + [ + "let zz; let zz; a ?? b || c; export = a;", + "Logical expressions and coalesce expressions cannot be mixed @16+11 help: Wrap either expression by parentheses", + "Unexpected token @36+1", + ], + [ + 'let zz; let zz; a ?? b || c; import a = require("a");', + "Logical expressions and coalesce expressions cannot be mixed @16+11 help: Wrap either expression by parentheses", + "TS(8002) 'import ... =' can only be used in TypeScript files. @29+24 help: TypeScript transforms 'import ... =' to 'const ... ='", + ], + [ + "throw\n;", + 'Illegal newline after throw @0+5 "throw starts here", 6+1 "A newline is not expected here"', + "Unexpected token @6+1", + ], + [ + "throw\n", + 'Illegal newline after throw @0+5 "throw starts here", 7+0 "A newline is not expected here"', + "Unexpected token @7+0", + ], + [ + "throw\nvar a;", + 'Illegal newline after throw @0+5 "throw starts here", 6+3 "A newline is not expected here"', + "Unexpected token @6+3", + ], + [ + "throw\nif (a) b;", + 'Illegal newline after throw @0+5 "throw starts here", 6+2 "A newline is not expected here"', + "Unexpected token @6+2", + ], + ['import("a");', 'Expected `from` but found `<` @6+1 "`from` expected"'], + ["function f(); a ?? b || c;", "Expected function body @0+12 help: Add a function body (`{}`)."], + ["class A { m(); } a ?? b || c;", "Expected function body @11+2 help: Add a function body (`{}`)."], + ["class A { m(); n(); }", "Expected function body @11+2 help: Add a function body (`{}`)."], + ["class A { m(): void {} } a ?? b || c;", "Expected function body @11+2 help: Add a function body (`{}`)."], + ["function f(): void {} a ?? b || c;", "Expected function body @0+12 help: Add a function body (`{}`)."], + ["let o = { m(); };", "Expected function body @11+2 help: Add a function body (`{}`)."], + ["let o = { m() };", "Expected function body @11+2 help: Add a function body (`{}`)."], + [ + "class A { a!; } a ?? b || c;", + "TS(1264) Declarations with definite assignment assertions must also have type annotations. @11+1", + "Logical expressions and coalesce expressions cannot be mixed @16+11 help: Wrap either expression by parentheses", + ], + [ + "let zz; let zz; class A { a!; }", + "TS(1264) Declarations with definite assignment assertions must also have type annotations. @27+1", + ], + [ + "class A { a! = 1; }", + "TS(1263) Declarations with initializers cannot also have definite assignment assertions. @11+1", + ], + [ + "class A { static a!; }", + "TS(1264) Declarations with definite assignment assertions must also have type annotations. @18+1", + ], + ["class A { a?: number; b: string; }", 'Expected `;` but found `:` @12+1 "`;` expected"'], + ["class A { get a?() { return 1; } }", 'Expected `(` but found `?` @15+1 "`(` expected"'], + ["class A { constructor?() {} }", 'Expected `(` but found `?` @21+1 "`(` expected"'], + [ + "class A { accessor a?; }", + "TS(1276) An 'accessor' property cannot be declared optional. @20+1 help: Remove this `?`", + ], + ["f(a!);", 'Expected `,` or `)` but found `!` @3+1 "`,` or `)` expected", 1+1 "Opened here"'], + ["(a!);", 'Expected `,` or `)` but found `!` @2+1 "`,` or `)` expected", 0+1 "Opened here"'], + ["[a!];", 'Expected `,` or `]` but found `!` @2+1 "`,` or `]` expected", 0+1 "Opened here"'], + ["let o = { a: b! };", 'Expected `,` or `}` but found `!` @14+1 "`,` or `}` expected", 8+1 "Opened here"'], + ["if (a!) {}", 'Expected `)` but found `!` @5+1 "`)` expected", 3+1 "Opened here"'], + [ + "a! + b;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @1+0 help: Try inserting a semicolon here", + ], + [ + "a!\n;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @1+0 help: Try inserting a semicolon here", + ], + [ + "x = a!;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @5+0 help: Try inserting a semicolon here", + ], + ["f(a!, b);", 'Expected `,` or `)` but found `!` @3+1 "`,` or `)` expected", 1+1 "Opened here"'], + [ + "a!!;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @1+0 help: Try inserting a semicolon here", + ], + [ + "return a!;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @8+0 help: Try inserting a semicolon here", + ], + [ + "function f() { return a!; }", + "Expected a semicolon or an implicit semicolon after a statement, but found none @23+0 help: Try inserting a semicolon here", + ], + [ + "() => a!;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @7+0 help: Try inserting a semicolon here", + ], + ["a ? b! : c;", 'Expected `:` but found `!` @5+1 "`:` expected", 2+1 "Conditional starts here"'], + ["for (a! of b) {}", 'Expected `;` but found `!` @6+1 "`;` expected"'], + [ + "function f(private readonly a) {}", + "TS(8012) Parameter modifiers can only be used in TypeScript files. @11+7 help: No modifiers are allowed here.", + "TS(8012) Parameter modifiers can only be used in TypeScript files. @19+8 help: No modifiers are allowed here.", + ], + [ + "class A { constructor(private readonly a, public b) {} }", + "TS(8012) Parameter modifiers can only be used in TypeScript files. @22+7 help: No modifiers are allowed here.", + "TS(8012) Parameter modifiers can only be used in TypeScript files. @30+8 help: No modifiers are allowed here.", + "TS(8012) Parameter modifiers can only be used in TypeScript files. @42+6 help: No modifiers are allowed here.", + ], + [ + "class A { constructor(@d private a) {} }", + "TS(8012) Parameter modifiers can only be used in TypeScript files. @25+7 help: No modifiers are allowed here.", + "Decorators are not valid here. @22+2", + ], + [ + "class A { constructor(private @d a) {} }", + 'Expected `,` or `)` but found `@` @30+1 "`,` or `)` expected", 21+1 "Opened here"', + ], + [ + "function f(@d @e a, @g b) {}", + "Decorators are not valid here. @11+2", + "Decorators are not valid here. @14+2", + "Decorators are not valid here. @20+2", + ], + ["let zz; let zz; function f(@d a) {}", "Decorators are not valid here. @27+2"], + [ + "let zz; let zz; function f(private a) {}", + "TS(8012) Parameter modifiers can only be used in TypeScript files. @27+7 help: No modifiers are allowed here.", + ], + [ + "let zz; let zz; class A { private a; }", + "TS(8009) The 'private' modifier can only be used in TypeScript files. @26+7", + ], + ["let zz; let zz; a as b;", "TS(8016) Type assertion expressions can only be used in TypeScript files. @16+6"], + [ + "let zz; let zz; class A implements B {}", + "TS(8005) 'implements' clauses can only be used in TypeScript files. @24+10", + ], + [ + "let zz; let zz; class A extends B {}", + "TS(8011) Type arguments can only be used in TypeScript files. @33+3", + ], + [ + 'let zz; let zz; import a = require("a");', + "TS(8002) 'import ... =' can only be used in TypeScript files. @16+24 help: TypeScript transforms 'import ... =' to 'const ... ='", + ], + [ + "function f(private a: number) {}", + "TS(8012) Parameter modifiers can only be used in TypeScript files. @11+7 help: No modifiers are allowed here.", + 'Expected `,` or `)` but found `:` @20+1 "`,` or `)` expected", 10+1 "Opened here"', + ], + [ + "function f(private a?) {}", + "TS(8012) Parameter modifiers can only be used in TypeScript files. @11+7 help: No modifiers are allowed here.", + 'Expected `,` or `)` but found `?` @20+1 "`,` or `)` expected", 10+1 "Opened here"', + ], + [ + "class A { private readonly a; }", + "TS(8009) The 'private' modifier can only be used in TypeScript files. @10+7", + "TS(8009) The 'readonly' modifier can only be used in TypeScript files. @18+8", + ], + [ + "class A { private static readonly a; }", + "TS(8009) The 'private' modifier can only be used in TypeScript files. @10+7", + "TS(8009) The 'readonly' modifier can only be used in TypeScript files. @25+8", + ], + [ + "class A { private private a; }", + "TS(1028) Accessibility modifier already seen. @18+7 help: Remove the duplicate modifier.", + "TS(8009) The 'private' modifier can only be used in TypeScript files. @18+7", + ], + [ + "class A { readonly private a; }", + "TS(1029) 'private' modifier must precede 'readonly' modifier. @19+7", + "TS(8009) The 'readonly' modifier can only be used in TypeScript files. @10+8", + "TS(8009) The 'private' modifier can only be used in TypeScript files. @19+7", + ], + [ + "class A { static private a; }", + "TS(1029) 'private' modifier must precede 'static' modifier. @17+7", + "TS(8009) The 'private' modifier can only be used in TypeScript files. @17+7", + ], + [ + "class A { declare readonly a; }", + "TS(8009) The 'declare' modifier can only be used in TypeScript files. @10+7", + "TS(8009) The 'readonly' modifier can only be used in TypeScript files. @18+8", + ], + [ + "class A { private a: number; }", + "TS(8009) The 'private' modifier can only be used in TypeScript files. @10+7", + 'Expected `;` but found `:` @19+1 "`;` expected"', + ], + [ + "class A { private m(): void {} }", + "TS(8009) The 'private' modifier can only be used in TypeScript files. @10+7", + "Expected function body @19+2 help: Add a function body (`{}`).", + ], + [ + "class A { private get a() { return 1; } }", + "TS(8009) The 'private' modifier can only be used in TypeScript files. @10+7", + ], + [ + "class A { private async m() {} }", + "TS(8009) The 'private' modifier can only be used in TypeScript files. @10+7", + ], + ["class A { private *m() {} }", "TS(8009) The 'private' modifier can only be used in TypeScript files. @10+7"], + ["class A { private [a]; }", "TS(8009) The 'private' modifier can only be used in TypeScript files. @10+7"], + ["class A { private static {} }", "TS(1184) Modifiers cannot appear here. @10+7"], + [ + "class A { abstract a = 1; }", + "TS(8009) The 'abstract' modifier can only be used in TypeScript files. @10+8", + "TS(1267) Property 'a' cannot have an initializer because it is marked abstract. @19+1", + ], + [ + "abstract class A { abstract m(); }", + "Expected a semicolon or an implicit semicolon after a statement, but found none @8+0 help: Try inserting a semicolon here", + ], + ["class A { static async a; }", "'async' modifier cannot be used here. @17+5"], + [ + "class A { async static a; }", + "TS(1029) 'static' modifier must precede 'async' modifier. @16+6", + "'async' modifier cannot be used here. @10+5", + ], + ["class A { async static m() {} }", "TS(1029) 'static' modifier must precede 'async' modifier. @16+6"], + ["class A { accessor static a; }", "TS(1029) 'static' modifier must precede 'accessor' modifier. @19+6"], + [ + "class A { accessor accessor a; }", + "TS(1030) 'accessor' modifier already seen. @19+8 help: Remove the duplicate modifier.", + ], + [ + "class A { accessor m() {} }", + "TS(1275) 'accessor' modifier can only appear on a property declaration. @10+8 help: Allowed modifiers are: private, protected, public, static, abstract, override, async", + ], + ["class A { async get a() { return 1; } }", "'async' modifier cannot be used here. @10+5"], + ["class A { async constructor() {} }", "Constructor can't be an async method @16+11"], + ["class A { export m() {} }", "TS(1031) 'export' modifier cannot appear on class elements of this kind. @10+6"], + [ + "class A extends B implements C {}", + "TS(8011) Type arguments can only be used in TypeScript files. @17+3", + "TS(8005) 'implements' clauses can only be used in TypeScript files. @21+10", + ], + ["class A extends B {}", "TS(8011) Type arguments can only be used in TypeScript files. @17+6"], + ["class A extends B.C {}", "TS(8011) Type arguments can only be used in TypeScript files. @19+3"], + ["class A extends f() {}", "TS(8011) Type arguments can only be used in TypeScript files. @19+3"], + ["class A implements B, C {}", "TS(8005) 'implements' clauses can only be used in TypeScript files. @8+10"], + ["class A implements B {}", "TS(8005) 'implements' clauses can only be used in TypeScript files. @8+10"], + [ + "class A implements {}", + "TS(8005) 'implements' clauses can only be used in TypeScript files. @8+10", + "Unexpected token @19+1", + ], + [ + "class A implements B implements C {}", + "TS(8005) 'implements' clauses can only be used in TypeScript files. @8+10", + "TS(1175) 'implements' clause already seen @8+10, 21+10 help: Merge the two 'implements' clauses into one by a ','", + "TS(8005) 'implements' clauses can only be used in TypeScript files. @21+10", + ], + [ + "class A implements B extends C {}", + "TS(8005) 'implements' clauses can only be used in TypeScript files. @8+10", + "TS(1173) 'extends' clause must precede 'implements' clause @8+10 \"'implements' clause found here\", 21+7 \"'extends' clause found here\" help: Move the 'extends' clause before the 'implements' clause", + ], + [ + "class A extends B, C {}", + "TS(1174) Classes can only extend a single class. @19+1 help: Remove the extra base class or use interfaces for multiple inheritance", + ], + [ + "class A extends B, C, D {}", + "TS(1174) Classes can only extend a single class. @19+1 help: Remove the extra base class or use interfaces for multiple inheritance", + "TS(1174) Classes can only extend a single class. @22+1 help: Remove the extra base class or use interfaces for multiple inheritance", + ], + ["class A extends B extends C {}", "TS(1172) 'extends' clause already seen @18+7"], + ["class A extends {}", 'Expected `{` but found `EOF` @19+0 "`{` expected"'], + ["let o = { static a: 1 };", "'static' modifier cannot be used here. @10+6 help: No modifiers are allowed here."], + ["let o = { async a: 1 };", "'async' modifier cannot be used here. @10+5 help: No modifiers are allowed here."], + [ + "let o = { public m() {} };", + "'public' modifier cannot be used here. @10+6 help: Only 'async' modifier is allowed here.", + ], + [ + "let o = { async get a() { return 1; } };", + "'async' modifier cannot be used here. @10+5 help: No modifiers are allowed here.", + ], + [ + "let o = { static m() {} };", + "'static' modifier cannot be used here. @10+6 help: Only 'async' modifier is allowed here.", + ], + ["let o = { declare a };", "'declare' modifier cannot be used here. @10+7 help: No modifiers are allowed here."], + [ + "let o = { abstract m() {} };", + "'abstract' modifier cannot be used here. @10+8 help: Only 'async' modifier is allowed here.", + ], + [ + "let o = { async async m() {} };", + "TS(1030) 'async' modifier already seen. @16+5 help: Remove the duplicate modifier.", + ], + ["({ async get a(){} });", "'async' modifier cannot be used here. @3+5 help: No modifiers are allowed here."], + ["function f(a, ...b, c){}", "A rest parameter must be last in a parameter list @14+4"], + ["(...a, b) => 1;", "A rest parameter must be last in a parameter list @1+4"], + ["function f(...a = []){}", "A rest parameter cannot have an initializer. @14+6"], + [ + "function f(...a,){}", + "A rest parameter or binding pattern may not have a trailing comma. @15+1 help: Remove the trailing comma here", + ], + [ + "(...a,) => 1;", + "A rest parameter or binding pattern may not have a trailing comma. @5+1 help: Remove the trailing comma here", + ], + ["(...a = []) => 1;", "A rest parameter cannot have an initializer. @4+6"], + ["class A { m(...a, b) {} }", "A rest parameter must be last in a parameter list @12+4"], + [ + "let o = { m(...a,) {} };", + "A rest parameter or binding pattern may not have a trailing comma. @16+1 help: Remove the trailing comma here", + ], + ["function f(...[a] = []) {}", "A rest parameter cannot have an initializer. @14+8"], + ["function f(...a, ...b) {}", "A rest parameter must be last in a parameter list @11+4"], + ["let zz; let zz; function f(...a = []){}", "A rest parameter cannot have an initializer. @30+6"], + [ + "let zz; let zz; function f(...a,){}", + "A rest parameter or binding pattern may not have a trailing comma. @31+1 help: Remove the trailing comma here", + ], + ["class A { set a(...v) {} }", "A 'set' accessor cannot have rest parameter. @16+4"], + ["{ import a from 'a'; }", "'import statement' declaration can only be used at the top level of a module @2+6"], + [ + "function f(){ export {}; }", + "'export statement' declaration can only be used at the top level of a module @14+6", + ], + ["if (1) export {};", "'export statement' declaration can only be used at the top level of a module @7+6"], + ["{ export const a = 1; }", "'export statement' declaration can only be used at the top level of a module @2+6"], + ["{ export default 1; }", "'export statement' declaration can only be used at the top level of a module @2+6"], + ['{ export * from "a"; }', "'export statement' declaration can only be used at the top level of a module @2+6"], + ['{ import "a"; }', "'import statement' declaration can only be used at the top level of a module @2+6"], + [ + 'if (1) import a from "a";', + "'import statement' declaration can only be used at the top level of a module @7+6", + ], + [ + 'function f() { import a from "a"; }', + "'import statement' declaration can only be used at the top level of a module @15+6", + ], + ["() => { export {}; };", "'export statement' declaration can only be used at the top level of a module @8+6"], + [ + 'class A { static { import a from "a"; } }', + "'import statement' declaration can only be used at the top level of a module @19+6", + ], + [ + "let zz; let zz; { import a from 'a'; }", + "'import statement' declaration can only be used at the top level of a module @18+6", + ], + [ + "{ import a from 'a'; } a ?? b || c;", + "'import statement' declaration can only be used at the top level of a module @2+6", + "Logical expressions and coalesce expressions cannot be mixed @23+11 help: Wrap either expression by parentheses", + ], + ['a: import b from "b";', "'import statement' declaration can only be used at the top level of a module @3+6"], + ["while (1) export {};", "'export statement' declaration can only be used at the top level of a module @10+6"], + ["export async class A {}", "Unexpected token @7+5"], + [ + "export export class A {}", + "TS(1030) 'export' modifier already seen. @7+6 help: Remove the duplicate modifier.", + ], + ["export static class A {}", "Unexpected token @7+6"], + [ + "export default declare class A {}", + "Expected a semicolon or an implicit semicolon after a statement, but found none @22+0 help: Try inserting a semicolon here", + ], + [ + "export default static class A {}", + "Expected a semicolon or an implicit semicolon after a statement, but found none @21+0 help: Try inserting a semicolon here", + ], + [ + "export default async class A {}", + "Expected a semicolon or an implicit semicolon after a statement, but found none @20+0 help: Try inserting a semicolon here", + ], + [ + "declare export class A {}", + "Expected a semicolon or an implicit semicolon after a statement, but found none @7+0 help: Try inserting a semicolon here", + ], + ["export public class A {}", "Unexpected token @7+6"], + ["export readonly a;", "Unexpected token @7+8"], + ["export accessor a;", "Unexpected token @7+8"], + [ + "public class A {}", + "Expected a semicolon or an implicit semicolon after a statement, but found none @6+0 help: Try inserting a semicolon here", + ], + [ + "static function f() {}", + "Expected a semicolon or an implicit semicolon after a statement, but found none @6+0 help: Try inserting a semicolon here", + ], + [ + "static let a;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @6+0 help: Try inserting a semicolon here", + ], + [ + "readonly let a;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @8+0 help: Try inserting a semicolon here", + ], + [ + "private function f() {}", + "Expected a semicolon or an implicit semicolon after a statement, but found none @7+0 help: Try inserting a semicolon here", + ], + [ + "declare const enum A {}", + "Expected a semicolon or an implicit semicolon after a statement, but found none @7+0 help: Try inserting a semicolon here", + ], + ["export declare const enum A {}", "Unexpected token @7+7"], + ["export default enum A {}", "Unexpected token @15+4"], + [ + "export default type A = 1;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @19+0 help: Try inserting a semicolon here", + ], + [ + "export default namespace A {}", + "Expected a semicolon or an implicit semicolon after a statement, but found none @24+0 help: Try inserting a semicolon here", + ], + [ + "export default abstract class A { abstract m(); }", + "TS(8009) The 'abstract' modifier can only be used in TypeScript files. @34+8", + "Expected function body @44+2 help: Add a function body (`{}`).", ], + ["export default abstract class A {}", 'Expected `{` but found `<` @31+1 "`{` expected"'], + ["@d function f() {}", "Unexpected token @3+8"], + ["@d let a;", "Unexpected token @3+3"], + ["@d export function f() {}", "Decorators are not valid here. @0+2"], + ["@d export default function f() {}", "Decorators are not valid here. @0+2"], + ["@d export const a = 1;", "Decorators are not valid here. @0+2"], + ['@d import a from "a";', "Unexpected token @3+6"], + ["@d export default 1;", "Decorators are not valid here. @0+2"], + ["export @d function f() {}", 'Expected `{` but found `(` @20+1 "`{` expected"'], + ["export @d const a = 1;", 'Expected `{` but found `=` @18+1 "`{` expected"'], + ["export @d default function f() {}", 'Expected `{` but found `(` @28+1 "`{` expected"'], + ["class A { @d static {} }", "Decorators are not valid here. @10+2"], + ["class A { @d [k: string]: number; }", "Decorators are not valid here. @10+2", "Unexpected token @15+1"], [ - 'declare module "a";', - "Expected a semicolon or an implicit semicolon after a statement, but found none @7+0 help: Try inserting a semicolon here", + "class A { get a(v) {} }", + "A 'get' accessor must not have any formal parameters. @15+3 help: Remove these parameters here", ], [ - "declare module A {}", - "Expected a semicolon or an implicit semicolon after a statement, but found none @7+0 help: Try inserting a semicolon here", + "class A { set a() {} }", + "TS(1049) A 'set' accessor must have exactly one parameter. @15+2 help: Add a parameter here", ], + ["class A { constructor(...a, b) {} }", "A rest parameter must be last in a parameter list @22+4"], + ["class A { get a() { return 1; } }", 'Expected `(` but found `<` @15+1 "`(` expected"'], [ - "declare namespace A {}", + "declare foo;", "Expected a semicolon or an implicit semicolon after a statement, but found none @7+0 help: Try inserting a semicolon here", ], [ - "declare global {}", + "declare foo bar;", "Expected a semicolon or an implicit semicolon after a statement, but found none @7+0 help: Try inserting a semicolon here", ], + ["export default @d function f() {}", "Decorators are not valid here. @15+2"], + ["@d @e export function f() {}", "Decorators are not valid here. @0+2", "Decorators are not valid here. @3+2"], [ - "declare interface A {}", - "Expected a semicolon or an implicit semicolon after a statement, but found none @7+0 help: Try inserting a semicolon here", + "@d export function f() {} a ?? b || c;", + "Decorators are not valid here. @0+2", + "Logical expressions and coalesce expressions cannot be mixed @26+11 help: Wrap either expression by parentheses", ], + ["let zz; let zz; @d export function f() {}", "Decorators are not valid here. @16+2"], + ["@d async function f() {}", "Unexpected token @9+8"], [ - "declare type A = 1;", - "Expected a semicolon or an implicit semicolon after a statement, but found none @7+0 help: Try inserting a semicolon here", + "@d static class A {}", + "'static' modifier cannot be used here. @3+6 help: Allowed modifiers are: declare, abstract", ], - ["export declare let a;", "Unexpected token @7+7"], - ["export declare const a;", "Unexpected token @7+7"], - ["export declare function f();", "Unexpected token @7+7"], - ["export declare class A {}", "Unexpected token @7+7"], + ["@d export abstract class A {}", "Decorators are not valid here. @0+2", "Unexpected token @10+8"], + ["@d export declare class A {}", "Decorators are not valid here. @0+2", "Unexpected token @10+7"], [ - "abstract class A {}", - "Expected a semicolon or an implicit semicolon after a statement, but found none @8+0 help: Try inserting a semicolon here", + "export @d static class A {}", + "'static' modifier cannot be used here. @10+6 help: Allowed modifiers are: declare, abstract", ], - ["export abstract class A {}", "Unexpected token @7+8"], + ["export @d let a;", 'Expected `{` but found `;` @15+1 "`{` expected"'], + ["export @d async function f() {}", 'Expected `{` but found `(` @26+1 "`{` expected"'], + ["();", "Empty parenthesized expression @0+2"], + ["a > ();", "Empty parenthesized expression @4+2"], + ["if () {}", "Unexpected token @4+1"], + ["function f<>() {}", 'Expected `(` but found `<` @10+1 "`(` expected"'], + ["class A<> {}", 'Expected `{` but found `<` @7+1 "`{` expected"'], + ["class A { m<>() {} }", 'Expected `(` but found `<` @11+1 "`(` expected"'], + ["enum A {} a ?? b || c;", "Unexpected token @0+4"], [ - "class A { abstract m(); }", - "TS(8009) The 'abstract' modifier can only be used in TypeScript files. @10+8", - "Expected function body @20+2 help: Add a function body (`{}`).", + "a ?? b || c; enum;", + "Logical expressions and coalesce expressions cannot be mixed @0+11 help: Wrap either expression by parentheses", + "Unexpected token @13+4", ], - ["class A { abstract a; }", "TS(8009) The 'abstract' modifier can only be used in TypeScript files. @10+8"], + ["export enum;", "Unexpected token @7+4"], + ["x = enum;", "Unexpected token @4+4"], + ["enum = 1;", "Unexpected token @0+4"], + ["let enum;", "Identifier expected. 'enum' is a reserved word that cannot be used here. @4+4"], [ - "class A { abstract m() {} }", - "TS(8009) The 'abstract' modifier can only be used in TypeScript files. @10+8", - "TS(1245) Method 'm' cannot have an implementation because it is marked abstract. @19+1", + "interface A {} a ?? b || c;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @9+0 help: Try inserting a semicolon here", ], + ["export = a; a ?? b || c;", "Unexpected token @7+1"], [ - "namespace A {}", - "Expected a semicolon or an implicit semicolon after a statement, but found none @9+0 help: Try inserting a semicolon here", + "type A = 1; a ?? b || c;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @4+0 help: Try inserting a semicolon here", ], + ['import type A from "a"; a ?? b || c;', 'Expected `from` but found `Identifier` @12+1 "`from` expected"'], [ - "namespace A.B {}", - "Expected a semicolon or an implicit semicolon after a statement, but found none @9+0 help: Try inserting a semicolon here", + 'import { type A } from "a"; a ?? b || c;', + 'Expected `,` or `}` but found `Identifier` @14+1 "`,` or `}` expected", 7+1 "Opened here"', ], [ - "module A {}", - "Expected a semicolon or an implicit semicolon after a statement, but found none @6+0 help: Try inserting a semicolon here", + 'import { a, type B } from "a";', + 'Expected `,` or `}` but found `Identifier` @17+1 "`,` or `}` expected", 7+1 "Opened here"', ], [ - 'module "a" {}', + 'import { type "a" as b } from "a";', + 'Expected `,` or `}` but found `string` @14+3 "`,` or `}` expected", 7+1 "Opened here"', + ], + [ + 'import { type default as b } from "a";', + 'Expected `,` or `}` but found `default` @14+7 "`,` or `}` expected", 7+1 "Opened here"', + ], + [ + 'import { type a, b } from "a";', + 'Expected `,` or `}` but found `Identifier` @14+1 "`,` or `}` expected", 7+1 "Opened here"', + ], + [ + "export { a, type B };", + 'Expected `,` or `}` but found `Identifier` @17+1 "`,` or `}` expected", 7+1 "Opened here"', + ], + ["export { type as };", "Unexpected token @17+1"], + ["export { type as as };", "Export 'type' is not defined @9+4"], + [ + "export { type as as as };", + 'Expected `,` or `}` but found `as` @20+2 "`,` or `}` expected", 7+1 "Opened here"', + ], + ['import type from from "a";', "Unexpected token @17+4"], + ['import type a, { b } from "a";', 'Expected `from` but found `Identifier` @12+1 "`from` expected"'], + ["export type A = T;", "Unexpected token @7+4"], + ["export type;", "Unexpected token @7+4"], + ["export type = 1;", "Unexpected token @7+4"], + ["function f(this, a) {}", "Identifier expected. 'this' is a reserved word that cannot be used here. @11+4"], + ["function f(a, this) {}", "Identifier expected. 'this' is a reserved word that cannot be used here. @14+4"], + [ + "(this) => 1;", "Expected a semicolon or an implicit semicolon after a statement, but found none @6+0 help: Try inserting a semicolon here", ], - ["export namespace A {}", "Unexpected token @7+9"], + ["class A { m(this) {} }", "Identifier expected. 'this' is a reserved word that cannot be used here. @12+4"], [ - "namespace\nA {}", - "Expected a semicolon or an implicit semicolon after a statement, but found none @11+0 help: Try inserting a semicolon here", + "let a!;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @5+0 help: Try inserting a semicolon here", ], [ - "global {}", + "let a! = 1;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @5+0 help: Try inserting a semicolon here", + ], + [ + "var a!: T, b;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @5+0 help: Try inserting a semicolon here", + ], + [ + "let a, b!: T;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @8+0 help: Try inserting a semicolon here", + ], + [ + "const a!: T = 1;", + "Missing initializer in const declaration @6+1 help: Add an initializer (e.g. ` = undefined`) here", + "Expected a semicolon or an implicit semicolon after a statement, but found none @7+0 help: Try inserting a semicolon here", + ], + [ + "namespace A { export const a = 1; }", + "Expected a semicolon or an implicit semicolon after a statement, but found none @9+0 help: Try inserting a semicolon here", + ], + [ + "module A.B {}", "Expected a semicolon or an implicit semicolon after a statement, but found none @6+0 help: Try inserting a semicolon here", ], - ["class A {}", 'Expected `{` but found `<` @7+1 "`{` expected"'], - ["class A extends B {}", "TS(8011) Type arguments can only be used in TypeScript files. @17+3"], - ["class A implements B {}", "TS(8005) 'implements' clauses can only be used in TypeScript files. @8+10"], + ["export module A {}", "Unexpected token @7+6"], + ['export module "a" {}', "Unexpected token @7+6"], + ['export declare module "a" {}', "Unexpected token @7+7"], + ["export declare global {}", "Unexpected token @7+7"], + ["export global {}", "Unexpected token @7+6"], + ["export as namespace A; a ?? b || c;", "Unexpected token @7+2"], + ["let f = async function () {};", 'Expected `(` but found `<` @23+1 "`(` expected"'], + ["let f = async function (): T {};", 'Expected `{` but found `:` @25+1 "`{` expected"'], + ["let f = function* (): T {};", 'Expected `{` but found `:` @20+1 "`{` expected"'], + ["let o = { async m(): T {} };", "Expected function body @17+2 help: Add a function body (`{}`)."], + ["let o = { *m(): T {} };", "Expected function body @12+2 help: Add a function body (`{}`)."], + ["let o = { set a(v): void {} };", "Expected function body @15+3 help: Add a function body (`{}`)."], + ["class A { constructor(): void {} }", "Expected function body @21+2 help: Add a function body (`{}`)."], + ["class A { static m(): void {} }", "Expected function body @18+2 help: Add a function body (`{}`)."], + ["class A { async *m(): T {} }", "Expected function body @18+2 help: Add a function body (`{}`)."], + ["class A { #m(): T {} }", "Expected function body @12+2 help: Add a function body (`{}`)."], + ["class A { [a](): T {} }", "Expected function body @13+2 help: Add a function body (`{}`)."], + ["export async function f(): T {}", "Expected function body @7+18 help: Add a function body (`{}`)."], + ["export default async function (): T {}", "Expected function body @15+17 help: Add a function body (`{}`)."], + ["export default function (): T {}", "Expected function body @15+11 help: Add a function body (`{}`)."], + ["export function f();", "Expected function body @7+12 help: Add a function body (`{}`)."], + ["export default function f();", "Expected function body @15+12 help: Add a function body (`{}`)."], + ["export default function ();", "Expected function body @15+11 help: Add a function body (`{}`)."], + ["async function f();", "Expected function body @0+18 help: Add a function body (`{}`)."], + ["function* f();", "Expected function body @0+13 help: Add a function body (`{}`)."], + ["function f(): void;", "Expected function body @0+12 help: Add a function body (`{}`)."], + ["function f()\nfoo();", "Expected function body @0+12 help: Add a function body (`{}`)."], + ["class A { m()\n n() {} }", "Expected function body @11+2 help: Add a function body (`{}`)."], + ["class A { get a(); }", "Expected function body @15+2 help: Add a function body (`{}`)."], + ["class A { static m(); }", "Expected function body @18+2 help: Add a function body (`{}`)."], + ["class A { async m(); }", "Expected function body @17+2 help: Add a function body (`{}`)."], + ["class A { m(): void; }", "Expected function body @11+2 help: Add a function body (`{}`)."], + ["(a, b: T) => a;", 'Expected `,` or `)` but found `:` @5+1 "`,` or `)` expected", 0+1 "Opened here"'], + ["({a}: T) => a;", 'Expected `,` or `)` but found `:` @4+1 "`,` or `)` expected", 0+1 "Opened here"'], + ["([a]: T) => a;", 'Expected `,` or `)` but found `:` @4+1 "`,` or `)` expected", 0+1 "Opened here"'], + ["(...a: T[]) => a;", 'Expected `,` or `)` but found `:` @5+1 "`,` or `)` expected", 0+1 "Opened here"'], + ["async (a?: T) => a;", 'Expected `,` or `)` but found `?` @8+1 "`,` or `)` expected", 6+1 "Opened here"'], + ["(): T => a;", "Empty parenthesized expression @0+2"], + ["(a: T): U => a;", 'Expected `,` or `)` but found `:` @2+1 "`,` or `)` expected", 0+1 "Opened here"'], + ["f((a: T) => a);", 'Expected `,` or `)` but found `:` @4+1 "`,` or `)` expected", 2+1 "Opened here"'], + [ + "x = (a): T => a;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @7+0 help: Try inserting a semicolon here", + ], + ["f((a): T => a);", 'Expected `,` or `)` but found `:` @5+1 "`,` or `)` expected", 1+1 "Opened here"'], + ["[(a): T => a];", 'Expected `,` or `]` but found `:` @4+1 "`,` or `]` expected", 0+1 "Opened here"'], + ["for (var a: T;;) {}", 'Expected `;` but found `:` @10+1 "`;` expected"'], + ["for (var a: T, b;;) {}", 'Expected `;` but found `:` @10+1 "`;` expected"'], + ["for (using a: T of b) {}", 'Expected `;` but found `:` @12+1 "`;` expected"'], + ["try {} catch ({a}: T) {}", 'Expected `)` but found `:` @17+1 "`)` expected"'], + [ + "let { a }: T = b, c;", + "Missing initializer in destructuring declaration @4+5 help: Add an initializer (e.g. ` = undefined`) here", + "Expected a semicolon or an implicit semicolon after a statement, but found none @9+0 help: Try inserting a semicolon here", + ], + [ + "export let a: T;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @12+0 help: Try inserting a semicolon here", + ], + [ + "export var a: T = 1, b;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @12+0 help: Try inserting a semicolon here", + ], + [ + "class A { static accessor a: T; }", + "Expected a semicolon or an implicit semicolon after a statement, but found none @27+0 help: Try inserting a semicolon here", + ], + ["class A { a: T\n b; }", 'Expected `;` but found `:` @11+1 "`;` expected"'], + ['class A { "a": T; }', 'Expected `;` but found `:` @13+1 "`;` expected"'], + ["class A { [a]: T; }", 'Expected `;` but found `:` @13+1 "`;` expected"'], + ["class A { 1: T; }", 'Expected `;` but found `:` @11+1 "`;` expected"'], + ["class A { a: T = 1 }", 'Expected `;` but found `:` @11+1 "`;` expected"'], + [ + "class A { readonly a: T; }", + "TS(8009) The 'readonly' modifier can only be used in TypeScript files. @10+8", + 'Expected `;` but found `:` @20+1 "`;` expected"', + ], + [ + "x = a as b as c;", + "TS(8016) Type assertion expressions can only be used in TypeScript files. @4+6", + "TS(8016) Type assertion expressions can only be used in TypeScript files. @4+11", + ], + ["x = (a as b).c;", "TS(8016) Type assertion expressions can only be used in TypeScript files. @5+6"], + [ + "f(a as b, c as d);", + "TS(8016) Type assertion expressions can only be used in TypeScript files. @2+6", + "TS(8016) Type assertion expressions can only be used in TypeScript files. @10+6", + ], + ["a as b + 1;", "TS(8016) Type assertion expressions can only be used in TypeScript files. @0+6"], + ["a + b as c;", "TS(8016) Type assertion expressions can only be used in TypeScript files. @0+10"], + [ + "a as const as b;", + "TS(8016) Type assertion expressions can only be used in TypeScript files. @0+10", + "TS(8016) Type assertion expressions can only be used in TypeScript files. @0+15", + ], + [ + "a satisfies b as c;", + "TS(8037) Type satisfaction expressions can only be used in TypeScript files. @0+13", + "TS(8016) Type assertion expressions can only be used in TypeScript files. @0+18", + ], + ["a as\nb;", "TS(8016) Type assertion expressions can only be used in TypeScript files. @0+6"], [ - "class A extends B implements C {}", - "TS(8005) 'implements' clauses can only be used in TypeScript files. @18+10", + "a\nas b;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @4+0 help: Try inserting a semicolon here", ], - ["let x = class {};", 'Expected `{` but found `<` @13+1 "`{` expected"'], - ["let x = class A {};", 'Expected `{` but found `<` @15+1 "`{` expected"'], - ["let x = class implements B {};", "TS(8005) 'implements' clauses can only be used in TypeScript files. @14+10"], - ["class A { a: number; }", 'Expected `;` but found `:` @11+1 "`;` expected"'], - ["class A { a: number = 1; }", 'Expected `;` but found `:` @11+1 "`;` expected"'], - ["class A { a?: number; }", 'Expected `;` but found `:` @12+1 "`;` expected"'], - ["class A { a!: number; }", 'Expected `;` but found `:` @12+1 "`;` expected"'], + ["(a as b) = 1;", "TS(8016) Type assertion expressions can only be used in TypeScript files. @1+6"], + ["for (a as b of c) {}", "TS(8016) Type assertion expressions can only be used in TypeScript files. @5+6"], + ["let { a = b as c } = d;", "TS(8016) Type assertion expressions can only be used in TypeScript files. @10+6"], + ["x = b as c;", "TS(8016) Type assertion expressions can only be used in TypeScript files. @4+13"], + ["(a) => a;", "Unexpected token. Did you mean `{'>'}` or `>`? @8+0"], + ["a;", "Unexpected token @3+3"], + ["let x = a;", "Unexpected token @11+3"], [ - "class A { a!; }", - "TS(1264) Declarations with definite assignment assertions must also have type annotations. @11+1", + "let x = a ? (b): c => d : e;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @23+0 help: Try inserting a semicolon here", ], - ["class A { private a; }", "TS(8009) The 'private' modifier can only be used in TypeScript files. @10+7"], - ["class A { private a = 1; }", "TS(8009) The 'private' modifier can only be used in TypeScript files. @10+7"], - ["class A { public a; }", "TS(8009) The 'public' modifier can only be used in TypeScript files. @10+6"], - ["class A { protected a; }", "TS(8009) The 'protected' modifier can only be used in TypeScript files. @10+9"], - ["class A { readonly a; }", "TS(8009) The 'readonly' modifier can only be used in TypeScript files. @10+8"], - ["class A { readonly a = 1; }", "TS(8009) The 'readonly' modifier can only be used in TypeScript files. @10+8"], - ["class A { declare a; }", "TS(8009) The 'declare' modifier can only be used in TypeScript files. @10+7"], - ["class A { override a; }", "TS(8009) The 'override' modifier can only be used in TypeScript files. @10+8"], - ["class A { override m() {} }", "TS(8009) The 'override' modifier can only be used in TypeScript files. @10+8"], - ["class A { private m() {} }", "TS(8009) The 'private' modifier can only be used in TypeScript files. @10+7"], - ["class A { public m() {} }", "TS(8009) The 'public' modifier can only be used in TypeScript files. @10+6"], + ["let a = b;", "Unexpected token @15+3"], + ["`${a!}`;", 'Expected `}` but found `!` @4+1 "`}` expected"'], + ["(@d a) => 1;", "Unexpected token @4+1"], + ["class A { static static a; }", 'Expected `;` but found `Identifier` @24+1 "`;` expected"'], [ - "class A { public static m() {} }", - "TS(8009) The 'public' modifier can only be used in TypeScript files. @10+6", + "class A extends B<> {}", + "TS(1099) Type argument list cannot be empty. @17+2", + "TS(8011) Type arguments can only be used in TypeScript files. @17+2", ], [ - "class A { static readonly a = 1; }", - "TS(8009) The 'readonly' modifier can only be used in TypeScript files. @17+8", + "export default interface A { a: number }", + "Expected a semicolon or an implicit semicolon after a statement, but found none @30+0 help: Try inserting a semicolon here", ], - ["class A { private static a; }", "TS(8009) The 'private' modifier can only be used in TypeScript files. @10+7"], + ["export default interface A {}", 'Expected `{` but found `<` @26+1 "`{` expected"'], + ["@d;", "Unexpected token @2+1"], + ["@d a;", "Unexpected token @3+1"], + ["export @d default 1;", 'Expected `{` but found `;` @19+1 "`{` expected"'], + ["class A { @d; }", "Unexpected token @12+1"], + ["<>() => 1;", "Unexpected token. Did you mean `{'>'}` or `>`? @6+0"], + ['import type "a";', 'Expected `from` but found `string` @12+3 "`from` expected"'], + ["import type;", 'Expected `from` but found `;` @11+1 "`from` expected"'], + ["(a = 1, b?: T) => a;", "Unexpected token @10+1"], [ - "class A { private constructor() {} }", - "TS(8009) The 'private' modifier can only be used in TypeScript files. @10+7", + "x = a ? (b): T => c : d;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @19+0 help: Try inserting a semicolon here", ], - ["class A { m(): void {} }", "Expected function body @11+2 help: Add a function body (`{}`)."], - ["class A { m() {} }", 'Expected `(` but found `<` @11+1 "`(` expected"'], [ - "class A { m(a: number) {} }", - 'Expected `,` or `)` but found `:` @13+1 "`,` or `)` expected", 11+1 "Opened here"', + "await using a: T = b;", + "Using declarations must have an initializer. @12+1 help: Add an initializer (e.g. ` = undefined`) here", + "Expected a semicolon or an implicit semicolon after a statement, but found none @13+0 help: Try inserting a semicolon here", ], - ["class A { m(a?) {} }", 'Expected `,` or `)` but found `?` @13+1 "`,` or `)` expected", 11+1 "Opened here"'], - ["class A { m(); }", "Expected function body @11+2 help: Add a function body (`{}`)."], - ["class A { m(); m() {} }", "Expected function body @11+2 help: Add a function body (`{}`)."], + ["", "Unexpected token @3+1"], + ["a", "Unexpected token @3+2"], + ["{a}", "Unexpected token @6+1"], + ["{a} b", "Unexpected token @6+3"], + ["a", "Unexpected token @6+2"], + ["a;", "Unexpected token @10+3"], + ["a;", 'Expected corresponding JSX closing tag for \'T\'. @6+1 "Expected ``", 1+1 "Opened here"'], + ["a<", "Unexpected token @6+0"], + ["aa` but found `EOF` @8+0 "`>` expected"'], + ["x = a; b; c;", "Unexpected token @7+9"], + ["a;", "Unexpected token @5+3"], + ["<>a;", "Unexpected token @2+3"], + ['c;', "Unexpected token @9+3"], [ - "class A { constructor(private a) {} }", - "TS(8012) Parameter modifiers can only be used in TypeScript files. @22+7 help: No modifiers are allowed here.", + "a;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @5+0 help: Try inserting a semicolon here", ], + ["f(a);", "Unexpected token @5+4"], + ["(a) => a;\nb > c;", "Unexpected token. Did you mean `{'>'}` or `>`? @8+0"], + ["(a) => { return a; };", "Unexpected token. Did you mean `{'>'}` or `>`? @8+0"], + ["a}b;", "Unexpected token. Did you mean `{'}'}` or `}`? @4+0"], + ["(a, b?) => 1;", "Unexpected token @6+1"], + ["(a, b? = 1) => 1;", "Unexpected token @7+1"], + ["(a, b?, c) => 1;", "Unexpected token @6+1"], + ["({a}?: T) => 1;", "Unexpected token @5+1"], + ["([a]?) => 1;", "Unexpected token @5+1"], + ["(a = 1, b?) => 1;", "Unexpected token @10+1"], + ["async (a, b?) => 1;", "Unexpected token @12+1"], + ["async (a, b?: T) => 1;", "Unexpected token @12+1"], + ["async ({a}?: T) => 1;", "Unexpected token @11+1"], + ["async (a = 1, b: T) => 1;", 'Expected `,` or `)` but found `:` @15+1 "`,` or `)` expected", 6+1 "Opened here"'], + ["(a?, b?) => 1;", 'Expected `,` or `)` but found `?` @2+1 "`,` or `)` expected", 0+1 "Opened here"'], [ - "class A { constructor(public a) {} }", - "TS(8012) Parameter modifiers can only be used in TypeScript files. @22+6 help: No modifiers are allowed here.", + "(a, b): T => 1;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @6+0 help: Try inserting a semicolon here", ], + ["(this, a?) => 1;", "Unexpected token @9+1"], [ - "class A { constructor(readonly a) {} }", - "TS(8012) Parameter modifiers can only be used in TypeScript files. @22+8 help: No modifiers are allowed here.", + "(a, this) => 1;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @9+0 help: Try inserting a semicolon here", ], - ["class A { constructor(); }", "Expected function body @21+2 help: Add a function body (`{}`)."], - ["class A { [k: string]: number; }", "Unexpected token @12+1"], - ["class A { static [k: string]: number; }", "Unexpected token @19+1"], - ["class A { get a(): number { return 1; } }", "Expected function body @15+2 help: Add a function body (`{}`)."], + ["f((a, b?) => 1);", "Unexpected token @8+1"], [ - "class A { set a(v: number) {} }", - 'Expected `,` or `)` but found `:` @17+1 "`,` or `)` expected", 15+1 "Opened here"', + "x = a ? (b, c): T => d : e;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @22+0 help: Try inserting a semicolon here", ], [ - "class A { accessor a: number; }", - "Expected a semicolon or an implicit semicolon after a statement, but found none @20+0 help: Try inserting a semicolon here", + "x = a ? (b): T => c : d ? e : f;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @19+0 help: Try inserting a semicolon here", ], - ["class A { static a: number; }", 'Expected `;` but found `:` @18+1 "`;` expected"'], - ["class A { #a: number; }", 'Expected `;` but found `:` @12+1 "`;` expected"'], - ["class A { private #a; }", "TS(8009) The 'private' modifier can only be used in TypeScript files. @10+7"], + ["f(a ? (b): T => c : d);", 'Expected `,` or `)` but found `:` @18+1 "`,` or `)` expected", 1+1 "Opened here"'], + ["x = a ? (): T => c : d;", "Empty parenthesized expression @8+2"], [ - "class A { in a; }", - "TS(8009) The 'in' modifier can only be used in TypeScript files. @10+2", - "TS(1274) 'in' modifier can only appear on a type parameter of a class, interface or type alias. @10+2", + "x = a ? (b: U): T => c : d;", + 'Expected `,` or `)` but found `:` @10+1 "`,` or `)` expected", 8+1 "Opened here"', ], [ - "class A { out a; }", - "TS(8009) The 'out' modifier can only be used in TypeScript files. @10+3", - "TS(1274) 'out' modifier can only appear on a type parameter of a class, interface or type alias. @10+3", + "x = a ? async (b): T => c : d;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @25+0 help: Try inserting a semicolon here", ], [ - "class A { const a; }", - "TS(8009) The 'const' modifier can only be used in TypeScript files. @10+5", - "TS(1248) A class member cannot have the 'const' keyword. @10+5 help: Did you mean `readonly`?", + "class A { default default a; }", + "TS(1030) 'default' modifier already seen. @18+7 help: Remove the duplicate modifier.", ], - ["class A { export a; }", "TS(1031) 'export' modifier cannot appear on class elements of this kind. @10+6"], - ["class A { async a; }", "'async' modifier cannot be used here. @10+5"], - ['import type A from "a";', 'Expected `from` but found `Identifier` @12+1 "`from` expected"'], - ['import type { A } from "a";', 'Expected `from` but found `{` @12+1 "`from` expected"'], - ['import type * as A from "a";', 'Expected `from` but found `*` @12+1 "`from` expected"'], [ - 'import { type A } from "a";', - 'Expected `,` or `}` but found `Identifier` @14+1 "`,` or `}` expected", 7+1 "Opened here"', + "class A extends B<> implements C {}", + "TS(1099) Type argument list cannot be empty. @17+2", + "TS(8011) Type arguments can only be used in TypeScript files. @17+2", + "TS(8005) 'implements' clauses can only be used in TypeScript files. @20+10", ], [ - 'import { type A as B } from "a";', - 'Expected `,` or `}` but found `Identifier` @14+1 "`,` or `}` expected", 7+1 "Opened here"', + "class A extends B< > {}", + "TS(1099) Type argument list cannot be empty. @17+3", + "TS(8011) Type arguments can only be used in TypeScript files. @17+3", ], - ['import { type as } from "a";', "Unexpected token @17+1"], [ - 'import { type as as as } from "a";', - 'Expected `,` or `}` but found `as` @20+2 "`,` or `}` expected", 7+1 "Opened here"', + "class A implements B<> {}", + "TS(8005) 'implements' clauses can only be used in TypeScript files. @8+10", + "TS(1099) Type argument list cannot be empty. @20+2", ], [ - 'import a = require("a");', - "TS(8002) 'import ... =' can only be used in TypeScript files. @0+24 help: TypeScript transforms 'import ... =' to 'const ... ='", + "let x = class extends B<> {};", + "TS(1099) Type argument list cannot be empty. @23+2", + "TS(8011) Type arguments can only be used in TypeScript files. @23+2", ], + ["class A { static constructor; }", "Classes can't have a field named 'constructor' @17+11"], + ["class A { static constructor = 1; }", "Classes can't have a field named 'constructor' @17+11"], + ["class A { constructor; }", 'Expected `(` but found `;` @21+1 "`(` expected"'], [ - "import a = b.c;", - "TS(8002) 'import ... =' can only be used in TypeScript files. @0+15 help: TypeScript transforms 'import ... =' to 'const ... ='", + "export @d export class A {}", + "'export' modifier cannot be used here. @10+6 help: Allowed modifiers are: declare, abstract", ], - ['import type a = require("a");', 'Expected `from` but found `Identifier` @12+1 "`from` expected"'], [ - 'export import a = require("a");', - "TS(8002) 'import ... =' can only be used in TypeScript files. @7+24 help: TypeScript transforms 'import ... =' to 'const ... ='", + "export using a = b;", + "Using declarations cannot be exported directly. @7+7 help: Remove the `export` here and add `export { a }` as a separate statement to export the declaration", ], [ - "export import a = b.c;", - "TS(8002) 'import ... =' can only be used in TypeScript files. @7+15 help: TypeScript transforms 'import ... =' to 'const ... ='", + "export default interface A { a b }", + "Expected a semicolon or an implicit semicolon after a statement, but found none @30+0 help: Try inserting a semicolon here", ], - ["export type { A };", "Unexpected token @7+4"], - ['export type { A } from "a";', "Unexpected token @7+4"], - ['export type * from "a";', "Unexpected token @7+4"], - ['export type * as A from "a";', "Unexpected token @7+4"], [ - "export { type A };", - 'Expected `,` or `}` but found `Identifier` @14+1 "`,` or `}` expected", 7+1 "Opened here"', + "export default interface A { a?: T }", + "Expected a semicolon or an implicit semicolon after a statement, but found none @31+0 help: Try inserting a semicolon here", ], [ - 'export { type A } from "a";', - 'Expected `,` or `}` but found `Identifier` @14+1 "`,` or `}` expected", 7+1 "Opened here"', + "export default interface A { m(a: T) }", + 'Expected `,` or `)` but found `:` @32+1 "`,` or `)` expected", 30+1 "Opened here"', ], + ["export default interface A { m() }", 'Expected `(` but found `<` @30+1 "`(` expected"'], + ["export default interface A { [k: string]: T }", "Unexpected token @31+1"], [ - "export { type A as B };", - 'Expected `,` or `}` but found `Identifier` @14+1 "`,` or `}` expected", 7+1 "Opened here"', + "export default interface A { [k]: T }", + "Expected a semicolon or an implicit semicolon after a statement, but found none @32+0 help: Try inserting a semicolon here", ], - ["export = a;", "Unexpected token @7+1"], - ["export as namespace A;", "Unexpected token @7+2"], - ["let o = { m(): void {} };", "Expected function body @11+2 help: Add a function body (`{}`)."], - ["let o = { m() {} };", 'Expected `(` but found `<` @11+1 "`(` expected"'], [ - "let o = { m(a: number) {} };", - 'Expected `,` or `)` but found `:` @13+1 "`,` or `)` expected", 11+1 "Opened here"', + "export default interface A { readonly a: T }", + "Expected a semicolon or an implicit semicolon after a statement, but found none @39+0 help: Try inserting a semicolon here", ], - ["let o = { m?() {} };", 'Expected `,` or `}` but found `?` @11+1 "`,` or `}` expected", 8+1 "Opened here"'], - ["let o = { a?: 1 };", 'Expected `,` or `}` but found `?` @11+1 "`,` or `}` expected", 8+1 "Opened here"'], - ["let o = { a!: 1 };", 'Expected `,` or `}` but found `!` @11+1 "`,` or `}` expected", 8+1 "Opened here"'], - ["let o = { get a(): number { return 1; } };", "Expected function body @15+2 help: Add a function body (`{}`)."], [ - "let o = { private a: 1 };", - "'private' modifier cannot be used here. @10+7 help: No modifiers are allowed here.", + "export default interface A { set a(v: T) }", + 'Expected `,` or `)` but found `:` @36+1 "`,` or `)` expected", 34+1 "Opened here"', ], [ - "let o = { readonly a: 1 };", - "'readonly' modifier cannot be used here. @10+8 help: No modifiers are allowed here.", + 'export default interface A { "a": T }', + "Expected a semicolon or an implicit semicolon after a statement, but found none @32+0 help: Try inserting a semicolon here", ], - ["let o = { async m() {} };", 'Expected `(` but found `<` @17+1 "`(` expected"'], - ["try {} catch (e: unknown) {}", 'Expected `)` but found `:` @15+1 "`)` expected"'], - ["try {} catch (e: any) {}", 'Expected `)` but found `:` @15+1 "`)` expected"'], - ["for (let a: number of []) {}", 'Expected `;` but found `:` @10+1 "`;` expected"'], - ["for (const a: number in {}) {}", 'Expected `;` but found `:` @12+1 "`;` expected"'], - ["for (let a: number = 0;;) {}", 'Expected `;` but found `:` @10+1 "`;` expected"'], [ - "let x: typeof a;", - "Expected a semicolon or an implicit semicolon after a statement, but found none @5+0 help: Try inserting a semicolon here", + "export default interface A { a; b: T }", + "Expected a semicolon or an implicit semicolon after a statement, but found none @33+0 help: Try inserting a semicolon here", ], [ - "let x: A.B;", - "Expected a semicolon or an implicit semicolon after a statement, but found none @5+0 help: Try inserting a semicolon here", + "export default interface A extends f() {}", + "TS(2499) An interface can only extend an identifier/qualified-name with optional type arguments. @35+3", ], + ["export default interface A extends B {}", 'Expected `{` but found `<` @26+1 "`{` expected"'], + ["export default interface {}", "Unexpected token @25+1"], [ - "let x: A;", - "Expected a semicolon or an implicit semicolon after a statement, but found none @5+0 help: Try inserting a semicolon here", + "export default interface A {} a ?? b || c;", + "Logical expressions and coalesce expressions cannot be mixed @30+11 help: Wrap either expression by parentheses", ], + [";", "Identifiers in JSX cannot contain hyphens @3+3 help: Remove the hyphen from the identifier"], + [";", "TS(17000) JSX attributes must only be assigned a non-empty 'expression'. @5+2"], [ - "let x: { a: 1 };", - "Expected a semicolon or an implicit semicolon after a statement, but found none @5+0 help: Try inserting a semicolon here", + "; a ?? b || c;", + "TS(17000) JSX attributes must only be assigned a non-empty 'expression'. @5+2", + "Logical expressions and coalesce expressions cannot be mixed @12+11 help: Wrap either expression by parentheses", ], [ - "let x: () => void;", - "Expected a semicolon or an implicit semicolon after a statement, but found none @5+0 help: Try inserting a semicolon here", + "for await (const a: T of b) {}", + 'Expected `;` but found `:` @18+1 "`;` expected"', + "`for await` loops are only allowed within async functions and at the top levels of modules @4+5 help: Either remove this `await` or add the `async` keyword to the enclosing function", ], + ["try {} catch (e?) {}", 'Expected `)` but found `?` @15+1 "`)` expected"'], + ["x = a ? (b): T[] => c : d;", "Unexpected token @15+1"], [ - "let x: [a, b];", - "Expected a semicolon or an implicit semicolon after a statement, but found none @5+0 help: Try inserting a semicolon here", + "export default interface A extends B {}", + "TS(8011) Type arguments can only be used in TypeScript files. @36+3", ], [ - 'let x: "a";', - "Expected a semicolon or an implicit semicolon after a statement, but found none @5+0 help: Try inserting a semicolon here", + "export default interface\nA {}", + "Expected a semicolon or an implicit semicolon after a statement, but found none @26+0 help: Try inserting a semicolon here", ], [ - "let x: 1;", - "Expected a semicolon or an implicit semicolon after a statement, but found none @5+0 help: Try inserting a semicolon here", + "export using {a} = b;", + "Using declarations cannot be exported directly. @7+7 help: Remove the `export` here and add `export { { }` as a separate statement to export the declaration", ], + ["x = a ? (b): T[][] => c : d;", "Unexpected token @15+1"], [ - "let x: a | b;", - "Expected a semicolon or an implicit semicolon after a statement, but found none @5+0 help: Try inserting a semicolon here", + "x = a ? (b): T.U => c : d;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @16+0 help: Try inserting a semicolon here", ], + ["x = a ? (b): T => c : d;", "Unexpected token @18+2"], [ - "let x: a[];", - "Expected a semicolon or an implicit semicolon after a statement, but found none @5+0 help: Try inserting a semicolon here", + "x = a ? (b): [T] => c : d;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @16+0 help: Try inserting a semicolon here", ], - ["function f(a): a is string {}", "Expected function body @0+13 help: Add a function body (`{}`)."], - ["function f(a): asserts a {}", "Expected function body @0+13 help: Add a function body (`{}`)."], - ["function* f(): T {}", "Expected function body @0+13 help: Add a function body (`{}`)."], - ["async function f(): Promise {}", "Expected function body @0+18 help: Add a function body (`{}`)."], - ["async function f() {}", 'Expected `(` but found `<` @16+1 "`(` expected"'], - ["export default function f(): void {}", "Expected function body @15+12 help: Add a function body (`{}`)."], - ["export default function () {}", 'Expected `(` but found `<` @24+1 "`(` expected"'], - ["export default class {}", 'Expected `{` but found `<` @20+1 "`{` expected"'], [ - "export function f(a: number) {}", - 'Expected `,` or `)` but found `:` @19+1 "`,` or `)` expected", 17+1 "Opened here"', + "x = a ? (b): {} => c : d;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @15+0 help: Try inserting a semicolon here", ], [ - "export const a: number = 1;", - "Missing initializer in const declaration @13+1 help: Add an initializer (e.g. ` = undefined`) here", - "Expected a semicolon or an implicit semicolon after a statement, but found none @14+0 help: Try inserting a semicolon here", + "x = a ? (b): () => T => c : d;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @25+0 help: Try inserting a semicolon here", ], - ["export class A {}", 'Expected `{` but found `<` @14+1 "`{` expected"'], - ["export class A implements B {}", "TS(8005) 'implements' clauses can only be used in TypeScript files. @15+10"], - ["@d class A { @d a: number; }", 'Expected `;` but found `:` @17+1 "`;` expected"'], - ["class A { m(@d a) {} }", "Decorators are not valid here. @12+2"], - ["class A { constructor(@d a) {} }", "Decorators are not valid here. @22+2"], - ["function f(@d a) {}", "Decorators are not valid here. @11+2"], [ - "accessor a;", - "Expected a semicolon or an implicit semicolon after a statement, but found none @8+0 help: Try inserting a semicolon here", + "x = a ? (b): T | U => c : d;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @18+0 help: Try inserting a semicolon here", ], [ - "unique symbol;", - "Expected a semicolon or an implicit semicolon after a statement, but found none @6+0 help: Try inserting a semicolon here", + 'x = a ? (b): "a" => c : d;', + "Expected a semicolon or an implicit semicolon after a statement, but found none @16+0 help: Try inserting a semicolon here", ], + ["x = a ? (b): void => c : d;", "Unexpected token @18+2"], [ - "keyof a;", - "Expected a semicolon or an implicit semicolon after a statement, but found none @5+0 help: Try inserting a semicolon here", + "x = a ? (b): typeof T => c : d;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @21+0 help: Try inserting a semicolon here", ], + ["export default interface\nA\n{}", "The keyword 'interface' is reserved @15+9"], [ - "using a: T = b;", - "Using declarations must have an initializer. @6+1 help: Add an initializer (e.g. ` = undefined`) here", - "Expected a semicolon or an implicit semicolon after a statement, but found none @7+0 help: Try inserting a semicolon here", + "export default interface\nA extends B {}", + "Expected a semicolon or an implicit semicolon after a statement, but found none @26+0 help: Try inserting a semicolon here", ], + ["export default interface\nA;", "The keyword 'interface' is reserved @15+9"], [ - "let zz; let zz; let a: number = 1;", - "Expected a semicolon or an implicit semicolon after a statement, but found none @21+0 help: Try inserting a semicolon here", + "export default interface A extends B, C {}", + "TS(8011) Type arguments can only be used in TypeScript files. @36+3", + "TS(8011) Type arguments can only be used in TypeScript files. @42+3", ], [ - "let zz; let zz; interface A {}", - "Expected a semicolon or an implicit semicolon after a statement, but found none @25+0 help: Try inserting a semicolon here", + "export default interface A extends B<> {}", + "TS(1099) Type argument list cannot be empty. @36+2", + "TS(8011) Type arguments can only be used in TypeScript files. @36+2", ], [ - "let zz; let zz; class A { private a; } a ?? b || c;", - "TS(8009) The 'private' modifier can only be used in TypeScript files. @26+7", - "Logical expressions and coalesce expressions cannot be mixed @39+11 help: Wrap either expression by parentheses", + "export default interface A extends B.C {}", + "TS(8011) Type arguments can only be used in TypeScript files. @38+3", ], + ["let x = 1;\nconst", "Unexpected token @17+0"], + ["export var", "Unexpected token @11+0"], + ["for (const of a);", 'Expected `;` but found `Identifier` @14+1 "`;` expected"'], + ["class extends A {}", "A class name is required. @0+5"], + ["x = {a?: 1}", 'Expected `,` or `}` but found `?` @6+1 "`,` or `}` expected", 4+1 "Opened here"'], + ["x = {async a}", "'async' modifier cannot be used here. @5+5 help: No modifiers are allowed here."], + ["class A { async a }", "'async' modifier cannot be used here. @10+5"], + ["try {} catch (a = 1) {}", 'Expected `)` but found `=` @16+1 "`)` expected"'], [ - "let zz; let zz; a ?? b || c; class A { private a; }", - "Logical expressions and coalesce expressions cannot be mixed @16+11 help: Wrap either expression by parentheses", - "TS(8009) The 'private' modifier can only be used in TypeScript files. @39+7", + "let a!", + "Expected a semicolon or an implicit semicolon after a statement, but found none @5+0 help: Try inserting a semicolon here", ], [ - "let zz; let zz; a ?? b || c; let a: number;", - "Logical expressions and coalesce expressions cannot be mixed @16+11 help: Wrap either expression by parentheses", - "Expected a semicolon or an implicit semicolon after a statement, but found none @34+0 help: Try inserting a semicolon here", + 'declare module "a"', + "Expected a semicolon or an implicit semicolon after a statement, but found none @7+0 help: Try inserting a semicolon here", ], + ["export as namespace a", "Unexpected token @7+2"], + ["for (let of a);", 'Expected `;` but found `Identifier` @12+1 "`;` expected"'], + ["for (var of a);", 'Expected `;` but found `Identifier` @12+1 "`;` expected"'], + ["for (const of);", 'Expected `;` but found `)` @13+1 "`;` expected"'], + ["export default function f(a: {}): T {}", 'Expected `(` but found `<` @25+1 "`(` expected"'], + ["export default function f(): Promise {}", 'Expected `(` but found `<` @25+1 "`(` expected"'], [ - "let zz; let zz; a ?? b || c; abstract class A {}", - "Logical expressions and coalesce expressions cannot be mixed @16+11 help: Wrap either expression by parentheses", - "Expected a semicolon or an implicit semicolon after a statement, but found none @37+0 help: Try inserting a semicolon here", + "function g() {\n function init(config: {}): void {}\n}", + 'Expected `,` or `)` but found `:` @37+1 "`,` or `)` expected", 30+1 "Opened here"', ], [ - "let zz; let zz; a ?? b || c; declare let x;", - "Logical expressions and coalesce expressions cannot be mixed @16+11 help: Wrap either expression by parentheses", - "Expected a semicolon or an implicit semicolon after a statement, but found none @36+0 help: Try inserting a semicolon here", + "function V0({v1, v2}: V3<{v1: any, v2: V4}>): V12.V11 {}", + 'Expected `,` or `)` but found `:` @20+1 "`,` or `)` expected", 11+1 "Opened here"', ], [ - "let zz; let zz; a ?? b || c; function f(a?) {}", - "Logical expressions and coalesce expressions cannot be mixed @16+11 help: Wrap either expression by parentheses", - 'Expected `,` or `)` but found `?` @41+1 "`,` or `)` expected", 39+1 "Opened here"', + "function V0({v1}: V2<{v1?: V3}>): V2b.V2a {}", + 'Expected `,` or `)` but found `:` @16+1 "`,` or `)` expected", 11+1 "Opened here"', ], + ["class A { m(a: T): T {} }", 'Expected `(` but found `<` @11+1 "`(` expected"'], [ - "let zz; let zz; a ?? b || c; enum A {}", - "Logical expressions and coalesce expressions cannot be mixed @16+11 help: Wrap either expression by parentheses", - "Unexpected token @29+4", + "class A { m(a: T): T {} }", + 'Expected `,` or `)` but found `:` @13+1 "`,` or `)` expected", 11+1 "Opened here"', ], [ - "let zz; let zz; a ?? b || c; a as b;", - "Logical expressions and coalesce expressions cannot be mixed @16+11 help: Wrap either expression by parentheses", - "TS(8016) Type assertion expressions can only be used in TypeScript files. @29+6", + "let o = { m(a: T): T {} };", + 'Expected `,` or `)` but found `:` @13+1 "`,` or `)` expected", 11+1 "Opened here"', ], [ - "let zz; let zz; a ?? b || c; f();", - "Logical expressions and coalesce expressions cannot be mixed @16+11 help: Wrap either expression by parentheses", - "Empty parenthesized expression @33+2", + "let f = function (a: T): T {};", + 'Expected `,` or `)` but found `:` @19+1 "`,` or `)` expected", 17+1 "Opened here"', ], + ["let f = function (): T {};", 'Expected `(` but found `<` @17+1 "`(` expected"'], + ["function f(a?): T {}", 'Expected `,` or `)` but found `?` @12+1 "`,` or `)` expected", 10+1 "Opened here"'], + ["function f(this: T): U {}", "Identifier expected. 'this' is a reserved word that cannot be used here. @11+4"], + ["0k0;", "Invalid characters after number @1+1"], + ["00k0;", "Invalid characters after number @2+1"], + ["000000k000;", "Invalid characters after number @6+1"], + ["1k;", "Invalid characters after number @1+1"], + ["x = 000k0;", "Invalid characters after number @7+1"], + ["0k", "Invalid characters after number @1+1"], + ["09k;", "Invalid characters after number @2+1"], + ["08.5k;", "Invalid characters after number @4+1"], + ["0.5k;", "Invalid characters after number @3+1"], + ["1e3k;", "Invalid characters after number @3+1"], + ["0x1k;", "Invalid characters after number @3+1"], + ["0b1k;", "Invalid characters after number @3+1"], + ["0o1k;", "Invalid characters after number @3+1"], + ["1nk;", "Invalid characters after number @2+1"], + ["1_0k;", "Invalid characters after number @3+1"], + ['"use strict"; 000k;', "Invalid characters after number @17+1"], [ - "let zz; let zz; a ?? b || c; class A { constructor(private a) {} }", - "Logical expressions and coalesce expressions cannot be mixed @16+11 help: Wrap either expression by parentheses", - "TS(8012) Parameter modifiers can only be used in TypeScript files. @51+7 help: No modifiers are allowed here.", + "00 k;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @2+0 help: Try inserting a semicolon here", ], [ - "let zz; let zz; a ?? b || c; class A implements B {}", - "Logical expressions and coalesce expressions cannot be mixed @16+11 help: Wrap either expression by parentheses", - "TS(8005) 'implements' clauses can only be used in TypeScript files. @37+10", + "007.5;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @3+0 help: Try inserting a semicolon here", ], + ["007n;", "Invalid characters after number @3+1"], + ["007e1;", "Invalid characters after number @3+1"], + ["007_1;", "Invalid characters after number @3+1"], + ["class {}", "A class name is required. @0+5"], [ - 'let zz; let zz; a ?? b || c; import type A from "a";', - "Logical expressions and coalesce expressions cannot be mixed @16+11 help: Wrap either expression by parentheses", - 'Expected `from` but found `Identifier` @41+1 "`from` expected"', + "class {} a ?? b || c;", + "Logical expressions and coalesce expressions cannot be mixed @9+11 help: Wrap either expression by parentheses", ], [ - "let zz; let zz; a ?? b || c; export = a;", - "Logical expressions and coalesce expressions cannot be mixed @16+11 help: Wrap either expression by parentheses", - "Unexpected token @36+1", + "a ?? b || c; class {}", + "Logical expressions and coalesce expressions cannot be mixed @0+11 help: Wrap either expression by parentheses", ], [ - 'let zz; let zz; a ?? b || c; import a = require("a");', - "Logical expressions and coalesce expressions cannot be mixed @16+11 help: Wrap either expression by parentheses", - "TS(8002) 'import ... =' can only be used in TypeScript files. @29+24 help: TypeScript transforms 'import ... =' to 'const ... ='", + "let zz; let zz; class {}", + 'Identifier `zz` has already been declared @4+2 "`zz` has already been declared here", 12+2 "It can not be redeclared here"', + "A class name is required. @16+5", ], + ["export class {}", "A class name is required. @7+5"], + ["export class extends A {}", "A class name is required. @7+5"], + ["{ class {} }", "A class name is required. @2+5"], + ["function f() { class {} }", "A class name is required. @15+5"], [ - "throw\n;", - 'Illegal newline after throw @0+5 "throw starts here", 6+1 "A newline is not expected here"', - "Unexpected token @6+1", + "if (a) class {}", + "Invalid class declaration @7+6 help: Classes can only be declared at top level or inside a block", ], + ["@d class {}", "A class name is required. @0+5"], + ["class { m(): void {} }", "Expected function body @9+2 help: Add a function body (`{}`)."], + ["class {} let a = ;", "Unexpected token @17+1"], + ["class implements A {}", "TS(8005) 'implements' clauses can only be used in TypeScript files. @6+10"], [ - "throw\n", - 'Illegal newline after throw @0+5 "throw starts here", 7+0 "A newline is not expected here"', - "Unexpected token @7+0", + "(yield a b);", + "A 'yield' expression is only allowed in a generator body. @1+5 help: Either remove this `yield` or change the enclosing function to a generator function (`function*`)", + 'Expected `,` or `)` but found `Identifier` @9+1 "`,` or `)` expected", 0+1 "Opened here"', ], [ - "throw\nvar a;", - 'Illegal newline after throw @0+5 "throw starts here", 6+3 "A newline is not expected here"', - "Unexpected token @6+3", + "let a?;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @5+0 help: Try inserting a semicolon here", ], [ - "throw\nif (a) b;", - 'Illegal newline after throw @0+5 "throw starts here", 6+2 "A newline is not expected here"', - "Unexpected token @6+2", + "class A { a?! }", + "A property cannot be both optional and definite. @11+2 help: Remove either the `?` or the `!`", + "TS(1264) Declarations with definite assignment assertions must also have type annotations. @12+1", ], - ['import("a");', 'Expected `from` but found `<` @6+1 "`from` expected"'], - ["function f(); a ?? b || c;", "Expected function body @0+12 help: Add a function body (`{}`)."], - ["class A { m(); } a ?? b || c;", "Expected function body @11+2 help: Add a function body (`{}`)."], - ["class A { m(); n(); }", "Expected function body @11+2 help: Add a function body (`{}`)."], - ["class A { m(): void {} } a ?? b || c;", "Expected function body @11+2 help: Add a function body (`{}`)."], - ["function f(): void {} a ?? b || c;", "Expected function body @0+12 help: Add a function body (`{}`)."], - ["let o = { m(); };", "Expected function body @11+2 help: Add a function body (`{}`)."], - ["let o = { m() };", "Expected function body @11+2 help: Add a function body (`{}`)."], + ["import {", 'Expected `}` but found `EOF` @9+0 "`}` expected"'], + ["import { a", 'Expected `}` but found `EOF` @11+0 "`}` expected"'], + ["import { a,", 'Expected `}` but found `EOF` @12+0 "`}` expected"'], + ["export {", 'Expected `}` but found `EOF` @9+0 "`}` expected"'], + ["import a, {", 'Expected `}` but found `EOF` @12+0 "`}` expected"'], + ["import {} from", "Unexpected token @15+0"], + ["function enum() {}", 'Expected `Identifier` but found `enum` @9+4 "`Identifier` expected"'], + ["function* enum() {}", 'Expected `Identifier` but found `enum` @10+4 "`Identifier` expected"'], + ["async function enum() {}", 'Expected `Identifier` but found `enum` @15+4 "`Identifier` expected"'], + ["x = function enum() {};", 'Expected `(` but found `enum` @13+4 "`(` expected"'], + ["function if() {}", 'Expected `Identifier` but found `if` @9+2 "`Identifier` expected"'], + ["export function enum() {}", 'Expected `Identifier` but found `enum` @16+4 "`Identifier` expected"'], + ["export default function enum() {}", 'Expected `(` but found `enum` @24+4 "`(` expected"'], + ["import a from b;", "Unexpected token @14+1"], + ["import a from", "Unexpected token @14+0"], + ["export * from b;", "Unexpected token @14+1"], + ["export { a } from b;", "Unexpected token @18+1"], + ["import { a } from 1;", "Unexpected token @18+1"], + ["a?.;", "Identifier expected after '?.' @1+2 help: Add an identifier after '?.'"], + ["a?.<;", "Identifier expected after '?.' @1+2 help: Add an identifier after '?.'"], + ["a?.1;", 'Expected `:` but found `;` @4+1 "`:` expected", 1+1 "Conditional starts here"'], + ["a?.;", "Identifier expected after '?.' @1+2 help: Add an identifier after '?.'"], + ["a?.(); b", "Identifier expected after '?.' @1+2 help: Add an identifier after '?.'"], + ["let \\u12 = 1;", "Invalid Unicode escape sequence @5+3"], + ["var \\u12 = 1;", "Invalid Unicode escape sequence @5+3"], + ["x = \\u12;", "Invalid Unicode escape sequence @5+3"], + ["let a\\u12 = 1;", "Invalid Unicode escape sequence @6+3"], + ["let \\u{zz} = 1;", "Invalid Unicode escape sequence @5+2", "Unexpected token @9+1"], + ["let a,\nfunction f() {}", "Identifier expected. 'function' is a reserved word that cannot be used here. @7+8"], + ["let @ = 1;", "Unexpected token @4+1"], + ["let 1;", "Unexpected token @4+1"], + ["await; import.meta;", "Unexpected token @5+1"], + ["await.y; import.meta;", "Unexpected token @5+1"], + ["await = 1; import.meta;", "Unexpected token @6+1"], [ - "class A { a!; } a ?? b || c;", - "TS(1264) Declarations with definite assignment assertions must also have type annotations. @11+1", - "Logical expressions and coalesce expressions cannot be mixed @16+11 help: Wrap either expression by parentheses", + "await / x / g; import.meta;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @11+0 help: Try inserting a semicolon here", ], + ["{ await(); } import.meta;", "Empty parenthesized expression @7+2"], + ["{ class A extends await {} } import.meta;", "Cannot use `await` as an identifier in an async context @18+5"], + ["function w() { await (x); } import.meta;", "The keyword 'await' is reserved @15+5"], + ["const w = () => await (x); import.meta;", "The keyword 'await' is reserved @16+5"], + ["var await; import.meta;", "The keyword 'await' is reserved @4+5"], + ["x = { await }; import.meta;", "The keyword 'await' is reserved @6+5"], + ["a ` at the start of a line. + [ + "js", + "await (x);\n--> b\nimport.meta;", + "HTML comments are not allowed in modules @11+3", + "HTML comments are not allowed in modules @11+3", + ], + ["js", "\n--> b\nimport.meta;", "HTML comments are not allowed in modules @1+3"], + // `let` with an escape is a name. + [ + "js", + "l\\u0065t a;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @8+0 help: Try inserting a semicolon here", + ], + // The name as it is spelled. + ["js", "yi\\u0065ld; export {};", "The keyword 'yield' is reserved @0+10"], + // `new.target` in a declaration file. + [ + "d.ts", + "new.target;", + "Unexpected new.target expression @0+10 help: new.target is only allowed in constructors, functions, and class field initializers", + "TS(1036) Statements are not allowed in ambient contexts. @0+11", + ], + [ + "d.ts", + "declare const a: 1; new.target;", + "Unexpected new.target expression @20+10 help: new.target is only allowed in constructors, functions, and class field initializers", + "TS(1036) Statements are not allowed in ambient contexts. @20+11", + ], + // What the lexer says next. + ["js", "await (x); 1__0; import.meta;", "Invalid Character `_` @13+0", "Invalid characters after number @13+1"], + ["js", "await (x); 3in []; import.meta;", "Invalid characters after number @12+2"], + ["js", "1__0; new.target; let zz; let zz;", "Invalid Character `_` @2+0", "Invalid characters after number @2+1"], + ["js", "3in []; new.target; let zz; let zz;", "Invalid characters after number @1+2"], + [ + "js", + "await (x); \\; import.meta;", + "Invalid Unicode escape sequence @12+1", + "Expected a semicolon or an implicit semicolon after a statement, but found none @13+0 help: Try inserting a semicolon here", + ], + [ + "js", + "1_; new.target; let zz; let zz;", + "Invalid Character `;` @2+0", + "Unexpected new.target expression @4+10 help: new.target is only allowed in constructors, functions, and class field initializers", + ], + // The second reading has `await` as a keyword. + ["js", "await (x), class await {}; import.meta;", "Cannot use `await` as an identifier in an async context @17+5"], + // Type parameters that do not start. + ["ts", "declare function <;", "Unexpected token @18+1"], ]; test.todo.each(knownDifferences)("%s: %j", async (extension, code, ...errors) => { const name = `a.${extension}`; diff --git a/test/cli/lint/oracle/robustness/cases.ts b/test/cli/lint/oracle/robustness/cases.ts index 55e4357cde84..76d88328ab71 100644 --- a/test/cli/lint/oracle/robustness/cases.ts +++ b/test/cli/lint/oracle/robustness/cases.ts @@ -366,12 +366,12 @@ export const cases: Case[] = [ exitCode: 1, }, { - name: "60,000 assignments that pass a value on, all but the first reported", + name: "10,000 assignments that pass a value on, all but the first reported", isHeavy: true, file: "a.js", - text: () => `${rep("a = ", 60_000)}2;\n`, + text: () => `${rep("a = ", 10_000)}2;\n`, rules: { "no-multi-assign": "error" }, - reports: { "no-multi-assign": 59_999 }, + reports: { "no-multi-assign": 9_999 }, exitCode: 1, }, { diff --git a/test/cli/lint/oxc-names.test.ts b/test/cli/lint/oxc-names.test.ts index 96045b5e4d46..6ce7786414a8 100644 --- a/test/cli/lint/oxc-names.test.ts +++ b/test/cli/lint/oxc-names.test.ts @@ -768,6 +768,120 @@ describe("beside an .oxlintrc.json, like oxc's parser", () => { }, ); + // At the end of a file that turned out to be a module, oxc reads each statement again in which `await` was a reference. It + // doesn't after an error that stops it, and not an `export`. The name in `{ await }` is no reference to its parser. + test.concurrent("`await` as a reference before what makes the file a module", async () => { + const reserved = "The keyword 'await' is reserved"; + const both: [code: string, errors: string][] = [ + ["new await; export {};", `${asyncContext} 4+5`], + ["new await; import.meta;", `${asyncContext} 4+5`], + ["new await;", ""], + ["{ class A extends await {} } import.meta;", `${asyncContext} 18+5`], + ["{ class A extends await {} }", ""], + ["@await class A {} export {};", `${asyncContext} 1+5`], + ["@await export class A {}", `${reserved} 1+5`], + ["@await export default class {}", `${reserved} 1+5`], + ["function w() { f(await); } export {};", `${reserved} 17+5`], + ["const w = () => f(await); export {};", `${reserved} 18+5`], + ["x = { await }; export {};", `${reserved} 6+5`], + ["{ x = { await }; } import.meta;", `${reserved} 8+5`], + ["({ await = 1 } = {}); export {};", `${reserved} 3+5`], + ["{ ({ await = 1 } = {}); } import.meta;", `${reserved} 5+5`], + ["await of; export {};", `${semicolon} 5+0`], + ["{ await () => 1; } import.meta;", `${semicolon} 10+0`], + // Before a parenthesis or an operator `await` is a reference the first time. + ["await (x), class await {}; import.meta;", `${asyncContext} 17+5`], + ["await (x), class await {};", ""], + ["await (x), function await() {}; import.meta;", `${reserved} 20+5`], + ["await (x), (await) => 1; import.meta;", `${asyncContext} 12+5`], + ["await [x], class await {}; export {};", `${asyncContext} 17+5`], + ["await + x, class await {}; import.meta;", `${asyncContext} 17+5`], + ["await - x, class await {}; import.meta;", `${asyncContext} 17+5`], + ["await `x`, class await {}; import.meta;", `${asyncContext} 17+5`], + ["await\n(x), class await {}; import.meta;", `${asyncContext} 17+5`], + ["await /* c */ (x), class await {}; import.meta;", `${asyncContext} 25+5`], + ["await y, class await {}; import.meta;", `${reserved} 15+5`], + ["await (x), class await {}; await y;", `${asyncContext} 17+5`], + ["await (x), class await {}; await using a = b;", `${asyncContext} 17+5`], + ["x = class await {}, await (x); import.meta;", `${asyncContext} 10+5`], + ["x = class await {}, new await; export {};", `${asyncContext} 10+5 | ${asyncContext} 24+5`], + ["x = () => await (x), class await {}; import.meta;", `${asyncContext} 27+5`], + ["label: await (x), class await {}; import.meta;", `${asyncContext} 24+5`], + ["var await = await (x); import.meta;", `${asyncContext} 4+5`], + ["let await = await (x); import.meta;", `${asyncContext} 4+5`], + ["for (var await of [await (x)]) ; import.meta;", `${asyncContext} 9+5`], + ["class await { [await (x)]() {} } import.meta;", `${asyncContext} 6+5`], + ["class A { [await (x)]() {} m(await) {} } import.meta;", `${reserved} 29+5`], + ["class A extends (await (x)) { p = (await) => 1 } import.meta;", `${reserved} 35+5`], + ["function f(a = await (x), await) {} import.meta;", `${reserved} 15+5 | ${reserved} 26+5`], + ["{ await (x); class await {} } import.meta;", `${asyncContext} 19+5`], + ["{ await (x); var await; } import.meta;", `${asyncContext} 17+5`], + ["{ await (x); function await() {} } import.meta;", `${asyncContext} 22+5`], + ["{ var await; new await; } import.meta;", `${asyncContext} 6+5 | ${asyncContext} 17+5`], + ["if (a) { await (x); } else { var await; } import.meta;", `${asyncContext} 33+5`], + // Only that statement. + ["await (x); x = class await {}; import.meta;", `${reserved} 21+5`], + ["x = class await {}; await (x); import.meta;", `${reserved} 10+5`], + ["{ var await; } { await (x); } import.meta;", `${reserved} 6+5`], + ["function w() { await (x); } x = class await {}; import.meta;", `${reserved} 15+5 | ${reserved} 38+5`], + ["{ var await; await (x); } { var await; } import.meta;", `${asyncContext} 6+5`], + // Where it goes back to the next such statement, it forgets what it has said since it first came there. + ["await (x), class await {}; await (y); import.meta;", `${reserved} 17+5`], + ["{ var await; await (x); } { var await; await (x); } import.meta;", `${asyncContext} 32+5`], + // After an error that stops it, it reads nothing again. + ["await (x), class await {}; a b; import.meta;", `${semicolon} 28+0`], + ["await (x), class await {}; import.meta; a b;", `${semicolon} 41+0`], + ["f(await), class await {}; export {};", "Unexpected token 7+1"], + ]; + const rows = both.flatMap(([code, errors]): Row[] => [ + ["js", code, errors], + ["ts", code, errors], + ]); + rows.push(["js", "await !x; export {};", `${semicolon} 5+0`], ["ts", "await !x; export {};", `${semicolon} 7+0`]); + expect(await found(rows, quiet)).toEqual(rows); + }); + + // A known difference. The second time, oxc has the error about the name before it stops at the `await` without an operand: + // oxlint 1.87 prints "Cannot use `await` as an identifier in an async context" at 10+5 first. + test.concurrent( + "a name before the `await` without an operand at which oxc stops has no error of its own", + async () => { + const rows: Row[] = [ + ["js", "x = class await {}, f(await); export {};", "Unexpected token 27+1"], + ["ts", "x = class await {}, f(await); export {};", "Unexpected token 27+1"], + ]; + expect(await found(rows, quiet)).toEqual(rows); + }, + ); + + test.concurrent("the error has the word, however it is spelled", async () => { + const both: [code: string, errors: string][] = [ + ["yi\\u0065ld; export {};", "The keyword 'yield' is reserved 0+10"], + ["var yi\\u0065ld; export {};", "The keyword 'yield' is reserved 4+10"], + ["yi\\u0065ld: a; export {};", "The keyword 'yield' is reserved 0+10"], + ["x = { yi\\u0065ld }; export {};", "The keyword 'yield' is reserved 6+10"], + ["'use strict'; yi\\u0065ld;", "The keyword 'yield' is reserved 14+10"], + ["'use strict'; var l\\u0065t;", "The keyword 'let' is reserved 18+8"], + ["'use strict'; var st\\u0061tic;", "The keyword 'static' is reserved 18+11"], + ["'use strict'; impl\\u0065ments;", "The keyword 'implements' is reserved 14+15"], + ["class A { m() { var yi\\u{65}ld; } }", "The keyword 'yield' is reserved 20+10"], + ["var aw\\u0061it; export {};", "The keyword 'await' is reserved 4+10"], + ["function aw\\u0061it() {} export {};", "The keyword 'await' is reserved 9+10"], + ["x = { aw\\u0061it }; export {};", "The keyword 'await' is reserved 6+10"], + ["async function f() { var aw\\u0061it; }", `${asyncContext} 25+10`], + ["function* f() { var yi\\u0065ld; }", `${generatorContext} 20+10`], + ["class A { static { var aw\\u0061it; } }", `${asyncContext} 23+10`], + ["'use strict'; \\u0065val = 1;", "Cannot assign to 'eval' in strict mode 14+9"], + ["'use strict'; argum\\u0065nts = 1;", "Cannot assign to 'arguments' in strict mode 14+14"], + ["'use strict'; var \\u0065val;", "Cannot assign to 'eval' in strict mode 18+9"], + ]; + const rows = both.flatMap(([code, errors]): Row[] => [ + ["js", code, errors], + ["ts", code, errors], + ]); + expect(await found(rows, quiet)).toEqual(rows); + }); + // A type is in the await context of what is around it. oxc leaves that context for a declaration that starts with `type`, // `interface`, `enum`, `abstract`, `declare`, `namespace` or `module`, but not for one after `export` or a decorator. test.concurrent("`await` and `yield` as names of parameters in types", async () => { diff --git a/test/config/bunfig/bunfig-errors.test.ts b/test/config/bunfig/bunfig-errors.test.ts index 1f778240504a..e1581e953929 100644 --- a/test/config/bunfig/bunfig-errors.test.ts +++ b/test/config/bunfig/bunfig-errors.test.ts @@ -264,6 +264,7 @@ describe.concurrent("bun lint and bun fmt", () => { ["fmt", "[fmt]\nsemi = 1\n", "error: expected boolean but received number"], ["lint", "lint = true\n", "error: expected object but received boolean"], ["fmt", "fmt = true\n", "error: expected object but received boolean"], + ["fmt", "[format]\nsemi = false\n", "error: Unknown section [format]"], ["lint", "logLevel = 3\n", "error: expected string but received number"], ["fmt", "logLevel = 3\n", "error: expected string but received number"], ])("bun %s with %j", async (command, config, error) => { diff --git a/test/js/bun/glob/match.test.ts b/test/js/bun/glob/match.test.ts index a4d8e87a42c9..168ccb948572 100644 --- a/test/js/bun/glob/match.test.ts +++ b/test/js/bun/glob/match.test.ts @@ -2601,6 +2601,136 @@ describe("Glob.match", () => { } }); + test.each([ + ["**/a/*.ts", ["a/c.ts", "x/a/c.ts", "x/y/a/.ts"], ["a/x/c.ts", "c.ts", "x/a/c.js", "xa/c.ts"]], + ["**/[ab]/?.ts", ["b/q.ts", "x/y/b/q.ts"], ["x/b/y/q.ts", "x/c/q.ts", "b/.ts"]], + ["**/\\*.ts", ["*.ts", "a/*.ts"], ["a/b.ts", "a/*.js"]], + ["**/a/", ["a/", "x/a/"], ["a", "x/a", "a/x/"]], + ["**/{a,bc}.ts", ["a.ts", "x/y/bc.ts"], ["x/b.ts", "a.ts/x"]], + ["**/{a),b/c}/d", ["x/a)/d", "x/b/c/d", "b/c/d"], ["x/b/d", "x/a/d"]], + ["**/(a/b", ["(a/b", "x/(a/b"], ["x/a/b"]], + ["**/a)/b", ["a)/b", "x/y/a)/b"], ["x/a/b"]], + ["**/!{.,$)],/-/}ts/x", ["a/a/!$)]ts/x", "!.ts/x", "a/!/-/ts/x"], ["a/!ts/x"]], + ["x/**/{,}{),/ }/*", ["x/ba/)/-", "x/)/a"], ["x/ba/-"]], + ["**/x/!{é,}*", ["q/x/!a", "x/!é", "x/!"], ["q/x/a"]], + ["**/[!a]/b", ["c/b", "x/c/b"], ["a/b", "x/a/b"]], + ["a/**/b/**/c/*.ts", ["a/b/c/x.ts", "a/1/b/2/3/c/x.ts"], ["a/b/x.ts", "a/b/c/d/x.ts"]], + ])("what follows the last `**/` matches the last segments: %s", (pattern, matches, others) => { + const glob = new Glob(pattern); + for (let round = 0; round < 12; round++) { + expect(matches.filter(path => !glob.match(path) || !new Glob(pattern).match(path))).toEqual([]); + expect(others.filter(path => glob.match(path) || new Glob(pattern).match(path))).toEqual([]); + } + }); + + test.each([ + ["*[!abc]", ["d", "xyzd", "abcd"], ["", "xyzb", "a"]], + ["*[!a]", ["x/", "b"], ["xa", "x/a"]], + ["*\\*", ["*", "ab*"], ["ab", "a*b"]], + ["*\\[a\\]", ["[a]", "x[a]"], ["xa", "x[a"]], + ["*?.ts", ["a.ts", "index.ts", "é.ts"], [".ts", "a.js", "a/.ts"]], + ["*??", ["ab", "abc", "éé"], ["", "a", "a/b"]], + ["*?[ab]/x", ["ca/x", "xxcb/x"], ["a/x", "cc/x", "ca/y"]], + ["*?[^/]_*1", ["_xa_aab1", "ab_1"], ["a_1", "ab_2"]], + ["*[0-9].log", ["1.log", "server12.log"], ["server.log", "1.logs", "a/1.log"]], + ["*[+-9]/x", ["a1/x", "a//x"], ["aa/x"]], + ["*{a,}", ["", "a", "xx", "xxa"], ["x/a"]], + ["*{,a}/y", ["/y", "xxa/y", "xx/y"], ["xxa", "x/x/y"]], + ["*{a,bc}", ["a", "xbc", "xxa"], ["xb", "xc", "x/a"]], + ["*{ab,c}*", ["ab", "xxabyy", "c", "xcy"], ["xxbyy", "a", "x/c"]], + ["*{abcdef,cd}*f", ["abcdef", "cdf", "abcdeff"], ["abcde", "cd"]], + ["*{abcdef,cd}*e", ["abcde", "cde"], ["abcdef", "cd"]], + ["*?()*", ["", "a", "ab"], ["a/b"]], + ["**o", ["o", "foo"], ["fo/o", "fa"]], + ["**o*", ["o", "foo", "oy"], ["f/o", "fa"]], + ])("where a `*` can stop: %s", (pattern, matches, others) => { + const long = Buffer.alloc(100, "n").toString(); + const glob = new Glob(pattern); + for (let round = 0; round < 12; round++) { + expect(matches.filter(path => !glob.match(path) || !new Glob(pattern).match(path))).toEqual([]); + expect(others.filter(path => glob.match(path) || new Glob(pattern).match(path))).toEqual([]); + expect(matches.filter(path => !glob.match(long + path))).toEqual([]); + } + }); + + test.each([ + ["{a/b,c/d}/e", ["a/b/e", "c/d/e"], ["c/e", "a/d/e", "a/b/e/f"]], + ["{a,a/b}/c", ["a/c", "a/b/c"], ["a/b", "b/c"]], + ["{,/}{}", ["", "/"], ["a"]], + ["src/{a,b/c}/*.ts", ["src/a/x.ts", "src/b/c/x.ts"], ["src/b/x.ts", "src/c/x.ts"]], + ["{a?,b?}", ["ax", "bx"], ["a", "cx", "b/", "axx"]], + ["{a?,b?}.ts", ["ax.ts", "bé.ts"], ["cx.ts", "a.ts"]], + ["{[ab],c}x", ["ax", "bx", "cx"], ["dx", "x"]], + ["{a,{b,c}}", ["a", "b", "c"], ["d", "", "ab"]], + ["{a,b{c,d}}e", ["ae", "bce", "bde"], ["be", "bcde"]], + ["{a,b{c,{d,e}}}", ["a", "bc", "bd", "be"], ["b", "bf"]], + ["a{,b}c", ["ac", "abc"], ["adc", "a", "abbc"]], + ["x{a,bc}*", ["xa", "xbcd", "xab"], ["xb", "ybc"]], + ["{a,ab}*c", ["ac", "abc", "abxc"], ["ab", "bc"]], + ["{a,ab}c", ["ac", "abc"], ["abbc", "c"]], + ["{a,ab}bc", ["abc", "abbc"], ["ac", "abbbc"]], + ["**/{a?,b?}/**", ["ax/c", "x/bx/c/d"], ["x/cx/d", "x/a/d"]], + ["é{a,{b,c}}é", ["éaé", "écé"], ["édé", "éé"]], + ["é{*.a,*.b}é", ["é.aé", "éx.bé"], ["éx.cé"]], + ["日本/{a?,b?}.ts", ["日本/bx.ts"], ["日本/cx.ts", "日/bx.ts"]], + ["é?é", ["éqé", "ééé"], ["é/é", "éé"]], + ["{a,bc}\\n", ["a\n", "bc\n"], ["an", "a", "b\n"]], + ["*{a,bc}\\n", ["a\n", "xbc\n"], ["xan", "xb\n"]], + ["*{a,bc}\\.x", ["a.x", "xbc.x"], ["ax", "xb.x"]], + ])("groups that the loop takes by itself: %s", (pattern, matches, others) => { + const glob = new Glob(pattern); + for (let round = 0; round < 12; round++) { + expect(matches.filter(path => !glob.match(path) || !new Glob(pattern).match(path))).toEqual([]); + expect(others.filter(path => glob.match(path) || new Glob(pattern).match(path))).toEqual([]); + } + }); + + test.each([ + ["é{,a}é", ["éé", "éaé"], ["ébé", "é", "éaaé", "eae"]], + ["日本/*.ts", ["日本/a.ts", "日本/語.ts", "日本/.ts"], ["日/a.ts", "日本/a.js", "日本語/a.ts"]], + ["*語*", ["語", "日本語テキスト", "a語b"], ["日本テキスト", "", "語/a"]], + ["?", ["a", "é", "語", "😀", "\u0900", "\ud7a3", "\u{10000}", "\u{10ffff}", "\ufffd"], ["", "éé", "a😀", "/"]], + ["??", ["aé", "éa", "語😀", "😀😀"], ["é", "😀", "aéa"]], + ["a?b", ["aéb", "a語b", "a😀b"], ["ab", "aééb", "a/b"]], + ["[!a]", ["é", "😀", "b"], ["a", "éé"]], + ["[a-c]*", ["aé", "b語語"], ["é", "éa"]], + ["*.ts", ["ソース.ts", "é.ts"], ["ソース.js", "ソ/ース.ts"]], + ["**/*.ts", ["ソース/コンポーネント/ボタン.ts", "é/a.ts"], ["ソース/ボタン.js"]], + ["**/é/**", ["é/a", "a/é/b", "語/é/😀"], ["e/a", "éé/a", "a/é"]], + ["{é,語}*{a,😀}", ["éa", "語x😀", "é😀"], ["a😀", "éx"]], + ["+(é|a)", ["é", "éaé"], ["", "éb", "語"]], + ["!(é)", ["a", "éé", "語"], ["é"]], + ["[é語]x", ["éx", "語x"], ["ax", "😀x"]], + ["\\éx", ["éx"], ["ex", "語x"]], + ])("characters of several bytes, however often it's asked: %s", (pattern, matches, others) => { + const glob = new Glob(pattern); + for (let round = 0; round < 40; round++) { + expect(matches.filter(path => !glob.match(path) || !new Glob(pattern).match(path))).toEqual([]); + expect(others.filter(path => glob.match(path) || new Glob(pattern).match(path))).toEqual([]); + } + }); + + test.each(["a?b", "a*b", "a[!x]b", "*?b", "a{?,xy}b", "a語b", "a+(?)b"])( + "%s gives the same answers for every character once it has been asked often", + pattern => { + const glob = new Glob(pattern); + for (let round = 0; round < 40; round++) glob.match("axb"); + const differs = []; + // The first and the last character of each range of first and second bytes in UTF-8, and some in between. + const edges = [0x80, 0x800, 0x1000, 0xd000, 0xd800, 0xe000, 0x10000, 0x40000, 0x100000, 0x110000]; + const codes = edges.flatMap(edge => [edge - 1, edge]).slice(0, -1); + for (let code = 0xa1; code < 0x110000; code += code < 0x3000 ? 61 : 7919) codes.push(code); + for (const code of codes) { + if (code >= 0xd800 && code <= 0xdfff) continue; + const character = String.fromCodePoint(code); + for (const path of ["a" + character + "b", "a" + character + character + "b", "a" + character]) { + if (glob.match(path) !== new Glob(pattern).match(path)) differs.push(path); + } + } + expect(differs).toEqual([]); + }, + ); + test("a group can be anywhere in a long pattern", () => { for (let before = 50; before <= 270; before++) { const any = Buffer.alloc(before, "?").toString(); From 3f0e8d06112468d4591413ee2c71c520d4723512 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 13 Oct 2026 04:07:00 +0000 Subject: [PATCH 45/49] lint, format: 14 files --- src/glob/matcher.rs | 137 ++++++++++++++++++- src/lint/driver/fmt/mod.rs | 18 ++- src/lint/linter/mod.rs | 23 +++- src/lint/linter/resolved.rs | 4 +- src/lint/linter/syntax/order.rs | 2 + src/lint/linter/syntax/oxc.rs | 17 ++- src/lint/linter/syntax/oxc_typescript.rs | 2 +- src/lint/oxlint_order.rs | 141 +++++++++++++------ src/lint/oxlint_order/visited.rs | 166 +++++++++++++++++++++-- src/sema/parser/parser/expr.rs | 4 +- src/sema/parser/parser/func.rs | 2 +- src/sema/parser/parser/recover.rs | 13 +- test/cli/lint/lint.test.ts | 116 ++++++++++++++-- test/js/bun/glob/match.test.ts | 18 +++ 14 files changed, 576 insertions(+), 87 deletions(-) diff --git a/src/glob/matcher.rs b/src/glob/matcher.rs index 81fdf738c50a..5d7899441938 100644 --- a/src/glob/matcher.rs +++ b/src/glob/matcher.rs @@ -236,6 +236,7 @@ fn match_from_first_byte( if is_plain && path.first() != Some(&first) { return MatchResult::NO; } + if let Some(shape) = shape { let uses = shape.uses.load(Ordering::Relaxed); // From now on a `Walk` decides. @@ -252,10 +253,27 @@ fn match_from_first_byte( true => match_literal_for_windows(glob, shape, path, dots), false => match_literal_for_posix(glob, shape, path, dots), }, - b'!' | b'\\' | b'?' | b'[' | 0x80.. => match WINDOWS { + // An escaped character that stands for itself is as good as a literal. + b'\\' + if glob.get(1).is_some_and(|it| { + path.first() != Some(it) + && !matches!(it, b'b' | b'n' | b'r' | b't' | b'/' | b'\\' | 0x80..) + }) => + { + MatchResult::NO + } + b'!' => match WINDOWS { + true => match_negated_for_windows(glob, shape, path, dots), + false => match_negated_for_posix(glob, shape, path, dots), + }, + b'\\' | b'?' | b'[' | 0x80.. => match WINDOWS { true => match_escapes_for_windows(glob, shape, path, Start::of(dots)), false => match_escapes_for_posix(glob, shape, path, Start::of(dots)), }, + b'{' if dots == Dots::Match && glob.ends_with(b"*}") => match WINDOWS { + true => match_braces_for_windows(glob, shape, path), + false => match_braces_for_posix(glob, shape, path), + }, _ => match WINDOWS { true => match_for_windows(glob, shape, path, Start::of(dots), None), false => match_for_posix(glob, shape, path, Start::of(dots), None), @@ -304,6 +322,36 @@ fn match_escapes_for_windows( match_escapes::(glob, shape, path, start) } +#[inline(never)] +fn match_negated_for_posix( + glob: &[u8], + shape: Option<&Shape>, + path: &[u8], + dots: Dots, +) -> MatchResult { + match_negated::(glob, shape, path, dots) +} + +#[inline(never)] +fn match_negated_for_windows( + glob: &[u8], + shape: Option<&Shape>, + path: &[u8], + dots: Dots, +) -> MatchResult { + match_negated::(glob, shape, path, dots) +} + +#[inline(never)] +fn match_braces_for_posix(glob: &[u8], shape: Option<&Shape>, path: &[u8]) -> MatchResult { + match_braces::(glob, shape, path) +} + +#[inline(never)] +fn match_braces_for_windows(glob: &[u8], shape: Option<&Shape>, path: &[u8]) -> MatchResult { + match_braces::(glob, shape, path) +} + #[inline(never)] fn match_warm_for_posix(glob: &[u8], shape: &Shape, path: &[u8], dots: Dots) -> MatchResult { match_warm::(glob, shape, path, dots) @@ -401,6 +449,93 @@ fn match_literal( } } +/// [`match_literal`] for a pattern that starts with `!`. +#[inline(always)] +fn match_negated( + glob: &[u8], + shape: Option<&Shape>, + path: &[u8], + dots: Dots, +) -> MatchResult { + let glob_start = negations(glob); + let negated = glob_start % 2 == 1; + let mut taken = 0; + while let Some(&wanted) = glob.get(glob_start + taken) { + let is_same = match path.get(taken) { + Some(&found) if IS_PLAIN[wanted as usize] => found == wanted, + Some(&found) if wanted == b'/' => is_separator::(found), + None if IS_PLAIN[wanted as usize] || wanted == b'/' => false, + _ => break, + }; + if !is_same { + return MatchResult { + matches: negated, + negated, + }; + } + taken += 1; + } + let Some(next) = glob.get(glob_start + taken) else { + return MatchResult { + matches: (taken == path.len()) != negated, + negated, + }; + }; + let start = Start { + glob_start: glob_start as u32, + glob_index: (glob_start + taken) as u32, + path_index: taken as u32, + ..Start::of(dots) + }; + match (matches!(next, b'\\' | b'?' | b'[' | 0x80..), WINDOWS) { + (true, true) => match_escapes_for_windows(glob, shape, path, start), + (true, false) => match_escapes_for_posix(glob, shape, path, start), + (false, true) => match_for_windows(glob, shape, path, start, None), + (false, false) => match_for_posix(glob, shape, path, start, None), + } +} + +/// The pattern is like `{src/**,lib/**}`. If it's short, each alternative is matched as a pattern of its own. +#[inline(always)] +fn match_braces( + glob: &[u8], + shape: Option<&Shape>, + path: &[u8], +) -> MatchResult { + let text = Text { + path, + path_index: 0, + is_committed: true, + is_windows: WINDOWS, + matches: |pattern, text| match_on::(pattern, text).matches(), + }; + let written = match_written_out(glob, 0, &text); + #[cfg(debug_assertions)] + if let Some(written) = written { + written.check(glob, 0, path, WINDOWS); + } + let start = match written { + Some(Written::Rest(matches)) => { + return MatchResult { + matches, + negated: false, + }; + } + Some(Written::Segment(false, ..)) => return MatchResult::NO, + Some(Written::Segment(true, glob_index, path_index)) => Start { + glob_index, + path_index, + seen: Seen::Choice, + ..Start::of(Dots::Match) + }, + None => Start::of(Dots::Match), + }; + match WINDOWS { + true => match_for_windows(glob, shape, path, start, None), + false => match_for_posix(glob, shape, path, start, None), + } +} + /// The same for a pattern that starts with `!`, or has other tokens among those bytes that take one character whatever follows: /// escapes, `?` and classes. #[inline(always)] diff --git a/src/lint/driver/fmt/mod.rs b/src/lint/driver/fmt/mod.rs index 8a68a2b93f7a..b7db72d5d7b7 100644 --- a/src/lint/driver/fmt/mod.rs +++ b/src/lint/driver/fmt/mod.rs @@ -236,11 +236,11 @@ fn with_tree<'h, R>( ) } -/// Parses `text` and calls `then` with the file, with its tree, and with the first error in it. +/// Parses `text` and calls `then` with the file, with its tree if `how.verifies`, and with the first error in it. fn with_file( how: &How, text: &[u8], - then: impl for<'a> FnOnce(&'a File<'a>, &Program<'a>, Refused<'a>) -> R, + then: impl for<'a> FnOnce(&'a File<'a>, Option<&Program<'a>>, Refused<'a>) -> R, ) -> R { let (path, session) = (how.path, how.memory); let arena = session.arena(); @@ -259,9 +259,10 @@ fn with_file( && let Some(bound) = try_bind_for_format_in(&**hir, &mut recycled) { let file = File::new(path, &**hir, bound, atoms, language, None).with_text(text); + let program = how.verifies.then(|| Program::new(&**hir, text, atoms)); return then( &file, - &Program::new(&**hir, text, atoms), + program.as_ref(), Refused { first_error: hir.diagnostics.first(), is_nested_too_deeply: hir.ran_out_of_stack, @@ -275,11 +276,12 @@ fn with_file( before_es2020: false, before_es2017: false, }; + let program = how.verifies.then(|| Program::new(&hir, text, atoms)); if needs_symbols { let bound = bind(&hir, bind_options, atoms, arena); return then( &File::new(path, &hir, &bound, atoms, language, None), - &Program::new(&hir, text, atoms), + program.as_ref(), Refused { first_error: hir.diagnostics.first(), is_nested_too_deeply: hir.ran_out_of_stack || bound.ran_out_of_stack, @@ -289,7 +291,7 @@ fn with_file( let bound = bind_for_format_in(&hir, bind_options, atoms, &mut recycled); then( &File::new(path, &hir, bound, atoms, language, None), - &Program::new(&hir, text, atoms), + program.as_ref(), Refused { first_error: hir.diagnostics.first(), is_nested_too_deeply: hir.ran_out_of_stack || bound.ran_out_of_stack, @@ -692,7 +694,7 @@ fn format( } fn print<'a>( - (file, program): (&'a File<'a>, &Program<'a>), + (file, program): (&'a File<'a>, Option<&Program<'a>>), refused: Refused<'_>, how: &How, scratch: &mut Scratches, @@ -754,7 +756,9 @@ fn print<'a>( if how.resolved.omits_final_newline { without_final_newline(&mut out); } - if how.verifies && out != file.text() { + if let Some(program) = program + && out != file.text() + { let is_same = with_tree(how, &out, |after, atoms, _| { let after = match &after { Summary::InPlace(hir) => Program::new(&**hir, &out, atoms), diff --git a/src/lint/linter/mod.rs b/src/lint/linter/mod.rs index f66c0d777ea1..7b779314be40 100644 --- a/src/lint/linter/mod.rs +++ b/src/lint/linter/mod.rs @@ -79,7 +79,7 @@ use crate::ast::File; use crate::context::{Diagnostic, Severity}; use crate::js_plugin; use crate::options::{Json, Options}; -use crate::oxlint_order::Kinds; +use crate::oxlint_order::Called; use crate::rule::{Meta, When}; use crate::rule_set::{RuleBits, RuleSet}; use crate::runner::{Enabled, Listeners, RuleEntry, each_bit}; @@ -206,7 +206,7 @@ struct Running<'r, S> { /// [`Slot::when`] when: When, /// [`Slot::kinds`] - kinds: Kinds, + kinds: Called, /// [`Slot::oxlint_number`] oxlint_number: u16, rule: RuleRef<'r, S>, @@ -406,7 +406,7 @@ impl Linter { let order = crate::oxlint_order::of(rule.reported_as()); (order.when, order.number, order.kinds) } - false => (rule.entry.meta.reports, 0, Kinds::default()), + false => (rule.entry.meta.reports, 0, Called::default()), }; let at = at as u32; let severity = rule.severity; @@ -878,7 +878,20 @@ impl Linter { Some((it.when, it.kinds, it.oxlint_number)) } }; - let wants_keys = prepared.needs_modules; + // A file is linted again if it names a module. + let names_a_module = || { + use crate::ast::{ExprTag, StmtTag}; + let statements = [ + StmtTag::Import, + StmtTag::ImportEquals, + StmtTag::ExportNamed, + StmtTag::ExportStar, + ]; + file.has_stmts(statements) + || file.has_exprs([ExprTag::ImportCall]) + || file.mentions("require") + }; + let wants_keys = follows_run && prepared.needs_modules && names_a_module(); let about = |rule: u16| { let meta = ran(rule as usize)?.reported_as(); let (when, kinds, number) = slot_of(rule as usize)?; @@ -1690,7 +1703,7 @@ impl<'c, 'a, S: RuleSet> Inline<'_, 'c, 'a, S> { configured: existing, severity, when: entry.meta.reports, - kinds: Kinds::default(), + kinds: Called::default(), oxlint_number: u16::MAX, rule, refusal, diff --git a/src/lint/linter/resolved.rs b/src/lint/linter/resolved.rs index 1036d3c125a7..45a07c2ce46d 100644 --- a/src/lint/linter/resolved.rs +++ b/src/lint/linter/resolved.rs @@ -6,7 +6,7 @@ use crate::context::Severity; use crate::js_plugin::{self, Route}; use crate::language::{LanguageOptions, Parser}; use crate::options::Json; -use crate::oxlint_order::Kinds; +use crate::oxlint_order::Called; use crate::rule::{Meta, Plugin, When}; use crate::rule_set::RuleBits; use crate::runner::{Listeners, RuleEntry}; @@ -215,7 +215,7 @@ pub(super) struct Slot { /// With a configuration of oxlint: when oxlint reports what the rule reports. pub(super) when: When, /// With a configuration of oxlint: the kinds of node that it calls the rule with. - pub(super) kinds: Kinds, + pub(super) kinds: Called, /// With a configuration of oxlint: the number that it gives the rule. pub(super) oxlint_number: u16, pub(super) severity: Severity, diff --git a/src/lint/linter/syntax/order.rs b/src/lint/linter/syntax/order.rs index 1c26a5d66cf1..6e279fa6a608 100644 --- a/src/lint/linter/syntax/order.rs +++ b/src/lint/linter/syntax/order.rs @@ -63,6 +63,7 @@ fn ranks_in_function(func: Func) -> [u16; 4] { } /// When `child` is converted, among the children of `parent`. From 1: the checks of `parent` itself come first. +#[optimize(size)] fn rank<'a>(parent: Node<'a>, child: Node<'a>) -> u16 { let is = |it: Node<'a>| it == child; let is_some = |it: Option>| it == Some(child); @@ -258,6 +259,7 @@ fn rank_of_check(node: Node, when: When) -> u16 { } /// The index of the candidate whose check comes first. +#[optimize(size)] pub(super) fn first<'a>(file: &'a File<'a>, candidates: &[Candidate]) -> Option { let mut inside: Vec = (0..candidates.len()).collect(); let mut at = Node::File(file); diff --git a/src/lint/linter/syntax/oxc.rs b/src/lint/linter/syntax/oxc.rs index 7807e57d9970..4e3f2a0d2885 100644 --- a/src/lint/linter/syntax/oxc.rs +++ b/src/lint/linter/syntax/oxc.rs @@ -1750,6 +1750,21 @@ fn second_readings<'a>(file: &'a File<'a>) -> impl Iterator(file: &'a File<'a>, errors: &[Diagnostic]) -> Vec { + let (mut said, stop) = super::in_words_of_oxc(file, errors); + // What the parser has noted in oxc's words comes before the error of TypeScript's parser that it is about. + let before = errors.get(..stop.map_or(errors.len(), |it| it.0)); + let mut noted = before.unwrap_or_default().iter(); + let noted = noted.find(|it| it.kind == DiagnosticKind::OxcFatal); + if let Some(noted) = noted.and_then(|it| oxc_typescript::of_parser(file, it)) { + said.pop(); + said.push(noted); + } + said +} + fn can_be_forgotten(it: &SyntaxError) -> bool { it.of_oxc.stage == Stage::Parser && !matches!(it.order(), 0 | u32::MAX) } @@ -1786,7 +1801,7 @@ fn read_again_like_oxc<'a>(file: &'a File<'a>, all: &mut Vec) { }; kept.truncate(all.iter().filter(is_from_first_reading).count()); let second = second_readings(file).find(|it| it.start == span.start); - said_last = second.map_or_else(Vec::new, |it| super::in_words_of_oxc(file, &it.related).0); + said_last = second.map_or_else(Vec::new, |it| second_reading(file, &it.related)); let read_to = match (second, said_last.last()) { (Some(_), Some(last)) => { given_up.push(*span); diff --git a/src/lint/linter/syntax/oxc_typescript.rs b/src/lint/linter/syntax/oxc_typescript.rs index 0a3a93440e24..6c6831457172 100644 --- a/src/lint/linter/syntax/oxc_typescript.rs +++ b/src/lint/linter/syntax/oxc_typescript.rs @@ -37,7 +37,7 @@ fn text_of(it: &Diagnostic, index: usize) -> Cow<'static, str> { /// An error that the parser has written down for oxc. #[optimize(size)] -fn of_parser<'a>(file: &'a File<'a>, it: &Diagnostic) -> Option { +pub(super) fn of_parser<'a>(file: &'a File<'a>, it: &Diagnostic) -> Option { let stage = match it.kind { DiagnosticKind::Oxc => Stage::Parser, DiagnosticKind::OxcFatal => Stage::Fatal, diff --git a/src/lint/oxlint_order.rs b/src/lint/oxlint_order.rs index b845efb03425..a545f3974b1a 100644 --- a/src/lint/oxlint_order.rs +++ b/src/lint/oxlint_order.rs @@ -61,6 +61,24 @@ impl Kinds { } } +/// With what oxlint calls a rule: the kinds of node, and what follows from them for the nodes here, so that one look at most +/// nodes tells whether the rule is called with them. +#[derive(Copy, Clone, Default)] +pub(crate) struct Called { + kinds: Kinds, + /// A bit for each `ExprTag` that is of one of the kinds wherever it is. + exprs: u64, + /// The same for those that are so in a file without JSX. + exprs_without_jsx: u64, + /// A bit for each `StmtTag`. + stmts: u64, + /// A bit for each `BinOp` and for each `UnOp`. + binaries: u32, + unaries: u32, + /// It is called with a part of a node here, or with declarations alone: it takes more than a look. + is_odd: bool, +} + /// What oxlint does with a rule. #[derive(Copy, Clone)] pub(crate) struct Order { @@ -68,18 +86,22 @@ pub(crate) struct Order { /// Its place among all the rules, by the plugin and the name. A rule that oxlint has not comes after its rules. pub(crate) number: u16, /// For [`Entering`]: the kinds of node that it is called with. - pub(crate) kinds: Kinds, + pub(crate) kinds: Called, } pub(crate) fn of(rule: &Meta) -> Order { let key = key(rule.plugin, rule.name.as_bytes()); let found = TABLE.rules.binary_search_by_key(&key, |it| it.key); match found.ok().and_then(|at| TABLE.rules.get(at)) { - Some(it) => it.order, + Some(it) => Order { + when: it.when, + number: it.number, + kinds: visited::called(it.kinds), + }, None => Order { when: Entering, number: u16::MAX, - kinds: Kinds::default(), + kinds: Called::default(), }, } } @@ -129,7 +151,9 @@ fn key(plugin: Plugin, name: &[u8]) -> u32 { struct Rule { /// [`key`], by which they are sorted. key: u32, - order: Order, + when: When, + number: u16, + kinds: Kinds, } /// [`RULES`] and [`KINDS`], taken apart. @@ -159,11 +183,9 @@ static TABLE: LazyLock = LazyLock::new(|| { let mut rules: Vec = (all.iter().zip(0u16..)) .map(|(it, number)| Rule { key: key(it.2, it.1), - order: Order { - when: it.3, - number, - kinds: it.4, - }, + when: it.3, + number, + kinds: it.4, }) .collect(); sort::sort_unstable_by_key(&mut rules, |it| it.key); @@ -177,7 +199,7 @@ static TABLE: LazyLock
    = LazyLock::new(|| { #[derive(Copy, Clone)] pub(crate) struct Ran { pub(crate) when: When, - pub(crate) kinds: Kinds, + pub(crate) kinds: Called, /// [`once`] pub(crate) once: Once, /// [`is_port`] @@ -241,8 +263,26 @@ const END: u32 = DEPTH + 15; /// Deeper than any node: what is reported stands in for a node that is not found. const DEEPEST: u128 = (1 << 15) - 1; -/// Puts `nodes` in the order in which a walk enters the nodes, those of one node by the rule. In the high half of each is where -/// the node starts, in the low half the place in `diagnostics` and in `found`, which has the nodes, each with what decides among +/// Two lists that are in order, as one. +fn merged(a: &[u64], b: &[u64]) -> Vec { + let mut all = Vec::with_capacity(a.len() + b.len()); + let (mut rest_of_a, mut rest_of_b) = (a, b); + while let ([first_of_a, after_a @ ..], [first_of_b, after_b @ ..]) = (rest_of_a, rest_of_b) { + if first_of_a <= first_of_b { + all.push(*first_of_a); + rest_of_a = after_a; + } else { + all.push(*first_of_b); + rest_of_b = after_b; + } + } + all.extend_from_slice(rest_of_a); + all.extend_from_slice(rest_of_b); + all +} + +/// Puts `nodes`, which are by where the node starts, in the order in which a walk enters the nodes, those of one node by the rule. In +/// the high half of each is where the node starts, in the low half the place in `diagnostics` and in `found`, which has the nodes, each with what decides among /// what one rule reports about it. fn sort_as_entered<'a>( file: &'a File<'a>, @@ -250,7 +290,6 @@ fn sort_as_entered<'a>( found: &[(Place, u32)], nodes: &mut [u64], ) { - nodes.sort_unstable(); let key = |at: u32, depth_of: &dyn Fn(&Place) -> u128| { let (it, (place, within)) = (diagnostics.get(at as usize)?, found.get(at as usize)?); Some( @@ -357,49 +396,63 @@ pub(crate) fn in_run_order<'a>( } Some(When::Last) => last.push(with_place(by_place, at)), _ => { - nodes.push(at.into()); - if has_place == 0 { - continue; - } // A rule that says which node it is goes through the node as oxlint's does. place.1 = match it.states_node || ran.is_some_and(|it| it.is_port) { true => at, false => it.span.start, }; - let kinds = ran.map_or_else(Kinds::default, |it| it.kinds); - let label = match (it.node, details.and_then(|it| it.listened_on)) { - (None, Some(stated)) => stated, - _ => it.span, + let called = ran.map_or_else(Called::default, |it| it.kinds); + let stated = details.and_then(|it| it.listened_on); + let label = stated.filter(|_| it.node.is_none()).unwrap_or(it.span); + let known = match it.node { + _ if has_place == 0 => Some(nowhere), + Some(node) => (places.at_a_look(&called, node, it)) + .or_else(|| places.of(it.rule, &called.kinds, label, it.node)), + // The name is the node. + None if it.states_node && stated.is_none() => Some(Place { + span: label, + node: None, + }), + None => places.of(it.rule, &called.kinds, label, None), }; - if it.states_node - && it.node.is_none() - && details.is_none_or(|it| it.listened_on.is_none()) - { - place.0.span = label; - continue; - } - match places.of(it.rule, &kinds, label, it.node) { - Some(found) => place.0 = found, + match known { + Some(known) => { + place.0 = known; + nodes.push((u64::from(known.span.start) << 32) | u64::from(at)); + } None => searched.push(with_place(as_entered(label), at)), } } } } - searched.sort_unstable(); - for (label, at) in searched - .into_iter() - .map(|it| ((it >> 32) as u64, it as u32)) - { - let label = Span::new((label >> 32) as u32, u32::MAX - label as u32); - if let (Some(it), Some(place)) = (diagnostics.get(at as usize), found.get_mut(at as usize)) - { - let kinds = ask(it.rule).map_or_else(Kinds::default, |it| it.kinds); - place.0 = places.search(it.rule, &kinds, label); + nodes.sort_unstable(); + if !searched.is_empty() { + searched.sort_unstable(); + let mut late: Vec = Vec::with_capacity(searched.len()); + // How many of `nodes` start no later than what is searched for. + let mut before = 0; + for (label, at) in (searched.into_iter()).map(|it| ((it >> 32) as u64, it as u32)) { + let label = Span::new((label >> 32) as u32, u32::MAX - label as u32); + while (nodes.get(before)).is_some_and(|it| (it >> 32) as u32 <= label.start) { + before += 1; + } + // The last of them is close by. + let near = before.checked_sub(1).and_then(|it| { + let (start, at) = nodes.get(it).map(|it| ((it >> 32) as u32, *it as u32))?; + let node = found.get(at as usize)?.0.node.map(|it| it.0); + Some((start, node.or(diagnostics.get(at as usize)?.node)?)) + }); + let (Some(it), Some(place)) = + (diagnostics.get(at as usize), found.get_mut(at as usize)) + else { + continue; + }; + let kinds = ask(it.rule).map_or_else(Kinds::default, |it| it.kinds.kinds); + place.0 = places.search(it.rule, &kinds, label, near); + late.push((u64::from(place.0.span.start) << 32) | u64::from(at)); } - } - for it in &mut nodes { - let start = found.get(*it as usize).map_or(0, |it| it.0.span.start); - *it |= u64::from(start) << 32; + late.sort_unstable(); + nodes = merged(&nodes, &late); } sort_as_entered(file, diagnostics, &found, &mut nodes); once.sort_unstable(); diff --git a/src/lint/oxlint_order/visited.rs b/src/lint/oxlint_order/visited.rs index d0c40e1be94e..fcd045d34507 100644 --- a/src/lint/oxlint_order/visited.rs +++ b/src/lint/oxlint_order/visited.rs @@ -8,11 +8,12 @@ //! shape. A report keeps the node that it was given, so most start there. One for a token or a name starts where the last //! ended: see [`Places::innermost_around`]. The way up is short, or it is remembered: [`AncestorMemo`]. -use super::{Kinds, kind_named}; +use super::{Called, Kinds, kind_named}; use crate::ast::{ - BinOp, ExprKind, ExprTag, File, FnKind, Node, Packed, Pat, PatKind, PropKind, StmtKind, - StmtTag, UnOp, + BinOp, ExprKind, ExprTag, File, Flags, FnKind, MemberKind, Node, Packed, Pat, PatKind, + PropKind, StmtKind, StmtTag, UnOp, }; +use crate::context::Diagnostic; use crate::semantic::Declaration; use crate::span::Span; use crate::utils::ancestor_memo::AncestorMemo; @@ -67,6 +68,53 @@ struct Known { leaves: Kinds, /// What has no node here and is in one that has: a body, an opening tag. parts: Kinds, + /// A bit for each `ExprTag` and for each `StmtTag` that is one of `leaves`. + leaf_exprs: u64, + leaf_stmts: u64, + method_definition: u8, + property_definition: u8, + accessor_property: u8, +} + +/// A bit for each place in `table` that has one of `kinds`. +fn bits(table: &[u8], kinds: &Kinds) -> u64 { + let places = table.iter().zip(0u32..).filter(|it| kinds.has(*it.0)); + places.fold(0, |all, it| all | (1 << it.1)) +} + +pub(super) fn called(kinds: Kinds) -> Called { + let known = &*KNOWN; + let bit = |kind: u8, at: u32| u64::from(kinds.has(kind)) << at; + // All the bits from `first` up to `last`, if the rule is called with `kind`. + let range = |kind: u8, first: u32, last: u32| match kinds.has(kind) { + true => ((1u32 << (last + 1)) - 1) & !((1 << first) - 1), + false => 0, + }; + Called { + kinds, + exprs: bits(&known.exprs, &kinds), + exprs_without_jsx: bit(known.identifier_reference, ExprTag::Ident as u32) + | bit(known.string_literal, ExprTag::String as u32), + stmts: bits(&known.stmts, &kinds), + binaries: range(known.binary_expression, 0, BinOp::Instanceof as u32) + | range( + known.logical_expression, + BinOp::And as u32, + BinOp::Nullish as u32, + ) + | range( + known.sequence_expression, + BinOp::Comma as u32, + BinOp::Comma as u32, + ), + unaries: range(known.unary_expression, 0, UnOp::Delete as u32) + | range( + known.update_expression, + UnOp::PreInc as u32, + UnOp::PostDec as u32, + ), + is_odd: kinds.has_any_of(&known.parts) || kinds.are_among(&known.declarations), + } } static KNOWN: LazyLock = LazyLock::new(|| { @@ -201,6 +249,13 @@ static KNOWN: LazyLock = LazyLock::new(|| { jsx_opening_element: kind("JSXOpeningElement"), ts_interface_body: kind("TSInterfaceBody"), ts_enum_body: kind("TSEnumBody"), + leaf_exprs: bits(&exprs, &leaves) + | (1 << ExprTag::Ident as u32) + | (1 << ExprTag::String as u32), + leaf_stmts: bits(&stmts, &leaves), + method_definition: kind("MethodDefinition"), + property_definition: kind("PropertyDefinition"), + accessor_property: kind("AccessorProperty"), declarations, leaves, parts, @@ -292,6 +347,26 @@ impl Known { Node::Func(it) => match it.kind() { FnKind::Arrow => self.arrow_function_expression, FnKind::Decl | FnKind::Expr => self.function, + FnKind::Method | FnKind::Getter | FnKind::Setter | FnKind::Constructor => { + match it.owner() { + // A signature. + Node::Member(owner) if !matches!(owner.parent(), Node::Class(_)) => { + by_name() + } + _ => self.function, + } + } + _ => by_name(), + }, + Node::Member(it) if matches!(it.parent(), Node::Class(_)) => match it.kind() { + MemberKind::Property if it.flags().contains(Flags::ACCESSOR) => { + self.accessor_property + } + MemberKind::Property => self.property_definition, + MemberKind::Method + | MemberKind::Getter + | MemberKind::Setter + | MemberKind::Constructor => self.method_definition, _ => by_name(), }, Node::Class(_) => self.class, @@ -443,8 +518,10 @@ fn value_of(node: Node) -> Option { pub(super) struct Places<'a> { file: &'a File<'a>, known: &'static Known, - /// Where the last search ended. + has_no_jsx: bool, + /// Where the last search ended, and where what it was for starts. last: Node<'a>, + last_start: u32, /// For each rule that has gone far up: what it has found there. far: FxHashMap>>, } @@ -454,16 +531,73 @@ impl<'a> Places<'a> { Places { file, known: &KNOWN, + has_no_jsx: file.hir.jsx.is_empty(), last: Node::File(file), + last_start: 0, far: FxHashMap::default(), } } + /// [`Places::of`] for most of what is reported: `node`, which `it` has, is an expression or a statement that oxlint calls the + /// rule with. `None`: it is not, or it takes more than a look to tell. + #[inline] + pub(super) fn at_a_look( + &self, + called: &Called, + node: Packed, + it: &Diagnostic, + ) -> Option { + if called.is_odd { + return None; + } + let (is_leaf, span) = match node.unpack(self.file) { + Node::Expr(e) => { + let tag = e.tag(); + let tags = called.exprs + | if self.has_no_jsx { + called.exprs_without_jsx + } else { + 0 + }; + let is_called_with = (tags >> tag as u32) & 1 != 0 + || match tag { + ExprTag::Binary => (called.binaries >> e.binary_op()? as u32) & 1 != 0, + ExprTag::Unary => (called.unaries >> e.unary_op()? as u32) & 1 != 0, + _ => false, + }; + if !is_called_with { + return None; + } + let span = if it.states_node { e.span() } else { it.span }; + ((self.known.leaf_exprs >> tag as u32) & 1 != 0, span) + } + // With an `export` before it, it is in another node, which starts there. + Node::Stmt(statement) if !statement.is_exported() => { + let tag = statement.tag(); + if (called.stmts >> tag as u32) & 1 == 0 || statement.is_with() { + return None; + } + let span = if it.states_node { + statement.span() + } else { + it.span + }; + ((self.known.leaf_stmts >> tag as u32) & 1 != 0, span) + } + _ => return None, + }; + Some(Place { + span, + node: (!is_leaf).then_some((node, ITSELF)), + }) + } + /// The innermost node around `label`. /// - /// It goes up from where the last search ended, and then down. What is asked for comes by where it starts, the longer first, - /// which is the order in which a walk enters the nodes. So a node that is left is not come back to, and all the searches - /// together go along each edge of the tree once up and once down. + /// It goes up from `last`, and then down. That is where the last search ended, or a node that starts after what that was for and + /// no later than `label`. What is asked for comes by where it starts, the longer first, which is the order in which a walk + /// enters the nodes. So a node that is left is not come back to, and all the searches together go along each edge of the + /// tree once up and once down. fn innermost_around(&self, label: Span) -> Node<'a> { let is_around = |it: Node| { let span = it.span(); @@ -567,10 +701,22 @@ impl<'a> Places<'a> { Some(self.find(rule, kinds, node, None)) } - /// [`Places::of`] for a token, a name or a span. To be asked by where they start, the longer first. - pub(super) fn search(&mut self, rule: u16, kinds: &Kinds, label: Span) -> Place { + /// [`Places::of`] for a token, a name or a span. To be asked by where they start, the longer first. `near`: a node that starts + /// no later, and where. + pub(super) fn search( + &mut self, + rule: u16, + kinds: &Kinds, + label: Span, + near: Option<(u32, Packed)>, + ) -> Place { + if let Some((start, node)) = near + && start > self.last_start + { + self.last = node.unpack(self.file); + } let from = self.innermost_around(label); - self.last = from; + (self.last, self.last_start) = (from, label.start); self.find(rule, kinds, from, Some(label)) } } diff --git a/src/sema/parser/parser/expr.rs b/src/sema/parser/parser/expr.rs index 21261e0f6397..c90ce77f83ee 100644 --- a/src/sema/parser/parser/expr.rs +++ b/src/sema/parser/parser/expr.rs @@ -947,8 +947,8 @@ impl Parser<'_, GENERAL> { // To oxc's lexer ` Parser<'_, GENERAL> { && self.reports_like_oxc() { // `declare function ();`, `function () {}`: oxc wants no name there. - (Atom::NONE, start.pos) + (Atom::NONE, self.pos()) } else if !flags.contains(Flags::DEFAULT) { let full = self.full_start(); match self.missing_identifier(0, 0) { diff --git a/src/sema/parser/parser/recover.rs b/src/sema/parser/parser/recover.rs index 878619e0e4e1..1af96bcff89d 100644 --- a/src/sema/parser/parser/recover.rs +++ b/src/sema/parser/parser/recover.rs @@ -245,10 +245,12 @@ impl Parser<'_, GENERAL> { (self.has_await_in_name, self.has_await_identifier) = (false, false); let before = self.checkpoint(); let had_plain_await = self.has_plain_top_level_await; + let reparsed_rest = self.reparses_rest_of_file; let statement = self.statement(); if self.has_await_identifier && self.options.is_module_or_script && !had_plain_await + && !reparsed_rest && !self.f.has_module_syntax && let Some(statement) = self.first_reading_of_oxc(&before, statement) { @@ -274,14 +276,21 @@ impl Parser<'_, GENERAL> { let is_of_parser = |it: &&Diagnostic| it.kind == DiagnosticKind::Parse; let said_before = before.file.diagnostics as usize; let said = self.f.diagnostics.get(said_before..).unwrap_or_default(); - let second: Vec = said.iter().filter(is_of_parser).cloned().collect(); + let gives_up = + |it: &&Diagnostic| matches!(it.kind, DiagnosticKind::Parse | DiagnosticKind::OxcFatal); + let second: Vec = said.iter().filter(gives_up).cloned().collect(); let start = self.f.stmts.get(statement.idx())?.start; let is_import_meta = |it: &Expr| it.pos < start && matches!(it.kind, ExprKind::ImportMeta); - if second.is_empty() || self.has_failed() || self.f.exprs.iter().any(is_import_meta) { + if !second.iter().any(|it| is_of_parser(&it)) + || self.has_failed() + || self.f.exprs.iter().any(is_import_meta) + { return None; } self.rollback(before); self.has_plain_top_level_await = false; + (self.has_await_in_statement, self.was_await_refused) = (false, false); + self.reparses_rest_of_file = false; let saved = self.enter_context(0, ctx::AWAIT); let statement = self.statement(); self.context = saved; diff --git a/test/cli/lint/lint.test.ts b/test/cli/lint/lint.test.ts index 21c789da07ab..23a464a722ba 100644 --- a/test/cli/lint/lint.test.ts +++ b/test/cli/lint/lint.test.ts @@ -11053,6 +11053,10 @@ describe("TypeScript grammar errors under .oxlintrc.json", () => { const rejected: Record = { "ts": [ + [ + "await (x) => 1; import.meta;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @13+0 help: Try inserting a semicolon here", + ], [ "function f(a?, b, c? = 1) {}", "TS(1016) A required parameter cannot follow an optional parameter. @15+1", @@ -14832,6 +14836,44 @@ describe("TypeScript grammar errors under .oxlintrc.json", () => { ["class A { p\\u0075blic a; }", "Keywords cannot contain escape characters @10+11"], ["let a = b \\u0061s c;", "Keywords cannot contain escape characters @10+7"], ["let a: k\\u0065yof b;", "Keywords cannot contain escape characters @7+10"], + ["f(await); g(await); export {};", "Unexpected token @17+1"], + ["f(await); g(await); h(await); export {};", "Unexpected token @27+1"], + ["await (x); await; export {};", "Unexpected token @16+1"], + ["f(await); a; export {};", "Unexpected token @7+1"], + ["f(await); new.target; g(await); export {};", "Unexpected token @29+1"], + [ + "new.target; f(await); g(await (x)); export {};", + "Unexpected new.target expression @0+10 help: new.target is only allowed in constructors, functions, and class field initializers", + ], + [ + "new.target; f(await); g(await); export {};", + "Unexpected new.target expression @0+10 help: new.target is only allowed in constructors, functions, and class field initializers", + "Unexpected token @29+1", + ], + [ + "new.target, f(await); g(await (x)); export {};", + "Unexpected new.target expression @0+10 help: new.target is only allowed in constructors, functions, and class field initializers", + ], + [ + "new.target, f(await); new.target; g(await (x)); export {};", + "Unexpected new.target expression @0+10 help: new.target is only allowed in constructors, functions, and class field initializers", + ], + ["typeof await; function f() { await(x); } import.meta;", "The keyword 'await' is reserved @29+5"], + ["await / x, class await {}; import.meta;", "Unterminated regular expression @6+34", "Unexpected token @6+34"], + ["await / x; import.meta;", "Unterminated regular expression @6+18", "Unexpected token @6+18"], + ["await / x\nimport.meta;", "Unterminated regular expression @6+4", "Unexpected token @6+17"], + [ + "await / x / y; import.meta;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @11+0 help: Try inserting a semicolon here", + ], + [ + "f(await); let a; let a; g(await (x)); export {};", + 'Identifier `a` has already been declared @14+1 "`a` has already been declared here", 21+1 "It can not be redeclared here"', + ], + [ + "f(await); g(await (x)); let a; let a; export {};", + 'Identifier `a` has already been declared @28+1 "`a` has already been declared here", 35+1 "It can not be redeclared here"', + ], ], "d.ts": [ ["const a: number = 1;", "TS(1039) Initializers are not allowed in ambient contexts. @18+1"], @@ -15143,7 +15185,6 @@ describe("TypeScript grammar errors under .oxlintrc.json", () => { ["let zz; let zz; ;", "TS(17000) JSX attributes must only be assigned a non-empty 'expression'. @21+2"], ["< />;", "Unexpected token @2+1"], ["await ; import.meta;", "Unexpected token @10+1"], - ["await b; import.meta;", "Unterminated regular expression @11+18", "Unexpected token @11+18"], [ "await (x) => 1; import.meta;", "Expected a semicolon or an implicit semicolon after a statement, but found none @13+0 help: Try inserting a semicolon here", @@ -18203,6 +18244,9 @@ describe("TypeScript grammar errors under .oxlintrc.json", () => { "Expected a semicolon or an implicit semicolon after a statement, but found none @9+0 help: Try inserting a semicolon here", ], ["await b; import.meta;", "Unterminated regular expression @11+18", "Unexpected token @11+18"], + ["yi\\u0065ld; export {};", "The keyword 'yield' is reserved @0+10"], + ["await < a; import.meta;", "Unexpected token @9+1"], + ["await (x), class await {}; import.meta;", "Cannot use `await` as an identifier in an async context @17+5"], ["await ; import.meta;", "Unexpected token @10+1"], ["await ; new.target; import.meta;", "Unexpected token @10+1"], ["await /k/; import.meta;", "Unexpected token @9+1"], @@ -18292,6 +18336,44 @@ describe("TypeScript grammar errors under .oxlintrc.json", () => { ["import a fr\\u006fm 'a';", "Keywords cannot contain escape characters @9+9"], ["import * \\u0061s a from 'a';", "Keywords cannot contain escape characters @9+7"], ["export { a \\u0061s b }; var a;", "Keywords cannot contain escape characters @11+7"], + ["f(await); g(await); export {};", "Unexpected token @17+1"], + ["f(await); g(await); h(await); export {};", "Unexpected token @27+1"], + ["await (x); await; export {};", "Unexpected token @16+1"], + ["f(await); a; export {};", "Unexpected token @7+1"], + ["f(await); new.target; g(await); export {};", "Unexpected token @29+1"], + [ + "new.target; f(await); g(await (x)); export {};", + "Unexpected new.target expression @0+10 help: new.target is only allowed in constructors, functions, and class field initializers", + ], + [ + "new.target; f(await); g(await); export {};", + "Unexpected new.target expression @0+10 help: new.target is only allowed in constructors, functions, and class field initializers", + "Unexpected token @29+1", + ], + [ + "new.target, f(await); g(await (x)); export {};", + "Unexpected new.target expression @0+10 help: new.target is only allowed in constructors, functions, and class field initializers", + ], + [ + "new.target, f(await); new.target; g(await (x)); export {};", + "Unexpected new.target expression @0+10 help: new.target is only allowed in constructors, functions, and class field initializers", + ], + ["typeof await; function f() { await(x); } import.meta;", "The keyword 'await' is reserved @29+5"], + ["await / x, class await {}; import.meta;", "Unterminated regular expression @6+34", "Unexpected token @6+34"], + ["await / x; import.meta;", "Unterminated regular expression @6+18", "Unexpected token @6+18"], + ["await / x\nimport.meta;", "Unterminated regular expression @6+4", "Unexpected token @6+17"], + [ + "await / x / y; import.meta;", + "Expected a semicolon or an implicit semicolon after a statement, but found none @11+0 help: Try inserting a semicolon here", + ], + [ + "f(await); let a; let a; g(await (x)); export {};", + 'Identifier `a` has already been declared @14+1 "`a` has already been declared here", 21+1 "It can not be redeclared here"', + ], + [ + "f(await); g(await (x)); let a; let a; export {};", + 'Identifier `a` has already been declared @28+1 "`a` has already been declared here", 35+1 "It can not be redeclared here"', + ], ], "cjs": [ ["await /k/;", "Unexpected token @9+1"], @@ -19242,6 +19324,16 @@ describe("TypeScript grammar errors under .oxlintrc.json", () => { "export declare function (a: T): T;", "export default function () {}", "\\u{61}wait;", + "f(await); g(await (x)); export {};", + "f(await); await (x); export {};", + "await; await (x); export {};", + "f(await); new.target; g(await (x)); export {};", + "f(await); new.target; new.target; g(await (x)); export {};", + "f(await), new.target; g(await (x)); export {};", + "await = 1; await (x); import.meta;", + "await.a; await (x); import.meta;", + "await / x; await (y); import.meta;", + "await < x; await (y); import.meta;", ], "ts tsx mts": [ "using a = b; await using c = d; export {};", @@ -19579,6 +19671,16 @@ describe("TypeScript grammar errors under .oxlintrc.json", () => { "\\u{61}wait (x);", "function f() { \\u{61}wait; }", "yi\\u0065ld;", + "f(await); g(await (x)); export {};", + "f(await); await (x); export {};", + "await; await (x); export {};", + "f(await); new.target; g(await (x)); export {};", + "f(await); new.target; new.target; g(await (x)); export {};", + "f(await), new.target; g(await (x)); export {};", + "await = 1; await (x); import.meta;", + "await.a; await (x); import.meta;", + "await / x; await (y); import.meta;", + "await < x; await (y); import.meta;", ], "cts": [ "for (var a = 1 in {}) {}", @@ -19651,13 +19753,7 @@ describe("TypeScript grammar errors under .oxlintrc.json", () => { // The second `await` is a reference, so oxc reads the statement again once it knows that the file is a module. ["js", "x = { await, b: await }; import.meta;", 'Expected `:` but found `,` @11+1 "`:` expected"'], // After a name `<` compares. oxc reads the statement again, where an element starts. - ["js", "await < a; import.meta;", "Unexpected token @9+1"], ["js", "await < a > b; import.meta;", "Unexpected token @11+17"], - [ - "ts", - "await (x) => 1; import.meta;", - "Expected a semicolon or an implicit semicolon after a statement, but found none @13+0 help: Try inserting a semicolon here", - ], // `-->` at the start of a line. [ "js", @@ -19672,8 +19768,6 @@ describe("TypeScript grammar errors under .oxlintrc.json", () => { "l\\u0065t a;", "Expected a semicolon or an implicit semicolon after a statement, but found none @8+0 help: Try inserting a semicolon here", ], - // The name as it is spelled. - ["js", "yi\\u0065ld; export {};", "The keyword 'yield' is reserved @0+10"], // `new.target` in a declaration file. [ "d.ts", @@ -19704,8 +19798,8 @@ describe("TypeScript grammar errors under .oxlintrc.json", () => { "Invalid Character `;` @2+0", "Unexpected new.target expression @4+10 help: new.target is only allowed in constructors, functions, and class field initializers", ], - // The second reading has `await` as a keyword. - ["js", "await (x), class await {}; import.meta;", "Cannot use `await` as an identifier in an async context @17+5"], + // In a .tsx file `b; import.meta;", "Unterminated regular expression @11+18", "Unexpected token @11+18"], // Type parameters that do not start. ["ts", "declare function <;", "Unexpected token @18+1"], ]; diff --git a/test/js/bun/glob/match.test.ts b/test/js/bun/glob/match.test.ts index 168ccb948572..2e4ae8810b35 100644 --- a/test/js/bun/glob/match.test.ts +++ b/test/js/bun/glob/match.test.ts @@ -2629,6 +2629,8 @@ describe("Glob.match", () => { ["*\\*", ["*", "ab*"], ["ab", "a*b"]], ["*\\[a\\]", ["[a]", "x[a]"], ["xa", "x[a"]], ["*?.ts", ["a.ts", "index.ts", "é.ts"], [".ts", "a.js", "a/.ts"]], + ["*?", ["a", "ab", "é", "😀"], ["", "/", "a/", "a/b"]], + ["?*", ["a", "ab", "é", "😀x"], ["", "/", "/a", "a/b"]], ["*??", ["ab", "abc", "éé"], ["", "a", "a/b"]], ["*?[ab]/x", ["ca/x", "xxcb/x"], ["a/x", "cc/x", "ca/y"]], ["*?[^/]_*1", ["_xa_aab1", "ab_1"], ["a_1", "ab_2"]], @@ -2731,6 +2733,22 @@ describe("Glob.match", () => { }, ); + test.each([ + ["*[éa]", "é", "ü"], + ["*\\é", "é", "ü"], + ["*[語a]", "語", "誰"], + ])("%s tells characters apart after a long path", (pattern, matching, other) => { + const glob = new Glob(pattern); + for (let round = 0; round < 40; round++) glob.match("x"); + const long = Buffer.alloc(600, "x").toString(); + expect([long + matching, long + other, "x" + matching, "x" + other].map(path => glob.match(path))).toEqual([ + true, + false, + true, + false, + ]); + }); + test("a group can be anywhere in a long pattern", () => { for (let before = 50; before <= 270; before++) { const any = Buffer.alloc(before, "?").toString(); From dea575e9209fc4f8c7b152a6554daeb114bfa553 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 13 Oct 2026 04:30:15 +0000 Subject: [PATCH 46/49] lint, format: 34 files --- src/lint/context.rs | 2 + src/lint/driver/configs.rs | 2 +- src/lint/driver/format/excerpt.rs | 33 +++- src/lint/driver/format/info.rs | 23 ++- src/lint/driver/format/mod.rs | 5 +- src/lint/driver/format/oxlint.rs | 25 ++- src/lint/driver/lint.rs | 16 +- src/lint/driver/run.rs | 2 +- src/lint/eslint/rules/complexity.rs | 59 ++++-- src/lint/eslint/rules/max_depth.rs | 43 ++-- src/lint/eslint/rules/no_continue.rs | 9 +- src/lint/eslint/rules/no_empty_function.rs | 39 ++-- src/lint/eslint/rules/no_negated_condition.rs | 26 ++- src/lint/eslint/rules/object_shorthand.rs | 120 +++++++++--- src/lint/language.rs | 4 + src/lint/linter/config/mod.rs | 144 +++++++++++++- src/lint/linter/mod.rs | 77 ++------ src/lint/linter/registry.rs | 30 ++- src/lint/linter/resolved.rs | 12 +- src/lint/linter/syntax/espree/early.rs | 5 +- src/lint/modules.rs | 1 + src/lint/oxlint_order.rs | 185 +++++++++--------- src/lint/oxlint_order/visited.rs | 55 ++++-- src/lint/semantic/table_of_oxc.rs | 5 +- src/lint/source.rs | 69 +++++-- .../rules/consistent_type_imports.rs | 151 ++++++++++---- .../typescript/rules/init_declarations.rs | 18 +- .../rules/no_unsafe_function_type.rs | 4 +- src/lint/typescript/rules/prefer_for_of.rs | 92 ++++++--- .../unicorn/rules/unicorn_no_hex_escape.rs | 39 +++- .../rules/unicorn_no_negated_condition.rs | 12 +- .../rules/unicorn_number_literal_case.rs | 54 +++-- .../rules/unicorn_numeric_separators_style.rs | 45 +++-- .../rules/unicorn_prefer_string_raw.rs | 69 +++++-- 34 files changed, 1060 insertions(+), 415 deletions(-) diff --git a/src/lint/context.rs b/src/lint/context.rs index cdc784e6e749..ad9e87153b06 100644 --- a/src/lint/context.rs +++ b/src/lint/context.rs @@ -103,6 +103,8 @@ pub(crate) struct Sink { pub(crate) thrown: RefCell>, /// [`File::calls_in_turns`] pub(crate) calls_in_turns: OnceCell>, + /// A rule has asked for [`File::modules`]: the file may be linted again. + pub(crate) asks_about_modules: Cell, } impl File<'_> { diff --git a/src/lint/driver/configs.rs b/src/lint/driver/configs.rs index 2a2593bef5a4..c7485d551302 100644 --- a/src/lint/driver/configs.rs +++ b/src/lint/driver/configs.rs @@ -2198,7 +2198,7 @@ impl<'l> Loader<'l> { // With no config file and no flag, the rules decide. A rule that only uses types when they're available isn't a // reason to type check. if loaded.flavor == Flavor::BuiltIn && self.options.type_aware.is_none() { - return self.options.type_check || config.has_enabled(|it| it.requires_types); + return self.options.type_check || config.language.has_rule_that_requires_types; } // oxlint reads `options` in the configuration of the working directory only. let of_the_run = || { diff --git a/src/lint/driver/format/excerpt.rs b/src/lint/driver/format/excerpt.rs index a9e860baf572..bffab9127acf 100644 --- a/src/lint/driver/format/excerpt.rs +++ b/src/lint/driver/format/excerpt.rs @@ -70,6 +70,8 @@ struct Place<'r> { file: Option<(&'r FileResult, LineStarts, Vec)>, /// The file that [`with_help`] has asked about last, and the answer. helped: Option<(&'r FileResult, Vec)>, + /// The rules of the problems that are written, if those are few. + written: Option>, } impl<'r> Place<'r> { @@ -94,7 +96,7 @@ impl<'r> Place<'r> { fn with_help<'r>(problem: Problem<'r>, meta: &Meta, place: &mut Place<'r>) -> Option { let (lint, Problem { result, message }) = (meta.help?, problem); if !(place.helped.as_ref()).is_some_and(|it| std::ptr::eq(it.0, result)) { - place.helped = Some((result, lint(result))); + place.helped = Some((result, lint(result, place.written.as_deref()))); } let rule = |it: &LintMessage| it.rule_id.as_ref().map(|id| id.to_vec()); let is_same = |it: &&LintMessage| { @@ -220,12 +222,30 @@ impl<'r> Rule<'r> { } } +fn rules_of<'r>(problems: impl Iterator>) -> Option> { + Some( + problems + .filter_map(|it| it.message.rule_id.as_ref()) + .collect(), + ) +} + /// Groups problems that have the same rule and message, largest group first. With `by_rule`, the message is ignored and /// groups of errors come before groups of warnings. fn grouped<'r>(problems: &[Problem<'r>], by_rule: bool) -> Vec>> { let mut index: FxHashMap<(Rule, &[u8]), usize> = FxHashMap::default(); + // With `by_rule`: the group of a rule that is built in, by where the rule is. That saves reading its name for each problem. + let mut known: FxHashMap = FxHashMap::default(); let mut groups: Vec> = Vec::new(); for problem in problems { + let address = match &problem.message.rule_id { + Some(RuleId::Known(meta)) if by_rule => Some(std::ptr::from_ref(*meta).addr()), + _ => None, + }; + if let Some(at) = address.and_then(|it| known.get(&it)) { + groups[*at].push(*problem); + continue; + } let key = match Rule::of(problem) { Rule::None => (Rule::None, &problem.message.message[..]), rule if by_rule => (rule, &b""[..]), @@ -236,6 +256,9 @@ fn grouped<'r>(problems: &[Problem<'r>], by_rule: bool) -> Vec>> groups.len() - 1 }); groups[at].push(*problem); + if let Some(address) = address { + known.insert(address, at); + } } bun_lint::utils::sort::sort_by_key(&mut groups, |group| { let is_first = by_rule && group[0].is_error(); @@ -485,6 +508,9 @@ fn write_pretty_problems(out: &mut Vec, results: &[FileResult], meta: &Meta) let problems = all(results); let place = &mut Place::default(); if problems.len() <= GROUP_THRESHOLD || meta.shows_all { + if problems.len() <= GROUP_THRESHOLD { + place.written = rules_of(problems.iter().copied()); + } for (i, problem) in problems.into_iter().enumerate() { if i > 0 { out.push(b'\n'); @@ -495,6 +521,7 @@ fn write_pretty_problems(out: &mut Vec, results: &[FileResult], meta: &Meta) } let groups = grouped(&problems, meta.is_native); let shown = groups.len().min(MAX_GROUPS); + place.written = rules_of(groups[..shown].iter().map(|it| it[0])); for group in &groups[..shown] { let has_notes = write_pretty_problem(out, group[0], meta, place); if group.len() > 1 { @@ -676,12 +703,16 @@ pub(super) fn write_agent(out: &mut Vec, results: &[FileResult], meta: &Meta let problems = all(results); let place = &mut Place::default(); if problems.len() <= GROUP_THRESHOLD || meta.shows_all { + if problems.len() <= GROUP_THRESHOLD { + place.written = rules_of(problems.iter().copied()); + } problems.iter().for_each(|problem| { write_agent_problem(out, std::slice::from_ref(problem), meta, place) }); } else { let groups = grouped(&problems, false); let shown = groups.len().min(MAX_GROUPS + MAX_COMPACT_GROUPS); + place.written = rules_of(groups[..shown].iter().map(|it| it[0])); groups[..shown] .iter() .for_each(|group| write_agent_problem(out, group, meta, place)); diff --git a/src/lint/driver/format/info.rs b/src/lint/driver/format/info.rs index 5c92a8c3a6a2..c8e072952419 100644 --- a/src/lint/driver/format/info.rs +++ b/src/lint/driver/format/info.rs @@ -49,8 +49,18 @@ impl<'r> Source<'r> { strings::replace_owned(paths::inside(meta.cwd, &path).unwrap_or(&path), b"\\", b"/"); let text = result.text.as_deref().unwrap_or_default(); let offsets = Offsets::new(text, result.line_starts.as_deref()); - let has = |separator: &str| strings::contains(text, separator.as_bytes()); - let lines = (!offsets.is_ascii && (has("\u{2028}") || has("\u{2029}"))).then(|| { + // U+2028 and U+2029 start with the same two bytes. + let has_separator = || { + let mut rest = text; + while let Some(at) = strings::index_of(rest, b"\xE2\x80") { + if matches!(rest.get(at + 2), Some(0xA8 | 0xA9)) { + return true; + } + rest = &rest[at + 2..]; + } + false + }; + let lines = (!offsets.is_ascii && has_separator()).then(|| { let mut lines = vec![0]; let mut at = 0; while let Some(found) = strings::index_of_any(&text[at..], b"\r\n") { @@ -121,6 +131,15 @@ impl<'r> Source<'r> { /// [`Info::start`] pub(super) fn start(&self, message: &LintMessage) -> Position { + // In a line after the first, which may start with a byte order mark, and before its end, the line is the one that + // ESLint counts, and the column is known with the offset. + if message.line > 1 && self.lines.is_none() { + let (at, column) = self.offsets.at(message.line, message.column); + if self.text.get(at).is_some_and(|it| *it != b'\n') { + let line = message.line as usize; + return Position { line, column }; + } + } (self.span(message)).map_or_else(Default::default, |it| self.position(it.0)) } diff --git a/src/lint/driver/format/mod.rs b/src/lint/driver/format/mod.rs index cd7d956dfdf4..da0ce0066d83 100644 --- a/src/lint/driver/format/mod.rs +++ b/src/lint/driver/format/mod.rs @@ -16,7 +16,7 @@ mod xml; use crate::results::{Counts, FileResult}; use crate::run::Pool; use bun_core::fmt::TerminalHyperlink; -use bun_lint::linter::LintMessage; +use bun_lint::linter::{LintMessage, RuleId}; use std::borrow::Cow; /// `--format` @@ -161,7 +161,8 @@ impl Format { /// ESLint's `ResultsMeta`, and what else a format wants to know. /// [`Context::with_help`](crate::lint::Context::with_help) -pub(crate) type WithHelp<'m> = &'m (dyn Fn(&FileResult) -> Vec + Sync); +pub(crate) type WithHelp<'m> = + &'m (dyn Fn(&FileResult, Option<&[&RuleId]>) -> Vec + Sync); pub(crate) struct Meta<'m> { /// If the messages lack what it makes. diff --git a/src/lint/driver/format/oxlint.rs b/src/lint/driver/format/oxlint.rs index 3658bbb84e0c..763051a846de 100644 --- a/src/lint/driver/format/oxlint.rs +++ b/src/lint/driver/format/oxlint.rs @@ -34,7 +34,12 @@ pub(super) fn code(message: &LintMessage) -> Option> { /// [`code`], made once for each rule that is built in. #[derive(Default)] -pub(super) struct Codes(FxHashMap>); +pub(super) struct Codes { + /// One after the other. + text: Vec, + /// By where the rule is: from where to where its code is in `text`. + places: FxHashMap, +} impl Codes { /// Writes `before` and the code of `message`, if it has one. @@ -42,13 +47,25 @@ impl Codes { let made; let code = match &message.rule_id { Some(RuleId::Known(meta)) => { - let known = self.0.entry(std::ptr::from_ref(*meta).addr()); - &*known.or_insert_with(|| code(message).unwrap_or_default()) + let rule = std::ptr::from_ref(*meta).addr(); + let (start, end) = match self.places.get(&rule) { + Some(place) => *place, + None => { + let start = self.text.len() as u32; + self.text.extend(code(message).unwrap_or_default()); + let place = (start, self.text.len() as u32); + self.places.insert(rule, place); + place + } + }; + self.text + .get(start as usize..end as usize) + .unwrap_or_default() } _ => match code(message) { Some(code) => { made = code; - &made + &made[..] } None => return, }, diff --git a/src/lint/driver/lint.rs b/src/lint/driver/lint.rs index f18f36f54149..2c86ceae594b 100644 --- a/src/lint/driver/lint.rs +++ b/src/lint/driver/lint.rs @@ -115,6 +115,8 @@ struct How<'h> { without_rules: bool, /// [`Context::with_help`] with_help: bool, + /// Which rules run. + only: Option<&'h RuleFilter<'h>>, } fn only_errors(_: &RuleId, severity: Severity) -> bool { @@ -285,8 +287,12 @@ impl Context<'_, '_> { } /// Lints the text that the messages of `result` are about once more, and makes what oxlint says besides each message. Nothing, if - /// that was made the first time or there is no such thing: see [`Context::help_on_demand`]. - pub(crate) fn with_help(&self, result: &FileResult) -> Vec { + /// that was made the first time or there is no such thing: see [`Context::help_on_demand`]. `only`: the rules that are asked about. + pub(crate) fn with_help( + &self, + result: &FileResult, + only: Option<&[&RuleId]>, + ) -> Vec { let (Some(linted), Some(text)) = (&result.linted, &result.text) else { return Vec::new(); }; @@ -297,8 +303,10 @@ impl Context<'_, '_> { if was_made || !has_help { return Vec::new(); } + let is_asked_about = |id: &RuleId, _: Severity| only.is_none_or(|only| only.contains(&id)); let how = How { with_help: true, + only: Some(&is_asked_about), ..How::default() }; self.verify_as(&path, text, &linted.config, &how).messages @@ -565,7 +573,9 @@ impl Context<'_, '_> { physical_path_len: as_what.physical_path_len, rule_filter: match as_what.without_rules { true => Some(&no_rule), - false => as_what.script.map(|it| it.1).or(options.rule_filter), + false => (as_what.script.map(|it| it.1)) + .or(as_what.only) + .or(options.rule_filter), }, ..options }; diff --git a/src/lint/driver/run.rs b/src/lint/driver/run.rs index 4ddc5a1b4bf2..d20993280110 100644 --- a/src/lint/driver/run.rs +++ b/src/lint/driver/run.rs @@ -1410,7 +1410,7 @@ impl Run<'_> { && only.is_none(); let started = Instant::now(); - let with_help: format::WithHelp = &|result: &FileResult| context.with_help(result); + let with_help: format::WithHelp = &|result, only| context.with_help(result, only); let color = options.color.unwrap_or(environment.stdout.colors); let to_terminal = environment.stdout.is_tty && options.output_file.is_none(); let meta = format::Meta { diff --git a/src/lint/eslint/rules/complexity.rs b/src/lint/eslint/rules/complexity.rs index eca660ae3e07..52f6837c7f69 100644 --- a/src/lint/eslint/rules/complexity.rs +++ b/src/lint/eslint/rules/complexity.rs @@ -53,12 +53,18 @@ impl Complexity { let inside = |func: Func<'a>| { let whole = func.span(); let start = match func.open_paren() { - Some(open) => func.type_params().first().map_or(open, |it| it.span().start), + Some(open) => func + .type_params() + .first() + .map_or(open, |it| it.span().start), None => whole.start, }; (Span::new(start, whole.end), 0) }; - let outermost = cx.file().funcs().filter(|it| it.has_body() && it.enclosing().is_none()); + let outermost = cx + .file() + .funcs() + .filter(|it| it.has_body() && it.enclosing().is_none()); let mut outermost: Vec<(Span, usize)> = outermost.map(inside).collect(); utils::sort::sort_unstable_by_key(&mut outermost, |it| it.0.start); // The last that starts before what has been found. @@ -68,7 +74,9 @@ impl Complexity { let is_at = outermost.get(at).is_some_and(|it| it.0.start <= start) && outermost.get(at + 1).is_none_or(|it| start < it.0.start); if !is_at { - at = outermost.partition_point(|it| it.0.start <= start).wrapping_sub(1); + at = outermost + .partition_point(|it| it.0.start <= start) + .wrapping_sub(1); } around.push(match outermost.get_mut(at).filter(|it| start < it.0.end) { Some(function) => { @@ -88,11 +96,14 @@ impl Complexity { /// Adds to the complexity of what has the code path that `node` is in: the `Func` of a function /// or of a static block, or the `Member` whose initializer it is in. Nothing at the top level. fn add<'a>(node: Node<'a>, by: usize, cx: &mut Cx<'a, Self>) { - let owner = cx.state.owners.find(node, |inner, ancestor| match ancestor { - Node::Func(func) if func.has_body() => Some(ancestor), - Node::Member(member) if is_field_initializer(member, inner) => Some(ancestor), - _ => None, - }); + let owner = cx + .state + .owners + .find(node, |inner, ancestor| match ancestor { + Node::Func(func) if func.has_body() => Some(ancestor), + Node::Member(member) if is_field_initializer(member, inner) => Some(ancestor), + _ => None, + }); match owner { Some(Node::Func(func)) => Self::increase_function(func, by, cx), Some(Node::Member(member)) => *cx.state.of_fields.entry(member).or_insert(1) += by, @@ -119,7 +130,9 @@ impl Complexity { // oxlint points at the parentheses too. Node::Member(member) if is_oxlint => ( b"class field initializer".to_vec(), - member.init().map_or_else(|| member.span(), Expr::outer_span), + member + .init() + .map_or_else(|| member.span(), Expr::outer_span), ), Node::Member(member) => ( b"Class field initializer".to_vec(), @@ -137,7 +150,10 @@ impl Complexity { } } // oxlint points at the function. - Node::Func(func) if is_oxlint => (oxlint::get_function_name_with_kind(func), func.estree_span()), + Node::Func(func) if is_oxlint => ( + oxlint::get_function_name_with_kind(func), + func.estree_span(), + ), Node::Func(func) => ( upper_case_first(&ast_utils::get_function_name_with_kind(func)).into_owned(), ast_utils::get_function_head_loc(func), @@ -145,6 +161,7 @@ impl Complexity { _ => return, }; cx.report(loc, COMPLEX) + .called_with(owner) .data("name", name) .data("complexity", complexity) .data("max", threshold); @@ -180,9 +197,10 @@ impl Rule for Complexity { Complexity { threshold: match options.number(0) { Some(n) => Some(n as usize), - None if object.has("maximum") || object.has("max") => { - object.usize("maximum").filter(|n| *n != 0).or_else(|| object.usize("max")) - } + None if object.has("maximum") || object.has("max") => object + .usize("maximum") + .filter(|n| *n != 0) + .or_else(|| object.usize("max")), None => Some(THRESHOLD_DEFAULT), }, is_modified: object.str("variant") == Some("modified"), @@ -249,11 +267,22 @@ impl Rule for Complexity { fn stmt<'a>(&self, stmt: Stmt<'a>, cx: &mut Cx<'a, Self>) { match stmt.tag() { - StmtTag::If | StmtTag::For | StmtTag::ForIn | StmtTag::ForOf | StmtTag::While | StmtTag::DoWhile => { + StmtTag::If + | StmtTag::For + | StmtTag::ForIn + | StmtTag::ForOf + | StmtTag::While + | StmtTag::DoWhile => { Self::increase(stmt, 1, cx); } StmtTag::Try => { - if matches!(stmt.kind(), StmtKind::Try { handler: Some(_), .. }) { + if matches!( + stmt.kind(), + StmtKind::Try { + handler: Some(_), + .. + } + ) { Self::increase(stmt, 1, cx); } } diff --git a/src/lint/eslint/rules/max_depth.rs b/src/lint/eslint/rules/max_depth.rs index 41fa16375431..387538fb0abb 100644 --- a/src/lint/eslint/rules/max_depth.rs +++ b/src/lint/eslint/rules/max_depth.rs @@ -111,7 +111,8 @@ impl MaxDepth { /// An `if` that is directly in another one, as that of `else if`, is not counted where it begins, and is where it /// ends. So what follows it in the function is less deep by one. fn enter_before_10<'a>(&self, node: Node<'a>, cx: &mut Cx<'a, Self>) { - let (Some(max), Node::Stmt(stmt), true) = (self.max, node, ends_a_block_before_10(node)) else { + let (Some(max), Node::Stmt(stmt), true) = (self.max, node, ends_a_block_before_10(node)) + else { return; }; let is_if = |it: Stmt| it.tag() == StmtTag::If; @@ -125,7 +126,9 @@ impl MaxDepth { if let Ok(depth) = usize::try_from(*depth) && depth > max { - cx.report(stmt, TOO_DEEPLY).data("depth", depth).data("maxDepth", max); + cx.report(stmt, TOO_DEEPLY) + .data("depth", depth) + .data("maxDepth", max); } } } @@ -142,18 +145,28 @@ const STATEMENTS: &[StmtTag] = &[ ]; /// The tag of a `with` statement. const WITH: &[StmtTag] = &[StmtTag::Block]; -const WALKED: NodeTags = NodeTags::FILE.union(NodeTags::FUNC).stmts(STATEMENTS).stmts(WITH); +const WALKED: NodeTags = NodeTags::FILE + .union(NodeTags::FUNC) + .stmts(STATEMENTS) + .stmts(WITH); impl Rule for MaxDepth { const META: Meta = Meta::eslint("max-depth", Kind::Suggestion); - const ON: On = On::new().stmts(STATEMENTS).stmts(WITH).enter(WALKED).exit(WALKED); + const ON: On = On::new() + .stmts(STATEMENTS) + .stmts(WITH) + .enter(WALKED) + .exit(WALKED); type State<'a> = State<'a>; fn new(options: &Options) -> Self { let object = options.object(0); MaxDepth { max: match object.has("maximum") || object.has("max") { - true => object.usize("maximum").filter(|max| *max != 0).or_else(|| object.usize("max")), + true => object + .usize("maximum") + .filter(|max| *max != 0) + .or_else(|| object.usize("max")), false => Some(options.number(0).map_or(4, |max| max as usize)), }, } @@ -175,17 +188,25 @@ impl Rule for MaxDepth { let (Some(max), Some(keyword)) = (self.max, keyword_of(stmt)) else { return; }; - let around = cx.state.ancestors.count(Node::Stmt(stmt), |ancestor| match ancestor { - Node::Func(_) => Ancestor::End, - Node::Stmt(outer) if keyword_of(outer).is_some() => Ancestor::Counted, - _ => Ancestor::Passed, - }); + let around = cx + .state + .ancestors + .count(Node::Stmt(stmt), |ancestor| match ancestor { + Node::Func(_) => Ancestor::End, + Node::Stmt(outer) if keyword_of(outer).is_some() => Ancestor::Counted, + _ => Ancestor::Passed, + }); let depth = around + 1; if depth > max { let whole = stmt.span(); // oxlint points at the whole statement. - let end = if cx.language().is_oxlint { whole.end } else { whole.start + keyword.len() as u32 }; + let end = if cx.language().is_oxlint { + whole.end + } else { + whole.start + keyword.len() as u32 + }; cx.report(Span::new(whole.start, end), TOO_DEEPLY) + .called_with(stmt) .data("depth", depth) .data("maxDepth", max); } diff --git a/src/lint/eslint/rules/no_continue.rs b/src/lint/eslint/rules/no_continue.rs index cdf997f1687c..3a24501d0236 100644 --- a/src/lint/eslint/rules/no_continue.rs +++ b/src/lint/eslint/rules/no_continue.rs @@ -17,7 +17,12 @@ impl Rule for NoContinue { fn stmt<'a>(&self, stmt: Stmt<'a>, cx: &mut Cx<'a, Self>) { let whole = stmt.span(); // oxlint points at the keyword. - let end = if cx.language().is_oxlint { whole.start + "continue".len() as u32 } else { whole.end }; - cx.report(Span::new(whole.start, end), UNEXPECTED); + let end = if cx.language().is_oxlint { + whole.start + "continue".len() as u32 + } else { + whole.end + }; + cx.report(Span::new(whole.start, end), UNEXPECTED) + .called_with(stmt); } } diff --git a/src/lint/eslint/rules/no_empty_function.rs b/src/lint/eslint/rules/no_empty_function.rs index e7e8d5634c38..6149ffb276cf 100644 --- a/src/lint/eslint/rules/no_empty_function.rs +++ b/src/lint/eslint/rules/no_empty_function.rs @@ -72,17 +72,16 @@ fn method_definition_of(func: Func<'_>) -> Option> { let member = match func.owner() { Node::Member(member) => member, Node::Expr(e) => match e.parent() { - Node::Member(member) - if matches!(member.key().map(Key::kind), Some(KeyKind::Computed(key)) if key == e) => - { + Node::Member(member) if matches!(member.key().map(Key::kind), Some(KeyKind::Computed(key)) if key == e) => { member } _ => return None, }, _ => return None, }; - let is_method_definition = - member.func().is_some() && !member.is_signature() && !member.flags().contains(Flags::ABSTRACT); + let is_method_definition = member.func().is_some() + && !member.is_signature() + && !member.flags().contains(Flags::ABSTRACT); is_method_definition.then_some(member) } @@ -102,7 +101,10 @@ fn get_kind(func: Func) -> Option { FnKind::Arrow => return Some(ARROW_FUNCTIONS), FnKind::Decl => false, FnKind::Expr | FnKind::Method | FnKind::Getter | FnKind::Setter | FnKind::Constructor => { - match (method_definition_of(func), property_of(func).map(Prop::kind)) { + match ( + method_definition_of(func), + property_of(func).map(Prop::kind), + ) { (Some(member), _) if member.is_constructor() => return Some(CONSTRUCTORS), (Some(member), _) => match member.kind() { MemberKind::Getter => return Some(GETTERS), @@ -160,7 +162,9 @@ fn is_allowed_by_oxlint(func: Func, allow: Allow) -> bool { let is_allowed_method = is_async && allow.includes(ASYNC_METHODS) || is_generator && allow.includes(GENERATOR_METHODS) || allow.includes(METHODS); - let is_decorated = |member: Member| allow.includes(DECORATED_FUNCTIONS) && member.decorators().next().is_some(); + let is_decorated = |member: Member| { + allow.includes(DECORATED_FUNCTIONS) && member.decorators().next().is_some() + }; if func.is_arrow() { return allow.includes(ARROW_FUNCTIONS) || is_async && allow.includes(ASYNC_FUNCTIONS) @@ -177,12 +181,17 @@ fn is_allowed_by_oxlint(func: Func, allow: Allow) -> bool { _ if member.is_constructor() => { allow.includes(CONSTRUCTORS) || func.params().iter().any(Param::is_parameter_property) - || flags.contains(Flags::PRIVATE) && allow.includes(PRIVATE_CONSTRUCTORS) - || flags.contains(Flags::PROTECTED) && allow.includes(PROTECTED_CONSTRUCTORS) + || flags.contains(Flags::PRIVATE) + && allow.includes(PRIVATE_CONSTRUCTORS) + || flags.contains(Flags::PROTECTED) + && allow.includes(PROTECTED_CONSTRUCTORS) } MemberKind::Getter => allow.includes(GETTERS), MemberKind::Setter => allow.includes(SETTERS), - _ => is_allowed_method || flags.contains(Flags::OVERRIDE) && allow.includes(OVERRIDE_METHODS), + _ => { + is_allowed_method + || flags.contains(Flags::OVERRIDE) && allow.includes(OVERRIDE_METHODS) + } } } FunctionParent::ObjectProperty(prop) => match prop.kind() { @@ -255,14 +264,14 @@ fn check_without_statements<'a, R: Rule>(func: Func<'a>, allow: Allow, cx: &Cx<' let report = match cx.language().is_oxlint { true => { let (name, fn_kind) = name_for_oxlint(func); - cx.report(body, UNEXPECTED).data("name", name).data("fn_kind", fn_kind) + let report = cx.report(body, UNEXPECTED).called_with(func); + report.data("name", name).data("fn_kind", fn_kind) } false => cx.report(body, UNEXPECTED).data("name", name.clone()), }; - report - .suggest_with(SUGGEST_COMMENT, &[("name", &name[..])], |fixer| { - fixer.replace(inside, " /* empty */ ") - }); + report.suggest_with(SUGGEST_COMMENT, &[("name", &name[..])], |fixer| { + fixer.replace(inside, " /* empty */ ") + }); } impl Rule for NoEmptyFunction { diff --git a/src/lint/eslint/rules/no_negated_condition.rs b/src/lint/eslint/rules/no_negated_condition.rs index d61037f293c1..043787f5057a 100644 --- a/src/lint/eslint/rules/no_negated_condition.rs +++ b/src/lint/eslint/rules/no_negated_condition.rs @@ -4,12 +4,14 @@ use bun_lint_oxlint::no_negated_condition::{fix_conditional_expression, fix_if_s /// Disallow negated conditions. pub struct NoNegatedCondition; -const UNEXPECTED_NEGATED: Message = Message::new("unexpectedNegated", "Unexpected negated condition."); +const UNEXPECTED_NEGATED: Message = + Message::new("unexpectedNegated", "Unexpected negated condition."); /// ESLint's `isNegatedIf` #[inline] fn is_negated(test: Expr) -> bool { - test.unary_op() == Some(UnOp::Not) || matches!(test.binary_op(), Some(BinOp::NotEq | BinOp::NotEqEq)) + test.unary_op() == Some(UnOp::Not) + || matches!(test.binary_op(), Some(BinOp::NotEq | BinOp::NotEqEq)) } impl Rule for NoNegatedCondition { @@ -43,19 +45,31 @@ impl Rule for NoNegatedCondition { impl NoNegatedCondition { #[inline(never)] - fn report_if<'a>(stmt: Stmt<'a>, (test, yes, no): (Expr<'a>, Stmt<'a>, Stmt<'a>), cx: &Cx<'a, Self>) { + fn report_if<'a>( + stmt: Stmt<'a>, + (test, yes, no): (Expr<'a>, Stmt<'a>, Stmt<'a>), + cx: &Cx<'a, Self>, + ) { // oxlint points at the test, and has a fix. match cx.language().is_oxlint { - true => cx.report(test, UNEXPECTED_NEGATED).fix(|fixer| fix_if_statement(fixer, test, yes, no)), + true => { + let report = cx.report(test, UNEXPECTED_NEGATED).called_with(stmt); + report.fix(|fixer| fix_if_statement(fixer, test, yes, no)) + } false => cx.report(stmt, UNEXPECTED_NEGATED), }; } #[inline(never)] - fn report_conditional<'a>(e: Expr<'a>, (test, yes, no): (Expr<'a>, Expr<'a>, Expr<'a>), cx: &Cx<'a, Self>) { + fn report_conditional<'a>( + e: Expr<'a>, + (test, yes, no): (Expr<'a>, Expr<'a>, Expr<'a>), + cx: &Cx<'a, Self>, + ) { match cx.language().is_oxlint { true => { - cx.report(test, UNEXPECTED_NEGATED).fix(|fixer| fix_conditional_expression(fixer, e, test, yes, no)) + let report = cx.report(test, UNEXPECTED_NEGATED).called_with(e); + report.fix(|fixer| fix_conditional_expression(fixer, e, test, yes, no)) } false => cx.report(e, UNEXPECTED_NEGATED), }; diff --git a/src/lint/eslint/rules/object_shorthand.rs b/src/lint/eslint/rules/object_shorthand.rs index 2e9fe92a3272..f7350b0b4734 100644 --- a/src/lint/eslint/rules/object_shorthand.rs +++ b/src/lint/eslint/rules/object_shorthand.rs @@ -26,8 +26,10 @@ const EXPECTED_LITERAL_METHOD_LONGFORM: Message = Message::new( ); const EXPECTED_PROPERTY_SHORTHAND: Message = Message::new("expectedPropertyShorthand", "Expected property shorthand."); -const EXPECTED_PROPERTY_LONGFORM: Message = - Message::new("expectedPropertyLongform", "Expected longform property syntax."); +const EXPECTED_PROPERTY_LONGFORM: Message = Message::new( + "expectedPropertyLongform", + "Expected longform property syntax.", +); const EXPECTED_METHOD_SHORTHAND: Message = Message::new("expectedMethodShorthand", "Expected method shorthand."); const EXPECTED_METHOD_LONGFORM: Message = @@ -52,7 +54,8 @@ enum Finding<'a> { /// ESLint's `isConstructor`: whether the first character of `name` after `_`, `$` and digits is a /// capital letter. fn is_constructor(name: &[u8]) -> bool { - let mut rest = strings::wtf8_codepoints(name).skip_while(|it| matches!(it.1, 0x5F | 0x24 | 0x30..=0x39)); + let mut rest = + strings::wtf8_codepoints(name).skip_while(|it| matches!(it.1, 0x5F | 0x24 | 0x30..=0x39)); let Some((start, first)) = rest.next() else { return false; }; @@ -62,7 +65,10 @@ fn is_constructor(name: &[u8]) -> bool { /// ESLint's `canHaveShorthand` fn can_have_shorthand(prop: Prop) -> bool { - !matches!(prop.kind(), PropKind::Getter | PropKind::Setter | PropKind::Spread) + !matches!( + prop.kind(), + PropKind::Getter | PropKind::Setter | PropKind::Spread + ) } /// ESLint's `isStringLiteral(property.key)` @@ -70,7 +76,9 @@ fn is_string_literal_key<'a>(key: Key<'a>, file: &File<'a>) -> bool { match key.kind() { KeyKind::String(_) => true, // It is also what a template without substitutions is. - KeyKind::ComputedString(_) => file.text().get(key.inner_span(file).start as usize) != Some(&b'`'), + KeyKind::ComputedString(_) => { + file.text().get(key.inner_span(file).start as usize) != Some(&b'`') + } KeyKind::Computed(e) => matches!(e.kind(), ExprKind::String(_)), _ => false, } @@ -92,7 +100,9 @@ fn is_redundant(prop: Prop) -> bool { } match value.kind() { ExprKind::Fn(func) if func.kind() == FnKind::Expr => func.name().is_none(), - ExprKind::Ident(name) => ast_utils::get_static_key_name(key).is_some_and(|it| *it == *name.bytes()), + ExprKind::Ident(name) => { + ast_utils::get_static_key_name(key).is_some_and(|it| *it == *name.bytes()) + } _ => false, } } @@ -118,7 +128,8 @@ fn has_lexical_identifier(arrow: Func) -> bool { while let Some(node) = pending.pop() { match node { Node::Func(func) => { - let defines_its_own = func.has_body() && !matches!(func.kind(), FnKind::Arrow | FnKind::StaticBlock); + let defines_its_own = + func.has_body() && !matches!(func.kind(), FnKind::Arrow | FnKind::StaticBlock); if defines_its_own { continue; } @@ -126,7 +137,9 @@ fn has_lexical_identifier(arrow: Func) -> bool { Node::Expr(e) => match e.kind() { ExprKind::Super | ExprKind::NewTarget => return true, ExprKind::This if !e.is_jsx_tag_name() => return true, - ExprKind::Ident(name) if name.is("arguments") && is_arguments_of_function(e) => return true, + ExprKind::Ident(name) if name.is("arguments") && is_arguments_of_function(e) => { + return true; + } _ => {} }, _ => {} @@ -155,10 +168,17 @@ fn make_function_shorthand<'a>(fixer: Fixer<'a>, prop: Prop<'a>, func: Func<'a>) None => { // `function`, `async function` let first = next_token(file.text(), value.start); - let keyword = if func.is_async() { next_token(file.text(), first.end) } else { first }; + let keyword = if func.is_async() { + next_token(file.text(), first.end) + } else { + first + }; let function_token = match file.slice(keyword) == b"function" { true => keyword, - false => file.tokens_in(value).find(|token| token.is_keyword("function"))?.span(), + false => file + .tokens_in(value) + .find(|token| token.is_keyword("function"))? + .span(), }; let token_before_params = match func.is_generator() { true => Some(next_token(file.text(), function_token.end)) @@ -170,10 +190,16 @@ fn make_function_shorthand<'a>(fixer: Fixer<'a>, prop: Prop<'a>, func: Func<'a>) } Some(arrow) => { // The first token that is not `async`. - let start = file.tokens_in(value).nth(usize::from(func.is_async()))?.start(); + let start = file + .tokens_in(value) + .nth(usize::from(func.is_async()))? + .start(); let params = file.slice(Span::new(start, file.token_before(arrow)?.end())); - let should_add_parens = - func.params().len() == 1 && func.params().first().is_some_and(|it| it.span().start == start); + let should_add_parens = func.params().len() == 1 + && func + .params() + .first() + .is_some_and(|it| it.span().start == start); if should_add_parens { method.push(b'('); } @@ -192,7 +218,11 @@ fn make_function_longform<'a>(fixer: Fixer<'a>, prop: Prop<'a>, func: Func<'a>) let file = fixer.file(); let key = prop.key()?.span(file); let mut longform = file.slice(key).to_vec(); - longform.extend_from_slice(if func.is_async() { &b": async function"[..] } else { b": function" }); + longform.extend_from_slice(if func.is_async() { + &b": async function"[..] + } else { + b": function" + }); if func.is_generator() { longform.push(b'*'); } @@ -218,12 +248,23 @@ impl ObjectShorthand { } if shorthand > 0 { cx.report(e, UNEXPECTED_MIX); - } else if self.is_consistent_as_needed && props.iter().filter(|it| can_have_shorthand(*it)).all(is_redundant) { + } else if self.is_consistent_as_needed + && props + .iter() + .filter(|it| can_have_shorthand(*it)) + .all(is_redundant) + { cx.report(e, EXPECTED_ALL_PROPERTIES_SHORTHANDED); } } - fn find<'a>(&self, prop: Prop<'a>, key: Key<'a>, value: Expr<'a>, file: &'a File<'a>) -> Option> { + fn find<'a>( + &self, + prop: Prop<'a>, + key: Key<'a>, + value: Expr<'a>, + file: &'a File<'a>, + ) -> Option> { match (prop.kind(), value.as_fn(), value.as_ident()) { (PropKind::Method, Some(func), _) => { let message = if self.is_never { @@ -243,8 +284,11 @@ impl ObjectShorthand { let name = ast_utils::get_static_key_name(key); let name = name.as_deref(); // oxlint takes only a key that is written as a name for that of a constructor. - let can_be_constructor = !file.language().is_oxlint || matches!(key.kind(), KeyKind::Ident(_)); - if (self.ignores_constructors && can_be_constructor && name.is_some_and(is_constructor)) + let can_be_constructor = + !file.language().is_oxlint || matches!(key.kind(), KeyKind::Ident(_)); + if (self.ignores_constructors + && can_be_constructor + && name.is_some_and(is_constructor)) || (self.methods_ignore_pattern.as_ref()) .is_some_and(|pattern| name.is_some_and(|name| pattern.test(name))) || (self.avoids_quotes && is_string_literal_key(key, file)) @@ -271,8 +315,14 @@ impl ObjectShorthand { // A JSDoc type annotation would be lost. let has_type_annotation = file.comments_in(prop).any(|comment| { let value = comment.comment_value(); - let start = if is_quoted { value } else { strings::trim_js_whitespace_start(value) }; - comment.kind() == TokenKind::Block && start.starts_with(b"*") && strings::contains(value, b"@type") + let start = if is_quoted { + value + } else { + strings::trim_js_whitespace_start(value) + }; + comment.kind() == TokenKind::Block + && start.starts_with(b"*") + && strings::contains(value, b"@type") }); (!has_type_annotation).then_some(Finding::PropertyShorthand(name)) } @@ -303,22 +353,32 @@ impl ObjectShorthand { let Some(finding) = self.find(prop, key, value, cx.file()) else { return; }; - let is_in_pattern = matches!(prop.parent(), Node::Expr(object) if utils::is_assignment_target(object)); + let is_in_pattern = + matches!(prop.parent(), Node::Expr(object) if utils::is_assignment_target(object)); if is_proto_key(key) || is_in_pattern { return; } match finding { Finding::MethodLongform(message, func) => { - cx.report(prop, message).fix(|fixer| make_function_longform(fixer, prop, func)); + cx.report(prop, message) + .fix(|fixer| make_function_longform(fixer, prop, func)); } Finding::PropertyLongform(name) => { - cx.report(prop, EXPECTED_PROPERTY_LONGFORM) - .fix(|fixer| fixer.insert_after(key.span(fixer.file()), [&b": "[..], name.bytes()].concat())); + cx.report(prop, EXPECTED_PROPERTY_LONGFORM).fix(|fixer| { + fixer.insert_after(key.span(fixer.file()), [&b": "[..], name.bytes()].concat()) + }); } Finding::MethodShorthand(func) => { // oxlint points at the function. - let place = if cx.language().is_oxlint { value.span() } else { prop.span() }; - cx.report(place, EXPECTED_METHOD_SHORTHAND).fix(|fixer| make_function_shorthand(fixer, prop, func)); + let place = if cx.language().is_oxlint { + value.span() + } else { + prop.span() + }; + let report = cx + .report(place, EXPECTED_METHOD_SHORTHAND) + .called_with(prop); + report.fix(|fixer| make_function_shorthand(fixer, prop, func)); } Finding::PropertyShorthand(name) => { cx.report(prop, EXPECTED_PROPERTY_SHORTHAND).fix(|fixer| { @@ -331,14 +391,18 @@ impl ObjectShorthand { } impl Rule for ObjectShorthand { - const META: Meta = Meta::eslint("object-shorthand", Kind::Suggestion).fixable(Fixable::Code).reports_on_exit(); + const META: Meta = Meta::eslint("object-shorthand", Kind::Suggestion) + .fixable(Fixable::Code) + .reports_on_exit(); const ON: On = On::new().exprs(&[ExprTag::Object]).props(); no_state!(); fn new(options: &Options) -> Self { let apply = options.str(0).unwrap_or("always"); let params = options.object(1); - let pattern = params.str("methodsIgnorePattern").filter(|it| !it.is_empty()); + let pattern = params + .str("methodsIgnorePattern") + .filter(|it| !it.is_empty()); ObjectShorthand { applies_to_methods: matches!(apply, "methods" | "always"), applies_to_props: matches!(apply, "properties" | "always"), diff --git a/src/lint/language.rs b/src/lint/language.rs index 430d4fbfa321..4ebc562afe38 100644 --- a/src/lint/language.rs +++ b/src/lint/language.rs @@ -147,6 +147,8 @@ pub struct LanguageOptions { pub has_scopes_of_oxc: bool, /// With a configuration of oxlint: the rules that are on and that oxlint could only run as rules of a plugin in JavaScript. pub rules_that_oxlint_lacks: Vec<&'static crate::rule::Meta>, + /// A rule is on that can't run without types. + pub has_rule_that_requires_types: bool, /// With a configuration of oxlint in which no rule makes the variables of every file: the parser looks out for a name that may /// be declared twice, which is an error for oxlint. See `Dialect::oxc_names`. pub parser_tracks_names: bool, @@ -412,6 +414,7 @@ impl LanguageOptions { stands_alone: false, has_scopes_of_oxc: false, rules_that_oxlint_lacks: Vec::new(), + has_rule_that_requires_types: false, parser_tracks_names: false, eslint_8: None, eslint_major: 10, @@ -521,6 +524,7 @@ impl Default for LanguageOptions { stands_alone: false, has_scopes_of_oxc: false, rules_that_oxlint_lacks: Vec::new(), + has_rule_that_requires_types: false, parser_tracks_names: false, eslint_8: None, eslint_major: 10, diff --git a/src/lint/linter/config/mod.rs b/src/lint/linter/config/mod.rs index 92987eb09873..9506e1c9e2c7 100644 --- a/src/lint/linter/config/mod.rs +++ b/src/lint/linter/config/mod.rs @@ -109,6 +109,35 @@ struct Pattern { matches_without_slash: bool, /// `*`, `!..`, `../*`, `../**`: it does not make ESLint lint a file that nothing else matches. is_universal: bool, + /// What it matches ends with `.` and one of these, [packed](packed_extension). Empty: it cannot be told. + extensions: SmallVec<[u64; 8]>, + /// It ends with `/**`, so it says the same about all files of a directory. + is_about_directory: bool, +} + +/// An extension of 8 bytes at most as a number. +fn packed_extension(extension: &[u8]) -> Option { + let mut bytes = [0; 8]; + bytes.get_mut(..extension.len())?.copy_from_slice(extension); + Some(u64::from_le_bytes(bytes)) +} + +/// [`Pattern::extensions`] of a pattern without a leading `!`: what follows its last `.`, a word or words in braces. +fn extensions_of(pattern: &[u8]) -> SmallVec<[u64; 8]> { + let is_word = |it: &[u8]| !it.is_empty() && it.iter().all(u8::is_ascii_alphanumeric); + let dot = bun_core::strings::last_index_of_char(pattern, b'.'); + let words: SmallVec<[&[u8]; 8]> = match dot.and_then(|at| pattern.get(at + 1..)) { + // Braces around one word are no choice. + Some([b'{', words @ .., b'}']) if bun_core::strings::contains_char(words, b',') => { + bun_core::strings::split(words, b",").collect() + } + Some(word) => smallvec::smallvec![word], + None => SmallVec::new(), + }; + let packed = words + .iter() + .map(|it| packed_extension(it).filter(|_| is_word(it))); + packed.collect::>().unwrap_or_default() } impl Pattern { @@ -120,6 +149,7 @@ impl Pattern { written => written.to_vec(), }; let bangs = normalized.iter().take_while(|it| **it == b'!').count(); + let says_what_it_matches = bangs == 0 && !normalized.starts_with(b"#"); Pattern { matcher: bun_glob::Pattern::with_dots( &crate::glob::from_minimatch(&normalized[bangs..]), @@ -133,6 +163,11 @@ impl Pattern { || normalized.starts_with(b"!") || normalized.ends_with(b"/*") || normalized.ends_with(b"/**"), + extensions: match says_what_it_matches { + true => extensions_of(&normalized), + false => SmallVec::new(), + }, + is_about_directory: says_what_it_matches && normalized.ends_with(b"/**"), } } @@ -155,6 +190,8 @@ impl Pattern { is_comment: false, matches_without_slash: false, is_universal: true, + extensions: SmallVec::new(), + is_about_directory: false, } } @@ -220,6 +257,12 @@ struct ConfigObject { } impl ConfigObject { + /// Whether it is for a file depends on the directory alone. + fn is_about_directories(&self) -> bool { + let mut patterns = self.files.iter().flatten().flatten(); + self.files.is_some() && self.ignores.is_none() && patterns.all(|it| it.is_about_directory) + } + /// What it sees of the entries of `directory`, which is absolute, if it has a base path of its own. `before_name`: of /// [`Config::relative`]. fn see(&self, config: &Config, directory: &[u8], before_name: &[u8]) -> BeforeName { @@ -309,6 +352,12 @@ struct Heads { is_listed: Vec, /// For each object, the number of its first element, and whether all of its elements are listed. objects: Vec<(u32, bool)>, + /// For each extension that a pattern ends with, [packed](packed_extension): what can match a file that has it. + by_extension: Vec<(u64, Listed)>, + /// What can match a file with any other extension, or without one. + without_extension: Listed, + /// A bit for each object that is [about directories](ConfigObject::is_about_directories). + about_directories: Bits, } type Bits = SmallVec<[u64; 4]>; @@ -321,6 +370,7 @@ fn has_bit(bits: &Bits, index: usize) -> bool { } /// What is listed for a path: a bit for each element, and one for each object. +#[derive(Default)] struct Listed { elements: Bits, objects: Bits, @@ -336,7 +386,7 @@ fn heads_of(patterns: &[Pattern]) -> Option> { } impl Heads { - fn new(objects: &[ConfigObject]) -> Heads { + fn new<'o>(objects: &'o [ConfigObject]) -> Heads { let mut heads = Heads::default(); for (index, object) in objects.iter().enumerate() { let first = heads.is_listed.len(); @@ -355,9 +405,55 @@ impl Heads { object.files.is_some() && heads.is_listed[first..].iter().all(|it| *it); heads.objects.push((first as u32, are_all_listed)); } + let elements = || objects.iter().flat_map(|it| it.files.iter().flatten()); + // All of the patterns of an element have to match, so one that says so is enough. + let extensions_of = |element: &'o Vec| { + let mut known = element.iter().map(|it| &it.extensions[..]); + known.find(|it| !it.is_empty()).unwrap_or_default() + }; + let for_extension = |extension: Option| { + let can_match = |element: &'o Vec| { + let needs = extensions_of(element); + needs.is_empty() || extension.is_some_and(|it| needs.contains(&it)) + }; + let mut found = Listed { + elements: smallvec::smallvec![0; heads.is_listed.len().div_ceil(64)], + objects: smallvec::smallvec![0; objects.len().div_ceil(64)], + }; + for (at, _) in elements().enumerate().filter(|it| can_match(it.1)) { + found.elements[at / 64] |= 1 << (at % 64); + } + for (at, object) in objects.iter().enumerate() { + if (object.files.as_ref()).is_none_or(|it| it.iter().any(can_match)) { + found.objects[at / 64] |= 1 << (at % 64); + } + } + found + }; + let mut extensions: Vec = elements().flat_map(extensions_of).copied().collect(); + extensions.sort_unstable(); + extensions.dedup(); + let by_extension = extensions.iter().map(|&it| (it, for_extension(Some(it)))); + heads.by_extension = by_extension.collect(); + heads.without_extension = for_extension(None); + heads.about_directories = smallvec::smallvec![0; objects.len().div_ceil(64)]; + for (at, _) in (objects.iter().enumerate()).filter(|it| it.1.is_about_directories()) { + heads.about_directories[at / 64] |= 1 << (at % 64); + } heads } + /// What can match `path`, going by its extension. + fn of_extension(&self, path: &[u8]) -> &Listed { + // A `/` in it is in no extension that is known. + let extension = bun_core::strings::last_index_of_char(path, b'.') + .and_then(|at| packed_extension(path.get(at + 1..)?)); + let mut known = self.by_extension.iter(); + known + .find(|it| Some(it.0) == extension) + .map_or(&self.without_extension, |it| &it.1) + } + /// What is listed for what `path` starts with. `None`: it cannot be told. fn of(&self, path: &[u8]) -> Option { if self.by_head.is_empty() || bun_core::strings::contains(path, b"//") { @@ -389,9 +485,14 @@ impl Heads { /// The same for its `nth` element. fn allows(&self, found: &Listed, object: usize, nth: usize) -> bool { - let element = self.objects[object].0 as usize + nth; + let element = self.element(object, nth); !self.is_listed[element] || has_bit(&found.elements, element) } + + /// The number of the `nth` element of `files` of `object`. + fn element(&self, object: usize, nth: usize) -> usize { + self.objects[object].0 as usize + nth + } } /// The configuration of a run. All threads share it. @@ -461,12 +562,18 @@ pub struct Within<'c> { relative: Vec, /// The objects that were merged last, and what they make. merged: Option<(Indices, Arc)>, + said: Said, } +/// For objects that are [about directories](ConfigObject::is_about_directories), by their index: whether it is for the files of +/// the directory, and whether that makes ESLint lint them. +type Said = std::cell::RefCell>; + /// An entry of the directory of a [`Within`]. struct Entry<'w> { name: &'w [u8], seen_by_objects: &'w [(u32, BeforeName)], + said: &'w Said, } impl Entry<'_> { @@ -478,6 +585,12 @@ impl Entry<'_> { .ok()?; Some([objects.get(at)?.1.as_deref()?, self.name].concat()) } + + fn said_by(&self, index: usize) -> Option<(bool, bool)> { + let said = self.said.borrow(); + let found = said.iter().find(|it| it.0 as usize == index)?; + Some((found.1, found.2)) + } } impl Within<'_> { @@ -500,6 +613,7 @@ impl Within<'_> { Entry { name, seen_by_objects: &self.seen.by_objects, + said: &self.said, }, )) } @@ -832,6 +946,7 @@ impl Config { name_at: directory.len() + usize::from(!directory.ends_with(b"/")), relative: seen.before_name.clone().unwrap_or_default(), merged: None, + said: Said::default(), } } @@ -933,8 +1048,20 @@ impl Config { let mut matching = Indices::new(); let mut is_matched = false; let listed = self.heads.of(parts.path()); + let by_extension = self.heads.of_extension(parts.path()); for (index, object) in self.objects.iter().enumerate() { - if (listed.as_ref()).is_some_and(|found| !self.heads.allows_object(found, index)) { + if !has_bit(&by_extension.objects, index) + || (listed.as_ref()).is_some_and(|found| !self.heads.allows_object(found, index)) + { + continue; + } + // Said of another file of the directory. + let said = entry.filter(|_| has_bit(&self.heads.about_directories, index)); + if let Some((applies, is_linted)) = said.and_then(|it| it.said_by(index)) { + if applies { + matching.push(index as u32); + } + is_matched |= is_linted; continue; } let own = object.base_path.as_ref().map(|base_path| { @@ -957,7 +1084,9 @@ impl Config { }; let can_match = (files.iter().enumerate()) .filter(|it| { - (listed.as_ref()).is_none_or(|found| self.heads.allows(found, index, it.0)) + has_bit(&by_extension.elements, self.heads.element(index, it.0)) + && (listed.as_ref()) + .is_none_or(|found| self.heads.allows(found, index, it.0)) }) .map(|it| it.1); let is_universal = |all: &&Vec| all.iter().all(|it| it.is_universal); @@ -977,6 +1106,12 @@ impl Config { Some(own) => status(&Candidate::new(own)), None => status(parts), }; + if let Some(entry) = said { + entry + .said + .borrow_mut() + .push((index as u32, applies, is_linted)); + } if applies { matching.push(index as u32); } @@ -1431,6 +1566,7 @@ impl Config { } config.plugins = Some(implemented); } + config.language.has_rule_that_requires_types = config.has_enabled(|it| it.requires_types); config } } diff --git a/src/lint/linter/mod.rs b/src/lint/linter/mod.rs index 7b779314be40..8435aea8f780 100644 --- a/src/lint/linter/mod.rs +++ b/src/lint/linter/mod.rs @@ -79,7 +79,7 @@ use crate::ast::File; use crate::context::{Diagnostic, Severity}; use crate::js_plugin; use crate::options::{Json, Options}; -use crate::oxlint_order::Called; +use crate::oxlint_order::Ran as InOxlint; use crate::rule::{Meta, When}; use crate::rule_set::{RuleBits, RuleSet}; use crate::runner::{Enabled, Listeners, RuleEntry, each_bit}; @@ -203,12 +203,8 @@ struct Running<'r, S> { /// What the configuration has for it. configured: Option<&'r ConfiguredRule>, severity: Severity, - /// [`Slot::when`] - when: When, - /// [`Slot::kinds`] - kinds: Called, - /// [`Slot::oxlint_number`] - oxlint_number: u16, + /// [`Slot::in_oxlint`] + in_oxlint: InOxlint, rule: RuleRef<'r, S>, /// [`ConfiguredRule::refusal`] refusal: Option>, @@ -401,24 +397,19 @@ impl Linter { }; let number = instance.number(); on.insert(number); - let (when, oxlint_number, kinds) = match config.language.is_oxlint { - true => { - let order = crate::oxlint_order::of(rule.reported_as()); - (order.when, order.number, order.kinds) - } - false => (rule.entry.meta.reports, 0, Called::default()), + let in_oxlint = match config.language.is_oxlint { + true => crate::oxlint_order::of(rule.reported_as()), + false => InOxlint::unknown(rule.entry.meta.reports, 0), }; let at = at as u32; let severity = rule.severity; let slot = Slot { at, number, - when, - kinds, - oxlint_number, + in_oxlint, severity, }; - order.push((oxlint_number, slot, Arc::clone(instance))); + order.push((in_oxlint.number, slot, Arc::clone(instance))); if rule.entry.meta.requires_types { typed.push(rule.entry.meta); } @@ -569,9 +560,7 @@ impl Linter { entry: rule.entry, configured: Some(rule), severity: rule.severity, - when: slot.when, - kinds: slot.kinds, - oxlint_number: slot.oxlint_number, + in_oxlint: slot.in_oxlint, rule: RuleRef::Shared(instance), refusal: self.refusal(rule).map(Cow::Borrowed), }); @@ -864,45 +853,23 @@ impl Linter { } } problems.reserve(diagnostics.len()); + if wants_places { + file.expect_positions(diagnostics.len()); + } // oxlint sorts nothing. It prints what its rules report in the order in which it runs them. let follows_run = wants_places && file.language().is_oxlint && !diagnostics.is_empty(); // When oxlint runs a rule, and with which kinds of node it calls it. - let slot_of = |rule: usize| match is_plain { + let in_oxlint = |rule: usize| match is_plain { true => { let place = enabled.get(rule)?.place as usize; - let slot = prepared.order.get(place)?; - Some((slot.when, slot.kinds, slot.oxlint_number)) + Some(&prepared.order.get(place)?.in_oxlint) } - false => { - let it = running.get(rule)?; - Some((it.when, it.kinds, it.oxlint_number)) - } - }; - // A file is linted again if it names a module. - let names_a_module = || { - use crate::ast::{ExprTag, StmtTag}; - let statements = [ - StmtTag::Import, - StmtTag::ImportEquals, - StmtTag::ExportNamed, - StmtTag::ExportStar, - ]; - file.has_stmts(statements) - || file.has_exprs([ExprTag::ImportCall]) - || file.mentions("require") - }; - let wants_keys = follows_run && prepared.needs_modules && names_a_module(); - let about = |rule: u16| { - let meta = ran(rule as usize)?.reported_as(); - let (when, kinds, number) = slot_of(rule as usize)?; - Some(crate::oxlint_order::Ran { - when, - kinds, - once: crate::oxlint_order::once(meta), - is_port: crate::oxlint_order::is_port(meta), - number, - }) + false => Some(&running.get(rule)?.in_oxlint), }; + // A file is linted again for a rule that has asked about the other files. + let wants_keys = + follows_run && prepared.needs_modules && file.sink.asks_about_modules.get(); + let about = |rule: u16| in_oxlint(rule as usize); let (by_run, run_keys) = match follows_run { true => crate::oxlint_order::in_run_order(file, &diagnostics, &about, wants_keys), false => (Vec::new(), Vec::new()), @@ -934,7 +901,7 @@ impl Linter { } else { 0 }; - let when: Vec = rules.map_while(|it| Some(slot_of(it)?.0)).collect(); + let when: Vec = rules.map_while(|it| Some(in_oxlint(it)?.when)).collect(); crate::runner::sorted(&diagnostics, &when) } false => (0..diagnostics.len() as u32).collect(), @@ -1702,9 +1669,7 @@ impl<'c, 'a, S: RuleSet> Inline<'_, 'c, 'a, S> { entry, configured: existing, severity, - when: entry.meta.reports, - kinds: Called::default(), - oxlint_number: u16::MAX, + in_oxlint: InOxlint::unknown(entry.meta.reports, u16::MAX), rule, refusal, }; diff --git a/src/lint/linter/registry.rs b/src/lint/linter/registry.rs index b3673596b1d0..34394d40c1ca 100644 --- a/src/lint/linter/registry.rs +++ b/src/lint/linter/registry.rs @@ -14,9 +14,16 @@ use std::borrow::Cow; pub struct Registry { /// Sorted by plugin and name. rules: Vec<&'static RuleEntry>, + /// The rules of typescript-eslint, each with the name of the core rule that it is a version of, or else its own. Sorted + /// by that name, which is there once. + extensions: Vec<(&'static str, &'static RuleEntry)>, native_plugins: NativePlugins, } +fn core_rule_of(extension: &Meta) -> &'static str { + extension.extends_base_rule.unwrap_or(extension.name) +} + /// For which of the plugins that a configuration of ESLint loads from a package the rules here answer, in place of those of /// the package. #[derive(Clone, PartialEq, Eq, Debug, Default)] @@ -316,8 +323,15 @@ impl Registry { .filter(|it| it.is_deprecated && !it.follows_oxlint) .all(|it| deprecated_rule(&super::RuleId::Known(it).to_vec()).is_some()) ); + let of_typescript = (rules.iter()).filter(|it| it.meta.plugin == Plugin::TypeScript); + let mut extensions: Vec<_> = of_typescript + .map(|it| (core_rule_of(it.meta), *it)) + .collect(); + crate::utils::sort::sort_by_key(&mut extensions, |it| it.0); + extensions.dedup_by_key(|it| it.0); Registry { rules, + extensions, native_plugins: NativePlugins::All, } } @@ -422,17 +436,17 @@ impl Registry { /// If `meta` is a core rule with a typescript-eslint version, or that version, returns both (core rule first). Without an /// ESLint or oxlint config they act as one rule. The core rule runs on JavaScript and the other on TypeScript. pub fn pair_of(&self, meta: &Meta) -> Option<(&'static RuleEntry, &'static RuleEntry)> { - let base_of = |it: &Meta| it.extends_base_rule.unwrap_or(it.name); - let mut of_typescript = self.rules.iter().copied(); - let mut of_typescript = of_typescript - .by_ref() - .filter(|it| it.meta.plugin == Plugin::TypeScript); let extension = match meta.plugin { - Plugin::TypeScript => of_typescript.find(|it| it.meta.name == meta.name), - Plugin::Eslint => of_typescript.find(|it| base_of(it.meta) == meta.name), + Plugin::TypeScript => self.get(Plugin::TypeScript, meta.name.as_bytes()), + Plugin::Eslint => { + let at = self.extensions.binary_search_by_key(&meta.name, |it| it.0); + at.ok() + .and_then(|at| self.extensions.get(at)) + .map(|it| it.1) + } _ => None, }?; - let base = self.get(Plugin::Eslint, base_of(extension.meta).as_bytes())?; + let base = self.get(Plugin::Eslint, core_rule_of(extension.meta).as_bytes())?; Some((base, extension)) } diff --git a/src/lint/linter/resolved.rs b/src/lint/linter/resolved.rs index 45a07c2ce46d..62e6d4e34b70 100644 --- a/src/lint/linter/resolved.rs +++ b/src/lint/linter/resolved.rs @@ -6,8 +6,8 @@ use crate::context::Severity; use crate::js_plugin::{self, Route}; use crate::language::{LanguageOptions, Parser}; use crate::options::Json; -use crate::oxlint_order::Called; -use crate::rule::{Meta, Plugin, When}; +use crate::oxlint_order::Ran as InOxlint; +use crate::rule::{Meta, Plugin}; use crate::rule_set::RuleBits; use crate::runner::{Listeners, RuleEntry}; use std::sync::{Arc, OnceLock}; @@ -212,12 +212,8 @@ pub(super) struct Slot { pub(super) at: u32, /// In the set of the rules of the linter. pub(super) number: u16, - /// With a configuration of oxlint: when oxlint reports what the rule reports. - pub(super) when: When, - /// With a configuration of oxlint: the kinds of node that it calls the rule with. - pub(super) kinds: Called, - /// With a configuration of oxlint: the number that it gives the rule. - pub(super) oxlint_number: u16, + /// With a configuration of oxlint: what it does with the rule. Otherwise only when the rule reports. + pub(super) in_oxlint: InOxlint, pub(super) severity: Severity, } diff --git a/src/lint/linter/syntax/espree/early.rs b/src/lint/linter/syntax/espree/early.rs index a9ff36534ea1..50ed963020c3 100644 --- a/src/lint/linter/syntax/espree/early.rs +++ b/src/lint/linter/syntax/espree/early.rs @@ -1321,7 +1321,10 @@ impl<'a> Checks<'a, '_> { .map(|it| (it.span().start, "Setter cannot use rest params")), _ => None, }; - if let (true, Some(_), Some(list)) = (self.is_oxc, wrong, it.params_span()) { + if self.is_oxc + && wrong.is_some() + && let Some(list) = it.params_span() + { self.report( match (it.kind(), params.len()) { (FnKind::Getter, _) => { diff --git a/src/lint/modules.rs b/src/lint/modules.rs index b078379dcdcf..7621dcc60ca4 100644 --- a/src/lint/modules.rs +++ b/src/lint/modules.rs @@ -278,6 +278,7 @@ impl<'a> File<'a> { /// The other files. `None` where a file is linted alone: a rule that needs them has nothing to say. #[inline] pub fn modules(&self) -> Option<&'a dyn Modules> { + self.sink.asks_about_modules.set(true); self.modules.get() } diff --git a/src/lint/oxlint_order.rs b/src/lint/oxlint_order.rs index a545f3974b1a..7722846bc380 100644 --- a/src/lint/oxlint_order.rs +++ b/src/lint/oxlint_order.rs @@ -75,34 +75,50 @@ pub(crate) struct Called { /// A bit for each `BinOp` and for each `UnOp`. binaries: u32, unaries: u32, - /// It is called with a part of a node here, or with declarations alone: it takes more than a look. + /// It is called with a part of a node here: it takes more than a look. is_odd: bool, } /// What oxlint does with a rule. #[derive(Copy, Clone)] -pub(crate) struct Order { +pub(crate) struct Ran { pub(crate) when: When, /// Its place among all the rules, by the plugin and the name. A rule that oxlint has not comes after its rules. pub(crate) number: u16, /// For [`Entering`]: the kinds of node that it is called with. - pub(crate) kinds: Called, + kinds: Called, + /// [`once`] + once: Once, + /// [`is_port`] + is_port: bool, } -pub(crate) fn of(rule: &Meta) -> Order { +impl Ran { + /// A rule that nothing is known about is taken for one that is called with nodes. + pub(crate) fn unknown(when: When, number: u16) -> Ran { + Ran { + when, + number, + kinds: Called::default(), + once: Once::ByStart, + is_port: false, + } + } +} + +pub(crate) fn of(rule: &Meta) -> Ran { let key = key(rule.plugin, rule.name.as_bytes()); let found = TABLE.rules.binary_search_by_key(&key, |it| it.key); - match found.ok().and_then(|at| TABLE.rules.get(at)) { - Some(it) => Order { - when: it.when, - number: it.number, - kinds: visited::called(it.kinds), - }, - None => Order { - when: Entering, - number: u16::MAX, - kinds: Called::default(), - }, + let (when, number, kinds) = match found.ok().and_then(|at| TABLE.rules.get(at)) { + Some(it) => (it.when, it.number, visited::called(it.kinds)), + None => (Entering, u16::MAX, Called::default()), + }; + Ran { + when, + number, + kinds, + once: once(rule), + is_port: is_port(rule), } } @@ -113,7 +129,7 @@ fn kind_named(name: &str) -> Option { /// In which order a rule that oxlint runs once reports. #[derive(Copy, Clone, PartialEq, Eq)] -pub(crate) enum Once { +enum Once { /// It goes through the text. ByStart, /// It reports a function when it leaves it. @@ -122,7 +138,7 @@ pub(crate) enum Once { AsReported, } -pub(crate) fn once(rule: &Meta) -> Once { +fn once(rule: &Meta) -> Once { let as_reported = match rule.plugin.in_oxlint() { Plugin::Eslint if rule.name == "max-statements" => return Once::ByEnd, Plugin::Eslint => rule.name == "no-undef", @@ -140,7 +156,7 @@ pub(crate) fn once(rule: &Meta) -> Once { /// Whether the rule here is made after oxlint's, and goes through a node as that does. One that is made after a rule for ESLint /// may go through it in another way: what it reports about one node is put in the order of the text. -pub(crate) fn is_port(rule: &Meta) -> bool { +fn is_port(rule: &Meta) -> bool { rule.follows_oxlint } @@ -195,19 +211,6 @@ static TABLE: LazyLock
    = LazyLock::new(|| { } }); -/// What [`in_run_order`] asks about a rule that has reported. -#[derive(Copy, Clone)] -pub(crate) struct Ran { - pub(crate) when: When, - pub(crate) kinds: Called, - /// [`once`] - pub(crate) once: Once, - /// [`is_port`] - pub(crate) is_port: bool, - /// [`Order::number`] - pub(crate) number: u16, -} - /// Where in a run of oxlint it reports something: less for what is earlier. It says the same whichever rules run beside the one /// that reports, so that what is reported when a file is linted again can be put among what was reported before. pub(crate) type RunKey = (u64, u128); @@ -337,22 +340,13 @@ fn sort_as_entered<'a>( /// The places in `diagnostics` in the order in which oxlint reports them, and if `wants_keys` the [`RunKey`] of each. `ran`: about /// the rule with that index. -pub(crate) fn in_run_order<'a>( +pub(crate) fn in_run_order<'a, 'r>( file: &'a File<'a>, diagnostics: &[Diagnostic], - ran: &dyn Fn(u16) -> Option, + ran: &dyn Fn(u16) -> Option<&'r Ran>, wants_keys: bool, ) -> (Vec, Vec) { - // What `ran` has said last, and about which rule. A rule reports all that it reports in a row. - let mut asked: Option<(u16, Option)> = None; - let mut ask = |rule: u16| match asked { - Some((last, ran)) if last == rule => ran, - _ => { - let ran = ran(rule); - asked = Some((rule, ran)); - ran - } - }; + let unknown = Ran::unknown(Entering, u16::MAX); let mut places = Places::new(file); // The four parts of the run. Each with the place in `diagnostics` in the low 32 bits. let with_place = |key: u64, at: u32| (u128::from(key) << 32) | u128::from(at); @@ -369,58 +363,63 @@ pub(crate) fn in_run_order<'a>( let mut searched: Vec = Vec::new(); // The number of the rule of each, for the keys. let mut numbers: Vec = Vec::with_capacity(if wants_keys { diagnostics.len() } else { 0 }); - for ((at, it), place) in (0u32..).zip(diagnostics).zip(&mut found) { - let ran = ask(it.rule); + // A rule reports all that it reports in a row. + let mut rest = (0u32..).zip(&mut found); + for of_rule in diagnostics.chunk_by(|a, b| a.rule == b.rule) { + let ran = of_rule.first().and_then(|it| ran(it.rule)); + let ran = ran.unwrap_or(&unknown); if wants_keys { - numbers.push(ran.map_or(u16::MAX, |it| it.number)); + numbers.resize(numbers.len() + of_rule.len(), ran.number); } - let details = it.details.as_deref(); - // What is about the file as a whole, at no place in it, is the first of its part. - let start = details.and_then(|it| it.start_position.as_ref()); - let has_place = u64::from(start.is_none_or(|it| it.line != 0)); - let by_place = (has_place << 32) | u64::from(it.span.start); - match ran.map(|it| it.when) { - // One that says with which node has a `run` as well, which reports this. - Some(When::Once) if !it.states_node => { - let within = match ran.map(|it| it.once) { - Some(Once::AsReported) => u64::from(at), - Some(Once::ByEnd) => (has_place << 32) | u64::from(it.span.end), - _ => by_place, - }; - once.push(with_place((u64::from(it.rule) << 33) | within, at)); - } - // Of a chain of calls it comes to the inner ones first. - Some(When::AtTheEnd) => { - let label = (u64::from(it.span.start) << 32) | u64::from(it.span.end); - calls.push(with_place(label, at)); - } - Some(When::Last) => last.push(with_place(by_place, at)), - _ => { - // A rule that says which node it is goes through the node as oxlint's does. - place.1 = match it.states_node || ran.is_some_and(|it| it.is_port) { - true => at, - false => it.span.start, - }; - let called = ran.map_or_else(Called::default, |it| it.kinds); - let stated = details.and_then(|it| it.listened_on); - let label = stated.filter(|_| it.node.is_none()).unwrap_or(it.span); - let known = match it.node { - _ if has_place == 0 => Some(nowhere), - Some(node) => (places.at_a_look(&called, node, it)) - .or_else(|| places.of(it.rule, &called.kinds, label, it.node)), - // The name is the node. - None if it.states_node && stated.is_none() => Some(Place { - span: label, - node: None, - }), - None => places.of(it.rule, &called.kinds, label, None), - }; - match known { - Some(known) => { - place.0 = known; - nodes.push((u64::from(known.span.start) << 32) | u64::from(at)); + for (it, (at, place)) in of_rule.iter().zip(&mut rest) { + let details = it.details.as_deref(); + // What is about the file as a whole, at no place in it, is the first of its part. + let start = details.and_then(|it| it.start_position.as_ref()); + let has_place = u64::from(start.is_none_or(|it| it.line != 0)); + let by_place = (has_place << 32) | u64::from(it.span.start); + match ran.when { + // One that says with which node has a `run` as well, which reports this. + When::Once if !it.states_node => { + let within = match ran.once { + Once::AsReported => u64::from(at), + Once::ByEnd => (has_place << 32) | u64::from(it.span.end), + Once::ByStart => by_place, + }; + once.push(with_place((u64::from(it.rule) << 33) | within, at)); + } + // Of a chain of calls it comes to the inner ones first. + When::AtTheEnd => { + let label = (u64::from(it.span.start) << 32) | u64::from(it.span.end); + calls.push(with_place(label, at)); + } + When::Last => last.push(with_place(by_place, at)), + _ => { + // A rule that says which node it is goes through the node as oxlint's does. + place.1 = match it.states_node || ran.is_port { + true => at, + false => it.span.start, + }; + let called = &ran.kinds; + let stated = details.and_then(|it| it.listened_on); + let label = stated.filter(|_| it.node.is_none()).unwrap_or(it.span); + let known = match it.node { + _ if has_place == 0 => Some(nowhere), + Some(node) => (places.at_a_look(called, node, it)) + .or_else(|| places.of(it.rule, &called.kinds, label, it.node)), + // The name is the node. + None if it.states_node && stated.is_none() => Some(Place { + span: label, + node: None, + }), + None => places.of(it.rule, &called.kinds, label, None), + }; + match known { + Some(known) => { + place.0 = known; + nodes.push((u64::from(known.span.start) << 32) | u64::from(at)); + } + None => searched.push(with_place(as_entered(label), at)), } - None => searched.push(with_place(as_entered(label), at)), } } } @@ -447,7 +446,7 @@ pub(crate) fn in_run_order<'a>( else { continue; }; - let kinds = ask(it.rule).map_or_else(Kinds::default, |it| it.kinds.kinds); + let kinds = ran(it.rule).map_or_else(Kinds::default, |it| it.kinds.kinds); place.0 = places.search(it.rule, &kinds, label, near); late.push((u64::from(place.0.span.start) << 32) | u64::from(at)); } diff --git a/src/lint/oxlint_order/visited.rs b/src/lint/oxlint_order/visited.rs index fcd045d34507..09d66a1d2a26 100644 --- a/src/lint/oxlint_order/visited.rs +++ b/src/lint/oxlint_order/visited.rs @@ -113,7 +113,7 @@ pub(super) fn called(kinds: Kinds) -> Called { UnOp::PreInc as u32, UnOp::PostDec as u32, ), - is_odd: kinds.has_any_of(&known.parts) || kinds.are_among(&known.declarations), + is_odd: kinds.has_any_of(&known.parts), } } @@ -550,7 +550,8 @@ impl<'a> Places<'a> { if called.is_odd { return None; } - let (is_leaf, span) = match node.unpack(self.file) { + let known = self.known; + let (node, level, is_leaf, span) = match node.unpack(self.file) { Node::Expr(e) => { let tag = e.tag(); let tags = called.exprs @@ -563,18 +564,29 @@ impl<'a> Places<'a> { || match tag { ExprTag::Binary => (called.binaries >> e.binary_op()? as u32) & 1 != 0, ExprTag::Unary => (called.unaries >> e.unary_op()? as u32) & 1 != 0, - _ => false, + // They are other nodes. + ExprTag::Fn | ExprTag::Class => return None, + _ => { + known.exprs.get(tag as usize) == Some(&ASK) + && called.kinds.has(known.kind_of(Node::Expr(e))) + } }; - if !is_called_with { - return None; - } + let level = match is_called_with { + true => ITSELF, + false if called.kinds.has(known.chain_expression) && is_chain_root(e) => CHAIN, + false => return None, + }; let span = if it.states_node { e.span() } else { it.span }; - ((self.known.leaf_exprs >> tag as u32) & 1 != 0, span) + let is_leaf = level == ITSELF && (known.leaf_exprs >> tag as u32) & 1 != 0; + (node, level, is_leaf, span) } - // With an `export` before it, it is in another node, which starts there. - Node::Stmt(statement) if !statement.is_exported() => { + Node::Stmt(statement) => { let tag = statement.tag(); - if (called.stmts >> tag as u32) & 1 == 0 || statement.is_with() { + // With an `export` before it, it is in another node, which starts there. + if (called.stmts >> tag as u32) & 1 == 0 + || statement.is_exported() + || statement.is_with() + { return None; } let span = if it.states_node { @@ -582,13 +594,30 @@ impl<'a> Places<'a> { } else { it.span }; - ((self.known.leaf_stmts >> tag as u32) & 1 != 0, span) + ( + node, + ITSELF, + (known.leaf_stmts >> tag as u32) & 1 != 0, + span, + ) + } + other => { + let other = normalize(other); + let kind = known.kind_of(other); + if !called.kinds.has(kind) { + return None; + } + ( + other.packed(), + ITSELF, + known.leaves.has(kind), + estree_span(other), + ) } - _ => return None, }; Some(Place { span, - node: (!is_leaf).then_some((node, ITSELF)), + node: (!is_leaf).then_some((node, level)), }) } diff --git a/src/lint/semantic/table_of_oxc.rs b/src/lint/semantic/table_of_oxc.rs index 7d20bd93aafe..280304abb17f 100644 --- a/src/lint/semantic/table_of_oxc.rs +++ b/src/lint/semantic/table_of_oxc.rs @@ -36,8 +36,9 @@ fn hash(name: &[u8]) -> u32 { hash ^ (len as u32).rotate_left(16) } -/// How many buckets hashbrown looks at in one step: with SSE2, and with NEON. -const GROUP: usize = if cfg!(target_arch = "aarch64") { 8 } else { 16 }; +/// How many buckets hashbrown looks at in one step on x86-64. On arm64 it is 8, so oxlint itself has another order there. What is +/// printed here does not depend on the machine. +const GROUP: usize = 16; const FREE: u32 = u32::MAX; diff --git a/src/lint/source.rs b/src/lint/source.rs index 7511cb215877..f834c31823cd 100644 --- a/src/lint/source.rs +++ b/src/lint/source.rs @@ -55,13 +55,50 @@ impl ByName { /// Where the lines of a file start. pub(crate) struct Lines { starts: LineStarts, - /// No column needs converting: a byte is a UTF-16 code unit. - is_ascii: bool, + /// A bit for each line that has a byte that is not ASCII, from the first. In the others no column needs converting: a byte is + /// a UTF-16 code unit. Empty if there is no such line. + not_ascii: Vec, /// For columns far into a line: how many UTF-16 code units the characters have that start before each multiple of /// `BLOCK`, and at last all of them. `None` if the text is not valid UTF-8. Made when the first such column is asked for. units: OnceCell>>, } +impl Lines { + fn new(text: &[u8], starts: LineStarts) -> Lines { + let (mut not_ascii, mut at) = (Vec::new(), 0); + while let Some(found) = text.get(at..).and_then(bun_core::strings::first_non_ascii) { + let line = starts.line_at(at as u32 + found).0 as usize; + not_ascii.resize(starts.starts.len().div_ceil(64), 0u64); + if let Some(bits) = not_ascii.get_mut((line - 1) / 64) { + *bits |= 1 << ((line - 1) % 64); + } + // On with the next line. + match starts.starts.get(line) { + Some(next) => at = *next as usize, + None => break, + } + } + Lines { + starts, + not_ascii, + units: OnceCell::new(), + } + } + + /// The whole text. + #[inline] + fn is_ascii(&self) -> bool { + self.not_ascii.is_empty() + } + + /// The line `line`, counted from 1. + #[inline] + fn is_line_ascii(&self, line: u32) -> bool { + let bits = self.not_ascii.get((line as usize - 1) / 64); + bits.is_none_or(|bits| (bits >> ((line - 1) % 64)) & 1 == 0) + } +} + /// Where the lines of a text start. pub struct LineStarts { starts: Vec, @@ -204,10 +241,9 @@ impl<'a> File<'a> { } fn lines_index(&self) -> &Lines { - self.lazy.lines.get_or_init(|| Lines { - starts: LineStarts::at(line_starts(self.text(), self.is_plain())), - is_ascii: bun_core::strings::first_non_ascii(self.text()).is_none(), - units: OnceCell::new(), + self.lazy.lines.get_or_init(|| { + let starts = LineStarts::at(line_starts(self.text(), self.is_plain())); + Lines::new(self.text(), starts) }) } @@ -320,11 +356,19 @@ impl<'a> File<'a> { None => { let lines = self.lines_index(); let (line, start) = lines.starts.line_at(offset); - self.position_in_line(line, start, offset, lines.is_ascii) + self.position_in_line(line, start, offset, lines.is_line_ascii(line)) } } } + /// The positions of `count` spans are about to be asked for. If that is many for a text of this length, where the lines start + /// is found at once. It takes about as long as to find the lines of a dozen spans one by one in 25,000 bytes. + pub(crate) fn expect_positions(&self, count: usize) { + if count.saturating_mul(2048) >= self.text().len() { + self.lines_index(); + } + } + /// [`File::position`] of the start and of the end of `span`. Fast if the spans are asked for in the order of their starts. pub fn positions(&self, span: Span) -> (Position, Position) { let Some(lines) = self.lazy.lines.get() else { @@ -334,7 +378,8 @@ impl<'a> File<'a> { let (from, to) = (span.start.min(len), span.end.min(len)); let (line, start) = lines.starts.line_at(from); let (end_line, end_start) = lines.starts.line_from(line - 1, to); - if lines.is_ascii { + let (is_ascii, is_end_ascii) = (lines.is_line_ascii(line), lines.is_line_ascii(end_line)); + if is_ascii && is_end_ascii { let (column, end_column) = (from - start, to - end_start); let end = Position { line: end_line, @@ -343,12 +388,12 @@ impl<'a> File<'a> { return (Position { line, column }, end); } ( - self.position_in_line(line, start, from, lines.is_ascii), - self.position_in_line(end_line, end_start, to, lines.is_ascii), + self.position_in_line(line, start, from, is_ascii), + self.position_in_line(end_line, end_start, to, is_end_ascii), ) } - /// The position of `offset`, which is in the line `line`, which starts at `start`. `is_ascii`: the text is known to be. + /// The position of `offset`, which is in the line `line`, which starts at `start`. `is_ascii`: the line is known to be. #[inline] fn position_in_line(&self, line: u32, start: u32, offset: u32, is_ascii: bool) -> Position { let text = self.text(); @@ -386,7 +431,7 @@ impl<'a> File<'a> { /// ESLint's `getIndexFromLoc`. The position between the two UTF-16 code units of a character is its offset plus 2. pub fn offset(&self, position: Position) -> u32 { let line = self.line_span(position.line); - if self.lines_index().is_ascii { + if self.lines_index().is_ascii() { return line.start + position.column; } let (mut at, mut units) = (line.start, 0); diff --git a/src/lint/typescript/rules/consistent_type_imports.rs b/src/lint/typescript/rules/consistent_type_imports.rs index 99338f3a6fa0..c54402c4fae6 100644 --- a/src/lint/typescript/rules/consistent_type_imports.rs +++ b/src/lint/typescript/rules/consistent_type_imports.rs @@ -15,8 +15,10 @@ pub struct ConsistentTypeImports { prefers_type_imports: bool, } -const AVOID_IMPORT_TYPE: Message = - Message::new("avoidImportType", "Use an `import` instead of an `import type`."); +const AVOID_IMPORT_TYPE: Message = Message::new( + "avoidImportType", + "Use an `import` instead of an `import type`.", +); const NO_IMPORT_TYPE_ANNOTATIONS: Message = Message::new( "noImportTypeAnnotations", "`import()` type annotations are forbidden.", @@ -60,7 +62,10 @@ impl<'a> Specifier<'a> { fn specifiers_of<'a>(import: Import<'a>) -> impl Iterator> { let default = import.default().map(|_| Specifier::Default(import)); let namespace = import.namespace().map(|_| Specifier::Namespace(import)); - default.into_iter().chain(namespace).chain(import.named().iter().map(Specifier::Named)) + default + .into_iter() + .chain(namespace) + .chain(import.named().iter().map(Specifier::Named)) } /// oxlint does not know what JSX is compiled to, and takes `import React` and `import * as React` for values that it needs, @@ -92,7 +97,10 @@ fn source_imports<'a>(imports: &[Import<'a>]) -> FxHashMap, SourceImpor if has_only_named { source_imports.type_only_named_import.get_or_insert(import); } - } else if source_imports.value_only_named_import.is_none() && has_only_named && !import.named().is_empty() { + } else if source_imports.value_only_named_import.is_none() + && has_only_named + && !import.named().is_empty() + { source_imports.value_only_named_import = Some(import); source_imports.value_import = Some(import); } else if import.default().is_some() { @@ -114,7 +122,9 @@ impl<'a> ReportValueImport<'a> { /// `specifier`: one of the import. fn is_type(&self, specifier: Specifier<'a>) -> bool { let start = |it: &Specifier<'a>| it.local().map(|it| it.span().start); - self.type_specifiers.binary_search_by_key(&start(&specifier), start).is_ok() + self.type_specifiers + .binary_search_by_key(&start(&specifier), start) + .is_ok() } } @@ -162,13 +172,16 @@ fn is_type_only_use(reference: Reference) -> bool { && match member.parent() { Node::Class(class) => { is_oxlint - && (member.flags().intersects(Flags::ABSTRACT | Flags::AMBIENT) || oxlint_is_ambient(class)) + && (member.flags().intersects(Flags::ABSTRACT | Flags::AMBIENT) + || oxlint_is_ambient(class)) } _ => is_oxlint || member.kind() == MemberKind::Property, }; } // `declare class A extends foo.bar {}` - Node::Class(class) => return is_oxlint && class.extends() == Some(child) && oxlint_is_ambient(class), + Node::Class(class) => { + return is_oxlint && class.extends() == Some(child) && oxlint_is_ambient(class); + } Node::Expr(parent) => match parent.kind() { ExprKind::Call(call) if is_oxlint && call.callee() == child => child = parent, ExprKind::Dot { @@ -195,7 +208,11 @@ fn fix_remove_type_specifier_from_import_specifier<'a>( ) -> Option { let start = spec.span().start; let type_token = Span::new(start, start + "type".len() as u32); - let after = fixer.file().tokens_after(type_token).with_comments().next()?; + let after = fixer + .file() + .tokens_after(type_token) + .with_comments() + .next()?; Some(fixer.remove(Span::new(start, after.start()))) } @@ -205,7 +222,10 @@ fn fix_remove_type_specifier_from_import_declaration<'a>( import: Import<'a>, ) -> Option { let file = fixer.file(); - let type_token = file.tokens_after(import_keyword(import)).next().filter(|it| it.is("type"))?; + let type_token = file + .tokens_after(import_keyword(import)) + .next() + .filter(|it| it.is("type"))?; let after = file.tokens_after(type_token).with_comments().next()?; Some(fixer.remove(Span::new(type_token.start(), after.start()))) } @@ -222,15 +242,24 @@ fn fix_insert_type_specifier_for_import_declaration<'a>( if is_default_import { let source = import.stmt().module_specifier_span()?; // `import Foo, {} from 'foo'` - let opening = file.tokens_between(import_token, source).find(is_opening_brace_token); + let opening = file + .tokens_between(import_token, source) + .find(is_opening_brace_token); if let Some(opening) = opening { let comma = file.tokens_before(opening).find(is_comma_token)?; - let closing = file.tokens_between(opening, source).find(is_closing_brace_token)?; + let closing = file + .tokens_between(opening, source) + .find(is_closing_brace_token)?; fixes.push(fixer.remove(Span::new(comma.start(), closing.end()))); if specifiers_of(import).count() > 1 { let specifiers_text = file.slice(Span::new(comma.end(), closing.end())); - let text = - concat(&[b"\nimport type", specifiers_text, b" from ", file.slice(source), b";"]); + let text = concat(&[ + b"\nimport type", + specifiers_text, + b" from ", + file.slice(source), + b";", + ]); fixes.push(fixer.insert_after(import.stmt(), text)); } } @@ -238,7 +267,9 @@ fn fix_insert_type_specifier_for_import_declaration<'a>( // Not `import type { type T }`. for spec in import.named() { if spec.is_type_only() { - fixes.push(fix_remove_type_specifier_from_import_specifier(fixer, spec)?); + fixes.push(fix_remove_type_specifier_from_import_specifier( + fixer, spec, + )?); } } Some(()) @@ -285,9 +316,13 @@ fn get_fixes_named_specifiers<'a>( if subset.len() == all.len() { // `import Foo, { Type1, Type2 } from 'foo'`: from the comma to the closing brace. let source = import.stmt().module_specifier_span()?; - let opening = file.tokens_before(*subset.first()?).find(is_opening_brace_token)?; + let opening = file + .tokens_before(*subset.first()?) + .find(is_opening_brace_token)?; let comma = file.tokens_before(opening).find(is_comma_token)?; - let closing = file.tokens_between(opening, source).find(is_closing_brace_token)?; + let closing = file + .tokens_between(opening, source) + .find(is_closing_brace_token)?; removals.push(fixer.remove(Span::new(comma.start(), closing.end()))); texts.push(file.slice(Span::new(opening.end(), closing.start()))); } else { @@ -397,7 +432,8 @@ impl ConsistentTypeImports { } let source_text = file.slice(statement.module_specifier_span()?); - let type_named: Vec = named.iter().filter(|spec| is_named_type(*spec)).collect(); + let type_named: Vec = + named.iter().filter(|spec| is_named_type(*spec)).collect(); let (remove_type_named, type_named_text) = get_fixes_named_specifiers(fixer, import, &type_named)?; let mut after_fixes = Vec::new(); @@ -426,8 +462,13 @@ impl ConsistentTypeImports { text.extend_from_slice(&concat(&[b"} from ", source_text, b";\n"])); fixes.push(fixer.insert_before(statement, text)); } else { - let text = - concat(&[b"import type {", &type_named_text, b"} from ", source_text, b";\n"]); + let text = concat(&[ + b"import type {", + &type_named_text, + b"} from ", + source_text, + b";\n", + ]); fixes.push(fixer.insert_before(statement, text)); } } @@ -438,8 +479,13 @@ impl ConsistentTypeImports { let namespace = import.namespace_span()?; let comma = file.tokens_before(namespace).find(is_comma_token)?; remove_type_namespace = Some(fixer.remove(Span::new(comma.start(), namespace.end))); - let text = - concat(&[b"import type ", file.slice(namespace), b" from ", source_text, b";\n"]); + let text = concat(&[ + b"import type ", + file.slice(namespace), + b" from ", + source_text, + b";\n", + ]); fixes.push(fixer.insert_before(statement, text)); } if is_default_type { @@ -449,8 +495,16 @@ impl ConsistentTypeImports { } else { // `import Type , { .. } from 'foo'` let comma = file.tokens_after(default).find(is_comma_token)?; - let default_text = strings::trim_js_whitespace(file.slice(Span::new(default.start, comma.start()))); - let text = concat(&[b"import type ", default_text, b" from ", source_text, b";\n"]); + let default_text = strings::trim_js_whitespace( + file.slice(Span::new(default.start, comma.start())), + ); + let text = concat(&[ + b"import type ", + default_text, + b" from ", + source_text, + b";\n", + ]); fixes.push(fixer.insert_before(statement, text)); let after = file.tokens_after(comma).with_comments().next()?; fixes.push(fixer.remove(Span::new(default.start, after.start()))); @@ -474,7 +528,9 @@ impl ConsistentTypeImports { for &import in imports.iter().filter(|it| !it.is_type_only()) { let statement = import.stmt(); let scope = match statement.parent() { - Node::File(_) => *top_level_scope.get_or_insert_with(|| Node::Stmt(statement).scope()), + Node::File(_) => { + *top_level_scope.get_or_insert_with(|| Node::Stmt(statement).scope()) + } _ => Node::Stmt(statement).scope(), }; let mut type_specifiers = Vec::new(); @@ -483,11 +539,14 @@ impl ConsistentTypeImports { if matches!(specifier, Specifier::Named(spec) if spec.is_type_only()) { continue; } - let variable = specifier.local().and_then(|local| scope.get_name(local.name())); - let has_only_type_references = !oxlint_takes_for_jsx_factory(cx.file(), specifier) && variable.is_some_and(|variable| { - let mut references = variable.references(); - references.len() > 0 && references.all(is_type_only_use) - }); + let variable = specifier + .local() + .and_then(|local| scope.get_name(local.name())); + let has_only_type_references = !oxlint_takes_for_jsx_factory(cx.file(), specifier) + && variable.is_some_and(|variable| { + let mut references = variable.references(); + references.len() > 0 && references.all(is_type_only_use) + }); match has_only_type_references { true => type_specifiers.push(specifier), false => has_value_or_unused_specifiers = true, @@ -505,15 +564,22 @@ impl ConsistentTypeImports { // Only a fix asks. let sources = OnceCell::new(); let fix = |fixer: Fixer<'a>, report: &ReportValueImport<'a>| { - let source_imports = sources.get_or_init(|| source_imports(&imports)).get(&report.import.spec())?; + let source_imports = sources + .get_or_init(|| source_imports(&imports)) + .get(&report.import.spec())?; self.fix_to_type_import_declaration(fixer, report, source_imports) }; for report in &reports { let statement = report.import.stmt(); if report.has_value_or_unused_specifiers { - let locals = report.type_specifiers.iter().filter_map(|specifier| specifier.local()); + let locals = report + .type_specifiers + .iter() + .filter_map(|specifier| specifier.local()); let type_imports = match cx.language().is_oxlint { - true => utils::oxlint::format_word_list(&locals.map(|local| local.bytes()).collect::>()), + true => utils::oxlint::format_word_list( + &locals.map(|local| local.bytes()).collect::>(), + ), false => { let in_quotes = locals.map(|local| concat(&[b"\"", local.bytes(), b"\""])); format_word_list(&in_quotes.collect::>()) @@ -534,7 +600,9 @@ impl ConsistentTypeImports { let problem = match (cx.language().is_oxlint, adds_to_each) { (false, _) => problem, (true, true) => problem.help("Add type specifier to imported types"), - (true, false) => problem.help("Mark all type-only imports with the type specifier"), + (true, false) => { + problem.help("Mark all type-only imports with the type specifier") + } }; problem.fix(|fixer| fix(fixer, report)); } else if !has_attributes(report.import) { @@ -547,8 +615,9 @@ impl ConsistentTypeImports { } impl Rule for ConsistentTypeImports { - const META: Meta = - Meta::typescript("consistent-type-imports", Kind::Suggestion).fixable(Fixable::Code).uses_types(); + const META: Meta = Meta::typescript("consistent-type-imports", Kind::Suggestion) + .fixable(Fixable::Code) + .uses_types(); const ON: On = On::new() .stmts(&[StmtTag::Import]) .types(&[TypeTag::Import]) @@ -582,11 +651,17 @@ impl Rule for ConsistentTypeImports { let (experimental_decorators, emit_decorator_metadata) = match file.types() { Some(types) => { let options = types.compiler_options(); - (options.experimental_decorators, options.emit_decorator_metadata) + ( + options.experimental_decorators, + options.emit_decorator_metadata, + ) } // There's no config file to set these in, and the fix would remove imports that decorator metadata needs. None if file.language().stands_alone => (true, true), - None => (file.language().experimental_decorators, file.language().emit_decorator_metadata), + None => ( + file.language().experimental_decorators, + file.language().emit_decorator_metadata, + ), }; if experimental_decorators && emit_decorator_metadata { on = on.classes().members().params(); @@ -615,7 +690,7 @@ impl Rule for ConsistentTypeImports { fn ty<'a>(&self, ty: TypeNode<'a>, cx: &mut Cx<'a, Self>) { if let Some(span) = ty.import_span() { - cx.report(span, NO_IMPORT_TYPE_ANNOTATIONS); + cx.report(span, NO_IMPORT_TYPE_ANNOTATIONS).called_with(ty); } } diff --git a/src/lint/typescript/rules/init_declarations.rs b/src/lint/typescript/rules/init_declarations.rs index 44ae9515c569..9dbb557bac9a 100644 --- a/src/lint/typescript/rules/init_declarations.rs +++ b/src/lint/typescript/rules/init_declarations.rs @@ -1,6 +1,8 @@ use bun_core::strings; use bun_lint::prelude::*; -use bun_lint_eslint::rules::init_declarations::{Config, Finding, declared_namespace_around, has_declare}; +use bun_lint_eslint::rules::init_declarations::{ + Config, Finding, declared_namespace_around, has_declare, +}; /// Require or disallow initialization in variable declarations. pub struct InitDeclarations { @@ -10,8 +12,8 @@ pub struct InitDeclarations { } impl Rule for InitDeclarations { - const META: Meta = - Meta::typescript("init-declarations", Kind::Suggestion).extends_base_rule("init-declarations") + const META: Meta = Meta::typescript("init-declarations", Kind::Suggestion) + .extends_base_rule("init-declarations") .reports_on_exit(); const ON: On = On::new().var_decls(); type State<'a> = (); @@ -51,12 +53,18 @@ impl InitDeclarations { return; } if decl.init().is_some() { - cx.report(decl, found.message).data("idName", found.name); + let report = cx + .report(decl, found.message) + .called_with(found.declaration); + report.data("idName", found.name); return; } // For oxlint the type annotation is part of it. if cx.language().is_oxlint { - cx.report(decl.binding_span(), found.message).data("idName", found.name); + let report = cx.report(decl.binding_span(), found.message); + report + .called_with(found.declaration) + .data("idName", found.name); return; } // Without the type annotation: as many columns as the name is long. diff --git a/src/lint/typescript/rules/no_unsafe_function_type.rs b/src/lint/typescript/rules/no_unsafe_function_type.rs index 45f6dfd15486..f4466834bdf9 100644 --- a/src/lint/typescript/rules/no_unsafe_function_type.rs +++ b/src/lint/typescript/rules/no_unsafe_function_type.rs @@ -1,5 +1,5 @@ -use bun_lint::prelude::*; use crate::rules::no_wrapper_object_types::{GlobalFunctions, is_reference_to_global}; +use bun_lint::prelude::*; /// Disallow using the unsafe built-in Function type. pub struct NoUnsafeFunctionType; @@ -29,7 +29,7 @@ impl Rule for NoUnsafeFunctionType { && name.name().is("Function") && is_reference_to_global(name.name(), ty, &mut cx.state) { - cx.report(name, BANNED_FUNCTION_TYPE); + cx.report(name, BANNED_FUNCTION_TYPE).called_with(ty); } } } diff --git a/src/lint/typescript/rules/prefer_for_of.rs b/src/lint/typescript/rules/prefer_for_of.rs index bfea07741824..f013304378b8 100644 --- a/src/lint/typescript/rules/prefer_for_of.rs +++ b/src/lint/typescript/rules/prefer_for_of.rs @@ -18,7 +18,12 @@ fn is_matching_identifier<'a>(e: Expr<'a>, name: Name<'a>) -> bool { /// The `arr` of `name < arr.length`. fn is_less_than_length_expression<'a>(test: Expr<'a>, name: Name<'a>) -> Option> { - let ExprKind::Binary { op: BinOp::Lt, left, right } = test.kind() else { + let ExprKind::Binary { + op: BinOp::Lt, + left, + right, + } = test.kind() + else { return None; }; if !is_matching_identifier(left, name) { @@ -27,11 +32,13 @@ fn is_less_than_length_expression<'a>(test: Expr<'a>, name: Name<'a>) -> Option< let object = match right.kind() { ExprKind::Dot { obj, name, .. } if name.name().is("length") => obj, // For oxlint `a["length"]`, not `a[length]`. - ExprKind::Index { obj, index, .. } if test.file().language().is_oxlint => match index.kind() { - ExprKind::String(key) if key.is("length") => obj, - ExprKind::Template(key) if key.as_static().is_some_and(|it| it.is("length")) => obj, - _ => return None, - }, + ExprKind::Index { obj, index, .. } if test.file().language().is_oxlint => { + match index.kind() { + ExprKind::String(key) if key.is("length") => obj, + ExprKind::Template(key) if key.as_static().is_some_and(|it| it.is("length")) => obj, + _ => return None, + } + } ExprKind::Index { obj, index, .. } if index.is_ident("length") => obj, _ => return None, }; @@ -43,7 +50,9 @@ fn is_less_than_length_expression<'a>(test: Expr<'a>, name: Name<'a>) -> Option< fn oxlint_looks_at<'a>(array: Expr<'a>, index_var: Symbol<'a>) -> bool { !array.is_parenthesized() && matches!(array.tag(), ExprTag::Ident | ExprTag::Dot | ExprTag::Index) - && !index_var.references().any(|it| array.span().contains(it.span())) + && !index_var + .references() + .any(|it| array.span().contains(it.span())) } /// For oxlint what is in parentheses is nothing that it knows, unless it is assigned to. @@ -83,11 +92,16 @@ fn oxc_parent(at: Node<'_>) -> Option<(Around, Node<'_>, bool)> { let parent = at.parent(); Some(match (at, parent) { (Node::Expr(e), Node::Expr(around)) => match around.kind() { - ExprKind::Unary { op: UnOp::Delete, .. } => (Around::Delete, parent, false), - ExprKind::Unary { op: UnOp::PreInc | UnOp::PreDec | UnOp::PostInc | UnOp::PostDec, .. } => { - (Around::Update, parent, true) + ExprKind::Unary { + op: UnOp::Delete, .. + } => (Around::Delete, parent, false), + ExprKind::Unary { + op: UnOp::PreInc | UnOp::PreDec | UnOp::PostInc | UnOp::PostDec, + .. + } => (Around::Update, parent, true), + ExprKind::Assign { target, .. } if target == e && !is_pattern(parent) => { + (Around::Assignment, parent, true) } - ExprKind::Assign { target, .. } if target == e && !is_pattern(parent) => (Around::Assignment, parent, true), ExprKind::Assign { target, .. } => (Around::Other, parent, target == e), ExprKind::Array(_) if is_pattern(parent) => (Around::Pattern, parent, true), ExprKind::Spread(_) => (Around::Other, parent, is_pattern(parent)), @@ -120,28 +134,42 @@ fn oxlint_is_assignee(member: Expr) -> bool { (*it, at, parentheses) = (kind, parent, 0); } let [first, second] = around; - !matches!(first, Around::Member | Around::Other) || second == Around::Pattern && first != Around::Member + !matches!(first, Around::Member | Around::Other) + || second == Around::Pattern && first != Around::Member } fn is_increment<'a>(update: Expr<'a>, name: Name<'a>) -> bool { match update.kind() { - ExprKind::Unary { op: UnOp::PreInc | UnOp::PostInc, operand } => is_matching_identifier(operand, name), - ExprKind::Assign { op, target, value } if is_matching_identifier(target, name) => match op { - Some(BinOp::Add) => is_literal(value, 1.0), - None => match value.kind() { - ExprKind::Binary { op: BinOp::Add, left, right } => { - (is_matching_identifier(left, name) && is_literal(right, 1.0)) - || (is_literal(left, 1.0) && is_matching_identifier(right, name)) - } + ExprKind::Unary { + op: UnOp::PreInc | UnOp::PostInc, + operand, + } => is_matching_identifier(operand, name), + ExprKind::Assign { op, target, value } if is_matching_identifier(target, name) => { + match op { + Some(BinOp::Add) => is_literal(value, 1.0), + None => match value.kind() { + ExprKind::Binary { + op: BinOp::Add, + left, + right, + } => { + (is_matching_identifier(left, name) && is_literal(right, 1.0)) + || (is_literal(left, 1.0) && is_matching_identifier(right, name)) + } + _ => false, + }, _ => false, - }, - _ => false, - }, + } + } _ => false, } } -fn is_index_only_used_with_array<'a>(body: Stmt<'a>, index_var: Symbol<'a>, array: Expr<'a>) -> bool { +fn is_index_only_used_with_array<'a>( + body: Stmt<'a>, + index_var: Symbol<'a>, + array: Expr<'a>, +) -> bool { let (body, array_text) = (body.span(), array.text()); let is_oxlint = array.file().language().is_oxlint; index_var.references().all(|reference| { @@ -164,8 +192,8 @@ fn is_index_only_used_with_array<'a>(body: Stmt<'a>, index_var: Symbol<'a>, arra } impl Rule for PreferForOf { - const META: Meta = - Meta::typescript("prefer-for-of", Kind::Suggestion).presets(Presets::STYLISTIC) + const META: Meta = Meta::typescript("prefer-for-of", Kind::Suggestion) + .presets(Presets::STYLISTIC) .reports_on_exit(); const ON: On = On::new().stmts(&[StmtTag::For]); no_state!(); @@ -211,14 +239,20 @@ impl Rule for PreferForOf { }; if is_oxlint && (!oxlint_looks_at(array, index_var) - || declarator.init().is_some_and(|init| oxlint_minds_parentheses(init, test, update))) + || declarator + .init() + .is_some_and(|init| oxlint_minds_parentheses(init, test, update))) { return; } if is_index_only_used_with_array(body, index_var, array) { // oxlint points at what is between the parentheses. - let place = if is_oxlint { Span::new(init.span().start, update.span().end) } else { stmt.span() }; - cx.report(place, PREFER_FOR_OF); + let place = if is_oxlint { + Span::new(init.span().start, update.span().end) + } else { + stmt.span() + }; + cx.report(place, PREFER_FOR_OF).called_with(stmt); } } } diff --git a/src/lint/unicorn/rules/unicorn_no_hex_escape.rs b/src/lint/unicorn/rules/unicorn_no_hex_escape.rs index 9d29fd025c7e..282f0394099a 100644 --- a/src/lint/unicorn/rules/unicorn_no_hex_escape.rs +++ b/src/lint/unicorn/rules/unicorn_no_hex_escape.rs @@ -1,16 +1,20 @@ -use bun_lint_oxlint::ast_util::plain; use bun_core::strings; use bun_lint::prelude::*; use bun_lint::rule::Plugin; +use bun_lint_oxlint::ast_util::plain; /// Enforces a convention of using Unicode escapes instead of hexadecimal escapes for consistency and clarity. pub struct NoHexEscape; -const NO_HEX_ESCAPE: Message = Message::new("", "Use Unicode escapes instead of hexadecimal escapes."); +const NO_HEX_ESCAPE: Message = + Message::new("", "Use Unicode escapes instead of hexadecimal escapes."); impl Rule for NoHexEscape { - const META: Meta = Meta::oxlint(Plugin::Unicorn, "no-hex-escape", Kind::Suggestion).fixable(Fixable::Code); - const ON: On = On::new().exprs(&[ExprTag::Template, ExprTag::Regex]).string_literals(); + const META: Meta = + Meta::oxlint(Plugin::Unicorn, "no-hex-escape", Kind::Suggestion).fixable(Fixable::Code); + const ON: On = On::new() + .exprs(&[ExprTag::Template, ExprTag::Regex]) + .string_literals(); type State<'a> = (); fn new(_: &Options) -> Self { @@ -19,7 +23,11 @@ impl Rule for NoHexEscape { fn start<'a>(&self, file: &'a File<'a>) -> Option<()> { let text = file.text(); - strings::contains(text.get(strings::index_of_char_usize(text, b'\\')?..)?, b"\\x").then_some(()) + strings::contains( + text.get(strings::index_of_char_usize(text, b'\\')?..)?, + b"\\x", + ) + .then_some(()) } fn expr<'a>(&self, e: Expr<'a>, cx: &mut Cx<'a, Self>) { @@ -60,14 +68,18 @@ impl NoHexEscape { }; let (pattern, start) = (regex.pattern(), e.span().start + 1); let mut at = 0; - while let Some(found) = pattern.get(at..).and_then(|rest| strings::index_of_char_usize(rest, b'\\')) { + while let Some(found) = pattern + .get(at..) + .and_then(|rest| strings::index_of_char_usize(rest, b'\\')) + { at += found; // `\x` without two digits is an `x`. if let Some([b'\\', b'x', digits @ ..]) = pattern.get(at..at + 4) && digits.iter().all(u8::is_ascii_hexdigit) { let span = Span::new(start + at as u32, start + at as u32 + 4); - cx.report(span, NO_HEX_ESCAPE).fix(|fixer| fixer.replace(span, [b"\\u00", digits].concat())); + let report = cx.report(span, NO_HEX_ESCAPE).called_with(e); + report.fix(|fixer| fixer.replace(span, [b"\\u00", digits].concat())); } at += 2; } @@ -78,9 +90,13 @@ impl NoHexEscape { fn check<'a>(escapes: Span, place: Span, cx: &Cx<'a, NoHexEscape>) { if let Some(fixed) = check_escape(cx.slice(escapes)) { // With the quotes, as oxlint replaces it. - let (before, after) = (Span::new(place.start, escapes.start), Span::new(escapes.end, place.end)); + let (before, after) = ( + Span::new(place.start, escapes.start), + Span::new(escapes.end, place.end), + ); let fixed = [cx.slice(before), &fixed, cx.slice(after)].concat(); - cx.report(place, NO_HEX_ESCAPE).fix(|fixer| fixer.replace(place, fixed)); + cx.report(place, NO_HEX_ESCAPE) + .fix(|fixer| fixer.replace(place, fixed)); } } @@ -88,7 +104,10 @@ fn check<'a>(escapes: Span, place: Span, cx: &Cx<'a, NoHexEscape>) { fn check_escape(value: &[u8]) -> Option> { let mut fixed = Vec::new(); let (mut at, mut last) = (0, 0); - while let Some(found) = value.get(at..).and_then(|rest| strings::index_of_char_usize(rest, b'\\')) { + while let Some(found) = value + .get(at..) + .and_then(|rest| strings::index_of_char_usize(rest, b'\\')) + { at += found; if value.get(at + 1) == Some(&b'x') { fixed.extend_from_slice(value.get(last..at).unwrap_or_default()); diff --git a/src/lint/unicorn/rules/unicorn_no_negated_condition.rs b/src/lint/unicorn/rules/unicorn_no_negated_condition.rs index 23e4e7fc3c45..a212f8f929f2 100644 --- a/src/lint/unicorn/rules/unicorn_no_negated_condition.rs +++ b/src/lint/unicorn/rules/unicorn_no_negated_condition.rs @@ -8,7 +8,8 @@ pub struct NoNegatedCondition; const NO_NEGATED_CONDITION: Message = Message::new("", "Unexpected negated condition."); impl Rule for NoNegatedCondition { - const META: Meta = Meta::oxlint(Plugin::Unicorn, "no-negated-condition", Kind::Suggestion).fixable(Fixable::Code); + const META: Meta = Meta::oxlint(Plugin::Unicorn, "no-negated-condition", Kind::Suggestion) + .fixable(Fixable::Code); const ON: On = On::new().stmts(&[StmtTag::If]).exprs(&[ExprTag::Cond]); no_state!(); @@ -21,7 +22,8 @@ impl Rule for NoNegatedCondition { && alternate.tag() != StmtTag::If && is_negated_expression(test) { - cx.report(test, NO_NEGATED_CONDITION).fix(|fixer| fix_if_statement(fixer, test, consequent, alternate)); + let report = cx.report(test, NO_NEGATED_CONDITION).called_with(if_stmt); + report.fix(|fixer| fix_if_statement(fixer, test, consequent, alternate)); } } @@ -29,12 +31,14 @@ impl Rule for NoNegatedCondition { if let Some((test, yes, no)) = e.as_cond() && is_negated_expression(test) { - cx.report(test, NO_NEGATED_CONDITION).fix(|fixer| fix_conditional_expression(fixer, e, test, yes, no)); + let report = cx.report(test, NO_NEGATED_CONDITION).called_with(e); + report.fix(|fixer| fix_conditional_expression(fixer, e, test, yes, no)); } } } /// `!a`, `a != b`, `a !== b` fn is_negated_expression(expr: Expr) -> bool { - expr.unary_op() == Some(UnOp::Not) || matches!(expr.binary_op(), Some(BinOp::NotEq | BinOp::NotEqEq)) + expr.unary_op() == Some(UnOp::Not) + || matches!(expr.binary_op(), Some(BinOp::NotEq | BinOp::NotEqEq)) } diff --git a/src/lint/unicorn/rules/unicorn_number_literal_case.rs b/src/lint/unicorn/rules/unicorn_number_literal_case.rs index 401130ced1c1..b7b76251e856 100644 --- a/src/lint/unicorn/rules/unicorn_number_literal_case.rs +++ b/src/lint/unicorn/rules/unicorn_number_literal_case.rs @@ -5,14 +5,20 @@ use bun_lint::rule::Plugin; /// This rule enforces proper case for numeric literals. pub struct NumberLiteralCase; -const UPPERCASE_PREFIX: Message = Message::new("", "Unexpected number literal prefix in uppercase."); -const UPPERCASE_EXPONENTIAL_NOTATION: Message = Message::new("", "Unexpected exponential notation in uppercase."); -const LOWERCASE_HEXADECIMAL_DIGITS: Message = Message::new("", "Unexpected hexadecimal digits in lowercase."); -const UPPERCASE_PREFIX_AND_LOWERCASE_HEXADECIMAL_DIGITS: Message = - Message::new("", "Unexpected number literal prefix in uppercase and hexadecimal digits in lowercase."); +const UPPERCASE_PREFIX: Message = + Message::new("", "Unexpected number literal prefix in uppercase."); +const UPPERCASE_EXPONENTIAL_NOTATION: Message = + Message::new("", "Unexpected exponential notation in uppercase."); +const LOWERCASE_HEXADECIMAL_DIGITS: Message = + Message::new("", "Unexpected hexadecimal digits in lowercase."); +const UPPERCASE_PREFIX_AND_LOWERCASE_HEXADECIMAL_DIGITS: Message = Message::new( + "", + "Unexpected number literal prefix in uppercase and hexadecimal digits in lowercase.", +); impl Rule for NumberLiteralCase { - const META: Meta = Meta::oxlint(Plugin::Unicorn, "number-literal-case", Kind::Suggestion).fixable(Fixable::Code); + const META: Meta = Meta::oxlint(Plugin::Unicorn, "number-literal-case", Kind::Suggestion) + .fixable(Fixable::Code); const ON: On = On::new().number_literals(); no_state!(); @@ -27,7 +33,11 @@ impl Rule for NumberLiteralCase { } let prefix = Span::new(raw_span.start + 1, raw_span.start + 2); let (message, span, fixed_literal) = match number_literal { - [b'0', b'B' | b'O', ..] => (UPPERCASE_PREFIX, prefix, number_literal.to_ascii_lowercase()), + [b'0', b'B' | b'O', ..] => ( + UPPERCASE_PREFIX, + prefix, + number_literal.to_ascii_lowercase(), + ), [b'0', x @ (b'X' | b'x'), digits @ ..] => { let has_lowercase_digits = digits.iter().any(|it| matches!(it, b'a'..=b'f')); let (message, span) = match (*x == b'X', has_lowercase_digits) { @@ -37,20 +47,42 @@ impl Rule for NumberLiteralCase { (false, false) => return, }; // The `n` of a `BigInt` stays. - let upper = |it: &u8| if *it == b'n' { b'n' } else { it.to_ascii_uppercase() }; - (message, span, b"0x".iter().copied().chain(digits.iter().map(upper)).collect()) + let upper = |it: &u8| { + if *it == b'n' { + b'n' + } else { + it.to_ascii_uppercase() + } + }; + ( + message, + span, + b"0x" + .iter() + .copied() + .chain(digits.iter().map(upper)) + .collect(), + ) } _ => match strings::index_of_char(number_literal, b'E') { Some(index) => { let char_position = raw_span.start + index; let span = Span::new(char_position, char_position + 1); - (UPPERCASE_EXPONENTIAL_NOTATION, span, number_literal.to_ascii_lowercase()) + ( + UPPERCASE_EXPONENTIAL_NOTATION, + span, + number_literal.to_ascii_lowercase(), + ) } None => return, }, }; let lowercase_prefix = fixed_literal.get(..2).unwrap_or_default().to_vec(); - let report = cx.report(span, message).data("prefix", lowercase_prefix); + let report = match number.owner() { + Node::Expr(e) if e.span() == raw_span => cx.report(span, message).called_with(e), + _ => cx.report(span, message), + }; + let report = report.data("prefix", lowercase_prefix); report.fix(|fixer| fixer.replace(raw_span, fixed_literal)); } } diff --git a/src/lint/unicorn/rules/unicorn_numeric_separators_style.rs b/src/lint/unicorn/rules/unicorn_numeric_separators_style.rs index 46b86eab2621..71d7988c980b 100644 --- a/src/lint/unicorn/rules/unicorn_numeric_separators_style.rs +++ b/src/lint/unicorn/rules/unicorn_numeric_separators_style.rs @@ -12,7 +12,8 @@ pub struct NumericSeparatorsStyle { fraction_group_length: usize, } -const NUMERIC_SEPARATORS_STYLE: Message = Message::new("", "Invalid group length in numeric value."); +const NUMERIC_SEPARATORS_STYLE: Message = + Message::new("", "Invalid group length in numeric value."); struct NumericBaseConfig { only_if_contains_separator: Option, @@ -22,7 +23,12 @@ struct NumericBaseConfig { impl NumericBaseConfig { fn new(options: Object, group_length: usize, minimum_digits: usize) -> Self { - let integer = |key: &str| options.number(key).filter(|it| it.fract() == 0.0).and_then(|_| options.usize(key)); + let integer = |key: &str| { + options + .number(key) + .filter(|it| it.fract() == 0.0) + .and_then(|_| options.usize(key)) + }; NumericBaseConfig { only_if_contains_separator: options.bool("onlyIfContainsSeparator"), group_length: integer("groupLength").unwrap_or(group_length), @@ -53,13 +59,17 @@ impl NumericSeparatorsStyle { [b'0', b'x' | b'X', digits @ ..] => (raw.get(..2)?, digits, &self.hexadecimal), [b'0', b'o' | b'O', digits @ ..] => (raw.get(..2)?, digits, &self.octal), // `010`, which cannot have separators. - [b'0', digits @ ..] if !digits.is_empty() && digits.iter().all(|it| matches!(it, b'0'..=b'7')) => { + [b'0', digits @ ..] + if !digits.is_empty() && digits.iter().all(|it| matches!(it, b'0'..=b'7')) => + { return None; } _ => (&b""[..], raw, &self.number), }; if !strings::contains_char(raw, b'_') - && config.only_if_contains_separator.unwrap_or(self.only_if_contains_separator) + && config + .only_if_contains_separator + .unwrap_or(self.only_if_contains_separator) { return None; } @@ -74,7 +84,8 @@ impl NumericSeparatorsStyle { Some(at) => (raw.get(..at)?, raw.get(at..)), None => (raw, None), }; - let (integer_part, decimal_part) = strings::split_once_char(mantissa, b'.').unwrap_or((mantissa, b"")); + let (integer_part, decimal_part) = + strings::split_once_char(mantissa, b'.').unwrap_or((mantissa, b"")); config.add_separators(&mut out, integer_part, config.group_length, false); // The `.` of `1.e2` is dropped, as in oxlint. That of `1.` stays, for `1..toString()`. if !decimal_part.is_empty() || raw.ends_with(b".") { @@ -97,21 +108,29 @@ impl NumericSeparatorsStyle { } impl Rule for NumericSeparatorsStyle { - const META: Meta = - Meta::oxlint(Plugin::Unicorn, "numeric-separators-style", Kind::Suggestion).fixable(Fixable::Code); + const META: Meta = Meta::oxlint( + Plugin::Unicorn, + "numeric-separators-style", + Kind::Suggestion, + ) + .fixable(Fixable::Code); const ON: On = On::new().number_literals(); no_state!(); fn new(options: &Options) -> Self { let options = options.object(0); let number = options.object("number"); - let fraction_group_length = number.number("fractionGroupLength").filter(|it| it.fract() == 0.0); + let fraction_group_length = number + .number("fractionGroupLength") + .filter(|it| it.fract() == 0.0); NumericSeparatorsStyle { only_if_contains_separator: options.bool_or("onlyIfContainsSeparator", false), hexadecimal: NumericBaseConfig::new(options.object("hexadecimal"), 2, 0), binary: NumericBaseConfig::new(options.object("binary"), 4, 0), octal: NumericBaseConfig::new(options.object("octal"), 4, 0), - fraction_group_length: fraction_group_length.and_then(|_| number.usize("fractionGroupLength")).unwrap_or(usize::MAX), + fraction_group_length: fraction_group_length + .and_then(|_| number.usize("fractionGroupLength")) + .unwrap_or(usize::MAX), number: NumericBaseConfig::new(number, 3, 5), } } @@ -119,7 +138,8 @@ impl Rule for NumericSeparatorsStyle { fn number_literal<'a>(&self, number: Literal<'a>, cx: &mut Cx<'a, Self>) { let raw = number.text(); // Most numbers are a few digits. - let is_short = raw.len() <= self.number.group_length || raw.len() < self.number.minimum_digits; + let is_short = + raw.len() <= self.number.group_length || raw.len() < self.number.minimum_digits; if is_short && raw.iter().all(u8::is_ascii_digit) { return; } @@ -129,7 +149,10 @@ impl Rule for NumericSeparatorsStyle { }; if formatted != digits { formatted.extend_from_slice(raw.get(digits.len()..).unwrap_or_default()); - cx.report(number, NUMERIC_SEPARATORS_STYLE).fix(|fixer| fixer.replace(number, formatted)); + let report = cx + .report(number, NUMERIC_SEPARATORS_STYLE) + .called_with_the_name(); + report.fix(|fixer| fixer.replace(number, formatted)); } } } diff --git a/src/lint/unicorn/rules/unicorn_prefer_string_raw.rs b/src/lint/unicorn/rules/unicorn_prefer_string_raw.rs index df54f8874279..3d3c7211aa54 100644 --- a/src/lint/unicorn/rules/unicorn_prefer_string_raw.rs +++ b/src/lint/unicorn/rules/unicorn_prefer_string_raw.rs @@ -5,10 +5,12 @@ use bun_lint::rule::Plugin; /// Prefers use of `String.raw` to avoid escaping `\`. pub struct PreferStringRaw; -const PREFER_STRING_RAW: Message = Message::new("", "`String.raw` should be used to avoid escaping `\\`."); +const PREFER_STRING_RAW: Message = + Message::new("", "`String.raw` should be used to avoid escaping `\\`."); impl Rule for PreferStringRaw { - const META: Meta = Meta::oxlint(Plugin::Unicorn, "prefer-string-raw", Kind::Suggestion).fixable(Fixable::Code); + const META: Meta = + Meta::oxlint(Plugin::Unicorn, "prefer-string-raw", Kind::Suggestion).fixable(Fixable::Code); const ON: On = On::new().string_literals(); /// Where the text has a `\\`, in order. type State<'a> = Vec; @@ -23,7 +25,10 @@ impl Rule for PreferStringRaw { } let (text, mut found) = (file.text(), Vec::new()); let mut at = strings::index_of_char_usize(text, b'\\')?; - while let Some(next) = text.get(at..).and_then(|rest| strings::index_of(rest, b"\\\\")) { + while let Some(next) = text + .get(at..) + .and_then(|rest| strings::index_of(rest, b"\\\\")) + { found.push((at + next) as u32); at += next + 2; } @@ -32,13 +37,18 @@ impl Rule for PreferStringRaw { fn string_literal<'a>(&self, string_literal: Literal<'a>, cx: &mut Cx<'a, Self>) { let (span, found) = (string_literal.span(), &cx.state); - if found.get(found.partition_point(|it| *it < span.start)).is_none_or(|it| *it >= span.end) + if found + .get(found.partition_point(|it| *it < span.start)) + .is_none_or(|it| *it >= span.end) || is_where_no_template_can_be(string_literal) { return; } let raw = string_literal.text(); - let Some(([quote], trimmed)) = raw.get(..raw.len() - 1).and_then(|it| it.split_first_chunk()) else { + let Some(([quote], trimmed)) = raw + .get(..raw.len() - 1) + .and_then(|it| it.split_first_chunk()) + else { return; }; // It is the value if there is no other escape. @@ -46,17 +56,32 @@ impl Rule for PreferStringRaw { return; }; // A last `\` would escape the backtick. - if unescaped.iter().rev().take_while(|it| **it == b'\\').count() % 2 == 1 + if unescaped + .iter() + .rev() + .take_while(|it| **it == b'\\') + .count() + % 2 + == 1 || strings::contains_char(&unescaped, b'`') || strings::contains(&unescaped, b"${") { return; } - cx.report(string_literal, PREFER_STRING_RAW).fix(|fixer| { - let before = fixer.file().text().get(..string_literal.span().start as usize).unwrap_or_default(); - let space: &[u8] = if ends_with_keyword(before) { b" " } else { b"" }; - fixer.replace(string_literal, [space, b"String.raw`", &unescaped, b"`"].concat()) - }); + cx.report(string_literal, PREFER_STRING_RAW) + .called_with_the_name() + .fix(|fixer| { + let before = fixer + .file() + .text() + .get(..string_literal.span().start as usize) + .unwrap_or_default(); + let space: &[u8] = if ends_with_keyword(before) { b" " } else { b"" }; + fixer.replace( + string_literal, + [space, b"String.raw`", &unescaped, b"`"].concat(), + ) + }); } } @@ -65,7 +90,9 @@ fn unescape_backslash(input: &[u8], quote: u8) -> Option> { let mut result = Vec::with_capacity(input.len()); let mut rest = input; while let Some(at) = strings::index_of_char_usize(rest, b'\\') { - let next = rest.get(at + 1).filter(|it| **it == b'\\' || **it == quote)?; + let next = rest + .get(at + 1) + .filter(|it| **it == b'\\' || **it == quote)?; result.extend_from_slice(rest.get(..at)?); result.push(*next); rest = rest.get(at + 2..)?; @@ -76,14 +103,20 @@ fn unescape_backslash(input: &[u8], quote: u8) -> Option> { /// What oxlint leaves out, by the parent. The value of a property is among it unless the key is a string. fn is_where_no_template_can_be(string_literal: Literal) -> bool { - let is_string = - |key: Option| matches!(key.map(Key::kind), Some(KeyKind::String(_) | KeyKind::ComputedString(_))); + let is_string = |key: Option| { + matches!( + key.map(Key::kind), + Some(KeyKind::String(_) | KeyKind::ComputedString(_)) + ) + }; match string_literal.owner() { Node::Expr(e) if !e.is_parenthesized() => match e.parent() { Node::Prop(prop) if prop.is_jsx_attribute() => e.jsx_container_span().is_none(), Node::Prop(prop) => !is_string(prop.key()), Node::Stmt(statement) => statement.directive().is_some(), - Node::EnumMember(member) => !matches!(member.key().map(Key::kind), Some(KeyKind::String(_))), + Node::EnumMember(member) => { + !matches!(member.key().map(Key::kind), Some(KeyKind::String(_))) + } _ => false, }, Node::Expr(_) => false, @@ -92,7 +125,8 @@ fn is_where_no_template_can_be(string_literal: Literal) -> bool { Node::Type(ty) => ty.tag() != TypeTag::Import, // Not the name of `declare module "a"`, nor what else a statement has. Node::Stmt(statement) => { - statement.tag() != StmtTag::ImportEquals && statement.module_specifier_span() == Some(string_literal.span()) + statement.tag() != StmtTag::ImportEquals + && statement.module_specifier_span() == Some(string_literal.span()) } Node::EnumMember(_) => true, _ => false, @@ -150,5 +184,6 @@ fn ends_with_keyword(source: &[u8]) -> bool { "yield", "of", ]; - source.last().is_some_and(u8::is_ascii_lowercase) && KEYWORDS.iter().any(|it| source.ends_with(it.as_bytes())) + source.last().is_some_and(u8::is_ascii_lowercase) + && KEYWORDS.iter().any(|it| source.ends_with(it.as_bytes())) } From 8f21994221aa835e78759c69ffccc2316b01e43c Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 13 Oct 2026 05:04:36 +0000 Subject: [PATCH 47/49] lint, format: 70 files --- docs/guides/format/migrate-from-prettier.mdx | 22 +- docs/runtime/format.mdx | 2 + src/format/html/js.rs | 6 +- src/format/js/context.rs | 6 +- src/format/js/print/literals.rs | 7 +- src/format/js/print/template.rs | 6 +- src/format/js/utils/object.rs | 10 +- src/format/js/utils/tailwindcss.rs | 238 ++++- src/format/tailwind.rs | 6 + src/glob/matcher.rs | 884 ++++++++++-------- src/lint/driver/discover.rs | 3 +- src/lint/driver/fmt/config.rs | 85 +- src/lint/driver/fmt/doctor.rs | 10 +- src/lint/driver/fmt/mod.rs | 18 +- src/lint/driver/fmt/tailwind.js | 32 +- src/lint/driver/fmt/tailwind.rs | 253 +++-- src/lint/driver/format/oxlint.rs | 44 - src/lint/driver/format/unix.rs | 59 +- src/lint/driver/lib.rs | 1 + src/lint/driver/lint.rs | 39 +- src/lint/driver/typed.rs | 6 +- src/lint/eslint/lib.rs | 1 + src/lint/eslint/rules/complexity.rs | 4 +- src/lint/eslint/rules/no_else_return.rs | 51 +- src/lint/eslint/rules/no_loss_of_precision.rs | 82 +- .../rules/no_unused_private_class_members.rs | 114 ++- .../eslint/rules/no_useless_assignment.rs | 11 +- src/lint/eslint/rules/no_useless_return.rs | 126 ++- src/lint/eslint/rules/require_await.rs | 1 + src/lint/graph/lib.rs | 1 + src/lint/jest/jest_expect.rs | 2 +- src/lint/jest/jest_matchers.rs | 3 +- src/lint/jest/jest_tests.rs | 5 +- src/lint/jest/lib.rs | 1 + .../vitest_prefer_called_exactly_once_with.rs | 87 +- src/lint/language.rs | 144 +-- src/lint/lib.rs | 1 + src/lint/linter/config/cache.rs | 59 +- src/lint/linter/config/mod.rs | 55 +- src/lint/linter/config/rc.rs | 33 +- src/lint/linter/globals/mod.rs | 4 +- src/lint/linter/syntax/oxc/names.rs | 4 + src/lint/oxlint/lib.rs | 1 + src/lint/oxlint_order/generate-table.mjs | 5 +- src/lint/oxlint_order/table.rs | 57 +- src/lint/plugins/lib.rs | 1 + src/lint/plugins/oxlint/exhaustive_deps.rs | 21 +- .../plugins/rules/import_no_duplicates.rs | 267 ++++-- src/lint/plugins/rules/import_order.rs | 357 +++++-- .../promise_prefer_await_to_callbacks.rs | 49 +- .../rules/react_hooks_rules_of_hooks.rs | 294 ++++-- src/lint/react/lib.rs | 1 + .../rules/react_jsx_props_no_spread_multi.rs | 70 +- src/lint/react/util_default_props.rs | 4 + src/lint/semantic/mod.rs | 9 + src/lint/semantic/references.rs | 9 + src/lint/typescript/lib.rs | 1 + ...necessary_parameter_property_assignment.rs | 2 +- .../typescript/rules/parameter_properties.rs | 47 +- src/lint/typescript/rules/prefer_readonly.rs | 126 ++- .../rules/related_getter_setter_pairs.rs | 27 +- .../typescript/rules/unified_signatures.rs | 143 ++- src/lint/unicorn/lib.rs | 1 + .../rules/unicorn_consistent_assert.rs | 28 +- .../unicorn_consistent_function_scoping.rs | 216 ++++- .../rules/unicorn_prefer_number_properties.rs | 100 +- src/lint/utils/sort.rs | 11 + src/lint/utils/ts_scope/scope_utils.rs | 17 +- test/cli/format/format-doctor.test.ts | 40 +- test/cli/format/tailwind/tailwind.test.ts | 277 ++++++ 70 files changed, 3445 insertions(+), 1262 deletions(-) diff --git a/docs/guides/format/migrate-from-prettier.mdx b/docs/guides/format/migrate-from-prettier.mdx index e6dc6a0bc87e..4ff56e6f4a62 100644 --- a/docs/guides/format/migrate-from-prettier.mdx +++ b/docs/guides/format/migrate-from-prettier.mdx @@ -92,33 +92,15 @@ Bun reads everything Prettier reads: --- -Bun doesn't load Prettier plugins. The three that sort imports are built in, with all their options: +Bun doesn't load Prettier plugins. These are built in, with all their options, so leave them in `plugins`: +- `prettier-plugin-tailwindcss` - `@trivago/prettier-plugin-sort-imports` - `@ianvs/prettier-plugin-sort-imports` - `prettier-plugin-organize-imports` --- -If you use `prettier-plugin-tailwindcss`, take it out of `.prettierrc` and turn on Bun's own [class sorting](/runtime/format#sort-tailwind-css-classes). - -```json .prettierrc icon="file-json" -{ - "plugins": ["prettier-plugin-tailwindcss"], // [!code --] - "tailwindFunctions": ["tw"], // [!code --] - "semi": false -} -``` - -```toml bunfig.toml icon="settings" -[fmt.sortTailwindcss] -functions = ["tw"] -``` - -The order of the classes is the same. A few edge cases differ, like a class string inside a nested function call, so expect a small diff the first time. - ---- - Svelte, Astro, and other languages that come from a plugin go to your installed Prettier. Keep `prettier` and every plugin in `plugins` installed. `--doctor` shows which files those are. ```txt diff --git a/docs/runtime/format.mdx b/docs/runtime/format.mdx index 961fe2f85bb2..6ed84ea7a84b 100644 --- a/docs/runtime/format.mdx +++ b/docs/runtime/format.mdx @@ -167,6 +167,8 @@ sortTailwindcss = true Classes are sorted in Tailwind's recommended order, in `class` and `className` and in calls to `clsx`, `cn`, `cva`, `twMerge`, and [a few more](/runtime/bunfig#format-sorttailwindcss). The order comes from the `tailwindcss` package in your project, so version 3 or 4 has to be installed. +If your `.prettierrc` lists `prettier-plugin-tailwindcss`, sorting is already on, with that plugin's options and defaults. + To name your own functions or your stylesheet, use a table. ```toml bunfig.toml icon="settings" diff --git a/src/format/html/js.rs b/src/format/html/js.rs index 5b047d3a1a5a..6f3cc97b71c9 100644 --- a/src/format/html/js.rs +++ b/src/format/html/js.rs @@ -182,10 +182,10 @@ fn with_file( if !matches!(piece, Piece::Script(flavor) | Piece::Binding(flavor) if flavor.is_oxfmt()) { options.filepath.clone_from(&f.options().filepath); } - // The plugin that sorts classes takes over the parsers for programs and `__js_expression`, no other. Bindings are - // oxfmt's own, and it does not sort there. + // The plugin that sorts classes takes over the parsers for programs and `__js_expression`, no other. In oxfmt bindings + // are its own, and it does not sort there. let sorts_classes = match (piece, in_html.root) { - (Piece::Binding(_), _) => false, + (Piece::Binding(_), _) => (options.tailwind.as_deref()).is_some_and(|it| it.follows_plugin), (_, HtmlRoot::Program) => true, (_, HtmlRoot::JsExpression) => paths == [EXPRESSION_JSX], _ => false, diff --git a/src/format/js/context.rs b/src/format/js/context.rs index 9207bdc8fcf1..abfc44a0364b 100644 --- a/src/format/js/context.rs +++ b/src/format/js/context.rs @@ -61,6 +61,8 @@ impl<'a> JsFormatContext<'a> { options: FormatOptions, comments: &'a [Comment], ) -> Self { + let tailwind = options.tailwind.as_deref(); + let of_plugin = (tailwind.filter(|it| it.follows_plugin)).map(TailwindContext::of_plugin); Self { file: None, has_tree_of_babel: false, @@ -68,7 +70,7 @@ impl<'a> JsFormatContext<'a> { comments: Comments::new(SourceText::new(source), comments), cached_elements: FxHashMap::default(), quote_needed_stack: Vec::new(), - tailwind_contexts: Vec::new(), + tailwind_contexts: of_plugin.into_iter().collect(), is_quiet: false, cursor: CursorRegion::NONE, stack_check: bun_core::StackCheck::init(), @@ -161,7 +163,7 @@ impl<'a> JsFormatContext<'a> { /// if there is a context. #[inline] pub(crate) fn disable_tailwind_context(&mut self, is_disabled: bool) -> Option { - let context = self.tailwind_contexts.last_mut()?; + let context = (self.tailwind_contexts.last_mut()).filter(|it| !it.follows_plugin)?; Some(std::mem::replace(&mut context.is_disabled, is_disabled)) } } diff --git a/src/format/js/print/literals.rs b/src/format/js/print/literals.rs index 4d787b582c2e..6b582a05dd5b 100644 --- a/src/format/js/print/literals.rs +++ b/src/format/js/print/literals.rs @@ -12,9 +12,10 @@ pub(crate) fn write_string_literal<'a>(e: Expr<'a>, f: &mut Formatter<'a>) { let is_jsx = matches!(e.parent(), Node::Prop(property) if property.is_jsx_attribute()) && matches!(e.ast_parent(), AstNodes::JSXAttribute(_)); let source = f.source_text().text_for(&e); - if let Some(context) = context_of_string(source, f) { - let place = (e.span(), e.ast_parent()); - let printed = sorted_string_literal(source, is_jsx, place, context, f); + if let Some(context) = context_of_string(source, f) + && let place = (e.span(), e.ast_parent()) + && let Some(printed) = sorted_string_literal(source, is_jsx, place, context, f) + { return write!(f, text(&printed)); } write!( diff --git a/src/format/js/print/template.rs b/src/format/js/print/template.rs index 8f5feabd4379..14462ea93a1c 100644 --- a/src/format/js/print/template.rs +++ b/src/format/js/print/template.rs @@ -50,7 +50,7 @@ pub(crate) fn write_template_literal<'a>( TemplateLike::TemplateLiteral(template).fmt(f); } }); - InTailwindContext(context.map(|it| it.in_template(e)), content).fmt(f); + InTailwindContext(context.map(|it| it.in_template(e, f)), content).fmt(f); } /// `${e}` at `i` in a template that is written as the language in it. Prettier's @@ -258,7 +258,7 @@ impl<'a> Format<'a> for TemplateLike<'a> { write!(f, [line_suffix_boundary(), "`"]); let context = f.context().tailwind_context(); // The line breaks between classes go, so they say nothing about where a `${` is. - let collapses_line_breaks = context.is_some_and(|it| !it.preserves_whitespace); + let collapses_line_breaks = context.is_some_and(|it| it.collapses_line_breaks()); let mut expressions = self .expressions(false, f) .map(|it| match collapses_line_breaks { @@ -276,7 +276,7 @@ impl<'a> Format<'a> for TemplateLike<'a> { .and_then(|context| { let tailwind = f.options().tailwind.as_deref()?; let place = (i == 0, i + 1 == count); - Some(sorted_template_text(source, place, context, tailwind)) + sorted_template_text(source, place, context, tailwind) }); let raw = sorted.as_deref().unwrap_or(source); around_node_at( diff --git a/src/format/js/utils/object.rs b/src/format/js/utils/object.rs index 854611483fb7..9b755f6eace1 100644 --- a/src/format/js/utils/object.rs +++ b/src/format/js/utils/object.rs @@ -105,9 +105,9 @@ fn printed_key<'a>( if matches!(key.kind(), KeyKind::String(_) | KeyKind::ComputedString(_)) && !source.starts_with(b"`") && let Some(context) = context_of_string(source, f) + && let Some(sorted) = sorted_string_literal(source, false, (span, parent), context, f) { - let place = (span, parent); - return Cow::Owned(sorted_string_literal(source, false, place, context, f)); + return Cow::Owned(sorted); } if let (KeyKind::ComputedString(_), [b'`', text @ .., b'`']) = (key.kind(), source) && has_white_space(text) @@ -115,8 +115,12 @@ fn printed_key<'a>( f.context().tailwind_context(), f.options().tailwind.as_deref(), ) + && let context = match context.follows_plugin { + true => context.in_template_at(span, parent, f), + false => context, + } + && let Some(sorted) = sorted_template_text(text, (true, true), context, tailwind) { - let sorted = sorted_template_text(text, (true, true), context, tailwind); return with_normalized_newlines(Cow::Owned([&b"`"[..], &sorted, b"`"].concat())); } match key.kind() { diff --git a/src/format/js/utils/tailwindcss.rs b/src/format/js/utils/tailwindcss.rs index f3f352124d30..ade22b538e41 100644 --- a/src/format/js/utils/tailwindcss.rs +++ b/src/format/js/utils/tailwindcss.rs @@ -3,10 +3,14 @@ //! //! A port of `oxc_formatter/src/utils/tailwindcss.rs`, which follows `prettier-plugin-tailwindcss`. What is written //! is in a context, the last of a stack: `JsFormatContext::tailwind_context`. +//! +//! Where the plugin itself has turned the sorting on, `Tailwind::follows_plugin`, its `transformJavaScript` counts. The stack +//! has one context then, and what is around a text says whether it is sorted: [`collapse_by_plugin`]. use super::string::{FormatLiteralStringToken, StringLiteralParentKind}; use crate::prelude::*; -use crate::tailwind::Tailwind; +use crate::tailwind::{Ends, Names, Tailwind}; +use smallvec::SmallVec; /// What is written is in the value of an attribute, or among the arguments of a function, that takes classes. #[derive(Clone, Copy, Debug)] @@ -22,6 +26,10 @@ pub(crate) struct TailwindContext { around_template: Collapse, /// It is in a call of another function. A string there is not sorted. pub(crate) is_disabled: bool, + /// See [`Tailwind::follows_plugin`]. + pub(crate) follows_plugin: bool, + /// The texts of the template that is written are sorted. oxfmt sorts all that are in a context. + sorts_template: bool, } /// Whether the white space at the start and at the end of a text can go. If not, one blank stays of it. @@ -36,6 +44,14 @@ impl Collapse { start: true, end: true, }; + + /// What can go here, and also at the `start` and the `end`. + fn and(self, start: bool, end: bool) -> Collapse { + Collapse { + start: self.start && start, + end: self.end && end, + } + } } #[inline] @@ -61,29 +77,64 @@ impl TailwindContext { has_whitespace_behind: true, around_template: Collapse::ALL, is_disabled: false, + follows_plugin: false, + sorts_template: true, + } + } + + /// For all of a file in which the rules of the plugin count. + pub(crate) fn of_plugin(tailwind: &Tailwind) -> Self { + TailwindContext { + follows_plugin: true, + sorts_template: false, + ..TailwindContext::new(tailwind) } } /// For the template `e`. - pub(crate) fn in_template(self, e: Expr<'_>) -> Self { + pub(crate) fn in_template<'a>(self, e: Expr<'a>, f: &Formatter<'a>) -> Self { + if self.follows_plugin { + return self.in_template_at(e.span(), e.ast_parent(), f); + } TailwindContext { around_template: collapse_in_concatenation(e.span(), e.ast_parent()), ..self } } + /// For the template at `span` in `parent`, by the rules of the plugin. + pub(crate) fn in_template_at<'a>( + self, + span: Span, + parent: AstNodes<'a>, + f: &Formatter<'a>, + ) -> Self { + let around = collapse_by_plugin(span, parent, f); + TailwindContext { + sorts_template: around.is_some(), + around_template: around.unwrap_or(Collapse::ALL), + ..self + } + } + /// For what is between `${` and `}`, `before` and `behind` which these texts are. pub(crate) fn in_template_expression(self, before: &[u8], behind: &[u8]) -> Self { TailwindContext { - preserves_whitespace: self.preserves_whitespace, is_in_template_expression: true, has_whitespace_before: ends_with_white_space(before), has_whitespace_behind: starts_with_white_space(behind), around_template: Collapse::ALL, is_disabled: false, + sorts_template: !self.follows_plugin, + ..self } } + /// Whether the line breaks in the template that is written go. + pub(crate) fn collapses_line_breaks(self) -> bool { + self.sorts_template && !self.preserves_whitespace + } + /// What the template around it leaves of the white space at the ends of what is written. fn collapse(self) -> Collapse { Collapse { @@ -111,14 +162,14 @@ impl<'a, T: Format<'a>> Format<'a> for InTailwindContext { /// The context for the arguments of `callee`, or for the template that it is the tag of, if they are classes. pub(crate) fn context_of_function(callee: Expr<'_>, f: &Formatter<'_>) -> Option { (f.options().tailwind.as_deref()) - .filter(|tailwind| is_class_function(callee, tailwind)) + .filter(|tailwind| !tailwind.follows_plugin && is_class_function(callee, tailwind, f)) .map(TailwindContext::new) } /// The context for the value of the JSX attribute called `name`, if it is classes. pub(crate) fn context_of_attribute(name: &[u8], f: &Formatter<'_>) -> Option { (f.options().tailwind.as_deref()) - .filter(|tailwind| is_class_attribute(name, tailwind)) + .filter(|tailwind| !tailwind.follows_plugin && is_class_attribute(name, tailwind)) .map(TailwindContext::new) } @@ -132,25 +183,156 @@ pub(crate) fn context_of_string(source: &[u8], f: &Formatter<'_>) -> Option bool { matches!(name, b"class" | b"className") - || (!bun_core::strings::contains_char(name, b':') && tailwind.attributes.has_name(name)) + || (!bun_core::strings::contains_char(name, b':') + && has(&tailwind.attributes, name, tailwind)) +} + +/// oxfmt's own rules know no patterns. +fn has(names: &Names, name: &[u8], tailwind: &Tailwind) -> bool { + names.has_name(name) || (tailwind.follows_plugin && names.has_pattern_for(name)) +} + +/// Whether what is in an optional chain with `e` is another kind of node to the plugin than a call or a member access: to +/// Babel every link from the `?.` on is, to the other parsers what is around the whole chain. +fn is_other_node_to_plugin(e: Expr<'_>, f: &Formatter<'_>) -> bool { + match f.context().has_tree_of_babel() { + true => e.is_in_optional_chain(), + false => e.is_chain_root(), + } } /// Whether the arguments of `callee` are classes: `cn`, `cn.a`, `cn()`. The plugin's `isSortableExpression`. -fn is_class_function(callee: Expr<'_>, tailwind: &Tailwind) -> bool { - if tailwind.functions.names().is_empty() { +fn is_class_function(callee: Expr<'_>, tailwind: &Tailwind, f: &Formatter<'_>) -> bool { + if tailwind.functions.is_empty() { return false; } let mut node = callee; loop { + if tailwind.follows_plugin && is_other_node_to_plugin(node, f) { + return false; + } node = match node.kind() { ExprKind::Call(call) => call.callee(), ExprKind::Dot { obj, .. } | ExprKind::Index { obj, .. } => obj, - ExprKind::Ident(_) => return tailwind.functions.has_name(node.text()), + ExprKind::Ident(_) => return has(&tailwind.functions, node.text(), tailwind), _ => return false, }; } } +/// What stays of the white space at the ends of the string or the template at `leaf` in `parent`, by the rules of the plugin. +/// `None`: it is not sorted. The plugin sorts all strings and templates in the value of an attribute and in an argument of a +/// function that take classes, however deep (`sortInside`), and the template behind such a function. +fn collapse_by_plugin<'a>(leaf: Span, parent: AstNodes<'a>, f: &Formatter<'a>) -> Option { + let tailwind = f.options().tailwind.as_deref()?; + let is_in = |span: Span| span.start <= leaf.start && leaf.end <= span.end; + // Each `+` and each template around it, from the inside to the outside. + let mut around: SmallVec<[Expr<'a>; 4]> = SmallVec::new(); + let (mut is_sorted, mut inner) = (false, None); + for ancestor in parent.ancestors() { + match ancestor { + AstNodes::BinaryExpression(e) | AstNodes::TemplateLiteral(e) => around.push(e), + AstNodes::TaggedTemplateExpression(e) if inner.is_none() => { + is_sorted = matches!(e.kind(), ExprKind::TaggedTemplate(call) if is_class_function(call.callee(), tailwind, f)); + } + // It goes by what is between it and the innermost of these. + AstNodes::JSXAttribute(attribute) + if matches!(inner, None | Some(AstNodes::JSXExpressionContainer(_))) + && (attribute.key()).is_some_and(|key| { + let name = f.source_text().text_for(&key.span(f.file())); + is_class_attribute(name, tailwind) + }) => + { + is_sorted = true; + break; + } + AstNodes::CallExpression(e) => { + if let ExprKind::Call(call) = e.kind() + && call.args().iter().any(|it| is_in(it.span())) + && !(f.context().has_tree_of_babel() && e.is_in_optional_chain()) + && is_class_function(call.callee(), tailwind, f) + { + is_sorted = true; + break; + } + } + _ => {} + } + inner = Some(ancestor); + } + if !is_sorted { + return None; + } + let mut collapse = Collapse::ALL; + if tailwind.preserves_whitespace { + return Some(collapse); + } + // `canCollapseWhitespaceIn` + for e in around.into_iter().rev() { + match e.kind() { + ExprKind::Binary { + op: BinOp::Add, + left, + right, + } => { + collapse = collapse.and(!is_in(right.span()), !is_in(left.span())); + } + ExprKind::Template(template) => { + let is_babel = f.context().has_tree_of_babel(); + collapse = collapse_in_template(template, leaf, collapse, (tailwind, is_babel)); + } + _ => {} + } + } + Some(collapse) +} + +/// The same for what is at `leaf` between a `${` and a `}` of `template`, of whose own ends `around` stays. +fn collapse_in_template( + template: Template<'_>, + leaf: Span, + around: Collapse, + (tailwind, is_babel): (&Tailwind, bool), +) -> Collapse { + let (count, mut collapse) = (template.quasi_count(), around); + let mut expressions = template.exprs().iter().enumerate(); + let Some((index, expression)) = + expressions.find(|(_, it)| it.span().start <= leaf.start && leaf.end <= it.span().end) + else { + return around; + }; + // The plugin looks at the texts behind it, and at the one before it if nothing is between the `${` and it, as it has + // sorted them, and at the start of each for the start. The tree of TypeScript does not have the positions that it asks for. + if is_babel { + let touches_brace = template.quasi_span(index).end == expression.span().start; + for i in (index + usize::from(!touches_brace))..count { + let raw = template.raw(i); + let sorted = tailwind + .sorted_between(raw, ends_in_template(raw, (i == 0, i + 1 == count), around)); + collapse = collapse.and( + starts_with_white_space(&sorted), + i == index || ends_with_white_space(&sorted), + ); + } + } + // That takes the blank away in `` `a${" b"}` ``, and `a` and `b` are one class. Here what touches a text stays apart from it. + let (before, behind) = (template.raw(index), template.raw(index + 1)); + collapse.and( + ends_with_white_space(before) || (before.is_empty() && index == 0), + starts_with_white_space(behind) || (behind.is_empty() && index + 2 == count), + ) +} + +/// What the plugin's `sortTemplateLiteral` calls `sortClasses` with for `text`. `around`: what stays at the ends of the template. +fn ends_in_template(text: &[u8], (is_first, is_last): (bool, bool), around: Collapse) -> Ends { + Ends { + ignores_first: !is_first && !starts_with_white_space(text), + ignores_last: !is_last && !ends_with_white_space(text), + collapses_start: around.start && is_first, + collapses_end: around.end && is_last, + } +} + /// What `a + .. + b` leaves of the white space at the ends of what is at `span` in `parent`: what is added to has to stay /// apart. The plugin's `canCollapseWhitespaceIn`. fn collapse_in_concatenation(span: Span, parent: AstNodes<'_>) -> Collapse { @@ -169,8 +351,7 @@ fn collapse_in_concatenation(span: Span, parent: AstNodes<'_>) -> Collapse { let contains = |operand: Expr<'_>| { operand.span().start <= span.start && span.end <= operand.span().end }; - collapse.end &= !contains(left); - collapse.start &= !contains(right); + collapse = collapse.and(!contains(right), !contains(left)); if !collapse.start && !collapse.end { break; } @@ -187,27 +368,37 @@ fn collapse_in_concatenation(span: Span, parent: AstNodes<'_>) -> Collapse { } /// What is written for the string literal `source`, which is at `span` in `parent`. `is_jsx`: it is the value of an -/// attribute. +/// attribute. `None`: it is not sorted. pub(crate) fn sorted_string_literal<'a>( source: &'a [u8], is_jsx: bool, (span, parent): (Span, AstNodes<'a>), context: TailwindContext, f: &Formatter<'a>, -) -> Vec { +) -> Option> { + let by_plugin = match context.follows_plugin { + true => Some(collapse_by_plugin(span, parent, f)?), + false => None, + }; let normalized = FormatLiteralStringToken::new(source, is_jsx, StringLiteralParentKind::Expression) .clean_text(f) .into_text(); - let (Some(tailwind), [quote, content @ .., _]) = - (f.options().tailwind.as_deref(), &*normalized) + let (tailwind, [quote, content @ .., _]) = (f.options().tailwind.as_deref()?, &*normalized) else { - return normalized.into_owned(); + return None; }; let mut printed = Vec::with_capacity(normalized.len()); printed.push(*quote); let trimmed = content.trim_ascii(); - if context.preserves_whitespace { + if let Some(around) = by_plugin { + let ends = Ends { + collapses_start: around.start, + collapses_end: around.end, + ..Ends::FREE + }; + printed.extend_from_slice(&tailwind.sorted_between(content, ends)); + } else if context.preserves_whitespace { printed.extend_from_slice(&tailwind.sorted(content)); } else if trimmed.is_empty() { printed.extend(content.first().map(|_| b' ')); @@ -222,7 +413,7 @@ pub(crate) fn sorted_string_literal<'a>( } } printed.push(*quote); - printed + Some(printed) } /// `text`, which is between two `${}` of a template or at an end of it, in three parts: a class that touches the `}` @@ -250,13 +441,18 @@ fn split_template_text(text: &[u8], is_first: bool, is_last: bool) -> (&[u8], &[ (prefix, sortable, suffix) } -/// What is written for `text`, which is text of a template. `is_first`, `is_last`: of the texts of the template. +/// What is written for `text`, which is text of a template. `is_first`, `is_last`: of the texts of the template. `None`: it +/// is not sorted. pub(crate) fn sorted_template_text( text: &[u8], (is_first, is_last): (bool, bool), context: TailwindContext, tailwind: &Tailwind, -) -> Vec { +) -> Option> { + if context.follows_plugin { + let ends = ends_in_template(text, (is_first, is_last), context.around_template); + return (context.sorts_template).then(|| tailwind.sorted_between(text, ends).into_owned()); + } let (prefix, sortable, suffix) = split_template_text(text, is_first, is_last); let mut printed = Vec::with_capacity(text.len()); printed.extend_from_slice(prefix); @@ -280,5 +476,5 @@ pub(crate) fn sorted_template_text( } } printed.extend_from_slice(suffix); - printed + Some(printed) } diff --git a/src/format/tailwind.rs b/src/format/tailwind.rs index 6446a492f193..2f39f0f732a9 100644 --- a/src/format/tailwind.rs +++ b/src/format/tailwind.rs @@ -46,6 +46,10 @@ impl Names { all } + pub fn is_empty(&self) -> bool { + self.names.is_empty() && self.patterns.is_empty() + } + /// Those that are no patterns. pub fn names(&self) -> &[Vec] { &self.names @@ -75,6 +79,8 @@ pub struct Tailwind { pub attributes: Names, pub preserves_whitespace: bool, pub preserves_duplicates: bool, + /// `prettier-plugin-tailwindcss` in a Prettier config has turned it on: where oxfmt has rules of its own, the plugin's count. + pub follows_plugin: bool, pub orders: Box, /// See [`Tailwind::has_missed`]. pub has_missed: AtomicBool, diff --git a/src/glob/matcher.rs b/src/glob/matcher.rs index 5d7899441938..265955122cc0 100644 --- a/src/glob/matcher.rs +++ b/src/glob/matcher.rs @@ -24,8 +24,8 @@ use bun_collections::smallvec::SmallVec; use bun_core::{Once, strings}; +use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU32, Ordering}; -use std::sync::{Arc, OnceLock}; /// Result of [`match`](r#match). Two independent bits: whether the path matched /// (after applying any leading `!` negation), and whether the pattern was negated. @@ -192,7 +192,7 @@ pub fn r#match(glob: &[u8], path: &[u8]) -> MatchResult { /// Use `match_on::` for text that isn't a file path and for paths already normalized to `/`. #[inline] pub fn match_on(glob: &[u8], path: &[u8]) -> MatchResult { - match_from_first_byte::(glob, None, path, Dots::Match) + match_from_first_byte(glob, None, path, Mode::new::(Dots::Match)) } /// How wildcards treat path segments that start with `.`. @@ -215,16 +215,16 @@ pub fn match_prepared( path: &[u8], dots: Dots, ) -> MatchResult { - match_from_first_byte::(glob, Some(shape), path, dots) + match_from_first_byte(glob, Some(shape), path, Mode::new::(dots)) } /// This is at the call site. Most paths that a pattern is asked about differ from it in the first byte. #[inline(always)] -fn match_from_first_byte( +fn match_from_first_byte( glob: &[u8], shape: Option<&Shape>, path: &[u8], - dots: Dots, + mode: Mode, ) -> MatchResult { let Some(&first) = glob.first() else { return MatchResult { @@ -241,18 +241,12 @@ fn match_from_first_byte( let uses = shape.uses.load(Ordering::Relaxed); // From now on a `Walk` decides. if uses == Shape::WALKS { - return match WINDOWS { - true => match_warm_for_windows(glob, shape, path, dots), - false => match_warm_for_posix(glob, shape, path, dots), - }; + return match_warm(glob, shape, path, mode); } shape.count(glob, uses); } match first { - _ if is_plain => match WINDOWS { - true => match_literal_for_windows(glob, shape, path, dots), - false => match_literal_for_posix(glob, shape, path, dots), - }, + _ if is_plain => match_literal(glob, shape, path, mode), // An escaped character that stands for itself is as good as a literal. b'\\' if glob.get(1).is_some_and(|it| { @@ -262,126 +256,62 @@ fn match_from_first_byte( { MatchResult::NO } - b'!' => match WINDOWS { - true => match_negated_for_windows(glob, shape, path, dots), - false => match_negated_for_posix(glob, shape, path, dots), - }, - b'\\' | b'?' | b'[' | 0x80.. => match WINDOWS { - true => match_escapes_for_windows(glob, shape, path, Start::of(dots)), - false => match_escapes_for_posix(glob, shape, path, Start::of(dots)), - }, - b'{' if dots == Dots::Match && glob.ends_with(b"*}") => match WINDOWS { - true => match_braces_for_windows(glob, shape, path), - false => match_braces_for_posix(glob, shape, path), - }, - _ => match WINDOWS { - true => match_for_windows(glob, shape, path, Start::of(dots), None), - false => match_for_posix(glob, shape, path, Start::of(dots), None), + // At least one character. + b'*' | b'?' if mode.dots == Dots::Match && matches!(glob, b"*?" | b"?*") => MatchResult { + matches: !path.is_empty() && is_one_segment(path, mode), + negated: false, }, + b'!' => match_negated(glob, shape, path, mode), + b'\\' | b'?' | b'[' | 0x80.. => match_escapes_at_start(glob, shape, path, mode), + b'{' if mode.dots == Dots::Match && glob.ends_with(b"*}") => { + match_braces(glob, shape, path, mode) + } + _ => match_more(glob, shape, path, Start::of(mode), None), } } -// None of these is generic, so that the binary has them once per platform and not once per crate that calls them. -#[inline(never)] -fn match_literal_for_posix( - glob: &[u8], - shape: Option<&Shape>, - path: &[u8], - dots: Dots, -) -> MatchResult { - match_literal::(glob, shape, path, dots) -} - -#[inline(never)] -fn match_literal_for_windows( - glob: &[u8], - shape: Option<&Shape>, - path: &[u8], - dots: Dots, -) -> MatchResult { - match_literal::(glob, shape, path, dots) -} - -#[inline(never)] -fn match_escapes_for_posix( - glob: &[u8], - shape: Option<&Shape>, - path: &[u8], - start: Start, -) -> MatchResult { - match_escapes::(glob, shape, path, start) -} - -#[inline(never)] -fn match_escapes_for_windows( - glob: &[u8], - shape: Option<&Shape>, - path: &[u8], - start: Start, -) -> MatchResult { - match_escapes::(glob, shape, path, start) -} - -#[inline(never)] -fn match_negated_for_posix( - glob: &[u8], - shape: Option<&Shape>, - path: &[u8], - dots: Dots, -) -> MatchResult { - match_negated::(glob, shape, path, dots) -} - -#[inline(never)] -fn match_negated_for_windows( - glob: &[u8], - shape: Option<&Shape>, - path: &[u8], +/// How a match is asked for. The platform is a value and not a type parameter, so that a build for Windows has everything once. +/// Elsewhere nothing asks for it, so it isn't there, and asking for it doesn't compile. +#[derive(Copy, Clone)] +struct Mode { dots: Dots, -) -> MatchResult { - match_negated::(glob, shape, path, dots) -} - -#[inline(never)] -fn match_braces_for_posix(glob: &[u8], shape: Option<&Shape>, path: &[u8]) -> MatchResult { - match_braces::(glob, shape, path) + #[cfg(any(windows, debug_assertions))] + is_windows: bool, } -#[inline(never)] -fn match_braces_for_windows(glob: &[u8], shape: Option<&Shape>, path: &[u8]) -> MatchResult { - match_braces::(glob, shape, path) -} +impl Mode { + #[inline(always)] + fn new(dots: Dots) -> Mode { + const { assert!(!WINDOWS || cfg!(any(windows, debug_assertions))) } + Mode::on(dots, WINDOWS) + } -#[inline(never)] -fn match_warm_for_posix(glob: &[u8], shape: &Shape, path: &[u8], dots: Dots) -> MatchResult { - match_warm::(glob, shape, path, dots) -} + #[inline(always)] + fn on(dots: Dots, is_windows: bool) -> Mode { + _ = is_windows; + Mode { + dots, + #[cfg(any(windows, debug_assertions))] + is_windows, + } + } -#[inline(never)] -fn match_warm_for_windows(glob: &[u8], shape: &Shape, path: &[u8], dots: Dots) -> MatchResult { - match_warm::(glob, shape, path, dots) -} + #[cfg(any(windows, debug_assertions))] + #[inline(always)] + fn is_windows(self) -> bool { + self.is_windows + } -#[inline(never)] -fn match_for_posix( - glob: &[u8], - shape: Option<&Shape>, - path: &[u8], - start: Start, - resumed: Option<&mut State>, -) -> MatchResult { - match_more::(glob, shape, path, start, resumed) + #[cfg(not(any(windows, debug_assertions)))] + #[inline(always)] + fn is_windows(self) -> bool { + false + } } #[inline(never)] -fn match_for_windows( - glob: &[u8], - shape: Option<&Shape>, - path: &[u8], - start: Start, - resumed: Option<&mut State>, -) -> MatchResult { - match_more::(glob, shape, path, start, resumed) +fn is_one_segment(path: &[u8], mode: Mode) -> bool { + !path.iter().any(|it| is_separator(*it, mode.is_windows())) } /// Where the loop starts. @@ -393,35 +323,31 @@ struct Start { glob_index: u32, path_index: u32, seen: Seen, - dots: Dots, + mode: Mode, } impl Start { #[inline(always)] - fn of(dots: Dots) -> Start { + fn of(mode: Mode) -> Start { Start { glob_start: 0, glob_index: 0, path_index: 0, seen: Seen::OnlyRegular, - dots, + mode, } } } /// Takes the literal bytes that the pattern starts with. They need nothing of what follows. -#[inline(always)] -fn match_literal( - glob: &[u8], - shape: Option<&Shape>, - path: &[u8], - dots: Dots, -) -> MatchResult { +#[inline(never)] +fn match_literal(glob: &[u8], shape: Option<&Shape>, path: &[u8], mode: Mode) -> MatchResult { + let is_windows = mode.is_windows(); let mut taken = 0; while let Some(&wanted) = glob.get(taken) { let is_same = match path.get(taken) { Some(&found) if IS_PLAIN[wanted as usize] => found == wanted, - Some(&found) if wanted == b'/' => is_separator::(found), + Some(&found) if wanted == b'/' => is_separator(found, is_windows), None if IS_PLAIN[wanted as usize] || wanted == b'/' => false, _ => break, }; @@ -439,31 +365,25 @@ fn match_literal( let start = Start { glob_index: taken as u32, path_index: taken as u32, - ..Start::of(dots) + ..Start::of(mode) }; - match (matches!(glob[taken], b'\\' | b'?' | b'[' | 0x80..), WINDOWS) { - (true, true) => match_escapes_for_windows(glob, shape, path, start), - (true, false) => match_escapes_for_posix(glob, shape, path, start), - (false, true) => match_for_windows(glob, shape, path, start, None), - (false, false) => match_for_posix(glob, shape, path, start, None), + match matches!(glob[taken], b'\\' | b'?' | b'[' | 0x80..) { + true => match_escapes(glob, shape, path, start), + false => match_more(glob, shape, path, start, None), } } /// [`match_literal`] for a pattern that starts with `!`. -#[inline(always)] -fn match_negated( - glob: &[u8], - shape: Option<&Shape>, - path: &[u8], - dots: Dots, -) -> MatchResult { +#[inline(never)] +fn match_negated(glob: &[u8], shape: Option<&Shape>, path: &[u8], mode: Mode) -> MatchResult { + let is_windows = mode.is_windows(); let glob_start = negations(glob); let negated = glob_start % 2 == 1; let mut taken = 0; while let Some(&wanted) = glob.get(glob_start + taken) { let is_same = match path.get(taken) { Some(&found) if IS_PLAIN[wanted as usize] => found == wanted, - Some(&found) if wanted == b'/' => is_separator::(found), + Some(&found) if wanted == b'/' => is_separator(found, is_windows), None if IS_PLAIN[wanted as usize] || wanted == b'/' => false, _ => break, }; @@ -485,34 +405,28 @@ fn match_negated( glob_start: glob_start as u32, glob_index: (glob_start + taken) as u32, path_index: taken as u32, - ..Start::of(dots) + ..Start::of(mode) }; - match (matches!(next, b'\\' | b'?' | b'[' | 0x80..), WINDOWS) { - (true, true) => match_escapes_for_windows(glob, shape, path, start), - (true, false) => match_escapes_for_posix(glob, shape, path, start), - (false, true) => match_for_windows(glob, shape, path, start, None), - (false, false) => match_for_posix(glob, shape, path, start, None), + match matches!(next, b'\\' | b'?' | b'[' | 0x80..) { + true => match_escapes(glob, shape, path, start), + false => match_more(glob, shape, path, start, None), } } /// The pattern is like `{src/**,lib/**}`. If it's short, each alternative is matched as a pattern of its own. -#[inline(always)] -fn match_braces( - glob: &[u8], - shape: Option<&Shape>, - path: &[u8], -) -> MatchResult { +#[inline(never)] +fn match_braces(glob: &[u8], shape: Option<&Shape>, path: &[u8], mode: Mode) -> MatchResult { + let is_windows = mode.is_windows(); let text = Text { path, path_index: 0, is_committed: true, - is_windows: WINDOWS, - matches: |pattern, text| match_on::(pattern, text).matches(), + is_windows, }; - let written = match_written_out(glob, 0, &text); + let written = match_written_out(glob, 0, &text, &mut { Layout::NONE }); #[cfg(debug_assertions)] if let Some(written) = written { - written.check(glob, 0, path, WINDOWS); + written.check(glob, 0, path, is_windows); } let start = match written { Some(Written::Rest(matches)) => { @@ -526,28 +440,41 @@ fn match_braces( glob_index, path_index, seen: Seen::Choice, - ..Start::of(Dots::Match) + ..Start::of(mode) }, - None => Start::of(Dots::Match), + None => Start::of(mode), }; - match WINDOWS { - true => match_for_windows(glob, shape, path, start, None), - false => match_for_posix(glob, shape, path, start, None), - } + match_more(glob, shape, path, start, None) } /// The same for a pattern that starts with `!`, or has other tokens among those bytes that take one character whatever follows: /// escapes, `?` and classes. +#[inline(never)] +fn match_escapes(glob: &[u8], shape: Option<&Shape>, path: &[u8], start: Start) -> MatchResult { + match_escapes_from::(glob, shape, path, start) +} + +/// The same from the start of the pattern, which saves a little. +#[inline(never)] +fn match_escapes_at_start( + glob: &[u8], + shape: Option<&Shape>, + path: &[u8], + mode: Mode, +) -> MatchResult { + match_escapes_from::(glob, shape, path, Start::of(mode)) +} + #[inline(always)] -fn match_escapes( +fn match_escapes_from( glob: &[u8], shape: Option<&Shape>, path: &[u8], start: Start, ) -> MatchResult { - let dots = start.dots; + let (dots, is_windows) = (start.mode.dots, start.mode.is_windows()); let glob_start = match glob[0] { - b'!' => negations(glob), + b'!' if !IS_AT_START => negations(glob), _ => 0, }; let negated = glob_start % 2 == 1; @@ -556,12 +483,13 @@ fn match_escapes( start.path_index as usize, ); let mut seen = Seen::OnlyRegular; + let mut first_class = usize::MAX; while let Some(&wanted) = glob.get(index) { let found = path.get(path_index).copied(); let is_same = if IS_PLAIN[wanted as usize] { found == Some(wanted) } else if wanted == b'/' { - found.is_some_and(is_separator::) + found.is_some_and(|it| is_separator(it, is_windows)) } else if wanted >= 128 { // The loop compares as many bytes as the first one announces. That's the same unless they go on into something else. let len = usize::from(strings::wtf8_byte_sequence_length(wanted)); @@ -589,14 +517,12 @@ fn match_escapes( } else if dots != Dots::Match || found.is_some_and(|it| it >= 128) { break; } else if wanted == b'?' && glob.get(index + 1) != Some(&b'(') { - found.is_some_and(|it| !is_separator::(it)) + found.is_some_and(|it| !is_separator(it, is_windows)) } else if wanted == b'[' - && let Some((has, close, is_regular)) = ascii_class(glob, index, found.unwrap_or(0)) + && let Some((has, close, _)) = ascii_class::(glob, index, found.unwrap_or(0)) { + first_class = first_class.min(index); index = close; - if !is_regular { - seen = Seen::Odd; - } has && found.is_some() } else { break; @@ -616,23 +542,27 @@ fn match_escapes( negated, }; } + if first_class != usize::MAX && !has_regular_classes(&glob[first_class..index]) { + seen = Seen::Odd; + } let start = Start { glob_start: glob_start as u32, glob_index: index as u32, path_index: path_index as u32, seen, - dots, + mode: start.mode, }; - match WINDOWS { - true => match_for_windows(glob, shape, path, start, None), - false => match_for_posix(glob, shape, path, start, None), - } + match_more(glob, shape, path, start, None) } /// [`match_class`] for an ASCII character and a class that has neither escapes nor characters that aren't ASCII. /// Returns the result, the index of the `]`, and whether a regular pattern may have the class. Returns `None` for any other class. #[inline(always)] -fn ascii_class(glob: &[u8], open: usize, c: u8) -> Option<(bool, usize, bool)> { +fn ascii_class( + glob: &[u8], + open: usize, + c: u8, +) -> Option<(bool, usize, bool)> { let mut at = open + 1; let negated = matches!(glob.get(at), Some(b'^' | b'!')); at += usize::from(negated); @@ -655,16 +585,37 @@ fn ascii_class(glob: &[u8], open: usize, c: u8) -> Option<(bool, usize, bool)> { return None; } has |= low <= c && c <= high; - is_regular &= !(low <= b'/' && b'/' <= high) && !(low <= b'\\' && b'\\' <= high); + if ASKS_IF_REGULAR { + is_regular &= !(low <= b'/' && b'/' <= high) && !(low <= b'\\' && b'\\' <= high); + } at += 3; } else { has |= low == c; - is_regular &= low != b'/'; + if ASKS_IF_REGULAR { + is_regular &= low != b'/'; + } at += 1; } } } +/// `tokens` are tokens that [`match_escapes`] has taken. Returns whether a regular pattern may have the classes among them. +#[inline(never)] +fn has_regular_classes(tokens: &[u8]) -> bool { + let mut at = 0; + while let Some(&ch) = tokens.get(at) { + at = match ch { + b'\\' => at + 2, + b'[' => match ascii_class::(tokens, at, 0) { + Some((_, close, true)) => close + 1, + _ => return false, + }, + _ => at + 1, + }; + } + true +} + /// Result of [`Cache::answer`]. enum Answer { Yes, @@ -676,13 +627,9 @@ enum Answer { } /// Matches a pattern that has been matched often. This is where such a pattern spends its time. -#[inline(always)] -fn match_warm( - glob: &[u8], - shape: &Shape, - path: &[u8], - dots: Dots, -) -> MatchResult { +#[inline(never)] +fn match_warm(glob: &[u8], shape: &Shape, path: &[u8], mode: Mode) -> MatchResult { + let (dots, is_windows) = (mode.dots, mode.is_windows()); let parsed = shape.parsed(glob); let negated = parsed.top % 2 == 1; let found = |matched: bool| MatchResult { @@ -690,11 +637,14 @@ fn match_warm( negated, }; let mut is_ascii = true; - let Some(first) = parsed.take_front(glob, parsed.top, path, WINDOWS) else { + let Some(first) = parsed.take_front(glob, parsed.top, path, is_windows) else { return found(false); }; if dots == Dots::Match - && let Some(cache) = parsed.program.get().and_then(|it| it.warm_cache(WINDOWS)) + && let Some(cache) = parsed + .program + .get() + .and_then(|it| it.warm_cache(is_windows)) { match cache.answer(&path[first as usize..]) { Answer::Yes => return found(true), @@ -704,7 +654,7 @@ fn match_warm( } } let walked = match is_ascii || path.len() >= Program::IS_LONG { - true => match_at_once(glob, parsed.top, Some(shape), path, dots, WINDOWS), + true => match_at_once(glob, parsed.top, Some(shape), path, dots, is_windows), false => None, }; match walked { @@ -714,28 +664,24 @@ fn match_warm( let start = Start { glob_start: parsed.top, glob_index: parsed.top, - ..Start::of(dots) + ..Start::of(mode) }; - match WINDOWS { - true => match_for_windows(glob, Some(shape), path, start, None), - false => match_for_posix(glob, Some(shape), path, start, None), - } + match_more(glob, Some(shape), path, start, None) } } } /// With `resumed`, only runs the loop from that state on, and leaves the state and the outcome there. See [`match_slowly`]. -#[inline(always)] -fn match_more( +#[inline(never)] +fn match_more( glob: &[u8], shape: Option<&Shape>, path: &[u8], start: Start, resumed: Option<&mut State>, ) -> MatchResult { - let Start { - glob_start, dots, .. - } = start; + let glob_start = start.glob_start; + let (dots, is_windows) = (start.mode.dots, start.mode.is_windows()); let negated = glob_start % 2 == 1; let found = |matched: bool| MatchResult { matches: matched != negated, @@ -746,13 +692,14 @@ fn match_more( Some(it) => state = **it, None => { if path.len() >= Program::IS_LONG - && let Some(matched) = match_at_once(glob, glob_start, shape, path, dots, WINDOWS) + && let Some(matched) = + match_at_once(glob, glob_start, shape, path, dots, is_windows) { return found(matched); } } } - let outcome = run::(glob, glob_start, path, dots, &mut state, WINDOWS); + let outcome = run::(glob, glob_start, path, dots, &mut state, is_windows); if let Some(it) = resumed { state.outcome = outcome; *it = state; @@ -769,19 +716,13 @@ fn match_more( || path.len() >= Program::IS_LONG || it.known.get().is_some() }; - let resume: Resume = match WINDOWS { - true => match_for_windows, - false => match_for_posix, - }; - let shape = shape.filter(keeps); match_slowly( glob, glob_start, - shape, + shape.filter(keeps), path, - dots, + start.mode, state, - (WINDOWS, resume), ) } }; @@ -792,7 +733,8 @@ fn match_more( parsed.read(glob); debug_assert!( !parsed.is_regular - || matched == match_groups(glob, glob_start, path, &parsed, dots, false, WINDOWS), + || matched + == match_groups(glob, glob_start, path, &parsed, dots, false, is_windows), "the loop and the walk differ" ); } @@ -812,8 +754,7 @@ fn match_at_once( ) -> Option { let mut groups = Groups::new(shape); let parsed = groups.parsed(glob); - // A `Cache` has no steps for bytes that aren't ASCII. On those the loop is faster, if it can decide. - let walks = parsed.may_walk() && (path.len() >= Program::IS_LONG || path.is_ascii()); + let walks = parsed.may_walk(); walks.then(|| { match_groups( glob, @@ -827,9 +768,6 @@ fn match_at_once( }) } -/// [`match_for_posix`] or [`match_for_windows`]. -type Resume = fn(&[u8], Option<&Shape>, &[u8], Start, Option<&mut State>) -> MatchResult; - /// Goes on where the loop has stopped. What's found out about the pattern is kept in `shape`. #[cold] #[inline(never)] @@ -838,15 +776,15 @@ fn match_slowly( glob_start: u32, shape: Option<&Shape>, path: &[u8], - dots: Dots, + mode: Mode, mut state: State, - (is_windows, resume): (bool, Resume), ) -> bool { + let (dots, is_windows) = (mode.dots, mode.is_windows()); let mut groups = Groups::new(shape); let mut is_strict = false; let start = Start { glob_start, - ..Start::of(dots) + ..Start::of(mode) }; loop { let goes_on = match state.outcome { @@ -916,7 +854,7 @@ fn match_slowly( true => { state.outcome = run_strictly(glob, glob_start, path, dots, &mut state, is_windows) } - false => _ = resume(glob, None, path, start, Some(&mut state)), + false => _ = match_more(glob, None, path, start, Some(&mut state)), } } let is_kept = groups.prepared.is_some(); @@ -1229,10 +1167,23 @@ fn run( && state.path_index > 0 && (state.path_index as usize) < path.len() && let Some(&wanted) = glob.get(state.glob_index as usize + 3) - && !matches!(wanted, b'{' | b'@' | b'+') { let is_literal = IS_PLAIN[wanted as usize] || wanted >= 128; let mut start = state.path_index as usize; + let mut can_start = true; + if matches!(wanted, b'{' | b'@' | b'+') { + let skipped = skip_to_start( + glob, + state.glob_index + 3, + path, + state.path_index, + true, + &mut kept.firsts, + is_windows, + ); + start = skipped.path_index as usize; + can_start = skipped.can_start; + } let next = loop { let mut end = start; while let Some(&found) = path.get(end) @@ -1249,6 +1200,9 @@ fn run( state.path_index = start as u32; state.globstar.path_index = next as u32; state.wildcard = state.globstar; + if !can_start { + break 'fallthrough; + } continue 'main_loop; } // `**` can still match nothing, so it's handled below. @@ -1269,6 +1223,7 @@ fn run( { if !STRICT && state.seen != Seen::Odd + && !(kept.layout.open == state.glob_index && kept.layout.has_star) && let Some(taken) = take_plain_choice( glob, state.glob_index, @@ -1307,15 +1262,8 @@ fn run( path_index: state.path_index as usize, is_committed: state.wildcard.path_index == 0, is_windows, - matches: match is_windows { - true => |pattern, text| { - match_on::(pattern, text).matches() - }, - false => |pattern, text| { - match_on::(pattern, text).matches() - }, - }, }, + &mut kept.layout, ) { #[cfg(debug_assertions)] @@ -1556,12 +1504,24 @@ fn run( if (state.path_index as usize) < path.len() { // source unicode char to match against the target + its byte length in `path` let (c, len) = decode_rune_at(path, state.path_index as usize); - let class = match kept.class_at(glob, state.glob_index) { - Some(class) => Some((class.has(c), class.end)), - None => class_has_at(glob, state.glob_index, c), + // The first time, it isn't worth it to keep anything. + let (class, is_regular) = if kept.class_seen_at != state.glob_index + && c < 128 + && let Some((has, close, is_regular)) = ascii_class::( + glob, + state.glob_index as usize, + c as u8, + ) { + kept.class_seen_at = state.glob_index; + (Some((has, close as u32 + 1)), is_regular) + } else { + let class = match kept.class_at(glob, state.glob_index) { + Some(class) => Some((class.has(c), class.end)), + None => class_has_at(glob, state.glob_index, c), + }; + let is_kept = kept.class_index == state.glob_index; + (class, is_kept && kept.is_regular_class) }; - let is_regular = - kept.class_index == state.glob_index && kept.is_regular_class; let Some((has, end)) = class else { // An unclosed `[` is literal. see_odd_token!(); @@ -1723,9 +1683,9 @@ fn class_has_at(glob: &[u8], open: u32, c: u32) -> Option<(bool, u32)> { } /// [`match_class`] for callers outside the loop. -#[inline(never)] +#[inline] fn class_has(glob: &[u8], open: u32, c: u32) -> Option { - match_class(glob, open, c).map(|it| it.0) + class_has_at(glob, open, c).map(|it| it.0) } /// Counts the leading `!`s that negate the result. @@ -1973,8 +1933,6 @@ struct Text<'a> { /// The loop has no `*` or `**` to go back to. is_committed: bool, is_windows: bool, - /// [`match_on`] for the platform. - matches: fn(&[u8], &[u8]) -> bool, } /// Result of [`match_written_out`]. @@ -2017,71 +1975,142 @@ impl Written { } } -/// Matches without a [`Program`]: writes the pattern out once for each alternative of the group at `open`, and matches that. -/// It writes out the segment that the group is in, which has to be a [`Segment`]. If the group has a `/` or a `**`, it's the rest of -/// the pattern. Returns `None` unless that is short and has few groups and alternatives, which makes this the cheaper way. -#[inline(never)] -fn match_written_out(glob: &[u8], open: usize, text: &Text<'_>) -> Option { - if glob[open] != b'{' { - return None; - } - // Where the alternatives but the last one end, and where that one does. - let mut commas = [0; 4]; - let (mut count, mut close) = (0, 0); - // How many groups are open, which of those have had a `,`, and how many groups there have been. - let (mut depth, mut has_comma, mut groups) = (1, 0_u32, 1); - let (mut is_rest, mut has_globstar) = (false, false); - let mut end = open + 1; - while let Some(&ch) = glob.get(end) { - match ch { - _ if IS_TEXT[ch as usize] => {} - // The loop compares as many bytes as the first one announces. They must not go on into something else. - 0x80.. => { - let len = usize::from(strings::wtf8_byte_sequence_length(ch)); - if glob.get(end..end + len)?.iter().any(u8::is_ascii) { - return None; +/// What [`match_written_out`] has to know about the pattern. The loop keeps this for the last group that it has asked about. +#[derive(Copy, Clone)] +struct Layout { + /// Index of the group. + open: u32, + /// Index of its `}`, or 0 if it can't be written out. + close: u32, + /// Where its alternatives end, and how many there are besides the last one. + commas: [u32; 4], + count: u8, + /// Index where what's written out ends. + end: u32, + /// That's the end of the pattern, because the group has a `/` or a `**`. + is_rest: bool, + has_globstar: bool, + /// The group has a `*`, so it's no [`Choice`]. + has_star: bool, +} + +impl Layout { + const NONE: Layout = Layout { + open: u32::MAX, + close: 0, + commas: [0; 4], + count: 0, + end: 0, + is_rest: false, + has_globstar: false, + has_star: false, + }; + + #[inline(always)] + fn read(glob: &[u8], open: usize) -> Option { + if glob[open] != b'{' { + return None; + } + let mut commas = [0; 4]; + let (mut count, mut close) = (0, 0); + // How many groups are open, which of those have had a `,`, and how many groups there have been. + let (mut depth, mut has_comma, mut groups) = (1, 0_u32, 1); + let (mut is_rest, mut has_globstar, mut has_star) = (false, false, false); + let mut end = open + 1; + while let Some(&ch) = glob.get(end) { + match ch { + _ if IS_TEXT[ch as usize] => {} + // The loop compares as many bytes as the first one announces. They must not go on into something else. + 0x80.. => { + let len = usize::from(strings::wtf8_byte_sequence_length(ch)); + if glob.get(end..end + len)?.iter().any(u8::is_ascii) { + return None; + } + end += len - 1; } - end += len - 1; - } - b'/' if depth == 0 && !is_rest => break, - b'/' => is_rest = true, - b',' if depth > 0 => { - has_comma |= 1 << depth; - if close == 0 && depth == 1 { - *commas[..3].get_mut(count)? = end; - count += 1; + b'/' if depth == 0 && !is_rest => break, + b'/' => is_rest = true, + b',' if depth > 0 => { + has_comma |= 1 << depth; + if close == 0 && depth == 1 { + *commas[..3].get_mut(count)? = end as u32; + count += 1; + } } - } - b'{' if groups < 3 => { - groups += 1; - depth += 1; - has_comma &= !(1 << depth); - } - // With one alternative it may be a sequence like `{1..3}`. - b'}' if has_comma & 1 << depth != 0 => { - depth -= 1; - if close == 0 && depth == 0 { - close = end; + b'{' if groups < 3 => { + groups += 1; + depth += 1; + has_comma &= !(1 << depth); } - } - b'*' if glob.get(end + 1) == Some(&b'*') => { - (is_rest, has_globstar) = (true, true); - end += 1; - } - b'*' | b'?' => {} - b'[' => { - let (after, may_be_separator) = class_shape(glob, end)?; - if may_be_separator || glob[end + 1] == b'(' { - return None; + // With one alternative it may be a sequence like `{1..3}`. + b'}' if has_comma & 1 << depth != 0 => { + depth -= 1; + if close == 0 && depth == 0 { + close = end; + } + } + b'*' => { + has_star |= close == 0; + if glob.get(end + 1) == Some(&b'*') { + (is_rest, has_globstar) = (true, true); + end += 1; + } + } + b'?' => {} + b'[' => { + let (after, may_be_separator) = class_shape(glob, end)?; + if may_be_separator || glob[end + 1] == b'(' { + return None; + } + end = after - 1; } - end = after - 1; + _ => return None, } - _ => return None, + end += 1; } - end += 1; + // A `**` right after the group is never a whole segment. + if depth > 0 || glob[close + 1..].starts_with(b"**") { + return None; + } + commas[count] = close as u32; + Some(Layout { + open: open as u32, + close: close as u32, + commas, + count: count as u8, + end: end as u32, + is_rest, + has_globstar, + has_star, + }) } - // A `**` right after the group is never a whole segment. - if depth > 0 || is_rest && !text.is_committed || glob[close + 1..].starts_with(b"**") { +} + +/// Matches without a [`Program`]: writes the pattern out once for each alternative of the group at `open`, and matches that. +/// It writes out the segment that the group is in, which has to be a [`Segment`]. If the group has a `/` or a `**`, it's the rest of +/// the pattern. Returns `None` unless that is short and has few groups and alternatives, which makes this the cheaper way. +#[inline(never)] +fn match_written_out( + glob: &[u8], + open: usize, + text: &Text<'_>, + layout: &mut Layout, +) -> Option { + if layout.open != open as u32 { + *layout = Layout::read(glob, open).unwrap_or(Layout { + open: open as u32, + ..Layout::NONE + }); + } + let Layout { + commas, + count, + is_rest, + has_globstar, + .. + } = *layout; + let (close, end) = (layout.close as usize, layout.end as usize); + if close == 0 || is_rest && !text.is_committed { return None; } // The segment of the path that the loop is in. @@ -2122,11 +2151,10 @@ fn match_written_out(glob: &[u8], open: usize, text: &Text<'_>) -> Option) -> Option) -> Option bool { struct Firsts { /// Index of the group. index: u32, - /// It's a `{a,b}` or `@(a|b)` of plain literals. - is_literal: bool, /// The first byte of each alternative, and how many there are. 0 if there are too many, or if one is empty. count: u8, bytes: [u8; 8], @@ -2214,7 +2250,6 @@ struct Firsts { impl Firsts { const NONE: Firsts = Firsts { index: u32::MAX, - is_literal: false, count: 0, bytes: [0; 8], longest_at_end: None, @@ -2267,7 +2302,6 @@ impl Firsts { if start == first { return; } - self.is_literal = true; if has_firsts { self.count = count as u8; } @@ -2474,6 +2508,9 @@ impl AsciiClass { /// What the loop keeps about the tokens that it has looked at last, because it often comes back to them. struct Kept { firsts: Firsts, + layout: Layout, + /// Index of the last class that the loop has been at. + class_seen_at: u32, /// Index of `class`. class_index: u32, class: AsciiClass, @@ -2484,6 +2521,8 @@ struct Kept { impl Kept { const NOTHING: Kept = Kept { firsts: Firsts::NONE, + layout: Layout::NONE, + class_seen_at: u32::MAX, class_index: u32::MAX, class: AsciiClass { members: 0, @@ -2747,9 +2786,9 @@ pub struct Shape { } struct Known { - parsed: OnceLock, + parsed: Once, /// See [`Groups::segment_at`]. - segment: OnceLock>>, + segment: Once>>, } impl Shape { @@ -2772,8 +2811,8 @@ impl Shape { fn known(&self) -> &Known { self.known.get_or_init(|| { Arc::new(Known { - parsed: OnceLock::new(), - segment: OnceLock::new(), + parsed: Once::new(), + segment: Once::new(), }) }) } @@ -2912,12 +2951,10 @@ fn take_plain_choice( let is_optional = glob[open as usize] == b'?'; let is_separator = |it: u8| it == b'/' || is_windows && it == b'\\'; let rest = &path[path_index as usize..]; - // The lengths of the alternatives that `rest` starts with. `?(a)` has an empty one. - let mut lens = [0; 4]; - let mut count = usize::from(is_optional); - let (mut has_one_length, mut has_slash) = (!is_optional, false); - let mut shortest = if is_optional { 0 } else { usize::MAX }; - let mut first_end = 0; + // The lengths of the alternatives that `rest` starts with, one bit for each. `?(a)` has an empty one. + let mut lens = u64::from(is_optional); + let mut has_slash = false; + let (mut shortest, mut longest) = (if is_optional { 0 } else { usize::MAX }, 0); // Where the alternative being read starts. let mut start = first; let mut at = first; @@ -2933,14 +2970,10 @@ fn take_plain_choice( loop { let ch = *glob.get(at)?; if ch == separator || ch == closer { - if first_end == 0 { - first_end = at; - } - has_one_length &= at - start == first_end - first; shortest = shortest.min(at - start); + longest = longest.max(at - start); if is_start { - *lens.get_mut(count)? = at - start; - count += 1; + lens |= 1_u64.checked_shl((at - start) as u32)?; } at += 1; if ch == closer { @@ -2976,20 +3009,20 @@ fn take_plain_choice( if !is_optional && start == first { return None; } - let (lens, after) = (&lens[..count], at); + let after = at; let mut taken = None; match glob.get(after) { None => { - for &len in lens { - if len == rest.len() { - taken = Some(len); - } + if rest.len() < 64 && lens >> rest.len() & 1 == 1 { + taken = Some(rest.len()); } } Some(b'/') => { - for &len in lens { + while lens != 0 { + let len = lens.trailing_zeros() as usize; + lens &= lens - 1; if rest.get(len).is_none_or(|it| is_separator(*it)) { - if taken.is_some_and(|it| it != len) { + if taken.is_some() { return None; } taken = Some(len); @@ -2998,11 +3031,11 @@ fn take_plain_choice( } // What looks for the start of a segment of the pattern later on must not find that `/`. Some(_) if has_slash => return None, - Some(_) if has_one_length && first_end > first => taken = lens.first().copied(), + Some(_) if shortest == longest && shortest > 0 => taken = (lens != 0).then_some(shortest), Some(_) if pending == Pending::Star => return None, Some(b'*') if !matches!(glob.get(after + 1), Some(b'*' | b'(')) => { // The `*` makes up for what a shorter alternative leaves. - taken = lens.iter().copied().min(); + taken = (lens != 0).then(|| lens.trailing_zeros() as usize); // After a `*`, an alternative that starts further to the right may end further to the left. if pending == Pending::StarBefore && let Some(len) = taken @@ -3013,11 +3046,13 @@ fn take_plain_choice( } Some(_) if pending == Pending::StarBefore => return None, Some(_) => { - for &len in lens { + while lens != 0 { + let len = lens.trailing_zeros() as usize; + lens &= lens - 1; let mut index = 0; let fits = loop { match glob.get(after + index) { - Some(wanted) if IS_PLAIN[*wanted as usize] => { + Some(wanted) if IS_PLAIN[*wanted as usize] || *wanted >= 128 => { if rest.get(len + index) != Some(wanted) { break false; } @@ -3027,7 +3062,7 @@ fn take_plain_choice( } }; if fits { - if taken.is_some_and(|it| it != len) { + if taken.is_some() { return None; } taken = Some(len); @@ -3100,7 +3135,7 @@ fn take_fixed_choice( b'/' => has_slash = true, b'?' if glob.get(at) == Some(&b'(') => return None, b'[' => { - let (_, close, is_regular) = ascii_class(glob, at - 1, 0)?; + let (_, close, is_regular) = ascii_class::(glob, at - 1, 0)?; if !is_regular { return None; } @@ -3119,7 +3154,7 @@ fn take_fixed_choice( b'/' => is_separator(found), b'?' | b'[' if found >= 128 => return None, b'?' => !is_separator(found), - b'[' => ascii_class(glob, at - 1, found)?.0, + b'[' => ascii_class::(glob, at - 1, found)?.0, _ => found == ch, }; if is_same { @@ -3211,7 +3246,7 @@ fn take_fixed_choice( let mut index = 0; let fits = loop { match glob.get(after + index) { - Some(wanted) if IS_PLAIN[*wanted as usize] => { + Some(wanted) if IS_PLAIN[*wanted as usize] || *wanted >= 128 => { if rest.get(len + index) != Some(wanted) { break false; } @@ -3319,8 +3354,6 @@ struct Parsed { is_matched_often: bool, /// Number of `*`, `**`, `?` and classes, up to 2. wildcards: u8, - /// A [`Cache`] has no steps for bytes that aren't ASCII. - is_ascii: bool, /// All it has besides literal bytes is a `*` or `**` at the end, which the loop doesn't have to look at the path for. is_decided_at_once: bool, /// Index after the leading `!`s. @@ -3331,7 +3364,7 @@ struct Parsed { /// Index in `alternatives` of the group that starts there, if one does. first_group: u32, /// Made when it's first needed. - program: OnceLock>, + program: Once>, } /// A group that [`Parsed::read`] hasn't seen the end of. @@ -3372,19 +3405,17 @@ impl Parsed { is_one_segment: false, is_matched_often: false, wildcards: 0, - is_ascii: true, is_decided_at_once: false, top: 0, front_end: 0, first_group: u32::MAX, - program: OnceLock::new(), + program: Once::new(), } } /// Returns true if a [`Walk`] gives the same answers as the loop, and is faster once it has a [`Cache`]. fn may_walk(&self) -> bool { self.is_regular - && self.is_ascii && !self.is_decided_at_once && (self.has_group || self.has_choice || self.wildcards >= 1) } @@ -3572,7 +3603,6 @@ impl Parsed { self.is_one_segment = is_one_segment; self.has_choice = has_choice; self.wildcards = wildcards.min(2) as u8; - self.is_ascii = glob.is_ascii(); let first = strings::index_of_any(glob, b"*?["); self.is_decided_at_once = !has_group && !has_choice @@ -3742,8 +3772,8 @@ fn take_choice( /// Returns true if `c` is a path separator. #[inline(always)] -fn is_separator(c: u8) -> bool { - c == b'/' || WINDOWS && c == b'\\' +fn is_separator(c: u8, is_windows: bool) -> bool { + c == b'/' || is_windows && c == b'\\' } #[inline(always)] @@ -3937,7 +3967,7 @@ struct Program { /// How many walks it has been through without a cache. walks: AtomicU8, /// A small cache, and a large one for when that is full. - caches: [OnceLock>>; 2], + caches: [Once>>; 2], } /// A group that [`Program::read`] hasn't seen the end of. @@ -4123,7 +4153,7 @@ impl Program { sequences: Vec::new(), start: 0, walks: AtomicU8::new(0), - caches: [OnceLock::new(), OnceLock::new()], + caches: [Once::new(), Once::new()], } } @@ -4506,7 +4536,7 @@ impl Program { self.walks.store(walks + 1, Ordering::Relaxed); return None; } - // Few patterns get to more than 40 states on the paths of a real tree. A small cache is about 3 KB, a large one 30. + // Few patterns get to more than 40 states on the paths of a real tree. A small cache is about 4 KB, a large one 35. let states = if is_large { 320 } else { 40 }; Some( self.caches[usize::from(is_large)] @@ -4942,12 +4972,10 @@ impl Walk { *set |= globstars & masks.trailing_skips; } } - // After a byte that isn't ASCII, a character that starts further ahead may still go on with an earlier set. - let sets = if byte.is_some_and(|it| it >= 128) { - 4 - } else { - 1 - }; + // No character goes on over a byte that starts one. Inside one, a character that starts further ahead may still go on + // with an earlier set. + let starts_character = !matches!(byte, Some(0x80..=0xBF)); + let sets = if starts_character { 1 } else { 4 }; if walk.sets[..sets] .iter() .all(|it| it.words().iter().all(|it| *it == 0)) @@ -4959,7 +4987,8 @@ impl Walk { } if let Some(it) = cache && (walk.index == on.first - || on.path[walk.index - 1] < 128 && !on.is_closed_at(walk.index - 1)) + || (on.path[walk.index - 1] < 128 || it.has_characters && starts_character) + && !on.is_closed_at(walk.index - 1)) { match it.state_of(walk.sets[0].words()) { Some(state) => return Ok((state, walk.index)), @@ -4975,16 +5004,23 @@ impl Walk { /// set for an index follows from that byte and the set for the next index, so each such step is worked out once per pattern. /// After that it's one lookup. /// +/// The same holds for a well-formed character of several bytes and the set for the index after it. Its last bytes lead through +/// states that stand for no set, only for the state and the bytes that they come from. +/// /// Threads share it without a lock. Each entry of `steps` is complete in itself, and a state is never changed or removed. /// Two threads may add the same set as two states, which does no harm. When `states` is full, new steps are worked out each time. struct Cache { is_windows: bool, + /// It has steps for characters that aren't ASCII. + has_characters: bool, /// For each byte, where its entry is in the part of `steps` that belongs to a state. columns: [u8; 256], /// For each state: whether the pattern matches if the path starts here (`NO`, `YES`), then one entry per column: /// the state before that byte. A state is written as the index of its first column. 0 means not known yet. + /// For a state inside a character, the first entry is `INSIDE` plus 4 times the state after the character plus the number of + /// bytes between the two. steps: Box<[AtomicU32]>, - states: Box<[OnceLock>]>, + states: Box<[Once>]>, /// Number of states that are taken. taken: AtomicU32, is_full: AtomicBool, @@ -4998,6 +5034,7 @@ impl Cache { const UNKNOWN: u32 = 0; const NO: u32 = 1; const YES: u32 = 2; + const INSIDE: u32 = 3; /// The state of the empty set, which is added first. const NOTHING: u32 = 1; /// Column of the bytes that always need a `Walk`. Its entries stay `UNKNOWN`. @@ -5009,48 +5046,78 @@ impl Cache { if !program.sequences.is_empty() || program.masks.len() > 4 { return None; } + let on = program.on(glob, b"", 0, is_windows, Dots::Match); + // All characters that aren't ASCII are alike to a class or an escape that has none of them. + let has_characters = on.pattern.iter().enumerate().all(|(node, _)| { + let (masks, bit) = (&program.masks[node / 64], 1 << (node % 64)); + let end = match () { + _ if masks.classes & bit != 0 => class_end(on.pattern, node).unwrap_or(node), + _ if masks.escapes & bit != 0 => (node + 2).min(on.pattern.len()), + _ => node, + }; + on.pattern[node..end].is_ascii() + }); // Bytes that no node tells apart share a column. let mut columns = [Cache::NOT_ASCII; 256]; columns[..128].fill(1); let mut count = 2_u8; + // These ranges are all that it takes to tell whether a sequence of bytes is well-formed. + for range in [ + 0x80..=0x8F, + 0x90..=0x9F, + 0xA0..=0xBF, + 0xC2..=0xDF, + 0xE0..=0xE0, + 0xE1..=0xEC, + 0xED..=0xED, + 0xEE..=0xEF, + 0xF0..=0xF0, + 0xF1..=0xF3, + 0xF4..=0xF4, + ] { + if has_characters { + columns[range].fill(count); + count += 1; + } + } let mut split = |is_member: &dyn Fn(u8) -> bool| { // For each column, the new column of its members. - let mut moved = [0_u8; 130]; - let mut has_others = [false; 130]; - for byte in 0..128_u8 { + let mut moved = [0_u8; 256]; + let mut has_others = [false; 256]; + for byte in 0..=255_u8 { has_others[columns[byte as usize] as usize] |= !is_member(byte); } - for byte in (0..128_u8).filter(|it| is_member(*it)) { + for byte in 0..=255_u8 { let column = columns[byte as usize] as usize; - if has_others[column] { + if column != usize::from(Cache::NOT_ASCII) && has_others[column] && is_member(byte) + { if moved[column] == 0 { moved[column] = count; - count += 1; + count = count.checked_add(1)?; } columns[byte as usize] = moved[column]; } } + Some(()) }; - split(&|byte| byte == b'/' || is_windows && byte == b'\\'); - let mut seen = [false; 128]; - let on = program.on(glob, b"", 0, is_windows, Dots::Match); + split(&|byte| byte == b'/' || is_windows && byte == b'\\')?; + let mut seen = [false; 256]; for (node, &wanted) in on.pattern.iter().enumerate() { let (masks, bit) = (&program.masks[node / 64], 1 << (node % 64)); if (masks.classes | masks.escapes) & bit != 0 { - split(&|byte| on.takes(node, byte, u32::from(byte))); - } else if masks.bytes & bit != 0 - && wanted < 128 - && !std::mem::replace(&mut seen[wanted as usize], true) + split(&|byte| byte < 128 && on.takes(node, byte, u32::from(byte)))?; + } else if masks.bytes & bit != 0 && !std::mem::replace(&mut seen[wanted as usize], true) { - split(&|byte| byte == wanted); + split(&|byte| byte == wanted)?; } } let stride = u32::from(count) + 1; let mut cache = Box::new(Cache { is_windows, + has_characters, columns, steps: (0..states * stride).map(|_| AtomicU32::new(0)).collect(), - states: (0..states).map(|_| OnceLock::new()).collect(), + states: (0..states).map(|_| Once::new()).collect(), taken: AtomicU32::new(0), is_full: AtomicBool::new(false), stride, @@ -5065,7 +5132,7 @@ impl Cache { fn state_of(&self, set: &[u64]) -> Option { let name = |index: usize| index as u32 * self.stride + 1; let taken = (self.states.len()).min(self.taken.load(Ordering::Relaxed) as usize); - let is_set = |it: &OnceLock>| it.get().is_some_and(|it| **it == *set); + let is_set = |it: &Once>| it.get().is_some_and(|it| **it == *set); if let Some(index) = self.states[..taken].iter().position(is_set) { return Some(name(index)); } @@ -5076,8 +5143,7 @@ impl Cache { let index = self.taken.fetch_add(1, Ordering::Relaxed) as usize; self.states .get(index)? - .set(SmallVec::from_slice(set)) - .ok()?; + .get_or_init(|| SmallVec::from_slice(set)); let answer = if set[0] & 1 != 0 { Cache::YES } else { @@ -5098,15 +5164,15 @@ impl Cache { if state <= Cache::NOTHING { return match state { Cache::NOTHING => Answer::No, - _ if *byte >= 128 => Answer::NotAscii, + _ if *byte >= 128 && !self.has_characters => Answer::NotAscii, _ => Answer::Unknown, }; } } match steps[state as usize - 1].load(Ordering::Relaxed) { - Cache::UNKNOWN => Answer::Unknown, Cache::YES => Answer::Yes, - _ => Answer::No, + Cache::NO => Answer::No, + _ => Answer::Unknown, } } @@ -5139,7 +5205,7 @@ impl Cache { index -= 1; } let answer = self.steps[state as usize - 1].load(Ordering::Relaxed); - if index == first && answer != Cache::UNKNOWN { + if index == first && matches!(answer, Cache::NO | Cache::YES) { return answer == Cache::YES; } } @@ -5171,6 +5237,7 @@ impl Cache { && (byte == b'.' || index > 1 && path[index - 2] == b'.') && on.is_closed_at(index - 1) { + (state, index) = self.after_character(state, index); match self.leave(on, Some(state), index) { Ok(found) => (state, index) = found, Err(answer) => return answer, @@ -5184,9 +5251,50 @@ impl Cache { index -= 1; } match self.steps[state as usize - 1].load(Ordering::Relaxed) { - Cache::UNKNOWN => on.walk(), - answer => answer == Cache::YES, + Cache::YES => true, + Cache::NO => false, + _ => on.walk(), + } + } + + /// For a state inside a character, returns the state and the index after the character. + fn after_character(&self, state: u32, index: usize) -> (u32, usize) { + match self.steps[state as usize - 1].load(Ordering::Relaxed) { + ..Cache::INSIDE => (state, index), + it => ( + (it - Cache::INSIDE) / 4, + index + ((it - Cache::INSIDE) % 4) as usize, + ), + } + } + + /// Remembers that `character` leads from the state `after` to the state `before`. + fn add_character(&self, after: u32, character: &[u8], before: u32) -> Option<()> { + let (first, others) = character.split_first()?; + let mut state = after; + for (count, byte) in others.iter().rev().enumerate() { + let step = &self.steps[state as usize + self.columns[*byte as usize] as usize]; + state = match step.load(Ordering::Relaxed) { + Cache::UNKNOWN => { + let index = self.taken.fetch_add(1, Ordering::Relaxed); + if index as usize >= self.states.len() { + self.is_full.store(true, Ordering::Relaxed); + return None; + } + let inside = index * self.stride + 1; + self.steps[inside as usize - 1].store( + Cache::INSIDE + after * 4 + count as u32 + 1, + Ordering::Relaxed, + ); + step.store(inside, Ordering::Relaxed); + inside + } + inside => inside, + }; } + self.steps[state as usize + self.columns[*first as usize] as usize] + .store(before, Ordering::Relaxed); + Some(()) } /// Takes a step that isn't in `steps` from `state` at `index`, or from the end of the path. @@ -5206,6 +5314,14 @@ impl Cache { self.steps[state as usize + self.columns[byte as usize] as usize] .store(found.0, Ordering::Relaxed); } + let character = &on.path[found.1..index]; + if byte >= 128 + && self.has_characters + && on.dots == Dots::Match + && std::str::from_utf8(character).is_ok_and(|it| it.chars().count() == 1) + { + self.add_character(state, character, found.0); + } Ok(found) } } diff --git a/src/lint/driver/discover.rs b/src/lint/driver/discover.rs index 58e5371c7d92..09e79dbb7c49 100644 --- a/src/lint/driver/discover.rs +++ b/src/lint/driver/discover.rs @@ -360,7 +360,8 @@ fn search( Some(seen) if Arc::ptr_eq(&own, &directory.inherited) => seen, _ => &own.config.see(&directory.path), }; - let mut within = own.config.within(&directory.path, seen); + let within = own.config.within(&directory.path, seen); + let mut within = within.outside_of_react(!is_of_react); for mut entry in entries { let path = paths::join(&directory.path, &entry.name); let (mut real, mut is_loop) = (None, false); diff --git a/src/lint/driver/fmt/config.rs b/src/lint/driver/fmt/config.rs index 3edf0c53ce00..45571aaadd4a 100644 --- a/src/lint/driver/fmt/config.rs +++ b/src/lint/driver/fmt/config.rs @@ -8,6 +8,7 @@ //! `.editorconfig` counts, and which files are ignored (`files.rs`). use super::cli::{Options, Precedence}; +use super::tailwind::SetBy; use super::{editorconfig, tailwind}; use crate::configs::NAMES_OF_VITE; use crate::gitignore::{self, Chain}; @@ -155,6 +156,8 @@ struct Override { struct Plugins { /// One of them is not built in: its options are not known here. names_others: bool, + /// One of them is `prettier-plugin-tailwindcss`. + sorts_classes: bool, /// Those that may print what this formatter formats in another way. missing: Vec>, /// How the names of the files end that they add to what Prettier reads. @@ -219,14 +222,31 @@ fn patterns_of<'t>(all: impl Iterator, is_oxfmt: bool) -> Vec

    bool { let name = package_of(name); - name.ends_with(b"/prettier-plugin-sort-imports") || name == b"prettier-plugin-organize-imports" + name.ends_with(b"/prettier-plugin-sort-imports") + || name == b"prettier-plugin-organize-imports" + || name == PLUGIN_FOR_CLASSES +} + +/// What an element of `plugins` names. One that is no string is a module that a configuration in JavaScript has imported: +/// the plugin that sorts classes is known by its options. +fn name_of(plugin: &Json) -> Option<&[u8]> { + let is_for_classes = || plugin.get(b"options")?.get(b"tailwindConfig"); + plugin + .as_str() + .or_else(|| is_for_classes().map(|_| PLUGIN_FOR_CLASSES)) +} + +fn is_plugin_for_classes(name: &[u8]) -> bool { + package_of(name) == PLUGIN_FOR_CLASSES } fn is_built_in(plugin: &Json) -> bool { - plugin.as_str().is_some_and(is_built_in_plugin) + name_of(plugin).is_some_and(is_built_in_plugin) } /// Plugins that add a language to Prettier and leave the others as they are, with how the names of its files end. @@ -278,7 +298,7 @@ pub(super) fn is_missing_plugin(name: &[u8]) -> bool { } /// `value` as Prettier shows it in a warning: `["a", 1]`, `{ a: true }`. -fn write_as_shown(out: &mut Vec, value: &Json) { +pub(super) fn write_as_shown(out: &mut Vec, value: &Json) { let separate = |out: &mut Vec, index: usize| { if index > 0 { out.extend_from_slice(b", "); @@ -345,6 +365,14 @@ fn settings(json: &Json, is_oxfmt: bool) -> Settings { for (name, value) in json.as_object().unwrap_or_default() { let is_option = OPTIONS.contains(&&name[..]); let text = match value { + // As JSON, a string too: the plugin that sorts classes refuses a value of the wrong type. + Json::Bool(_) | Json::Number(_) | Json::String(_) + if !is_oxfmt && name.starts_with(b"tailwind") => + { + let mut text = Vec::new(); + write_json(&mut text, value); + text + } Json::String(text) if is_option && !OPTIONS_WITH_STRINGS.contains(&&name[..]) => { [b"\"", &text[..], b"\""].concat() } @@ -735,14 +763,15 @@ impl Plugins { let plugins = json.get(b"plugins")?.as_array()?; Some(Plugins { names_others: !plugins.iter().all(is_built_in), - // One that is not a name is an object that a configuration in JavaScript has imported. + sorts_classes: (plugins.iter().filter_map(name_of)).any(is_plugin_for_classes), missing: (plugins.iter()) - .map(|it| it.as_str().unwrap_or(b"(an object)")) + .map(|it| name_of(it).unwrap_or(b"(an object)")) .filter(|it| is_missing_plugin(it)) .map(<[u8]>::to_vec) .collect(), packages: (plugins.iter().filter_map(Json::as_str)) .filter(|it| !it.starts_with(b".") && !it.starts_with(b"/")) + .filter(|it| !it.starts_with(b"file:")) .map(<[u8]>::to_vec) .collect(), endings: (plugins.iter()) @@ -1513,6 +1542,12 @@ impl<'c> Configs<'c> { || !(self.options.plugins.iter()).all(|it| is_built_in_plugin(it)) } + /// Whether the config or `--plugin` names `prettier-plugin-tailwindcss` for the file at `path`, which has `scope`. + fn names_plugin_for_classes(&self, scope: &Scope, path: Option<&[u8]>) -> bool { + (self.plugins_for(scope, path)).is_some_and(|it| it.sorts_classes) + || (self.options.plugins.iter()).any(|it| is_plugin_for_classes(it)) + } + /// See [`Plugins::endings`], for the file at `path`, which has `scope`. fn endings_of_plugins<'s>( &'s self, @@ -1706,7 +1741,18 @@ impl<'c> Configs<'c> { // Prettier knows the options of the plugins that it has loaded. let knows_all_options = !is_oxfmt && !self.names_other_plugins(scope, path); let mut unknown: Vec<(&[u8], &[u8])> = Vec::new(); + // `sortTailwindcss` as the options of the plugin have it. + let mut of_plugin = + (!is_oxfmt && self.names_plugin_for_classes(scope, path)).then(Vec::new); for (name, value) in all { + if let Some(entries) = &mut of_plugin + && let Some(entry) = tailwind::option_of_plugin(name, value) + { + let entry = entry.map_err(Fatal)?; + entries.retain(|it: &(Vec, Json)| it.0 != entry.0); + entries.push(entry); + continue; + } match name { SORT_IMPORTS_OF_BUN => _ = sort.set(name, value), // An override of oxfmt changes the keys that it has. `experimentalSortImports` is the old name. @@ -1774,7 +1820,8 @@ impl<'c> Configs<'c> { } for (name, value) in sort.of_plugins_not_named().chain(unknown) { // A string and what else there is in JSON are told apart by their looks here. - let json = json::parse(value).filter(|it| !matches!(it, Json::String(_))); + let is_json = name.starts_with(b"tailwind"); + let json = json::parse(value).filter(|it| is_json || !matches!(it, Json::String(_))); let mut shown = Vec::new(); write_as_shown( &mut shown, @@ -1788,10 +1835,26 @@ impl<'c> Configs<'c> { } } resolved.options.sort_imports = self.sort_imports(sort)?; - if let Some(value) = sort_tailwindcss.filter(|it| it != b"false") { - // Paths in an oxfmt config are relative to that file. Paths in `[fmt]` are relative to the working directory. - let of_config = (config.filter(|it| it.is_oxfmt)).map(|it| paths::dirname(&it.path)); - let places = (of_config, directory); + // A flag or `[fmt]` counts for more than the plugin. + let (value, set_by) = match (sort_tailwindcss, of_plugin, config) { + (Some(value), _, Some(config)) if config.is_oxfmt => { + (value, SetBy::Oxfmt(paths::dirname(&config.path))) + } + (Some(value), ..) => (value, SetBy::Bun), + (None, Some(entries), _) => { + if let Some(warning) = tailwind::warning_of_plugin(&entries) { + self.warn(&[&warning]); + } + let mut value = Vec::new(); + write_json(&mut value, &Json::Object(entries)); + // Not the file that `--config` names. + let found = scope.config.as_ref().map(|it| paths::dirname(&it.path)); + (value, SetBy::Plugin(found.unwrap_or(&self.environment.cwd))) + } + (None, None, _) => (b"false".to_vec(), SetBy::Bun), + }; + if value != b"false" { + let places = (set_by, directory); let tailwind = tailwind::for_file(&self.classes, self.environment, &value, places); resolved.options.tailwind = Some(Arc::new(tailwind)); } diff --git a/src/lint/driver/fmt/doctor.rs b/src/lint/driver/fmt/doctor.rs index 067af14873d9..924daa35cf84 100644 --- a/src/lint/driver/fmt/doctor.rs +++ b/src/lint/driver/fmt/doctor.rs @@ -131,7 +131,10 @@ pub(super) fn report( { Some(Need::SkippedFiles) } else if let Some(file) = &file { - (loaded.iter().any(|it| it == name)).then(|| Need::Configuration(file.clone())) + let is_asked = + name == b"prettier-plugin-tailwindcss" && configs.classes.asks_plugin(); + (is_asked || loaded.iter().any(|it| it == name)) + .then(|| Need::Configuration(file.clone())) } else { (is_oxfmt && matches!(name, b"oxfmt" | b"vite-plus")).then_some(Need::Defaults) } @@ -246,11 +249,6 @@ pub(super) fn report( write_count(&mut out, colors, files.left_for_plugins, &what); let hint = "Use --allow-unsupported to format them without it"; pretty!(&mut out, colors, " {}\n", hint); - if is_not_supported(b"prettier-plugin-tailwindcss") { - let hint = - "Or replace prettier-plugin-tailwindcss with [fmt.sortTailwindcss] in bunfig.toml"; - pretty!(&mut out, colors, " {}\n", hint); - } } let rows: [(&str, &[Vec]); 5] = [ ("Built-in plugins:", &plugins.built_in), diff --git a/src/lint/driver/fmt/mod.rs b/src/lint/driver/fmt/mod.rs index b7db72d5d7b7..2852a6f6e43c 100644 --- a/src/lint/driver/fmt/mod.rs +++ b/src/lint/driver/fmt/mod.rs @@ -860,6 +860,8 @@ struct Run<'r> { modules: Option<&'r Modules>, /// [`Configs::is_native`], once that's known. If true, messages use Bun's own wording. is_native: bool, + /// Some of the files that were found have not been looked at, so nothing can be said about all of them. + has_left_files: bool, } /// What the file at `path`, which has `scope`, is to the tool that the run stands in for. @@ -929,7 +931,9 @@ impl Run<'_> { } else if self.options.list_different { return; } else if self.options.check && different == 0 { - self.log(b"All matched files use the correct format."); + if !self.has_left_files { + self.log(b"All matched files use the correct format."); + } } else if self.options.check { self.log(b""); let text = format!( @@ -968,7 +972,8 @@ impl Run<'_> { let _ = writeln!(out, "Could not check {failed} {}.", files(failed)); } // If not every file is formatted, the reason has already been printed. - if different + failed > 0 || self.out.exit_code != 0 || level < LogLevel::Log { + let is_all = different + failed == 0 && self.out.exit_code == 0 && !self.has_left_files; + if !is_all || level < LogLevel::Log { return; } match unchanged { @@ -1747,6 +1752,10 @@ impl Run<'_> { format!("{n} files") } }; + // Why is said at the end. + self.has_left_files = left_for_plugins.0 > 0 + || !others.is_empty() + || (classes_are_unknown.is_some() && !options.allow_unsupported); // oxfmt has no `--debug-check`, so there it is as without a config. if is_oxfmt && (options.check || !options.debug_check) { self.sum_up_as_oxfmt([different, unchanged, failed], pool.threads()); @@ -1764,7 +1773,9 @@ impl Run<'_> { .as_bytes(), ); } else if different == 0 { - self.log(b"All matched files use Prettier code style!"); + if !self.has_left_files { + self.log(b"All matched files use Prettier code style!"); + } } else if only_looks { self.warn( format!( @@ -2050,6 +2061,7 @@ fn run_in(options: &Options, environment: &Environment, modules: Option<&Modules began: Instant::now(), modules, is_native: false, + has_left_files: false, }; run.execute() } diff --git a/src/lint/driver/fmt/tailwind.js b/src/lint/driver/fmt/tailwind.js index 44a602933894..a5483a95cd2c 100644 --- a/src/lint/driver/fmt/tailwind.js +++ b/src/lint/driver/fmt/tailwind.js @@ -1,5 +1,5 @@ // Prints where Tailwind CSS puts the classes that it is asked about. The question is read from standard input: -// { groups: [{ root, config, stylesheet, classes }] }. `root`: the directory of the package. +// { groups: [{ root, config, stylesheet, isBundled, classes }] }. `root`: the directory of the package. // // Where it puts a class does not depend on what else it is asked about. The numbers that it answers with do. // @@ -104,19 +104,43 @@ async function loadV3(root, config) { }); } +/** The Tailwind CSS that prettier-plugin-tailwindcss, which is in `root`, brings along. It sorts, and does not say where it puts a class. */ +async function loadBundled(root, config, stylesheet) { + const from = root.slice(0, root.lastIndexOf("node_modules")); + const { createSorter } = await import( + pathToFileURL(Bun.resolveSync("prettier-plugin-tailwindcss/sorter", from)).href + ); + // No package is found from there, so it takes its own. + const where = { base: "/", filepath: "/a", configPath: config ?? undefined, stylesheetPath: stylesheet ?? undefined }; + const sort = (await createSorter(where)).sortClassLists; + return classes => { + // Of a class that it knows it leaves one of two. + const isKnown = sort(classes.map(it => [it, it])).map(it => it.length === 1); + const [known] = sort([classes.filter((_, index) => isKnown[index])]); + // Two that stay as they are either way are in the same place. + const swapped = sort(known.slice(1).map((it, index) => [it, known[index]])); + let order = 0; + const orders = new Map(known.map((it, index) => [it, index > 0 && swapped[index - 1][0] !== it ? ++order : order])); + return classes.map(it => [it, orders.get(it) ?? null]); + }; +} + const loaded = new Map(); -function orderFor({ root, config, stylesheet }) { +function orderFor({ root, config, stylesheet, isBundled }) { const isV4 = fs.existsSync(join(root, "theme.css")); if (stylesheet) config = null; - if (isV4 ? config : stylesheet) { + if (!isBundled && (isV4 ? config : stylesheet)) { const [option, major] = isV4 ? ["config", 3] : ["stylesheet", 4]; throw new Error(`\`${option}\` only works with Tailwind CSS ${major}, and ${root} is another version`); } const key = JSON.stringify([root, config, stylesheet]); if (!loaded.has(key)) { const load = async () => { + if (isBundled) return loadBundled(root, config, stylesheet); if (!isV4) return loadV3(root, config); - const tailwind = await import(pathToFileURL(Bun.resolveSync("tailwindcss", dirname(dirname(root)))).href); + const at = root.lastIndexOf("node_modules"); + const name = root.slice(at + "node_modules/".length); + const tailwind = await import(pathToFileURL(Bun.resolveSync(name, root.slice(0, at))).href); return loadV4(tailwind, stylesheet ?? join(root, "theme.css")); }; loaded.set(key, load()); diff --git a/src/lint/driver/fmt/tailwind.rs b/src/lint/driver/fmt/tailwind.rs index 05b3a6b5ceb5..e760edc17594 100644 --- a/src/lint/driver/fmt/tailwind.rs +++ b/src/lint/driver/fmt/tailwind.rs @@ -21,11 +21,13 @@ use std::sync::Arc; use std::sync::atomic::AtomicBool; /// Which Tailwind is asked: the package in the directory `root`, loaded with one of these files. -#[derive(Clone, PartialEq, Eq, Hash)] +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] struct Which { root: Vec, config: Option>, stylesheet: Option>, + /// `root` is `prettier-plugin-tailwindcss`, and the Tailwind that it brings along is asked. + is_bundled: bool, } impl Which { @@ -35,6 +37,7 @@ impl Which { (b"root".to_vec(), Json::String(self.root.clone())), (b"config".to_vec(), path(&self.config)), (b"stylesheet".to_vec(), path(&self.stylesheet)), + (b"isBundled".to_vec(), Json::Bool(self.is_bundled)), ] } @@ -50,21 +53,23 @@ impl Which { struct Found { /// The directory of the package `tailwindcss`. root: Vec, - /// For Tailwind CSS 3: the nearest `tailwind.config.*`. + is_version_4: bool, + /// Unless it is version 4: the nearest `tailwind.config.*`. config: Option>, } -fn find(directory: &[u8]) -> Option { +/// `package`: what Tailwind CSS is called. +fn find(directory: &[u8], package: &[u8]) -> Option { // Only another package of the workspace may depend on it. pnpm and Bun's isolated linker still have one place for all // packages of an installation. let places: [&[u8]; 3] = [ - b"node_modules/tailwindcss", - b"node_modules/.pnpm/node_modules/tailwindcss", - b"node_modules/.bun/node_modules/tailwindcss", + b"node_modules/", + b"node_modules/.pnpm/node_modules/", + b"node_modules/.bun/node_modules/", ]; let root = places.iter().find_map(|place| { paths::ancestors(directory) - .map(|it| paths::join(it, place)) + .map(|it| paths::join(it, &[place, package].concat())) .find(|it| fs::is_file(&paths::join(it, b"package.json"))) })?; let names: [&[u8]; 4] = [ @@ -73,13 +78,18 @@ fn find(directory: &[u8]) -> Option { b"tailwind.config.mjs", b"tailwind.config.ts", ]; - let config = match fs::is_file(&paths::join(&root, b"theme.css")) { + let is_version_4 = fs::is_file(&paths::join(&root, b"theme.css")); + let config = match is_version_4 { true => None, false => paths::ancestors(directory) .flat_map(|it| names.map(|name| paths::join(it, name))) .find(|it| fs::is_file(it)), }; - Some(Found { root, config }) + Some(Found { + root, + is_version_4, + config, + }) } /// The version of the package `name` that is loaded from `directory`. @@ -120,10 +130,10 @@ fn key_of(class: &[u8]) -> Cow<'_, [u8]> { #[derive(Default)] struct Known { + /// For a package of another name than `tailwindcss`, that is behind the directory, and a NUL. by_directory: FxHashMap, Option>, groups: FxHashMap, - /// The first, in the order of their names, of the directories with files that have classes from which there is no - /// Tailwind to be found. + /// Why there is no Tailwind for files that have classes: the first in the order of the texts, which end with a directory. without_package: Option>, } @@ -136,7 +146,7 @@ pub(crate) struct Classes { /// [`Classes`], for the files of a directory. struct OfGroup { classes: Arc, - /// `Err`: the directory. There is no Tailwind for it. + /// `Err`: why there is no Tailwind for it. which: Result>, } @@ -154,13 +164,9 @@ impl Orders for OfGroup { let mut known = self.classes.known.lock(); let which = match &self.which { Ok(which) => which, - Err(directory) => { - if known - .without_package - .as_ref() - .is_none_or(|it| directory < it) - { - known.without_package = Some(directory.clone()); + Err(why) => { + if known.without_package.as_ref().is_none_or(|it| why < it) { + known.without_package = Some(why.clone()); } return None; } @@ -238,15 +244,86 @@ const FUNCTIONS: [&[u8]; 9] = [ b"tv", ]; -/// Resolves `sortTailwindcss` for a file in `directory`. `value` is the option as JSON, and it isn't `false`. `of_config` is -/// the directory of the oxfmt config that sets it, or `None` if `[fmt]` or the flag sets it. +/// What turns the sorting on. +#[derive(Clone, Copy)] +pub(crate) enum SetBy<'a> { + /// `[fmt]` or the flag. Paths are relative to the working directory. + Bun, + /// An oxfmt config in this directory, which paths are relative to. + Oxfmt(&'a [u8]), + /// `prettier-plugin-tailwindcss` in a Prettier config. Paths are relative to this directory. + Plugin(&'a [u8]), +} + +const PLUGIN: &[u8] = b"prettier-plugin-tailwindcss"; + +/// The options of `prettier-plugin-tailwindcss` and what they are called in `sortTailwindcss`: four strings, two lists of +/// strings, two that are on or off. +const OPTIONS_OF_PLUGIN: [(&[u8], &[u8]); 8] = [ + (b"tailwindConfig", b"config"), + (b"tailwindStylesheet", b"stylesheet"), + (b"tailwindEntryPoint", b"entryPoint"), + (b"tailwindPackageName", b"packageName"), + (b"tailwindFunctions", b"functions"), + (b"tailwindAttributes", b"attributes"), + (b"tailwindPreserveWhitespace", b"preserveWhitespace"), + (b"tailwindPreserveDuplicates", b"preserveDuplicates"), +]; + +/// `name`, which a Prettier config sets to `value`, which is JSON, as an entry of `sortTailwindcss`. `None`: it is no option +/// of the plugin. `Err`: what Prettier says about a value of the wrong type. +pub(crate) fn option_of_plugin( + name: &[u8], + value: &[u8], +) -> Option, Json), Vec>> { + let at = OPTIONS_OF_PLUGIN.iter().position(|it| it.0 == name)?; + let value = bun_lint::json::parse(value)?; + let is_string = |it: &Json| matches!(it, Json::String(_)); + let (is_right, expected): (bool, &[u8]) = match at { + 0..4 => (is_string(&value), b"a string"), + 4..6 => ( + value.as_array().is_some_and(|it| it.iter().all(is_string)), + b"an array of a string", + ), + _ => (matches!(value, Json::Bool(_)), b"true or false"), + }; + if is_right { + return Some(Ok((OPTIONS_OF_PLUGIN[at].1.to_vec(), value))); + } + let mut refusal = [b"Invalid ", name, b" value. Expected ", expected].concat(); + refusal.extend_from_slice(b", but received "); + super::config::write_as_shown(&mut refusal, &value); + refusal.push(b'.'); + Some(Err(refusal)) +} + +/// What the plugin says about its options, which are `entries` of `sortTailwindcss`. +pub(crate) fn warning_of_plugin(entries: &[(Vec, Json)]) -> Option> { + let get = |key: &[u8]| entries.iter().find(|it| it.0 == key)?.1.as_str(); + let is_sheet = |path: &&[u8]| path.ends_with(b".css"); + let old: &[u8] = match (get(b"stylesheet"), get(b"entryPoint"), get(b"config")) { + (Some(_), _, Some(config)) | (_, Some(_), Some(config)) if !is_sheet(&config) => { + return Some(b"You have specified a Tailwind CSS stylesheet and a Tailwind CSS config at the same time. Use stylesheetPath unless you are using v3. Preferring the stylesheet.".to_vec()); + } + (None, Some(_), _) => b"tailwindEntryPoint", + (None, None, Some(config)) if is_sheet(&config) => b"tailwindConfig", + _ => return None, + }; + let start = b"Deprecated: Use the `tailwindStylesheet` option for v4 projects instead of `"; + Some([&start[..], old, b"`."].concat()) +} + +/// Resolves `sortTailwindcss` for a file in `directory`. `value` is the option as JSON, and it isn't `false`. pub(crate) fn for_file( classes: &Arc, environment: &Environment, value: &[u8], - (of_config, directory): (Option<&[u8]>, &[u8]), + (set_by, directory): (SetBy, &[u8]), ) -> Tailwind { - let base = of_config.unwrap_or(&environment.cwd); + let base = match set_by { + SetBy::Bun => &environment.cwd, + SetBy::Oxfmt(base) | SetBy::Plugin(base) => base, + }; // `true` has no keys. let json = bun_lint::json::parse(value); let get = |key: &[u8]| json.as_ref()?.get(key); @@ -260,14 +337,21 @@ pub(crate) fn for_file( Some(paths::resolve(base, &paths::from_native(path))) }; let is_on = |key: &[u8]| get(key).and_then(Json::as_bool) == Some(true); - let mut known = classes.known.lock(); - if !known.by_directory.contains_key(directory) { - known - .by_directory - .insert(directory.to_vec(), find(directory)); - } - let found = known.by_directory.get(directory).cloned().flatten(); - drop(known); + let find = |package: &[u8]| { + let key = match package { + b"tailwindcss" => Cow::Borrowed(directory), + _ => Cow::Owned([directory, b"\0", package].concat()), + }; + let mut known = classes.known.lock(); + if !known.by_directory.contains_key(&key[..]) { + known + .by_directory + .insert(key.to_vec(), find(directory, package)); + } + known.by_directory.get(&key[..]).cloned().flatten() + }; + let package = (get(b"packageName").and_then(Json::as_str)).unwrap_or(b"tailwindcss"); + let found = find(package); // The plugin's `getTailwindConfig`. let (sheets, config): (Vec<_>, Vec<_>) = (path_at(b"config").into_iter()).partition(|it| it.ends_with(b".css")); @@ -275,28 +359,71 @@ pub(crate) fn for_file( .or_else(|| path_at(b"entryPoint")) .or_else(|| sheets.into_iter().next()); let config = config.into_iter().next(); - let which = found.map(|found| Which { - root: found.root, - config: config.or_else(|| found.config.filter(|_| stylesheet.is_none())), - stylesheet, - }); + let follows_plugin = matches!(set_by, SetBy::Plugin(_)); + // The plugin brings versions 3 and 4 along, for a project without the package and for an option of the other version. + let of_other_version: Option<&[u8]> = match &found { + Some(found) if found.is_version_4 && config.is_some() && stylesheet.is_none() => { + Some(b"tailwindConfig is for Tailwind CSS 3") + } + Some(found) if !found.is_version_4 && stylesheet.is_some() => { + Some(b"tailwindStylesheet is for Tailwind CSS 4") + } + _ => None, + }; + let bundled = match follows_plugin && (found.is_none() || of_other_version.is_some()) { + true => find(PLUGIN), + false => None, + }; + let is_bundled = bundled.is_some(); + let which = match (bundled.or(found), of_other_version) { + (Some(found), Some(option)) if follows_plugin && !is_bundled => Err([ + option, + b", and another version is in ", + &found.root, + b"\n Run bun add -d prettier-plugin-tailwindcss, which brings its own", + ] + .concat()), + (Some(found), _) => Ok(Which { + root: found.root, + config: config.or_else(|| found.config.filter(|_| stylesheet.is_none())), + stylesheet, + is_bundled, + }), + (None, _) => Err([ + b"the ", + package, + b" package was not found from ", + directory, + b"\n Run bun add -d ", + package, + ] + .concat()), + }; Tailwind { - functions: Names::new(match (of_config, get(b"functions")) { - (None, None) => FUNCTIONS.map(<[u8]>::to_vec).to_vec(), + functions: Names::new(match (set_by, get(b"functions")) { + (SetBy::Bun, None) => FUNCTIONS.map(<[u8]>::to_vec).to_vec(), _ => names(b"functions"), }), attributes: Names::new(names(b"attributes")), preserves_whitespace: is_on(b"preserveWhitespace"), preserves_duplicates: is_on(b"preserveDuplicates"), + follows_plugin, orders: Box::new(OfGroup { classes: Arc::clone(classes), - which: which.ok_or_else(|| directory.to_vec()), + which, }), has_missed: AtomicBool::new(false), } } impl Classes { + /// Whether there are files for which the Tailwind that the plugin brings along is asked. + pub(crate) fn asks_plugin(&self) -> bool { + let known = self.known.lock(); + let mut found = known.by_directory.iter().filter(|it| it.1.is_some()); + found.any(|it| it.0.ends_with(PLUGIN)) + } + /// Whether the package has been looked for from the directory of `path`, in vain. pub(crate) fn has_no_package_for(&self, path: &[u8]) -> bool { let known = self.known.lock(); @@ -307,26 +434,19 @@ impl Classes { /// asked. What it says about the others is known all the same. pub(crate) fn ask(&self, environment: &Environment) -> Result<(), Vec> { let fail = |why: &[u8]| [b"sortTailwindcss: ", why].concat(); - let needs_package = |directory: &[u8]| { - fail( - &[ - &b"the tailwindcss package was not found from "[..], - directory, - b"\n Run bun add -d tailwindcss", - ] - .concat(), - ) - }; let mut known = self.known.lock(); - let without_package = known.without_package.as_deref().map(needs_package); + let without_package = known.without_package.as_deref().map(fail); if !known.groups.values().any(|it| !it.missing.is_empty()) { return without_package.map_or(Ok(()), Err); } // The ranks are among all classes that are asked about, so those that are known are asked about again. - let asked: Vec = known.groups.keys().cloned().collect(); - let groups = known.groups.iter().map(|(which, group)| { - let mut classes: Vec<&[u8]> = (group.known.keys()) - .chain(&group.missing) + // In an order that is the same on every machine. + let mut asked: Vec = known.groups.keys().cloned().collect(); + bun_collections::index_sort::sort_slice(&mut asked[..]); + let groups = asked.iter().map(|which| { + let group = known.groups.get(which); + let mut classes: Vec<&[u8]> = (group.into_iter()) + .flat_map(|it| it.known.keys().chain(&it.missing)) .map(|it| &it[..]) .collect(); bun_collections::index_sort::sort_slice(&mut classes[..]); @@ -353,19 +473,24 @@ impl Classes { } } -/// Takes `sortTailwindcss` from `options`, which are for a file of this kind, if oxfmt does not sort in that language. +/// Takes `sortTailwindcss` from `options`, which are for a file of this kind, if oxfmt does not sort in that language. The +/// plugin of Prettier sorts in LWC too. pub(crate) fn only_where_sorted(options: &mut FormatOptions, kind: Option) { use bun_format::html::Parser; - let is_sorted = matches!( - kind, - None | Some( + let is_sorted = match kind { + None + | Some( Kind::Script - | Kind::Css(_) - | Kind::Markdown - | Kind::Handlebars - | Kind::Html(Parser::Html | Parser::Vue | Parser::Angular) - ) - ); + | Kind::Css(_) + | Kind::Markdown + | Kind::Handlebars + | Kind::Html(Parser::Html | Parser::Vue | Parser::Angular), + ) => true, + Some(Kind::Html(Parser::Lwc)) => { + (options.tailwind.as_deref()).is_some_and(|it| it.follows_plugin) + } + Some(_) => false, + }; if !is_sorted { options.tailwind = None; } diff --git a/src/lint/driver/format/oxlint.rs b/src/lint/driver/format/oxlint.rs index 763051a846de..b16a9f9ebca4 100644 --- a/src/lint/driver/format/oxlint.rs +++ b/src/lint/driver/format/oxlint.rs @@ -6,7 +6,6 @@ use bun_lint::linter::{LintMessage, RuleId, parse_rule_id}; use bun_lint::options::Json; use bun_lint::rule::Plugin; use bun_lint::source::LineStarts; -use bun_sema::util::FxHashMap; use std::cell::OnceCell; /// What oxlint says about a run. @@ -32,49 +31,6 @@ pub(super) fn code(message: &LintMessage) -> Option> { Some([scope_of(rule, plugin), b"(", name, b")"].concat()) } -/// [`code`], made once for each rule that is built in. -#[derive(Default)] -pub(super) struct Codes { - /// One after the other. - text: Vec, - /// By where the rule is: from where to where its code is in `text`. - places: FxHashMap, -} - -impl Codes { - /// Writes `before` and the code of `message`, if it has one. - pub(super) fn write(&mut self, out: &mut Vec, before: &[u8], message: &LintMessage) { - let made; - let code = match &message.rule_id { - Some(RuleId::Known(meta)) => { - let rule = std::ptr::from_ref(*meta).addr(); - let (start, end) = match self.places.get(&rule) { - Some(place) => *place, - None => { - let start = self.text.len() as u32; - self.text.extend(code(message).unwrap_or_default()); - let place = (start, self.text.len() as u32); - self.places.insert(rule, place); - place - } - }; - self.text - .get(start as usize..end as usize) - .unwrap_or_default() - } - _ => match code(message) { - Some(code) => { - made = code; - &made[..] - } - None => return, - }, - }; - out.extend_from_slice(before); - out.extend_from_slice(code); - } -} - /// [`scope`] for the prefix of `rule`. A plugin in JavaScript is called what it is called. pub(crate) fn scope_of<'p>(rule: &RuleId, prefix: &'p [u8]) -> &'p [u8] { match rule { diff --git a/src/lint/driver/format/unix.rs b/src/lint/driver/format/unix.rs index a8981f9c254d..f12d301fad85 100644 --- a/src/lint/driver/format/unix.rs +++ b/src/lint/driver/format/unix.rs @@ -2,10 +2,12 @@ use super::Meta; use super::info::Source; -use super::oxlint::{Codes, is_error}; +use super::oxlint::{code, is_error}; use crate::results::FileResult; use bun_core::fmt::{ItoaBuf, itoa}; use bun_lint::context::Severity; +use bun_lint::linter::{LintMessage, RuleId}; +use bun_sema::util::FxHashMap; use std::io::Write; /// `:1:2: ` @@ -46,9 +48,55 @@ pub(super) fn write(out: &mut Vec, results: &[FileResult]) { } } +/// ` [Warning/eslint(no-debugger)]` and the end of the line +fn write_end(out: &mut Vec, message: &LintMessage) { + out.extend_from_slice(match is_error(message) { + true => b" [Error", + false => b" [Warning", + }); + if let Some(code) = code(message) { + out.push(b'/'); + out.extend_from_slice(&code); + } + out.extend_from_slice(b"]\n"); +} + +/// [`write_end`], made once for each rule that is built in and for errors and warnings. +#[derive(Default)] +struct Ends { + /// One after the other. + text: Vec, + /// By where the rule is, plus 1 for an error: from where to where its end is in `text`. + places: FxHashMap, +} + +impl Ends { + fn write(&mut self, out: &mut Vec, message: &LintMessage) { + let Some(RuleId::Known(meta)) = &message.rule_id else { + return write_end(out, message); + }; + let key = std::ptr::from_ref(*meta).addr() + usize::from(is_error(message)); + let (start, end) = match self.places.get(&key) { + Some(place) => *place, + None => { + let start = self.text.len() as u32; + write_end(&mut self.text, message); + let place = (start, self.text.len() as u32); + self.places.insert(key, place); + place + } + }; + out.extend_from_slice( + self.text + .get(start as usize..end as usize) + .unwrap_or_default(), + ); + } +} + /// With the end of the last line. pub(super) fn write_as_oxlint(out: &mut Vec, results: &[FileResult], meta: &Meta) { - let (mut total, mut codes) = (0, Codes::default()); + let (mut total, mut ends) = (0, Ends::default()); for result in results.iter().filter(|it| !it.messages.is_empty()) { let source = Source::new(result, meta); total += result.messages.len(); @@ -57,12 +105,7 @@ pub(super) fn write_as_oxlint(out: &mut Vec, results: &[FileResult], meta: & out.extend_from_slice(&source.name); write_place(out, start.line, start.column); out.extend_from_slice(&message.message); - match is_error(message) { - true => out.extend_from_slice(b" [Error"), - false => out.extend_from_slice(b" [Warning"), - } - codes.write(out, b"/", message); - out.extend_from_slice(b"]\n"); + ends.write(out, message); } } if total > 0 { diff --git a/src/lint/driver/lib.rs b/src/lint/driver/lib.rs index c5d6a695c8fe..891f927a91d5 100644 --- a/src/lint/driver/lib.rs +++ b/src/lint/driver/lib.rs @@ -8,6 +8,7 @@ //! - [`fmt`]: the same for `bun fmt`. #![forbid(unsafe_code)] +#![deny(clippy::iter_over_hash_type)] // For what a flag like `--doctor`, `--rules` or `--init` runs once: `#[optimize(size)]` roughly halves it, and how fast it is // doesn't matter. Nothing that runs for each file has it. #![feature(optimize_attribute)] diff --git a/src/lint/driver/lint.rs b/src/lint/driver/lint.rs index 2c86ceae594b..beb6ff05b7dc 100644 --- a/src/lint/driver/lint.rs +++ b/src/lint/driver/lint.rs @@ -99,6 +99,18 @@ pub(crate) enum Fixes { Disabled, } +/// Which rules run. +#[derive(Copy, Clone, Default)] +enum Which<'h> { + /// Those of the configuration. + #[default] + All, + /// It is only to be known whether it can be parsed. + None, + /// [`Context::with_help`] + WithHelp(&'h RuleFilter<'h>), +} + /// How a text is linted, if not like a file. #[derive(Copy, Clone, Default)] struct How<'h> { @@ -111,12 +123,7 @@ struct How<'h> { script: Option<(ScriptKind, &'h RuleFilter<'h>)>, /// [`File::is_later_script`] is_later_script: bool, - /// It is only to be known whether it can be parsed. - without_rules: bool, - /// [`Context::with_help`] - with_help: bool, - /// Which rules run. - only: Option<&'h RuleFilter<'h>>, + rules: Which<'h>, } fn only_errors(_: &RuleId, severity: Severity) -> bool { @@ -305,8 +312,7 @@ impl Context<'_, '_> { } let is_asked_about = |id: &RuleId, _: Severity| only.is_none_or(|only| only.contains(&id)); let how = How { - with_help: true, - only: Some(&is_asked_about), + rules: Which::WithHelp(&is_asked_about), ..How::default() }; self.verify_as(&path, text, &linted.config, &how).messages @@ -315,7 +321,7 @@ impl Context<'_, '_> { /// Whether `text` can be parsed as the file at `path`. pub(crate) fn parses(&self, path: &[u8], text: &[u8], config: &ResolvedConfig) -> bool { let how = How { - without_rules: true, + rules: Which::None, ..How::default() }; let result = match Framework::of(path).filter(|_| config.language.is_oxlint) { @@ -552,7 +558,8 @@ impl Context<'_, '_> { let options = self.lint_options(); // Help on demand lints the text a second time. That only works for a file that's linted in one piece. let is_whole = as_what.script.is_none() && as_what.physical_path_len.is_none(); - let waits_for_demand = self.help_on_demand && is_whole && !as_what.with_help; + let is_for_help = matches!(as_what.rules, Which::WithHelp(_)); + let waits_for_demand = self.help_on_demand && is_whole && !is_for_help; let options = LintOptions { again: as_what.again, wants_fixes: match as_what.fixes { @@ -568,14 +575,14 @@ impl Context<'_, '_> { wants_messages: options.wants_messages || as_what.physical_path_len.is_some(), wants_places: options.wants_places || as_what.physical_path_len.is_some(), // JavaScript rules have no help, so there's no reason to run them again. - js_plugins: options.js_plugins.filter(|_| !as_what.with_help), + js_plugins: options.js_plugins.filter(|_| !is_for_help), parse_again: Some(parse_again), physical_path_len: as_what.physical_path_len, - rule_filter: match as_what.without_rules { - true => Some(&no_rule), - false => (as_what.script.map(|it| it.1)) - .or(as_what.only) - .or(options.rule_filter), + rule_filter: match (as_what.rules, as_what.script) { + (Which::None, _) => Some(&no_rule), + (_, Some(script)) => Some(script.1), + (Which::WithHelp(only), None) => Some(only), + (Which::All, None) => options.rule_filter, }, ..options }; diff --git a/src/lint/driver/typed.rs b/src/lint/driver/typed.rs index fbfddea66e53..a1e86c5aceb2 100644 --- a/src/lint/driver/typed.rs +++ b/src/lint/driver/typed.rs @@ -132,8 +132,10 @@ impl ByPath { return None; } let folded = self.folded.get_or_init(|| { - let exact = self.exact.iter(); - exact + // Of two that differ in case alone, the first counts. + let exact = + bun_lint::utils::sort::in_order_of(&self.exact, |it| std::cmp::Reverse(*it.1)); + (exact.into_iter()) .map(|(path, &at)| (to_file_name_lower_case(path), at)) .collect() }); diff --git a/src/lint/eslint/lib.rs b/src/lint/eslint/lib.rs index 1464f46e4fde..6347ab0d7ba0 100644 --- a/src/lint/eslint/lib.rs +++ b/src/lint/eslint/lib.rs @@ -3,6 +3,7 @@ //! License): the same options, the same messages, at the same places. #![forbid(unsafe_code)] +#![deny(clippy::iter_over_hash_type)] bun_lint::rules! { accessor_pairs::AccessorPairs, diff --git a/src/lint/eslint/rules/complexity.rs b/src/lint/eslint/rules/complexity.rs index 52f6837c7f69..5e96e8361d3f 100644 --- a/src/lint/eslint/rules/complexity.rs +++ b/src/lint/eslint/rules/complexity.rs @@ -334,7 +334,9 @@ impl Rule for Complexity { }; Self::count(threshold, cx); // oxlint reports an initializer that is a function and the function at one place: the initializer first. - for (&member, &complexity) in &cx.state.of_fields { + for (&member, &complexity) in + utils::sort::in_order_of(&cx.state.of_fields, |it| it.0.span().start) + { self.report(Node::Member(member), complexity, cx); } let of_functions = &cx.state.of_functions; diff --git a/src/lint/eslint/rules/no_else_return.rs b/src/lint/eslint/rules/no_else_return.rs index 106689df9970..aac1bc426c0d 100644 --- a/src/lint/eslint/rules/no_else_return.rs +++ b/src/lint/eslint/rules/no_else_return.rs @@ -40,7 +40,10 @@ fn is_safe_to_declare<'a>(names: &[Name<'a>], scope: Scope<'a>) -> bool { let mut nodes = symbol.declarations().filter_map(Declaration::node); nodes.any(|node| block.contains(utils::estree_span(node))) }; - if function_scope.symbols().any(|it| names.contains(&it.name()) && is_declared_in_block(it)) { + if function_scope + .symbols() + .any(|it| names.contains(&it.name()) && is_declared_in_block(it)) + { return false; } } @@ -66,7 +69,10 @@ fn is_safe_from_name_collisions<'a>(else_node: Stmt<'a>, scope: Scope<'a>) -> bo /// It could continue the statement before it, if that does not end with a `;`. fn can_continue_statement(token: Token) -> bool { - matches!(token.text().first(), Some(b'(' | b'[' | b'/' | b'+' | b'`' | b'-')) + matches!( + token.text().first(), + Some(b'(' | b'[' | b'/' | b'+' | b'`' | b'-') + ) } /// `consequent`: that of the `if` whose `else` is `else_node`. @@ -94,7 +100,8 @@ fn fix<'a>(fixer: Fixer<'a>, else_node: Stmt<'a>, consequent: Stmt<'a>) -> Optio if !last_token_of_else_block.is(";") && let Some(next_token) = file.token_after(end_token) { - let is_on_same_line = file.is_on_same_line(next_token.start(), last_token_of_else_block.start()); + let is_on_same_line = + file.is_on_same_line(next_token.start(), last_token_of_else_block.start()); if can_continue_statement(next_token) || (is_on_same_line && !next_token.is("}")) { return None; } @@ -108,7 +115,10 @@ fn fix<'a>(fixer: Fixer<'a>, else_node: Stmt<'a>, consequent: Stmt<'a>) -> Optio Some( FixTracker::new(fixer) .retain_enclosing_function(else_node) - .replace_text_range(Span::new(else_token.start(), else_node.span().end), fixed_source), + .replace_text_range( + Span::new(else_token.start(), else_node.span().end), + fixed_source, + ), ) } @@ -124,10 +134,14 @@ fn fix_as_oxlint<'a>(fixer: Fixer<'a>, else_node: Stmt<'a>, consequent: Stmt<'a> // For oxc nothing declares `arguments`, and the parameter of a `catch` is in the scope of its block. let of_catch = scope.parent().filter(|it| it.kind() == ScopeKind::Catch); let is_taken = |name: Name<'a>| { - scope.get_name(name).is_some_and(|it| !it.is_implicit_arguments()) + scope + .get_name(name) + .is_some_and(|it| !it.is_implicit_arguments()) || of_catch.is_some_and(|it| it.get_name(name).is_some()) }; - if else_scope.node() == Node::Stmt(else_node) && else_scope.symbols().any(|it| is_taken(it.name())) { + if else_scope.node() == Node::Stmt(else_node) + && else_scope.symbols().any(|it| is_taken(it.name())) + { return None; } (else_node.span().shrink(1, 1), body.last()) @@ -135,12 +149,17 @@ fn fix_as_oxlint<'a>(fixer: Fixer<'a>, else_node: Stmt<'a>, consequent: Stmt<'a> _ => (else_node.span(), Some(else_node)), }; let (start, end) = (consequent.span().end, else_node.span().end); - let needs_newline = - matches!(consequent.tag(), StmtTag::Expr | StmtTag::Return) && !consequent.text().ends_with(b";"); - let is_open = last.is_some_and(|it| !matches!(it.text().trim_ascii_end().last(), Some(b';' | b'}'))); + let needs_newline = matches!(consequent.tag(), StmtTag::Expr | StmtTag::Return) + && !consequent.text().ends_with(b";"); + let is_open = + last.is_some_and(|it| !matches!(it.text().trim_ascii_end().last(), Some(b';' | b'}'))); let after = file.text().get(end as usize..).unwrap_or_default(); - let next = after.iter().copied().find(|it| !matches!(it, b' ' | b'\t' | 0x0b | 0x0c)); - let needs_semicolon = is_open && next.is_some_and(|it| !matches!(it, b'\n' | b'\r' | b'}' | b';')); + let next = after + .iter() + .copied() + .find(|it| !matches!(it, b' ' | b'\t' | 0x0b | 0x0c)); + let needs_semicolon = + is_open && next.is_some_and(|it| !matches!(it, b'\n' | b'\r' | b'}' | b';')); let newline: &[u8] = if needs_newline { b"\n" } else { b"" }; let semicolon: &[u8] = if needs_semicolon { b";" } else { b"" }; let text = [newline, file.slice(kept), semicolon]; @@ -184,7 +203,9 @@ fn returning_statement(statement: Stmt<'_>) -> Option> { } impl Rule for NoElseReturn { - const META: Meta = Meta::eslint("no-else-return", Kind::Suggestion).fixable(Fixable::Code).reports_on_exit(); + const META: Meta = Meta::eslint("no-else-return", Kind::Suggestion) + .fixable(Fixable::Code) + .reports_on_exit(); const ON: On = On::new().stmts(&[StmtTag::If]); no_state!(); @@ -217,9 +238,13 @@ impl Rule for NoElseReturn { Some(returning) => { let (start, end) = (consequent.span().end, else_node.span().start); cx.report(returning, UNEXPECTED) + .called_with(statement) .comments_apply_at(Span::new(start, end)) .first_label("This consequent block always returns,") - .label(Span::new(start, end), "Making this `else` block unnecessary.") + .label( + Span::new(start, end), + "Making this `else` block unnecessary.", + ) } None => cx.report(else_node, UNEXPECTED), }; diff --git a/src/lint/eslint/rules/no_loss_of_precision.rs b/src/lint/eslint/rules/no_loss_of_precision.rs index 9029d18f111f..912a1d7baeb0 100644 --- a/src/lint/eslint/rules/no_loss_of_precision.rs +++ b/src/lint/eslint/rules/no_loss_of_precision.rs @@ -16,7 +16,10 @@ fn not_base_ten_loses_precision(digits: &[u8], bits_per_digit: u32) -> bool { // The positions of the first and of the last bit that is set, counted from the first bit. let (mut first, mut last) = (None, 0); let mut at = 0; - for digit in digits.iter().filter_map(|digit| char::from(*digit).to_digit(16)) { + for digit in digits + .iter() + .filter_map(|digit| char::from(*digit).to_digit(16)) + { if digit != 0 { if first.is_none() { first = Some(at + bits_per_digit - 1 - digit.ilog2()); @@ -25,17 +28,25 @@ fn not_base_ten_loses_precision(digits: &[u8], bits_per_digit: u32) -> bool { } at += bits_per_digit; } - first.is_some_and(|first| last - first >= f64::MANTISSA_DIGITS || at - first > f64::MAX_EXP as u32) + first.is_some_and(|first| { + last - first >= f64::MANTISSA_DIGITS || at - first > f64::MAX_EXP as u32 + }) } /// `value.toPrecision(precision)` of a finite `value > 0`, for a precision from 1 to 100: the digits, and the exponent /// of the first. fn to_precision(value: f64, precision: usize) -> (Vec, i64) { let mut buffer = [0; 124]; - let text = bun_core::fmt::FormatDouble::to_exponential(&mut buffer, value, Some(precision as u32 - 1)); + let text = + bun_core::fmt::FormatDouble::to_exponential(&mut buffer, value, Some(precision as u32 - 1)); let (digits, exponent) = strings::split_once_char(text, b'e').unwrap_or((text, b"0")); - let exponent = std::str::from_utf8(exponent).ok().and_then(|it| it.parse().ok()); - (digits.iter().copied().filter(u8::is_ascii_digit).collect(), exponent.unwrap_or(0)) + let exponent = std::str::from_utf8(exponent) + .ok() + .and_then(|it| it.parse().ok()); + ( + digits.iter().copied().filter(u8::is_ascii_digit).collect(), + exponent.unwrap_or(0), + ) } fn base_ten_loses_precision(raw: &[u8]) -> bool { @@ -51,7 +62,10 @@ fn base_ten_loses_precision(raw: &[u8]) -> bool { return false; } let integer_digits = match point { - Some(point) => mantissa[..point].iter().filter(|digit| digit.is_ascii_digit()).count(), + Some(point) => mantissa[..point] + .iter() + .filter(|digit| digit.is_ascii_digit()) + .count(), None => count, }; // Those of `1.00` are asked for, those of `100` and of `100.` are not. @@ -62,7 +76,10 @@ fn base_ten_loses_precision(raw: &[u8]) -> bool { let precision = count - leading_zeros - trailing_zeros; let is_negative = exponent.first() == Some(&b'-'); let exponent = exponent.iter().filter(|digit| digit.is_ascii_digit()); - let exponent = exponent.fold(0i64, |all, digit| all.saturating_mul(10).saturating_add(i64::from(digit - b'0'))); + let exponent = exponent.fold(0i64, |all, digit| { + all.saturating_mul(10) + .saturating_add(i64::from(digit - b'0')) + }); let magnitude = (integer_digits as i64 - 1 - leading_zeros as i64) .saturating_add(if is_negative { -exponent } else { exponent }); // Every number with 15 digits that is not next to the limits of a double is told apart from @@ -74,7 +91,10 @@ fn base_ten_loses_precision(raw: &[u8]) -> bool { return true; } let text: Vec = raw.iter().copied().filter(|it| *it != b'_').collect(); - let Some(value) = std::str::from_utf8(&text).ok().and_then(|it| it.parse::().ok()) else { + let Some(value) = std::str::from_utf8(&text) + .ok() + .and_then(|it| it.parse::().ok()) + else { return false; }; if value == 0.0 || !value.is_finite() { @@ -88,14 +108,18 @@ fn base_ten_loses_precision(raw: &[u8]) -> bool { fn loses_precision(raw: &[u8]) -> bool { // At most 15 decimal digits, or 13 digits of 4 bits, and no exponent. let has_radix = matches!(raw, [b'0', b'x' | b'X' | b'o' | b'O' | b'b' | b'B', ..]); - if raw.len() <= f64::DIGITS as usize && (has_radix || strings::index_of_any(raw, b"eE").is_none()) { + if raw.len() <= f64::DIGITS as usize + && (has_radix || strings::index_of_any(raw, b"eE").is_none()) + { return false; } match raw { [b'0', b'x' | b'X', digits @ ..] => not_base_ten_loses_precision(digits, 4), [b'0', b'o' | b'O', digits @ ..] => not_base_ten_loses_precision(digits, 3), [b'0', b'b' | b'B', digits @ ..] => not_base_ten_loses_precision(digits, 1), - [b'0', digits @ ..] if !digits.is_empty() && digits.iter().all(|digit| matches!(digit, b'0'..=b'7')) => { + [b'0', digits @ ..] + if !digits.is_empty() && digits.iter().all(|digit| matches!(digit, b'0'..=b'7')) => + { not_base_ten_loses_precision(digits, 3) } _ => base_ten_loses_precision(raw), @@ -106,7 +130,9 @@ fn loses_precision(raw: &[u8]) -> bool { /// `3.14159265358979323846`. An integer such as a 64-bit ID becomes a different integer. fn is_decimal(raw: &[u8]) -> bool { let text: Vec = raw.iter().copied().filter(|it| *it != b'_').collect(); - let value = std::str::from_utf8(&text).ok().and_then(|it| it.parse::().ok()); + let value = std::str::from_utf8(&text) + .ok() + .and_then(|it| it.parse::().ok()); value.is_some_and(|it| it.is_finite() && it.fract() != 0.0) } @@ -115,27 +141,41 @@ pub fn ignores_decimals(options: &Options) -> bool { options.object(0).bool_or("ignoreDecimals", false) } +#[inline] pub fn check<'a, R: Rule>(literal: Literal<'a>, ignore_decimals: bool, cx: &Cx<'a, R>) { let raw = literal.text(); // An exponent takes three characters. - if raw.len() > 2 && !raw.ends_with(b"n") && loses_precision(raw) && !(ignore_decimals && is_decimal(raw)) { - cx.report(literal, NO_LOSS_OF_PRECISION).hint_with(|| { - let Node::Expr(number) = literal.owner() else { - return None; - }; - let value = text::number_to_string(number.as_number()?); - Some(format!("At runtime it is {}.", bstr::BStr::new(&value))) - }); + if raw.len() > 2 && !raw.ends_with(b"n") && loses_precision(raw) { + report(literal, ignore_decimals, cx); } } +#[cold] +#[inline(never)] +fn report<'a, R: Rule>(literal: Literal<'a>, ignore_decimals: bool, cx: &Cx<'a, R>) { + if ignore_decimals && is_decimal(literal.text()) { + return; + } + cx.report(literal, NO_LOSS_OF_PRECISION).hint_with(|| { + let Node::Expr(number) = literal.owner() else { + return None; + }; + let value = text::number_to_string(number.as_number()?); + Some(format!("At runtime it is {}.", bstr::BStr::new(&value))) + }); +} + impl Rule for NoLossOfPrecision { - const META: Meta = Meta::eslint("no-loss-of-precision", Kind::Problem).recommended().hints(); + const META: Meta = Meta::eslint("no-loss-of-precision", Kind::Problem) + .recommended() + .hints(); const ON: On = On::new().number_literals(); no_state!(); fn new(options: &Options) -> Self { - NoLossOfPrecision { ignore_decimals: ignores_decimals(options) } + NoLossOfPrecision { + ignore_decimals: ignores_decimals(options), + } } fn number_literal<'a>(&self, literal: Literal<'a>, cx: &mut Cx<'a, Self>) { diff --git a/src/lint/eslint/rules/no_unused_private_class_members.rs b/src/lint/eslint/rules/no_unused_private_class_members.rs index a13703cca37e..bc89990be003 100644 --- a/src/lint/eslint/rules/no_unused_private_class_members.rs +++ b/src/lint/eslint/rules/no_unused_private_class_members.rs @@ -1,5 +1,5 @@ -use bun_core::strings; use super::no_unused_vars::is_pure; +use bun_core::strings; use bun_lint::prelude::*; use bun_lint::utils::ancestor_memo::AncestorMemo; use rustc_hash::FxHashMap; @@ -7,8 +7,10 @@ use rustc_hash::FxHashMap; /// Disallow unused private class members. pub struct NoUnusedPrivateClassMembers; -const UNUSED_PRIVATE_CLASS_MEMBER: Message = - Message::new("unusedPrivateClassMember", "'{{classMemberName}}' is defined but never used."); +const UNUSED_PRIVATE_CLASS_MEMBER: Message = Message::new( + "unusedPrivateClassMember", + "'{{classMemberName}}' is defined but never used.", +); const REMOVE_UNUSED_PRIVATE_CLASS_MEMBER: Message = Message::new( "removeUnusedPrivateClassMember", "Remove unused private class member '{{classMemberName}}'.", @@ -55,7 +57,12 @@ fn is_value_context(e: Expr<'_>) -> bool { Node::Stmt(statement) => { return matches!( statement.tag(), - StmtTag::Return | StmtTag::If | StmtTag::Switch | StmtTag::Throw | StmtTag::While | StmtTag::DoWhile + StmtTag::Return + | StmtTag::If + | StmtTag::Switch + | StmtTag::Throw + | StmtTag::While + | StmtTag::DoWhile ); } Node::Case(_) | Node::VarDecl(_) => return true, @@ -90,11 +97,21 @@ fn is_value_context(e: Expr<'_>) -> bool { ExprKind::TaggedTemplate(call) => return call.callee() != child, ExprKind::Dot { .. } => return !parent.is_private_member(), ExprKind::Array(_) => return !parent.is_assignment_target(), - ExprKind::Spread(_) => return !parent.is_assignment_target() && parent.jsx_container_span().is_none(), + ExprKind::Spread(_) => { + return !parent.is_assignment_target() && parent.jsx_container_span().is_none(); + } ExprKind::Jsx(_) => return child.jsx_container_span().is_some(), - ExprKind::Assign { value, .. } => return value == child && !parent.is_assignment_target(), - ExprKind::Binary { op: BinOp::And | BinOp::Or | BinOp::Nullish, .. } - | ExprKind::Unary { op: UnOp::PreInc | UnOp::PreDec | UnOp::PostInc | UnOp::PostDec, .. } + ExprKind::Assign { value, .. } => { + return value == child && !parent.is_assignment_target(); + } + ExprKind::Binary { + op: BinOp::And | BinOp::Or | BinOp::Nullish, + .. + } + | ExprKind::Unary { + op: UnOp::PreInc | UnOp::PreDec | UnOp::PostInc | UnOp::PostDec, + .. + } | ExprKind::Cond { .. } | ExprKind::As { .. } | ExprKind::AsConst(_) @@ -102,7 +119,11 @@ fn is_value_context(e: Expr<'_>) -> bool { | ExprKind::NonNull(_) | ExprKind::Instantiation { .. } => {} // `#x in e` - ExprKind::Binary { op: BinOp::In, left, .. } if left.tag() == ExprTag::PrivateIdentifier => return false, + ExprKind::Binary { + op: BinOp::In, + left, + .. + } if left.tag() == ExprTag::PrivateIdentifier => return false, ExprKind::Binary { .. } | ExprKind::Unary { .. } => return true, _ => return false, } @@ -113,7 +134,14 @@ fn is_value_context(e: Expr<'_>) -> bool { /// oxlint's `is_read`, of an `a.#x`. fn is_read_for_oxlint(access: Expr<'_>) -> bool { let is_cast = |e: Expr| { - matches!(e.tag(), ExprTag::As | ExprTag::AsConst | ExprTag::Satisfies | ExprTag::NonNull | ExprTag::Instantiation) + matches!( + e.tag(), + ExprTag::As + | ExprTag::AsConst + | ExprTag::Satisfies + | ExprTag::NonNull + | ExprTag::Instantiation + ) }; let mut top = access; while !top.is_chain_root() @@ -130,11 +158,17 @@ fn is_read_for_oxlint(access: Expr<'_>) -> bool { match top.parent() { Node::Expr(parent) => match parent.kind() { // `a = this.#x += 1` - ExprKind::Assign { op: Some(_), target, .. } => { - target == top && !parent.is_assignment_target() && is_value_context(parent) - } + ExprKind::Assign { + op: Some(_), + target, + .. + } => target == top && !parent.is_assignment_target() && is_value_context(parent), ExprKind::Cond { test, .. } => is_bare && test == top, - ExprKind::Binary { op: BinOp::And | BinOp::Or | BinOp::Nullish, left, .. } => left == top, + ExprKind::Binary { + op: BinOp::And | BinOp::Or | BinOp::Nullish, + left, + .. + } => left == top, _ => false, }, Node::Stmt(statement) => match statement.kind() { @@ -160,7 +194,11 @@ fn is_in_expression_statement(e: Expr<'_>) -> bool { fn is_write_only(access: Expr<'_>) -> bool { if let Node::Expr(parent) = access.parent() { match parent.kind() { - ExprKind::Assign { op: Some(_), target, .. } if target == access => { + ExprKind::Assign { + op: Some(_), + target, + .. + } if target == access => { return is_in_expression_statement(parent); } ExprKind::Unary { @@ -182,7 +220,8 @@ fn get_member_removal_range<'a>(file: &'a File<'a>, member: Member<'a>) -> Optio before.is_some_and(|before| file.line_of(before.end()) != file.line_of(at.start)) }; let previous_token = file.token_before(node)?; - let shares_line_with_another_token = file.line_of(previous_token.end()) == file.line_of(node.start) + let shares_line_with_another_token = file.line_of(previous_token.end()) + == file.line_of(node.start) || file.line_of(file.token_after(node)?.start()) == file.line_of(node.end); // The first of the comments on the lines directly before the member. @@ -190,7 +229,8 @@ fn get_member_removal_range<'a>(file: &'a File<'a>, member: Member<'a>) -> Optio let mut next = node; for comment in file.comments_before(node).rev() { let comment = comment.span(); - if !starts_on_own_line(comment) || file.line_of(next.start) - file.line_of(comment.end) > 1 { + if !starts_on_own_line(comment) || file.line_of(next.start) - file.line_of(comment.end) > 1 + { break; } first_leading_comment = Some(comment); @@ -205,16 +245,26 @@ fn get_member_removal_range<'a>(file: &'a File<'a>, member: Member<'a>) -> Optio .map_or(node, Token::span), }; - let next_token = file.tokens_after(last_item_to_remove).with_comments().next()?; - let next_token_starts_on_new_line = file.line_of(next_token.start()) > file.line_of(last_item_to_remove.end); + let next_token = file + .tokens_after(last_item_to_remove) + .with_comments() + .next()?; + let next_token_starts_on_new_line = + file.line_of(next_token.start()) > file.line_of(last_item_to_remove.end); let (mut start, mut end) = (node.start, last_item_to_remove.end); if let Some(first) = first_leading_comment.filter(|_| !shares_line_with_another_token) { (start, end) = match next_token_starts_on_new_line { - true => (line_start_index(first.start), line_start_index(next_token.start())), + true => ( + line_start_index(first.start), + line_start_index(next_token.start()), + ), false => (first.start, next_token.start()), }; } else if starts_on_own_line(node) && next_token_starts_on_new_line { - (start, end) = (line_start_index(node.start), line_start_index(next_token.start())); + (start, end) = ( + line_start_index(node.start), + line_start_index(next_token.start()), + ); } else if file.line_of(previous_token.end()) == file.line_of(node.start) { start = previous_token.end(); } else if !next_token_starts_on_new_line { @@ -240,7 +290,8 @@ impl NoUnusedPrivateClassMembers { if cx.language().is_oxlint { // A method without a body is none, nor is an `accessor`. let is_element = |member: &Member<'a>| { - !member.flags().contains(Flags::ACCESSOR) && member.func().is_none_or(Func::has_body) + !member.flags().contains(Flags::ACCESSOR) + && member.func().is_none_or(Func::has_body) }; for member in class.members().iter().filter(is_element) { if let Some(KeyKind::Private(name)) = member.key().map(Key::kind) { @@ -277,7 +328,10 @@ impl NoUnusedPrivateClassMembers { cx.state.usages.push((Node::Expr(e), name)); } } - for e in file.exprs_of_kind(ExprTag::Dot).filter(|it| it.is_private_member()) { + for e in file + .exprs_of_kind(ExprTag::Dot) + .filter(|it| it.is_private_member()) + { if let Some(name) = e.member_name() { cx.state.usages.push((Node::Expr(e), name.name())); } @@ -291,7 +345,9 @@ impl NoUnusedPrivateClassMembers { return; } self.collect_usages(cx); - let State { declared, usages, .. } = std::mem::take(&mut cx.state); + let State { + declared, usages, .. + } = std::mem::take(&mut cx.state); let mut classes: AncestorMemo<'a, Class<'a>> = AncestorMemo::default(); // What is referred to, and whether it is read. let mut referenced: FxHashMap<(Class<'a>, Name<'a>), bool> = FxHashMap::default(); @@ -348,7 +404,11 @@ impl Rule for NoUnusedPrivateClassMembers { return; } self.collect_usages(cx); - let State { mut members, usages, .. } = std::mem::take(&mut cx.state); + let State { + mut members, + usages, + .. + } = std::mem::take(&mut cx.state); // The class whose body a node is in. let mut classes: AncestorMemo<'a, Class<'a>> = AncestorMemo::default(); for (usage, name) in usages { @@ -365,7 +425,9 @@ impl Rule for NoUnusedPrivateClassMembers { inner = Node::Class(class); } } - for ((_, name), member) in &members { + for ((_, name), member) in + utils::sort::in_order_of(&members, |it| it.1.declared.span().start) + { let (declared, has_reference) = (member.declared, member.has_reference); let Some(key) = declared.key().filter(|_| !member.is_used) else { continue; diff --git a/src/lint/eslint/rules/no_useless_assignment.rs b/src/lint/eslint/rules/no_useless_assignment.rs index 382d85d68dcf..e29d5106243d 100644 --- a/src/lint/eslint/rules/no_useless_assignment.rs +++ b/src/lint/eslint/rules/no_useless_assignment.rs @@ -1638,7 +1638,10 @@ impl NoUselessAssignment { }) }) }; - 'variables: for (variable, assignments) in target.assignments { + let by_variable = |it: &(Binding<'a>, _)| (it.0.symbol.key(), it.0.class.is_some()); + 'variables: for (variable, assignments) in + utils::sort::in_order_of(target.assignments, by_variable) + { let Some(mut assignments) = assignments else { continue; }; @@ -1905,12 +1908,14 @@ impl Rule for NoUselessAssignment { } } } + let as_entered = |it: &&Node<'_>| (it.span().start, std::cmp::Reverse(it.span().end)); let roots = std::mem::take(&mut cx.state.roots); - for &root in &roots { + for &root in utils::sort::in_order_of(&roots, as_entered) { self.check_code_path(root, cx); } cx.state.is_only_checked = true; - for root in std::mem::take(&mut cx.state.all_roots).difference(&roots) { + let all_roots = std::mem::take(&mut cx.state.all_roots); + for root in utils::sort::in_order_of(all_roots.difference(&roots), as_entered) { self.check_code_path(*root, cx); } } diff --git a/src/lint/eslint/rules/no_useless_return.rs b/src/lint/eslint/rules/no_useless_return.rs index 16d24fc8ba71..4f3730dd249f 100644 --- a/src/lint/eslint/rules/no_useless_return.rs +++ b/src/lint/eslint/rules/no_useless_return.rs @@ -54,7 +54,8 @@ struct ReturnSets { impl ReturnSets { fn add(&mut self, set: ReturnSet) -> u32 { if self.sets.is_empty() { - self.sets.push((ReturnSet::Union(EMPTY, EMPTY), Filtered::Used)); + self.sets + .push((ReturnSet::Union(EMPTY, EMPTY), Filtered::Used)); } self.sets.push((set, Filtered::No)); self.sets.len() as u32 - 1 @@ -82,7 +83,11 @@ impl ReturnSets { fn mark_as_used(&mut self, set: u32, mut uses: impl FnMut(u32) -> bool) -> u32 { let mut pending = vec![set]; while let Some(&at) = pending.last() { - let Some(&(kind, _)) = self.sets.get(at as usize).filter(|_| self.left_of(at).is_none()) else { + let Some(&(kind, _)) = self + .sets + .get(at as usize) + .filter(|_| self.left_of(at).is_none()) + else { pending.pop(); continue; }; @@ -154,7 +159,9 @@ pub struct State<'a> { /// ESLint's `isReturned`: `segment` ends with a `return`, or it is unreachable. fn is_returned(segments: &SegmentInfoMap, segment: Segment<'_>) -> bool { - segments.get(&segment.id()).is_none_or(|info| info.is_returned) + segments + .get(&segment.id()) + .is_none_or(|info| info.is_returned) } /// ESLint's `isInLoop` or `isInFinally`, as far as `parent`, which `child` is directly in, tells. @@ -182,11 +189,18 @@ impl<'a> State<'a> { let mut pending = segment.all_prev_segments(); while let Some(prev) = pending.pop() { if prev.is_reachable() { - let of_prev = self.segments.get(&prev.id()).map_or(EMPTY, |info| info.useless_returns); + let of_prev = self + .segments + .get(&prev.id()) + .map_or(EMPTY, |info| info.useless_returns); useless_returns = self.sets.union(useless_returns, of_prev); } else if traversed.insert(prev.id()) { let before = prev.all_prev_segments(); - pending.extend(before.into_iter().filter(|it| is_returned(&self.segments, *it))); + pending.extend( + before + .into_iter() + .filter(|it| is_returned(&self.segments, *it)), + ); } } useless_returns @@ -205,9 +219,14 @@ impl<'a> State<'a> { while let Some(segment) = pending.pop() { if !segment.is_reachable() { // Nothing is added to what has ended before it. - if self.used_unreachable.insert(segment.id(), self.sets.age) != Some(self.sets.age) { + if self.used_unreachable.insert(segment.id(), self.sets.age) != Some(self.sets.age) + { let before = segment.all_prev_segments(); - pending.extend(before.into_iter().filter(|it| is_returned(&self.segments, *it))); + pending.extend( + before + .into_iter() + .filter(|it| is_returned(&self.segments, *it)), + ); } continue; } @@ -219,7 +238,11 @@ impl<'a> State<'a> { return true; }; let span = statement.span(); - if scope.traversed_try_blocks.iter().any(|block| block.contains(span)) { + if scope + .traversed_try_blocks + .iter() + .any(|block| block.contains(span)) + { return false; } self.useless_count -= usize::from(std::mem::take(is_useless)); @@ -235,7 +258,10 @@ impl<'a> State<'a> { }; if !scope.is_active || !scope.code_path.is_current_reachable() - || self.in_loop_or_finally.find(statement.into(), is_in_loop_or_finally) == Some(true) + || self + .in_loop_or_finally + .find(statement.into(), is_in_loop_or_finally) + == Some(true) { return; } @@ -318,13 +344,21 @@ fn what_is_known<'a>(current: Node<'a>, parent: Node<'a>) -> Option { | StmtTag::While | StmtTag::DoWhile ) || matches!(next.as_return(), Some(Some(_))); - return Some(if is_executed { Known::Useful } else { Known::Nothing }); + return Some(if is_executed { + Known::Useful + } else { + Known::Nothing + }); } match parent { // A `return` in a static block is an error. - Node::Func(func) if list.is_some() && func.kind() != FnKind::StaticBlock => Some(Known::Useless), + Node::Func(func) if list.is_some() && func.kind() != FnKind::StaticBlock => { + Some(Known::Useless) + } Node::Stmt(parent) => match parent.tag() { - StmtTag::While | StmtTag::DoWhile | StmtTag::For | StmtTag::ForIn | StmtTag::ForOf => Some(Known::Useful), + StmtTag::While | StmtTag::DoWhile | StmtTag::For | StmtTag::ForIn | StmtTag::ForOf => { + Some(Known::Useful) + } StmtTag::If => None, StmtTag::Block if parent.as_block().is_some() => None, _ => Some(Known::Nothing), @@ -339,11 +373,16 @@ fn oxlint_is_at_the_end<'a>(child: Node<'a>, parent: Node<'a>) -> Option { let is_last = |list: List<'a, Stmt<'a>>| list.last().map(Node::Stmt) == Some(child); match parent { // A `return` in a static block is an error. - Node::Func(func) => Some(func.kind() != FnKind::StaticBlock && func.body_statements().is_some_and(is_last)), + Node::Func(func) => { + Some(func.kind() != FnKind::StaticBlock && func.body_statements().is_some_and(is_last)) + } Node::Case(_) => None, Node::Stmt(parent) => match parent.kind() { StmtKind::Block(list) if !is_last(list) => Some(false), - StmtKind::Try { finalizer: Some(finalizer), .. } if Node::Stmt(finalizer) == child => Some(false), + StmtKind::Try { + finalizer: Some(finalizer), + .. + } if Node::Stmt(finalizer) == child => Some(false), _ => parent.is_loop().then_some(false), }, _ => Some(false), @@ -385,8 +424,12 @@ fn oxlint_executes_after<'a>(child: Node<'a>, case: Case<'a>) -> Option { return None; }; let body = case.body(); - let rest = std::iter::successors(body.after(child.span().start), |it| body.after(it.span().start)); - let later = std::iter::successors(cases.after(case.span().start), |it| cases.after(it.span().start)); + let rest = std::iter::successors(body.after(child.span().start), |it| { + body.after(it.span().start) + }); + let later = std::iter::successors(cases.after(case.span().start), |it| { + cases.after(it.span().start) + }); oxlint_executes(&mut rest.chain(later.flat_map(|it| it.body().iter()))) } @@ -394,14 +437,17 @@ impl NoUselessReturn { /// oxlint does not follow the ways through a function: it asks what is written after the `return`. fn is_useless_for_oxlint<'a>(statement: Stmt<'a>, state: &mut State<'a>) -> bool { let at_the_end = &mut state.oxlint_at_the_end; - let is_useless = state.oxlint_useless.find(statement.into(), |child, parent| match parent { - Node::Case(case) => match oxlint_executes_after(child, case)? { - true => Some(false), - // It looks no further, not after the `switch` that the `break` leaves either. - false => Some(at_the_end.find(parent, oxlint_is_at_the_end) == Some(true)), - }, - _ => oxlint_is_at_the_end(child, parent), - }); + let is_useless = + state + .oxlint_useless + .find(statement.into(), |child, parent| match parent { + Node::Case(case) => match oxlint_executes_after(child, case)? { + true => Some(false), + // It looks no further, not after the `switch` that the `break` leaves either. + false => Some(at_the_end.find(parent, oxlint_is_at_the_end) == Some(true)), + }, + _ => oxlint_is_at_the_end(child, parent), + }); is_useless == Some(true) } @@ -409,7 +455,9 @@ impl NoUselessReturn { fn check_code_path<'a>(&self, root: Node<'a>, cx: &mut Cx<'a, Self>) { for step in steps_of_code_path(root, STATEMENTS, [StmtTag::Block, StmtTag::Try]) { match step { - Step::Event(Event::CodePathStart(path, node)) => self.on_code_path_start(path, node == root, cx), + Step::Event(Event::CodePathStart(path, node)) => { + self.on_code_path_start(path, node == root, cx) + } Step::Event(Event::CodePathEnd(..)) => self.on_code_path_end(cx), Step::Event(Event::SegmentStart(segment, _)) => self.on_segment_start(segment, cx), Step::Event(_) => {} @@ -424,7 +472,12 @@ impl NoUselessReturn { state.used_unreachable.clear(); } - fn on_code_path_start<'a>(&self, code_path: CodePath<'a>, is_active: bool, cx: &mut Cx<'a, Self>) { + fn on_code_path_start<'a>( + &self, + code_path: CodePath<'a>, + is_active: bool, + cx: &mut Cx<'a, Self>, + ) { cx.state.scopes.push(ScopeInfo { code_path, is_active, @@ -449,7 +502,11 @@ impl NoUselessReturn { let is_removable = ast_utils::is_statement_list_parent(statement.parent()) && fixer.file().comments_in(statement).next().is_none(); // The whole function, so that this does not conflict with `no-else-return`. - is_removable.then(|| FixTracker::new(fixer).retain_enclosing_function(statement).remove(statement)) + is_removable.then(|| { + FixTracker::new(fixer) + .retain_enclosing_function(statement) + .remove(statement) + }) }); } @@ -492,7 +549,8 @@ impl NoUselessReturn { StmtKind::Fn(func) if func.has_body() => innermost.wrapping_sub(1), _ => innermost, }; - cx.state.mark_return_statements_on_current_segments_as_used(scope); + cx.state + .mark_return_statements_on_current_segments_as_used(scope); } fn exit_statement<'a>(&self, node: Node<'a>, cx: &mut Cx<'a, Self>) { @@ -520,7 +578,9 @@ impl NoUselessReturn { } impl Rule for NoUselessReturn { - const META: Meta = Meta::eslint("no-useless-return", Kind::Suggestion).fixable(Fixable::Code).reports_on_code_path_end(); + const META: Meta = Meta::eslint("no-useless-return", Kind::Suggestion) + .fixable(Fixable::Code) + .reports_on_code_path_end(); const ON: On = On::new().stmts(&[StmtTag::Return]).finish(); type State<'a> = State<'a>; @@ -546,7 +606,10 @@ impl Rule for NoUselessReturn { if matches!(known, Some(Known::Useful)) { return; } - let function = cx.state.functions.find(statement.into(), |_, parent| parent.as_func()); + let function = cx + .state + .functions + .find(statement.into(), |_, parent| parent.as_func()); let root = function.map_or_else(|| Node::File(cx.file()), Node::Func); match known { Some(Known::Useless) => cx.state.at_the_end.push((root, statement)), @@ -563,7 +626,8 @@ impl Rule for NoUselessReturn { self.report(statement, cx); } } - for root in std::mem::take(&mut cx.state.roots) { + let as_entered = |it: &Node<'_>| (it.span().start, std::cmp::Reverse(it.span().end)); + for root in utils::sort::in_order_of(std::mem::take(&mut cx.state.roots), as_entered) { self.check_code_path(root, cx); } } diff --git a/src/lint/eslint/rules/require_await.rs b/src/lint/eslint/rules/require_await.rs index f83bf37286ae..b8f7aeee1ebc 100644 --- a/src/lint/eslint/rules/require_await.rs +++ b/src/lint/eslint/rules/require_await.rs @@ -111,6 +111,7 @@ fn report<'a>(func: Func<'a>, cx: &Cx<'a, RequireAwait>) { false => ast_utils::get_function_head_loc(func), }; cx.report(place, MISSING_AWAIT) + .called_with(func) .data("name", name) .suggest_change_of_behavior(REMOVE_ASYNC, &[], |fixer| { let file = fixer.file(); diff --git a/src/lint/graph/lib.rs b/src/lint/graph/lib.rs index c5e897555d6f..07876cbe9b8b 100644 --- a/src/lint/graph/lib.rs +++ b/src/lint/graph/lib.rs @@ -17,6 +17,7 @@ //! resolved as Node.js resolves them, after `paths`: [`AsRequire::as_import`]. #![forbid(unsafe_code)] +#![deny(clippy::iter_over_hash_type)] use bun_core::strings; use bun_glob::{BunGlobWalker, Pattern}; diff --git a/src/lint/jest/jest_expect.rs b/src/lint/jest/jest_expect.rs index 913be4a5f984..c90676119b1c 100644 --- a/src/lint/jest/jest_expect.rs +++ b/src/lint/jest/jest_expect.rs @@ -408,7 +408,7 @@ pub(crate) mod valid_expect_in_promise { }; let (mut pending_promises, mut return_found) = (PendingPromises::default(), false); process_statements(statements, &mut pending_promises, &mut return_found, ctx); - for span in pending_promises.values() { + for span in utils::sort::in_order_of(pending_promises.values(), |it| it.start) { ctx.report(*span, EXPECT_IN_UNHANDLED_PROMISE); } } diff --git a/src/lint/jest/jest_matchers.rs b/src/lint/jest/jest_matchers.rs index 459f90fa0890..4d2204d0192f 100644 --- a/src/lint/jest/jest_matchers.rs +++ b/src/lint/jest/jest_matchers.rs @@ -85,7 +85,8 @@ pub(crate) mod prefer_snapshot_hint { .push(node); } } - for expects in scoped_expects.values() { + let first = |it: &&SmallVec<[Expr<'a>; 2]>| it.first().map(|it| it.span().start); + for expects in utils::sort::in_order_of(scoped_expects.values(), first) { if self == SnapshotHintMode::Always || expects.len() > 1 { expects.iter().for_each(|it| check_expect(*it, ctx)); } diff --git a/src/lint/jest/jest_tests.rs b/src/lint/jest/jest_tests.rs index a6cfd69b4b39..27ade06e75db 100644 --- a/src/lint/jest/jest_tests.rs +++ b/src/lint/jest/jest_tests.rs @@ -896,7 +896,10 @@ pub(crate) mod no_identical_title { // between `it('a')` and `test('a')` then keeps these two apart. It starts from the order of its list, and the sort is not // stable for more than 20: the same sort of as large elements in the same order does the same. let mut order = None; - for calls in title_to_calls.values_mut().filter(|it| it.len() > 1) { + let several = title_to_calls.values_mut().filter(|it| it.len() > 1); + let first = + |it: &&mut SmallVec<[Titled<'a>; 1]>| it.first().map(|it| it.argument.span().start); + for calls in utils::sort::in_order_of(several, first) { let order = order.get_or_insert_with(|| OxlintOrder::new(ctx.file)); utils::sort::sort_by_cached_key(calls, |it| order.key(it.possible_jest_node)); let mut kind_and_spans: Vec<(Span, bool, u32)> = calls diff --git a/src/lint/jest/lib.rs b/src/lint/jest/lib.rs index b675af9708a5..a85a9cc75ff2 100644 --- a/src/lint/jest/lib.rs +++ b/src/lint/jest/lib.rs @@ -7,6 +7,7 @@ //! The rules of `bun:test` are the same rules for the tests of `bun test`. They are Bun's own, and exist with every configuration. #![forbid(unsafe_code)] +#![deny(clippy::iter_over_hash_type)] mod jest; mod jest_expect; diff --git a/src/lint/jest/rules/vitest_prefer_called_exactly_once_with.rs b/src/lint/jest/rules/vitest_prefer_called_exactly_once_with.rs index f170a124113f..ad3292655c8b 100644 --- a/src/lint/jest/rules/vitest_prefer_called_exactly_once_with.rs +++ b/src/lint/jest/rules/vitest_prefer_called_exactly_once_with.rs @@ -1,8 +1,8 @@ -use bun_lint_oxlint::ast_util::{as_member_expression, callee_name}; use crate::jest::{self, ParsedExpectFnCall, ParsedJestFnCall, PossibleJestNode}; use bun_core::strings; use bun_lint::prelude::*; use bun_lint::rule::Plugin; +use bun_lint_oxlint::ast_util::{as_member_expression, callee_name}; use rustc_hash::FxHashMap; /// It checks when a target is asserted with both `toHaveBeenCalledOnce` and `toHaveBeenCalledWith` instead of @@ -15,8 +15,12 @@ const PREFER_CALLED_EXACTLY_ONCE_WITH: Message = Message::new( ); impl Rule for PreferCalledExactlyOnceWith { - const META: Meta = - Meta::oxlint(Plugin::Vitest, "prefer-called-exactly-once-with", Kind::Suggestion).fixable(Fixable::Code); + const META: Meta = Meta::oxlint( + Plugin::Vitest, + "prefer-called-exactly-once-with", + Kind::Suggestion, + ) + .fixable(Fixable::Code); const ON: On = On::new().stmts(&[StmtTag::Block]).finish(); type State<'a> = (); @@ -25,7 +29,8 @@ impl Rule for PreferCalledExactlyOnceWith { } fn start<'a>(&self, file: &'a File<'a>) -> Option<()> { - (file.mentions("toHaveBeenCalledOnce") && file.mentions("toHaveBeenCalledWith")).then_some(()) + (file.mentions("toHaveBeenCalledOnce") && file.mentions("toHaveBeenCalledWith")) + .then_some(()) } fn stmt<'a>(&self, block_statement: Stmt<'a>, cx: &mut Cx<'a, Self>) { @@ -86,10 +91,15 @@ fn check_statements<'a>( let StmtKind::Expr(expression) = statement.kind() else { continue; }; - let Some(call_expr) = expression.as_call().filter(|_| !expression.is_parenthesized() && !expression.is_chain_root()) else { + let Some(call_expr) = expression + .as_call() + .filter(|_| !expression.is_parenthesized() && !expression.is_chain_root()) + else { continue; }; - if callee_name(call_expr).is_some_and(|it| it.is_any(&["mockClear", "mockReset", "mockRestore"])) { + if callee_name(call_expr) + .is_some_and(|it| it.is_any(&["mockClear", "mockReset", "mockRestore"])) + { if let Some(identify) = as_member_expression(call_expr.callee()).and_then(Expr::object) && let Some(name) = identify.as_ident().filter(|_| !identify.is_parenthesized()) { @@ -97,22 +107,36 @@ fn check_statements<'a>( } continue; } - let expect_call = match jest::parse_jest_fn_call(file, PossibleJestNode::new(expression, jest::Runner::Vitest)) { + let expect_call = match jest::parse_jest_fn_call( + file, + PossibleJestNode::new(expression, jest::Runner::Vitest), + ) { Some(ParsedJestFnCall::GeneralJest(_)) => { - let callback = call_expr.args().iter().rev().find_map(|it| it.as_fn().filter(|_| !it.is_parenthesized())); + let callback = call_expr + .args() + .iter() + .rev() + .find_map(|it| it.as_fn().filter(|_| !it.is_parenthesized())); pending.extend(callback.and_then(Func::body_statements)); continue; } Some(ParsedJestFnCall::Expect(expect_call)) => expect_call, _ => continue, }; - let Some((variable_expected_name, matcher)) = get_identifier_and_matcher_to_be_expected(&expect_call) else { + let Some((variable_expected_name, matcher)) = + get_identifier_and_matcher_to_be_expected(&expect_call) + else { continue; }; let called_with = (matcher == MatcherKind::ToHaveBeenCalledWith).then_some(call_expr); let Some(expects) = variables_expected.get_mut(&variable_expected_name) else { let span_to_substitute = expression.span(); - let expects = TrackingExpectPair { first: matcher, span_to_substitute, span_to_remove: None, called_with }; + let expects = TrackingExpectPair { + first: matcher, + span_to_substitute, + span_to_remove: None, + called_with, + }; variables_expected.insert(variable_expected_name, expects); continue; }; @@ -124,8 +148,12 @@ fn check_statements<'a>( variables_expected.remove(&variable_expected_name); } } - for (identifier, expects) in &variables_expected { - let (Some(span_to_remove), Some(called_with)) = (expects.span_to_remove, expects.called_with) else { + for (identifier, expects) in + utils::sort::in_order_of(&variables_expected, |it| it.1.span_to_substitute.start) + { + let (Some(span_to_remove), Some(called_with)) = + (expects.span_to_remove, expects.called_with) + else { continue; }; let report = cx @@ -138,23 +166,37 @@ fn check_statements<'a>( b"expect(".as_slice(), identifier.as_slice(), b").toHaveBeenCalledExactlyOnceWith".as_slice(), - file.slice(called_with.type_args().angle_brackets_span().unwrap_or_default()), + file.slice( + called_with + .type_args() + .angle_brackets_span() + .unwrap_or_default(), + ), b"(".as_slice(), arguments.as_slice(), b")".as_slice(), ]; - [fixer.replace(expects.span_to_substitute, substitute.concat()), fixer.remove(span_to_remove)] + [ + fixer.replace(expects.span_to_substitute, substitute.concat()), + fixer.remove(span_to_remove), + ] }); } } -fn get_identifier_and_matcher_to_be_expected(expect_call: &ParsedExpectFnCall) -> Option<(Vec, MatcherKind)> { +fn get_identifier_and_matcher_to_be_expected( + expect_call: &ParsedExpectFnCall, +) -> Option<(Vec, MatcherKind)> { let matcher = match expect_call.matcher()?.name()? { b"toHaveBeenCalledOnce" => MatcherKind::ToHaveBeenCalledOnce, b"toHaveBeenCalledWith" => MatcherKind::ToHaveBeenCalledWith, _ => return None, }; - if expect_call.members.iter().any(|member| member.is_name_equal("not")) { + if expect_call + .members + .iter() + .any(|member| member.is_name_equal("not")) + { return None; } Some((text_of_arguments(expect_call.expect_arguments?), matcher)) @@ -162,7 +204,14 @@ fn get_identifier_and_matcher_to_be_expected(expect_call: &ParsedExpectFnCall) - /// From the start of the line to the character after the statement, which is taken to be the line break. fn get_source_code_line_span(statement_span: Span, file: &File) -> Span { - let before = file.text().get(..statement_span.start as usize).unwrap_or_default(); - let column_0 = strings::last_index_of_char(before, b'\n').map_or(statement_span.start, |index| index as u32 + 1); - Span::new(column_0, (statement_span.end + 1).min(file.text().len() as u32)) + let before = file + .text() + .get(..statement_span.start as usize) + .unwrap_or_default(); + let column_0 = strings::last_index_of_char(before, b'\n') + .map_or(statement_span.start, |index| index as u32 + 1); + Span::new( + column_0, + (statement_span.end + 1).min(file.text().len() as u32), + ) } diff --git a/src/lint/language.rs b/src/lint/language.rs index 4ebc562afe38..85aad3877a91 100644 --- a/src/lint/language.rs +++ b/src/lint/language.rs @@ -86,6 +86,78 @@ impl InferGlobals { } } +/// What `$env` and `globals` of `languageOptions` make: the fields of [`LanguageOptions`] about them. Most of the configurations +/// of a run have the same, and `all` is long. +#[derive(Clone, Debug)] +pub(crate) struct GlobalsOfOptions { + all: Arc, Global)>>, + written: Vec>, + env_behind_off: Vec<(Box<[u8]>, Global)>, + envs: Vec>, + own: Vec<(Box<[u8]>, Global)>, +} + +impl GlobalsOfOptions { + /// `whose`: whose environments `$env` names. + pub(crate) fn new(language_options: &Json, whose: Tables) -> GlobalsOfOptions { + let mut globals: Vec<(Box<[u8]>, Global)> = Vec::new(); + let mut written_globals: Vec> = Vec::new(); + let mut envs: Vec> = Vec::new(); + let mut own_globals: Vec<(Box<[u8]>, Global)> = Vec::new(); + // `env` of an `.eslintrc` or an `.oxlintrc.json`. + for (name, is_enabled) in language_options + .get(b"$env") + .and_then(Json::as_object) + .unwrap_or_default() + { + // oxlint has an `es6` of its own. + let name: &[u8] = match whose { + Tables::Today | Tables::Eslint8 if name == b"es6" => b"es2015", + _ => name, + }; + if is_enabled.as_bool() == Some(true) { + envs.push(name.into()); + let variables = environment(name, whose).into_iter().flatten(); + globals.extend(variables.map(|(name, setting)| (name.into(), setting))); + } + } + for (name, value) in language_options + .get(b"globals") + .and_then(Json::as_object) + .unwrap_or_default() + { + if let Some(value) = Global::of_json(value) { + globals.push((name[..].into(), value)); + own_globals.push((name[..].into(), value)); + if value != Global::Off { + written_globals.push(name[..].into()); + } + } + } + crate::utils::sort::sort_unstable(&mut written_globals); + crate::utils::sort::sort_unstable(&mut envs); + crate::utils::sort::sort_by(&mut own_globals, |a, b| a.0.cmp(&b.0)); + // The last of two entries with the same name counts. + globals.reverse(); + crate::utils::sort::sort_by(&mut globals, |a, b| a.0.cmp(&b.0)); + let mut env_behind_off: Vec<(Box<[u8]>, Global)> = Vec::new(); + globals.dedup_by(|dropped, kept| { + let is_same = dropped.0 == kept.0; + if is_same && whose == Tables::Oxlint && kept.1 == Global::Off { + env_behind_off.push(dropped.clone()); + } + is_same + }); + GlobalsOfOptions { + all: Arc::new(globals), + written: written_globals, + env_behind_off, + envs, + own: own_globals, + } + } +} + #[derive(Clone, Debug)] pub struct LanguageOptions { /// The year: 2015, 2016, .. For ES3 and ES5, 3 and 5. @@ -93,7 +165,7 @@ pub struct LanguageOptions { pub source_type: SourceType, /// `languageOptions.globals`. Sorted by name. What the ECMAScript version defines is not /// listed here. - pub globals: Vec<(Box<[u8]>, Global)>, + pub globals: Arc, Global)>>, /// The names that `globals` itself turns on, without those of an `env`. Sorted. pub written_globals: Vec>, /// With an oxlint config: what an `env` says about each variable that `globals` turns off. See @@ -296,6 +368,16 @@ impl LanguageOptions { language_options: &Json, settings: Arc, whose: Tables, + ) -> LanguageOptions { + let globals = GlobalsOfOptions::new(language_options, whose); + Self::from_json_with(language_options, settings, globals) + } + + /// The same. `globals`: [`GlobalsOfOptions::new`] of `language_options`, for who has made it before. + pub(crate) fn from_json_with( + language_options: &Json, + settings: Arc, + globals: GlobalsOfOptions, ) -> LanguageOptions { let parser_options = language_options .get(b"parserOptions") @@ -322,65 +404,17 @@ impl LanguageOptions { let is_babel = written == Some(&b"@babel/eslint-parser"[..]); let source_type = source_type_of(language_options.get(b"sourceType")).unwrap_or(SourceType::Module); - let mut globals: Vec<(Box<[u8]>, Global)> = Vec::new(); - let mut written_globals: Vec> = Vec::new(); - let mut envs: Vec> = Vec::new(); - let mut own_globals: Vec<(Box<[u8]>, Global)> = Vec::new(); - // `env` of an `.eslintrc` or an `.oxlintrc.json`. - for (name, is_enabled) in language_options - .get(b"$env") - .and_then(Json::as_object) - .unwrap_or_default() - { - // oxlint has an `es6` of its own. - let name: &[u8] = match whose { - Tables::Today | Tables::Eslint8 if name == b"es6" => b"es2015", - _ => name, - }; - if is_enabled.as_bool() == Some(true) { - envs.push(name.into()); - let variables = environment(name, whose).into_iter().flatten(); - globals.extend(variables.map(|(name, setting)| (name.into(), setting))); - } - } - for (name, value) in language_options - .get(b"globals") - .and_then(Json::as_object) - .unwrap_or_default() - { - if let Some(value) = Global::of_json(value) { - globals.push((name[..].into(), value)); - own_globals.push((name[..].into(), value)); - if value != Global::Off { - written_globals.push(name[..].into()); - } - } - } - crate::utils::sort::sort_unstable(&mut written_globals); - crate::utils::sort::sort_unstable(&mut envs); - crate::utils::sort::sort_by(&mut own_globals, |a, b| a.0.cmp(&b.0)); - // The last of two entries with the same name counts. - globals.reverse(); - crate::utils::sort::sort_by(&mut globals, |a, b| a.0.cmp(&b.0)); - let mut env_behind_off: Vec<(Box<[u8]>, Global)> = Vec::new(); - globals.dedup_by(|dropped, kept| { - let is_same = dropped.0 == kept.0; - if is_same && whose == Tables::Oxlint && kept.1 == Global::Off { - env_behind_off.push(dropped.clone()); - } - is_same - }); LanguageOptions { ecma_version: match language_options.get(b"ecmaVersion") { None => Self::LATEST_ECMA_VERSION, version => Self::normalize_ecma_version(version), }, source_type, - globals, - written_globals, - env_behind_off, - envs, - own_globals, + globals: globals.all, + written_globals: globals.written, + env_behind_off: globals.env_behind_off, + envs: globals.envs, + own_globals: globals.own, parser, is_babel, // ESLint turns it off for espree in a module. Babel's parser passes over it there. @@ -501,7 +535,7 @@ impl Default for LanguageOptions { LanguageOptions { ecma_version: Self::LATEST_ECMA_VERSION, source_type: SourceType::Module, - globals: Vec::new(), + globals: Arc::default(), written_globals: Vec::new(), env_behind_off: Vec::new(), envs: Vec::new(), diff --git a/src/lint/lib.rs b/src/lint/lib.rs index 7635f3f1ac5d..ce026f9ac4fa 100644 --- a/src/lint/lib.rs +++ b/src/lint/lib.rs @@ -13,6 +13,7 @@ //! and `bun_lint_jest`. #![forbid(unsafe_code)] +#![deny(clippy::iter_over_hash_type)] // For what only words an error, like the message for a file that doesn't parse or for an option that is refused: // `#[optimize(size)]` roughly halves it, and how fast it is doesn't matter. Nothing that runs for a file without errors has it. #![feature(optimize_attribute)] diff --git a/src/lint/linter/config/cache.rs b/src/lint/linter/config/cache.rs index b35a025f2a14..baa4fbd1b64e 100644 --- a/src/lint/linter/config/cache.rs +++ b/src/lint/linter/config/cache.rs @@ -1,12 +1,13 @@ //! What a [`Config`](super::Config) computes once and all threads share. use crate::js_plugin::{Configured, Rule}; +use crate::language::GlobalsOfOptions; use crate::linter::resolved::ResolvedConfig; use crate::options::Json; use bun_threading::RwLock; -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; -type Resolved = Vec<(Box<[u32]>, Arc)>; +type Resolved = Vec<(Box<[u32]>, Arc>>)>; type JsInstances = Vec<(Arc<[Json]>, Arc)>; pub(super) struct Cache { @@ -16,6 +17,8 @@ pub(super) struct Cache { js_rules: RwLock, /// For each rule, by its index in the registry: the options that its schema allows. valid: RwLock>>>, + /// By `$env` and `globals` of `languageOptions`. + globals: RwLock, Option, GlobalsOfOptions)>>, } impl Default for Cache { @@ -24,6 +27,7 @@ impl Default for Cache { resolved: RwLock::new(Vec::new()), js_rules: RwLock::new(Vec::new()), valid: RwLock::new(Vec::new()), + globals: RwLock::new(Vec::new()), } } } @@ -35,22 +39,47 @@ impl Cache { make: impl FnOnce() -> ResolvedConfig, ) -> Arc { let find = |all: &Resolved| all.binary_search_by(|it| (*it.0).cmp(indices)); - { + let known = { let all = self.resolved.read(); - if let Ok(at) = find(&all) { - return Arc::clone(&all[at].1); - } - } - // Not under the lock: it takes the other one. Two threads may do the same work once. - let made = Arc::new(make()); - let mut all = self.resolved.write(); - match find(&all) { - Ok(at) => Arc::clone(&all[at].1), - Err(at) => { - all.insert(at, (indices.into(), Arc::clone(&made))); - made + find(&all).ok().map(|at| Arc::clone(&all[at].1)) + }; + let place = known.unwrap_or_else(|| { + let mut all = self.resolved.write(); + match find(&all) { + Ok(at) => Arc::clone(&all[at].1), + Err(at) => { + let place = Arc::default(); + all.insert(at, (indices.into(), Arc::clone(&place))); + place + } } + }); + // Not under the lock: it takes the other ones. A thread that wants the same waits for it. + Arc::clone(place.get_or_init(|| Arc::new(make()))) + } + + /// [`GlobalsOfOptions::new`] of `language_options`, which is what `make` does. + pub(super) fn globals( + &self, + language_options: &Json, + make: impl FnOnce() -> GlobalsOfOptions, + ) -> GlobalsOfOptions { + let key = ( + language_options.get(b"$env"), + language_options.get(b"globals"), + ); + let known = self.globals.read(); + if let Some(found) = known + .iter() + .find(|it| (it.0.as_ref(), it.1.as_ref()) == key) + { + return found.2.clone(); } + drop(known); + let made = make(); + let entry = (key.0.cloned(), key.1.cloned(), made.clone()); + self.globals.write().push(entry); + made } /// Whether `options` are [known](Cache::set_valid) to be what the schema of the rule at `index` of the registry allows. diff --git a/src/lint/linter/config/mod.rs b/src/lint/linter/config/mod.rs index 9506e1c9e2c7..281287de1be1 100644 --- a/src/lint/linter/config/mod.rs +++ b/src/lint/linter/config/mod.rs @@ -74,7 +74,7 @@ use super::resolved::{ use super::schema; use crate::context::Severity; use crate::js_plugin; -use crate::language::{InferGlobals, LanguageOptions}; +use crate::language::{GlobalsOfOptions, InferGlobals, LanguageOptions}; use crate::options::Json; use crate::paths; use crate::rule::{Meta, Plugin}; @@ -353,9 +353,9 @@ struct Heads { /// For each object, the number of its first element, and whether all of its elements are listed. objects: Vec<(u32, bool)>, /// For each extension that a pattern ends with, [packed](packed_extension): what can match a file that has it. - by_extension: Vec<(u64, Listed)>, - /// What can match a file with any other extension, or without one. - without_extension: Listed, + by_extension: Vec<(u64, OfExtension)>, + /// The same for a file with any other extension, or without one. + without_extension: OfExtension, /// A bit for each object that is [about directories](ConfigObject::is_about_directories). about_directories: Bits, } @@ -376,6 +376,14 @@ struct Listed { objects: Bits, } +/// What is known of the files with an extension. +#[derive(Default)] +struct OfExtension { + can_match: Listed, + /// One of these elements makes ESLint lint what it matches. + can_be_linted: bool, +} + /// What a path starts with that all of `patterns` match. One of them that says so is enough. fn heads_of(patterns: &[Pattern]) -> Option> { let says = |it: &&Pattern| !it.is_inverted && !it.is_comment; @@ -428,7 +436,11 @@ impl Heads { found.objects[at / 64] |= 1 << (at % 64); } } - found + let makes_linted = |it: &'o Vec| !it.iter().all(|it| it.is_universal); + OfExtension { + can_match: found, + can_be_linted: elements().any(|it| can_match(it) && makes_linted(it)), + } }; let mut extensions: Vec = elements().flat_map(extensions_of).copied().collect(); extensions.sort_unstable(); @@ -444,7 +456,7 @@ impl Heads { } /// What can match `path`, going by its extension. - fn of_extension(&self, path: &[u8]) -> &Listed { + fn of_extension(&self, path: &[u8]) -> &OfExtension { // A `/` in it is in no extension that is known. let extension = bun_core::strings::last_index_of_char(path, b'.') .and_then(|at| packed_extension(path.get(at + 1..)?)); @@ -563,6 +575,8 @@ pub struct Within<'c> { /// The objects that were merged last, and what they make. merged: Option<(Indices, Arc)>, said: Said, + /// [`Within::outside_of_react`] + is_outside_of_react: bool, } /// For objects that are [about directories](ConfigObject::is_about_directories), by their index: whether it is for the files of @@ -594,6 +608,13 @@ impl Entry<'_> { } impl Within<'_> { + /// `is_it`: the directory is of a project that doesn't use React, so [`Within::get`] is to leave out what is for React + /// alone. It saves making the configuration with that: see [`ResolvedConfig::outside_of_react`]. + pub fn outside_of_react(mut self, is_it: bool) -> Self { + self.is_outside_of_react = is_it && self.config.objects.iter().any(|it| it.is_for_react); + self + } + /// The entry at `path`, and what the patterns see of it, which has a slash behind it for a directory. `None`: see /// [`BeforeName`]. fn see<'w>( @@ -679,9 +700,18 @@ impl Within<'_> { } config.matching(path, &parts, Some(&entry), must_match) }; - let Some(matching) = matching else { + let Some(mut matching) = matching else { return FileConfig::Unconfigured; }; + if self.is_outside_of_react { + let is_for_react = |index: u32| { + config + .objects + .get(index as usize) + .is_some_and(|it| it.is_for_react) + }; + matching.retain(|index| !is_for_react(*index)); + } // Most files of a directory have the same. let merged = match self.merged.take().filter(|it| it.0 == matching) { Some(merged) => merged, @@ -947,6 +977,7 @@ impl Config { relative: seen.before_name.clone().unwrap_or_default(), merged: None, said: Said::default(), + is_outside_of_react: false, } } @@ -1048,7 +1079,11 @@ impl Config { let mut matching = Indices::new(); let mut is_matched = false; let listed = self.heads.of(parts.path()); - let by_extension = self.heads.of_extension(parts.path()); + let of_extension = self.heads.of_extension(parts.path()); + if must_match && !of_extension.can_be_linted { + return None; + } + let by_extension = &of_extension.can_match; for (index, object) in self.objects.iter().enumerate() { if !has_bit(&by_extension.objects, index) || (listed.as_ref()).is_some_and(|found| !self.heads.allows_object(found, index)) @@ -1324,7 +1359,9 @@ impl Config { // oxlint has no such setting. config.has_unknown_resolver = !self.prefers_typescript_rules && flat::names_unknown_resolver(&settings); - config.language = LanguageOptions::from_json_for(&language_options, settings, whose); + let make = || GlobalsOfOptions::new(&language_options, whose); + let globals = self.cache.globals(&language_options, make); + config.language = LanguageOptions::from_json_with(&language_options, settings, globals); // oxlint has no `parser`. config.language.refuses_what_parser_refuses = !self.prefers_typescript_rules; config.language.is_oxlint = self.prefers_typescript_rules; diff --git a/src/lint/linter/config/rc.rs b/src/lint/linter/config/rc.rs index fabeaa48640c..2216dcb0ca76 100644 --- a/src/lint/linter/config/rc.rs +++ b/src/lint/linter/config/rc.rs @@ -25,8 +25,9 @@ use crate::options::Json; use crate::paths; use crate::rule::{Meta, Plugin}; use bun_core::strings; -use rustc_hash::FxHashMap; +use rustc_hash::{FxBuildHasher, FxHashMap}; use std::borrow::Cow; +use std::hash::BuildHasher; use std::sync::{Arc, LazyLock}; /// Whether `word` is one of the words of `list`, which have spaces between them. @@ -330,21 +331,31 @@ fn written_rules(json: &Json) -> Vec { } /// oxlint's `Oxlintrc::merge`, of the rules: those of `extended` that `own` does not name are added, and all of them come out -/// in the order of its hash table. The same hashes in the same table give the same order. +/// in the order of its hash table, as that is on x86-64. fn merged(own: Vec, extended: Vec) -> Vec { fn text(it: &[u8]) -> &str { std::str::from_utf8(it).unwrap_or_default() } let mut all: Vec> = own.into_iter().chain(extended).map(Some).collect(); - let mut table: FxHashMap<(&str, &str), (usize, bool)> = FxHashMap::default(); + // The first of each name, in the order in which they get into the table, and the hashes of the names. + let mut firsts: Vec<(usize, bool)> = Vec::new(); + let mut hashes: Vec = Vec::new(); + let mut places: FxHashMap<(&str, &str), usize> = FxHashMap::default(); for (at, it) in all.iter().flatten().enumerate() { - table - .entry((text(&it.plugin), text(&it.name))) - .and_modify(|first| first.1 = true) - .or_insert((at, it.is_kept)); + let name = (text(&it.plugin), text(&it.name)); + match places.get(&name).and_then(|place| firsts.get_mut(*place)) { + Some(first) => first.1 = true, + None => { + places.insert(name, firsts.len()); + hashes.push(FxBuildHasher.hash_one(name) as u32); + firsts.push((at, it.is_kept)); + } + } } - let mut order = Vec::with_capacity(table.len()); - order.extend(table.into_values()); + let order = crate::semantic::order_in_table(&hashes, 0); + let order: Vec<(usize, bool)> = (order.iter()) + .filter_map(|place| firsts.get(*place as usize).copied()) + .collect(); let in_order = order.iter().filter_map(|&(at, is_kept)| { let it = all.get_mut(at)?.take()?; Some(Written { is_kept, ..it }) @@ -566,7 +577,9 @@ impl Rc<'_, '_> { let known = last.entry(oxlint_rule_key(&it.id)).or_default(); *known = (at, known.1 + 1 + usize::from(it.is_kept)); } - let later = (last.into_values().filter(|it| it.1 > 1)).filter_map(|it| rules.get(it.0)); + let later = last.into_values().filter(|it| it.1 > 1); + let later = crate::utils::sort::in_order_of(later, |it| it.0); + let later = later.iter().filter_map(|it| rules.get(it.0)); let later: Vec<_> = later .map(|it| (it.id.clone(), it.setting.clone())) .collect(); diff --git a/src/lint/linter/globals/mod.rs b/src/lint/linter/globals/mod.rs index f9db5b36dc8c..59b2bc8de942 100644 --- a/src/lint/linter/globals/mod.rs +++ b/src/lint/linter/globals/mod.rs @@ -188,7 +188,7 @@ pub fn config_globals_in_order(language: &LanguageOptions) -> Vec<(Cow<'static, add(tables::COMMONJS.0, tables::COMMONJS.1); } let defined = all.len(); - for (name, setting) in &language.globals { + for (name, setting) in language.globals.iter() { match all.iter_mut().take(defined).find(|it| *it.0 == **name) { Some(existing) => existing.1 = *setting, None => all.push((Cow::Owned(name.to_vec()), *setting)), @@ -236,7 +236,7 @@ impl ConfigGlobals { } settings.extend( (language.env_behind_off.iter().rev()) - .chain(&language.globals) + .chain(language.globals.iter()) .map(|(name, setting)| (Cow::Owned(name.to_vec()), *setting)), ); // Of two with the same name, the later one counts. diff --git a/src/lint/linter/syntax/oxc/names.rs b/src/lint/linter/syntax/oxc/names.rs index 0d63f9cb7e78..cf953d340692 100644 --- a/src/lint/linter/syntax/oxc/names.rs +++ b/src/lint/linter/syntax/oxc/names.rs @@ -537,6 +537,10 @@ pub(super) fn redeclarations<'a>(file: &'a File<'a>) -> Vec { return Vec::new(); } let mut all: Vec> = Vec::new(); + #[expect( + clippy::iter_over_hash_type, + reason = "what is found is put in the order of the text below" + )] for name in candidates(file) { for symbol in file.symbols_named(name) { let scope = symbol.scope(); diff --git a/src/lint/oxlint/lib.rs b/src/lint/oxlint/lib.rs index 9275fe10d1f1..0b500e424ec8 100644 --- a/src/lint/oxlint/lib.rs +++ b/src/lint/oxlint/lib.rs @@ -3,6 +3,7 @@ //! https://github.com/oxc-project/oxc (Copyright VoidZero Inc. and contributors, MIT License) #![forbid(unsafe_code)] +#![deny(clippy::iter_over_hash_type)] pub mod ast_util; pub mod codegen; diff --git a/src/lint/oxlint_order/generate-table.mjs b/src/lint/oxlint_order/generate-table.mjs index a06626505933..e3a9bf5f7acc 100644 --- a/src/lint/oxlint_order/generate-table.mjs +++ b/src/lint/oxlint_order/generate-table.mjs @@ -41,7 +41,7 @@ const both = { "eslint/no-restricted-imports": "Once", "import/extensions": "Once", "jest/no-jasmine-globals": "Once", - "unicorn/prefer-number-properties": "Entering", + "unicorn/prefer-number-properties": "Once", }; const kebab = module => module.split("::")[0].replaceAll("_", "-"); @@ -62,7 +62,8 @@ for (const [, plugin, module, body] of impls.matchAll( (kinds ? both[`${plugin}/${name}`] : "Last"); if (!when) throw new Error(`${plugin}/${name} has several functions: see \`both\``); if (when === "Entering" && !kinds) throw new Error(`${plugin}/${name} is called with every node`); - rules.push({ plugin, name, when, kinds: when === "Entering" ? kinds : [] }); + const isCalledWithNodes = when === "Entering" || `${plugin}/${name}` in both; + rules.push({ plugin, name, when, kinds: isCalledWithNodes ? kinds : [] }); } // A rule has no number here: the table is sorted by the plugin and the name when it is read. That has to be oxlint's order. diff --git a/src/lint/oxlint_order/table.rs b/src/lint/oxlint_order/table.rs index b81c392cda01..155420740803 100644 --- a/src/lint/oxlint_order/table.rs +++ b/src/lint/oxlint_order/table.rs @@ -36,9 +36,9 @@ pub(super) static KINDS: &str = concat!( #[rustfmt::skip] pub(super) static RULES: &[(Plugin, When, &str)] = &[ (Plugin::Import, Once, concat!( - "default export exports-last extensions first group-exports max-dependencies named namespace no-cycle ", + "default export exports-last first group-exports max-dependencies named namespace no-cycle ", "no-default-export no-duplicates no-named-as-default no-named-as-default-member no-namespace no-self-import ", - "prefer-default-export unambiguous", + "prefer-default-export unambiguous 17 extensions", )), (Plugin::Import, Entering, concat!( "17 no-amd 17,22,66,75 no-commonjs 17,28,31,44,46 no-relative-parent-imports 17,28,31,44,46,90 ", @@ -48,11 +48,11 @@ pub(super) static RULES: &[(Plugin, When, &str)] = &[ )), (Plugin::Eslint, Once, concat!( "capitalized-comments max-classes-per-file max-lines max-statements no-dupe-class-members ", - "no-duplicate-imports no-extend-native no-global-assign no-implicit-globals no-inline-comments ", - "no-irregular-whitespace no-redeclare no-restricted-globals no-restricted-imports no-shadow ", - "no-shadow-restricted-names no-this-before-super no-undef no-unreachable no-unused-labels ", - "no-unused-private-class-members no-unused-vars no-useless-assignment no-warning-comments one-var ", - "prefer-rest-params sort-imports unicode-bom", + "no-duplicate-imports no-extend-native no-global-assign no-implicit-globals no-inline-comments no-redeclare ", + "no-restricted-globals no-shadow no-shadow-restricted-names no-this-before-super no-undef no-unreachable ", + "no-unused-labels no-unused-private-class-members no-unused-vars no-useless-assignment no-warning-comments ", + "one-var prefer-rest-params sort-imports unicode-bom 46,90 no-restricted-imports 54,70,76,118 ", + "no-irregular-whitespace", )), (Plugin::Eslint, Entering, concat!( "0,58,69 class-methods-use-this 2 no-sparse-arrays 3,64 no-empty-pattern 4 arrow-body-style 4,39 ", @@ -127,9 +127,10 @@ pub(super) static RULES: &[(Plugin, When, &str)] = &[ )), (Plugin::Jest, Once, concat!( "consistent-test-it max-expects max-nested-describe no-commented-out-tests no-confusing-set-timeout ", - "no-duplicate-hooks no-export no-identical-title no-jasmine-globals no-large-snapshots no-mocks-import ", - "no-standalone-expect prefer-each prefer-expect-assertions prefer-hooks-in-order prefer-hooks-on-top ", - "prefer-importing-jest-globals prefer-snapshot-hint require-top-level-describe valid-expect", + "no-duplicate-hooks no-export no-identical-title no-large-snapshots no-mocks-import no-standalone-expect ", + "prefer-each prefer-expect-assertions prefer-hooks-in-order prefer-hooks-on-top ", + "prefer-importing-jest-globals prefer-snapshot-hint require-top-level-describe valid-expect 5,17 ", + "no-jasmine-globals", )), (Plugin::Jest, Entering, concat!( "5 prefer-spy-on 17 prefer-mock-promise-shorthand prefer-mock-return-shorthand 17,68 require-hook 22,75 ", @@ -169,7 +170,10 @@ pub(super) static RULES: &[(Plugin, When, &str)] = &[ "no-unescaped-entities 58,65 no-unsafe 119 no-this-in-sfc", )), (Plugin::ReactPerf, Entering, "49 jsx-no-jsx-as-prop jsx-no-new-array-as-prop jsx-no-new-function-as-prop jsx-no-new-object-as-prop"), - (Plugin::Unicorn, Once, "consistent-function-scoping filename-case no-abusive-eslint-disable no-empty-file"), + (Plugin::Unicorn, Once, concat!( + "consistent-function-scoping filename-case no-abusive-eslint-disable no-empty-file 17,22,66,75 ", + "prefer-number-properties", + )), (Plugin::Unicorn, Entering, concat!( "1,3 no-unreadable-array-destructuring 2,63 no-useless-spread 4,39 prefer-native-coercion-functions 5 ", "no-document-cookie prefer-add-event-listener 5,11,17,122 prefer-date-now 5,11,122 prefer-math-trunc ", @@ -192,22 +196,21 @@ pub(super) static RULES: &[(Plugin, When, &str)] = &[ "prefer-string-replace-all prefer-string-slice prefer-string-starts-ends-with prefer-string-trim-start-end ", "prefer-structured-clone prefer-top-level-await require-array-join-separator ", "require-number-to-fixed-digits-argument require-post-message-target-origin throw-new-error 17,18 ", - "catch-error-name 17,22 prefer-at 17,22,66,75 prefer-number-properties 17,31,44,46,90 prefer-node-protocol ", - "17,36,59,73,128 no-useless-iterator-to-array 17,42 no-useless-undefined 17,59 error-message ", - "max-nested-calls new-for-builtins no-invalid-fetch-options 18 prefer-optional-catch-binding 20 ", - "custom-error-definition no-static-only-class prefer-class-fields 20,59 prefer-event-target 22,66,75 ", - "no-await-expression-member 23 consistent-empty-array-spread no-nested-ternary ", - "prefer-logical-operator-over-ternary prefer-math-min-max 23,43 no-negated-condition ", - "prefer-classlist-toggle 26,42,71 prefer-module 28,31,34,44,46,125 import-style 28,31,44,46 ", - "require-module-attributes 31,32,44 require-module-specifiers 34 no-immediate-mutation prefer-single-call ", - "38 no-object-as-default-parameter 42 prefer-global-this 43 no-lonely-if prefer-ternary 44 ", - "consistent-assert prefer-export-from 47 prefer-import-meta-properties 54,76 text-encoding-identifier-case ", - "57 no-useless-fallback-in-spread no-useless-length-check 59 consistent-date-clone no-new-array ", - "no-new-buffer no-useless-collection-argument prefer-response-static-json relative-url-style 60 no-null 61 ", - "no-zero-fractions 70,76,118 escape-case no-hex-escape 75 explicit-length-check prefer-code-point ", - "prefer-set-size 76 prefer-string-raw 77 switch-case-break-position 78 no-useless-switch-case ", - "switch-case-braces 118 consistent-template-literal-escape 119 no-accessor-recursion 120 prefer-type-error ", - "125 prefer-set-has", + "catch-error-name 17,22 prefer-at 17,31,44,46,90 prefer-node-protocol 17,36,59,73,128 ", + "no-useless-iterator-to-array 17,42 no-useless-undefined 17,59 error-message max-nested-calls ", + "new-for-builtins no-invalid-fetch-options 18 prefer-optional-catch-binding 20 custom-error-definition ", + "no-static-only-class prefer-class-fields 20,59 prefer-event-target 22,66,75 no-await-expression-member 23 ", + "consistent-empty-array-spread no-nested-ternary prefer-logical-operator-over-ternary prefer-math-min-max ", + "23,43 no-negated-condition prefer-classlist-toggle 26,42,71 prefer-module 28,31,34,44,46,125 import-style ", + "28,31,44,46 require-module-attributes 31,32,44 require-module-specifiers 34 no-immediate-mutation ", + "prefer-single-call 38 no-object-as-default-parameter 42 prefer-global-this 43 no-lonely-if prefer-ternary ", + "44 consistent-assert prefer-export-from 47 prefer-import-meta-properties 54,76 ", + "text-encoding-identifier-case 57 no-useless-fallback-in-spread no-useless-length-check 59 ", + "consistent-date-clone no-new-array no-new-buffer no-useless-collection-argument ", + "prefer-response-static-json relative-url-style 60 no-null 61 no-zero-fractions 70,76,118 escape-case ", + "no-hex-escape 75 explicit-length-check prefer-code-point prefer-set-size 76 prefer-string-raw 77 ", + "switch-case-break-position 78 no-useless-switch-case switch-case-braces 118 ", + "consistent-template-literal-escape 119 no-accessor-recursion 120 prefer-type-error 125 prefer-set-has", )), (Plugin::JsxA11y, Entering, concat!( "49 aria-props aria-proptypes 50 anchor-ambiguous-text anchor-has-content anchor-is-valid aria-role ", diff --git a/src/lint/plugins/lib.rs b/src/lint/plugins/lib.rs index d96595f68176..b3f436dfdad5 100644 --- a/src/lint/plugins/lib.rs +++ b/src/lint/plugins/lib.rs @@ -11,6 +11,7 @@ //! And the rules of `bun`, which are Bun's own. #![forbid(unsafe_code)] +#![deny(clippy::iter_over_hash_type)] mod bun; mod import_export_map; diff --git a/src/lint/plugins/oxlint/exhaustive_deps.rs b/src/lint/plugins/oxlint/exhaustive_deps.rs index 6a893fb89658..5e8319ecad8c 100644 --- a/src/lint/plugins/oxlint/exhaustive_deps.rs +++ b/src/lint/plugins/oxlint/exhaustive_deps.rs @@ -6,9 +6,10 @@ use bun_lint::prelude::*; use bun_lint::utils::ancestor_memo::AncestorMemo; use bun_lint_oxlint::codegen::{Codegen, print_expression}; -use rustc_hash::{FxHashMap, FxHashSet}; +use bun_lint_oxlint::hash_order::HashOrder; +use rustc_hash::{FxBuildHasher, FxHashMap, FxHashSet}; use smallvec::SmallVec; -use std::hash::{Hash, Hasher}; +use std::hash::{BuildHasher, Hash, Hasher}; const MISSING_CALLBACK: Message = Message::new("", "React hook {{hook}} requires an effect callback."); @@ -383,19 +384,24 @@ fn order_in_table<'d, 'a>( inserted: &mut dyn Iterator, u32)>, symbols: &[(u32, u32)], ) -> Vec { - let mut table: FxHashSet = FxHashSet::default(); + let (mut seen, mut table): (FxHashSet, HashOrder) = + (FxHashSet::default(), HashOrder::new()); for (dependency, at) in inserted { let place = dependency.symbol.and_then(place_of_symbol); - table.insert(Hashed { + let hashed = Hashed { dependency, // oxc keeps the number so that `u32::MAX` is 0. symbol: place .and_then(|it| symbols.binary_search(&it).ok()) .map(|it| it as u32 ^ u32::MAX), - at, - }); + }; + let hash = FxBuildHasher.hash_one(&hashed); + match seen.insert(hashed) { + true => table.insert(hash, at), + false => table.insert_again(), + } } - table.iter().map(|it| it.at).collect() + table.iter().collect() } /// Of `candidates`, which are positions in `declared`, the one that oxlint comes to first: it has them in a hash table. @@ -427,7 +433,6 @@ struct Hashed<'d, 'a> { dependency: &'d Dependency<'a>, /// oxc's `SymbolId` symbol: Option, - at: u32, } /// As a `str` is hashed. diff --git a/src/lint/plugins/rules/import_no_duplicates.rs b/src/lint/plugins/rules/import_no_duplicates.rs index ecc51bd23281..3f4fdefa3ff9 100644 --- a/src/lint/plugins/rules/import_no_duplicates.rs +++ b/src/lint/plugins/rules/import_no_duplicates.rs @@ -1,10 +1,12 @@ use crate::import_resolve::{Resolved, Resolvers}; -use bun_lint_oxlint::import::{ImportImportName, import_declarations, import_entries_of}; -use bun_lint_oxlint::module_record::{get_loaded_module, in_hash_order, is_waiting_for_modules, requested_modules}; -use bun_lint_oxlint::text::find_next_token_within; use bun_core::strings; use bun_lint::prelude::*; use bun_lint::rule::Plugin; +use bun_lint_oxlint::import::{ImportImportName, import_declarations, import_entries_of}; +use bun_lint_oxlint::module_record::{ + get_loaded_module, in_hash_order, is_waiting_for_modules, requested_modules, +}; +use bun_lint_oxlint::text::find_next_token_within; use rustc_hash::{FxHashMap, FxHashSet}; use smallvec::SmallVec; use std::borrow::Cow; @@ -16,8 +18,14 @@ pub struct NoDuplicates { } const IMPORTED_MULTIPLE_TIMES: Message = Message::new("", "'{{module}}' imported multiple times."); -const MODULE: Message = Message::new("", "Module '{{module_name}}' is imported more than once in this file"); -const MODULES: Message = Message::new("", "Modules should not be imported multiple times in the same file"); +const MODULE: Message = Message::new( + "", + "Module '{{module_name}}' is imported more than once in this file", +); +const MODULES: Message = Message::new( + "", + "Modules should not be imported multiple times in the same file", +); type Imports<'a> = SmallVec<[Import<'a>; 2]>; /// The keys and the values of `with { key: "value" }`, sorted. @@ -26,7 +34,12 @@ type Attributes<'a> = SmallVec<[(&'a [u8], &'a [u8]); 2]>; fn attributes_of<'a>(import: Import<'a>) -> Attributes<'a> { let bytes = |name: Option>| name.map_or(&b""[..], Name::bytes); let entries = import.attributes().into_iter().flat_map(|it| it.entries()); - let key_and_value = |it: Prop<'a>| (bytes(it.key().and_then(Key::name)), bytes(it.value().and_then(Expr::as_string))); + let key_and_value = |it: Prop<'a>| { + ( + bytes(it.key().and_then(Key::name)), + bytes(it.value().and_then(Expr::as_string)), + ) + }; let mut attributes: Attributes<'a> = entries.map(key_and_value).collect(); utils::sort::sort_unstable(&mut attributes); attributes @@ -43,7 +56,10 @@ impl Rule for NoDuplicates { fn new(options: &Options) -> Self { let options = options.object(0); NoDuplicates { - prefer_inline: options.bool("preferInline").or_else(|| options.bool("prefer-inline")).unwrap_or(false), + prefer_inline: options + .bool("preferInline") + .or_else(|| options.bool("prefer-inline")) + .unwrap_or(false), consider_query_string: options.bool_or("considerQueryString", false), } } @@ -51,8 +67,13 @@ impl Rule for NoDuplicates { fn start<'a>(&self, file: &'a File<'a>) -> Option<()> { // oxlint looks at the top level only, and at its module records. match file.language().is_oxlint { - true => (!is_waiting_for_modules(file) && import_declarations(file).nth(1).is_some()).then_some(()), - false => file.stmts_of_kind(StmtTag::Import).nth(1).is_some().then_some(()), + true => (!is_waiting_for_modules(file) && import_declarations(file).nth(1).is_some()) + .then_some(()), + false => file + .stmts_of_kind(StmtTag::Import) + .nth(1) + .is_some() + .then_some(()), } } @@ -71,15 +92,25 @@ impl Rule for NoDuplicates { let mut previous_key: Option> = None; for (specifier, _) in in_hash_order(requested_modules(file)) { let source = specifier.bytes(); - let resolved_absolute_path = get_loaded_module(file, source).map_or(source, |it| it.path()); - let grouping_key = match strings::index_of_char_usize(source, b'?').filter(|_| self.consider_query_string) { - Some(query) => Cow::Owned([resolved_absolute_path, source.get(query..).unwrap_or_default()].concat()), + let resolved_absolute_path = + get_loaded_module(file, source).map_or(source, |it| it.path()); + let grouping_key = match strings::index_of_char_usize(source, b'?') + .filter(|_| self.consider_query_string) + { + Some(query) => Cow::Owned( + [ + resolved_absolute_path, + source.get(query..).unwrap_or_default(), + ] + .concat(), + ), None => Cow::Borrowed(resolved_absolute_path), }; if previous_key.as_ref() != Some(&grouping_key) { groups.push(Imports::new()); } - if let (Some(group), Some(imports)) = (groups.last_mut(), by_specifier.get(&specifier)) { + if let (Some(group), Some(imports)) = (groups.last_mut(), by_specifier.get(&specifier)) + { group.extend(imports.iter().copied()); } previous_key = Some(grouping_key); @@ -92,9 +123,14 @@ impl Rule for NoDuplicates { // With other attributes it is another module. let mut attribute_groups: FxHashMap, Imports<'a>> = FxHashMap::default(); for &import in group { - attribute_groups.entry(attributes_of(import)).or_default().push(import); + attribute_groups + .entry(attributes_of(import)) + .or_default() + .push(import); } - for group in attribute_groups.values().filter(|it| it.len() > 1) { + // oxlint sorts them by the attributes. + let several = attribute_groups.iter().filter(|it| it.1.len() > 1); + for (_, group) in utils::sort::in_order_of(several, |it| it.0) { self.check_group(group, cx); } } @@ -102,13 +138,19 @@ impl Rule for NoDuplicates { } impl NoDuplicates { - fn resolver<'a>(&self, resolvers: &Resolvers, file: &'a File<'a>, source: &'a [u8]) -> Cow<'a, [u8]> { + fn resolver<'a>( + &self, + resolvers: &Resolvers, + file: &'a File<'a>, + source: &'a [u8], + ) -> Cow<'a, [u8]> { let default_resolver = |path: &'a [u8]| match resolvers.resolve(file, path, false) { Resolved::File(found) => Cow::Owned(found), Resolved::Nothing | Resolved::Builtin => Cow::Borrowed(path), }; // `/^([^?]*)\?(.*)$/` - let parts = strings::split_once_char(source, b'?').filter(|it| !strings::contains_js_line_break(it.1)); + let parts = strings::split_once_char(source, b'?') + .filter(|it| !strings::contains_js_line_break(it.1)); match parts.filter(|_| self.consider_query_string) { Some((path, query)) => Cow::Owned([&default_resolver(path)[..], b"?", query].concat()), None => default_resolver(source), @@ -133,14 +175,17 @@ impl NoDuplicates { return; }; let imports = || { - file.stmts_of_kind(StmtTag::Import).filter_map(|it| match it.kind() { - StmtKind::Import(import) => Some(import), - _ => None, - }) + file.stmts_of_kind(StmtTag::Import) + .filter_map(|it| match it.kind() { + StmtKind::Import(import) => Some(import), + _ => None, + }) }; let mut resolved: FxHashMap, Cow<'a, [u8]>> = FxHashMap::default(); for specifier in imports().map(Import::spec) { - resolved.entry(specifier).or_insert_with(|| self.resolver(&resolvers, file, specifier.bytes())); + resolved + .entry(specifier) + .or_insert_with(|| self.resolver(&resolvers, file, specifier.bytes())); } let key = |it: Import<'a>| { let block = match it.stmt().parent() { @@ -163,7 +208,8 @@ impl NoDuplicates { // Values and, with `preferInline`, types by name; types by name; namespaces; types that are default exports. let mut import_entries_maps: [Imports<'a>; 4] = Default::default(); // An import for the side effects and an `import type` cannot be merged. An import of values can take in either. - let (mut key0_side_effect_imports, mut key0_type_only_imports) = (Imports::new(), Imports::new()); + let (mut key0_side_effect_imports, mut key0_type_only_imports) = + (Imports::new(), Imports::new()); let mut key0_has_runtime_specifiers = false; let mut push = |key: usize, import: Import<'a>| { if let Some(imports) = import_entries_maps.get_mut(key) { @@ -171,12 +217,13 @@ impl NoDuplicates { } }; for &import in group { - let mut keys = import_entries_of(import).map(|entry| match (entry.import_name, entry.is_type()) { - (ImportImportName::NamespaceObject(_), _) => 2, - (ImportImportName::Name(_), true) => usize::from(!self.prefer_inline), - (ImportImportName::Default(_), true) => 3, - (_, false) => 0, - }); + let mut keys = + import_entries_of(import).map(|entry| match (entry.import_name, entry.is_type()) { + (ImportImportName::NamespaceObject(_), _) => 2, + (ImportImportName::Name(_), true) => usize::from(!self.prefer_inline), + (ImportImportName::Default(_), true) => 3, + (_, false) => 0, + }); let is_in_key0 = match keys.next() { None => { let key = usize::from(import.is_type_only() && !self.prefer_inline); @@ -218,7 +265,12 @@ impl NoDuplicates { } /// `module`: what the plugin calls the module. oxlint has none. - fn check_duplicates<'a>(&self, imports: &[Import<'a>], module: Option<&[u8]>, cx: &Cx<'a, Self>) { + fn check_duplicates<'a>( + &self, + imports: &[Import<'a>], + module: Option<&[u8]>, + cx: &Cx<'a, Self>, + ) { let [first, _, ..] = imports else { return; }; @@ -229,9 +281,12 @@ impl NoDuplicates { let fix = |fixer| merge_imports_fix(fixer, self.prefer_inline, is_oxlint, imports); // The plugin reports each import, oxlint the first, with a label at each of the others. if let Some(module) = module { - cx.report(first, IMPORTED_MULTIPLE_TIMES).data("module", module.to_vec()).fix(fix); + cx.report(first, IMPORTED_MULTIPLE_TIMES) + .data("module", module.to_vec()) + .fix(fix); for other in imports.iter().skip(1).filter_map(|it| it.spec_span()) { - cx.report(other, IMPORTED_MULTIPLE_TIMES).data("module", module.to_vec()); + cx.report(other, IMPORTED_MULTIPLE_TIMES) + .data("module", module.to_vec()); } return; } @@ -239,10 +294,19 @@ impl NoDuplicates { for quote in *b"'\"" { let start = module_name.iter().take_while(|b| **b == quote).count(); module_name = module_name.get(start..).unwrap_or_default(); - let end = module_name.len() - module_name.iter().rev().take_while(|b| **b == quote).count(); + let end = module_name.len() + - module_name + .iter() + .rev() + .take_while(|b| **b == quote) + .count(); module_name = module_name.get(..end).unwrap_or_default(); } - let message = if module_name.len() > 16 { MODULES } else { MODULE }; + let message = if module_name.len() > 16 { + MODULES + } else { + MODULE + }; let report = cx.report(first, message).data("module_name", module_name); let report = report.first_label("It is first imported here"); let report = report.help("Merge these imports into a single import statement"); @@ -268,7 +332,12 @@ impl NextNewlines { { return found; } - let next = |from: u32| Some(from + strings::index_of_char_usize(file.text().get(from as usize..)?, b'\n')? as u32); + let next = |from: u32| { + Some( + from + strings::index_of_char_usize(file.text().get(from as usize..)?, b'\n')? + as u32, + ) + }; let first = next(from); let found = [first, first.and_then(|it| next(it + 1))]; (self.from, self.found) = (from, Some(found)); @@ -280,7 +349,10 @@ impl NextNewlines { fn find_brace<'a>(file: &'a File<'a>, within: Span, brace: &str, is_oxlint: bool) -> Option { match is_oxlint { true => find_next_token_within(file, within, brace.as_bytes()), - false => file.tokens_in(within).find(|it| it.is_punctuator(brace)).map(Token::start), + false => file + .tokens_in(within) + .find(|it| it.is_punctuator(brace)) + .map(Token::start), } } @@ -297,11 +369,16 @@ fn has_problematic_comments( let (file, span) = (decl.stmt().file(), decl.span()); // The plugin asks the comments next to the import, oxlint the closest, whatever is in between. let (mut before, mut after) = match is_oxlint { - true => (file.comments_in(Span::before(0, span)), file.comments_in(Span::new(span.end, file.span().end))), + true => ( + file.comments_in(Span::before(0, span)), + file.comments_in(Span::new(span.end, file.span().end)), + ), false => (file.comments_before(span), file.comments_after(span)), }; // At most one `\n` is between it and the import. The plugin counts lines. - let comment_before = before.next_back().filter(|it| !is_oxlint || !it.text().starts_with(b"#!")); + let comment_before = before + .next_back() + .filter(|it| !is_oxlint || !it.text().starts_with(b"#!")); if comment_before.is_some_and(|it| match is_oxlint { true => after_comment.after(file, it.end())[1].is_none_or(|second| second >= span.start), false => file.line_of(it.end()) + 1 >= file.line_of(span.start), @@ -316,7 +393,8 @@ fn has_problematic_comments( return true; } let braces = brace_position(decl, "{", is_oxlint).zip(brace_position(decl, "}", is_oxlint)); - file.comments_in(span).any(|it| braces.is_none_or(|(open, close)| it.end() <= open || it.start() >= close)) + file.comments_in(span) + .any(|it| braces.is_none_or(|(open, close)| it.end() <= open || it.start() >= close)) } struct MergeSpecifier<'a> { @@ -338,7 +416,9 @@ fn merge_imports_fix<'a>( let (&first, rest) = decls.split_first()?; let mut newlines = [NextNewlines::default(), NextNewlines::default()]; // oxlint merges only what is written alike, without attributes. - let is_other = |it: &Import<'a>| it.spec() != first.spec() || it.attributes().is_some() || it.phase().is_some(); + let is_other = |it: &Import<'a>| { + it.spec() != first.spec() || it.attributes().is_some() || it.phase().is_some() + }; if is_oxlint && decls.iter().any(is_other) || first.namespace().is_some() || has_problematic_comments(first, &mut newlines, is_oxlint) @@ -350,13 +430,17 @@ fn merge_imports_fix<'a>( if default_import_names.any(|it| Some(it) != default_name) { return None; } - let (mut specifiers, mut unnecessary): (Vec>, Imports<'a>) = (Vec::new(), Imports::new()); + let (mut specifiers, mut unnecessary): (Vec>, Imports<'a>) = + (Vec::new(), Imports::new()); for &decl in rest { if decl.namespace().is_some() || has_problematic_comments(decl, &mut newlines, is_oxlint) { continue; } match brace_position(decl, "{", is_oxlint).zip(brace_position(decl, "}", is_oxlint)) { - Some((open, close)) => specifiers.push(MergeSpecifier { decl, identifiers: file.slice(Span::new(open + 1, close)) }), + Some((open, close)) => specifiers.push(MergeSpecifier { + decl, + identifiers: file.slice(Span::new(open + 1, close)), + }), None if decl.named().is_empty() => unnecessary.push(decl), None => {} } @@ -368,16 +452,23 @@ fn merge_imports_fix<'a>( } let first_span = first.span(); let open_brace = brace_position(first, "{", is_oxlint); - let close_brace = |open: u32| find_brace(file, Span::new(open + 1, first_span.end), "}", is_oxlint); + let close_brace = + |open: u32| find_brace(file, Span::new(open + 1, first_span.end), "}", is_oxlint); let braces = open_brace.and_then(|open| Some((open, close_brace(open)?))); let has_open_brace = open_brace.is_some(); let import_keyword_end = Span::empty(first_span.start + "import".len() as u32); let first_is_empty = first.named().is_empty(); - let first_brace_content = braces.map_or(&b""[..], |(open, close)| file.slice(Span::new(open + 1, close))); + let first_brace_content = braces.map_or(&b""[..], |(open, close)| { + file.slice(Span::new(open + 1, close)) + }); // oxlint looks at the text: a comment after the `,` hides it. let first_has_trailing_comma = match (is_oxlint, braces) { - (true, _) => !first_is_empty && strings::trim_js_whitespace_end(first_brace_content).ends_with(b","), - (false, Some((_, close))) => file.token_before(Span::empty(close)).is_some_and(|it| it.is_punctuator(",")), + (true, _) => { + !first_is_empty && strings::trim_js_whitespace_end(first_brace_content).ends_with(b",") + } + (false, Some((_, close))) => file + .token_before(Span::empty(close)) + .is_some_and(|it| it.is_punctuator(",")), (false, None) => false, }; let mut existing: FxHashSet<&[u8]> = FxHashSet::default(); @@ -385,11 +476,17 @@ fn merge_imports_fix<'a>( existing.extend(strings::split(first_brace_content, b",").map(strings::trim_js_whitespace)); } // oxlint also where types and values come together. - let should_inline_type_imports = - prefer_inline || is_oxlint && (!first.is_type_only() || specifiers.iter().any(|it| !it.decl.is_type_only())); + let should_inline_type_imports = prefer_inline + || is_oxlint + && (!first.is_type_only() || specifiers.iter().any(|it| !it.decl.is_type_only())); let needs_comma = !first_has_trailing_comma && !first_is_empty; - let specifiers_text = - build_specifiers_text(&specifiers, &mut existing, needs_comma, should_inline_type_imports, is_oxlint); + let specifiers_text = build_specifiers_text( + &specifiers, + &mut existing, + needs_comma, + should_inline_type_imports, + is_oxlint, + ); let mut fixes = Vec::with_capacity(decls.len() + 1); if should_add_specifiers && should_inline_type_imports && first.is_type_only() { @@ -404,9 +501,16 @@ fn merge_imports_fix<'a>( } // The plugin goes by the tokens: `a as b` stays as it is, and a `from` that is imported makes two. match is_oxlint { - true => fixes.extend(first.named().iter().map(|it| fixer.insert_before(it, "type "))), + true => fixes.extend( + first + .named() + .iter() + .map(|it| fixer.insert_before(it, "type ")), + ), false => { - let known = file.tokens_in(first_span).filter(|it| existing.contains(it.value())); + let known = file + .tokens_in(first_span) + .filter(|it| existing.contains(it.value())); fixes.extend(known.map(|it| fixer.insert_before(it, "type "))); } } @@ -414,23 +518,43 @@ fn merge_imports_fix<'a>( let in_braces = || [b" {", &specifiers_text[..], b"}"].concat(); match (should_add_default.map(Name::bytes), has_open_brace, braces) { (Some(default_name), false, _) if should_add_specifiers => { - fixes.push(fixer.insert_before(import_keyword_end, [b" ", default_name, b",", &in_braces()[..], b" from"].concat())); + fixes.push(fixer.insert_before( + import_keyword_end, + [b" ", default_name, b",", &in_braces()[..], b" from"].concat(), + )); } (Some(default_name), false, _) => { - fixes.push(fixer.insert_before(import_keyword_end, [b" ", default_name, b" from"].concat())); + fixes.push( + fixer.insert_before(import_keyword_end, [b" ", default_name, b" from"].concat()), + ); } (Some(default_name), true, Some((open, close))) => { - fixes.push(fixer.insert_before(import_keyword_end, [b" ", default_name, b","].concat())); + fixes + .push(fixer.insert_before(import_keyword_end, [b" ", default_name, b","].concat())); if should_add_specifiers { - fixes.push(merge_into_braces(fixer, Span::new(open + 1, close), &specifiers_text, is_oxlint)); + fixes.push(merge_into_braces( + fixer, + Span::new(open + 1, close), + &specifiers_text, + is_oxlint, + )); } } (None, false, _) if should_add_specifiers => match first.default() { - None => fixes.push(fixer.insert_before(import_keyword_end, [&in_braces()[..], b" from"].concat())), - Some(first_default) => fixes.push(fixer.insert_after(first_default, [b",", &in_braces()[..]].concat())), + None => fixes.push( + fixer.insert_before(import_keyword_end, [&in_braces()[..], b" from"].concat()), + ), + Some(first_default) => { + fixes.push(fixer.insert_after(first_default, [b",", &in_braces()[..]].concat())) + } }, (None, true, Some((open, close))) => { - fixes.push(merge_into_braces(fixer, Span::new(open + 1, close), &specifiers_text, is_oxlint)); + fixes.push(merge_into_braces( + fixer, + Span::new(open + 1, close), + &specifiers_text, + is_oxlint, + )); } _ => {} } @@ -462,7 +586,8 @@ fn build_specifiers_text<'a>( if result.len() > start || needs_comma && !is_empty && !cur.is_empty() { result.push(b','); } - let is_type = inline_type_imports && specifier.decl.is_type_only() && !trimmed.is_empty(); + let is_type = + inline_type_imports && specifier.decl.is_type_only() && !trimmed.is_empty(); if is_type { result.extend_from_slice(b"type "); } @@ -473,7 +598,9 @@ fn build_specifiers_text<'a>( result.extend_from_slice(trimmed); result.push(b'\n'); } else if !is_type && strings::contains_char(cur, b'\n') { - result.extend_from_slice(strip_leading_inline_whitespace(strings::trim_js_whitespace_end(cur))); + result.extend_from_slice(strip_leading_inline_whitespace( + strings::trim_js_whitespace_end(cur), + )); } else { result.extend_from_slice(trimmed); } @@ -484,25 +611,35 @@ fn build_specifiers_text<'a>( } fn ends_with_line_comment(source: &[u8]) -> bool { - strings::contains(strings::rsplit_once_char(source, b'\n').map_or(source, |it| it.1), b"//") + strings::contains( + strings::rsplit_once_char(source, b'\n').map_or(source, |it| it.1), + b"//", + ) } /// Without the blanks that it starts with, up to a line break. fn strip_leading_inline_whitespace(source: &[u8]) -> &[u8] { let line = strings::split_once_char(source, b'\n').map_or(source, |it| it.0); - source.get(line.len() - strings::trim_js_whitespace_start(line).len()..).unwrap_or(source) + source + .get(line.len() - strings::trim_js_whitespace_start(line).len()..) + .unwrap_or(source) } /// `inside`: what is between the braces. fn merge_into_braces(fixer: Fixer, inside: Span, specifiers_text: &[u8], is_oxlint: bool) -> Fix { let (content, close) = (fixer.file().slice(inside), inside.end); - let trailing = content.get(strings::trim_js_whitespace_end(content).len()..).unwrap_or_default(); + let trailing = content + .get(strings::trim_js_whitespace_end(content).len()..) + .unwrap_or_default(); // The plugin puts it right before the `}`. if !is_oxlint || trailing.is_empty() && !content.is_empty() { fixer.insert_before(Span::empty(close), specifiers_text) } else if trailing.len() == content.len() { fixer.replace(inside, specifiers_text) } else { - fixer.replace(Span::new(close - trailing.len() as u32, close + 1), [specifiers_text, trailing, b"}"].concat()) + fixer.replace( + Span::new(close - trailing.len() as u32, close + 1), + [specifiers_text, trailing, b"}"].concat(), + ) } } diff --git a/src/lint/plugins/rules/import_order.rs b/src/lint/plugins/rules/import_order.rs index 1aea1e99cbf3..5de5ca64c1c9 100644 --- a/src/lint/plugins/rules/import_order.rs +++ b/src/lint/plugins/rules/import_order.rs @@ -115,12 +115,18 @@ struct Entry<'a> { is_multiline: bool, } -const OUT_OF_ORDER: Message = - Message::new("", "`{{second}}` {{secondKind}} should occur {{order}} {{firstKind}} of `{{first}}`"); -const EMPTY_LINE_BETWEEN_GROUPS: Message = - Message::new("", "There should be at least one empty line between import groups"); -const NO_EMPTY_LINE_WITHIN_GROUP: Message = Message::new("", "There should be no empty line within import group"); -const NO_EMPTY_LINE_BETWEEN_GROUPS: Message = Message::new("", "There should be no empty line between import groups"); +const OUT_OF_ORDER: Message = Message::new( + "", + "`{{second}}` {{secondKind}} should occur {{order}} {{firstKind}} of `{{first}}`", +); +const EMPTY_LINE_BETWEEN_GROUPS: Message = Message::new( + "", + "There should be at least one empty line between import groups", +); +const NO_EMPTY_LINE_WITHIN_GROUP: Message = + Message::new("", "There should be no empty line within import group"); +const NO_EMPTY_LINE_BETWEEN_GROUPS: Message = + Message::new("", "There should be no empty line between import groups"); const EMPTY_LINE_BEFORE_MULTILINE: Message = Message::new( "", "There should be at least one empty line between this import and the multi-line import that follows it", @@ -157,7 +163,10 @@ fn path_groups_of(written: &[Json]) -> (Vec, f64) { let mut groups = Vec::with_capacity(written.len()); for it in written { let it = Object::of(Some(it)); - let Some(group) = it.str("group").and_then(|it| ImportType::named(it.as_bytes())) else { + let Some(group) = it + .str("group") + .and_then(|it| ImportType::named(it.as_bytes())) + else { continue; }; let position = match it.str("position") { @@ -172,7 +181,11 @@ fn path_groups_of(written: &[Json]) -> (Vec, f64) { } _ => 0.0, }; - groups.push(PathGroup { glob: Glob::of_path_group(it), group, position }); + groups.push(PathGroup { + glob: Glob::of_path_group(it), + group, + position, + }); } for it in groups.iter_mut().filter(|it| it.position < 0.0) { it.position = -(f64::from(before[it.group as usize]) + 1.0 + it.position); @@ -190,22 +203,34 @@ impl Rule for Order { .fixable(Fixable::Code) .needs_modules() .reports_at_the_end(); - const ON: On = On::new().finish().var_decls().stmts(&[StmtTag::ExportNamed]); + const ON: On = On::new() + .finish() + .var_decls() + .stmts(&[StmtTag::ExportNamed]); no_state!(); fn new(options: &Options) -> Self { let options = options.object(0); - let newlines_between = newlines_of(options.str("newlines-between")).unwrap_or(Newlines::Ignore); + let newlines_between = + newlines_of(options.str("newlines-between")).unwrap_or(Newlines::Ignore); // `convertGroupsToRanks` let types_of = |group: &Json| -> Vec { - let items = group.as_array().unwrap_or_else(|| std::slice::from_ref(group)); - items.iter().filter_map(Json::as_str).filter_map(ImportType::named).collect() + let items = group + .as_array() + .unwrap_or_else(|| std::slice::from_ref(group)); + items + .iter() + .filter_map(Json::as_str) + .filter_map(ImportType::named) + .collect() }; let written: Vec> = match options.has("groups") { true => options.array("groups").iter().map(types_of).collect(), false => { use ImportType::{Builtin, External, Index, Parent, Sibling}; - [Builtin, External, Parent, Sibling, Index].map(|it| vec![it]).into() + [Builtin, External, Parent, Sibling, Index] + .map(|it| vec![it]) + .into() } }; let mut groups = [f64::NAN; 9]; @@ -222,18 +247,27 @@ impl Rule for Order { } let excluded = match options.get("pathGroupsExcludedImportTypes") { Some(written) => types_of(written), - None => vec![ImportType::Builtin, ImportType::External, ImportType::Object], + None => vec![ + ImportType::Builtin, + ImportType::External, + ImportType::Object, + ], }; let named = options.object("named"); let is_named = |key: &str| { let of_all = || named.bool("enabled"); - options.bool("named").or_else(|| named.bool(key)).or_else(of_all).unwrap_or(false) + options + .bool("named") + .or_else(|| named.bool(key)) + .or_else(of_all) + .unwrap_or(false) }; let alphabetize = options.object("alphabetize"); let (path_groups, max_position) = path_groups_of(options.array("pathGroups")); Order { newlines_between, - newlines_between_types: newlines_of(options.str("newlines-between-types")).unwrap_or(newlines_between), + newlines_between_types: newlines_of(options.str("newlines-between-types")) + .unwrap_or(newlines_between), excluded: excluded.iter().fold(0, |set, it| set | (1 << (*it as u16))), sorts_types_group: is_type_in_groups && options.bool_or("sortTypesGroup", false), consolidates_islands: options.str("consolidateIslands") == Some("inside-groups"), @@ -265,7 +299,10 @@ impl Rule for Order { fn narrow<'a>(&self, file: &'a File<'a>) -> On { let mut on = On::new(); let has_exports = self.named.cjs_exports && file.has_exprs([ExprTag::Assign]); - if has_exports || file.has_stmts([StmtTag::Import, StmtTag::ImportEquals]) || file.has_exprs([ExprTag::Call]) { + if has_exports + || file.has_stmts([StmtTag::Import, StmtTag::ImportEquals]) + || file.has_exprs([ExprTag::Call]) + { on = on.finish(); } if self.named.require { @@ -293,14 +330,20 @@ impl Rule for Order { /// A `Literal` fn is_literal(e: Expr) -> bool { use ExprKind::{BigInt, False, Null, Number, Regex, String, True}; - matches!(e.kind(), String(_) | Number(_) | True | False | Null | Regex(_) | BigInt(_)) + matches!( + e.kind(), + String(_) | Number(_) | True | False | Null | Regex(_) | BigInt(_) + ) } fn is_require_expression(e: Expr) -> bool { match e.kind() { ExprKind::Call(call) => { e.chain() == Chain::No - && call.callee().as_ident().is_some_and(|it| it.bytes() == b"require") + && call + .callee() + .as_ident() + .is_some_and(|it| it.bytes() == b"require") && call.args().len() == 1 && call.args().first().is_some_and(is_literal) } @@ -338,12 +381,16 @@ fn can_be_crossed(statement: Stmt) -> bool { !statement.is_exported() && matches!(import.target(), ImportEqualsTarget::Require(_)) } StmtKind::Var(declarations) if declarations.len() == 1 && !statement.is_exported() => { - let Some((declaration, init)) = declarations.first().and_then(|it| Some((it, it.init()?))) else { + let Some((declaration, init)) = + declarations.first().and_then(|it| Some((it, it.init()?))) + else { return false; }; let is_called_member = || match init.kind() { ExprKind::Call(call) if init.chain() == Chain::No => match call.callee().kind() { - ExprKind::Dot { obj, .. } | ExprKind::Index { obj, .. } => is_require_expression(obj), + ExprKind::Dot { obj, .. } | ExprKind::Index { obj, .. } => { + is_require_expression(obj) + } _ => false, }, _ => false, @@ -430,7 +477,11 @@ fn description_of(entry: &Entry) -> &'static str { /// The `name` of a name in the braces of an import or an export. One in quotes is a `Literal`, which has none. fn name_of(name: Ident<'_>) -> &[u8] { - if name.is_string() { b"undefined" } else { name.bytes() } + if name.is_string() { + b"undefined" + } else { + name.bytes() + } } /// The `name` of a key that is an `Identifier`, in brackets or not. @@ -445,7 +496,9 @@ fn identifier_of(key: Key<'_>) -> Option<&[u8]> { /// The object and the name of a `MemberExpression` whose `property` is an `Identifier`. fn member_of<'a>(e: Expr<'a>) -> Option<(Expr<'a>, &'a [u8])> { match e.kind() { - ExprKind::Dot { obj, name, .. } if !name.bytes().starts_with(b"#") => Some((obj, name.bytes())), + ExprKind::Dot { obj, name, .. } if !name.bytes().starts_with(b"#") => { + Some((obj, name.bytes())) + } ExprKind::Index { obj, index, .. } => Some((obj, index.as_ident()?.bytes())), _ => None, } @@ -454,12 +507,17 @@ fn member_of<'a>(e: Expr<'a>) -> Option<(Expr<'a>, &'a [u8])> { /// `isCJSExports` fn is_cjs_exports(e: Expr) -> bool { let name = match (member_of(e), e.as_ident()) { - (Some((object, b"exports")), _) if object.as_ident().is_some_and(|it| it.bytes() == b"module") => "module", + (Some((object, b"exports")), _) + if object.as_ident().is_some_and(|it| it.bytes() == b"module") => + { + "module" + } (None, Some(name)) if name.bytes() == b"exports" => "exports", _ => return false, }; let scope = Node::Expr(e).scope(); - scope.get(name).is_none() && !(scope.kind() == ScopeKind::Global && e.file().global(name.as_bytes()).is_some()) + scope.get(name).is_none() + && !(scope.kind() == ScopeKind::Global && e.file().global(name.as_bytes()).is_some()) } /// `getNamedCJSExports`, joined. @@ -504,7 +562,13 @@ impl Order { } /// `computeRank`. `None`: it is not registered. - fn rank_of(&self, value: &[u8], written: Written, is_type_only: bool, types: &ImportTypes) -> Option { + fn rank_of( + &self, + value: &[u8], + written: Written, + is_type_only: bool, + types: &ImportTypes, + ) -> Option { let is_excluded = |it: ImportType| self.excluded & (1 << (it as u16)) != 0; let is_of_types = is_type_only && self.is_type_in_groups; let import_type = match written { @@ -516,7 +580,8 @@ impl Order { let group = self.path_groups.iter().find(|it| it.glob.matches(value))?; Some(self.groups.get(group.group as usize)? + group.position / self.max_position) }; - let has_path_rank = !is_excluded(import_type) && !(is_of_types && is_excluded(ImportType::Type)); + let has_path_rank = + !is_excluded(import_type) && !(is_of_types && is_excluded(ImportType::Type)); let of_path = if has_path_rank { of_path() } else { None }; let mut rank = of_path.or_else(|| self.groups.get(import_type as usize).copied())?; if is_type_only && self.sorts_types_group { @@ -545,15 +610,28 @@ impl Order { } /// The imports of a `Program` or a `TSModuleBlock`. - fn check_block<'a>(&self, body: List<'a, Stmt<'a>>, is_program: bool, types: &ImportTypes<'a>, cx: &Cx<'a, Self>) { + fn check_block<'a>( + &self, + body: List<'a, Stmt<'a>>, + is_program: bool, + types: &ImportTypes<'a>, + cx: &Cx<'a, Self>, + ) { let file = cx.file(); let mut imported: Vec> = Vec::new(); let is_multiline = |span: Span| file.line_of(span.start) != file.line_of(span.end); for (index, statement) in body.iter().enumerate() { let root = statement.span(); // `whole`: of which it is asked whether it is on several lines. - let mut register = |(node, whole): (Span, Span), name: &'a [u8], written: Written, kind: ImportKind| { - let value = if written == Written::ImportObject { &b""[..] } else { name }; + let mut register = |(node, whole): (Span, Span), + name: &'a [u8], + written: Written, + kind: ImportKind| { + let value = if written == Written::ImportObject { + &b""[..] + } else { + name + }; if let Some(rank) = self.rank_of(value, written, kind == ImportKind::Type, types) { imported.push(Entry { node, @@ -571,14 +649,26 @@ impl Order { } }; match statement.kind() { - StmtKind::Import(import) if can_be_crossed(statement) || self.warn_on_unassigned_imports => { + StmtKind::Import(import) + if can_be_crossed(statement) || self.warn_on_unassigned_imports => + { let name = import.spec().bytes(); - register((root, root), name, Written::Import, Self::kind_of(cx, import.is_type_only())); + register( + (root, root), + name, + Written::Import, + Self::kind_of(cx, import.is_type_only()), + ); if self.named.import { let names = import.named().iter().map(|it| { let alias = it.is_renamed().then(|| it.local().bytes()); let kind = Self::kind_of(cx, it.is_type_only()); - self.named_entry(it.span(), (name_of(it.imported()), alias), Written::Import, kind) + self.named_entry( + it.span(), + (name_of(it.imported()), alias), + Written::Import, + kind, + ) }); self.check_names(names.collect(), cx); } @@ -590,7 +680,9 @@ impl Order { register((root, root), name.bytes(), Written::Import, kind); } ImportEqualsTarget::Entity(entity) => { - if let Some((first, last)) = entity.parts().next().zip(entity.parts().next_back()) { + if let Some((first, last)) = + entity.parts().next().zip(entity.parts().next_back()) + { let entity = file.slice(Span::new(first.start(), last.span().end)); register((root, root), entity, Written::ImportObject, kind); } @@ -603,7 +695,12 @@ impl Order { if let Some((call, name)) = static_require_in(init) { // `importNode.parent.parent.type === "VariableDeclaration"` let whole = if call == init { root } else { call.span() }; - register((call.span(), whole), name.bytes(), Written::Require, ImportKind::Undefined); + register( + (call.span(), whole), + name.bytes(), + Written::Require, + ImportKind::Undefined, + ); } } } @@ -613,7 +710,9 @@ impl Order { if imported.is_empty() { return; } - if self.newlines_between != Newlines::Ignore || self.newlines_between_types != Newlines::Ignore { + if self.newlines_between != Newlines::Ignore + || self.newlines_between_types != Newlines::Ignore + { self.check_newlines(&imported, cx); } if self.alphabetize.order != Direction::Ignore { @@ -628,22 +727,30 @@ impl Order { let file = cx.file(); let sorts_types = self.sorts_types_group; let consolidates = self.consolidates_islands - && (self.newlines_between == AlwaysAndInsideGroups || self.newlines_between_types == AlwaysAndInsideGroups); + && (self.newlines_between == AlwaysAndInsideGroups + || self.newlines_between_types == AlwaysAndInsideGroups); for (previous, current) in imported.iter().zip(imported.iter().skip(1)) { let mut lines = file.line_of(previous.node.end) + 1..file.line_of(current.node.start); let has_empty_lines = lines.any(|it| strings::is_all_js_whitespace(file.line_text(it))); let is_start_of_distinct_group = current.rank - 1.0 >= previous.rank; let is_type_only = current.import_kind == ImportKind::Type; - let is_beside_other_kind = is_type_only != (previous.import_kind == ImportKind::Type) && sorts_types; + let is_beside_other_kind = + is_type_only != (previous.import_kind == ImportKind::Type) && sorts_types; let is_type_only = is_type_only && sorts_types; let is_either_multiline = previous.is_multiline || current.is_multiline; // Where they want the opposite, `consolidateIslands` wins. let between = match self.newlines_between { - Never if sorts_types && consolidates && is_either_multiline => AlwaysAndInsideGroups, + Never if sorts_types && consolidates && is_either_multiline => { + AlwaysAndInsideGroups + } it => it, }; let between_types = match self.newlines_between_types { - Never if sorts_types && consolidates && (is_beside_other_kind || is_either_multiline) => { + Never + if sorts_types + && consolidates + && (is_beside_other_kind || is_either_multiline) => + { AlwaysAndInsideGroups } it => it, @@ -652,20 +759,27 @@ impl Order { if own == Ignore { continue; } - let applies = if is_type_only || is_beside_other_kind { between_types } else { between }; + let applies = if is_type_only || is_beside_other_kind { + between_types + } else { + between + }; let is_in_same_group = match self.distinct_group { true => current.rank == previous.rank, false => !is_start_of_distinct_group, }; let add = |message: Message| { let end = end_with_comments(file, previous.root); - cx.report(previous.node, message).fix(|fixer| fixer.insert_after(Span::empty(end), "\n")); + cx.report(previous.node, message) + .fix(|fixer| fixer.insert_after(Span::empty(end), "\n")); }; let remove = |message: Message| { let start = end_of_line_with_comments(file, previous.root); let end = start_of_line_with_comments(file, current.root); - let is_blank = start <= end && strings::is_all_js_whitespace(slice(file.text(), start, end)); - cx.report(previous.node, message).fix(|fixer| is_blank.then(|| fixer.remove(Span::new(start, end)))); + let is_blank = + start <= end && strings::is_all_js_whitespace(slice(file.text(), start, end)); + cx.report(previous.node, message) + .fix(|fixer| is_blank.then(|| fixer.remove(Span::new(start, end)))); }; let mut is_reported = true; if matches!(applies, Always | AlwaysAndInsideGroups) { @@ -679,7 +793,9 @@ impl Order { } else { is_reported = false; } - } else if has_empty_lines && (!sorts_types || !is_beside_other_kind || between_types == Never) { + } else if has_empty_lines + && (!sorts_types || !is_beside_other_kind || between_types == Never) + { remove(NO_EMPTY_LINE_BETWEEN_GROUPS); } else { is_reported = false; @@ -706,7 +822,10 @@ impl Order { match (of_a.next(), of_b.next()) { (Some(x), Some(y)) if is_first && is_relative(x) && is_relative(y) && x != y => { // By how many names they have. - let (names_of_a, names_of_b) = (strings::count_char(a.0, b'/'), strings::count_char(b.0, b'/')); + let (names_of_a, names_of_b) = ( + strings::count_char(a.0, b'/'), + strings::count_char(b.0, b'/'), + ); break match (names_of_a, names_of_b) == (0, 0) { true => strings::order_utf16(a.0, b.0), false => names_of_a.cmp(&names_of_b), @@ -727,9 +846,11 @@ impl Order { }; // "type" comes before "value", which is also what stands for none. let rank = |it: ImportKind| it != ImportKind::Type; - in_direction(by_name, self.alphabetize.order).then_with(|| match self.alphabetize.order_import_kind { - Direction::Ignore => Ordering::Equal, - direction => in_direction(rank(a.1).cmp(&rank(b.1)), direction), + in_direction(by_name, self.alphabetize.order).then_with(|| { + match self.alphabetize.order_import_kind { + Direction::Ignore => Ordering::Equal, + direction => in_direction(rank(a.1).cmp(&rank(b.1)), direction), + } }) } @@ -737,25 +858,45 @@ impl Order { fn alphabetize(&self, imported: &mut [Entry]) { let ranks: Vec = { let normalized: Vec> = match self.alphabetize.case_insensitive { - true => imported.iter().map(|it| text::to_lower_case(&it.value)).collect(), - false => imported.iter().map(|it| Cow::Borrowed(&*it.value)).collect(), + true => imported + .iter() + .map(|it| text::to_lower_case(&it.value)) + .collect(), + false => imported + .iter() + .map(|it| Cow::Borrowed(&*it.value)) + .collect(), }; let key = |at: u32| Some((imported.get(at as usize)?, &**normalized.get(at as usize)?)); let mut order: Vec = (0..imported.len() as u32).collect(); let rank = |at: u32| key(at).map_or(0.0, |it| it.0.rank); - sort_indices(&mut order, &mut |a, b| rank(a).partial_cmp(&rank(b)).unwrap_or(Ordering::Equal)); + sort_indices(&mut order, &mut |a, b| { + rank(a).partial_cmp(&rank(b)).unwrap_or(Ordering::Equal) + }); let is_before = |a: u32, b: u32| match (key(a), key(b)) { - (Some(a), Some(b)) => self.compare((a.1, a.0.import_kind), (b.1, b.0.import_kind)) == Ordering::Less, + (Some(a), Some(b)) => { + self.compare((a.1, a.0.import_kind), (b.1, b.0.import_kind)) == Ordering::Less + } _ => false, }; // Each group as `Array.prototype.sort` sorts it, which what is no order depends on. - for group in order.chunk_by_mut(|a, b| rank(*a) == rank(*b)).filter(|it| it.len() > 1) { + for group in order + .chunk_by_mut(|a, b| rank(*a) == rank(*b)) + .filter(|it| it.len() > 1) + { utils::array_sort_by(group, &is_before); } // What has the same name and the same kind gets the rank of the last of them. let mut ranks: FxHashMap<(&[u8], ImportKind), f64> = FxHashMap::default(); - for (new_rank, it) in order.iter().filter_map(|at| imported.get(*at as usize)).enumerate() { - ranks.insert((&*it.value, it.import_kind), it.rank.trunc() + new_rank as f64); + for (new_rank, it) in order + .iter() + .filter_map(|at| imported.get(*at as usize)) + .enumerate() + { + ranks.insert( + (&*it.value, it.import_kind), + it.rank.trunc() + new_rank as f64, + ); } let new_ranks = imported.iter().map(|it| { let known = ranks.get(&(&*it.value, it.import_kind)); @@ -763,7 +904,10 @@ impl Order { }); new_ranks.collect() }; - imported.iter_mut().zip(ranks).for_each(|(it, rank)| it.rank = rank); + imported + .iter_mut() + .zip(ranks) + .for_each(|(it, rank)| it.rank = rank); } /// `makeOutOfOrderReport`. `body`: the block, where whole statements are moved that cannot cross everything. @@ -816,7 +960,14 @@ impl Order { continue; }; let first = highest.partition_point(|it| it <= rank); - Self::report_pair(&mut imported, (first, second), is_reversed, category, &can_reorder, cx); + Self::report_pair( + &mut imported, + (first, second), + is_reversed, + category, + &can_reorder, + cx, + ); } } @@ -829,7 +980,9 @@ impl Order { can_reorder: &dyn Fn(usize, usize) -> bool, cx: &Cx<'a, Self>, ) { - if imported.get(first).map(|it| &it.display_name) == imported.get(second).map(|it| &it.display_name) { + if imported.get(first).map(|it| &it.display_name) + == imported.get(second).map(|it| &it.display_name) + { for at in [first, second] { if let Some(it) = imported.get_mut(at) && let Some(alias) = it.alias @@ -853,8 +1006,15 @@ impl Order { // The names are looked at where the declaration is entered. let report = report.on_exit(false); // `findSpecifierStart`, `findSpecifierEnd` - let around = |it: &Entry| Some((file.token_before(it.node)?.end(), file.token_after(it.node)?.start())); - let Some(((first_start, first_end), (second_start, second_end))) = around(first).zip(around(second)) else { + let around = |it: &Entry| { + Some(( + file.token_before(it.node)?.end(), + file.token_after(it.node)?.start(), + )) + }; + let Some(((first_start, first_end), (second_start, second_end))) = + around(first).zip(around(second)) + else { return; }; let first_code = slice(text, first_start, first.node.end); @@ -866,7 +1026,16 @@ impl Order { let trimmed = strings::trim_js_whitespace_end(second_trivia); let gap = slice(text, first_end, second_start.saturating_sub(1)); let blanks = second_trivia.get(trimmed.len()..).unwrap_or_default(); - let moved = [second_code, b",", trimmed, first_code, first_trivia, gap, blanks].concat(); + let moved = [ + second_code, + b",", + trimmed, + first_code, + first_trivia, + gap, + blanks, + ] + .concat(); fixer.replace(Span::new(first_start, second_end), moved) } true => { @@ -884,14 +1053,28 @@ impl Order { } report.fix(|fixer| { let lines_of = |it: &Entry| { - (start_of_line_with_comments(file, it.root), end_of_line_with_comments(file, it.root)) + ( + start_of_line_with_comments(file, it.root), + end_of_line_with_comments(file, it.root), + ) }; - let ((first_start, first_end), (second_start, second_end)) = (lines_of(first), lines_of(second)); + let ((first_start, first_end), (second_start, second_end)) = + (lines_of(first), lines_of(second)); let moved = substring(text, second_start, second_end); let line_break: &[u8] = if moved.ends_with(b"\n") { b"" } else { b"\n" }; let ((start, end), replacement) = match is_after { - false => ((first_start, second_end), [moved, line_break, substring(text, first_start, second_start)]), - true => ((second_start, first_end), [substring(text, second_end, first_end), moved, line_break]), + false => ( + (first_start, second_end), + [ + moved, + line_break, + substring(text, first_start, second_start), + ], + ), + true => ( + (second_start, first_end), + [substring(text, second_end, first_end), moved, line_break], + ), }; (start <= end).then(|| fixer.replace(Span::new(start, end), replacement.concat())) }); @@ -914,7 +1097,11 @@ impl Order { display_name: Cow::Borrowed(name), alias, written, - import_kind: if written == Written::Import { kind } else { ImportKind::Undefined }, + import_kind: if written == Written::Import { + kind + } else { + ImportKind::Undefined + }, is_type_export: written == Written::Export && is_type, rank: match self.named.types { NamedTypes::Mixed => 0.0, @@ -949,7 +1136,12 @@ impl Order { return None; }; let alias = (!it.is_shorthand()).then(|| value.bytes()); - Some(self.named_entry(it.span(), (name, alias), Written::Require, ImportKind::Undefined)) + Some(self.named_entry( + it.span(), + (name, alias), + Written::Require, + ImportKind::Undefined, + )) }); if let Some(names) = names.collect::>>() { self.check_names(names, cx); @@ -962,9 +1154,15 @@ impl Order { return; }; let names = export.items().iter().map(|it| { - let alias = (it.is_renamed() && !it.exported().is_string()).then(|| it.exported().bytes()); + let alias = + (it.is_renamed() && !it.exported().is_string()).then(|| it.exported().bytes()); let kind = Self::kind_of(cx, it.is_type_only()); - self.named_entry(it.span(), (name_of(it.local()), alias), Written::Export, kind) + self.named_entry( + it.span(), + (name_of(it.local()), alias), + Written::Export, + kind, + ) }); self.check_names(names.collect(), cx); } @@ -974,7 +1172,9 @@ impl Order { let file = cx.file(); let mut blocks: FxHashMap<(u32, u32, bool), Vec>> = FxHashMap::default(); for e in file.exprs_of_kind(ExprTag::Assign) { - let (ExprKind::Assign { target, value, .. }, Node::Stmt(statement)) = (e.kind(), e.parent()) else { + let (ExprKind::Assign { target, value, .. }, Node::Stmt(statement)) = + (e.kind(), e.parent()) + else { continue; }; if statement.tag() != StmtTag::Expr { @@ -987,14 +1187,23 @@ impl Order { let names = properties.iter().map(|it| { let (name, value) = (identifier_of(it.key()?)?, it.value()?.as_ident()?); let alias = (it.kind() != PropKind::Shorthand).then(|| value.bytes()); - Some(self.named_entry(it.span(), (name, alias), Written::Export, ImportKind::Undefined)) + Some(self.named_entry( + it.span(), + (name, alias), + Written::Export, + ImportKind::Undefined, + )) }); if let Some(names) = names.collect::>>() { self.check_names(names, cx); } } else if let Some(name) = named_cjs_export(target) { let block = statement.parent(); - let key = (block.span().start, block.span().end, matches!(block, Node::Case(_))); + let key = ( + block.span().start, + block.span().end, + matches!(block, Node::Case(_)), + ); blocks.entry(key).or_default().push(Entry { node: e.span(), root: root_of(statement), @@ -1013,7 +1222,7 @@ impl Order { if self.alphabetize.order == Direction::Ignore { return; } - for mut exported in blocks.into_values() { + for (_, mut exported) in utils::sort::in_order_of(blocks, |it| it.0) { utils::sort::sort_by_key(&mut exported, |it| it.node.start); self.alphabetize(&mut exported); self.report_out_of_order(exported, Category::Exports, None, cx); diff --git a/src/lint/plugins/rules/promise_prefer_await_to_callbacks.rs b/src/lint/plugins/rules/promise_prefer_await_to_callbacks.rs index f7e29e33f427..a9fc47a6e562 100644 --- a/src/lint/plugins/rules/promise_prefer_await_to_callbacks.rs +++ b/src/lint/plugins/rules/promise_prefer_await_to_callbacks.rs @@ -1,17 +1,22 @@ -use bun_lint_oxlint::ast_util::{get_inner_expression, static_property_name}; use bun_lint::prelude::*; use bun_lint::rule::Plugin; use bun_lint::utils::ancestor_memo::AncestorMemo; +use bun_lint_oxlint::ast_util::{get_inner_expression, static_property_name}; /// Prefer `async`/`await` over callback functions for handling asynchronous code. pub struct PreferAwaitToCallbacks; -const PREFER_AWAIT_TO_CALLBACKS: Message = Message::new("", "Prefer `async`/`await` to the callback pattern"); +const PREFER_AWAIT_TO_CALLBACKS: Message = + Message::new("", "Prefer `async`/`await` to the callback pattern"); const ARRAY_METHODS: [&str; 6] = ["map", "every", "forEach", "some", "find", "filter"]; impl Rule for PreferAwaitToCallbacks { - const META: Meta = Meta::oxlint(Plugin::Promise, "prefer-await-to-callbacks", Kind::Suggestion); + const META: Meta = Meta::oxlint( + Plugin::Promise, + "prefer-await-to-callbacks", + Kind::Suggestion, + ); const ON: On = On::new().exprs(&[ExprTag::Call]).funcs(); /// Whether something is in a `yield` or an `await`. type State<'a> = AncestorMemo<'a, ()>; @@ -49,24 +54,40 @@ impl Rule for PreferAwaitToCallbacks { let Some(last_arg) = call.args().last() else { return; }; - let Some(first_param) = last_arg.as_fn().and_then(|it| it.params().first()).filter(|it| !it.is_rest()) else { + let Some(first_param) = last_arg + .as_fn() + .and_then(|it| it.params().first()) + .filter(|it| !it.is_rest()) + else { return; }; - if !first_param.pat().as_ident().is_some_and(|name| name.is_any(&["err", "error"])) || last_arg.is_parenthesized() { + if !first_param + .pat() + .as_ident() + .is_some_and(|name| name.is_any(&["err", "error"])) + || last_arg.is_parenthesized() + { return; } let member = Some(callee).filter(|it| !it.is_parenthesized()); let callee_property_name = member.and_then(static_property_name); - if callee_property_name.is_some_and(|name| name.is_any(&["on", "once", "addEventListener", "removeEventListener"])) { + if callee_property_name.is_some_and(|name| { + name.is_any(&["on", "once", "addEventListener", "removeEventListener"]) + }) { return; } let is_lodash = member.and_then(Expr::object).is_some_and(|object| { - object.as_ident().is_some_and(|name| name.is_any(&["_", "lodash", "underscore"])) && !object.is_parenthesized() + object + .as_ident() + .is_some_and(|name| name.is_any(&["_", "lodash", "underscore"])) + && !object.is_parenthesized() }); let arguments = call.args().len(); - let calls_array_method = - callee_property_name.is_some_and(|name| name.is_any(&ARRAY_METHODS)) && (arguments == 1 || arguments == 2 && is_lodash); - let is_array_method = callee_name.is_some_and(|name| name.is_any(&ARRAY_METHODS)) && arguments == 2; + let calls_array_method = callee_property_name + .is_some_and(|name| name.is_any(&ARRAY_METHODS)) + && (arguments == 1 || arguments == 2 && is_lodash); + let is_array_method = + callee_name.is_some_and(|name| name.is_any(&ARRAY_METHODS)) && arguments == 2; if calls_array_method || is_array_method { return; } @@ -74,7 +95,8 @@ impl Rule for PreferAwaitToCallbacks { matches!(ancestor, Node::Expr(it) if matches!(it.tag(), ExprTag::Yield | ExprTag::Await)).then_some(()) }; if cx.state.find(Node::Expr(e), is_yield_or_await).is_none() { - cx.report(last_arg, PREFER_AWAIT_TO_CALLBACKS); + cx.report(last_arg, PREFER_AWAIT_TO_CALLBACKS) + .called_with(e); } } @@ -89,7 +111,10 @@ impl Rule for PreferAwaitToCallbacks { }; if is_function && let Some(param) = func.params().iter().rfind(|it| !it.is_rest()) - && param.pat().as_ident().is_some_and(|name| name.is_any(&["callback", "cb"])) + && param + .pat() + .as_ident() + .is_some_and(|name| name.is_any(&["callback", "cb"])) { cx.report(param, PREFER_AWAIT_TO_CALLBACKS); } diff --git a/src/lint/plugins/rules/react_hooks_rules_of_hooks.rs b/src/lint/plugins/rules/react_hooks_rules_of_hooks.rs index a33a9851a2d1..0007bddcd6b5 100644 --- a/src/lint/plugins/rules/react_hooks_rules_of_hooks.rs +++ b/src/lint/plugins/rules/react_hooks_rules_of_hooks.rs @@ -13,12 +13,18 @@ use std::collections::BinaryHeap; /// Enforces the Rules of Hooks. pub struct RulesOfHooks; -pub(crate) const TRY_CATCH: Message = Message::new("", "React Hook \"{{hook}}\" cannot be called in a try/catch block."); +pub(crate) const TRY_CATCH: Message = Message::new( + "", + "React Hook \"{{hook}}\" cannot be called in a try/catch block.", +); pub(crate) const LOOP: Message = Message::new( "", "React Hook \"{{hook}}\" may be executed more than once. Possibly because it is called in a loop. React Hooks must be called in the exact same order in every component render.", ); -const ASYNC: Message = Message::new("", "React Hook \"{{hook}}\" cannot be called in an async function."); +const ASYNC: Message = Message::new( + "", + "React Hook \"{{hook}}\" cannot be called in an async function.", +); const CONDITIONAL: Message = Message::new( "", "React Hook \"{{hook}}\" is called conditionally. React Hooks must be called in the exact same order in every component render.{{hint}}", @@ -65,7 +71,9 @@ fn starts_with_uppercase(name: Name) -> bool { fn is_hook(e: Expr) -> bool { match e.kind() { ExprKind::Ident(name) => is_hook_name(name.bytes()), - ExprKind::Dot { obj, name, .. } => is_hook_name(name.bytes()) && obj.as_ident().is_some_and(starts_with_uppercase), + ExprKind::Dot { obj, name, .. } => { + is_hook_name(name.bytes()) && obj.as_ident().is_some_and(starts_with_uppercase) + } _ => false, } } @@ -73,8 +81,12 @@ fn is_hook(e: Expr) -> bool { /// Whether `callee` is a `use` that the file declares itself, other than by importing it from React. Playwright passes a function /// with that name to every fixture: `async ({ page }, use) => { await use(page); }`. fn is_use_of_the_file(callee: Expr) -> bool { - let is_from_react = |it| matches!(it, Declaration::ImportSpec(spec) if spec.import().spec().is("react")); - callee.is_ident("use") && callee.symbol().is_some_and(|symbol| !symbol.declarations().any(is_from_react)) + let is_from_react = + |it| matches!(it, Declaration::ImportSpec(spec) if spec.import().spec().is("react")); + callee.is_ident("use") + && callee + .symbol() + .is_some_and(|symbol| !symbol.declarations().any(is_from_react)) } /// [`is_hook`], minus what only has the name of a hook. With an ESLint or oxlint config the name alone decides, as in the plugin. @@ -104,7 +116,8 @@ fn is_forward_ref_or_memo_callback(e: Expr) -> bool { match e.parent() { Node::Expr(parent) if !e.is_chain_root() => match parent.kind() { ExprKind::Call(call) | ExprKind::New(call) => { - is_react_function(call.callee(), "forwardRef") || is_react_function(call.callee(), "memo") + is_react_function(call.callee(), "forwardRef") + || is_react_function(call.callee(), "memo") } _ => false, }, @@ -139,7 +152,9 @@ impl<'a> FunctionName<'a> { /// `isComponentName(name) || isHook(name)` fn is_component_or_hook(self) -> bool { match self { - FunctionName::Ident(name, _) => starts_with_uppercase(name) || is_hook_name(name.bytes()), + FunctionName::Ident(name, _) => { + starts_with_uppercase(name) || is_hook_name(name.bytes()) + } FunctionName::Expr(e) => match e.kind() { ExprKind::Ident(name) => starts_with_uppercase(name) || is_hook_name(name.bytes()), _ => is_hook(e), @@ -160,7 +175,10 @@ fn get_function_name(func: Func) -> Option { return None; }; match e.parent() { - Node::VarDecl(declarator) => Some(FunctionName::of_pattern(declarator.pat(), declarator.binding_span())), + Node::VarDecl(declarator) => Some(FunctionName::of_pattern( + declarator.pat(), + declarator.binding_span(), + )), Node::Expr(parent) => match parent.kind() { ExprKind::Assign { op: None, @@ -173,13 +191,19 @@ fn get_function_name(func: Func) -> Option { let key = prop.key()?; match key.kind() { KeyKind::Ident(name) => Some(FunctionName::Ident(name, key.span(e.file()))), - KeyKind::String(_) | KeyKind::Number(_) => Some(FunctionName::Other(key.span(e.file()))), + KeyKind::String(_) | KeyKind::Number(_) => { + Some(FunctionName::Other(key.span(e.file()))) + } _ => None, } } Node::Param(param) => Some(FunctionName::of_pattern(param.pat(), param.binding_span())), - Node::PatElem(element) => element.pat().map(|pat| FunctionName::of_pattern(pat, pat.span())), - Node::PatProp(prop) if prop.default() == Some(e) => Some(FunctionName::of_pattern(prop.value(), prop.value().span())), + Node::PatElem(element) => element + .pat() + .map(|pat| FunctionName::of_pattern(pat, pat.span())), + Node::PatProp(prop) if prop.default() == Some(e) => { + Some(FunctionName::of_pattern(prop.value(), prop.value().span())) + } _ => None, } } @@ -203,14 +227,17 @@ pub(crate) struct Ranges { impl Ranges { pub(crate) fn new(ranges: impl Iterator) -> Ranges { - let (mut starts, mut ends): (Vec, Vec) = ranges.map(|it| (it.start, it.end)).unzip(); + let (mut starts, mut ends): (Vec, Vec) = + ranges.map(|it| (it.start, it.end)).unzip(); starts.sort_unstable(); ends.sort_unstable(); Ranges { starts, ends } } pub(crate) fn count_around(&self, at: u32) -> usize { - self.starts.partition_point(|it| *it <= at).saturating_sub(self.ends.partition_point(|it| *it <= at)) + self.starts + .partition_point(|it| *it <= at) + .saturating_sub(self.ends.partition_point(|it| *it <= at)) } pub(crate) fn contains(&self, at: u32) -> bool { @@ -237,11 +264,13 @@ pub(crate) struct Memo<'a> { impl<'a> Memo<'a> { pub(crate) fn do_while_loops(&self, file: &'a File<'a>) -> &Ranges { - self.do_while_loops.get_or_init(|| Ranges::new(file.stmts_of_kind(StmtTag::DoWhile).map(Stmt::span))) + self.do_while_loops + .get_or_init(|| Ranges::new(file.stmts_of_kind(StmtTag::DoWhile).map(Stmt::span))) } pub(crate) fn try_statements(&self, file: &'a File<'a>) -> &Ranges { - self.try_statements.get_or_init(|| Ranges::new(file.stmts_of_kind(StmtTag::Try).map(Stmt::span))) + self.try_statements + .get_or_init(|| Ranges::new(file.stmts_of_kind(StmtTag::Try).map(Stmt::span))) } fn is_inside_do_while_loop(&self, e: Expr<'a>) -> bool { @@ -260,12 +289,17 @@ impl<'a> Memo<'a> { if !strings::contains(file.text(), SUPPRESSION) { return Vec::new(); } - let comments = file.comments().filter(|it| strings::contains(it.value(), SUPPRESSION)); + let comments = file + .comments() + .filter(|it| strings::contains(it.value(), SUPPRESSION)); let mut lines: Vec = comments.map(|it| file.line_of(it.end()) + 1).collect(); lines.sort_unstable(); lines }); - !lines.is_empty() && lines.binary_search(&file.line_of(hook.span().start)).is_ok() + !lines.is_empty() + && lines + .binary_search(&file.line_of(hook.span().start)) + .is_ok() } } @@ -273,7 +307,11 @@ impl<'a> Memo<'a> { fn name_without_react_namespace(callee: Expr) -> Option { match callee.kind() { ExprKind::Ident(name) => Some(name), - ExprKind::Dot { obj, name, .. } if obj.is_ident("React") && !name.bytes().starts_with(b"#") => Some(name.name()), + ExprKind::Dot { obj, name, .. } + if obj.is_ident("React") && !name.bytes().starts_with(b"#") => + { + Some(name.name()) + } _ => None, } } @@ -306,7 +344,8 @@ impl Count { fn times(self, other: Count) -> Count { Count { - residue: (u128::from(self.residue) * u128::from(other.residue) % u128::from(Count::MODULUS)) as u64, + residue: (u128::from(self.residue) * u128::from(other.residue) + % u128::from(Count::MODULUS)) as u64, is_zero: self.is_zero || other.is_zero, } } @@ -360,7 +399,12 @@ struct ZeroGroup { impl Zeros { fn find(&mut self, group: usize) -> usize { let mut root = group; - while let Some(parent) = self.groups.get(root).map(|it| it.parent).filter(|it| *it != root) { + while let Some(parent) = self + .groups + .get(root) + .map(|it| it.parent) + .filter(|it| *it != root) + { root = parent; } let mut at = group; @@ -374,7 +418,10 @@ impl Zeros { fn get(&mut self, segment: u32) -> Option<(usize, usize)> { let group = *self.group_of.get(&segment)?; let root = self.find(group); - self.groups.get(root).filter(|it| it.is_valid).map(|it| (it.cyclic_from, it.owner)) + self.groups + .get(root) + .filter(|it| it.is_valid) + .map(|it| (it.cyclic_from, it.owner)) } /// Makes a group for `segment`, which takes over the groups `owned`. @@ -417,8 +464,13 @@ impl<'a> Paths<'a> { Direction::FromStart => segment.prev_segments(), Direction::ToEnd => segment.next_segments(), }; - if let Some(outer) = self.outer.filter(|outer| outer.code_path() == segment.code_path()) { - all.retain(|it| it.id() <= outer.id() && (direction == Direction::FromStart || segment != outer)); + if let Some(outer) = self + .outer + .filter(|outer| outer.code_path() == segment.code_path()) + { + all.retain(|it| { + it.id() <= outer.id() && (direction == Direction::FromStart || segment != outer) + }); } all } @@ -467,11 +519,19 @@ impl<'a> Paths<'a> { returned = Count::ZERO; } else { let is_thrown = self.thrown.contains(&segment.id()); - let neighbors = if is_thrown { Segments::new() } else { self.neighbors(segment, direction) }; + let neighbors = if is_thrown { + Segments::new() + } else { + self.neighbors(segment, direction) + }; positions.insert(segment.id(), stack.len()); stack.push(Frame { segment, - sum: if is_thrown || !neighbors.is_empty() { Count::ZERO } else { Count::ONE }, + sum: if is_thrown || !neighbors.is_empty() { + Count::ZERO + } else { + Count::ONE + }, neighbors, next: 0, cyclic_from: usize::MAX, @@ -512,7 +572,8 @@ impl<'a> Paths<'a> { self.from_start.remove(&segment.id()); match top.met.peek() { Some(&owner) => { - let group = zeros.add(segment.id(), top.cyclic_from, owner, &top.owned); + let group = + zeros.add(segment.id(), top.cyclic_from, owner, &top.owned); if let Some(frame) = stack.get_mut(owner) { frame.owned.push(group); } @@ -575,7 +636,11 @@ impl<'a> Paths<'a> { entering = Some(prev); continue; } - let length = if top.prev.is_empty() { 1 } else { top.length.saturating_add(1) }; + let length = if top.prev.is_empty() { + 1 + } else { + top.length.saturating_add(1) + }; self.shortest.insert(top.segment.id(), Some(length)); stack.pop(); if stack.is_empty() { @@ -605,27 +670,42 @@ fn why_conditional<'a>(hook: Expr<'a>, func: Func<'a>) -> Option { for outer in inner.ancestors().take_while(|it| *it != Node::Func(func)) { let decides = match outer { Node::Case(_) => true, - Node::Stmt(it) => it.as_if().is_some_and(|(test, ..)| Node::Expr(test) != inner), + Node::Stmt(it) => it + .as_if() + .is_some_and(|(test, ..)| Node::Expr(test) != inner), Node::Expr(it) => match it.kind() { ExprKind::Cond { test, .. } => Node::Expr(test) != inner, - ExprKind::Binary { op: BinOp::And | BinOp::Or | BinOp::Nullish, right, .. } => Node::Expr(right) == inner, + ExprKind::Binary { + op: BinOp::And | BinOp::Or | BinOp::Nullish, + right, + .. + } => Node::Expr(right) == inner, _ => false, }, _ => false, }; if decides { let line = file.line_of(outer.span().start); - return Some(format!("Whether it is called depends on the condition on line {line}.")); + return Some(format!( + "Whether it is called depends on the condition on line {line}." + )); } inner = outer; } - let early = func.returns().filter(|it| it.span().end <= hook.span().start).min_by_key(|it| it.span().start)?; + let early = func + .returns() + .filter(|it| it.span().end <= hook.span().start) + .min_by_key(|it| it.span().start)?; let line = file.line_of(early.span().start); - Some(format!("The return on line {line} can end the render before it is called.")) + Some(format!( + "The return on line {line} can end the render before it is called." + )) } impl Rule for RulesOfHooks { - const META: Meta = Meta::plugin(Plugin::ReactHooks, "rules-of-hooks", Kind::Problem).recommended().hints(); + const META: Meta = Meta::plugin(Plugin::ReactHooks, "rules-of-hooks", Kind::Problem) + .recommended() + .hints(); const ON: On = On::new().finish(); type State<'a> = State<'a>; @@ -638,14 +718,22 @@ impl Rule for RulesOfHooks { // A file has many times as many calls as names. let mut is_name_of_hook = ByName::default(); let may_call_a_hook = file.mentions_name_of_hook(); - for e in file.exprs_of_kind(ExprTag::Call).filter(|_| may_call_a_hook) { + for e in file + .exprs_of_kind(ExprTag::Call) + .filter(|_| may_call_a_hook) + { let Some(callee) = e.as_call().map(Call::callee) else { continue; }; - let Some(name) = callee.as_ident().or_else(|| callee.member_name().map(|it| it.name())) else { + let Some(name) = callee + .as_ident() + .or_else(|| callee.member_name().map(|it| it.name())) + else { continue; }; - if is_name_of_hook.get_or_insert_with(name, || is_hook_name(name.bytes())) && calls_hook(callee) { + if is_name_of_hook.get_or_insert_with(name, || is_hook_name(name.bytes())) + && calls_hook(callee) + { state.calls.push(e); } } @@ -700,9 +788,13 @@ fn at_the_top_of_its_code_path<'a>(call: Expr<'a>, steps: &mut usize) -> Option< | ExprKind::Spread(_) | ExprKind::Unary { .. } | ExprKind::Await(_) => !parent.is_assignment_target(), - ExprKind::Binary { op, left, .. } => !matches!(op, BinOp::And | BinOp::Or | BinOp::Nullish) || left == child, + ExprKind::Binary { op, left, .. } => { + !matches!(op, BinOp::And | BinOp::Or | BinOp::Nullish) || left == child + } ExprKind::Cond { test, .. } => test == child, - ExprKind::Assign { op: None, value, .. } => value == child && !parent.is_assignment_target(), + ExprKind::Assign { + op: None, value, .. + } => value == child && !parent.is_assignment_target(), _ => false, }; if !is_always_evaluated { @@ -710,11 +802,18 @@ fn at_the_top_of_its_code_path<'a>(call: Expr<'a>, steps: &mut usize) -> Option< } child = parent; } - Node::VarDecl(declarator) if declarator.init() == Some(child) => match declarator.parent() { - Node::Stmt(statement) if !statement.is_wrapper() => break statement, - _ => return None, - }, - Node::Stmt(statement) if matches!(statement.tag(), StmtTag::Expr | StmtTag::Return) && !statement.is_wrapper() => break statement, + Node::VarDecl(declarator) if declarator.init() == Some(child) => { + match declarator.parent() { + Node::Stmt(statement) if !statement.is_wrapper() => break statement, + _ => return None, + } + } + Node::Stmt(statement) + if matches!(statement.tag(), StmtTag::Expr | StmtTag::Return) + && !statement.is_wrapper() => + { + break statement; + } // The body of an arrow function. Node::Func(func) => { return Some(AtTheTop { @@ -744,13 +843,17 @@ fn at_the_top_of_its_code_path<'a>(call: Expr<'a>, steps: &mut usize) -> Option< fn roots_if_always_called<'a>(file: &'a File<'a>, calls: &[Expr<'a>]) -> Option>> { const STEPS_FOR_EACH: usize = 32; let mut steps = STEPS_FOR_EACH * (calls.len() + 8); - let calls: Vec> = calls.iter().map(|it| at_the_top_of_its_code_path(*it, &mut steps)).collect::>()?; + let calls: Vec> = calls + .iter() + .map(|it| at_the_top_of_its_code_path(*it, &mut steps)) + .collect::>()?; // For each code path, where the last of the statements starts. let mut last: FxHashMap, u32> = FxHashMap::default(); for call in &calls { let last = last.entry(call.root).or_insert(0); *last = call.before.end.max(*last); } + #[expect(clippy::iter_over_hash_type, reason = "whether any of them is so")] for (root, before) in &last { if let Node::Func(func) = root && func.returns().any(|it| it.span().start < *before) @@ -759,8 +862,16 @@ fn roots_if_always_called<'a>(file: &'a File<'a>, calls: &[Expr<'a>]) -> Option< } } let before = Ranges::new(calls.iter().map(|it| it.before)); - let ends_or_repeats = [StmtTag::Throw, StmtTag::For, StmtTag::While, StmtTag::DoWhile]; - let earlier = ends_or_repeats.into_iter().flat_map(|tag| file.stmts_of_kind(tag)).filter(|it| before.contains(it.span().start)); + let ends_or_repeats = [ + StmtTag::Throw, + StmtTag::For, + StmtTag::While, + StmtTag::DoWhile, + ]; + let earlier = ends_or_repeats + .into_iter() + .flat_map(|tag| file.stmts_of_kind(tag)) + .filter(|it| before.contains(it.span().start)); for statement in earlier { steps += STEPS_FOR_EACH; let mut around = Node::Stmt(statement).ancestors(); @@ -771,7 +882,10 @@ fn roots_if_always_called<'a>(file: &'a File<'a>, calls: &[Expr<'a>]) -> Option< root => break root, } }; - if root.and_then(|root| last.get(&root)).is_some_and(|before| statement.span().start < *before) { + if root + .and_then(|root| last.get(&root)) + .is_some_and(|before| statement.span().start < *before) + { return None; } } @@ -832,27 +946,40 @@ impl RulesOfHooks { // The routes, how many lead from the start to the end, and the length of the shortest. let mut analyzed = code_path.map(|code_path| { let mut paths = Paths { - thrown: code_path.thrown_segments().iter().map(|it| it.id()).collect(), + thrown: code_path + .thrown_segments() + .iter() + .map(|it| it.id()) + .collect(), cyclic: FxHashSet::default(), from_start: FxHashMap::default(), to_end: FxHashMap::default(), shortest: FxHashMap::default(), - outer: hooks.iter().find_map(|it| it.0.filter(|segment| segment.code_path() != code_path)), + outer: hooks + .iter() + .find_map(|it| it.0.filter(|segment| segment.code_path() != code_path)), }; - let all_paths_from_start_to_end = paths.count(code_path.initial_segment(), Direction::ToEnd); + let all_paths_from_start_to_end = + paths.count(code_path.initial_segment(), Direction::ToEnd); let mut shortest_final_path_length = INFINITY; for segment in code_path.final_segments() { if segment.is_reachable() { - shortest_final_path_length = shortest_final_path_length.min(paths.shortest_path_length_to_start(segment)); + shortest_final_path_length = shortest_final_path_length + .min(paths.shortest_path_length_to_start(segment)); } } - (paths, all_paths_from_start_to_end, shortest_final_path_length) + ( + paths, + all_paths_from_start_to_end, + shortest_final_path_length, + ) }); let func = node.as_func(); let function_name = func.and_then(get_function_name); let follows_oxlint = oxlint::is_followed(cx.file()); - let is_somewhere_inside_component_or_hook = !follows_oxlint && is_inside_component_or_hook(node, &mut memo.inside_component_or_hook); + let is_somewhere_inside_component_or_hook = !follows_oxlint + && is_inside_component_or_hook(node, &mut memo.inside_component_or_hook); let function_expr = match (node, func.map(Func::owner)) { (Node::Expr(e), _) | (_, Some(Node::Expr(e))) => Some(e), _ => None, @@ -864,7 +991,9 @@ impl RulesOfHooks { // It is the value of a method or of a property of a class. let is_in_class = match (func.map(Func::owner), function_expr.map(Expr::parent)) { (Some(Node::Member(member)), _) => member.kind() != MemberKind::StaticBlock, - (_, Some(Node::Member(member))) => member.init() == function_expr && !member.flags().contains(Flags::ACCESSOR), + (_, Some(Node::Member(member))) => { + member.init() == function_expr && !member.flags().contains(Flags::ACCESSOR) + } _ => false, }; @@ -875,16 +1004,20 @@ impl RulesOfHooks { is_cyclic: false, is_conditional: false, }; - if let Some((paths, all_paths_from_start_to_end, shortest_final_path_length)) = &mut analyzed { + if let Some((paths, all_paths_from_start_to_end, shortest_final_path_length)) = + &mut analyzed + { flow.is_reachable = false; if let Some(segment) = segment.filter(|it| it.is_reachable()) { let length = paths.shortest_path_length_to_start(segment); - possibly_has_early_return = match paths.neighbors(segment, Direction::ToEnd).is_empty() { - true => *shortest_final_path_length <= length, - false => *shortest_final_path_length < length, - }; - let paths_from_start_to_end = - paths.count(segment, Direction::FromStart).times(paths.count(segment, Direction::ToEnd)); + possibly_has_early_return = + match paths.neighbors(segment, Direction::ToEnd).is_empty() { + true => *shortest_final_path_length <= length, + false => *shortest_final_path_length < length, + }; + let paths_from_start_to_end = paths + .count(segment, Direction::FromStart) + .times(paths.count(segment, Direction::ToEnd)); flow = Flow { is_reachable: true, is_cyclic: paths.cyclic.contains(&segment.id()), @@ -942,8 +1075,13 @@ impl RulesOfHooks { }; report(message) .data("hint", hint) - .data("function", function_name.map_or(&b""[..], |it| cx.slice(it.span()))) - .hint_with(|| why_conditional(hook, func?).filter(|_| message.text == CONDITIONAL.text)); + .data( + "function", + function_name.map_or(&b""[..], |it| cx.slice(it.span())), + ) + .hint_with(|| { + why_conditional(hook, func?).filter(|_| message.text == CONDITIONAL.text) + }); } } } @@ -969,7 +1107,9 @@ impl RulesOfHooks { }; let is_effect_event = name.is("useEffectEvent"); let is_effect = name.is_any(&["useEffect", "useLayoutEffect", "useInsertionEffect"]) - || additional_effect_hooks.as_ref().is_some_and(|it| it.test(name.bytes())); + || additional_effect_hooks + .as_ref() + .is_some_and(|it| it.test(name.bytes())); if (is_effect || is_effect_event) && !call.args().is_empty() { effects.push(e.span()); } @@ -978,7 +1118,8 @@ impl RulesOfHooks { } let parent = e.parent(); let is_expression_statement = matches!(parent, Node::Stmt(stmt) if stmt.tag() == StmtTag::Expr && !stmt.is_wrapper()); - if e.is_chain_root() || !(matches!(parent, Node::VarDecl(_)) || is_expression_statement) { + if e.is_chain_root() || !(matches!(parent, Node::VarDecl(_)) || is_expression_statement) + { cx.report(e, EFFECT_EVENT_PASSED_DOWN); } // `recordAllUseEffectEventFunctions` @@ -987,7 +1128,9 @@ impl RulesOfHooks { && call.callee().tag() == ExprTag::Ident && declarator.pat().tag() == PatTag::Ident && let Some(symbol) = declarator.pat().symbol() - && let Some(declaring) = symbol.references().find(|it| it.span() == declarator.pat().span()) + && let Some(declaring) = symbol + .references() + .find(|it| it.span() == declarator.pat().span()) && let Node::Func(func) = declaring.scope().node() && matches!(func.kind(), FnKind::Decl | FnKind::Arrow) && is_inside_component_or_hook(Node::Func(func), &mut inside_component_or_hook) @@ -1007,12 +1150,23 @@ impl RulesOfHooks { }; // The call that was entered last, if the walk has not left it. let entered = effects.partition_point(|it| it.start <= e.span().start); - if entered.checked_sub(1).and_then(|at| effects.get(at)).is_some_and(|it| it.contains(e.span())) { + if entered + .checked_sub(1) + .and_then(|at| effects.get(at)) + .is_some_and(|it| it.contains(e.span())) + { continue; } - let is_called = matches!(e.parent(), Node::Expr(parent) if parent.tag() == ExprTag::Call); - let hint = if is_called { "" } else { " It cannot be assigned to a variable or passed down." }; - cx.report(e, EFFECT_EVENT_FUNCTION).data("function", e.text()).data("hint", hint); + let is_called = + matches!(e.parent(), Node::Expr(parent) if parent.tag() == ExprTag::Call); + let hint = if is_called { + "" + } else { + " It cannot be assigned to a variable or passed down." + }; + cx.report(e, EFFECT_EVENT_FUNCTION) + .data("function", e.text()) + .data("hint", hint); } } } diff --git a/src/lint/react/lib.rs b/src/lint/react/lib.rs index 90b795fa06b8..07904918a19a 100644 --- a/src/lint/react/lib.rs +++ b/src/lint/react/lib.rs @@ -8,6 +8,7 @@ //! https://github.com/vercel/next.js/tree/canary/packages/eslint-plugin-next (Copyright Vercel, Inc., MIT License) #![forbid(unsafe_code)] +#![deny(clippy::iter_over_hash_type)] mod a11y; mod jsx; diff --git a/src/lint/react/rules/react_jsx_props_no_spread_multi.rs b/src/lint/react/rules/react_jsx_props_no_spread_multi.rs index 21fcab4561c0..7225b2f7586b 100644 --- a/src/lint/react/rules/react_jsx_props_no_spread_multi.rs +++ b/src/lint/react/rules/react_jsx_props_no_spread_multi.rs @@ -1,8 +1,8 @@ -use bun_lint_oxlint::ast_util::{get_inner_expression, static_property_name_or_regex}; use crate::react::is_jsx; -use bun_lint_oxlint::same_expression::is_same_member_expression; use bun_lint::prelude::*; use bun_lint::rule::Plugin; +use bun_lint_oxlint::ast_util::{get_inner_expression, static_property_name_or_regex}; +use bun_lint_oxlint::same_expression::is_same_member_expression; use rustc_hash::{FxHashMap, FxHasher}; use smallvec::{SmallVec, smallvec}; use std::hash::Hasher; @@ -10,13 +10,18 @@ use std::hash::Hasher; /// Disallow JSX prop spreading the same identifier multiple times pub struct JsxPropsNoSpreadMulti; -const NO_MULTI_SPREADING: Message = - Message::new("noMultiSpreading", "Spreading the same expression multiple times is forbidden"); -const MULTIPLE_IDENTIFIERS: Message = Message::new("", "Prop '{{prop_name}}' is spread multiple times."); -const MULTIPLE_MEMBER_EXPRESSIONS: Message = Message::new("", "'{{member_name}}' is spread multiple times."); +const NO_MULTI_SPREADING: Message = Message::new( + "noMultiSpreading", + "Spreading the same expression multiple times is forbidden", +); +const MULTIPLE_IDENTIFIERS: Message = + Message::new("", "Prop '{{prop_name}}' is spread multiple times."); +const MULTIPLE_MEMBER_EXPRESSIONS: Message = + Message::new("", "'{{member_name}}' is spread multiple times."); impl Rule for JsxPropsNoSpreadMulti { - const META: Meta = Meta::plugin(Plugin::React, "jsx-props-no-spread-multi", Kind::None).fixable(Fixable::Code); + const META: Meta = + Meta::plugin(Plugin::React, "jsx-props-no-spread-multi", Kind::None).fixable(Fixable::Code); const ON: On = On::new().exprs(&[ExprTag::Jsx]); type State<'a> = (); @@ -38,9 +43,13 @@ fn check<'a>(_: &JsxPropsNoSpreadMulti, e: Expr<'a>, cx: &mut Cx<'a, JsxPropsNoS let ExprKind::Jsx(jsx) = e.kind() else { return; }; - let spread_attrs = jsx.attrs().iter().filter(|it| it.kind() == PropKind::Spread); - let spread_attrs: SmallVec<[(Expr<'a>, Span); 4]> = - spread_attrs.filter_map(|it| Some((it.value()?, it.span()))).collect(); + let spread_attrs = jsx + .attrs() + .iter() + .filter(|it| it.kind() == PropKind::Spread); + let spread_attrs: SmallVec<[(Expr<'a>, Span); 4]> = spread_attrs + .filter_map(|it| Some((it.value()?, it.span()))) + .collect(); if spread_attrs.len() < 2 { return; } @@ -50,14 +59,25 @@ fn check<'a>(_: &JsxPropsNoSpreadMulti, e: Expr<'a>, cx: &mut Cx<'a, JsxPropsNoS let is_oxlint = cx.language().is_oxlint; for &(argument, span) in &spread_attrs { // oxlint looks through `a!`, `a as T` and the like, and compares member expressions as well. - let seen = if is_oxlint { get_inner_expression(argument) } else { argument }; + let seen = if is_oxlint { + get_inner_expression(argument) + } else { + argument + }; if let Some(name) = seen.as_ident() { identifiers.entry(name).or_default().push(span); - } else if is_oxlint && matches!(argument.tag(), ExprTag::Dot | ExprTag::Index) && !argument.is_chain_root() { - member_expressions.entry(hash_of_names(argument)).or_default().push((argument, span)); + } else if is_oxlint + && matches!(argument.tag(), ExprTag::Dot | ExprTag::Index) + && !argument.is_chain_root() + { + member_expressions + .entry(hash_of_names(argument)) + .or_default() + .push((argument, span)); } } - for (name, spans) in identifiers { + for (name, spans) in utils::sort::in_order_of(identifiers, |it| it.1.first().map(|it| it.start)) + { // oxlint reports the first one, once, and removes all but the last. if !is_oxlint { // The plugin has no fix, so only fix when there's no plugin to match. @@ -69,12 +89,23 @@ fn check<'a>(_: &JsxPropsNoSpreadMulti, e: Expr<'a>, cx: &mut Cx<'a, JsxPropsNoS } } else if let [first, .., _] = spans[..] { (spans.iter().skip(1)) - .fold(cx.report(first, MULTIPLE_IDENTIFIERS), |report, span| report.label(*span, "")) + .fold(cx.report(first, MULTIPLE_IDENTIFIERS), |report, span| { + report.label(*span, "") + }) .data("prop_name", name) - .fix(|fixer| spans.iter().rev().skip(1).map(|span| fixer.remove(*span)).collect::>()); + .fix(|fixer| { + spans + .iter() + .rev() + .skip(1) + .map(|span| fixer.remove(*span)) + .collect::>() + }); } } - for alike in member_expressions.values() { + for alike in utils::sort::in_order_of(member_expressions.values(), |it| { + it.first().map(|it| it.1.start) + }) { for (i, &(left, left_span)) in alike.iter().enumerate() { for &(right, right_span) in alike.iter().skip(i + 1) { if is_same_member_expression(left, right) { @@ -99,7 +130,9 @@ fn hash_of_names(member: Expr) -> u64 { match (static_property_name_or_regex(e), e.index()) { (Some(name), _) => hasher.write(name), (None, Some(index)) => pending.push(index), - (None, None) => hasher.write(e.member_name().map(Ident::bytes).unwrap_or_default()), + (None, None) => { + hasher.write(e.member_name().map(Ident::bytes).unwrap_or_default()) + } } pending.push(obj); } @@ -119,4 +152,3 @@ fn hash_of_names(member: Expr) -> u64 { } hasher.finish() } - diff --git a/src/lint/react/util_default_props.rs b/src/lint/react/util_default_props.rs index ed630328de7a..bd7fecb7e0c1 100644 --- a/src/lint/react/util_default_props.rs +++ b/src/lint/react/util_default_props.rs @@ -334,6 +334,10 @@ impl<'a> Instructions<'a> for DefaultPropsInstructions<'a> { } fn program_exit(&mut self, components: &mut Components<'a>) { + #[expect( + clippy::iter_over_hash_type, + reason = "each is written to its own component" + )] for (component, default_props) in self.default_props.drain() { let default_props = match default_props { Some(default_props) => DefaultProps::Known( diff --git a/src/lint/semantic/mod.rs b/src/lint/semantic/mod.rs index 3aac7714c2b9..8d25e500a357 100644 --- a/src/lint/semantic/mod.rs +++ b/src/lint/semantic/mod.rs @@ -1178,6 +1178,15 @@ impl<'a> Scope<'a> { ) } + /// The same references, nearly in the order of the source: the names of a pattern come before its defaults. For who does + /// not go by ESLint's order, which costs a pass over all references of the file the first time it is asked. + pub fn references_with_names_of_patterns_first( + self, + ) -> impl DoubleEndedIterator> + ExactSizeIterator + 'a { + let in_it = self.file.reference_list().in_scope_as_visited(self.id.0); + Reference::all_at(self.file, in_it) + } + /// The references in it, and in the scopes inside it, to what is declared outside it or /// nowhere: ESLint's `scope.through`. Scope by scope, not in source order. pub fn through(self) -> impl Iterator> + 'a { diff --git a/src/lint/semantic/references.rs b/src/lint/semantic/references.rs index 462f18b7a06f..3ee0b6fd780e 100644 --- a/src/lint/semantic/references.rs +++ b/src/lint/semantic/references.rs @@ -1642,6 +1642,15 @@ impl References { Self::group(by_scope, starts, first as usize, last as usize) } + /// The same in the order of `visiting_order`, whatever else has been asked. + #[inline] + pub(crate) fn in_scope_as_visited(&self, scope: u32) -> &[u32] { + let (by_scope, starts) = self + .by_scope_in_some_order + .get_or_init(|| self.group_by_scope(&self.visiting_order)); + Self::group(by_scope, starts, scope as usize, scope as usize) + } + /// The first reference that is written at `pos`. pub(crate) fn at(&self, pos: u32) -> Option<&RawReference> { self.all diff --git a/src/lint/typescript/lib.rs b/src/lint/typescript/lib.rs index e19d1158430f..79ce806637d4 100644 --- a/src/lint/typescript/lib.rs +++ b/src/lint/typescript/lib.rs @@ -3,6 +3,7 @@ //! contributors, MIT License): the same options, the same messages, at the same places. #![forbid(unsafe_code)] +#![deny(clippy::iter_over_hash_type)] bun_lint::rules! { adjacent_overload_signatures::AdjacentOverloadSignatures, diff --git a/src/lint/typescript/rules/no_unnecessary_parameter_property_assignment.rs b/src/lint/typescript/rules/no_unnecessary_parameter_property_assignment.rs index ae1a9e29304b..74c127c6fd3f 100644 --- a/src/lint/typescript/rules/no_unnecessary_parameter_property_assignment.rs +++ b/src/lint/typescript/rules/no_unnecessary_parameter_property_assignment.rs @@ -359,7 +359,7 @@ impl Rule for NoUnnecessaryParameterPropertyAssignment { } } - for info in infos.values() { + for (_, info) in utils::sort::in_order_of(&infos, |it| it.0.span().start) { for (name, e) in &info.unnecessary_assignments { if !info.assigned_before_constructor.contains(name) { let report = cx.report(*e, UNNECESSARY_ASSIGN); diff --git a/src/lint/typescript/rules/parameter_properties.rs b/src/lint/typescript/rules/parameter_properties.rs index d324cdfddc23..340895772df6 100644 --- a/src/lint/typescript/rules/parameter_properties.rs +++ b/src/lint/typescript/rules/parameter_properties.rs @@ -33,12 +33,20 @@ fn modifiers_of(flags: Flags) -> u8 { } else { 0 }; - accessibility | if flags.contains(Flags::READONLY) { READONLY } else { 0 } + accessibility + | if flags.contains(Flags::READONLY) { + READONLY + } else { + 0 + } } /// Where typescript-estree has a `TSParameterProperty`: a keyword is before the parameter. fn has_keyword(param: Param) -> bool { - param.modifiers().iter().any(|modifier| modifier.decorator().is_none()) + param + .modifiers() + .iter() + .any(|modifier| modifier.decorator().is_none()) } /// The name of a parameter that is an `Identifier` for typescript-estree. @@ -60,14 +68,21 @@ pub(crate) fn identifier_key<'a>(member: Member<'a>) -> Option> { fn assigned_name<'a>(statement: Stmt<'a>) -> Option> { let (_, target, value) = statement.as_expr()?.as_assign()?; let is_property_of_this = match target.kind() { - ExprKind::Dot { obj, name, .. } => obj.tag() == ExprTag::This && !name.bytes().starts_with(b"#"), - ExprKind::Index { obj, index, .. } => obj.tag() == ExprTag::This && index.tag() == ExprTag::Ident, + ExprKind::Dot { obj, name, .. } => { + obj.tag() == ExprTag::This && !name.bytes().starts_with(b"#") + } + ExprKind::Index { obj, index, .. } => { + obj.tag() == ExprTag::This && index.tag() == ExprTag::Ident + } _ => false, }; value.as_ident().filter(|_| is_property_of_this) } -fn type_annotations_match<'a>(class_property: Member<'a>, constructor_parameter: Param<'a>) -> bool { +fn type_annotations_match<'a>( + class_property: Member<'a>, + constructor_parameter: Param<'a>, +) -> bool { match (class_property.ty(), constructor_parameter.ty()) { (Some(a), Some(b)) => { let file = a.file(); @@ -88,14 +103,17 @@ impl ParameterProperties { && !self.is_allowed(param.flags()) && let Some(name) = param.pat().as_ident() { - cx.report(param, PREFER_CLASS_PROPERTY).data("parameter", name); + cx.report(param, PREFER_CLASS_PROPERTY) + .data("parameter", name); } } fn check_class<'a>(&self, class: Class<'a>, cx: &mut Cx<'a, Self>) { let constructors = || { let members = class.members().iter(); - members.filter(|it| it.kind() == MemberKind::Constructor).filter_map(Member::func) + members + .filter(|it| it.kind() == MemberKind::Constructor) + .filter_map(Member::func) }; let mut assigned: FxHashSet> = FxHashSet::default(); for constructor in constructors() { @@ -106,8 +124,10 @@ impl ParameterProperties { return; } // Of several with the same name, the last counts. - let parameters: FxHashMap, Param<'a>> = - constructors().flat_map(Func::params).filter_map(|it| Some((plain_name(it)?, it))).collect(); + let parameters: FxHashMap, Param<'a>> = constructors() + .flat_map(Func::params) + .filter_map(|it| Some((plain_name(it)?, it))) + .collect(); let mut properties: FxHashMap, Member<'a>> = FxHashMap::default(); for member in class.members() { if member.kind() == MemberKind::Property @@ -119,11 +139,14 @@ impl ParameterProperties { properties.insert(name, member); } } - for name in assigned { - if let (Some(parameter), Some(property)) = (parameters.get(&name), properties.get(&name)) + let place = |name: &Name<'a>| properties.get(name).map(|it| it.span().start); + for name in utils::sort::in_order_of(assigned, |it| (place(it), it.bytes())) { + if let (Some(parameter), Some(property)) = + (parameters.get(&name), properties.get(&name)) && type_annotations_match(*property, *parameter) { - cx.report(*property, PREFER_PARAMETER_PROPERTY).data("parameter", name); + cx.report(*property, PREFER_PARAMETER_PROPERTY) + .data("parameter", name); } } } diff --git a/src/lint/typescript/rules/prefer_readonly.rs b/src/lint/typescript/rules/prefer_readonly.rs index 3abdab01d7af..8629e0efb342 100644 --- a/src/lint/typescript/rules/prefer_readonly.rs +++ b/src/lint/typescript/rules/prefer_readonly.rs @@ -19,8 +19,10 @@ pub struct PreferReadonly { only_inline_lambdas: bool, } -const PREFER_READONLY: Message = - Message::new("preferReadonly", "Member '{{name}}' is never reassigned; mark it as `readonly`."); +const PREFER_READONLY: Message = Message::new( + "preferReadonly", + "Member '{{name}}' is never reassigned; mark it as `readonly`.", +); #[derive(Copy, Clone, PartialEq, Eq)] enum TypeToClassRelation { @@ -62,11 +64,15 @@ fn get_member_name<'a>(key: Key<'a>, file: &'a File<'a>) -> Option<&'a [u8]> { | KeyKind::String(name) | KeyKind::ComputedNumber(name) => Some(name.bytes()), // A `bigint` is not a `NumericLiteral`. - KeyKind::Number(name) => (!file.slice(key.span(file)).ends_with(b"n")).then(|| name.bytes()), + KeyKind::Number(name) => { + (!file.slice(key.span(file)).ends_with(b"n")).then(|| name.bytes()) + } KeyKind::ComputedString(_) => None, KeyKind::Computed(expression) => match expression.kind() { ExprKind::Dot { obj, .. } - if !expression.is_parenthesized() && !obj.is_parenthesized() && obj.is_ident("Symbol") => + if !expression.is_parenthesized() + && !obj.is_parenthesized() + && obj.is_ident("Symbol") => { Some(expression.text()) } @@ -120,7 +126,10 @@ impl<'a> ClassScope<'a> { } fn push(&mut self, member: PrivateModifiable<'a>) { - self.positions.insert((member.name, member.is_static), self.private_modifiables.len()); + self.positions.insert( + (member.name, member.is_static), + self.private_modifiables.len(), + ); self.private_modifiables.push(member); } @@ -130,8 +139,12 @@ impl<'a> ClassScope<'a> { only_inline_lambdas: bool, ) { let (flags, initializer, key) = match node { - ParameterOrPropertyDeclaration::Parameter(param) => (param.flags(), param.default(), None), - ParameterOrPropertyDeclaration::Property(member) => (member.flags(), member.init(), member.key()), + ParameterOrPropertyDeclaration::Parameter(param) => { + (param.flags(), param.default(), None) + } + ParameterOrPropertyDeclaration::Property(member) => { + (member.flags(), member.init(), member.key()) + } }; if !(flags.contains(Flags::PRIVATE) || key.is_some_and(Key::is_private)) || flags.intersects(Flags::ACCESSOR | Flags::READONLY) @@ -144,7 +157,9 @@ impl<'a> ClassScope<'a> { }; // For tsgolint what is not initialized is no lambda either. if only_inline_lambdas - && initializer.map_or(is_oxlint, |it| it.is_parenthesized() || !it.as_fn().is_some_and(Func::is_arrow)) + && initializer.map_or(is_oxlint, |it| { + it.is_parenthesized() || !it.as_fn().is_some_and(Func::is_arrow) + }) { return; } @@ -153,8 +168,12 @@ impl<'a> ClassScope<'a> { return; } let member_name = match node { - ParameterOrPropertyDeclaration::Parameter(param) => param.pat().as_ident().map(|name| name.bytes()), - ParameterOrPropertyDeclaration::Property(member) => key.and_then(|key| get_member_name(key, member.file())), + ParameterOrPropertyDeclaration::Parameter(param) => { + param.pat().as_ident().map(|name| name.bytes()) + } + ParameterOrPropertyDeclaration::Property(member) => { + key.and_then(|key| get_member_name(key, member.file())) + } }; let Some(member_name) = member_name else { return; @@ -205,8 +224,14 @@ impl<'a> ClassScope<'a> { continue; } it.is_modified |= match it.is_static { - true => matches!(relation, TypeToClassRelation::Class | TypeToClassRelation::ClassAndInstance), - false => matches!(relation, TypeToClassRelation::Instance | TypeToClassRelation::ClassAndInstance), + true => matches!( + relation, + TypeToClassRelation::Class | TypeToClassRelation::ClassAndInstance + ), + false => matches!( + relation, + TypeToClassRelation::Instance | TypeToClassRelation::ClassAndInstance + ), }; } } @@ -226,7 +251,9 @@ enum TsNodeLike<'a> { /// for the `a = 1`. fn shorthand_with_default(e: Expr<'_>) -> Option> { match (e.kind(), e.parent()) { - (ExprKind::Assign { .. }, Node::Prop(prop)) if prop.kind() == PropKind::Shorthand => Some(prop), + (ExprKind::Assign { .. }, Node::Prop(prop)) if prop.kind() == PropKind::Shorthand => { + Some(prop) + } _ => None, } } @@ -242,7 +269,9 @@ fn ts_parent(node: TsNodeLike<'_>) -> TsNodeLike<'_> { Some(prop) => TsNodeLike::Prop(prop), None => TsNodeLike::Expr(parent, 0), }, - Node::Prop(prop) if prop.value() == Some(e) && !prop.is_jsx_attribute() => TsNodeLike::Prop(prop), + Node::Prop(prop) if prop.value() == Some(e) && !prop.is_jsx_attribute() => { + TsNodeLike::Prop(prop) + } _ => TsNodeLike::Other, } } @@ -264,9 +293,13 @@ fn is_destructuring_assignment(node: Expr<'_>) -> bool { ExprKind::Spread(_) => { matches!(ts_parent(parent), TsNodeLike::Expr(it, 0) if matches!(it.kind(), ExprKind::Array(_))) } - ExprKind::Assign { op: None, target, .. } => { + ExprKind::Assign { + op: None, target, .. + } => { return match current { - TsNodeLike::Expr(it, 0) if matches!(it.kind(), ExprKind::Dot { .. }) => false, + TsNodeLike::Expr(it, 0) if matches!(it.kind(), ExprKind::Dot { .. }) => { + false + } TsNodeLike::Expr(it, _) => it == target, _ => false, }; @@ -289,7 +322,9 @@ fn is_modified_property_access(node: Expr<'_>) -> bool { && let Node::Expr(parent) = node.parent() { match parent.kind() { - ExprKind::Assign { target, .. } if shorthand_with_default(parent).is_none() => return target == node, + ExprKind::Assign { target, .. } if shorthand_with_default(parent).is_none() => { + return target == node; + } ExprKind::Binary { .. } => return false, ExprKind::Unary { op: UnOp::Delete | UnOp::PreInc | UnOp::PreDec | UnOp::PostInc | UnOp::PostDec, @@ -309,8 +344,9 @@ fn enclosing_class(node: Expr<'_>) -> Option<(Class<'_>, bool)> { match ancestor { Node::Func(func) if innermost_function.is_none() => innermost_function = Some(func), Node::Class(class) => { - let is_directly_inside_constructor = innermost_function - .is_some_and(|func| matches!(func.owner(), Node::Member(member) if member.is_constructor())); + let is_directly_inside_constructor = innermost_function.is_some_and( + |func| matches!(func.owner(), Node::Member(member) if member.is_constructor()), + ); return Some((class, is_directly_inside_constructor)); } _ => {} @@ -352,7 +388,11 @@ fn report<'a>(cx: &Cx<'a, PreferReadonly>, violating: &PrivateModifiable<'a>) { loc.start = param.span().start; } // tsgolint has the name alone. - let name = if cx.language().is_oxlint { param.pat().span() } else { param.binding_span() }; + let name = if cx.language().is_oxlint { + param.pat().span() + } else { + param.binding_span() + }; cx.report(loc, PREFER_READONLY) .data("name", file.slice(name)) .fix(|fixer| fixer.insert_before(param.pat(), "readonly ")); @@ -374,20 +414,24 @@ fn report<'a>(cx: &Cx<'a, PreferReadonly>, violating: &PrivateModifiable<'a>) { loc.start = member.span().start; } let has_constructor_modifications = violating.has_constructor_modifications; - cx.report(loc, PREFER_READONLY).data("name", file.slice(name_node)).fix(|fixer| { - let readonly_insertion_target = match is_property_definition && key.is_computed() { - true => key.span(file), - false => name_node, - }; - let mut fixes = vec![fixer.insert_before(readonly_insertion_target, "readonly ")]; - if is_property_definition - && has_constructor_modifications - && let Some(type_annotation) = get_type_annotation(member, key) - { - fixes.push(fixer.insert_after(name_node, [&b": "[..], &type_annotation[..]].concat())); - } - fixes - }); + cx.report(loc, PREFER_READONLY) + .data("name", file.slice(name_node)) + .fix(|fixer| { + let readonly_insertion_target = match is_property_definition && key.is_computed() { + true => key.span(file), + false => name_node, + }; + let mut fixes = vec![fixer.insert_before(readonly_insertion_target, "readonly ")]; + if is_property_definition + && has_constructor_modifications + && let Some(type_annotation) = get_type_annotation(member, key) + { + fixes.push( + fixer.insert_after(name_node, [&b": "[..], &type_annotation[..]].concat()), + ); + } + fixes + }); } impl PreferReadonly { @@ -405,7 +449,11 @@ impl PreferReadonly { ); } } - for constructor in class.members().iter().filter(|member| member.is_constructor()) { + for constructor in class + .members() + .iter() + .filter(|member| member.is_constructor()) + { let parameters = constructor.func().into_iter().flat_map(Func::params); for parameter in parameters.filter(|it| it.flags().contains(Flags::PRIVATE)) { scope.add_declared_variable( @@ -483,8 +531,12 @@ impl Rule for PreferReadonly { } fn finish(&self, cx: &mut Cx<'_, Self>) { - for class_scope in cx.state.0.values() { - for violating in class_scope.private_modifiables.iter().filter(|it| !it.is_modified) { + for (_, class_scope) in utils::sort::in_order_of(&cx.state.0, |it| it.0.span().start) { + for violating in class_scope + .private_modifiables + .iter() + .filter(|it| !it.is_modified) + { report(cx, violating); } } diff --git a/src/lint/typescript/rules/related_getter_setter_pairs.rs b/src/lint/typescript/rules/related_getter_setter_pairs.rs index 6360c0eabdfe..761821d0175c 100644 --- a/src/lint/typescript/rules/related_getter_setter_pairs.rs +++ b/src/lint/typescript/rules/related_getter_setter_pairs.rs @@ -7,8 +7,10 @@ use std::borrow::Cow; /// Enforce that `get()` types should be assignable to their equivalent `set()` type. pub struct RelatedGetterSetterPairs; -const MISMATCH: Message = - Message::new("mismatch", "`get()` type should be assignable to its equivalent `set()` type."); +const MISMATCH: Message = Message::new( + "mismatch", + "`get()` type should be assignable to its equivalent `set()` type.", +); /// The function of a `MethodDefinition` or a `TSMethodSignature` of this `kind`. An abstract /// accessor is a `TSAbstractMethodDefinition`, which upstream does not look at. @@ -44,15 +46,28 @@ impl RelatedGetterSetterPairs { return; } // The last getter and the last setter of a name are the pair. - let mut pairs = FxHashMap::, (Option>, Option>)>::default(); + let mut pairs = + FxHashMap::, (Option>, Option>)>::default(); for member in members { if getter_type(member).is_some() { - pairs.entry(get_name_from_member(member).name).or_default().0 = Some(member); + pairs + .entry(get_name_from_member(member).name) + .or_default() + .0 = Some(member); } else if let Some(param) = setter_param(member) { - pairs.entry(get_name_from_member(member).name).or_default().1 = Some(param); + pairs + .entry(get_name_from_member(member).name) + .or_default() + .1 = Some(param); } } - for pair in pairs.into_values() { + let place = |it: &(Option>, Option>)| { + ( + it.0.map(|it| it.span().start), + it.1.map(|it| it.span().start), + ) + }; + for pair in utils::sort::in_order_of(pairs.into_values(), place) { let (Some(getter), Some(param)) = pair else { continue; }; diff --git a/src/lint/typescript/rules/unified_signatures.rs b/src/lint/typescript/rules/unified_signatures.rs index 141f0901ce70..eda6ab0370a3 100644 --- a/src/lint/typescript/rules/unified_signatures.rs +++ b/src/lint/typescript/rules/unified_signatures.rs @@ -87,7 +87,12 @@ struct OuterTypeParameters<'a>(FxHashSet>); impl<'a> OuterTypeParameters<'a> { fn new(list: Option>>) -> Self { - OuterTypeParameters(list.into_iter().flatten().map(|it| it.name().name()).collect()) + OuterTypeParameters( + list.into_iter() + .flatten() + .map(|it| it.name().name()) + .collect(), + ) } fn has(&self, name: Name<'a>) -> bool { @@ -100,7 +105,10 @@ fn get_overload_info<'a>(key: Key<'a>, file: &File<'a>) -> OverloadInfo<'a> { match key.kind() { KeyKind::Private(name) => OverloadInfo::PrivateIdentifier(name), KeyKind::Ident(name) => OverloadInfo::Identifier(name), - KeyKind::String(_) | KeyKind::Number(_) | KeyKind::ComputedNumber(_) | KeyKind::ComputedString(_) => { + KeyKind::String(_) + | KeyKind::Number(_) + | KeyKind::ComputedNumber(_) + | KeyKind::ComputedString(_) => { match file.slice(key.inner_span(file)) { // A template is not a literal. [b'`', ..] => OverloadInfo::Expression, @@ -229,13 +237,16 @@ fn get_static_parameter_name(parameter: Param<'_>) -> Option> { fn is_this_param(parameter: Option<&Param<'_>>) -> bool { parameter.is_some_and(|it| { - parameter_type(*it) == ParameterType::Identifier && it.pat().as_ident().is_some_and(|name| name.is("this")) + parameter_type(*it) == ParameterType::Identifier + && it.pat().as_ident().is_some_and(|name| name.is("this")) }) } fn is_this_void_param(parameter: Option<&Param<'_>>) -> bool { is_this_param(parameter) - && parameter.and_then(|it| it.ty()).is_some_and(|ty| ty.is_keyword(Keyword::Void)) + && parameter + .and_then(|it| it.ty()) + .is_some_and(|ty| ty.is_keyword(Keyword::Void)) } fn types_are_equal<'a>(a: Option>, b: Option>) -> bool { @@ -253,7 +264,10 @@ fn parameters_have_equal_sigils<'a>(a: Param<'a>, b: Param<'a>) -> bool { fn parameters_are_equal<'a>(a: Param<'a>, b: Param<'a>) -> bool { parameters_have_equal_sigils(a, b) - && types_are_equal(get_parameter_type_annotation(a), get_parameter_type_annotation(b)) + && types_are_equal( + get_parameter_type_annotation(a), + get_parameter_type_annotation(b), + ) } /// True for optional/rest parameters. @@ -269,8 +283,13 @@ fn type_parameters_are_equal<'a>(a: TypeParam<'a>, b: TypeParam<'a>) -> bool { fn type_contains_type_parameter<'a>(mut ty: TypeNode<'a>, outer: &OuterTypeParameters<'a>) -> bool { loop { ty = match ty.kind() { - TypeKind::Ref { name, .. } => return name.as_ident().is_some_and(|name| outer.has(name.name())), - TypeKind::Array(inner) | TypeKind::Keyof(inner) | TypeKind::Readonly(inner) | TypeKind::Unique(inner) => inner, + TypeKind::Ref { name, .. } => { + return name.as_ident().is_some_and(|name| outer.has(name.name())); + } + TypeKind::Array(inner) + | TypeKind::Keyof(inner) + | TypeKind::Readonly(inner) + | TypeKind::Unique(inner) => inner, TypeKind::Mapped(mapped) => match mapped.ty() { Some(inner) => inner, None => return false, @@ -281,7 +300,10 @@ fn type_contains_type_parameter<'a>(mut ty: TypeNode<'a>, outer: &OuterTypeParam } /// True if any of the outer type parameters are used in a signature. -fn signature_uses_type_parameter<'a>(parameters: &[Param<'a>], outer: &OuterTypeParameters<'a>) -> bool { +fn signature_uses_type_parameter<'a>( + parameters: &[Param<'a>], + outer: &OuterTypeParameters<'a>, +) -> bool { !outer.0.is_empty() && parameters .iter() @@ -292,7 +314,9 @@ fn signature_uses_type_parameter<'a>(parameters: &[Param<'a>], outer: &OuterType /// The last block comment before the declaration of `signature`. fn get_block_comment_for_node(signature: Func<'_>) -> Option<&[u8]> { let mut comments = signature.file().comments_before(signature.owner()); - comments.rfind(|comment| comment.kind() == TokenKind::Block).map(Token::comment_value) + comments + .rfind(|comment| comment.kind() == TokenKind::Block) + .map(Token::comment_value) } /// Detect no difference, i.e. `a(x: number, y: string)` and `a(x: number, y: string)`, @@ -346,11 +370,18 @@ fn signatures_differ_by_optional_or_rest_parameter<'a>( // If one is has 2+ parameters more than the other, they must all be optional/rest. // Differ by optional parameters: f() and f(x), f() and f(x, ?y, ...z) // Not allowed: f() and f(x, y) - if !longer.iter().skip(min_length + 1).all(|it| parameter_may_be_missing(*it)) { + if !longer + .iter() + .skip(min_length + 1) + .all(|it| parameter_may_be_missing(*it)) + { return None; } let have_equal_types = |(a, b): (&Param<'a>, &Param<'a>)| { - types_are_equal(get_parameter_type_annotation(*a), get_parameter_type_annotation(*b)) + types_are_equal( + get_parameter_type_annotation(*a), + get_parameter_type_annotation(*b), + ) }; if !sig1.iter().zip(sig2).all(have_equal_types) { return None; @@ -462,12 +493,27 @@ impl UnifiedSignatures { return None; } match a.parameters.len() == b.parameters.len() { - true => signatures_have_same_amount_of_parameters(a.func, b.func, &a.parameters, &b.parameters), - false => signatures_differ_by_optional_or_rest_parameter(a.func, b.func, &a.parameters, &b.parameters), + true => signatures_have_same_amount_of_parameters( + a.func, + b.func, + &a.parameters, + &b.parameters, + ), + false => signatures_differ_by_optional_or_rest_parameter( + a.func, + b.func, + &a.parameters, + &b.parameters, + ), } } - fn check_overloads<'a>(&self, overloads: &[Func<'a>], outer: &OuterTypeParameters<'a>, cx: &Cx<'a, Self>) { + fn check_overloads<'a>( + &self, + overloads: &[Func<'a>], + outer: &OuterTypeParameters<'a>, + cx: &Cx<'a, Self>, + ) { let only2 = overloads.len() == 2; let start = |other: Span| failure_string_start(cx, (!only2).then_some(other)); let signatures: SmallVec<[Signature<'a>; 4]> = (overloads.iter()) @@ -476,7 +522,10 @@ impl UnifiedSignatures { Signature { func, uses_type_parameter: signature_uses_type_parameter(¶meters, outer), - block_comment: self.ignore_overloads_with_different_jsdoc.then(|| get_block_comment_for_node(func)).flatten(), + block_comment: self + .ignore_overloads_with_different_jsdoc + .then(|| get_block_comment_for_node(func)) + .flatten(), parameters, } }) @@ -506,7 +555,9 @@ impl UnifiedSignatures { cx.report(parameter_span(p1), SINGLE_PARAMETER_DIFFERENCE) .data("failureStringStart", start(parameter_span(p0))) .data("types", types) - .labels_with(|labels| labels.push(parameter_span(p0), "with this overload parameter")); + .labels_with(|labels| { + labels.push(parameter_span(p0), "with this overload parameter") + }); } Some(Unify::ExtraParameter { extra_parameter, @@ -519,8 +570,13 @@ impl UnifiedSignatures { cx.report(parameter_span(extra_parameter), message) .data("failureStringStart", start(other_signature.estree_span())) .labels_with(|labels| { - if let Some(anchor_parameter) = other_signature.params_with_this().last() { - labels.push(parameter_span(anchor_parameter), "matching overload ends here"); + if let Some(anchor_parameter) = + other_signature.params_with_this().last() + { + labels.push( + parameter_span(anchor_parameter), + "matching overload ends here", + ); } }); } @@ -530,7 +586,9 @@ impl UnifiedSignatures { }) => { cx.report(signature1.estree_span(), ALL_PARAMETERS_ARE_SAME) .data("failureStringStart", start(signature0.estree_span())) - .labels_with(|labels| labels.push(signature0.estree_span(), "with this overload signature")); + .labels_with(|labels| { + labels.push(signature0.estree_span(), "with this overload signature") + }); } } } @@ -545,32 +603,47 @@ impl UnifiedSignatures { cx: &Cx<'a, Self>, ) { let overloads: SmallVec<[Overload<'a>; 16]> = overloads.collect(); - let has_same_key_as_earlier = - |(i, it): (usize, &Overload<'a>)| overloads[..i].iter().any(|earlier| earlier.key == it.key); + let has_same_key_as_earlier = |(i, it): (usize, &Overload<'a>)| { + overloads[..i].iter().any(|earlier| earlier.key == it.key) + }; if !overloads.spilled() && !overloads.iter().enumerate().any(has_same_key_as_earlier) { return; } let mut by_key: FxHashMap, SmallVec<[Func<'a>; 4]>> = FxHashMap::default(); for overload in &overloads { - by_key.entry(overload.key).or_default().push(overload.signature); + by_key + .entry(overload.key) + .or_default() + .push(overload.signature); } let outer = OuterTypeParameters::new(outer); - for signatures in by_key.values().filter(|it| it.len() > 1) { + let several = by_key.values().filter(|it| it.len() > 1); + for signatures in + utils::sort::in_order_of(several, |it| it.first().map(|it| it.span().start)) + { self.check_overloads(signatures, &outer, cx); } } } impl Rule for UnifiedSignatures { - const META: Meta = Meta::typescript("unified-signatures", Kind::Suggestion).presets(Presets::STRICT).reports_on_exit(); - const ON: On = On::new().classes().stmts(&[StmtTag::Interface, StmtTag::Module]).types(&[TypeTag::Object]).finish(); + const META: Meta = Meta::typescript("unified-signatures", Kind::Suggestion) + .presets(Presets::STRICT) + .reports_on_exit(); + const ON: On = On::new() + .classes() + .stmts(&[StmtTag::Interface, StmtTag::Module]) + .types(&[TypeTag::Object]) + .finish(); no_state!(); fn new(options: &Options) -> Self { let options = options.object(0); UnifiedSignatures { - ignore_differently_named_parameters: options.bool_or("ignoreDifferentlyNamedParameters", false), - ignore_overloads_with_different_jsdoc: options.bool_or("ignoreOverloadsWithDifferentJSDoc", false), + ignore_differently_named_parameters: options + .bool_or("ignoreDifferentlyNamedParameters", false), + ignore_overloads_with_different_jsdoc: options + .bool_or("ignoreOverloadsWithDifferentJSDoc", false), } } @@ -588,7 +661,15 @@ impl Rule for UnifiedSignatures { self.check_scope(overloads, Some(interface.type_params()), cx); } StmtKind::Module(module) => { - self.check_scope(module.innermost().body().iter().filter_map(overload_of_statement), None, cx); + self.check_scope( + module + .innermost() + .body() + .iter() + .filter_map(overload_of_statement), + None, + cx, + ); } _ => {} } @@ -601,6 +682,10 @@ impl Rule for UnifiedSignatures { } fn finish(&self, cx: &mut Cx<'_, Self>) { - self.check_scope(cx.file().body().iter().filter_map(overload_of_statement), None, cx); + self.check_scope( + cx.file().body().iter().filter_map(overload_of_statement), + None, + cx, + ); } } diff --git a/src/lint/unicorn/lib.rs b/src/lint/unicorn/lib.rs index 443ab243ce74..db9a6ec86bc7 100644 --- a/src/lint/unicorn/lib.rs +++ b/src/lint/unicorn/lib.rs @@ -4,6 +4,7 @@ //! - which are ports of https://github.com/sindresorhus/eslint-plugin-unicorn (Copyright Sindre Sorhus, MIT License) #![forbid(unsafe_code)] +#![deny(clippy::iter_over_hash_type)] mod unicorn; diff --git a/src/lint/unicorn/rules/unicorn_consistent_assert.rs b/src/lint/unicorn/rules/unicorn_consistent_assert.rs index 455c9d04b1e8..b2a3017f72c4 100644 --- a/src/lint/unicorn/rules/unicorn_consistent_assert.rs +++ b/src/lint/unicorn/rules/unicorn_consistent_assert.rs @@ -7,7 +7,8 @@ pub struct ConsistentAssert; const INCONSISTENT_ASSERT_USAGE: Message = Message::new("", "Inconsistent assert usage."); impl Rule for ConsistentAssert { - const META: Meta = Meta::oxlint(Plugin::Unicorn, "consistent-assert", Kind::Suggestion).fixable(Fixable::Code); + const META: Meta = + Meta::oxlint(Plugin::Unicorn, "consistent-assert", Kind::Suggestion).fixable(Fixable::Code); const ON: On = On::new().stmts(&[StmtTag::Import]); type State<'a> = (); @@ -16,8 +17,16 @@ impl Rule for ConsistentAssert { } fn start<'a>(&self, file: &'a File<'a>) -> Option<()> { - let modules = ["assert", "node:assert", "assert/strict", "node:assert/strict"]; - modules.iter().any(|it| file.find_name(it).is_some()).then_some(()) + let modules = [ + "assert", + "node:assert", + "assert/strict", + "node:assert/strict", + ]; + modules + .iter() + .any(|it| file.find_name(it).is_some()) + .then_some(()) } fn stmt<'a>(&self, stmt: Stmt<'a>, cx: &mut Cx<'a, Self>) { @@ -25,7 +34,11 @@ impl Rule for ConsistentAssert { return; }; let is_assert_module = import.spec().is_any(&["assert", "node:assert"]); - if !is_assert_module && !import.spec().is_any(&["assert/strict", "node:assert/strict"]) { + if !is_assert_module + && !import + .spec() + .is_any(&["assert/strict", "node:assert/strict"]) + { return; } let file = cx.file(); @@ -50,12 +63,17 @@ impl Rule for ConsistentAssert { let ident = match reference.parent() { _ if reference.is_parenthesized() => reference, Node::Expr(call) if call.tag() == ExprTag::Call => match call.callee() { - Some(callee) if callee.tag() == ExprTag::Ident && !callee.is_parenthesized() => callee, + Some(callee) + if callee.tag() == ExprTag::Ident && !callee.is_parenthesized() => + { + callee + } _ => continue, }, _ => continue, }; cx.report(ident, INCONSISTENT_ASSERT_USAGE) + .called_with(stmt) .data("assert_identifier", ident.text()) .fix(|fixer| fixer.insert_after(ident, ".ok")); } diff --git a/src/lint/unicorn/rules/unicorn_consistent_function_scoping.rs b/src/lint/unicorn/rules/unicorn_consistent_function_scoping.rs index 2641876c686e..4daa35d8b773 100644 --- a/src/lint/unicorn/rules/unicorn_consistent_function_scoping.rs +++ b/src/lint/unicorn/rules/unicorn_consistent_function_scoping.rs @@ -1,9 +1,9 @@ -use bun_lint_oxlint::ast_util::{get_inner_expression, is_react_hook, iter_outer_expressions}; -use bun_lint_oxlint::import::has_module_syntax; use bun_core::strings; use bun_lint::prelude::*; use bun_lint::rule::Plugin; use bun_lint::utils::ancestor_memo::AncestorMemo; +use bun_lint_oxlint::ast_util::{get_inner_expression, is_react_hook, iter_outer_expressions}; +use bun_lint_oxlint::import::has_module_syntax; use rustc_hash::{FxHashMap, FxHashSet}; use smallvec::SmallVec; @@ -12,8 +12,10 @@ pub struct ConsistentFunctionScoping { check_arrow_functions: bool, } -const CONSISTENT_FUNCTION_SCOPING: Message = - Message::new("", "Function `{{name}}` does not capture any variables from its parent scope"); +const CONSISTENT_FUNCTION_SCOPING: Message = Message::new( + "", + "Function `{{name}}` does not capture any variables from its parent scope", +); #[derive(Default)] pub struct State<'a> { @@ -33,12 +35,18 @@ pub struct State<'a> { type Outermost<'a> = SmallVec<[Symbol<'a>; 4]>; impl Rule for ConsistentFunctionScoping { - const META: Meta = Meta::oxlint(Plugin::Unicorn, "consistent-function-scoping", Kind::Problem); + const META: Meta = Meta::oxlint( + Plugin::Unicorn, + "consistent-function-scoping", + Kind::Problem, + ); const ON: On = On::new().funcs(); type State<'a> = State<'a>; fn new(options: &Options) -> Self { - ConsistentFunctionScoping { check_arrow_functions: options.object(0).bool_or("checkArrowFunctions", true) } + ConsistentFunctionScoping { + check_arrow_functions: options.object(0).bool_or("checkArrowFunctions", true), + } } fn start<'a>(&self, file: &'a File<'a>) -> Option> { @@ -118,7 +126,10 @@ impl ConsistentFunctionScoping { (Some(binding), _, _) if func.is_arrow() => (binding.text(), binding.span()), (Some(binding), Some(own_name), _) => (binding.text(), own_name.span()), // The `function`. - (Some(binding), None, Some(e)) => (binding.text(), Span::new(e.span().start, e.span().start + 8)), + (Some(binding), None, Some(e)) => ( + binding.text(), + Span::new(e.span().start, e.span().start + 8), + ), (None, Some(own_name), _) => (own_name.bytes(), own_name.span()), _ => return, }; @@ -126,7 +137,11 @@ impl ConsistentFunctionScoping { let Some(scope) = func.scope() else { return; }; - let Some(parent_scope) = scope.chain().skip(1).find(|it| it.kind() != ScopeKind::FunctionExpressionName) else { + let Some(parent_scope) = scope + .chain() + .skip(1) + .find(|it| it.kind() != ScopeKind::FunctionExpressionName) + else { return; }; let Some(symbol) = binding.map_or_else(|| func.symbol(), Pat::symbol) else { @@ -141,42 +156,61 @@ impl ConsistentFunctionScoping { return; } - let captures = cx.state.captures.get_or_insert_with(|| Captures::of(file, |it| self.place_of(it).is_some())); + let captures = cx + .state + .captures + .get_or_insert_with(|| Captures::of(file, |it| self.place_of(it).is_some())); // What is declared further out than the function around it does not keep it in that function. let is_in_plain_function = function_of(parent_scope).is_some() && !captures.with_eval.contains(&parent_scope) && !captures.is_in_scope_of_oxlint_only(parent_scope, scope.span().start); let is_kept = match is_in_plain_function { true => captures.of_parent.contains(&scope), - false => (captures.outermost.get(&scope)) - .is_some_and(|it| it.iter().any(|it| *it != symbol && Some(*it) != func.symbol())), + false => (captures.outermost.get(&scope)).is_some_and(|it| { + it.iter() + .any(|it| *it != symbol && Some(*it) != func.symbol()) + }), }; if is_kept { return; } let with_private_names = &mut cx.state.with_private_names; - if with_private_names.get_or_insert_with(|| functions_with_private_names(file)).contains(&scope) { + if with_private_names + .get_or_insert_with(|| functions_with_private_names(file)) + .contains(&scope) + { return; } let arrows = &mut cx.state.arrows_with_lexical_capture; - if func.is_arrow() && arrows.get_or_insert_with(|| arrows_with_lexical_capture(file)).contains(&func) { + if func.is_arrow() + && arrows + .get_or_insert_with(|| arrows_with_lexical_capture(file)) + .contains(&func) + { return; } // oxlint has the name of a function expression in the scope of the function itself. let parent_scope_span = match expression.filter(|_| binding.is_none()) { - Some(e) if is_value_of_assignment_or_property(e) => get_short_span_for_fn_scope(parent_scope), + Some(e) if is_value_of_assignment_or_property(e) => { + get_short_span_for_fn_scope(parent_scope) + } Some(_) => Some((reporter_span, "function")), None => get_short_span_for_fn_scope(symbol_scope), }; let report = match parent_scope_span { - Some((parent, kind)) => { - cx.report(parent, CONSISTENT_FUNCTION_SCOPING).comments_apply_at(reporter_span).labels_with(|labels| { + Some((parent, kind)) => cx + .report(parent, CONSISTENT_FUNCTION_SCOPING) + .comments_apply_at(reporter_span) + .labels_with(|labels| { labels.first("Outer scope where this function is defined"); - let text = ["This function does not use any variables from the parent ", kind].concat(); + let text = [ + "This function does not use any variables from the parent ", + kind, + ] + .concat(); labels.push(reporter_span, text); - }) - } + }), None => cx.report(reporter_span, CONSISTENT_FUNCTION_SCOPING), }; report.data("name", name); @@ -312,8 +346,9 @@ impl<'a> Captures<'a> { } else if is_around.get(scope.id().idx()) != Some(&true) { continue; } - for reference in scope.references() { - let is_of_interest = |it: &Symbol<'a>| it.scope() != scope && is_reference_of_oxlint(reference, *it); + for reference in scope.references_with_names_of_patterns_first() { + let is_of_interest = + |it: &Symbol<'a>| it.scope() != scope && is_reference_of_oxlint(reference, *it); let Some(symbol) = reference.symbol().filter(is_of_interest) else { continue; }; @@ -339,7 +374,10 @@ impl<'a> Captures<'a> { fn is_in_scope_of_oxlint_only(&self, scope: Scope<'a>, at: u32) -> bool { self.scopes_of_oxlint_only.get(&scope).is_some_and(|spans| { let after = spans.partition_point(|it| it.start <= at); - after.checked_sub(1).and_then(|it| spans.get(it)).is_some_and(|it| at < it.end) + after + .checked_sub(1) + .and_then(|it| spans.get(it)) + .is_some_and(|it| at < it.end) }) } @@ -357,16 +395,26 @@ impl<'a> Captures<'a> { } /// `entries`: the scopes of `path` that refer to `symbol`, which the last of `path` does at `at`. - fn note_reference(&mut self, path: &[Scope<'a>], entries: &mut [Direct<'a>], symbol: Symbol<'a>, at: u32) { + fn note_reference( + &mut self, + path: &[Scope<'a>], + entries: &mut [Direct<'a>], + symbol: Symbol<'a>, + at: u32, + ) { let in_scope_of_declaration = scope_in(path, symbol.scope()); let in_what_it_names = match symbol.declarations().next() { Some(Declaration::Fn(func)) => func.scope().and_then(|it| scope_in(path, it)), - Some(Declaration::Class(class)) => { - class.scope().and_then(|it| scope_in(path, it)).and_then(|it| scope_in(path, it)) - } + Some(Declaration::Class(class)) => class + .scope() + .and_then(|it| scope_in(path, it)) + .and_then(|it| scope_in(path, it)), _ => None, }; - for inner in [in_scope_of_declaration, in_what_it_names].into_iter().flatten() { + for inner in [in_scope_of_declaration, in_what_it_names] + .into_iter() + .flatten() + { self.note(inner, symbol, at); } for entry in entries.iter_mut().rev() { @@ -387,7 +435,10 @@ impl<'a> Captures<'a> { /// For oxlint the name of a function expression and the initializer of a field have no scope. fn is_scope_of_oxlint(scope: Scope) -> bool { - !matches!(scope.kind(), ScopeKind::FunctionExpressionName | ScopeKind::ClassFieldInitializer) + !matches!( + scope.kind(), + ScopeKind::FunctionExpressionName | ScopeKind::ClassFieldInitializer + ) } /// The scope directly in `outer`, for oxlint, that is one of `path`, which leads from the global scope inwards. @@ -396,7 +447,10 @@ fn scope_in<'a>(path: &[Scope<'a>], outer: Scope<'a>) -> Option> { if after.checked_sub(1).and_then(|it| path.get(it)) != Some(&outer) { return None; } - path.get(after..)?.iter().copied().find(|it| is_scope_of_oxlint(*it)) + path.get(after..)? + .iter() + .copied() + .find(|it| is_scope_of_oxlint(*it)) } /// The scopes with an `eval(..)` in them. @@ -411,7 +465,10 @@ fn scopes_with_eval<'a>(file: &'a File<'a>) -> FxHashSet> { path.pop(); } path.push(scope); - if scope.references().any(|it| it.name() == eval && is_called(it)) { + if scope + .references_with_names_of_patterns_first() + .any(|it| it.name() == eval && is_called(it)) + { for it in path.iter().rev() { // Those around one that is known are known. if !found.insert(*it) { @@ -447,14 +504,22 @@ fn declares_in_its_head(statement: Stmt) -> bool { StmtKind::ForIn { left, .. } | StmtKind::ForOf { left, .. } => Some(left), _ => None, }; - head.and_then(Stmt::as_var).and_then(|it| it.first()).is_some_and(|it| it.var_kind() != VarKind::Var) + head.and_then(Stmt::as_var) + .and_then(|it| it.first()) + .is_some_and(|it| it.var_kind() != VarKind::Var) } /// By the scope of the function that they are directly in: the statements that have a scope for oxlint only, without /// those that are in another of them, in the order of the file. fn scopes_of_oxlint_only<'a>(file: &'a File<'a>) -> FxHashMap, Vec> { let mut found: FxHashMap, Vec> = FxHashMap::default(); - for tag in [StmtTag::For, StmtTag::ForIn, StmtTag::ForOf, StmtTag::TypeAlias, StmtTag::Interface] { + for tag in [ + StmtTag::For, + StmtTag::ForIn, + StmtTag::ForOf, + StmtTag::TypeAlias, + StmtTag::Interface, + ] { for statement in file.stmts_of_kind(tag) { if matches!(statement.parent(), Node::File(_)) || declares_in_its_head(statement) { continue; @@ -465,6 +530,10 @@ fn scopes_of_oxlint_only<'a>(file: &'a File<'a>) -> FxHashMap, Vec(file: &'a File<'a>) -> FxHashMap, Vec<(Name< if !file.has_classes() || !strings::contains_char(file.text(), b'#') { return found; } - let members = file.exprs_of_kind(ExprTag::Dot).filter(|it| it.is_private_member()); + let members = file + .exprs_of_kind(ExprTag::Dot) + .filter(|it| it.is_private_member()); for usage in members.chain(file.exprs_of_kind(ExprTag::PrivateIdentifier)) { let name = match usage.kind() { ExprKind::Dot { name, .. } => name.name(), ExprKind::PrivateIdentifier(name) => name, _ => continue, }; - found.entry(Node::Expr(usage).scope()).or_default().push((name, usage.span().start)); + found + .entry(Node::Expr(usage).scope()) + .or_default() + .push((name, usage.span().start)); } found } @@ -555,8 +629,12 @@ fn functions_with_private_names<'a>(file: &'a File<'a>) -> FxHashSet> fn add_outermost<'a>(list: &mut Outermost<'a>, symbol: Symbol<'a>) { if !list.contains(&symbol) { - let is_further_in = |it: &Symbol<'a>| it.scope() != symbol.scope() && symbol.scope().contains(it.scope()); - list.insert(list.iter().position(is_further_in).unwrap_or(list.len()), symbol); + let is_further_in = + |it: &Symbol<'a>| it.scope() != symbol.scope() && symbol.scope().contains(it.scope()); + list.insert( + list.iter().position(is_further_in).unwrap_or(list.len()), + symbol, + ); list.truncate(3); } } @@ -571,13 +649,16 @@ fn outermost_captures<'a>( ) -> FxHashMap, Outermost<'a>> { let mut of_functions = FxHashMap::default(); // What the scopes in a scope refer to and is declared outside it: all, and what is in the region of the function. - let mut from_inside: FxHashMap, (Outermost<'a>, Outermost<'a>)> = FxHashMap::default(); + let mut from_inside: FxHashMap, (Outermost<'a>, Outermost<'a>)> = + FxHashMap::default(); for &scope in scopes.iter().rev() { let (mut all, mut in_region) = from_inside.remove(&scope).unwrap_or_default(); let region = regions.get(&scope); - for reference in scope.references() { + for reference in scope.references_with_names_of_patterns_first() { let counts = |it: &Symbol<'a>| { - it.scope() != scope && is_reference_of_oxlint(reference, *it) && !is_jsx_tag_name(reference) + it.scope() != scope + && is_reference_of_oxlint(reference, *it) + && !is_jsx_tag_name(reference) }; let Some(referenced) = reference.symbol().filter(counts) else { continue; @@ -593,7 +674,9 @@ fn outermost_captures<'a>( let Some(parent) = scope.parent().filter(|_| !all.is_empty()) else { continue; }; - let is_in_region = regions.get(&parent).is_some_and(|it| it.contains(scope.span().start)); + let is_in_region = regions + .get(&parent) + .is_some_and(|it| it.contains(scope.span().start)); let (all_of_parent, in_region_of_parent) = from_inside.entry(parent).or_default(); for referenced in all.into_iter().filter(|it| it.scope() != parent) { add_outermost(all_of_parent, referenced); @@ -608,17 +691,35 @@ fn outermost_captures<'a>( /// The scope that oxc has `symbol` in, which is the name of `func` or the variable that it is the value of. In sloppy /// JavaScript a plain function that is declared in a block is a variable of the function around it as well (Annex /// B.3.2.1): oxc moves it there, unless something before it has the name there. -fn scope_of_symbol<'a>(func: Func<'a>, symbol: Symbol<'a>, file: &'a File<'a>, state: &mut State<'a>) -> Scope<'a> { +fn scope_of_symbol<'a>( + func: Func<'a>, + symbol: Symbol<'a>, + file: &'a File<'a>, + state: &mut State<'a>, +) -> Scope<'a> { let scope = symbol.scope(); let var_scope = scope.variable_scope(); - let Some(name) = func.name().filter(|_| func.kind() == FnKind::Decl && scope != var_scope) else { + let Some(name) = func + .name() + .filter(|_| func.kind() == FnKind::Decl && scope != var_scope) + else { return scope; }; - if func.is_async() || func.is_generator() || !file.is_javascript() || is_strict_mode(scope, file, state) { + if func.is_async() + || func.is_generator() + || !file.is_javascript() + || is_strict_mode(scope, file, state) + { return scope; } - let is_before = |it: Declaration<'a>| it.name_span().is_some_and(|it| it.start < name.span().start); - match var_scope.get_name(name.name()).is_some_and(|it| it.declarations().any(is_before)) { + let is_before = |it: Declaration<'a>| { + it.name_span() + .is_some_and(|it| it.start < name.span().start) + }; + match var_scope + .get_name(name.name()) + .is_some_and(|it| it.declarations().any(is_before)) + { true => scope, false => var_scope, } @@ -633,8 +734,12 @@ fn is_strict_mode<'a>(scope: Scope<'a>, file: &'a File<'a>, state: &mut State<'a if is_module { return true; } - let has_use_strict = - |statements: List<'a, Stmt<'a>>| statements.iter().map_while(Stmt::directive).any(|it| it == b"use strict"); + let has_use_strict = |statements: List<'a, Stmt<'a>>| { + statements + .iter() + .map_while(Stmt::directive) + .any(|it| it == b"use strict") + }; let mut passed: SmallVec<[Scope<'a>; 8]> = SmallVec::new(); let mut answer = false; for scope in scope.chain() { @@ -653,7 +758,9 @@ fn is_strict_mode<'a>(scope: Scope<'a>, file: &'a File<'a>, state: &mut State<'a break; } } - state.is_strict.extend(passed.into_iter().map(|it| (it, answer))); + state + .is_strict + .extend(passed.into_iter().map(|it| (it, answer))); answer } @@ -665,7 +772,8 @@ fn is_iife(func: Func) -> bool { /// `parent_scope`: the scope that the function is in. Whether that is of a function that is an argument of a hook. fn is_in_react_hook(parent_scope: Scope) -> bool { - let (ScopeKind::Function, Node::Func(outer)) = (parent_scope.kind(), parent_scope.node()) else { + let (ScopeKind::Function, Node::Func(outer)) = (parent_scope.kind(), parent_scope.node()) + else { return false; }; matches!(outer.owner(), Node::Expr(e) if !e.is_parenthesized() && matches!(e.parent(), Node::Expr(call) @@ -723,7 +831,11 @@ fn arrows_with_lexical_capture<'a>(file: &'a File<'a>) -> FxHashSet> { /// oxlint calls it. fn get_short_span_for_fn_scope(scope: Scope) -> Option<(Span, &'static str)> { // oxlint has no scope for the initializer of a field. - let scope = if scope.kind() == ScopeKind::ClassFieldInitializer { scope.parent()? } else { scope }; + let scope = if scope.kind() == ScopeKind::ClassFieldInitializer { + scope.parent()? + } else { + scope + }; let keyword = |len: u32| Some(Span::new(scope.span().start, scope.span().start + len)); let (span, kind) = match (scope.kind(), scope.node()) { (ScopeKind::Function, Node::Func(func)) if func.is_arrow() => { @@ -733,7 +845,9 @@ fn get_short_span_for_fn_scope(scope: Scope) -> Option<(Span, &'static str)> { let span = match arrow.parent() { _ if arrow.is_parenthesized() => None, Node::VarDecl(declarator) => Some(declarator.pat().span()), - Node::Expr(parent) if parent.tag() == ExprTag::Assign && !parent.is_assignment_target() => { + Node::Expr(parent) + if parent.tag() == ExprTag::Assign && !parent.is_assignment_target() => + { parent.left().map(Expr::span) } _ => None, diff --git a/src/lint/unicorn/rules/unicorn_prefer_number_properties.rs b/src/lint/unicorn/rules/unicorn_prefer_number_properties.rs index e2ecf3eaf89d..96183c1c16cc 100644 --- a/src/lint/unicorn/rules/unicorn_prefer_number_properties.rs +++ b/src/lint/unicorn/rules/unicorn_prefer_number_properties.rs @@ -1,8 +1,8 @@ -use bun_lint_oxlint::ast_util::{as_member_expression, is_global_reference, static_property_name}; use crate::unicorn::{GLOBAL_OBJECT_NAMES, is_unary_expression}; use bun_lint::prelude::*; use bun_lint::rule::Plugin; use bun_lint::utils::ancestor_memo::AncestorMemo; +use bun_lint_oxlint::ast_util::{as_member_expression, is_global_reference, static_property_name}; /// Disallows use of `parseInt()`, `parseFloat()`, `isNaN()`, `isFinite()`, `NaN`, `Infinity` and `-Infinity` as global /// variables. @@ -11,8 +11,10 @@ pub struct PreferNumberProperties { check_nan: bool, } -const PREFER_NUMBER_PROPERTIES: Message = - Message::new("", "Use `Number.{{method_name}}` instead of the global `{{method_name}}`"); +const PREFER_NUMBER_PROPERTIES: Message = Message::new( + "", + "Use `Number.{{method_name}}` instead of the global `{{method_name}}`", +); const FUNCTIONS: [&str; 4] = ["isNaN", "isFinite", "parseFloat", "parseInt"]; @@ -23,10 +25,17 @@ pub struct State<'a> { } impl Rule for PreferNumberProperties { - const META: Meta = - Meta::oxlint(Plugin::Unicorn, "prefer-number-properties", Kind::Suggestion).fixable(Fixable::Code); - const ON: On = - On::new().members_named(&["NaN", "Infinity"]).exprs(&[ExprTag::Index]).calls_named(&FUNCTIONS).finish(); + const META: Meta = Meta::oxlint( + Plugin::Unicorn, + "prefer-number-properties", + Kind::Suggestion, + ) + .fixable(Fixable::Code); + const ON: On = On::new() + .members_named(&["NaN", "Infinity"]) + .exprs(&[ExprTag::Index]) + .calls_named(&FUNCTIONS) + .finish(); type State<'a> = State<'a>; fn new(options: &Options) -> Self { @@ -55,7 +64,10 @@ impl Rule for PreferNumberProperties { } fn start<'a>(&self, file: &'a File<'a>) -> Option> { - Some(State { nearest_unary: AncestorMemo::default(), functions: FUNCTIONS.map(|it| file.name_of(it)) }) + Some(State { + nearest_unary: AncestorMemo::default(), + functions: FUNCTIONS.map(|it| file.name_of(it)), + }) } fn expr<'a>(&self, e: Expr<'a>, cx: &mut Cx<'a, Self>) { @@ -70,47 +82,79 @@ impl Rule for PreferNumberProperties { fn finish<'a>(&self, cx: &mut Cx<'a, Self>) { let file = cx.file(); let constants = [("NaN", self.check_nan), ("Infinity", self.check_infinity)]; - for (name, _) in constants.into_iter().filter(|it| it.1 && file.find_name(it.0).is_some()) { - for reference in file.unresolved_references_to(name.as_bytes()).filter(|it| !it.is_jsx_pragma()) { + for (name, _) in constants + .into_iter() + .filter(|it| it.1 && file.find_name(it.0).is_some()) + { + for reference in file + .unresolved_references_to(name.as_bytes()) + .filter(|it| !it.is_jsx_pragma()) + { let span = reference.span(); - let is_shorthand = reference.expr().and_then(is_shorthand_of_object_property) == Some(true); - let report = cx.report(span, PREFER_NUMBER_PROPERTIES).data("method_name", name); + let is_shorthand = + reference.expr().and_then(is_shorthand_of_object_property) == Some(true); + let report = cx + .report(span, PREFER_NUMBER_PROPERTIES) + .data("method_name", name); let nearest_unary = &mut cx.state.nearest_unary; report.fix(|fixer| { if name == "NaN" { - return fixer.insert_before(span, if is_shorthand { "NaN: Number." } else { "Number." }); + return fixer.insert_before( + span, + if is_shorthand { + "NaN: Number." + } else { + "Number." + }, + ); } - let is_unary = |_, parent: Node<'a>| parent.as_expr().filter(|it| is_unary_expression(*it)); + let is_unary = |_, parent: Node<'a>| { + parent.as_expr().filter(|it| is_unary_expression(*it)) + }; let unary = nearest_unary.find(reference.node(), is_unary); let negation = unary.filter(|it| it.unary_op() == Some(UnOp::Minus)); match (negation, is_shorthand) { (Some(_), true) => fixer.insert_after(span, ": Number.NEGATIVE_INFINITY"), (None, true) => fixer.insert_after(span, ": Number.POSITIVE_INFINITY"), - (Some(negation), false) => fixer.replace(negation, "Number.NEGATIVE_INFINITY"), + (Some(negation), false) => { + fixer.replace(negation, "Number.NEGATIVE_INFINITY") + } (None, false) => fixer.replace(span, "Number.POSITIVE_INFINITY"), } }); } } - for name in FUNCTIONS.into_iter().filter(|it| file.find_name(it).is_some()) { + for name in FUNCTIONS + .into_iter() + .filter(|it| file.find_name(it).is_some()) + { for reference in file.unresolved_references_to(name.as_bytes()) { - let Some(is_shorthand) = reference.expr().and_then(is_shorthand_of_object_property) else { + let Some(is_shorthand) = reference.expr().and_then(is_shorthand_of_object_property) + else { continue; }; let span = reference.span(); - let report = cx.report(span, PREFER_NUMBER_PROPERTIES).data("method_name", name); + let report = cx + .report(span, PREFER_NUMBER_PROPERTIES) + .data("method_name", name); let fix = |fixer: Fixer<'a>| match is_shorthand { true => fixer.insert_before(span, [name, ": Number."].concat()), false => fixer.insert_before(span, "Number."), }; - if matches!(name, "isNaN" | "isFinite") { report.fix_dangerously(fix) } else { report.fix(fix) }; + if matches!(name, "isNaN" | "isFinite") { + report.fix_dangerously(fix) + } else { + report.fix(fix) + }; } } } } fn is_global_object(e: Expr) -> bool { - e.as_ident().is_some_and(|it| it.is_any(&GLOBAL_OBJECT_NAMES)) && !e.is_parenthesized() + e.as_ident() + .is_some_and(|it| it.is_any(&GLOBAL_OBJECT_NAMES)) + && !e.is_parenthesized() } impl PreferNumberProperties { @@ -123,6 +167,7 @@ impl PreferNumberProperties { && !member.is_in_type_query() { cx.report(member, PREFER_NUMBER_PROPERTIES) + .called_with(member) .data("method_name", name) .fix(|fixer| fixer.replace(object, "Number")); } @@ -133,7 +178,9 @@ impl PreferNumberProperties { let Some(callee) = e.callee() else { return; }; - let name = callee.as_ident().or_else(|| callee.member_name().map(Ident::name)); + let name = callee + .as_ident() + .or_else(|| callee.member_name().map(Ident::name)); if name.is_some_and(|it| !cx.state.functions.contains(&it)) || callee.is_parenthesized() { return; } @@ -149,13 +196,20 @@ impl PreferNumberProperties { if callee.tag() == ExprTag::Ident && !is_global_reference(callee) { return; } - let report = cx.report(callee, PREFER_NUMBER_PROPERTIES).data("method_name", name); + let report = cx + .report(callee, PREFER_NUMBER_PROPERTIES) + .called_with(e) + .data("method_name", name); // The whole call, so that this and the fix of `prefer-numeric-literals` are not both applied. let fix = |fixer: Fixer<'a>| { let arguments = fixer.file().slice(Span::after(callee.span(), e.span().end)); fixer.replace(e, [&b"Number."[..], name.bytes(), arguments].concat()) }; - if name.is_any(&["isNaN", "isFinite"]) { report.fix_dangerously(fix) } else { report.fix(fix) }; + if name.is_any(&["isNaN", "isFinite"]) { + report.fix_dangerously(fix) + } else { + report.fix(fix) + }; } } diff --git a/src/lint/utils/sort.rs b/src/lint/utils/sort.rs index f9b5615416fb..b13c6ec0cf33 100644 --- a/src/lint/utils/sort.rs +++ b/src/lint/utils/sort.rs @@ -11,3 +11,14 @@ pub use bun_collections::index_sort::{ sort_slice_unstable as sort_unstable, sort_slice_unstable_by as sort_unstable_by, sort_slice_unstable_by_key as sort_unstable_by_key, }; + +/// What a hash table has, by `key`, which no two of them have in common. The order of the table itself depends on the machine, so +/// nothing that is printed may follow it. +pub fn in_order_of( + table: impl IntoIterator, + key: impl FnMut(&T) -> K, +) -> Vec { + let mut all: Vec = table.into_iter().collect(); + sort_unstable_by_key(&mut all, key); + all +} diff --git a/src/lint/utils/ts_scope/scope_utils.rs b/src/lint/utils/ts_scope/scope_utils.rs index a8027097c2d9..90be10d6c350 100644 --- a/src/lint/utils/ts_scope/scope_utils.rs +++ b/src/lint/utils/ts_scope/scope_utils.rs @@ -1,6 +1,7 @@ //! typescript-eslint's `util/scopeUtils.ts`. use crate::ast::{Name, Node}; +use crate::semantic::Reference; /// typescript-eslint's `isReferenceToGlobalFunction`. `node` is where `callee_name` is used: the /// call, the type reference, the identifier. @@ -12,9 +13,19 @@ pub fn is_reference_to_global_function<'a>( callee_name: Name<'a>, node: impl Into>, ) -> bool { - let first = (node.into().scope().references()).find(|it| it.name() == callee_name); - first.is_none_or(|it| { + let scope = node.into().scope(); + let is_global = |it: Reference<'a>| { it.symbol() .is_none_or(|symbol| symbol.declarations().len() == 0) - }) + }; + let is_named = |it: &Reference<'a>| it.name() == callee_name; + let mut named = (scope.references_with_names_of_patterns_first()).filter(is_named); + let Some(answer) = named.next().map(is_global) else { + return true; + }; + // Which is the first only matters if they differ. + match named.all(|it| is_global(it) == answer) { + true => answer, + false => scope.references().find(is_named).is_none_or(is_global), + } } diff --git a/test/cli/format/format-doctor.test.ts b/test/cli/format/format-doctor.test.ts index 07a4ae9647cc..0c4053341c20 100644 --- a/test/cli/format/format-doctor.test.ts +++ b/test/cli/format/format-doctor.test.ts @@ -126,12 +126,30 @@ Not checked: other tools' configs, CI files, Git hooks expect(exitCode).toBe(0); }); + test("the plugin that sorts classes is built in", async () => { + const { stdout, exitCode } = await doctor({ + "package.json": dependencies("prettier", "prettier-plugin-tailwindcss", "tailwindcss"), + ".prettierrc.json": JSON.stringify({ plugins: ["prettier-plugin-tailwindcss"], tailwindFunctions: ["cn"] }), + "a.js": ugly, + }); + expect(stdout).toBe(`Using .prettierrc.json (Prettier config) + + 3 files are formatted natively + +Built-in plugins: prettier-plugin-tailwindcss + +You can remove: prettier, prettier-plugin-tailwindcss +Not checked: other tools' configs, CI files, Git hooks +`); + expect(exitCode).toBe(0); + }); + test("lists unsupported plugins and plugins that aren't installed", async () => { const project = { ...installed("prettier"), - "package.json": dependencies("prettier", "prettier-plugin-tailwindcss", "prettier-plugin-astro"), + "package.json": dependencies("prettier", "prettier-plugin-css-order", "prettier-plugin-astro"), ".prettierrc.json": JSON.stringify({ plugins: ["prettier-plugin-astro"] }), - "styled/.prettierrc.json": JSON.stringify({ plugins: ["prettier-plugin-tailwindcss"] }), + "styled/.prettierrc.json": JSON.stringify({ plugins: ["prettier-plugin-css-order"] }), "styled/a.js": ugly, "b.astro": "

    b

    \n", }; @@ -143,14 +161,13 @@ Also styled/.prettierrc.json 1 file cannot be formatted: 1 *.astro 2 files are skipped because a plugin is not supported Use --allow-unsupported to format them without it - Or replace prettier-plugin-tailwindcss with [fmt.sortTailwindcss] in bunfig.toml Plugins that use your installed Prettier: prettier-plugin-astro -Plugins that are not supported: prettier-plugin-tailwindcss +Plugins that are not supported: prettier-plugin-css-order Not installed: prettier-plugin-astro Still needed: prettier, prettier-plugin-astro (some files go to them) - prettier-plugin-tailwindcss (the skipped files need it) + prettier-plugin-css-order (the skipped files need it) `); const { config, files, plugins, packages } = JSON.parse(stdout); expect(config.nested).toEqual(["styled/.prettierrc.json"]); @@ -158,7 +175,7 @@ Still needed: prettier, prettier-plugin-astro (some files go to them) expect(plugins).toEqual({ built_in: [], by_prettier: ["prettier-plugin-astro"], - not_supported: ["prettier-plugin-tailwindcss"], + not_supported: ["prettier-plugin-css-order"], not_installed: ["prettier-plugin-astro"], }); expect(packages).toEqual({ @@ -166,7 +183,7 @@ Still needed: prettier, prettier-plugin-astro (some files go to them) needed: [ { name: "prettier", reason: "files", by: null }, { name: "prettier-plugin-astro", reason: "files", by: null }, - { name: "prettier-plugin-tailwindcss", reason: "skipped_files", by: null }, + { name: "prettier-plugin-css-order", reason: "skipped_files", by: null }, ], not_checked: ["other tools' configs", "CI files", "Git hooks"], }); @@ -175,8 +192,8 @@ Still needed: prettier, prettier-plugin-astro (some files go to them) test("Prettier is not removable while files are skipped because of a plugin", async () => { const { stdout, exitCode } = await doctor({ - "package.json": dependencies("prettier", "prettier-plugin-tailwindcss", "prettier-plugin-other"), - ".prettierrc": JSON.stringify({ plugins: ["prettier-plugin-tailwindcss"] }), + "package.json": dependencies("prettier", "prettier-plugin-css-order", "prettier-plugin-other"), + ".prettierrc": JSON.stringify({ plugins: ["prettier-plugin-css-order"] }), "a.js": ugly, }); expect(stdout).toStartWith(`Using .prettierrc (Prettier config) @@ -184,12 +201,11 @@ Still needed: prettier, prettier-plugin-astro (some files go to them) 0 files are formatted natively 3 files are skipped because a plugin is not supported Use --allow-unsupported to format them without it - Or replace prettier-plugin-tailwindcss with [fmt.sortTailwindcss] in bunfig.toml -Plugins that are not supported: prettier-plugin-tailwindcss +Plugins that are not supported: prettier-plugin-css-order You can remove: prettier-plugin-other -Still needed: prettier, prettier-plugin-tailwindcss (the skipped files need them) +Still needed: prettier, prettier-plugin-css-order (the skipped files need them) Not checked: other tools' configs, CI files, Git hooks `); expect(exitCode).toBe(0); diff --git a/test/cli/format/tailwind/tailwind.test.ts b/test/cli/format/tailwind/tailwind.test.ts index 38aaa247292e..93a198506180 100644 --- a/test/cli/format/tailwind/tailwind.test.ts +++ b/test/cli/format/tailwind/tailwind.test.ts @@ -471,4 +471,281 @@ if (now.join() !== before.join()) throw new Error("There is " + now.filter(it => const files = { ".oxfmtrc.json": '{ "sortTailwindcss": true }', "a.jsx": ';\n' }; expect(await format(files, ["a.jsx"])).toMatchObject({ stderr: "", exitCode: 0 }); }); + + // What is expected is what Prettier 3.9.9 does with prettier-plugin-tailwindcss 0.8.1 and tailwindcss 4.3.3. + describe("prettier-plugin-tailwindcss in a Prettier config", () => { + const plugin = "prettier-plugin-tailwindcss"; + /** The plugin as far as a configuration can look at it. Nothing of it runs. */ + const installed = { + [`node_modules/${plugin}/package.json`]: JSON.stringify({ name: plugin, type: "module", exports: "./index.mjs" }), + [`node_modules/${plugin}/index.mjs`]: + 'export const options = { tailwindConfig: { type: "string" } };\nexport const parsers = {};\n', + }; + const project = { ...version4, ...installed }; + const config = (options: object) => ({ ".prettierrc": JSON.stringify({ plugins: [plugin], ...options }) }); + const shared = (main: string, text: string) => ({ + "node_modules/@acme/config/package.json": JSON.stringify({ name: "@acme/config", main }), + [`node_modules/@acme/config/${main}`]: text, + }); + + test.each<[string, Record, string[]?]>([ + ["its name", config({})], + ["a path", { ".prettierrc": `{ "plugins": ["./node_modules/${plugin}/index.mjs"] }` }], + ["require.resolve()", { "prettier.config.cjs": `module.exports = { plugins: [require.resolve("${plugin}")] };` }], + [ + "import.meta.resolve()", + { "prettier.config.mjs": `export default { plugins: [import.meta.resolve("${plugin}")] };` }, + ], + ["a URL", { "prettier.config.mjs": `export default { plugins: [new URL(import.meta.resolve("${plugin}"))] };` }], + ["the module", { "prettier.config.mjs": `import * as it from "${plugin}";\nexport default { plugins: [it] };` }], + ["import()", { "prettier.config.mjs": `export default { plugins: [await import("${plugin}")] };` }], + ["require()", { "prettier.config.cjs": `module.exports = { plugins: [require("${plugin}")] };` }], + [ + "an override", + { ".prettierrc": `{ "overrides": [{ "files": "*.jsx", "options": { "plugins": ["${plugin}"] } }] }` }, + ], + ["--plugin", {}, ["--plugin", plugin]], + ["--plugin beside a config", { ".prettierrc": "{}" }, [`--plugin=${plugin}`]], + ["package.json", { "package.json": `{ "prettier": { "plugins": ["${plugin}"] } }` }], + ["YAML", { ".prettierrc.yaml": `plugins:\n - ${plugin}\n` }], + ["TOML", { ".prettierrc.toml": `plugins = ["${plugin}"]\n` }], + [ + "a shared config", + { "package.json": '{ "prettier": "@acme/config" }', ...shared("a.json", `{ "plugins": ["${plugin}"] }`) }, + ], + [ + "a shared config in JavaScript", + { + ".prettierrc": '"@acme/config"', + ...shared("a.js", `module.exports = { plugins: [require.resolve("${plugin}")] };`), + }, + ], + ])("is named by %s", async (_, files, flags = []) => { + const result = await format({ ...project, ...files, "a.jsx": input }, ["a.jsx"], flags); + expect(result).toMatchObject({ stderr: "", files: [sorted], exitCode: 0 }); + }); + + test("counts for the files whose list of plugins has it", async () => { + const overrides = [ + { files: "off/**", options: { plugins: [] } }, + { files: "*.tsx", options: { tailwindPreserveDuplicates: true } }, + ]; + const twice = ';\n'; + const files = { ...config({ overrides }), "a.jsx": input, "off/a.jsx": input, "a.tsx": twice, "b.jsx": twice }; + const result = await format({ ...project, ...files }, ["a.jsx", "off/a.jsx", "a.tsx", "b.jsx"]); + expect(result).toMatchObject({ + stderr: "", + files: [sorted, input, ';\n', ';\n'], + exitCode: 0, + }); + }); + + test.each<[object, string, string]>([ + // No function takes classes unless the config says so. + [{}, 'cn("p-4 flex");\nclsx("p-4 flex");\ntwMerge("p-4 flex");\ntw`p-4 flex`;\n', ""], + [ + { tailwindFunctions: ["cn"] }, + 'cn("p-4 flex");\nclsx("p-4 flex");\ncn`p-4 flex`;\n', + 'cn("flex p-4");\nclsx("p-4 flex");\ncn`flex p-4`;\n', + ], + [ + { tailwindFunctions: ["cn"], overrides: [{ files: "*.jsx", options: { tailwindFunctions: ["tw"] } }] }, + 'cn("p-4 flex");\ntw("p-4 flex");\n', + 'cn("p-4 flex");\ntw("flex p-4");\n', + ], + [ + { tailwindAttributes: ["mine"] }, + ';\n', + ';\n', + ], + [{}, ';\n', ';\n'], + [ + { tailwindPreserveWhitespace: true }, + ';\n', + ';\n', + ], + [{ tailwindPreserveDuplicates: true }, ';\n', ';\n'], + ])("has its options and its defaults: %j", async (options, before, after) => { + const result = await format({ ...project, ...config(options), "a.jsx": before }, ["a.jsx"]); + expect(result).toMatchObject({ stderr: "", files: [after || before], exitCode: 0 }); + }); + + // The plugin says the same, behind the directory of the config and without the `[warn]`. + const deprecated = (name: string) => + `[warn] Deprecated: Use the \`tailwindStylesheet\` option for v4 projects instead of \`${name}\`.\n`; + const both = + "[warn] You have specified a Tailwind CSS stylesheet and a Tailwind CSS config at the same time. Use stylesheetPath unless you are using v3. Preferring the stylesheet.\n"; + test.each<[object, string]>([ + [{ tailwindStylesheet: "./css/app.css" }, ""], + [{ tailwindStylesheet: "css/app.css" }, ""], + [{ tailwindEntryPoint: "./css/app.css" }, deprecated("tailwindEntryPoint")], + [{ tailwindConfig: "./css/app.css" }, deprecated("tailwindConfig")], + [{ tailwindStylesheet: "./css/app.css", tailwindEntryPoint: "./none.css", tailwindConfig: "./none.js" }, both], + [{ overrides: [{ files: "src/**", options: { tailwindStylesheet: "./css/app.css" } }] }, ""], + ])("a path is relative to the config: %j", async (options, stderr) => { + const files = { ...config(options), "css/app.css": "/* reversed */\n", "src/a.jsx": sorted }; + using dir = tempDir("bun-format-tailwind", { ...project, ...files }); + const result = await formatIn(join(String(dir), "src"), ["a.jsx"], ["a.jsx"]); + expect(result).toMatchObject({ stderr, files: [reversed], exitCode: 0 }); + }); + + test("a path is relative to the working directory if the config is the one that --config names", async () => { + const files = { + "config/.prettierrc": config({ tailwindStylesheet: "./app.css" })[".prettierrc"], + "a.jsx": sorted, + }; + const flags = ["--config", "config/.prettierrc"]; + const beside = await format({ ...project, ...files, "app.css": "/* reversed */\n" }, ["a.jsx"], flags); + expect(beside).toMatchObject({ stderr: "", files: [reversed], exitCode: 0 }); + const elsewhere = await format({ ...project, ...files, "config/app.css": "/* reversed */\n" }, ["a.jsx"], flags); + expect(elsewhere).toMatchObject({ files: [sorted], exitCode: 2 }); + expect(elsewhere.stderr).toContain("app.css"); + }); + + test("tailwindConfig is what Tailwind CSS 3 is loaded with", async () => { + const files = { ...version3, ...installed, ...config({ tailwindConfig: "./other.js" }), "src/a.jsx": sorted }; + const result = await format({ ...files, "other.js": "module.exports = { reversed: true };\n" }, ["src/a.jsx"]); + expect(result).toMatchObject({ stderr: "", files: [reversed], exitCode: 0 }); + }); + + test("tailwindPackageName is what Tailwind CSS is called", async () => { + const renamed = Object.fromEntries( + Object.entries(version4).map(([name, text]) => [name.replace("/tailwindcss/", "/@acme/tw/"), text]), + ); + const files = { ...renamed, ...installed, ...config({ tailwindPackageName: "@acme/tw" }), "a.jsx": input }; + expect(await format(files, ["a.jsx"])).toMatchObject({ stderr: "", files: [sorted], exitCode: 0 }); + }); + + test.each([ + [ + { tailwindFunctions: "cn" }, + 'Invalid tailwindFunctions value. Expected an array of a string, but received "cn".', + ], + [ + { tailwindAttributes: [1] }, + "Invalid tailwindAttributes value. Expected an array of a string, but received [1].", + ], + [ + { tailwindPreserveWhitespace: "yes" }, + 'Invalid tailwindPreserveWhitespace value. Expected true or false, but received "yes".', + ], + [{ tailwindStylesheet: 1 }, "Invalid tailwindStylesheet value. Expected a string, but received 1."], + ])("a value of the wrong type is refused: %j", async (options, refusal) => { + const result = await format({ ...project, ...config(options), "a.jsx": input }, ["a.jsx"]); + expect(result.stderr).toBe(`[error] ${refusal}\n`); + expect(result.files).toEqual([input]); + expect(result.exitCode).not.toBe(0); + }); + + test("without it its options are unknown, and with it an option that it does not have is", async () => { + const options = { tailwindFunctions: ["cn"], tailwindConfig: "./x.js", tailwindPreserveWhitespace: true }; + const without = await format({ ...project, ".prettierrc": JSON.stringify(options), "a.jsx": input }, ["a.jsx"]); + expect(without).toMatchObject({ + stderr: + '[warn] Ignored unknown option { tailwindFunctions: ["cn"] }.\n' + + '[warn] Ignored unknown option { tailwindConfig: "./x.js" }.\n' + + "[warn] Ignored unknown option { tailwindPreserveWhitespace: true }.\n", + files: [input], + exitCode: 0, + }); + const unknown = await format({ ...project, ...config({ tailwindOther: 1 }), "a.jsx": input }, ["a.jsx"]); + expect(unknown).toMatchObject({ + stderr: "[warn] Ignored unknown option { tailwindOther: 1 }.\n", + files: [sorted], + exitCode: 0, + }); + }); + + test("another plugin beside it is still refused, by its name", async () => { + const files = { ".prettierrc": JSON.stringify({ plugins: [plugin, "prettier-plugin-other"] }), "a.jsx": input }; + const result = await format({ ...project, ...files }, ["a.jsx"], ["--log-level=log", "--check"]); + expect(result.stderr).toContain("plugins that bun fmt does not include: prettier-plugin-other\n"); + // Nothing is said about files that have not been looked at. + expect(result.stdout).toBe("Checking formatting...\n"); + expect(result.files).toEqual([input]); + expect(result.exitCode).toBe(2); + }); + + describe("the Tailwind CSS that it brings along", () => { + /** Its `createSorter`, which knows what is in order.json. A file with `reversed` in it turns the order around. */ + const withSorter = { + ...installed, + [`node_modules/${plugin}/package.json`]: JSON.stringify({ + name: plugin, + type: "module", + exports: { ".": "./index.mjs", "./sorter": "./sorter.mjs" }, + }), + [`node_modules/${plugin}/sorter.mjs`]: `import { readFileSync } from "node:fs"; +const order = ${JSON.stringify(order)}; +export async function createSorter({ configPath, stylesheetPath }) { + const files = [configPath, stylesheetPath].filter(Boolean).map(it => readFileSync(it, "utf8")); + const sign = files.join().includes("reversed") ? -1 : 1; + const at = name => (order.includes(name) ? sign * order.indexOf(name) : -Infinity); + const sortClassLists = lists => + lists.map(list => list.toSorted((a, z) => at(a) - at(z)).filter((it, i, all) => !order.includes(it) || all.indexOf(it) === i)); + return { sortClassLists }; +} +`, + }; + const flipped = "module.exports = { reversed: true };\n"; + + test.each<[string, Record, string]>([ + ["there is no tailwindcss", config({}), sorted], + [ + "there is no tailwindcss, with the nearest tailwind.config.js", + { ...config({}), "tailwind.config.js": flipped }, + reversed, + ], + [ + "there is no tailwindcss, with a style sheet", + { ...config({ tailwindStylesheet: "a.css" }), "a.css": "/* reversed */" }, + reversed, + ], + [ + "tailwindConfig is set and version 4 is installed", + { ...version4, ...config({ tailwindConfig: "a.js" }), "a.js": flipped }, + reversed, + ], + [ + "tailwindStylesheet is set and version 3 is installed", + { ...version3, ...config({ tailwindStylesheet: "a.css" }), "a.css": "/* reversed */" }, + reversed, + ], + ])("is asked if %s", async (_, files, expected) => { + const before = ';\n'; + const result = await format({ ...withSorter, ...files, "src/a.jsx": before }, ["src/a.jsx"]); + expect(result).toMatchObject({ stderr: "", files: [expected.replace('"', '"x x ')], exitCode: 0 }); + }); + + test.each<[Record, string[]]>([ + [config({}), ["the tailwindcss package was not found from ", " Run bun add -d tailwindcss"]], + [ + { ...version4, ...config({ tailwindConfig: "a.js" }), "a.js": flipped }, + [ + "tailwindConfig is for Tailwind CSS 3, and another version is in ", + ` Run bun add -d ${plugin}, which brings its own`, + ], + ], + [ + { ...version3, ...config({ tailwindStylesheet: "a.css" }), "a.css": "" }, + [ + "tailwindStylesheet is for Tailwind CSS 4, and another version is in ", + ` Run bun add -d ${plugin}, which brings its own`, + ], + ], + ])("has to be installed for that: %#", async (files, said) => { + const result = await format({ ...files, "a.jsx": input }, ["a.jsx"]); + for (const line of said) + expect(result.stderr).toContain(`[error] ${line.startsWith(" ") ? "" : "sortTailwindcss: "}${line}`); + expect(result.files).toEqual([input]); + expect(result.exitCode).toBe(2); + }); + }); + + test("a flag or [fmt] counts for more", async () => { + const files = { ...config({}), "bunfig.toml": '[fmt.sortTailwindcss]\nfunctions = ["mine"]\n' }; + const result = await format({ ...project, ...files, "a.jsx": 'mine("p-4 flex");\n' }, ["a.jsx"]); + expect(result).toMatchObject({ stderr: "", files: ['mine("flex p-4");\n'], exitCode: 0 }); + }); + }); }); From 9d29b6a72e942c0bb2dcdcdd0c3e46707d720214 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 13 Oct 2026 05:15:10 +0000 Subject: [PATCH 48/49] lint, format: 27 files --- docs/guides/format/migrate-from-prettier.mdx | 3 +- src/format/CLAUDE.md | 3 +- src/format/css/printer.rs | 9 +- src/format/html/js.rs | 110 +- src/format/html/tailwind.rs | 7 +- src/format/js/print/jsx/opening_element.rs | 9 +- src/format/js/utils/tailwindcss.rs | 158 ++- src/format/tailwind.rs | 12 + src/lint/driver/fmt/config.rs | 87 +- src/lint/driver/fmt/mod.rs | 1 + src/lint/driver/fmt/tailwind.rs | 7 +- src/lint/driver/format/excerpt.rs | 53 +- .../eslint/rules/no_irregular_whitespace.rs | 1 + src/lint/jest/rules/jest_no_export.rs | 51 +- src/lint/linter/config/mod.rs | 182 ++- src/lint/oxlint/module_record.rs | 21 +- src/lint/oxlint_order.rs | 6 +- src/lint/oxlint_order/visited.rs | 14 + src/lint/plugins/oxlint/exhaustive_deps.rs | 106 +- .../plugins/rules/import_group_exports.rs | 71 +- .../rules/import_newline_after_import.rs | 144 ++- src/sema/messages.rs | 32 +- test/cli/format/format.test.ts | 78 +- test/cli/format/tailwind/tailwind.test.ts | 157 +++ test/cli/lint/defaults.test.ts | 24 + test/cli/lint/oxc-errors.test.ts | 1107 ++++++++++++++++- test/js/bun/glob/match.test.ts | 65 +- 27 files changed, 2214 insertions(+), 304 deletions(-) diff --git a/docs/guides/format/migrate-from-prettier.mdx b/docs/guides/format/migrate-from-prettier.mdx index 4ff56e6f4a62..9f4f67170ed9 100644 --- a/docs/guides/format/migrate-from-prettier.mdx +++ b/docs/guides/format/migrate-from-prettier.mdx @@ -98,10 +98,11 @@ Bun doesn't load Prettier plugins. These are built in, with all their options, s - `@trivago/prettier-plugin-sort-imports` - `@ianvs/prettier-plugin-sort-imports` - `prettier-plugin-organize-imports` +- `@prettier/plugin-oxc` --- -Svelte, Astro, and other languages that come from a plugin go to your installed Prettier. Keep `prettier` and every plugin in `plugins` installed. `--doctor` shows which files those are. +Svelte, Astro, and other languages that come from a plugin go to your installed Prettier. So does `package.json` if you use `prettier-plugin-packagejson`. Keep `prettier` and every plugin in `plugins` installed. `--doctor` shows which files those are. ```txt Using .prettierrc (Prettier config) diff --git a/src/format/CLAUDE.md b/src/format/CLAUDE.md index 97d532230d7d..fe110eafc53d 100644 --- a/src/format/CLAUDE.md +++ b/src/format/CLAUDE.md @@ -298,12 +298,13 @@ oxfmt's option, which is made of `prettier-plugin-tailwindcss`: the classes of T - **Handlebars**: the plugin changes the tree, and so does `handlebars/tailwind.rs`. - **HTML, Vue and Angular**: the plugin rewrites the values of attributes in the tree, so `html/tailwind.rs` rewrites the text before it is parsed. Of the code in HTML the plugin takes over programs and `__js_expression`, nothing else: `js.rs::with_file`. - **`--debug-check`** knows that words change places and that one that is there twice is there once: `verify/tree.rs::has_same_words`. HTML is compared without the option: `html::has_same_content`. +- **`prettier-plugin-tailwindcss` in a Prettier config** turns it on too, with the plugin's options and defaults (`SetBy::Plugin` in `fmt/tailwind.rs`): `Tailwind::follows_plugin`. Where oxfmt has rules of its own, the plugin's count then. In JavaScript that is its `transformJavaScript`: the stack has one context, and a string or a template walks up its ancestors to see whether it is sorted (`collapse_by_plugin`). `collapse_among` is its `canCollapseWhitespaceIn`, for JavaScript, Vue and Angular. What the plugin sees depends on the parser: Babel, TypeScript, or `@prettier/plugin-oxc` (`Oxc`). Where it takes away the blank that keeps two classes apart, the blank stays. If the project has no `tailwindcss`, or an option is for the other major version, the order is read off the plugin's own `createSorter` (`loadBundled` in `tailwind.js`). - **Not there**: with `preserveWhitespace`, a line break in a list does not break the groups around it in oxfmt. oxfmt does not sort in the cells of a `test.each` table. Without the package oxfmt takes a Tailwind that the plugin brings along, also for `config` with version 4 installed. - **Tests**: `test/cli/format/tailwind`, with a stand-in for the package. `test/cli/format/oracle/tailwind/make-fixtures.ts` makes what is expected with the real tools. ## Languages that only a plugin of Prettier reads -`.svelte`, `.astro`, `.php` and the like. In Prettier's flavor, a file whose language a plugin of the configuration reads is handed to the Prettier of the project. +`.svelte`, `.astro`, `.php` and the like. In Prettier's flavor, a file whose language a plugin of the configuration reads is handed to the Prettier of the project. So is a `package.json` with `prettier-plugin-packagejson`, whose order is not that of `sortPackageJson`. - **Where**: `src/lint/driver/fmt/prettier.rs`, which talks to the host of the JS plugins of `bun lint`: `src/lint/js_plugin/prettier.rs` and `worker/prettier.js`. - **When**: `prettier` and every package that the configuration names as a plugin are installed. Prettier loads all of them, also those that are built in here. Else the files are named and left as they are. diff --git a/src/format/css/printer.rs b/src/format/css/printer.rs index 7b966c78a32b..0cc1f9163657 100644 --- a/src/format/css/printer.rs +++ b/src/format/css/printer.rs @@ -1113,10 +1113,11 @@ impl<'a> Printer<'a, '_> { self.sink.text(&adjust_strings(params, self.single_quote)); } Params::Text(params) | Params::Unknown(params) => { - let is_sorted = node.name == b"apply" - && !node.has_block - && !bun_core::strings::contains(params, b"/*"); - let sorted = (self.tailwind.as_deref().filter(|_| is_sorted)) + let is_sorted = node.name == b"apply" && !node.has_block; + // To the plugin the parts of a comment are classes like others. oxfmt leaves such a list alone. + let has_comment = bun_core::strings::contains(params, b"/*"); + let sorted = (self.tailwind.as_deref()) + .filter(|it| is_sorted && (it.follows_plugin || !has_comment)) .and_then(|tailwind| tailwind.sorted_to_apply(params)); self.sink.text(sorted.as_deref().unwrap_or(params)); } diff --git a/src/format/html/js.rs b/src/format/html/js.rs index 6f3cc97b71c9..27e0445cf4ec 100644 --- a/src/format/html/js.rs +++ b/src/format/html/js.rs @@ -9,10 +9,11 @@ use crate::js::comments::collect; use crate::js::context::JsFormatContext; use crate::js::print::program::{FormatStatements, write_hashbang}; use crate::js::sort_imports::{SortImports, sorted_text}; +use crate::js::utils::tailwindcss::{Texts, collapse_among, ends_in_template}; use crate::options::{Code, Flavor, HtmlRoot, InHtml, JavaScriptParser, ParseJavaScript, Then}; use crate::prelude::*; -use crate::tailwind::{Ends, Tailwind}; -use crate::{format_args, text, write}; +use crate::tailwind::Tailwind; +use crate::{format_args, write}; use bun_core::strings; use bun_lint::ast::walk::{Visitor, walk}; use bun_lint::linter::{TypesInJavaScript, refused_by_prettier_with}; @@ -278,80 +279,72 @@ pub(crate) fn sorted_script(parse: Parse<'_>, code: &[u8], how: &SortImports) -> } /// Finds the strings and the templates of a program, and sorts the classes in them. -struct ClassSorter<'t> { +struct ClassSorter<'t, 'a> { tailwind: &'t Tailwind, - /// The plugin's `canCollapseWhitespaceIn`: every `+` around a text counts. If not, the nearest does. is_angular: bool, - /// The operands of each `+` around what is visited, and on which sides of those further out it is. - concatenations: Vec<(Span, Span, (bool, bool))>, + /// Each `+` and each template around what is visited, from the outside to the inside. + around: Vec>, /// What is written in the place of what, in the order of the text. changes: Vec<(Span, Vec)>, } -impl ClassSorter<'_> { - /// Whether what is at `span` is on the left, and on the right, of a `+` that counts. - fn sides_of(&self, span: Span) -> (bool, bool) { - let is_in = |operand: Span| operand.start <= span.start && span.end <= operand.end; - self.concatenations - .last() - .map_or((false, false), |&(left, right, outer)| { - (outer.0 || is_in(left), outer.1 || is_in(right)) - }) - } - - /// `text`, which is at `span`, is text of a template, or what is between the quotes of a string. `index`, `count`: which of - /// how many texts of the template. - fn sort(&mut self, text: &[u8], span: Span, (index, count): (usize, usize)) { - // What is added to has to stay apart. - let (is_left, is_right) = self.sides_of(span); - let ends = Ends { - ignores_first: index > 0 && !text::starts_with_white_space(text), - ignores_last: index + 1 < count - && strings::trim_js_whitespace_end(text).len() == text.len(), - collapses_start: !is_right && index == 0, - collapses_end: !is_left && index + 1 == count, +impl ClassSorter<'_, '_> { + /// `texts`: those of the template at `whole`, or what is between the quotes of the string there, and where each is. + fn sort<'s>(&mut self, whole: Span, texts: impl ExactSizeIterator) { + let rules = match self.is_angular { + true => (true, Texts::AsWritten), + false => (false, Texts::Unseen), }; - let sorted = self.tailwind.sorted_between(text, ends); - if *sorted != *text { - self.changes.push((span, sorted.into_owned())); + let around = collapse_among(&self.around, whole, self.tailwind, rules); + let count = texts.len(); + for (index, (text, span)) in texts.enumerate() { + let ends = ends_in_template(text, (index == 0, index + 1 == count), around); + let sorted = self.tailwind.sorted_between(text, ends); + if *sorted != *text { + self.changes.push((span, sorted.into_owned())); + } } } /// `span`: of a string with its quotes, or of a template without substitutions. fn sort_literal(&mut self, file: &File<'_>, span: Span) { if let [b'"' | b'\'' | b'`', content @ .., _] = file.slice(span) { - self.sort(content, Span::new(span.start + 1, span.end - 1), (0, 1)); + let inner = Span::new(span.start + 1, span.end - 1); + self.sort(span, std::iter::once((content, inner))); } } } -impl<'a> Visitor<'a> for ClassSorter<'_> { +fn is_around_classes(e: Expr<'_>) -> bool { + matches!( + e.kind(), + ExprKind::Template(_) | ExprKind::Binary { op: BinOp::Add, .. } + ) +} + +impl<'a> Visitor<'a> for ClassSorter<'_, 'a> { fn enter(&mut self, node: Node<'a>) { match node { - Node::Expr(e) => match e.kind() { - ExprKind::Binary { - op: BinOp::Add, - left, - right, - } => { - let outer = match self.is_angular { - true => self.sides_of(e.span()), - false => (false, false), - }; - self.concatenations.push((left.span(), right.span(), outer)); - } - ExprKind::String(_) if !e.is_jsx_text() => self.sort_literal(e.file(), e.span()), - ExprKind::Template(template) => { - let count = template.quasi_count(); - for index in 0..count { - let span = template.quasi_span(index); - let end = span.end - if index + 1 == count { 1 } else { 2 }; - let span = Span::new(span.start + 1, end); - self.sort(template.raw(index), span, (index, count)); + Node::Expr(e) => { + match e.kind() { + ExprKind::String(_) if !e.is_jsx_text() => { + self.sort_literal(e.file(), e.span()) + } + ExprKind::Template(template) => { + let count = template.quasi_count(); + let text = |index: usize| { + let span = template.quasi_span(index); + let end = span.end - if index + 1 == count { 1 } else { 2 }; + (template.raw(index), Span::new(span.start + 1, end)) + }; + self.sort(e.span(), (0..count).map(text)); } + _ => {} } - _ => {} - }, + if is_around_classes(e) { + self.around.push(e); + } + } Node::Prop(property) => { if let Some(key) = property.key() && matches!(key.kind(), KeyKind::String(_) | KeyKind::ComputedString(_)) @@ -365,9 +358,8 @@ impl<'a> Visitor<'a> for ClassSorter<'_> { } fn exit(&mut self, node: Node<'a>) { - if matches!(node, Node::Expr(e) if matches!(e.kind(), ExprKind::Binary { op: BinOp::Add, .. })) - { - self.concatenations.pop(); + if matches!(node, Node::Expr(e) if is_around_classes(e)) { + self.around.pop(); } } } @@ -389,7 +381,7 @@ pub(crate) fn with_sorted_classes( let mut sorter = ClassSorter { tailwind, is_angular, - concatenations: Vec::new(), + around: Vec::new(), changes: Vec::new(), }; walk(file, &mut sorter); diff --git a/src/format/html/tailwind.rs b/src/format/html/tailwind.rs index f3a7e4a4214b..3dc8f9d7e203 100644 --- a/src/format/html/tailwind.rs +++ b/src/format/html/tailwind.rs @@ -68,7 +68,12 @@ fn sorted_value( (start, end) = (start + 1, end - 1); } let value = text.get(start..end)?; - let sorted = match value_of(attr.name_span.of(text), parser, tailwind) { + // In HTML Prettier writes the name of an attribute that it knows in small letters. + let name = match attr.name_span.of(text) { + name if parser == Parser::Html && name.eq_ignore_ascii_case(b"class") => b"class", + name => name, + }; + let sorted = match value_of(name, parser, tailwind) { Value::Classes => tailwind.sorted(value).into_owned(), Value::Expression if strings::index_of_any(value, b"`'\"").is_some() => { js::with_sorted_classes(parse?, value, tailwind, parser == Parser::Angular)? diff --git a/src/format/js/print/jsx/opening_element.rs b/src/format/js/print/jsx/opening_element.rs index f67ea5f97963..33a29628e622 100644 --- a/src/format/js/print/jsx/opening_element.rs +++ b/src/format/js/print/jsx/opening_element.rs @@ -1,5 +1,6 @@ use super::FormatJsxName; use crate::js::print::type_parameters::type_arguments; +use crate::js::utils::tailwindcss::loses_line_breaks; use crate::prelude::*; use crate::text::is_next_line_empty; use crate::{format_args, write}; @@ -19,8 +20,9 @@ fn as_string_literal_attribute_value(attribute: Prop<'_>) -> Option> { }) } -fn has_line_break(string: Expr<'_>) -> bool { +fn has_line_break<'a>(string: Expr<'a>, f: &Formatter<'a>) -> bool { bun_core::strings::index_of_any(string.text(), b"\r\n").is_some() + && !loses_line_breaks(string, f) } impl<'a> FormatOpeningElement<'a> { @@ -50,7 +52,7 @@ impl<'a> FormatOpeningElement<'a> { && attributes .first() .and_then(as_string_literal_attribute_value) - .is_some_and(|it| !has_line_break(it)) + .is_some_and(|it| !has_line_break(it, f)) { OpeningElementLayout::SingleStringAttribute } else { @@ -146,7 +148,8 @@ impl<'a> Format<'a> for FormatOpeningElement<'a> { let has_multiline_string_attribute = attributes .iter() - .any(|it| as_string_literal_attribute_value(it).is_some_and(has_line_break)); + .filter_map(as_string_literal_attribute_value) + .any(|it| has_line_break(it, f)); write!( f, group(&format_inner).should_expand(has_multiline_string_attribute) diff --git a/src/format/js/utils/tailwindcss.rs b/src/format/js/utils/tailwindcss.rs index ade22b538e41..2a451772a1c3 100644 --- a/src/format/js/utils/tailwindcss.rs +++ b/src/format/js/utils/tailwindcss.rs @@ -8,9 +8,11 @@ //! has one context then, and what is around a text says whether it is sorted: [`collapse_by_plugin`]. use super::string::{FormatLiteralStringToken, StringLiteralParentKind}; +use crate::options::HtmlRoot; use crate::prelude::*; -use crate::tailwind::{Ends, Names, Tailwind}; +use crate::tailwind::{Ends, Names, Oxc, Tailwind}; use smallvec::SmallVec; +use std::borrow::Cow; /// What is written is in the value of an attribute, or among the arguments of a function, that takes classes. #[derive(Clone, Copy, Debug)] @@ -34,9 +36,9 @@ pub(crate) struct TailwindContext { /// Whether the white space at the start and at the end of a text can go. If not, one blank stays of it. #[derive(Clone, Copy, Debug)] -struct Collapse { - start: bool, - end: bool, +pub(crate) struct Collapse { + pub(crate) start: bool, + pub(crate) end: bool, } impl Collapse { @@ -180,6 +182,13 @@ pub(crate) fn context_of_string(source: &[u8], f: &Formatter<'_>) -> Option(e: Expr<'a>, f: &Formatter<'a>) -> bool { + (f.context().tailwind_context()).is_some_and(|it| it.follows_plugin && !it.preserves_whitespace) + && !bun_core::strings::contains(e.text(), b"{{") + && collapse_by_plugin(e.span(), e.ast_parent(), f).is_some() +} + /// Whether the value of the JSX attribute called `name` is classes. fn is_class_attribute(name: &[u8], tailwind: &Tailwind) -> bool { matches!(name, b"class" | b"className") @@ -192,10 +201,21 @@ fn has(names: &Names, name: &[u8], tailwind: &Tailwind) -> bool { names.has_name(name) || (tailwind.follows_plugin && names.has_pattern_for(name)) } +/// Whether `@prettier/plugin-oxc` reads what is written. +fn is_read_by_oxc(tailwind: &Tailwind, f: &Formatter<'_>) -> bool { + tailwind.oxc != Oxc::Absent + && (!f.context().has_tree_of_babel() || f.options().in_html.root == HtmlRoot::None) +} + +/// Whether the plugin looks at the tree of Babel. +fn is_babel(tailwind: &Tailwind, f: &Formatter<'_>) -> bool { + f.context().has_tree_of_babel() && !is_read_by_oxc(tailwind, f) +} + /// Whether what is in an optional chain with `e` is another kind of node to the plugin than a call or a member access: to /// Babel every link from the `?.` on is, to the other parsers what is around the whole chain. -fn is_other_node_to_plugin(e: Expr<'_>, f: &Formatter<'_>) -> bool { - match f.context().has_tree_of_babel() { +fn is_other_node_to_plugin(e: Expr<'_>, tailwind: &Tailwind, f: &Formatter<'_>) -> bool { + match is_babel(tailwind, f) { true => e.is_in_optional_chain(), false => e.is_chain_root(), } @@ -208,7 +228,7 @@ fn is_class_function(callee: Expr<'_>, tailwind: &Tailwind, f: &Formatter<'_>) - } let mut node = callee; loop { - if tailwind.follows_plugin && is_other_node_to_plugin(node, f) { + if tailwind.follows_plugin && is_other_node_to_plugin(node, tailwind, f) { return false; } node = match node.kind() { @@ -225,6 +245,9 @@ fn is_class_function(callee: Expr<'_>, tailwind: &Tailwind, f: &Formatter<'_>) - /// function that take classes, however deep (`sortInside`), and the template behind such a function. fn collapse_by_plugin<'a>(leaf: Span, parent: AstNodes<'a>, f: &Formatter<'a>) -> Option { let tailwind = f.options().tailwind.as_deref()?; + if tailwind.oxc == Oxc::Behind && is_read_by_oxc(tailwind, f) { + return None; + } let is_in = |span: Span| span.start <= leaf.start && leaf.end <= span.end; // Each `+` and each template around it, from the inside to the outside. let mut around: SmallVec<[Expr<'a>; 4]> = SmallVec::new(); @@ -249,7 +272,7 @@ fn collapse_by_plugin<'a>(leaf: Span, parent: AstNodes<'a>, f: &Formatter<'a>) - AstNodes::CallExpression(e) => { if let ExprKind::Call(call) = e.kind() && call.args().iter().any(|it| is_in(it.span())) - && !(f.context().has_tree_of_babel() && e.is_in_optional_chain()) + && !(is_babel(tailwind, f) && e.is_in_optional_chain()) && is_class_function(call.callee(), tailwind, f) { is_sorted = true; @@ -263,68 +286,97 @@ fn collapse_by_plugin<'a>(leaf: Span, parent: AstNodes<'a>, f: &Formatter<'a>) - if !is_sorted { return None; } - let mut collapse = Collapse::ALL; + let texts = match f.context().has_tree_of_babel() || is_read_by_oxc(tailwind, f) { + true => Texts::Sorted, + false => Texts::Unseen, + }; + around.reverse(); + Some(collapse_among(&around, leaf, tailwind, (true, texts))) +} + +/// What the plugin sees of the texts of a template when it decides about what is between them. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum Texts { + /// Nothing: the tree of TypeScript does not have the positions that it asks for, and in Vue it does not ask. + Unseen, + AsWritten, + /// It has sorted them by then. + Sorted, +} + +/// What stays of the white space at the ends of the string or the template at `leaf`. `around`: each `+` and each template +/// around it, from the outside to the inside. `every_plus`: all of them count, as in the plugin's `canCollapseWhitespaceIn`. +/// If not, the innermost does. +pub(crate) fn collapse_among( + around: &[Expr<'_>], + leaf: Span, + tailwind: &Tailwind, + (every_plus, texts): (bool, Texts), +) -> Collapse { + let is_in = |span: Span| span.start <= leaf.start && leaf.end <= span.end; + // What the plugin lets go, and where nothing touches it. + let (mut collapse, mut free) = (Collapse::ALL, Collapse::ALL); if tailwind.preserves_whitespace { - return Some(collapse); + return collapse; } - // `canCollapseWhitespaceIn` - for e in around.into_iter().rev() { - match e.kind() { + for e in around { + let template = match e.kind() { ExprKind::Binary { op: BinOp::Add, left, right, } => { - collapse = collapse.and(!is_in(right.span()), !is_in(left.span())); + let outer = if every_plus { collapse } else { Collapse::ALL }; + collapse = outer.and(!is_in(right.span()), !is_in(left.span())); + continue; } - ExprKind::Template(template) => { - let is_babel = f.context().has_tree_of_babel(); - collapse = collapse_in_template(template, leaf, collapse, (tailwind, is_babel)); - } - _ => {} - } - } - Some(collapse) -} - -/// The same for what is at `leaf` between a `${` and a `}` of `template`, of whose own ends `around` stays. -fn collapse_in_template( - template: Template<'_>, - leaf: Span, - around: Collapse, - (tailwind, is_babel): (&Tailwind, bool), -) -> Collapse { - let (count, mut collapse) = (template.quasi_count(), around); - let mut expressions = template.exprs().iter().enumerate(); - let Some((index, expression)) = - expressions.find(|(_, it)| it.span().start <= leaf.start && leaf.end <= it.span().end) - else { - return around; - }; - // The plugin looks at the texts behind it, and at the one before it if nothing is between the `${` and it, as it has - // sorted them, and at the start of each for the start. The tree of TypeScript does not have the positions that it asks for. - if is_babel { + ExprKind::Template(template) => template, + _ => continue, + }; + let count = template.quasi_count(); + let mut expressions = template.exprs().iter().enumerate(); + let Some((index, expression)) = expressions.find(|it| is_in(it.1.span())) else { + continue; + }; + // It looks at the texts behind it, and at the one before it if nothing is between the `${` and it, and for the start + // at the start of each. let touches_brace = template.quasi_span(index).end == expression.span().start; - for i in (index + usize::from(!touches_brace))..count { + let seen = match texts { + Texts::Unseen => count, + _ => index + usize::from(!touches_brace), + }; + let of_template = collapse; + for i in seen..count { let raw = template.raw(i); - let sorted = tailwind - .sorted_between(raw, ends_in_template(raw, (i == 0, i + 1 == count), around)); + let text = match texts { + Texts::Sorted => tailwind.sorted_between( + raw, + ends_in_template(raw, (i == 0, i + 1 == count), of_template), + ), + _ => Cow::Borrowed(raw), + }; collapse = collapse.and( - starts_with_white_space(&sorted), - i == index || ends_with_white_space(&sorted), + starts_with_white_space(&text), + i == index || ends_with_white_space(&text), ); } + // So the blank goes in `` `a${" b"}` ``, and `a` and `b` are one class. Here what touches a text stays apart from it. + let (before, behind) = (template.raw(index), template.raw(index + 1)); + free = Collapse { + start: ends_with_white_space(before) || (before.is_empty() && index == 0 && free.start), + end: starts_with_white_space(behind) + || (behind.is_empty() && index + 2 == count && free.end), + }; } - // That takes the blank away in `` `a${" b"}` ``, and `a` and `b` are one class. Here what touches a text stays apart from it. - let (before, behind) = (template.raw(index), template.raw(index + 1)); - collapse.and( - ends_with_white_space(before) || (before.is_empty() && index == 0), - starts_with_white_space(behind) || (behind.is_empty() && index + 2 == count), - ) + collapse.and(free.start, free.end) } /// What the plugin's `sortTemplateLiteral` calls `sortClasses` with for `text`. `around`: what stays at the ends of the template. -fn ends_in_template(text: &[u8], (is_first, is_last): (bool, bool), around: Collapse) -> Ends { +pub(crate) fn ends_in_template( + text: &[u8], + (is_first, is_last): (bool, bool), + around: Collapse, +) -> Ends { Ends { ignores_first: !is_first && !starts_with_white_space(text), ignores_last: !is_last && !ends_with_white_space(text), diff --git a/src/format/tailwind.rs b/src/format/tailwind.rs index 2f39f0f732a9..9454ed314711 100644 --- a/src/format/tailwind.rs +++ b/src/format/tailwind.rs @@ -70,6 +70,17 @@ impl Names { } } +/// Whether `@prettier/plugin-oxc` is among the plugins. It reads TypeScript, and the JavaScript that is not in HTML. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum Oxc { + #[default] + Absent, + /// The plugin that sorts classes looks at its tree. + Before, + /// It is behind that plugin and takes its place: in what it reads nothing is sorted. + Behind, +} + /// `sortTailwindcss`, for one file. #[derive(Debug)] pub struct Tailwind { @@ -81,6 +92,7 @@ pub struct Tailwind { pub preserves_duplicates: bool, /// `prettier-plugin-tailwindcss` in a Prettier config has turned it on: where oxfmt has rules of its own, the plugin's count. pub follows_plugin: bool, + pub oxc: Oxc, pub orders: Box, /// See [`Tailwind::has_missed`]. pub has_missed: AtomicBool, diff --git a/src/lint/driver/fmt/config.rs b/src/lint/driver/fmt/config.rs index 45571aaadd4a..2132789782a3 100644 --- a/src/lint/driver/fmt/config.rs +++ b/src/lint/driver/fmt/config.rs @@ -21,6 +21,7 @@ use bun_collections::index_sort::sort_slice; use bun_core::strings; use bun_format::FormatOptions; use bun_format::sort_imports::{Settings as SortSettings, SortImports}; +use bun_format::tailwind::Oxc; use bun_lint::glob::Picomatch; use bun_lint::json::{self, Notation}; use bun_lint::linter::config::{glob_refusal, strings_of}; @@ -158,6 +159,7 @@ struct Plugins { names_others: bool, /// One of them is `prettier-plugin-tailwindcss`. sorts_classes: bool, + oxc: Oxc, /// Those that may print what this formatter formats in another way. missing: Vec>, /// How the names of the files end that they add to what Prettier reads. @@ -223,6 +225,8 @@ fn patterns_of<'t>(all: impl Iterator, is_oxfmt: bool) -> Vec

    bool { @@ -230,15 +234,32 @@ pub(super) fn is_built_in_plugin(name: &[u8]) -> bool { name.ends_with(b"/prettier-plugin-sort-imports") || name == b"prettier-plugin-organize-imports" || name == PLUGIN_FOR_CLASSES + || name == PLUGIN_OF_OXC } -/// What an element of `plugins` names. One that is no string is a module that a configuration in JavaScript has imported: -/// the plugin that sorts classes is known by its options. +/// What an element of `plugins` names. One that is no string is a module that a configuration in JavaScript has imported, +/// which is known by what it exports. fn name_of(plugin: &Json) -> Option<&[u8]> { - let is_for_classes = || plugin.get(b"options")?.get(b"tailwindConfig"); - plugin - .as_str() - .or_else(|| is_for_classes().map(|_| PLUGIN_FOR_CLASSES)) + let has = + |export: &[u8], key: &[u8]| plugin.get(export).is_some_and(|it| it.get(key).is_some()); + match plugin.as_str() { + None if has(b"options", b"tailwindConfig") => Some(PLUGIN_FOR_CLASSES), + None if has(b"parsers", b"oxc") => Some(PLUGIN_OF_OXC), + name => name, + } +} + +/// See [`Oxc`]. `plugins`: in the order of their list. +fn oxc_among<'p>(plugins: impl Iterator) -> Oxc { + let mut oxc = Oxc::Absent; + for name in plugins.map(package_of) { + oxc = match (name == PLUGIN_OF_OXC, oxc) { + (true, _) => Oxc::Behind, + (false, Oxc::Behind) if name == PLUGIN_FOR_CLASSES => Oxc::Before, + (false, oxc) => oxc, + }; + } + oxc } fn is_plugin_for_classes(name: &[u8]) -> bool { @@ -249,9 +270,11 @@ fn is_built_in(plugin: &Json) -> bool { name_of(plugin).is_some_and(is_built_in_plugin) } -/// Plugins that add a language to Prettier and leave the others as they are, with how the names of its files end. -/// Without them the rest of a project is formatted the same. -const PLUGINS_FOR_LANGUAGES: [(&[u8], &[&[u8]]); 22] = [ +/// Plugins that add a language to Prettier, or change what it makes of one kind of file, and leave the others as they are, with +/// how the names of those files end. Without them the rest of a project is formatted the same. +const PLUGINS_FOR_LANGUAGES: [(&[u8], &[&[u8]]); 23] = [ + // Its order of the fields is not that of `sortPackageJson`, which is oxfmt's. + (b"prettier-plugin-packagejson", &[b"/package.json"]), (b"prettier-plugin-svelte", &[b".svelte"]), (b"prettier-plugin-astro", &[b".astro"]), (b"@prettier/plugin-pug", &[b".pug", b".jade"]), @@ -764,6 +787,7 @@ impl Plugins { Some(Plugins { names_others: !plugins.iter().all(is_built_in), sorts_classes: (plugins.iter().filter_map(name_of)).any(is_plugin_for_classes), + oxc: oxc_among(plugins.iter().filter_map(name_of)), missing: (plugins.iter()) .map(|it| name_of(it).unwrap_or(b"(an object)")) .filter(|it| is_missing_plugin(it)) @@ -1258,15 +1282,22 @@ impl<'c> Configs<'c> { } let config = Config::new(path, &json, is_oxfmt); let of_overrides = config.overrides.iter().filter_map(|it| it.plugins.as_ref()); - if of_overrides - .chain([&config.plugins]) - .any(|it| !it.missing.is_empty()) + let mut missing: Vec<&[u8]> = Vec::new(); + for name in [&config.plugins] + .into_iter() + .chain(of_overrides) + .flat_map(|it| &it.missing) { - self.warn(&[ - b"\"plugins\" in ", - path, - b" has no effect because bun fmt does not load Prettier plugins", - ]); + if !missing.contains(&&name[..]) { + missing.push(name); + } + } + if !missing.is_empty() { + let end: &[u8] = match missing.len() { + 1 => b" has no effect because bun fmt does not include that plugin", + _ => b" have no effect because bun fmt does not include those plugins", + }; + self.warn(&[&missing.join(&b", "[..]), b" in ", path, end]); } Ok(Some(Arc::new(config))) } @@ -1548,6 +1579,20 @@ impl<'c> Configs<'c> { || (self.options.plugins.iter()).any(|it| is_plugin_for_classes(it)) } + /// See [`Oxc`], for the file at `path`, which has `scope` and the option `parser`. + fn oxc_for(&self, scope: &Scope, path: Option<&[u8]>, parser: Option<&[u8]>) -> Oxc { + let of_config = self + .plugins_for(scope, path) + .map_or(Oxc::Absent, |it| it.oxc); + match of_config { + _ if parser.is_some_and(|it| !it.starts_with(b"oxc")) => Oxc::Absent, + // The code in MDX is Babel's. + _ if path.is_some_and(|it| it.ends_with(b".mdx")) => Oxc::Absent, + Oxc::Absent => oxc_among(self.options.plugins.iter().map(|it| &it[..])), + oxc => oxc, + } + } + /// See [`Plugins::endings`], for the file at `path`, which has `scope`. fn endings_of_plugins<'s>( &'s self, @@ -1569,6 +1614,11 @@ impl<'c> Configs<'c> { (self.endings_of_plugins(scope, path)).any(|ending| path.ends_with(ending)) } + /// The same for a file that is formatted here too, without the plugin, if that is allowed. + pub(crate) fn is_changed_by_plugin(&self, scope: &Scope, path: &[u8]) -> bool { + !self.options.allow_unsupported && self.is_read_by_plugin(scope, path) + } + /// The plugin that adds the language of the file at `path`, which has `scope`, if Prettier reads it with one. pub(crate) fn plugin_that_reads(&self, scope: &Scope, path: &[u8]) -> Option<&'static [u8]> { PLUGINS_FOR_LANGUAGES @@ -1855,7 +1905,8 @@ impl<'c> Configs<'c> { }; if value != b"false" { let places = (set_by, directory); - let tailwind = tailwind::for_file(&self.classes, self.environment, &value, places); + let mut tailwind = tailwind::for_file(&self.classes, self.environment, &value, places); + tailwind.oxc = self.oxc_for(scope, path, resolved.options.parser.as_deref()); resolved.options.tailwind = Some(Arc::new(tailwind)); } Ok(resolved) diff --git a/src/lint/driver/fmt/mod.rs b/src/lint/driver/fmt/mod.rs index 2852a6f6e43c..796028382958 100644 --- a/src/lint/driver/fmt/mod.rs +++ b/src/lint/driver/fmt/mod.rs @@ -875,6 +875,7 @@ fn language_of(configs: &Configs, scope: &config::Scope, path: &[u8]) -> Languag } None => match files::language_of(path) { Language::Unknown if configs.is_read_by_plugin(scope, path) => Language::Other, + Language::Supported if configs.is_changed_by_plugin(scope, path) => Language::Other, Language::Unknown if files::is_toml(path) && configs.is_native_in(scope) => { Language::Supported } diff --git a/src/lint/driver/fmt/tailwind.rs b/src/lint/driver/fmt/tailwind.rs index e760edc17594..6d307b521b30 100644 --- a/src/lint/driver/fmt/tailwind.rs +++ b/src/lint/driver/fmt/tailwind.rs @@ -11,7 +11,7 @@ use crate::{fs, paths}; use bun_core::printer::json_stringify_alloc; use bun_core::strings; use bun_format::FormatOptions; -use bun_format::tailwind::{Names, Orders, Rank, Tailwind}; +use bun_format::tailwind::{Names, Orders, Oxc, Rank, Tailwind}; use bun_lint::linter::write_json; use bun_lint::options::Json; use bun_sema::util::FxHashMap; @@ -408,6 +408,7 @@ pub(crate) fn for_file( preserves_whitespace: is_on(b"preserveWhitespace"), preserves_duplicates: is_on(b"preserveDuplicates"), follows_plugin, + oxc: Oxc::Absent, orders: Box::new(OfGroup { classes: Arc::clone(classes), which, @@ -474,7 +475,7 @@ impl Classes { } /// Takes `sortTailwindcss` from `options`, which are for a file of this kind, if oxfmt does not sort in that language. The -/// plugin of Prettier sorts in LWC too. +/// plugin of Prettier sorts in LWC and in MDX too. pub(crate) fn only_where_sorted(options: &mut FormatOptions, kind: Option) { use bun_format::html::Parser; let is_sorted = match kind { @@ -486,7 +487,7 @@ pub(crate) fn only_where_sorted(options: &mut FormatOptions, kind: Option) | Kind::Handlebars | Kind::Html(Parser::Html | Parser::Vue | Parser::Angular), ) => true, - Some(Kind::Html(Parser::Lwc)) => { + Some(Kind::Html(Parser::Lwc) | Kind::Mdx) => { (options.tailwind.as_deref()).is_some_and(|it| it.follows_plugin) } Some(_) => false, diff --git a/src/lint/driver/format/excerpt.rs b/src/lint/driver/format/excerpt.rs index bffab9127acf..031cba05ca69 100644 --- a/src/lint/driver/format/excerpt.rs +++ b/src/lint/driver/format/excerpt.rs @@ -233,32 +233,43 @@ fn rules_of<'r>(problems: impl Iterator>) -> Option(problems: &[Problem<'r>], by_rule: bool) -> Vec>> { - let mut index: FxHashMap<(Rule, &[u8]), usize> = FxHashMap::default(); - // With `by_rule`: the group of a rule that is built in, by where the rule is. That saves reading its name for each problem. - let mut known: FxHashMap = FxHashMap::default(); + // A number for each rule, and that of a rule that is built in by where the rule is: its name is read once, not for each problem. + let mut numbers: FxHashMap = FxHashMap::default(); + let mut numbers_by_address: FxHashMap = FxHashMap::default(); + let mut index: FxHashMap<(u32, &[u8]), usize> = FxHashMap::default(); let mut groups: Vec> = Vec::new(); for problem in problems { - let address = match &problem.message.rule_id { - Some(RuleId::Known(meta)) if by_rule => Some(std::ptr::from_ref(*meta).addr()), - _ => None, + let mut number_of = || { + let next = numbers.len() as u32; + *numbers.entry(Rule::of(problem)).or_insert(next) }; - if let Some(at) = address.and_then(|it| known.get(&it)) { - groups[*at].push(*problem); - continue; - } - let key = match Rule::of(problem) { - Rule::None => (Rule::None, &problem.message.message[..]), - rule if by_rule => (rule, &b""[..]), - rule => (rule, &problem.message.message[..]), + let rule = match &problem.message.rule_id { + Some(RuleId::Known(meta)) => { + let address = std::ptr::from_ref(*meta).addr(); + match numbers_by_address.get(&address) { + Some(number) => *number, + None => { + let number = number_of(); + numbers_by_address.insert(address, number); + number + } + } + } + _ => number_of(), + }; + let key = match by_rule && problem.message.rule_id.is_some() { + true => (rule, &b""[..]), + false => (rule, &problem.message.message[..]), + }; + let at = match index.get(&key) { + Some(at) => *at, + None => { + groups.push(Vec::new()); + index.insert(key, groups.len() - 1); + groups.len() - 1 + } }; - let at = *index.entry(key).or_insert_with(|| { - groups.push(Vec::new()); - groups.len() - 1 - }); groups[at].push(*problem); - if let Some(address) = address { - known.insert(address, at); - } } bun_lint::utils::sort::sort_by_key(&mut groups, |group| { let is_first = by_rule && group[0].is_error(); diff --git a/src/lint/eslint/rules/no_irregular_whitespace.rs b/src/lint/eslint/rules/no_irregular_whitespace.rs index c28c092167c4..29d73ee914bc 100644 --- a/src/lint/eslint/rules/no_irregular_whitespace.rs +++ b/src/lint/eslint/rules/no_irregular_whitespace.rs @@ -20,6 +20,7 @@ enum Irregular { } /// What irregular whitespace character `text` starts with, and its length in bytes. +#[inline] fn irregular_at(text: &[u8]) -> Option<(Irregular, usize)> { Some(match text { [0x0B | 0x0C, ..] => (Irregular::Whitespace, 1), diff --git a/src/lint/jest/rules/jest_no_export.rs b/src/lint/jest/rules/jest_no_export.rs index b8d414d2e599..092093e52910 100644 --- a/src/lint/jest/rules/jest_no_export.rs +++ b/src/lint/jest/rules/jest_no_export.rs @@ -1,7 +1,7 @@ use crate::jest::{self, Ctx, JestFnKind, JestGeneralFnKind, Runner}; -use bun_lint_oxlint::import::is_type_export_declaration; -use bun_lint_oxlint::module_record::ModuleRecord; use bun_lint::prelude::*; +use bun_lint_oxlint::import::is_type_export_declaration; +use bun_lint_oxlint::module_record::{ModuleRecord, in_hash_order}; /// Prevents using exports if a file has one or more tests in it. pub struct NoExport; @@ -18,18 +18,27 @@ impl Rule for NoExport { } fn start<'a>(&self, file: &'a File<'a>) -> Option<()> { - (jest::is_for(file, RUNNER) && jest::mentions_test(file) && jest::is_jest_file(file)).then_some(()) + (jest::is_for(file, RUNNER) && jest::mentions_test(file) && jest::is_jest_file(file)) + .then_some(()) } fn finish(&self, cx: &mut Cx<'_, Self>) { - run_once(&Ctx { file: cx.file(), runner: Runner::of(RUNNER), report: &|at, message| cx.report(at, message) }); + run_once(&Ctx { + file: cx.file(), + runner: Runner::of(RUNNER), + report: &|at, message| cx.report(at, message), + }); } } fn run_once(ctx: &Ctx) { let file = ctx.file; let may_export = |it: Stmt| { - it.is_exported() || matches!(it.tag(), StmtTag::ExportStar | StmtTag::ExportDefault | StmtTag::ExportNamed) + it.is_exported() + || matches!( + it.tag(), + StmtTag::ExportStar | StmtTag::ExportDefault | StmtTag::ExportNamed + ) }; if !file.body().iter().any(may_export) { return; @@ -43,14 +52,32 @@ fn run_once(ctx: &Ctx) { // Who imports a type does not run the file. oxlint reports these too. let is_type = |at: Span| { ctx.runner == Runner::Bun - && file.body().iter().find(|it| it.span().contains(at)).is_some_and(|it| match it.kind() { - StmtKind::ExportNamed(export) => { - export.is_type_only() || export.items().iter().any(|it| it.is_type_only() && it.span().contains(at)) - } - _ => is_type_export_declaration(it), - }) + && file + .body() + .iter() + .find(|it| it.span().contains(at)) + .is_some_and(|it| match it.kind() { + StmtKind::ExportNamed(export) => { + export.is_type_only() + || export + .items() + .iter() + .any(|it| it.is_type_only() && it.span().contains(at)) + } + _ => is_type_export_declaration(it), + }) }; - for span in module_record.exported_bindings.values().copied().chain(module_record.export_default()) { + // The parser comes to them in the order of the source. oxlint goes through its hash map of them. + let bindings = module_record + .exported_bindings + .iter() + .map(|it| (*it.0, *it.1)); + let bindings = in_hash_order(utils::sort::in_order_of(bindings, |it| it.1.start)); + for span in bindings + .into_iter() + .map(|it| it.1) + .chain(module_record.export_default()) + { if !is_type(span) { ctx.report(span, NO_EXPORT); } diff --git a/src/lint/linter/config/mod.rs b/src/lint/linter/config/mod.rs index 281287de1be1..e00a801541a8 100644 --- a/src/lint/linter/config/mod.rs +++ b/src/lint/linter/config/mod.rs @@ -111,8 +111,61 @@ struct Pattern { is_universal: bool, /// What it matches ends with `.` and one of these, [packed](packed_extension). Empty: it cannot be told. extensions: SmallVec<[u64; 8]>, + /// It is `**/*.` and `extensions`, so it says the same about all files of a directory that have the same extension. + is_by_extension: bool, /// It ends with `/**`, so it says the same about all files of a directory. is_about_directory: bool, + /// Of `**/*{.,_}{test,spec}.{js,ts}`, a pattern that only asks what the name of a file ends with, and gives a choice more + /// than once: the parts after `**/*`, the last one first, each with what it can be. Otherwise empty. + ending: Vec; 8]>>, +} + +/// [`Pattern::ending`] of a pattern without a leading `!`. +fn ending_of(pattern: &[u8]) -> Vec; 8]>> { + let is_plain = |it: &u8| it.is_ascii_alphanumeric() || matches!(it, b'.' | b'_' | b'-'); + let is_text = |it: &[u8]| !it.is_empty() && it.iter().all(is_plain); + let (mut parts, mut choices) = (Vec::new(), 0); + let mut rest = pattern.strip_prefix(b"**/*").unwrap_or_default(); + while let Some(first) = rest.first() { + let len = match first { + b'{' => bun_core::strings::index_of_char_usize(rest, b'}').map_or(0, |end| end + 1), + _ => rest.iter().take_while(|it| is_plain(it)).count(), + }; + let (part, after) = rest.split_at(len); + let texts: SmallVec<[&[u8]; 8]> = match part { + [b'{', texts @ .., b'}'] => bun_core::strings::split(texts, b",").collect(), + text => smallvec::smallvec![text], + }; + // Braces around one text are no choice. + if !texts.iter().all(|it| is_text(it)) || (*first == b'{' && texts.len() < 2) { + return Vec::new(); + } + choices += usize::from(texts.len() > 1); + parts.push(texts.iter().map(|it| Box::from(*it)).collect()); + rest = after; + } + parts.reverse(); + match choices > 1 { + true => parts, + false => Vec::new(), + } +} + +/// Whether `name` ends with one text of each of `parts`, the last one first. +fn ends_with_all(name: &[u8], parts: &[SmallVec<[Box<[u8]>; 8]>]) -> bool { + let Some((last, before)) = parts.split_first() else { + return true; + }; + last.iter().any(|text| { + // They are a few bytes, and most differ in the last one. + name.len() >= text.len() + && name + .iter() + .rev() + .zip(text.iter().rev()) + .all(|(a, b)| a == b) + && ends_with_all(&name[..name.len() - text.len()], before) + }) } /// An extension of 8 bytes at most as a number. @@ -150,6 +203,10 @@ impl Pattern { }; let bangs = normalized.iter().take_while(|it| **it == b'!').count(); let says_what_it_matches = bangs == 0 && !normalized.starts_with(b"#"); + let extensions = match says_what_it_matches { + true => extensions_of(&normalized), + false => SmallVec::new(), + }; Pattern { matcher: bun_glob::Pattern::with_dots( &crate::glob::from_minimatch(&normalized[bangs..]), @@ -163,11 +220,15 @@ impl Pattern { || normalized.starts_with(b"!") || normalized.ends_with(b"/*") || normalized.ends_with(b"/**"), - extensions: match says_what_it_matches { - true => extensions_of(&normalized), - false => SmallVec::new(), - }, + is_by_extension: !extensions.is_empty() + && normalized.starts_with(b"**/*.") + && bun_core::strings::last_index_of_char(&normalized, b'.') == Some(4), + extensions, is_about_directory: says_what_it_matches && normalized.ends_with(b"/**"), + ending: match says_what_it_matches { + true => ending_of(&normalized), + false => Vec::new(), + }, } } @@ -191,8 +252,29 @@ impl Pattern { matches_without_slash: false, is_universal: true, extensions: SmallVec::new(), + is_by_extension: false, is_about_directory: false, + ending: Vec::new(), + } + } + + /// What `matcher` says about a file, where [`Pattern::ending`] tells. `**` doesn't go through a `.` or a `..`. + #[inline] + fn matches_by_ending(&self, path: &Candidate) -> Option { + if self.ending.is_empty() { + return None; } + let (name, path_len) = (path.name(), path.path().len()); + let answer = match ends_with_all(name, &self.ending) { + true if matches!(name, b"." | b"..") => None, + true => { + let before = path.path().get(..path_len - name.len())?; + (!bun_core::strings::contains(before, b"./")).then_some(true) + } + false => Some(false), + }; + debug_assert!(answer.is_none_or(|it| it == self.matcher.matches_candidate(path))); + answer } /// `flip_negate`: a `!` at the start of the pattern is left out of the answer. Only ESLint asks for that. @@ -202,7 +284,9 @@ impl Pattern { self.matches_without_slash && self.matcher.matches_candidate(&directory) || self.matcher.matches_candidate(path) } - None => self.matcher.matches_candidate(path), + None => self + .matches_by_ending(path) + .unwrap_or_else(|| self.matcher.matches_candidate(path)), }; !self.is_comment && hit != (self.is_inverted && !flip_negate) } @@ -358,6 +442,26 @@ struct Heads { without_extension: OfExtension, /// A bit for each object that is [about directories](ConfigObject::is_about_directories). about_directories: Bits, + /// The patterns in `files` that look at more of the name of a file than its extension. `None`: the objects that are for a + /// file don't follow from what these say about it, its extension and its directory, or there are more than 64. + by_name: Option>, +} + +/// Where a pattern is: in which element of `files` of which object. +#[derive(Copy, Clone)] +struct Place { + object: u32, + element: u32, + pattern: u32, +} + +/// What [`Heads::by_name`] say about a file, a bit for each, with its [extension](extension_of). +type SaidOfName = (Option, u64); + +/// The extension of the file at `path`, [packed](packed_extension). A `/` in it is in no extension that a pattern has. +fn extension_of(path: &[u8]) -> Option { + let dot = bun_core::strings::last_index_of_char(path, b'.')?; + packed_extension(path.get(dot + 1..)?) } type Bits = SmallVec<[u64; 4]>; @@ -452,14 +556,47 @@ impl Heads { for (at, _) in (objects.iter().enumerate()).filter(|it| it.1.is_about_directories()) { heads.about_directories[at / 64] |= 1 << (at % 64); } + let (mut by_name, mut follows) = (Vec::new(), true); + for (at, object) in objects.iter().enumerate() { + follows &= object.ignores.is_none() || object.is_global_ignores; + for (element, patterns) in object.files.iter().flatten().enumerate() { + let looks_at_name = |it: &&Pattern| !it.is_by_extension && !it.is_about_directory; + for (pattern, _) in patterns + .iter() + .enumerate() + .filter(|it| looks_at_name(&it.1)) + { + // It would have to be asked about another path. + follows &= object.base_path.is_none(); + by_name.push(Place { + object: at as u32, + element: element as u32, + pattern: pattern as u32, + }); + } + } + } + heads.by_name = (follows && by_name.len() <= 64).then_some(by_name); heads } + /// Two files of a directory for which this is the same have the same objects. + fn said_of_name(&self, objects: &[ConfigObject], path: &Candidate) -> Option { + let extension = extension_of(path.path()); + let mut said = 0; + for (bit, place) in self.by_name.as_ref()?.iter().enumerate() { + let files = objects.get(place.object as usize)?.files.as_ref()?; + let pattern = (files.get(place.element as usize)?).get(place.pattern as usize)?; + let can_match = pattern.extensions.is_empty() + || extension.is_some_and(|it| pattern.extensions.contains(&it)); + said |= u64::from(can_match && pattern.matches(path, false)) << bit; + } + Some((extension, said)) + } + /// What can match `path`, going by its extension. fn of_extension(&self, path: &[u8]) -> &OfExtension { - // A `/` in it is in no extension that is known. - let extension = bun_core::strings::last_index_of_char(path, b'.') - .and_then(|at| packed_extension(path.get(at + 1..)?)); + let extension = extension_of(path); let mut known = self.by_extension.iter(); known .find(|it| Some(it.0) == extension) @@ -575,6 +712,7 @@ pub struct Within<'c> { /// The objects that were merged last, and what they make. merged: Option<(Indices, Arc)>, said: Said, + known: Known, /// [`Within::outside_of_react`] is_outside_of_react: bool, } @@ -583,11 +721,15 @@ pub struct Within<'c> { /// the directory, and whether that makes ESLint lint them. type Said = std::cell::RefCell>; +/// [`Config::matching`] for the files of the directory, by [`Heads::said_of_name`]. +type Known = std::cell::RefCell)>>; + /// An entry of the directory of a [`Within`]. struct Entry<'w> { name: &'w [u8], seen_by_objects: &'w [(u32, BeforeName)], said: &'w Said, + known: &'w Known, } impl Entry<'_> { @@ -635,6 +777,7 @@ impl Within<'_> { name, seen_by_objects: &self.seen.by_objects, said: &self.said, + known: &self.known, }, )) } @@ -698,7 +841,27 @@ impl Within<'_> { if config.is_ignored_globally_as(&parts, Some(&entry), dotfiles) { return FileConfig::Ignored; } - config.matching(path, &parts, Some(&entry), must_match) + let matching = || config.matching(path, &parts, Some(&entry), must_match); + let said = match must_match { + true => config.heads.said_of_name(&config.objects, &parts), + false => None, + }; + let find = |said: SaidOfName| { + let known = entry.known.borrow(); + known.iter().find(|it| it.0 == said).map(|it| it.1.clone()) + }; + match (said, said.and_then(find)) { + (_, Some(known)) => { + debug_assert_eq!(known, matching()); + known + } + (Some(said), None) => { + let made = matching(); + entry.known.borrow_mut().push((said, made.clone())); + made + } + (None, None) => matching(), + } }; let Some(mut matching) = matching else { return FileConfig::Unconfigured; @@ -977,6 +1140,7 @@ impl Config { relative: seen.before_name.clone().unwrap_or_default(), merged: None, said: Said::default(), + known: Known::default(), is_outside_of_react: false, } } diff --git a/src/lint/oxlint/module_record.rs b/src/lint/oxlint/module_record.rs index a5ae7eb242fe..4c2151431c60 100644 --- a/src/lint/oxlint/module_record.rs +++ b/src/lint/oxlint/module_record.rs @@ -148,11 +148,22 @@ pub fn in_hash_order<'a, T>(requested: Vec<(Name<'a>, T)>) -> Vec<(Name<'a>, T)> of_parser .iter() .for_each(|(hash, at)| collected.insert(hash, at)); - let mut requested: Vec, T)>> = requested.into_iter().map(Some).collect(); - collected - .iter() - .filter_map(|at| requested.get_mut(at)?.take()) - .collect() + picked(requested, collected.iter()) +} + +/// `entries`, which are in the order in which their names get into an `FxHashMap` of oxlint, in the order of that map. +pub fn in_order_of_map<'a, T>(entries: Vec<(Name<'a>, T)>) -> Vec<(Name<'a>, T)> { + let mut map = HashOrder::new(); + for (at, (name, _)) in entries.iter().enumerate() { + map.insert(hash_of_str(name.bytes()), at); + } + picked(entries, map.iter()) +} + +/// Those of `all` at `places`, each of which comes once. +fn picked(all: Vec, places: impl Iterator) -> Vec { + let mut all: Vec> = all.into_iter().map(Some).collect(); + places.filter_map(|at| all.get_mut(at)?.take()).collect() } impl<'a> ModuleRecord<'a> { diff --git a/src/lint/oxlint_order.rs b/src/lint/oxlint_order.rs index 7722846bc380..64830ef9d14d 100644 --- a/src/lint/oxlint_order.rs +++ b/src/lint/oxlint_order.rs @@ -142,7 +142,11 @@ fn once(rule: &Meta) -> Once { let as_reported = match rule.plugin.in_oxlint() { Plugin::Eslint if rule.name == "max-statements" => return Once::ByEnd, Plugin::Eslint => rule.name == "no-undef", - Plugin::Import => matches!(rule.name, "extensions" | "no-duplicates" | "no-self-import"), + Plugin::Import => matches!( + rule.name, + "extensions" | "group-exports" | "no-duplicates" | "no-self-import" + ), + Plugin::Jest => rule.name == "no-export", Plugin::Node => rule.name == "exports-style", Plugin::React => rule.name == "only-export-components", _ => false, diff --git a/src/lint/oxlint_order/visited.rs b/src/lint/oxlint_order/visited.rs index 09d66a1d2a26..9a0dbe3d9e6e 100644 --- a/src/lint/oxlint_order/visited.rs +++ b/src/lint/oxlint_order/visited.rs @@ -55,6 +55,7 @@ struct Known { switch_case: u8, // What has no node here. chain_expression: u8, + ts_type_annotation: u8, export_default_declaration: u8, export_declaration: u8, function_body: u8, @@ -242,6 +243,7 @@ static KNOWN: LazyLock = LazyLock::new(|| { array_pattern: kind("ArrayPattern"), switch_case: kind("SwitchCase"), chain_expression: kind("ChainExpression"), + ts_type_annotation: kind("TSTypeAnnotation"), export_default_declaration: kind("ExportDefaultDeclaration"), export_declaration: kind("ExportDeclaration"), function_body: kind("FunctionBody"), @@ -433,6 +435,17 @@ impl Known { { return Some(found); } + // The `: T` of a variable, a parameter or a property, or of what a function returns. + if kinds.has(self.ts_type_annotation) + && let Node::Type(ty) = node + && matches!( + ty.parent(), + Node::Param(_) | Node::VarDecl(_) | Node::Pat(_) | Node::Member(_) | Node::Func(_) + ) + && let Some(found) = found(ty.annotation_span(), CHAIN, false) + { + return Some(found); + } if !kinds.has(self.export_declaration) && !kinds.has(self.export_default_declaration) { return None; } @@ -448,6 +461,7 @@ impl Known { // Which of the nodes of oxc that a node here stands for, from the outside in. const EXPORT: u8 = 0; +/// A `ChainExpression`, or the annotation that a type is in. const CHAIN: u8 = 1; const ITSELF: u8 = 2; /// A body, or the opening tag. diff --git a/src/lint/plugins/oxlint/exhaustive_deps.rs b/src/lint/plugins/oxlint/exhaustive_deps.rs index 5e8319ecad8c..e4916fe6038c 100644 --- a/src/lint/plugins/oxlint/exhaustive_deps.rs +++ b/src/lint/plugins/oxlint/exhaustive_deps.rs @@ -404,28 +404,39 @@ fn order_in_table<'d, 'a>( table.iter().collect() } -/// Of `candidates`, which are positions in `declared`, the one that oxlint comes to first: it has them in a hash table. -fn first_of_oxlint<'a>( - candidates: &[usize], +/// The place of each of `declared` in the hash table in which oxlint has them. +fn places_in_table<'a>( declared: &[(Dependency<'a>, u32)], file: &'a File<'a>, memo: &mut Memo<'a>, -) -> Option { - if candidates.len() < 2 { - return candidates.first().copied(); - } - let mut is_candidate = vec![false; declared.len()]; - for at in candidates { - if let Some(it) = is_candidate.get_mut(*at) { - *it = true; - } - } +) -> Vec { let symbols = memo .places_of_symbols .get_or_insert_with(|| places_of_symbols(file)); let mut inserted = (declared.iter().enumerate()).map(|(at, it)| (&it.0, at as u32)); - let order = order_in_table(&mut inserted, symbols); - (order.into_iter().map(|at| at as usize)).find(|at| is_candidate.get(*at) == Some(&true)) + let mut places = vec![0; declared.len()]; + for (place, at) in order_in_table(&mut inserted, symbols) + .into_iter() + .enumerate() + { + if let Some(it) = places.get_mut(at as usize) { + *it = place; + } + } + places +} + +/// Puts `positions`, which are in `declared`, in the order in which oxlint comes to them. +fn sort_as_oxlint<'a>( + positions: &mut [usize], + declared: &[(Dependency<'a>, u32)], + file: &'a File<'a>, + memo: &mut Memo<'a>, +) { + if positions.len() > 1 { + let places = places_in_table(declared, file, memo); + utils::sort::sort_unstable_by_key(positions, |at| places.get(*at).copied()); + } } /// A dependency that is hashed and compared as oxlint's `Dependency`. @@ -1223,7 +1234,8 @@ pub(crate) fn run<'a, R: Rule>( } outer.push(at); } - let first_outer = first_of_oxlint(&outer, &declared, cx.file(), memo); + sort_as_oxlint(&mut outer, &declared, cx.file(), memo); + let first_outer = outer.first().copied(); for (at, (dependency, _)) in outer .iter() .filter_map(|at| Some((*at, declared.get(*at)?))) @@ -1288,15 +1300,21 @@ pub(crate) fn run<'a, R: Rule>( ); } - for (dependency, _) in &declared { - if let Some(Declared::Variable(declarator)) = dependency.symbol.and_then(declaration_of) - && let Some(init) = declarator.init().filter(|it| !it.is_parenthesized()) - && let ExprKind::Call(call) = init.kind() - && func_call_without_react_namespace(call).is_some_and(|it| it.is("useEffectEvent")) - { - cx.report(dependency.span, USE_EFFECT_EVENT) - .called_with(node); - } + let is_effect_event = |dependency: &Dependency<'a>| { + matches!(dependency.symbol.and_then(declaration_of), Some(Declared::Variable(declarator)) + if declarator.init().filter(|it| !it.is_parenthesized()).is_some_and(|init| { + matches!(init.kind(), ExprKind::Call(call) + if func_call_without_react_namespace(call).is_some_and(|it| it.is("useEffectEvent"))) + })) + }; + let mut effect_events: Vec = (declared.iter().enumerate()) + .filter(|(_, it)| is_effect_event(&it.0)) + .map(|it| it.0) + .collect(); + sort_as_oxlint(&mut effect_events, &declared, cx.file(), memo); + for (dependency, _) in effect_events.iter().filter_map(|at| declared.get(*at)) { + cx.report(dependency.span, USE_EFFECT_EVENT) + .called_with(node); } // Effects may have more dependencies than they need. @@ -1316,31 +1334,47 @@ pub(crate) fn run<'a, R: Rule>( .filter_map(|it| (it.declared as usize).checked_sub(1)); pairs.extend(above.map(|other| (at.min(other), at.max(other), at))); } + // oxlint goes through the pairs of its hash table. + if pairs.len() > 1 { + let places = places_in_table(&declared, cx.file(), memo); + let place = |at: usize| places.get(at).copied().unwrap_or(0); + for it in &mut pairs { + let (first, second) = (place(it.0), place(it.1)); + (it.0, it.1) = (first.min(second), first.max(second)); + } + } utils::sort::sort_unstable(&mut pairs); for (dependency, _) in pairs.iter().filter_map(|it| declared.get(it.2)) { unnecessary(dependency); } - for (dependency, number) in declared.iter().filter(|it| !paths.get(it.1).is_found) { - let is_needed = paths.get(*number).is_found_within - || paths.get(*number).is_undeclared + let is_needed = |number: u32| { + paths.get(number).is_found + || paths.get(number).is_found_within + || paths.get(number).is_undeclared || paths - .above(*number) - .any(|it| it.is_undeclared || it.declared != 0); - if !is_needed { - unnecessary(dependency); - } + .above(number) + .any(|it| it.is_undeclared || it.declared != 0) + }; + let mut not_needed: Vec = (declared.iter().enumerate()) + .filter(|(_, it)| !is_needed(it.1)) + .map(|it| it.0) + .collect(); + sort_as_oxlint(&mut not_needed, &declared, cx.file(), memo); + for (dependency, _) in not_needed.iter().filter_map(|at| declared.get(*at)) { + unnecessary(dependency); } } let is_unique = |it: &Dependency<'a>| { it.chain.is_empty() && it.symbol.is_some_and(is_declaration_referentially_unique) }; - let changing: Vec = (declared.iter().enumerate()) + let mut changing: Vec = (declared.iter().enumerate()) .filter(|(_, it)| is_unique(&it.0)) .map(|it| it.0) .collect(); - let first_changing = first_of_oxlint(&changing, &declared, cx.file(), memo) - .filter(|_| outer.is_empty() && undeclared.is_empty()); + sort_as_oxlint(&mut changing, &declared, cx.file(), memo); + let first_changing = + (changing.first().copied()).filter(|_| outer.is_empty() && undeclared.is_empty()); for (at, (dependency, _)) in changing .iter() .filter_map(|at| Some((*at, declared.get(*at)?))) diff --git a/src/lint/plugins/rules/import_group_exports.rs b/src/lint/plugins/rules/import_group_exports.rs index f45f379846be..ec470cf385c7 100644 --- a/src/lint/plugins/rules/import_group_exports.rs +++ b/src/lint/plugins/rules/import_group_exports.rs @@ -1,10 +1,11 @@ +use bun_lint::prelude::*; +use bun_lint::rule::Plugin; use bun_lint_oxlint::ast_util::{is_specific_id, static_property_name}; use bun_lint_oxlint::import::{ - AssignmentTargets, export_declaration_span, is_assignment_target, is_export_declaration, is_type_export_declaration, - module_items, + AssignmentTargets, export_declaration_span, is_assignment_target, is_export_declaration, + is_type_export_declaration, module_items, }; -use bun_lint::prelude::*; -use bun_lint::rule::Plugin; +use bun_lint_oxlint::module_record::in_order_of_map; use rustc_hash::FxHashMap; use smallvec::SmallVec; @@ -50,8 +51,14 @@ impl Rule for GroupExports { } fn expr<'a>(&self, e: Expr<'a>, cx: &mut Cx<'a, Self>) { - let is_export = if cx.language().is_oxlint { is_commonjs_export } else { has_exporting_accessor_chain }; - if e.left().is_some_and(is_export) && !is_assignment_target(e, &mut cx.state.assignment_targets) { + let is_export = if cx.language().is_oxlint { + is_commonjs_export + } else { + has_exporting_accessor_chain + }; + if e.left().is_some_and(is_export) + && !is_assignment_target(e, &mut cx.state.assignment_targets) + { cx.state.commonjs_exports.push(e.span()); } } @@ -59,7 +66,9 @@ impl Rule for GroupExports { fn finish<'a>(&self, cx: &mut Cx<'a, Self>) { // Of values, of types. let mut nodes: [Spans; 2] = [Spans::new(), Spans::new()]; - let mut source_records: [FxHashMap, Spans>; 2] = [FxHashMap::default(), FxHashMap::default()]; + // Those of each source, in the order in which the sources come, and where each source is. + let mut source_records: [Vec<(Name<'a>, Spans)>; 2] = [Vec::new(), Vec::new()]; + let mut places: FxHashMap<(usize, Name<'a>), usize> = FxHashMap::default(); let is_oxlint = cx.language().is_oxlint; for stmt in module_items(cx.file()) { match stmt.kind() { @@ -68,24 +77,39 @@ impl Rule for GroupExports { match export.spec().filter(|_| export.has_from()) { // The original keeps them by name in an object, of which that is no key. oxlint has a map. Some(source) if !is_oxlint && source.is("__proto__") => {} - Some(source) => source_records[kind].entry(source).or_default().push(stmt.span()), + Some(source) => { + let records = &mut source_records[kind]; + let at = *places.entry((kind, source)).or_insert(records.len()); + if at == records.len() { + records.push((source, Spans::new())); + } + records[at].1.push(stmt.span()); + } None => nodes[kind].push(stmt.span()), } } _ if is_export_declaration(stmt) => { - nodes[usize::from(is_type_export_declaration(stmt))].push(export_declaration_span(stmt)); + nodes[usize::from(is_type_export_declaration(stmt))] + .push(export_declaration_span(stmt)); } _ => {} } } - let groups = nodes.iter().chain(source_records.iter().flat_map(|it| it.values())); - for span in groups.filter(|it| it.len() > 1).flatten() { + // oxlint reports one sort after the other, and those with a source in the order of its hash map. + for span in nodes.iter().filter(|it| it.len() > 1).flatten() { cx.report(*span, ES_MODULE); } - if cx.state.commonjs_exports.len() > 1 { - for span in &cx.state.commonjs_exports { - cx.report(*span, COMMONJS); - } + let mut commonjs_exports = std::mem::take(&mut cx.state.commonjs_exports); + utils::sort::sort_unstable_by_key(&mut commonjs_exports, |it| it.start); + for span in commonjs_exports + .iter() + .filter(|_| commonjs_exports.len() > 1) + { + cx.report(*span, COMMONJS); + } + let of_sources = source_records.into_iter().flat_map(in_order_of_map); + for span in of_sources.filter(|it| it.1.len() > 1).flat_map(|it| it.1) { + cx.report(span, ES_MODULE); } } } @@ -96,10 +120,14 @@ fn has_exporting_accessor_chain(left: Expr) -> bool { // From the last to the first. `None`: what is in the brackets is no name. let mut chain: SmallVec<[Option<&[u8]>; 5]> = SmallVec::new(); let mut node = left; - while chain.len() <= 3 && let Some(object) = node.object() { + while chain.len() <= 3 + && let Some(object) = node.object() + { chain.push(match node.index() { Some(index) => index.as_ident().map(Name::bytes), - None => node.member_name().map(|it| it.bytes().strip_prefix(b"#").unwrap_or_else(|| it.bytes())), + None => node + .member_name() + .map(|it| it.bytes().strip_prefix(b"#").unwrap_or_else(|| it.bytes())), }); if let Some(name) = object.as_ident() { chain.push(Some(name.bytes())); @@ -112,7 +140,9 @@ fn has_exporting_accessor_chain(left: Expr) -> bool { } matches!( chain[..], - [_, Some(b"exports")] | [Some(b"exports"), Some(b"module")] | [_, Some(b"exports"), Some(b"module")] + [_, Some(b"exports")] + | [Some(b"exports"), Some(b"module")] + | [_, Some(b"exports"), Some(b"module")] ) } @@ -127,5 +157,8 @@ fn is_commonjs_export(left: Expr) -> bool { } fn check_module_export(member: Expr) -> bool { - static_property_name(member).is_some_and(|it| it.is("exports")) && member.object().is_some_and(|it| is_specific_id(it, "module")) + static_property_name(member).is_some_and(|it| it.is("exports")) + && member + .object() + .is_some_and(|it| is_specific_id(it, "module")) } diff --git a/src/lint/plugins/rules/import_newline_after_import.rs b/src/lint/plugins/rules/import_newline_after_import.rs index ac13421feff9..40cdcc5c2895 100644 --- a/src/lint/plugins/rules/import_newline_after_import.rs +++ b/src/lint/plugins/rules/import_newline_after_import.rs @@ -1,9 +1,9 @@ use crate::module_visitor::static_require; -use bun_lint_oxlint::ast_util::{is_decorator_expression, is_global_reference_name}; use bun_core::strings; use bun_lint::prelude::*; use bun_lint::rule::Plugin; use bun_lint::utils::ancestor_memo::AncestorMemo; +use bun_lint_oxlint::ast_util::{is_decorator_expression, is_global_reference_name}; /// Enforce a newline after import statements. pub struct NewlineAfterImport { @@ -17,18 +17,30 @@ const NEWLINE_AFTER_IMPORT: Message = Message::new( "Expected {{count}} empty line{{line_suffix}} after {{keyword}} statement not followed by another {{keyword}}.", ); +/// What a line is to follow. +#[derive(Copy, Clone)] +enum After<'a> { + Import, + /// The last call in its statement. + Require(Expr<'a>), +} + #[derive(Default)] pub struct State<'a> { is_oxlint: bool, - /// Of each `require("a")` which counts: where its statement at the top level starts, and where the call ends. - requires: Vec<(u32, u32)>, + /// Each `require("a")` which counts, with where its statement at the top level starts. + requires: Vec<(u32, Expr<'a>)>, /// That something is in a function, a block, an object literal or a decorator. hidden: AncestorMemo<'a, ()>, } impl Rule for NewlineAfterImport { - const META: Meta = Meta::plugin(Plugin::Import, "newline-after-import", Kind::Layout).fixable(Fixable::Whitespace); - const ON: On = On::new().exprs(&[ExprTag::Call]).stmts(&[StmtTag::Import, StmtTag::ImportEquals]).finish(); + const META: Meta = Meta::plugin(Plugin::Import, "newline-after-import", Kind::Layout) + .fixable(Fixable::Whitespace); + const ON: On = On::new() + .exprs(&[ExprTag::Call]) + .stmts(&[StmtTag::Import, StmtTag::ImportEquals]) + .finish(); type State<'a> = State<'a>; fn new(options: &Options) -> Self { @@ -42,18 +54,25 @@ impl Rule for NewlineAfterImport { fn narrow<'a>(&self, file: &'a File<'a>) -> On { let on = On::new().stmts(&[StmtTag::Import, StmtTag::ImportEquals]); - if file.mentions("require") { on.exprs(&[ExprTag::Call]).finish() } else { on } + if file.mentions("require") { + on.exprs(&[ExprTag::Call]).finish() + } else { + on + } } fn start<'a>(&self, file: &'a File<'a>) -> Option> { - Some(State { is_oxlint: file.language().is_oxlint, ..State::default() }) + Some(State { + is_oxlint: file.language().is_oxlint, + ..State::default() + }) } fn expr<'a>(&self, e: Expr<'a>, cx: &mut Cx<'a, Self>) { if is_top_level_static_require_call(e, cx.state.is_oxlint, &mut cx.state.hidden) && let Some(stmt) = cx.file().body().around(e.span().start) { - cx.state.requires.push((stmt.span().start, e.span().end)); + cx.state.requires.push((stmt.span().start, e)); } } @@ -72,13 +91,20 @@ impl Rule for NewlineAfterImport { _ => return, }; let next = siblings.after(stmt.span().start); - self.check(stmt, "import", next, next.is_some_and(is_import_statement), comments_from, cx); + self.check( + stmt, + After::Import, + next, + next.is_some_and(is_import_statement), + comments_from, + cx, + ); } fn finish(&self, cx: &mut Cx<'_, Self>) { let body = cx.file().body(); let mut requires = std::mem::take(&mut cx.state.requires); - requires.sort_unstable(); + utils::sort::sort_unstable_by_key(&mut requires, |it| (it.0, it.1.span().end)); // Of each statement the last call. requires.dedup_by(|call, kept| { let is_in_same_statement = call.0 == kept.0; @@ -87,14 +113,24 @@ impl Rule for NewlineAfterImport { } is_in_same_statement }); - for (index, &(start, call_end)) in requires.iter().enumerate() { + for (index, &(start, call)) in requires.iter().enumerate() { let (Some(stmt), Some(next)) = (body.around(start), body.after(start)) else { continue; }; - let next_is_same_kind = requires.get(index + 1).is_some_and(|it| it.0 == next.span().start); + let next_is_same_kind = requires + .get(index + 1) + .is_some_and(|it| it.0 == next.span().start); // oxlint looks for a comment between two statements that require. if !next_is_same_kind || cx.state.is_oxlint { - self.check(stmt, "require", Some(next), next_is_same_kind, Some(call_end), cx); + let call_end = Some(call.span().end); + self.check( + stmt, + After::Require(call), + Some(next), + next_is_same_kind, + call_end, + cx, + ); } } } @@ -105,8 +141,15 @@ fn is_import_statement(stmt: Stmt) -> bool { } /// `require("a")` that is in no function, block, object literal or decorator. -fn is_top_level_static_require_call<'a>(e: Expr<'a>, is_oxlint: bool, hidden: &mut AncestorMemo<'a, ()>) -> bool { - let Some((call, argument)) = e.as_call().and_then(|call| Some((call, static_require(call)?))) else { +fn is_top_level_static_require_call<'a>( + e: Expr<'a>, + is_oxlint: bool, + hidden: &mut AncestorMemo<'a, ()>, +) -> bool { + let Some((call, argument)) = e + .as_call() + .and_then(|call| Some((call, static_require(call)?))) + else { return false; }; // oxlint takes nothing in parentheses, and no `require` that is declared. @@ -118,10 +161,16 @@ fn is_top_level_static_require_call<'a>(e: Expr<'a>, is_oxlint: bool, hidden: &m Node::Func(func) => func.kind() != FnKind::StaticBlock && (is_oxlint || func.has_body()), Node::Stmt(stmt) => matches!(stmt.kind(), StmtKind::Block(_)), Node::Expr(e) => e.tag() == ExprTag::Object && (is_oxlint || !e.is_assignment_target()), - Node::Class(_) | Node::Member(_) => matches!(child, Node::Expr(e) if is_decorator_expression(e)), + Node::Class(_) | Node::Member(_) => { + matches!(child, Node::Expr(e) if is_decorator_expression(e)) + } _ => false, }; - hidden.find(Node::Expr(e), |child, parent| hides(child, parent).then_some(())).is_none() + hidden + .find(Node::Expr(e), |child, parent| { + hides(child, parent).then_some(()) + }) + .is_none() && (!is_oxlint || is_global_reference_name(call.callee(), "require")) } @@ -139,7 +188,7 @@ impl NewlineAfterImport { fn check<'a>( &self, stmt: Stmt<'a>, - keyword: &'static str, + after: After<'a>, next: Option>, next_is_same_kind: bool, comments_from: Option, @@ -147,7 +196,10 @@ impl NewlineAfterImport { ) { let (file, is_oxlint) = (cx.file(), cx.state.is_oxlint); // ESLint's node starts at the `export`, after the decorators. - let node = stmt.export_span().filter(|_| !is_oxlint).unwrap_or_else(|| stmt.span()); + let node = stmt + .export_span() + .filter(|_| !is_oxlint) + .unwrap_or_else(|| stmt.span()); let next_start = next.map(next_statement_start); let line_difference = |to: u32| match is_oxlint { // oxlint counts the line feeds. @@ -156,16 +208,24 @@ impl NewlineAfterImport { }; let expected_line_diff = (self.count as i64).saturating_add(1); // The first: the others are further away. - let comment = comments_from.filter(|_| self.consider_comments).and_then(|from| { - if is_oxlint { - // oxlint looks between the two statements. - let first = file.comments_in(Span::after(node, next_start?)).next()?.start(); - return (line_difference(first) <= expected_line_diff).then_some(first); - } - let line = file.line_of(from); - let first = file.comments_in(file.line_span(line).to(file.span())).next()?.start(); - (i64::from(file.line_of(first) - line) <= expected_line_diff).then_some(first) - }); + let comment = comments_from + .filter(|_| self.consider_comments) + .and_then(|from| { + if is_oxlint { + // oxlint looks between the two statements. + let first = file + .comments_in(Span::after(node, next_start?)) + .next()? + .start(); + return (line_difference(first) <= expected_line_diff).then_some(first); + } + let line = file.line_of(from); + let first = file + .comments_in(file.line_span(line).to(file.span())) + .next()? + .start(); + (i64::from(file.line_of(first) - line) <= expected_line_diff).then_some(first) + }); let Some(to) = comment.or_else(|| next_start.filter(|_| !next_is_same_kind)) else { return; }; @@ -174,10 +234,18 @@ impl NewlineAfterImport { if line_diff >= expected_line_diff && !is_too_far { return; } - let report = match is_oxlint { - true => cx.report(node, NEWLINE_AFTER_IMPORT), + let keyword = match after { + After::Import => "import", + After::Require(_) => "require", + }; + let report = match (is_oxlint, after) { + (true, After::Import) => cx.report(node, NEWLINE_AFTER_IMPORT), + (true, After::Require(call)) => cx.report(node, NEWLINE_AFTER_IMPORT).called_with(call), // ESLint is given the line on which it ends, and the column at which it starts if it starts there, else 0. - false => cx.report_at(node.start.max(file.line_span(file.line_of(node.end)).start), NEWLINE_AFTER_IMPORT), + (false, _) => cx.report_at( + node.start.max(file.line_span(file.line_of(node.end)).start), + NEWLINE_AFTER_IMPORT, + ), }; let mut report = report .data("count", self.count) @@ -188,13 +256,17 @@ impl NewlineAfterImport { } if is_oxlint { // What oxlint calls its fix. - report = report.help(match keyword { - "import" => "Add empty line(s) after import", - _ => "Add empty line(s) after require", + report = report.help(match after { + After::Import => "Add empty line(s) after import", + After::Require(_) => "Add empty line(s) after require", }); } // The calls of `require` are looked at when the program ends. - let at = if keyword == "require" { Span::empty(0) } else { node }; + let at = if matches!(after, After::Require(_)) { + Span::empty(0) + } else { + node + }; let Some(missing) = cx.repeat_count((expected_line_diff - line_diff) as f64, at) else { return; }; diff --git a/src/sema/messages.rs b/src/sema/messages.rs index ab2d822b31b6..4a6b4efa3ed1 100644 --- a/src/sema/messages.rs +++ b/src/sema/messages.rs @@ -35,31 +35,31 @@ pub fn text(bytes: &[u8]) -> String { /// The category and the text of the message with `code`. `{0}`, `{1}`, ... are placeholders for its /// arguments. pub fn message(code: u32) -> Option<(Category, &'static str)> { - static MESSAGES: bun_core::Once> = bun_core::Once::new(); + static MESSAGES: bun_core::Once> = bun_core::Once::new(); let messages = MESSAGES.get_or_init(parse); let at = messages.binary_search_by_key(&code, |m| m.0).ok()?; - let [_, category, _, text @ ..] = messages[at].1 else { - return None; - }; - let category = match category { - b'E' => Category::Error, - _ => Category::Message, - }; - Some((category, core::str::from_utf8(text).ok()?)) + messages.get(at).map(|m| (m.1, m.2)) } -/// Each line's code, and the rest of the line from the first tab on. -fn parse() -> Vec<(u32, &'static [u8])> { - let mut rest: &[u8] = +fn parse() -> Vec<(u32, Category, &'static str)> { + let bytes: &'static [u8] = bun_zstd::embed_compressed!("src/sema/messages.txt", include_bytes!("messages.txt")); - let mut messages = Vec::new(); - while let Some((line, after)) = strings::split_once_char(rest, b'\n') { + let all = core::str::from_utf8(bytes).unwrap_or_default(); + let (mut messages, mut start) = (Vec::new(), 0); + while let Some(len) = strings::index_of_char_usize(&bytes[start..], b'\n') { + let line = &bytes[start..start + len]; let digits = line.iter().take_while(|b| b.is_ascii_digit()).count(); let code = line[..digits] .iter() .fold(0, |n, b| n * 10 + u32::from(b - b'0')); - messages.push((code, &line[digits..])); - rest = after; + let category = match line.get(digits + 1) { + Some(b'E') => Category::Error, + _ => Category::Message, + }; + if let Some(text) = all.get(start + digits + 3..start + len) { + messages.push((code, category, text)); + } + start += len + 1; } messages } diff --git a/test/cli/format/format.test.ts b/test/cli/format/format.test.ts index 0a7dd9aa3142..135b1bf985ab 100644 --- a/test/cli/format/format.test.ts +++ b/test/cli/format/format.test.ts @@ -1572,6 +1572,46 @@ exports.format = async (text, options) => { timeout, ); + // The order of prettier-plugin-packagejson is that of the package sort-package-json, not that of `sortPackageJson`. + test( + "so does a package.json with prettier-plugin-packagejson, and no other file", + async () => { + const plugin = "prettier-plugin-packagejson"; + const project = { + ...packages, + [`node_modules/${plugin}/package.json`]: `{ "name": "${plugin}", "version": "3.0.0" }`, + ".prettierrc": `{ "plugins": ["${plugin}"], "packageSortOrder": ["version"] }\n`, + "package.json": '{ "a": 1 }\n', + "packages/b/package.json": '{ "b": 1 }\n', + "my-package.json": '{ "c": 1 }\n', + "d.js": "d ;\n", + }; + const names = ["package.json", "packages/b/package.json", "my-package.json", "d.js"]; + const result = await format(project, [], { reads: names }); + expect(result.files).toEqual({ + "package.json": '{ "a": 1 }\n', + "packages/b/package.json": '{ "b": 1 }\n', + "my-package.json": '{ "c": 1 }\n', + "d.js": "d;\n", + }); + expect(result.stderr).toContain("Used your installed Prettier for 2 files"); + expect(result.stderr).not.toContain("[warn]"); + expect(result.exitCode).toBe(0); + + // Without Prettier only those files are left, and with the flag they are formatted without the plugin. + const { ".prettierrc": config, "package.json": text } = project; + const alone = { ".prettierrc": config, "package.json": text, "d.js": "d ;\n" }; + const left = await format(alone, [], { reads: ["package.json", "d.js"] }); + expect(left.files).toEqual({ "package.json": text, "d.js": "d;\n" }); + expect(left.stderr).toContain(`Not installed: prettier, ${plugin}`); + expect(left.exitCode).toBe(2); + const allowed = await format(alone, ["--allow-unsupported"], { reads: ["package.json"] }); + expect(allowed.files).toEqual({ "package.json": '{\n "a": 1\n}\n' }); + expect(allowed.exitCode).toBe(0); + }, + timeout, + ); + test("a path reaches Prettier and its plugins as the system writes it, also on Windows", async () => { const worker = join(import.meta.dir, "../../../src/lint/js_plugin/worker"); const head = { usesConfig: true, editorconfig: true, flags: {}, precedence: "cli-override" }; @@ -2099,7 +2139,9 @@ ${packages["node_modules/prettier/index.cjs"]}`; expect(result.exitCode).toBe(2); const allowed = await format(files, ["--allow-unsupported"], { reads }); expect(allowed.files).toEqual({ "a.js": formatted, "b.astro": files["b.astro"], "other/c.js": formatted }); - expect(allowed.stderr).toContain("has no effect because bun fmt does not load Prettier plugins"); + expect(allowed.stderr).toMatch( + /\[warn\] prettier-plugin-brace-style, \.\/own\.js in \S+\.prettierrc have no effect because bun fmt does not include those plugins\n/, + ); expect(allowed.exitCode).toBe(0); const flag = await format({ "a.js": ugly }, ["--plugin", "prettier-plugin-brace-style"], { reads: ["a.js"] }); expect(flag.files["a.js"]).toBe(ugly); @@ -2138,6 +2180,40 @@ ${packages["node_modules/prettier/index.cjs"]}`; expect(other.exitCode).toBe(2); }); + // Prettier 3.9.9 prints 3,939 files of JavaScript and TypeScript the same with and without @prettier/plugin-oxc 0.2.3. + test.each<[string, Record, string[]?]>([ + ["its name", { ".prettierrc": '{ "plugins": ["@prettier/plugin-oxc"] }' }], + [ + "the module", + { + "prettier.config.mjs": 'import * as oxc from "@prettier/plugin-oxc";\nexport default { plugins: [oxc] };', + "node_modules/@prettier/plugin-oxc/package.json": + '{ "name": "@prettier/plugin-oxc", "exports": "./index.mjs" }', + "node_modules/@prettier/plugin-oxc/index.mjs": + 'export const parsers = { oxc: { astFormat: "estree" }, "oxc-ts": { astFormat: "estree" } };\n', + }, + ], + [ + "overrides with its parsers", + { + ".prettierrc": JSON.stringify({ + overrides: [ + { files: "*.js", options: { parser: "oxc", plugins: ["@prettier/plugin-oxc"] } }, + { files: "*.ts", options: { parser: "oxc-ts", plugins: ["@prettier/plugin-oxc"] } }, + ], + }), + }, + ], + ["--plugin", {}, ["--plugin", "@prettier/plugin-oxc"]], + ])("the plugin that only reads with another parser, named by %s, changes nothing", async (_, files, flags = []) => { + const result = await format({ ...files, "a.js": ugly, "a.ts": "let a: 1\n" }, ["--log-level=warn", ...flags], { + reads: ["a.js", "a.ts"], + }); + expect(result.stderr).toBe(""); + expect(result.files).toEqual({ "a.js": formatted, "a.ts": "let a: 1;\n" }); + expect(result.exitCode).toBe(0); + }); + test("a plugin that only adds a language: the rest is formatted, and the files of that language are counted", async () => { const files = { ".prettierrc": '{ "plugins": ["prettier-plugin-astro"] }\n', diff --git a/test/cli/format/tailwind/tailwind.test.ts b/test/cli/format/tailwind/tailwind.test.ts index 93a198506180..324e776265ea 100644 --- a/test/cli/format/tailwind/tailwind.test.ts +++ b/test/cli/format/tailwind/tailwind.test.ts @@ -660,12 +660,169 @@ if (now.join() !== before.join()) throw new Error("There is " + now.filter(it => const files = { ".prettierrc": JSON.stringify({ plugins: [plugin, "prettier-plugin-other"] }), "a.jsx": input }; const result = await format({ ...project, ...files }, ["a.jsx"], ["--log-level=log", "--check"]); expect(result.stderr).toContain("plugins that bun fmt does not include: prettier-plugin-other\n"); + expect(result.stderr).toMatch( + /\[warn\] prettier-plugin-other in \S+ has no effect because bun fmt does not include that plugin\n/, + ); // Nothing is said about files that have not been looked at. expect(result.stdout).toBe("Checking formatting...\n"); expect(result.files).toEqual([input]); expect(result.exitCode).toBe(2); }); + const names = { tailwindFunctions: ["cn", "/^tw/"], tailwindAttributes: ["mine", "/^data-cls/"] }; + + test("where classes are is what it says, not oxfmt", async () => { + // The lines of a file, before and after. + const rows: Record = { + "a.jsx": [ + // However deep in an argument or in the value of an attribute. + ['cn(other("p-4 flex"));', 'cn(other("flex p-4"));'], + [';', ';'], + ['cn(a === "p-4 flex" && "flex m-2");', 'cn(a === "flex p-4" && "m-2 flex");'], + ['cn();', 'cn();'], + ["cn(other`p-4 flex`);", "cn(other`flex p-4`);"], + [ + 'cn({ "p-4 flex": a, [`p-1 m-2`]: b, ["p-2 m-3"]: c });', + 'cn({ "flex p-4": a, [`m-2 p-1`]: b, ["m-3 p-2"]: c });', + ], + // In chains. + ['cn("p-4 flex").b("p-4 flex");', 'cn("flex p-4").b("flex p-4");'], + ['cn.a.b.c("p-4 flex").d("p-4 flex").e("p-4 flex");', 'cn.a.b.c("flex p-4").d("flex p-4").e("flex p-4");'], + ['x.cn("p-4 flex");', ""], + ['new cn("p-4 flex");', ""], + // Patterns. + ['twx("p-4 flex");', 'twx("flex p-4");'], + [ + ';', + ';', + ], + // To Babel a call in an optional chain is another kind of node. + ['cn?.("p-4 flex");', ""], + ['cn?.x.y("p-4 flex");', ""], + // What is added to stays apart, up to the innermost function. + ['cn(a + (b ? " p-4 flex " : c));', 'cn(a + (b ? " flex p-4" : c));'], + ['cn("x " + cn(" p-4 flex "));', 'cn("x " + cn("flex p-4"));'], + ['"x " + cn` p-4 flex ` + " y";', '"x " + cn` flex p-4 ` + " y";'], + // Of the texts of a template it looks at the wrong ends, which leaves more blanks than are needed. + ['cn(`x ${" p-4 flex "} y`);', 'cn(`x ${" flex p-4 "} y`);'], + ['cn(`${" p-4 flex "}`);', 'cn(`${" flex p-4 "}`);'], + ['cn(` ${" p-4 flex "} `);', 'cn(` ${"flex p-4"} `);'], + ["cn(`p-4 flex${a}m-2 p-1`);", ""], + ["cn(` p-4 flex ${a} p-1 m-2 `);", "cn(`flex p-4 ${a} m-2 p-1`);"], + // A list that was on several lines does not keep the element open. + [';', ';'], + ], + "a.tsx": [ + ['cn?.("p-4 flex");', 'cn?.("flex p-4");'], + ['cn?.x.y("p-4 flex");', 'cn?.x.y("flex p-4");'], + ['(cn?.x)("p-4 flex");', ""], + ['cn!("p-4 flex");', ""], + ['cn("p-4 flex" as string);', 'cn("flex p-4" as string);'], + // In this tree it does not find the texts of a template. + ['cn(`x ${" p-4 flex "} y`);', 'cn(`x ${"flex p-4"} y`);'], + ['cn(`${" p-4 flex "}`);', 'cn(`${"flex p-4"}`);'], + ['cn(`x${`p-4 flex ${" p-1 m-2 "} p-2 m-3`}y`);', 'cn(`x${`flex p-4 ${"m-2 p-1"} m-3 p-2`}y`);'], + ], + "a.css": [["a {\n @apply p-4 /* c */ flex;\n}", "a {\n @apply /* c */ flex p-4;\n}"]], + "a.html": [['

    ', '
    x
    ']], + "a.mdx": [ + ['
    \n', '\n'], + ['export const a = cn("p-4 flex");', 'export const a = cn("flex p-4");'], + ], + "lwc/a.html": [ + [ + '', + '', + ], + ], + "a.vue": [ + [ + ``, + ``, + ], + ], + }; + const text = (at: 0 | 1) => (lines: [string, string][]) => lines.map(it => it[at] || it[0]).join("\n") + "\n"; + const options = { ...names, overrides: [{ files: "lwc/**", options: { parser: "lwc" } }] }; + const files = Object.fromEntries(Object.entries(rows).map(([name, lines]) => [name, text(0)(lines)])); + const result = await format({ ...project, ...config(options), ...files }, Object.keys(rows)); + expect(result.stderr).toBe(""); + expect(Object.fromEntries(Object.keys(rows).map((name, index) => [name, result.files[index]]))).toEqual( + Object.fromEntries(Object.entries(rows).map(([name, lines]) => [name, text(1)(lines)])), + ); + expect(result.exitCode).toBe(0); + }); + + test("a class stays apart from the text of a template that it touches", async () => { + // The code, what Bun makes of it, and what the plugin makes of it: one class of `x` and `flex`. + const rows: Record = { + "a.tsx": [ + [ + ';', + ';', + ';', + ], + [ + ';', + ';', + ';', + ], + ['cn(`x${" p-4 flex "}y`);', 'cn(`x${" flex p-4 "}y`);', 'cn(`x${"flex p-4"}y`);'], + ['cn(`${a}${" p-4 flex "}${b}`);', 'cn(`${a}${" flex p-4 "}${b}`);', 'cn(`${a}${"flex p-4"}${b}`);'], + ["cn(`x${` p-4 flex `} e`);", "cn(`x${` flex p-4`} e`);", "cn(`x${`flex p-4`} e`);"], + ], + "a.jsx": [ + ['cn(`x ${a} b${" p-4 flex "} e`);', 'cn(`x ${a} b${" flex p-4 "} e`);', 'cn(`x ${a} b${"flex p-4 "} e`);'], + ['cn(` x${" p-4 flex "} e`);', 'cn(` x${" flex p-4 "} e`);', 'cn(` x${"flex p-4 "} e`);'], + ['cn(`x${ " p-4 flex "} e`);', 'cn(`x${" flex p-4 "} e`);', 'cn(`x${"flex p-4 "} e`);'], + ], + "a.vue": [ + [ + "", + "", + "", + ], + ], + }; + const text = (at: 0 | 1) => (lines: string[][]) => lines.map(it => it[at]).join("\n") + "\n"; + for (const lines of Object.values(rows)) for (const it of lines) expect(it[1]).not.toBe(it[2]); + const files = Object.fromEntries(Object.entries(rows).map(([name, lines]) => [name, text(0)(lines)])); + const result = await format({ ...project, ...config(names), ...files }, Object.keys(rows)); + expect(result.stderr).toBe(""); + expect(result.files).toEqual(Object.values(rows).map(text(1))); + expect(result.exitCode).toBe(0); + }); + + test("with @prettier/plugin-oxc before it, it looks at the tree of that, and behind that it does nothing", async () => { + const code = 'cn?.("p-4 flex");\ncn(`x ${" p-4 flex "} y`);\n'; + const sortedCode = 'cn?.("flex p-4");\ncn(`x ${" flex p-4 "} y`);\n'; + const inVue = (text: string, lang = "") => `\n${text}\n`; + const files = { "a.js": code, "a.ts": code, "a.vue": inVue(code), "b.vue": inVue(code, ' lang="ts"') }; + const run = (plugins: string[]) => + format( + { ...project, ".prettierrc": JSON.stringify({ plugins, tailwindFunctions: ["cn"] }), ...files }, + Object.keys(files), + ); + // It does not read the JavaScript in HTML. + const byBabel = inVue('cn?.("p-4 flex");\ncn(`x ${" flex p-4 "} y`);\n'); + expect(await run(["@prettier/plugin-oxc", plugin])).toMatchObject({ + stderr: "", + files: [sortedCode, sortedCode, byBabel, inVue(sortedCode, ' lang="ts"')], + }); + expect(await run([plugin, "@prettier/plugin-oxc"])).toMatchObject({ + stderr: "", + files: [code, code, byBabel, inVue(code, ' lang="ts"')], + }); + }); + + test("in Angular it looks at the texts of a template as they are written", async () => { + const before = "\n"; + const after = + "\n"; + const result = await format({ ...project, ...config({}), "a.component.html": before }, ["a.component.html"]); + expect(result).toMatchObject({ stderr: "", files: [after], exitCode: 0 }); + }); + describe("the Tailwind CSS that it brings along", () => { /** Its `createSorter`, which knows what is in order.json. A file with `reversed` in it turns the order around. */ const withSorter = { diff --git a/test/cli/lint/defaults.test.ts b/test/cli/lint/defaults.test.ts index bfdfdfdfed41..155329c9a64e 100644 --- a/test/cli/lint/defaults.test.ts +++ b/test/cli/lint/defaults.test.ts @@ -125,6 +125,30 @@ describe.concurrent("globals", () => { expect(exitCode).toBe(1); }); + // Which names `**/*{.,_,-}{test,spec,e2e,cy}.{js,mjs,cjs,jsx,ts,mts,cts,tsx}` is for. A global of a test runner can't be + // assigned to. In another file nothing defines it, which is only reported in JavaScript. + test("a file is a test by what its name ends with", async () => { + const [stems, separators] = [ + ["a", "", "a.b", "test"], + [".", "_", "-", "", "+"], + ]; + const words = ["test", "spec", "e2e", "cy", "tes", "tests", "Test", "atest"]; + const extensions = ["js", "mjs", "cjs", "jsx", "ts", "mts", "cts", "tsx"]; + const names = stems.flatMap(stem => + separators.flatMap(separator => + words.flatMap(word => extensions.map(extension => `${stem}${separator}${word}.${extension}`)), + ), + ); + names.push("a.test.js.js", "a.test.jsx.ts", "a.test.d.ts", "a.test..js", "a.spec.ts.mts"); + names.push("src/deep/a_e2e.cts", "src/a.cy/b.js", "src/a.test.js/b.ts"); + const expected = [...new Set(names)].flatMap(name => { + if (/[._-](test|spec|e2e|cy)\.(js|mjs|cjs|jsx|ts|mts|cts|tsx)$/.test(name)) return [`${name} no-global-assign`]; + return /\.[mc]?jsx?$/.test(name) ? [`${name} no-undef`] : []; + }); + const { reports } = await lint(Object.fromEntries(names.map(name => [name, "describe = 1;\n"])), []); + expect(reports.map(it => it.replace(/:1:1 \w+ /, " ")).sort()).toEqual(expected.sort()); + }); + test("an eslint-env comment defines its globals, and is only an error with an ESLint config", async () => { const files = { "a.js": '/* eslint-env mocha, nonsense */\ndescribe("a", () => {});\njQuery("a");\n' }; const config = { "eslint.config.mjs": `export default [{ rules: { "no-undef": "error" } }];\n` }; diff --git a/test/cli/lint/oxc-errors.test.ts b/test/cli/lint/oxc-errors.test.ts index 9fdce2e93048..41b08632c6a0 100644 --- a/test/cli/lint/oxc-errors.test.ts +++ b/test/cli/lint/oxc-errors.test.ts @@ -20,10 +20,10 @@ type Label = { label?: string; span: { offset: number; length: number } }; type Diagnostic = { filename: string; code?: string; message: string; help?: string; labels: Label[] }; /** What `bun lint -f json` prints for `files` beside an .oxlintrc.json, file by file, in the order in which it is printed. */ -async function diagnosticsOf(files: Record, config: object) { +async function diagnosticsOf(files: Record, config: object, flags: string[] = [], onStderr = "") { using dir = tempDir("bun-lint-oxc-errors", { ".oxlintrc.json": JSON.stringify(config), ...files }); await using proc = spawn({ - cmd: [bunExe(), "lint", "-f", "json"], + cmd: [bunExe(), "lint", "-f", "json", ...flags], env, cwd: String(dir), stdin: "ignore", @@ -31,7 +31,7 @@ async function diagnosticsOf(files: Record, config: object) { stderr: "pipe", }); const [stdout, stderr] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stderr).toBe(""); + expect(stderr).toBe(onStderr); const found: Record = Object.fromEntries(Object.keys(files).map(name => [name, []])); for (const it of JSON.parse(stdout).diagnostics as Diagnostic[]) found[it.filename].push(it); return found; @@ -3779,6 +3779,1073 @@ const tables: [title: string, config: object | undefined, rows: Row[]][] = [ ], ], ], + [ + "several rules about one number report by their numbers, whatever part of it they mark", + { + "categories": { "correctness": "off" }, + "plugins": ["unicorn"], + "rules": { + "unicorn/numeric-separators-style": "error", + "unicorn/number-literal-case": "error", + "no-magic-numbers": "error", + "unicorn/prefer-number-properties": "error", + }, + }, + [ + [ + "js", + "a = 0xabcdef12;", + [ + "eslint(no-magic-numbers) | No magic number: 0xabcdef12 | Use a named constant instead of a magic number to make the code more readable and maintainable. | 4+10", + "unicorn(number-literal-case) | Unexpected hexadecimal digits in lowercase. | Use uppercase for hexadecimal digits. | 6+8", + "unicorn(numeric-separators-style) | Invalid group length in numeric value. | Group digits with numeric separators (_) so longer numbers are easier to read. | 4+10", + ], + ], + [ + "js", + "a = 0Xabcdef12;", + [ + "eslint(no-magic-numbers) | No magic number: 0Xabcdef12 | Use a named constant instead of a magic number to make the code more readable and maintainable. | 4+10", + "unicorn(number-literal-case) | Unexpected number literal prefix in uppercase and hexadecimal digits in lowercase. | Use lowercase for the number literal prefix `0x` and uppercase for hexadecimal digits. | 4+10", + "unicorn(numeric-separators-style) | Invalid group length in numeric value. | Group digits with numeric separators (_) so longer numbers are easier to read. | 4+10", + ], + ], + [ + "js", + "a = 1E10 + 0xabcdef12;", + [ + "eslint(no-magic-numbers) | No magic number: 1E10 | Use a named constant instead of a magic number to make the code more readable and maintainable. | 4+4", + "unicorn(number-literal-case) | Unexpected exponential notation in uppercase. | Use lowercase for `e` in exponential notations. | 5+1", + "eslint(no-magic-numbers) | No magic number: 0xabcdef12 | Use a named constant instead of a magic number to make the code more readable and maintainable. | 11+10", + "unicorn(number-literal-case) | Unexpected hexadecimal digits in lowercase. | Use uppercase for hexadecimal digits. | 13+8", + "unicorn(numeric-separators-style) | Invalid group length in numeric value. | Group digits with numeric separators (_) so longer numbers are easier to read. | 11+10", + ], + ], + [ + "js", + "a = 0xabcdef12n;", + [ + "eslint(no-magic-numbers) | No magic number: 0xabcdef12n | Use a named constant instead of a magic number to make the code more readable and maintainable. | 4+11", + "unicorn(number-literal-case) | Unexpected hexadecimal digits in lowercase. | Use uppercase for hexadecimal digits. | 6+9", + "unicorn(numeric-separators-style) | Invalid group length in numeric value. | Group digits with numeric separators (_) so longer numbers are easier to read. | 4+11", + ], + ], + [ + "js", + "f(0xabcdef12, 0B1010101010);", + [ + "eslint(no-magic-numbers) | No magic number: 0xabcdef12 | Use a named constant instead of a magic number to make the code more readable and maintainable. | 2+10", + "unicorn(number-literal-case) | Unexpected hexadecimal digits in lowercase. | Use uppercase for hexadecimal digits. | 4+8", + "unicorn(numeric-separators-style) | Invalid group length in numeric value. | Group digits with numeric separators (_) so longer numbers are easier to read. | 2+10", + "eslint(no-magic-numbers) | No magic number: 0B1010101010 | Use a named constant instead of a magic number to make the code more readable and maintainable. | 14+12", + "unicorn(number-literal-case) | Unexpected number literal prefix in uppercase. | Use lowercase for the number literal prefix `0b`. | 15+1", + "unicorn(numeric-separators-style) | Invalid group length in numeric value. | Group digits with numeric separators (_) so longer numbers are easier to read. | 14+12", + ], + ], + ], + ], + [ + "a rule that is called with a body or an opening tag reports after those that are called with the function or the element", + { + "categories": { "correctness": "off" }, + "plugins": ["oxc", "react", "jsx-a11y", "typescript", "unicorn"], + "rules": { + "require-await": "error", + "oxc/no-async-await": "error", + "no-empty-function": "error", + "func-style": "error", + "max-params": ["error", { "max": 0 }], + "jsx-a11y/alt-text": "error", + "jsx-a11y/html-has-lang": "error", + "react/jsx-no-target-blank": "error", + "react/react-in-jsx-scope": "error", + "react/jsx-no-literals": "error", + "react/forbid-elements": ["error", { "forbid": ["img", "h1"] }], + "typescript/no-duplicate-enum-values": "error", + "typescript/prefer-enum-initializers": "error", + "grouped-accessor-pairs": "error", + "class-methods-use-this": "error", + "id-length": "error", + "unicorn/empty-brace-spaces": "error", + }, + }, + [ + [ + "js", + "async function f() { g(); }\nf();", + [ + "eslint(func-style) | Expected a function expression. | Enforce the consistent use of either `function` declarations or expressions assigned to variables | 0+27", + "oxc(no-async-await) | async is not allowed | Remove the `async` keyword | 0+5", + "eslint(id-length) | Identifier name is too short (< 2). | | 15+1", + "eslint(require-await) | Async function has no `await` expression. | Consider removing the `async` keyword. | 15+1", + ], + ], + [ + "js", + "const f = async () => { g(); };\nf();", + [ + "eslint(id-length) | Identifier name is too short (< 2). | | 6+1", + "oxc(no-async-await) | async is not allowed | Remove the `async` keyword | 10+5", + "eslint(require-await) | Async function has no `await` expression. | Consider removing the `async` keyword. | 10+20", + ], + ], + [ + "js", + "class A { async m() { g(); } }\nnew A();", + [ + "eslint(id-length) | Identifier name is too short (< 2). | | 6+1", + "eslint(class-methods-use-this) | Expected method `m` to have this. | Consider converting method `m` to a static method. | 16+1", + "eslint(id-length) | Identifier name is too short (< 2). | | 16+1", + "oxc(no-async-await) | async is not allowed | Remove the `async` keyword | 10+5", + "eslint(require-await) | Async function has no `await` expression. | Consider removing the `async` keyword. | 16+1", + ], + ], + [ + "js", + "function f() {}\nf();", + [ + "eslint(func-style) | Expected a function expression. | Enforce the consistent use of either `function` declarations or expressions assigned to variables | 0+15", + "eslint(id-length) | Identifier name is too short (< 2). | | 9+1", + "eslint(no-empty-function) | Unexpected empty function `f` | Consider removing this function or adding logic to it. | 13+2", + ], + ], + [ + "jsx", + 'const a = ;', + [ + "eslint(id-length) | Identifier name is too short (< 2). | | 6+1", + "react(forbid-elements) | is forbidden. | | 11+3", + 'react(react-in-jsx-scope) | `React` must be in scope when using JSX. | When using JSX, `` expands to `React.createElement("a")`. Therefore the `React` variable must be in scope. | 11+3', + "jsx-a11y(alt-text) | Missing `alt` attribute. | Must have `alt` prop, either with meaningful text, or an empty string for decorative images. | 10+15", + ], + ], + [ + "jsx", + 'const a = ;', + [ + "eslint(id-length) | Identifier name is too short (< 2). | | 6+1", + "react(jsx-no-literals) | Disallow literal text as JSX children | Wrap this text in a JSX expression container, such as a call to a translation function. | 58+1", + 'react(react-in-jsx-scope) | `React` must be in scope when using JSX. | When using JSX, `` expands to `React.createElement("a")`. Therefore the `React` variable must be in scope. | 11+3', + "react(forbid-elements) | is forbidden. | | 16+3", + 'react(react-in-jsx-scope) | `React` must be in scope when using JSX. | When using JSX, `` expands to `React.createElement("a")`. Therefore the `React` variable must be in scope. | 16+3', + "jsx-a11y(alt-text) | Missing `alt` attribute. | Must have `alt` prop, either with meaningful text, or an empty string for decorative images. | 15+15", + 'react(react-in-jsx-scope) | `React` must be in scope when using JSX. | When using JSX, `` expands to `React.createElement("a")`. Therefore the `React` variable must be in scope. | 31+1', + ], + ], + [ + "jsx", + "const a =

    e

    ;", + [ + "eslint(id-length) | Identifier name is too short (< 2). | | 6+1", + "react(jsx-no-literals) | Disallow literal text as JSX children | Wrap this text in a JSX expression container, such as a call to a translation function. | 26+1", + 'react(react-in-jsx-scope) | `React` must be in scope when using JSX. | When using JSX, `
    ` expands to `React.createElement("a")`. Therefore the `React` variable must be in scope. | 11+4', + "jsx-a11y(html-has-lang) | Missing lang attribute. | Add a `lang` attribute to the `html` element whose value represents the primary language of document. | 11+4", + 'react(react-in-jsx-scope) | `React` must be in scope when using JSX. | When using JSX, `` expands to `React.createElement("a")`. Therefore the `React` variable must be in scope. | 17+4', + "react(forbid-elements) |

    is forbidden. | | 23+2", + 'react(react-in-jsx-scope) | `React` must be in scope when using JSX. | When using JSX, `` expands to `React.createElement("a")`. Therefore the `React` variable must be in scope. | 23+2', + ], + ], + [ + "ts", + "enum E { A = 1, B = 1 }\nexport { E };", + [ + "eslint(id-length) | Identifier name is too short (< 2). | | 5+1", + "typescript(no-duplicate-enum-values) | Duplicate enum value `1` | Give B a unique value | 13+1 1 is first used as an initializer here | 20+1 and is re-used here", + "eslint(id-length) | Identifier name is too short (< 2). | | 9+1", + "eslint(id-length) | Identifier name is too short (< 2). | | 16+1", + ], + ], + [ + "js", + "class A { get b() { return 1; } c() {} set b(d) {} }\nnew A();", + [ + "eslint(id-length) | Identifier name is too short (< 2). | | 6+1", + "eslint(grouped-accessor-pairs) | Accessor pair getter 'b' and setter 'b' should be grouped. | Require grouped accessor pairs in object literals and classes | 10+5 getter 'b' is here | 39+5 setter 'b' is here", + "eslint(class-methods-use-this) | Expected method `b` to have this. | Consider converting method `b` to a static method. | 14+1", + "eslint(id-length) | Identifier name is too short (< 2). | | 14+1", + "eslint(class-methods-use-this) | Expected method `c` to have this. | Consider converting method `c` to a static method. | 32+1", + "eslint(id-length) | Identifier name is too short (< 2). | | 32+1", + "eslint(no-empty-function) | Unexpected empty method `c` | Consider removing this method or adding logic to it. | 36+2", + "eslint(class-methods-use-this) | Expected method `b` to have this. | Consider converting method `b` to a static method. | 43+1", + "eslint(id-length) | Identifier name is too short (< 2). | | 43+1", + "eslint(max-params) | Function has too many parameters (1). Maximum allowed is 0. | This rule enforces a maximum number of parameters allowed in function definitions. | 44+3", + "eslint(id-length) | Identifier name is too short (< 2). | | 45+1", + "eslint(no-empty-function) | Unexpected empty setter `b` | Consider removing this setter or adding logic to it. | 48+2", + ], + ], + ], + ], + [ + "a rule reports when oxlint comes to the node that it calls the rule with, which is not always the one that is marked", + { + "categories": { "correctness": "off" }, + "plugins": ["oxc", "promise", "unicorn", "import", "node"], + "rules": { + "no-else-return": "error", + "curly": "error", + "new-cap": "error", + "no-magic-numbers": "error", + "promise/prefer-await-to-callbacks": "error", + "promise/prefer-await-to-then": "error", + "promise/catch-or-return": "error", + "unicorn/catch-error-name": "error", + "func-names": "error", + "no-undefined": "error", + "unicorn/consistent-assert": "error", + "import/no-nodejs-modules": "error", + "func-style": "error", + "unicorn/prefer-number-properties": "error", + "no-undef": "error", + "block-scoped-var": "error", + "no-var": "error", + "vars-on-top": "error", + "no-plusplus": "error", + "require-await": "error", + "oxc/no-async-await": "error", + "grouped-accessor-pairs": "error", + "id-length": "error", + "class-methods-use-this": "error", + }, + }, + [ + [ + "js", + "function f(a) {\n if (a === 1) return [g(1)];\n else if (a === 2) return [g(2)];\n else return [g(3)];\n}\nf(1);", + [ + "eslint(no-undef) | 'g' is not defined. | Either define 'g' or remove the reference to it. If 'g' is a global variable, add it to the 'globals' configuration. | 39+1", + "eslint(no-undef) | 'g' is not defined. | Either define 'g' or remove the reference to it. If 'g' is a global variable, add it to the 'globals' configuration. | 74+1", + "eslint(no-undef) | 'g' is not defined. | Either define 'g' or remove the reference to it. If 'g' is a global variable, add it to the 'globals' configuration. | 96+1", + "eslint(func-style) | Expected a function expression. | Enforce the consistent use of either `function` declarations or expressions assigned to variables | 0+104", + "eslint(id-length) | Identifier name is too short (< 2). | | 9+1", + "eslint(id-length) | Identifier name is too short (< 2). | | 11+1", + "eslint(curly) | Expected { after 'if' condition. | Replace `return [g(1)];` with `{return [g(1)];}`. | 31+14", + "eslint(curly) | Expected { after 'if' condition. | Replace `return [g(2)];` with `{return [g(2)];}`. | 66+14", + "eslint(curly) | Expected { after 'else'. | Replace `return [g(3)];` with `{return [g(3)];}`. | 88+14", + "eslint(no-else-return) | Unnecessary `else` after `return`. | Remove the `else` block, moving its contents outside of the `if` statement. | 66+14 This consequent block always returns, | 80+8 Making this `else` block unnecessary.", + "eslint(no-magic-numbers) | No magic number: 1 | Use a named constant instead of a magic number to make the code more readable and maintainable. | 28+1", + "eslint(no-magic-numbers) | No magic number: 1 | Use a named constant instead of a magic number to make the code more readable and maintainable. | 41+1", + "eslint(no-magic-numbers) | No magic number: 2 | Use a named constant instead of a magic number to make the code more readable and maintainable. | 63+1", + "eslint(no-magic-numbers) | No magic number: 2 | Use a named constant instead of a magic number to make the code more readable and maintainable. | 76+1", + "eslint(no-magic-numbers) | No magic number: 3 | Use a named constant instead of a magic number to make the code more readable and maintainable. | 98+1", + "eslint(no-magic-numbers) | No magic number: 1 | Use a named constant instead of a magic number to make the code more readable and maintainable. | 107+1", + ], + ], + [ + "js", + "function f(p) {\n p.then(undefined, function (err) {\n g(err);\n });\n}\nf();", + [ + "eslint(no-undef) | 'g' is not defined. | Either define 'g' or remove the reference to it. If 'g' is a global variable, add it to the 'globals' configuration. | 57+1", + "eslint(func-style) | Expected a function expression. | Enforce the consistent use of either `function` declarations or expressions assigned to variables | 0+72", + "eslint(id-length) | Identifier name is too short (< 2). | | 9+1", + "eslint(id-length) | Identifier name is too short (< 2). | | 11+1", + "promise(catch-or-return) | Expected `catch` or `return`. | Return the promise or chain a `catch()`. | 18+51", + 'unicorn(catch-error-name) | The catch parameter "err" should be named "error" | Rename `err` to `error` | 46+3', + "promise(prefer-await-to-callbacks) | Prefer `async`/`await` to the callback pattern | Refactor to use an `async` function with `await` instead of passing callbacks for cleaner error handling and control flow. | 36+32", + "promise(prefer-await-to-then) | Prefer await to then()/catch()/finally() | Use `await` with `try`/`catch` instead of promise chaining for more readable and maintainable async code. | 20+4", + "eslint(no-undefined) | Unexpected use of `undefined` | | 25+9", + "eslint(func-names) | Unexpected unnamed function. | Consider giving this function expression a name. | 36+9", + ], + ], + [ + "js", + 'import assert from "node:assert";\nimport path from "node:path";\nfunction f(a) {\n assert(a);\n return path.join(a);\n}\nf(1);', + [ + "import(no-nodejs-modules) | Do not import Node.js builtin module `node:assert` | Use a browser-compatible alternative or add this module to the `allow` list if Node.js usage is intentional. | 0+33", + "unicorn(consistent-assert) | Inconsistent assert usage. | Prefer `assert.ok(...)` over `assert(...)`. | 82+6", + "import(no-nodejs-modules) | Do not import Node.js builtin module `node:path` | Use a browser-compatible alternative or add this module to the `allow` list if Node.js usage is intentional. | 34+29", + "eslint(func-style) | Expected a function expression. | Enforce the consistent use of either `function` declarations or expressions assigned to variables | 64+53", + "eslint(id-length) | Identifier name is too short (< 2). | | 73+1", + "eslint(id-length) | Identifier name is too short (< 2). | | 75+1", + "eslint(no-magic-numbers) | No magic number: 1 | Use a named constant instead of a magic number to make the code more readable and maintainable. | 120+1", + ], + ], + [ + "js", + "function f(a) {\n g(NaN);\n g(parseInt(a));\n g(window.NaN);\n g(Infinity);\n return isNaN(a);\n}\nf(1);", + [ + "eslint(no-undef) | 'window' is not defined. | Either define 'window' or remove the reference to it. If 'window' is a global variable, add it to the 'globals' configuration. | 48+6", + "eslint(no-undef) | 'g' is not defined. | Either define 'g' or remove the reference to it. If 'g' is a global variable, add it to the 'globals' configuration. | 18+1", + "eslint(no-undef) | 'g' is not defined. | Either define 'g' or remove the reference to it. If 'g' is a global variable, add it to the 'globals' configuration. | 28+1", + "eslint(no-undef) | 'g' is not defined. | Either define 'g' or remove the reference to it. If 'g' is a global variable, add it to the 'globals' configuration. | 46+1", + "eslint(no-undef) | 'g' is not defined. | Either define 'g' or remove the reference to it. If 'g' is a global variable, add it to the 'globals' configuration. | 63+1", + "unicorn(prefer-number-properties) | Use `Number.NaN` instead of the global `NaN` | Replace it with `Number.NaN` | 20+3", + "eslint(func-style) | Expected a function expression. | Enforce the consistent use of either `function` declarations or expressions assigned to variables | 0+96", + "eslint(id-length) | Identifier name is too short (< 2). | | 9+1", + "eslint(id-length) | Identifier name is too short (< 2). | | 11+1", + "unicorn(prefer-number-properties) | Use `Number.parseInt` instead of the global `parseInt` | Replace it with `Number.parseInt` | 30+8", + "unicorn(prefer-number-properties) | Use `Number.NaN` instead of the global `NaN` | Replace it with `Number.NaN` | 48+10", + "unicorn(prefer-number-properties) | Use `Number.isNaN` instead of the global `isNaN` | Replace it with `Number.isNaN` | 85+5", + "eslint(no-magic-numbers) | No magic number: 1 | Use a named constant instead of a magic number to make the code more readable and maintainable. | 99+1", + ], + ], + [ + "js", + "function f(a) {\n for (var i = 0; i < a; i++) g(i);\n for (var i = 0, n = a; i < n; i++) g(i, n);\n return i + n;\n}\nf(1);", + [ + "eslint(no-undef) | 'g' is not defined. | Either define 'g' or remove the reference to it. If 'g' is a global variable, add it to the 'globals' configuration. | 46+1", + "eslint(no-undef) | 'g' is not defined. | Either define 'g' or remove the reference to it. If 'g' is a global variable, add it to the 'globals' configuration. | 89+1", + "eslint(func-style) | Expected a function expression. | Enforce the consistent use of either `function` declarations or expressions assigned to variables | 0+115", + "eslint(id-length) | Identifier name is too short (< 2). | | 9+1", + "eslint(id-length) | Identifier name is too short (< 2). | | 11+1", + "eslint(curly) | Expected { after 'for' condition. | Replace `g(i);` with `{g(i);}`. | 46+5", + "eslint(block-scoped-var) | 'i' is used outside of binding context. | Variable 'i' is used outside its declaration block. Declare it outside the block or use 'let'/'const'. | 63+1 it is redeclared here | 27+1 'i' is first declared here", + "eslint(block-scoped-var) | 'i' is used outside of binding context. | Variable 'i' is used outside its declaration block. Declare it outside the block or use 'let'/'const'. | 77+1 'i' is used here | 27+1 It is declared in a different scope here", + "eslint(block-scoped-var) | 'i' is used outside of binding context. | Variable 'i' is used outside its declaration block. Declare it outside the block or use 'let'/'const'. | 84+1 'i' is used here | 27+1 It is declared in a different scope here", + "eslint(block-scoped-var) | 'i' is used outside of binding context. | Variable 'i' is used outside its declaration block. Declare it outside the block or use 'let'/'const'. | 91+1 'i' is used here | 27+1 It is declared in a different scope here", + "eslint(block-scoped-var) | 'i' is used outside of binding context. | Variable 'i' is used outside its declaration block. Declare it outside the block or use 'let'/'const'. | 107+1 'i' is used here | 27+1 It is declared in a different scope here", + "eslint(no-var) | Unexpected var, use let or const instead. | Replace var with let or const | 23+3", + "eslint(vars-on-top) | All 'var' declarations must be at the top of the function scope. | Consider moving this to the top of the functions scope or using let or const to declare this variable. | 23+9", + "eslint(id-length) | Identifier name is too short (< 2). | | 27+1", + "eslint(no-plusplus) | Unary operator '++' used. | Use the assignment operator `+=` instead. | 41+3", + "eslint(curly) | Expected { after 'for' condition. | Replace `g(i, n);` with `{g(i, n);}`. | 89+8", + "eslint(block-scoped-var) | 'i' is used outside of binding context. | Variable 'i' is used outside its declaration block. Declare it outside the block or use 'let'/'const'. | 27+1 it is redeclared here | 63+1 'i' is first declared here", + "eslint(block-scoped-var) | 'i' is used outside of binding context. | Variable 'i' is used outside its declaration block. Declare it outside the block or use 'let'/'const'. | 34+1 'i' is used here | 63+1 It is declared in a different scope here", + "eslint(block-scoped-var) | 'i' is used outside of binding context. | Variable 'i' is used outside its declaration block. Declare it outside the block or use 'let'/'const'. | 41+1 'i' is used here | 63+1 It is declared in a different scope here", + "eslint(block-scoped-var) | 'i' is used outside of binding context. | Variable 'i' is used outside its declaration block. Declare it outside the block or use 'let'/'const'. | 48+1 'i' is used here | 63+1 It is declared in a different scope here", + "eslint(block-scoped-var) | 'i' is used outside of binding context. | Variable 'i' is used outside its declaration block. Declare it outside the block or use 'let'/'const'. | 107+1 'i' is used here | 63+1 It is declared in a different scope here", + "eslint(block-scoped-var) | 'n' is used outside of binding context. | Variable 'n' is used outside its declaration block. Declare it outside the block or use 'let'/'const'. | 111+1 'n' is used here | 70+1 It is declared in a different scope here", + "eslint(no-var) | Unexpected var, use let or const instead. | Replace var with let or const | 59+3", + "eslint(vars-on-top) | All 'var' declarations must be at the top of the function scope. | Consider moving this to the top of the functions scope or using let or const to declare this variable. | 59+16", + "eslint(id-length) | Identifier name is too short (< 2). | | 63+1", + "eslint(id-length) | Identifier name is too short (< 2). | | 70+1", + "eslint(no-plusplus) | Unary operator '++' used. | Use the assignment operator `+=` instead. | 84+3", + "eslint(no-magic-numbers) | No magic number: 1 | Use a named constant instead of a magic number to make the code more readable and maintainable. | 118+1", + ], + ], + [ + "js", + "function f(a) {\n if (a) { var b = 1; }\n if (!a) { var b = 2, c = 3; }\n return b + c;\n}\nf(1);", + [ + "eslint(func-style) | Expected a function expression. | Enforce the consistent use of either `function` declarations or expressions assigned to variables | 0+89", + "eslint(id-length) | Identifier name is too short (< 2). | | 9+1", + "eslint(id-length) | Identifier name is too short (< 2). | | 11+1", + "eslint(block-scoped-var) | 'b' is used outside of binding context. | Variable 'b' is used outside its declaration block. Declare it outside the block or use 'let'/'const'. | 56+1 it is redeclared here | 31+1 'b' is first declared here", + "eslint(block-scoped-var) | 'b' is used outside of binding context. | Variable 'b' is used outside its declaration block. Declare it outside the block or use 'let'/'const'. | 81+1 'b' is used here | 31+1 It is declared in a different scope here", + "eslint(no-var) | Unexpected var, use let or const instead. | Replace var with let or const | 27+3", + "eslint(vars-on-top) | All 'var' declarations must be at the top of the function scope. | Consider moving this to the top of the functions scope or using let or const to declare this variable. | 27+10", + "eslint(id-length) | Identifier name is too short (< 2). | | 31+1", + "eslint(block-scoped-var) | 'b' is used outside of binding context. | Variable 'b' is used outside its declaration block. Declare it outside the block or use 'let'/'const'. | 31+1 it is redeclared here | 56+1 'b' is first declared here", + "eslint(block-scoped-var) | 'b' is used outside of binding context. | Variable 'b' is used outside its declaration block. Declare it outside the block or use 'let'/'const'. | 81+1 'b' is used here | 56+1 It is declared in a different scope here", + "eslint(block-scoped-var) | 'c' is used outside of binding context. | Variable 'c' is used outside its declaration block. Declare it outside the block or use 'let'/'const'. | 85+1 'c' is used here | 63+1 It is declared in a different scope here", + "eslint(no-var) | Unexpected var, use let or const instead. | Replace var with let or const | 52+3", + "eslint(vars-on-top) | All 'var' declarations must be at the top of the function scope. | Consider moving this to the top of the functions scope or using let or const to declare this variable. | 52+17", + "eslint(id-length) | Identifier name is too short (< 2). | | 56+1", + "eslint(id-length) | Identifier name is too short (< 2). | | 63+1", + "eslint(no-magic-numbers) | No magic number: 1 | Use a named constant instead of a magic number to make the code more readable and maintainable. | 92+1", + ], + ], + [ + "js", + "const f = async () => { g(); };\nconst h = async () => g();\nf(h);", + [ + "eslint(no-undef) | 'g' is not defined. | Either define 'g' or remove the reference to it. If 'g' is a global variable, add it to the 'globals' configuration. | 24+1", + "eslint(no-undef) | 'g' is not defined. | Either define 'g' or remove the reference to it. If 'g' is a global variable, add it to the 'globals' configuration. | 54+1", + "eslint(id-length) | Identifier name is too short (< 2). | | 6+1", + "oxc(no-async-await) | async is not allowed | Remove the `async` keyword | 10+5", + "eslint(require-await) | Async function has no `await` expression. | Consider removing the `async` keyword. | 10+20", + "eslint(id-length) | Identifier name is too short (< 2). | | 38+1", + "eslint(require-await) | Async function has no `await` expression. | Consider removing the `async` keyword. | 42+15", + "oxc(no-async-await) | async is not allowed | Remove the `async` keyword | 42+5", + ], + ], + [ + "js", + "class A {\n get a() { return 1; }\n get b() { return 2; }\n set a(v) {}\n set b(v) {}\n}\nnew A();", + [ + "eslint(id-length) | Identifier name is too short (< 2). | | 6+1", + "eslint(grouped-accessor-pairs) | Accessor pair getter 'a' and setter 'a' should be grouped. | Require grouped accessor pairs in object literals and classes | 12+5 getter 'a' is here | 60+5 setter 'a' is here", + "eslint(grouped-accessor-pairs) | Accessor pair getter 'b' and setter 'b' should be grouped. | Require grouped accessor pairs in object literals and classes | 36+5 getter 'b' is here | 74+5 setter 'b' is here", + "eslint(class-methods-use-this) | Expected method `a` to have this. | Consider converting method `a` to a static method. | 16+1", + "eslint(id-length) | Identifier name is too short (< 2). | | 16+1", + "eslint(no-magic-numbers) | No magic number: 1 | Use a named constant instead of a magic number to make the code more readable and maintainable. | 29+1", + "eslint(class-methods-use-this) | Expected method `b` to have this. | Consider converting method `b` to a static method. | 40+1", + "eslint(id-length) | Identifier name is too short (< 2). | | 40+1", + "eslint(no-magic-numbers) | No magic number: 2 | Use a named constant instead of a magic number to make the code more readable and maintainable. | 53+1", + "eslint(class-methods-use-this) | Expected method `a` to have this. | Consider converting method `a` to a static method. | 64+1", + "eslint(id-length) | Identifier name is too short (< 2). | | 64+1", + "eslint(id-length) | Identifier name is too short (< 2). | | 66+1", + "eslint(class-methods-use-this) | Expected method `b` to have this. | Consider converting method `b` to a static method. | 78+1", + "eslint(id-length) | Identifier name is too short (< 2). | | 78+1", + "eslint(id-length) | Identifier name is too short (< 2). | | 80+1", + ], + ], + ], + ], + [ + "import/group-exports reports one sort of export after the other, and those with a source in the order of oxlint's hash table", + { + "categories": { "correctness": "off" }, + "plugins": ["import"], + "rules": { "import/group-exports": "error", "import/exports-last": "error", "id-length": "error" }, + }, + [ + [ + "ts", + "export type A = 1;\nexport const a = 1;\nexport type B = 2;\nexport const b = 2;\nexport interface C {}\nexport function c() {}", + [ + "import(group-exports) | Multiple named export declarations; consolidate all named exports into a single export declaration | Use a single export declaration with multiple specifiers: `export { spec1, spec2 }` | 19+19", + "import(group-exports) | Multiple named export declarations; consolidate all named exports into a single export declaration | Use a single export declaration with multiple specifiers: `export { spec1, spec2 }` | 58+19", + "import(group-exports) | Multiple named export declarations; consolidate all named exports into a single export declaration | Use a single export declaration with multiple specifiers: `export { spec1, spec2 }` | 100+22", + "import(group-exports) | Multiple named export declarations; consolidate all named exports into a single export declaration | Use a single export declaration with multiple specifiers: `export { spec1, spec2 }` | 0+18", + "import(group-exports) | Multiple named export declarations; consolidate all named exports into a single export declaration | Use a single export declaration with multiple specifiers: `export { spec1, spec2 }` | 39+18", + "import(group-exports) | Multiple named export declarations; consolidate all named exports into a single export declaration | Use a single export declaration with multiple specifiers: `export { spec1, spec2 }` | 78+21", + "eslint(id-length) | Identifier name is too short (< 2). | | 12+1", + "eslint(id-length) | Identifier name is too short (< 2). | | 32+1", + "eslint(id-length) | Identifier name is too short (< 2). | | 51+1", + "eslint(id-length) | Identifier name is too short (< 2). | | 71+1", + "eslint(id-length) | Identifier name is too short (< 2). | | 95+1", + "eslint(id-length) | Identifier name is too short (< 2). | | 116+1", + ], + ], + [ + "ts", + 'export { a } from "./x";\nexport const b = 1;\nexport { c } from "./y";\nexport { d } from "./x";\nexport const e = 2;\nexport { f } from "./y";\nexport type { G } from "./x";\nexport type { H } from "./x";', + [ + "import(group-exports) | Multiple named export declarations; consolidate all named exports into a single export declaration | Use a single export declaration with multiple specifiers: `export { spec1, spec2 }` | 25+19", + "import(group-exports) | Multiple named export declarations; consolidate all named exports into a single export declaration | Use a single export declaration with multiple specifiers: `export { spec1, spec2 }` | 95+19", + "import(group-exports) | Multiple named export declarations; consolidate all named exports into a single export declaration | Use a single export declaration with multiple specifiers: `export { spec1, spec2 }` | 0+24", + "import(group-exports) | Multiple named export declarations; consolidate all named exports into a single export declaration | Use a single export declaration with multiple specifiers: `export { spec1, spec2 }` | 70+24", + "import(group-exports) | Multiple named export declarations; consolidate all named exports into a single export declaration | Use a single export declaration with multiple specifiers: `export { spec1, spec2 }` | 45+24", + "import(group-exports) | Multiple named export declarations; consolidate all named exports into a single export declaration | Use a single export declaration with multiple specifiers: `export { spec1, spec2 }` | 115+24", + "import(group-exports) | Multiple named export declarations; consolidate all named exports into a single export declaration | Use a single export declaration with multiple specifiers: `export { spec1, spec2 }` | 140+29", + "import(group-exports) | Multiple named export declarations; consolidate all named exports into a single export declaration | Use a single export declaration with multiple specifiers: `export { spec1, spec2 }` | 170+29", + "eslint(id-length) | Identifier name is too short (< 2). | | 38+1", + "eslint(id-length) | Identifier name is too short (< 2). | | 108+1", + ], + ], + [ + "js", + "exports.a = 1;\nexport const b = 1;\nmodule.exports.c = 2;\nexport const d = 2;\nexports.e = exports.f = 3;", + [ + "import(exports-last) | Export statements should appear at the end of the file | Move this export to the end of the file, after all other statements. | 15+19", + "import(exports-last) | Export statements should appear at the end of the file | Move this export to the end of the file, after all other statements. | 57+19", + "import(group-exports) | Multiple named export declarations; consolidate all named exports into a single export declaration | Use a single export declaration with multiple specifiers: `export { spec1, spec2 }` | 15+19", + "import(group-exports) | Multiple named export declarations; consolidate all named exports into a single export declaration | Use a single export declaration with multiple specifiers: `export { spec1, spec2 }` | 57+19", + "import(group-exports) | Multiple CommonJS exports; consolidate all exports into a single assignment to `module.exports` | Combine multiple assignments into a single `module.exports = { ... }` statement | 0+13", + "import(group-exports) | Multiple CommonJS exports; consolidate all exports into a single assignment to `module.exports` | Combine multiple assignments into a single `module.exports = { ... }` statement | 35+20", + "import(group-exports) | Multiple CommonJS exports; consolidate all exports into a single assignment to `module.exports` | Combine multiple assignments into a single `module.exports = { ... }` statement | 77+25", + "import(group-exports) | Multiple CommonJS exports; consolidate all exports into a single assignment to `module.exports` | Combine multiple assignments into a single `module.exports = { ... }` statement | 89+13", + "eslint(id-length) | Identifier name is too short (< 2). | | 8+1", + "eslint(id-length) | Identifier name is too short (< 2). | | 28+1", + "eslint(id-length) | Identifier name is too short (< 2). | | 50+1", + "eslint(id-length) | Identifier name is too short (< 2). | | 70+1", + "eslint(id-length) | Identifier name is too short (< 2). | | 85+1", + "eslint(id-length) | Identifier name is too short (< 2). | | 97+1", + ], + ], + [ + "js", + 'export { a } from "./m1";\nexport { b } from "./m2";\nexport { c } from "./m3";\nexport { d } from "./m4";\nexport { e } from "./m5";\nexport { f } from "./m5";\nexport { g } from "./m4";\nexport { h } from "./m3";\nexport { i } from "./m2";\nexport { j } from "./m1";', + [ + "import(group-exports) | Multiple named export declarations; consolidate all named exports into a single export declaration | Use a single export declaration with multiple specifiers: `export { spec1, spec2 }` | 78+25", + "import(group-exports) | Multiple named export declarations; consolidate all named exports into a single export declaration | Use a single export declaration with multiple specifiers: `export { spec1, spec2 }` | 156+25", + "import(group-exports) | Multiple named export declarations; consolidate all named exports into a single export declaration | Use a single export declaration with multiple specifiers: `export { spec1, spec2 }` | 104+25", + "import(group-exports) | Multiple named export declarations; consolidate all named exports into a single export declaration | Use a single export declaration with multiple specifiers: `export { spec1, spec2 }` | 130+25", + "import(group-exports) | Multiple named export declarations; consolidate all named exports into a single export declaration | Use a single export declaration with multiple specifiers: `export { spec1, spec2 }` | 52+25", + "import(group-exports) | Multiple named export declarations; consolidate all named exports into a single export declaration | Use a single export declaration with multiple specifiers: `export { spec1, spec2 }` | 182+25", + "import(group-exports) | Multiple named export declarations; consolidate all named exports into a single export declaration | Use a single export declaration with multiple specifiers: `export { spec1, spec2 }` | 0+25", + "import(group-exports) | Multiple named export declarations; consolidate all named exports into a single export declaration | Use a single export declaration with multiple specifiers: `export { spec1, spec2 }` | 234+25", + "import(group-exports) | Multiple named export declarations; consolidate all named exports into a single export declaration | Use a single export declaration with multiple specifiers: `export { spec1, spec2 }` | 26+25", + "import(group-exports) | Multiple named export declarations; consolidate all named exports into a single export declaration | Use a single export declaration with multiple specifiers: `export { spec1, spec2 }` | 208+25", + ], + ], + ], + ], + [ + "jest/no-export goes through oxlint's hash table of what is exported, and comes to the default export last", + { "categories": { "correctness": "off" }, "plugins": ["jest"], "rules": { "jest/no-export": "error" } }, + [ + [ + "test.js", + 'export const alpha = 1;\nexport const beta = 2;\nexport function gamma() {}\nexport default 3;\nexport const delta = 4;\nexport { epsilon };\nconst epsilon = 5;\ntest("a", () => {});', + [ + "jest(no-export) | Do not export from a test file. | If you want to share code between tests, move it into a separate file and import it from there. | 37+4", + "jest(no-export) | Do not export from a test file. | If you want to share code between tests, move it into a separate file and import it from there. | 105+5", + "jest(no-export) | Do not export from a test file. | If you want to share code between tests, move it into a separate file and import it from there. | 13+5", + "jest(no-export) | Do not export from a test file. | If you want to share code between tests, move it into a separate file and import it from there. | 125+7", + "jest(no-export) | Do not export from a test file. | If you want to share code between tests, move it into a separate file and import it from there. | 63+5", + "jest(no-export) | Do not export from a test file. | If you want to share code between tests, move it into a separate file and import it from there. | 81+7", + ], + ], + [ + "test.js", + 'export const a = 1, b = 2, c = 3, d = 4, e = 5, f = 6, g = 7, h = 8, i = 9;\ntest("a", () => {});', + [ + "jest(no-export) | Do not export from a test file. | If you want to share code between tests, move it into a separate file and import it from there. | 69+1", + "jest(no-export) | Do not export from a test file. | If you want to share code between tests, move it into a separate file and import it from there. | 13+1", + "jest(no-export) | Do not export from a test file. | If you want to share code between tests, move it into a separate file and import it from there. | 48+1", + "jest(no-export) | Do not export from a test file. | If you want to share code between tests, move it into a separate file and import it from there. | 27+1", + "jest(no-export) | Do not export from a test file. | If you want to share code between tests, move it into a separate file and import it from there. | 62+1", + "jest(no-export) | Do not export from a test file. | If you want to share code between tests, move it into a separate file and import it from there. | 41+1", + "jest(no-export) | Do not export from a test file. | If you want to share code between tests, move it into a separate file and import it from there. | 34+1", + "jest(no-export) | Do not export from a test file. | If you want to share code between tests, move it into a separate file and import it from there. | 55+1", + "jest(no-export) | Do not export from a test file. | If you want to share code between tests, move it into a separate file and import it from there. | 20+1", + ], + ], + ], + ], + [ + "cases of oxlint's own tests that it does not report in the order of the text", + { + "categories": { "correctness": "off" }, + "plugins": ["jest", "promise", "react", "unicorn", "vitest"], + "rules": { + "jest/padding-around-test-blocks": "error", + "jest/prefer-mock-promise-shorthand": "error", + "jest/prefer-mock-return-shorthand": "error", + "jest/require-hook": "error", + "promise/no-return-wrap": "error", + "promise/prefer-await-to-then": "error", + "react/exhaustive-deps": "error", + "react/only-export-components": "error", + "unicorn/catch-error-name": "error", + "unicorn/custom-error-definition": "error", + "unicorn/escape-case": "error", + "unicorn/no-useless-length-check": "error", + "unicorn/no-useless-switch-case": "error", + "unicorn/prefer-array-find": "error", + "unicorn/prefer-dom-node-append": "error", + "unicorn/prefer-query-selector": "error", + "vitest/padding-around-test-blocks": "error", + "vitest/prefer-mock-promise-shorthand": "error", + "vitest/prefer-mock-return-shorthand": "error", + "vitest/require-hook": "error", + "vitest/warn-todo": "error", + }, + }, + [ + [ + "test.tsx", + "it('foo', () => {});\nfit('bar', () => {});\ntest('baz', () => {});", + [ + "jest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 21+20", + "vitest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 21+20", + "jest(padding-around-test-blocks) | Missing padding before test block | Make sure there is an empty new line before the test block | 43+0", + "vitest(padding-around-test-blocks) | Missing padding before test block | Make sure there is an empty new line before the test block | 43+0", + "jest(padding-around-test-blocks) | Missing padding before fit block | Make sure there is an empty new line before the fit block | 21+0", + "vitest(padding-around-test-blocks) | Missing padding before fit block | Make sure there is an empty new line before the fit block | 21+0", + ], + ], + [ + "test.tsx", + "\nconst foo = 'bar';\nconst bar = 'baz';\nit('foo', () => {\n // stuff\n});\nfit('bar', () => {\n // stuff\n});\ntest('foo foo', () => {});\ntest('bar bar', () => {});\n\n// Nesting\n describe('other bar', () => {\n const thing = 123;\n test('is another bar w/ test', () => {\n });\n // With a comment\n it('is another bar w/ it', () => {\n });\n test.skip('skipping', () => {}); // Another comment\n it.skip('skipping too', () => {});\n });xtest('weird', () => {});\n test\n .skip('skippy skip', () => {});\n xit('bar foo', () => {});\n ", + [ + "jest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 72+32", + "jest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 453+24", + "jest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 521+24", + "vitest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 72+32", + "vitest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 453+24", + "vitest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 521+24", + "jest(padding-around-test-blocks) | Missing padding before it block | Make sure there is an empty new line before the it block | 39+0", + "vitest(padding-around-test-blocks) | Missing padding before it block | Make sure there is an empty new line before the it block | 39+0", + "jest(padding-around-test-blocks) | Missing padding before it block | Make sure there is an empty new line before the it block | 308+0", + "vitest(padding-around-test-blocks) | Missing padding before it block | Make sure there is an empty new line before the it block | 308+0", + "jest(padding-around-test-blocks) | Missing padding before it block | Make sure there is an empty new line before the it block | 414+0", + "vitest(padding-around-test-blocks) | Missing padding before it block | Make sure there is an empty new line before the it block | 414+0", + "jest(padding-around-test-blocks) | Missing padding before fit block | Make sure there is an empty new line before the fit block | 72+0", + "vitest(padding-around-test-blocks) | Missing padding before fit block | Make sure there is an empty new line before the fit block | 72+0", + "jest(padding-around-test-blocks) | Missing padding before test block | Make sure there is an empty new line before the test block | 106+0", + "vitest(padding-around-test-blocks) | Missing padding before test block | Make sure there is an empty new line before the test block | 106+0", + "jest(padding-around-test-blocks) | Missing padding before test block | Make sure there is an empty new line before the test block | 133+0", + "vitest(padding-around-test-blocks) | Missing padding before test block | Make sure there is an empty new line before the test block | 133+0", + "jest(padding-around-test-blocks) | Missing padding before test block | Make sure there is an empty new line before the test block | 232+0", + "vitest(padding-around-test-blocks) | Missing padding before test block | Make sure there is an empty new line before the test block | 232+0", + "jest(padding-around-test-blocks) | Missing padding before test block | Make sure there is an empty new line before the test block | 357+0", + "vitest(padding-around-test-blocks) | Missing padding before test block | Make sure there is an empty new line before the test block | 357+0", + "jest(padding-around-test-blocks) | Missing padding before test block | Make sure there is an empty new line before the test block | 480+0", + "vitest(padding-around-test-blocks) | Missing padding before test block | Make sure there is an empty new line before the test block | 480+0", + "jest(padding-around-test-blocks) | Missing padding before xtest block | Make sure there is an empty new line before the xtest block | 453+0", + "vitest(padding-around-test-blocks) | Missing padding before xtest block | Make sure there is an empty new line before the xtest block | 453+0", + "jest(padding-around-test-blocks) | Missing padding before xit block | Make sure there is an empty new line before the xit block | 521+0", + "vitest(padding-around-test-blocks) | Missing padding before xit block | Make sure there is an empty new line before the xit block | 521+0", + ], + ], + [ + "test.tsx", + "\nconst foo = 'bar';\nconst bar = 'baz';\nit('foo', () => {\n // stuff\n});\nfit('bar', () => {\n // stuff\n});\ntest('foo foo', () => {});\ntest('bar bar', () => {});\n\n// Nesting\ndescribe('other bar', () => {\n const thing = 123;\n test('is another bar w/ test', () => {\n });\n // With a comment\n it('is another bar w/ it', () => {\n });\n test.skip('skipping', () => {}); // Another comment\n it.skip('skipping too', () => {});\n});xtest('weird', () => {});\ntest\n .skip('skippy skip', () => {});\nxit('bar foo', () => {});\n ", + [ + "jest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 72+32", + "jest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 443+24", + "jest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 508+24", + "vitest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 72+32", + "vitest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 443+24", + "vitest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 508+24", + "jest(padding-around-test-blocks) | Missing padding before it block | Make sure there is an empty new line before the it block | 39+0", + "vitest(padding-around-test-blocks) | Missing padding before it block | Make sure there is an empty new line before the it block | 39+0", + "jest(padding-around-test-blocks) | Missing padding before it block | Make sure there is an empty new line before the it block | 302+0", + "vitest(padding-around-test-blocks) | Missing padding before it block | Make sure there is an empty new line before the it block | 302+0", + "jest(padding-around-test-blocks) | Missing padding before it block | Make sure there is an empty new line before the it block | 405+0", + "vitest(padding-around-test-blocks) | Missing padding before it block | Make sure there is an empty new line before the it block | 405+0", + "jest(padding-around-test-blocks) | Missing padding before fit block | Make sure there is an empty new line before the fit block | 72+0", + "vitest(padding-around-test-blocks) | Missing padding before fit block | Make sure there is an empty new line before the fit block | 72+0", + "jest(padding-around-test-blocks) | Missing padding before test block | Make sure there is an empty new line before the test block | 106+0", + "vitest(padding-around-test-blocks) | Missing padding before test block | Make sure there is an empty new line before the test block | 106+0", + "jest(padding-around-test-blocks) | Missing padding before test block | Make sure there is an empty new line before the test block | 133+0", + "vitest(padding-around-test-blocks) | Missing padding before test block | Make sure there is an empty new line before the test block | 133+0", + "jest(padding-around-test-blocks) | Missing padding before test block | Make sure there is an empty new line before the test block | 229+0", + "vitest(padding-around-test-blocks) | Missing padding before test block | Make sure there is an empty new line before the test block | 229+0", + "jest(padding-around-test-blocks) | Missing padding before test block | Make sure there is an empty new line before the test block | 349+0", + "vitest(padding-around-test-blocks) | Missing padding before test block | Make sure there is an empty new line before the test block | 349+0", + "jest(padding-around-test-blocks) | Missing padding before test block | Make sure there is an empty new line before the test block | 469+0", + "vitest(padding-around-test-blocks) | Missing padding before test block | Make sure there is an empty new line before the test block | 469+0", + "jest(padding-around-test-blocks) | Missing padding before xtest block | Make sure there is an empty new line before the xtest block | 443+0", + "vitest(padding-around-test-blocks) | Missing padding before xtest block | Make sure there is an empty new line before the xtest block | 443+0", + "jest(padding-around-test-blocks) | Missing padding before xit block | Make sure there is an empty new line before the xit block | 508+0", + "vitest(padding-around-test-blocks) | Missing padding before xit block | Make sure there is an empty new line before the xit block | 508+0", + ], + ], + [ + "test.tsx", + "\n aVariable\n .mockImplementation(() => Promise.reject(42))\n .mockImplementation(() => Promise.resolve(42))\n .mockReturnValue(Promise.reject(42))\n ", + [ + "jest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 17+199", + "vitest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 17+199", + 'jest(prefer-mock-promise-shorthand) | Prefer mock resolved/rejected shorthands for promises. | Prefer "mockRejectedValue" | 181+15', + 'vitest(prefer-mock-promise-shorthand) | Prefer mock resolved/rejected shorthands for promises. | Prefer "mockRejectedValue" | 181+15', + 'jest(prefer-mock-promise-shorthand) | Prefer mock resolved/rejected shorthands for promises. | Prefer "mockResolvedValue" | 114+18', + "jest(prefer-mock-return-shorthand) | Mock functions that return simple values should use `mockReturnValue/mockReturnValueOnce`. | Replace `mockImplementation` with `mockReturnValue`. | 114+18", + 'vitest(prefer-mock-promise-shorthand) | Prefer mock resolved/rejected shorthands for promises. | Prefer "mockResolvedValue" | 114+18', + "vitest(prefer-mock-return-shorthand) | Mock functions that return simple values should use `mockReturnValue/mockReturnValueOnce`. | Replace `mockImplementation` with `mockReturnValue`. | 114+18", + 'jest(prefer-mock-promise-shorthand) | Prefer mock resolved/rejected shorthands for promises. | Prefer "mockRejectedValue" | 48+18', + "jest(prefer-mock-return-shorthand) | Mock functions that return simple values should use `mockReturnValue/mockReturnValueOnce`. | Replace `mockImplementation` with `mockReturnValue`. | 48+18", + 'vitest(prefer-mock-promise-shorthand) | Prefer mock resolved/rejected shorthands for promises. | Prefer "mockRejectedValue" | 48+18', + "vitest(prefer-mock-return-shorthand) | Mock functions that return simple values should use `mockReturnValue/mockReturnValueOnce`. | Replace `mockImplementation` with `mockReturnValue`. | 48+18", + ], + ], + [ + "test.tsx", + "\n aVariable\n .mockReturnValueOnce(Promise.reject(42))\n .mockImplementation(() => Promise.resolve(42))\n .mockReturnValueOnce(Promise.reject(42))\n ", + [ + "jest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 17+198", + "vitest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 17+198", + 'jest(prefer-mock-promise-shorthand) | Prefer mock resolved/rejected shorthands for promises. | Prefer "mockRejectedValueOnce" | 176+19', + 'vitest(prefer-mock-promise-shorthand) | Prefer mock resolved/rejected shorthands for promises. | Prefer "mockRejectedValueOnce" | 176+19', + 'jest(prefer-mock-promise-shorthand) | Prefer mock resolved/rejected shorthands for promises. | Prefer "mockResolvedValue" | 109+18', + "jest(prefer-mock-return-shorthand) | Mock functions that return simple values should use `mockReturnValue/mockReturnValueOnce`. | Replace `mockImplementation` with `mockReturnValue`. | 109+18", + 'vitest(prefer-mock-promise-shorthand) | Prefer mock resolved/rejected shorthands for promises. | Prefer "mockResolvedValue" | 109+18', + "vitest(prefer-mock-return-shorthand) | Mock functions that return simple values should use `mockReturnValue/mockReturnValueOnce`. | Replace `mockImplementation` with `mockReturnValue`. | 109+18", + 'jest(prefer-mock-promise-shorthand) | Prefer mock resolved/rejected shorthands for promises. | Prefer "mockRejectedValueOnce" | 48+19', + 'vitest(prefer-mock-promise-shorthand) | Prefer mock resolved/rejected shorthands for promises. | Prefer "mockRejectedValueOnce" | 48+19', + ], + ], + [ + "test.tsx", + 'aVariable\n .mockImplementation(() => 42)\n .mockImplementation(async () => 42)\n .mockImplementation(() => Promise.resolve(42))\n .mockReturnValue("hello world")', + [ + "jest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 0+210", + "vitest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 0+210", + 'jest(prefer-mock-promise-shorthand) | Prefer mock resolved/rejected shorthands for promises. | Prefer "mockResolvedValue" | 119+18', + "jest(prefer-mock-return-shorthand) | Mock functions that return simple values should use `mockReturnValue/mockReturnValueOnce`. | Replace `mockImplementation` with `mockReturnValue`. | 119+18", + 'vitest(prefer-mock-promise-shorthand) | Prefer mock resolved/rejected shorthands for promises. | Prefer "mockResolvedValue" | 119+18', + "vitest(prefer-mock-return-shorthand) | Mock functions that return simple values should use `mockReturnValue/mockReturnValueOnce`. | Replace `mockImplementation` with `mockReturnValue`. | 119+18", + "jest(prefer-mock-return-shorthand) | Mock functions that return simple values should use `mockReturnValue/mockReturnValueOnce`. | Replace `mockImplementation` with `mockReturnValue`. | 25+18", + "vitest(prefer-mock-return-shorthand) | Mock functions that return simple values should use `mockReturnValue/mockReturnValueOnce`. | Replace `mockImplementation` with `mockReturnValue`. | 25+18", + ], + ], + [ + "test.tsx", + 'aVariable\n .mockImplementationOnce(() => Promise.reject(42))\n .mockImplementation(() => "hello sunshine")\n .mockReturnValueOnce(Promise.reject(42))', + [ + "jest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 0+186", + "vitest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 0+186", + 'jest(prefer-mock-promise-shorthand) | Prefer mock resolved/rejected shorthands for promises. | Prefer "mockRejectedValueOnce" | 147+19', + 'vitest(prefer-mock-promise-shorthand) | Prefer mock resolved/rejected shorthands for promises. | Prefer "mockRejectedValueOnce" | 147+19', + "jest(prefer-mock-return-shorthand) | Mock functions that return simple values should use `mockReturnValue/mockReturnValueOnce`. | Replace `mockImplementation` with `mockReturnValue`. | 89+18", + "vitest(prefer-mock-return-shorthand) | Mock functions that return simple values should use `mockReturnValue/mockReturnValueOnce`. | Replace `mockImplementation` with `mockReturnValue`. | 89+18", + 'jest(prefer-mock-promise-shorthand) | Prefer mock resolved/rejected shorthands for promises. | Prefer "mockRejectedValueOnce" | 25+22', + "jest(prefer-mock-return-shorthand) | Mock functions that return simple values should use `mockReturnValue/mockReturnValueOnce`. | Replace `mockImplementationOnce` with `mockReturnValueOnce`. | 25+22", + 'vitest(prefer-mock-promise-shorthand) | Prefer mock resolved/rejected shorthands for promises. | Prefer "mockRejectedValueOnce" | 25+22', + "vitest(prefer-mock-return-shorthand) | Mock functions that return simple values should use `mockReturnValue/mockReturnValueOnce`. | Replace `mockImplementationOnce` with `mockReturnValueOnce`. | 25+22", + ], + ], + [ + "test.tsx", + "\n import { database, isCity } from '../database';\n import { loadCities } from '../api';\n\n jest.mock('../api');\n\n const initializeCityDatabase = () => {\n database.addCity('Vienna');\n database.addCity('San Juan');\n database.addCity('Wellington');\n };\n\n const clearCityDatabase = () => {\n database.clear();\n };\n\n initializeCityDatabase();\n\n test('that persists cities', () => {\n expect(database.cities.length).toHaveLength(3);\n });\n\n test('city database has Vienna', () => {\n expect(isCity('Vienna')).toBeTruthy();\n });\n\n test('city database has San Juan', () => {\n expect(isCity('San Juan')).toBeTruthy();\n });\n\n describe('when loading cities from the api', () => {\n let consoleWarnSpy = jest.spyOn(console, 'warn');\n\n loadCities.mockResolvedValue(['Wellington', 'London']);\n\n it('does not duplicate cities', async () => {\n await database.loadCities();\n\n expect(database.cities).toHaveLength(4);\n });\n\n it('logs any duplicates', async () => {\n await database.loadCities();\n\n expect(consoleWarnSpy).toHaveBeenCalledWith(\n 'Ignored duplicate cities: Wellington',\n );\n });\n });\n\n clearCityDatabase();\n ", + [ + "jest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 506+24", + "jest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 1719+19", + "vitest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 506+24", + "vitest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 1719+19", + "jest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 1042+49", + "jest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 1113+54", + "vitest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 1042+49", + "vitest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 1113+54", + ], + ], + [ + "tsx", + "fn(function() {\n\t\t\t fn2(function() {\n\t\t\t doThing().then(function() {\n\t\t\t fn3(function() {\n\t\t\t return Promise.resolve(4)\n\t\t\t })\n\t\t\t return Promise.resolve(4)\n\t\t\t })\n\t\t\t })\n\t\t\t })", + [ + "jest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 0+215", + "vitest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 0+215", + "promise(no-return-wrap) | Avoid wrapping return values in Promise.resolve | Return the value being passed into Promise.resolve instead | 170+18", + "promise(prefer-await-to-then) | Prefer await to then()/catch()/finally() | Use `await` with `try`/`catch` instead of promise chaining for more readable and maintainable async code. | 57+4", + "promise(no-return-wrap) | Avoid wrapping return values in Promise.resolve | Return the value being passed into Promise.resolve instead | 121+18", + ], + ], + [ + "tsx", + "fn((() => {\n\t\t\t fn2(function() {\n\t\t\t doThing().then(function() {\n\t\t\t fn3(function() {\n\t\t\t return Promise.resolve(4)\n\t\t\t })\n\t\t\t return Promise.resolve(4)\n\t\t\t })\n\t\t\t }).bind(this)\n\t\t\t }))", + [ + "jest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 0+223", + "vitest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 0+223", + "promise(no-return-wrap) | Avoid wrapping return values in Promise.resolve | Return the value being passed into Promise.resolve instead | 166+18", + "promise(prefer-await-to-then) | Prefer await to then()/catch()/finally() | Use `await` with `try`/`catch` instead of promise chaining for more readable and maintainable async code. | 53+4", + "promise(no-return-wrap) | Avoid wrapping return values in Promise.resolve | Return the value being passed into Promise.resolve instead | 117+18", + ], + ], + [ + "tsx", + "function foo() { hey.then(function() { }).then() }", + [ + "promise(prefer-await-to-then) | Prefer await to then()/catch()/finally() | Use `await` with `try`/`catch` instead of promise chaining for more readable and maintainable async code. | 42+4", + "promise(prefer-await-to-then) | Prefer await to then()/catch()/finally() | Use `await` with `try`/`catch` instead of promise chaining for more readable and maintainable async code. | 21+4", + ], + ], + [ + "tsx", + "function foo() { hey.then(function() { }).then(x).catch() }", + [ + "promise(prefer-await-to-then) | Prefer await to then()/catch()/finally() | Use `await` with `try`/`catch` instead of promise chaining for more readable and maintainable async code. | 50+5", + "promise(prefer-await-to-then) | Prefer await to then()/catch()/finally() | Use `await` with `try`/`catch` instead of promise chaining for more readable and maintainable async code. | 42+4", + "promise(prefer-await-to-then) | Prefer await to then()/catch()/finally() | Use `await` with `try`/`catch` instead of promise chaining for more readable and maintainable async code. | 21+4", + ], + ], + [ + "tsx", + "async function a() { hey.then(function() { }).then(function() { }) }", + [ + "promise(prefer-await-to-then) | Prefer await to then()/catch()/finally() | Use `await` with `try`/`catch` instead of promise chaining for more readable and maintainable async code. | 46+4", + "promise(prefer-await-to-then) | Prefer await to then()/catch()/finally() | Use `await` with `try`/`catch` instead of promise chaining for more readable and maintainable async code. | 25+4", + ], + ], + [ + "tsx", + "function MyComponent() {\n const local1 = someFunc();\n function MyNestedComponent() {\n const local2 = {};\n useCallback(() => {\n console.log(local1);\n console.log(local2);\n }, [local1]);\n }\n }", + [ + "react(only-export-components) | Fast refresh only works when a file has exports. Move your component(s) to a separate file. | | 9+11", + "react(only-export-components) | Fast refresh only works when a file has exports. Move your component(s) to a separate file. | | 81+17", + "react-hooks(exhaustive-deps) | React Hook useCallback has an unnecessary dependency: local1. | Consider removing it from the dependency array. Outer scope values aren't valid dependencies because mutating them doesn't re-render the component. | 252+6", + "react-hooks(exhaustive-deps) | React Hook useCallback has a missing dependency: 'local2' | Either include it or remove the dependency array. | 227+6 useCallback uses `local2` here | 251+8", + ], + ], + [ + "tsx", + "function MyComponent() {\n const local = {};\n useEffect(() => {\n console.log(local);\n console.log(local);\n }, [local, local]);\n }", + [ + "react(only-export-components) | Fast refresh only works when a file has exports. Move your component(s) to a separate file. | | 9+11", + "react-hooks(exhaustive-deps) | This dependency is specified more than once in the dependency array. | Remove the duplicate dependency from the array. | 166+5", + "react-hooks(exhaustive-deps) | React hook useEffect depends on `local`, which changes every render | Try memoizing this variable with `useRef` or `useCallback`. | 159+5 it will always cause this hook to re-evaluate | 41+5 `local` is declared here", + ], + ], + [ + "tsx", + "function MyComponent() {\n useCallback(() => {}, [window]);\n }", + [ + "react(only-export-components) | Fast refresh only works when a file has exports. Move your component(s) to a separate file. | | 9+11", + "react-hooks(exhaustive-deps) | React Hook useCallback has an unnecessary dependency: window. | Consider removing it from the dependency array. Outer scope values aren't valid dependencies because mutating them doesn't re-render the component. | 58+6", + "react-hooks(exhaustive-deps) | React Hook useCallback has unnecessary dependency: window | Either include it or remove the dependency array. | 57+8", + ], + ], + [ + "tsx", + "const MainView = () => {}; export default compose()(MainView);", + [ + "react(only-export-components) | Fast refresh can't handle anonymous components. Add a name to your export. | | 27+35", + "react(only-export-components) | Fast refresh only works when a file only exports components. Move your component(s) to a separate file. | | 6+8", + ], + ], + [ + "tsx", + "const MyComponent = () => {}; export default observer(MyComponent);", + [ + "react(only-export-components) | Fast refresh can't handle anonymous components. Add a name to your export. | | 30+37", + "react(only-export-components) | Fast refresh only works when a file only exports components. Move your component(s) to a separate file. | | 6+11", + ], + ], + [ + "tsx", + "const Root = () =>
    ; export default { Root };", + [ + "react(only-export-components) | Fast refresh can't handle anonymous components. Add a name to your export. | | 28+24", + "react(only-export-components) | Fast refresh only works when a file only exports components. Move your component(s) to a separate file. | | 6+4", + ], + ], + [ + "tsx", + "foo.then(() => {\n try {} catch (e) {}\n }).catch(err => err);", + [ + "jest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 0+85", + "vitest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 0+85", + 'unicorn(catch-error-name) | The catch parameter "err" should be named "error" | Rename `err` to `error` | 74+3', + "promise(prefer-await-to-then) | Prefer await to then()/catch()/finally() | Use `await` with `try`/`catch` instead of promise chaining for more readable and maintainable async code. | 68+5", + "promise(prefer-await-to-then) | Prefer await to then()/catch()/finally() | Use `await` with `try`/`catch` instead of promise chaining for more readable and maintainable async code. | 4+4", + 'unicorn(catch-error-name) | The catch parameter "e" should be named "error" | Replace `e` with `error`. | 47+1', + ], + ], + [ + "tsx", + "class \u00barror extends Error {}", + [ + "unicorn(custom-error-definition) | Invalid class name, use `\u00barrorError`. | | 6+6", + "unicorn(custom-error-definition) | The `name` property should be set to `\u00barror`. | | 0+29", + ], + ], + [ + "tsx", + "class FooError extends Error {\n constructor() {\n super();\n this.message = 'My custom message';\n }\n }", + [ + "unicorn(custom-error-definition) | Pass the error message to `super()` instead of setting `this.message`. | | 75+8", + "unicorn(custom-error-definition) | The `name` property should be set to `FooError`. | | 57+92", + ], + ], + [ + "tsx", + "class FooError extends Error {\n constructor(message) {\n super();\n this.message = message;\n }\n }", + [ + "unicorn(custom-error-definition) | Pass the error message to `super()` instead of setting `this.message`. | | 82+8", + "unicorn(custom-error-definition) | The `name` property should be set to `FooError`. | | 64+80", + ], + ], + [ + "tsx", + 'const foo = `${"\\ud834 foo"} \\ud834`;', + [ + "unicorn(escape-case) | Use uppercase characters for the value of the escape sequence. | Replace ` \\ud834` with ` \\uD834`. | 28+7", + 'unicorn(escape-case) | Use uppercase characters for the value of the escape sequence. | Replace `"\\ud834 foo"` with `"\\uD834 foo"`. | 15+12', + ], + ], + [ + "tsx", + "array.length === 0 || array.every(Boolean) || array.length === 0", + [ + "unicorn(no-useless-length-check) | Found a useless array length check | The empty check is useless as `Array#every()` returns `true` for an empty array. | 0+18", + "unicorn(no-useless-length-check) | Found a useless array length check | The empty check is useless as `Array#every()` returns `true` for an empty array. | 46+18", + "unicorn(no-useless-length-check) | Found a useless array length check | The empty check is useless as `Array#every()` returns `true` for an empty array. | 0+18", + ], + ], + [ + "tsx", + "array1.every(Boolean)\n || (( array1.length === 0 || array2.length === 0 )) // Both useless\n || array2.every(Boolean)", + [ + "unicorn(no-useless-length-check) | Found a useless array length check | The empty check is useless as `Array#every()` returns `true` for an empty array. | 40+19", + "unicorn(no-useless-length-check) | Found a useless array length check | The empty check is useless as `Array#every()` returns `true` for an empty array. | 63+19", + "unicorn(no-useless-length-check) | Found a useless array length check | The empty check is useless as `Array#every()` returns `true` for an empty array. | 40+19", + ], + ], + [ + "tsx", + "\n switch (foo) {\n case a:\n case (( b )) :\n default:\n handleDefaultCase();\n break;\n }\n ", + [ + "unicorn(no-useless-switch-case) | Useless case in switch statement. | Consider removing this case or removing the `default` case. | 56+22", + "unicorn(no-useless-switch-case) | Useless case in switch statement. | Consider removing this case or removing the `default` case. | 36+7", + ], + ], + [ + "tsx", + "\n switch (foo) {\n case a:\n case b:\n handleCaseAB();\n break;\n case d:\n case d:\n default:\n handleDefaultCase();\n break;\n }\n ", + [ + "unicorn(no-useless-switch-case) | Useless case in switch statement. | Consider removing this case or removing the `default` case. | 151+7", + "unicorn(no-useless-switch-case) | Useless case in switch statement. | Consider removing this case or removing the `default` case. | 131+7", + ], + ], + [ + "tsx", + "\n switch (foo) {\n case a:\n case b:\n default:\n handleDefaultCase();\n break;\n }\n ", + [ + "unicorn(no-useless-switch-case) | Useless case in switch statement. | Consider removing this case or removing the `default` case. | 56+7", + "unicorn(no-useless-switch-case) | Useless case in switch statement. | Consider removing this case or removing the `default` case. | 36+7", + ], + ], + [ + "tsx", + "const quz = array.filter(fn);\n const [foo] = array.filter(quz[0]);\n [{bar: baz}] = foo[\n array.filter(fn)[0]\n ].filter(\n array.filter(fn).shift()\n );", + [ + "unicorn(prefer-array-find) | Prefer `find` over filtering and accessing the first result. | Use `find(predicate)` instead of `filter(predicate)[0]` or similar patterns. | 18+6", + "unicorn(prefer-array-find) | Prefer `find` over filtering and accessing the first result. | Use `find(predicate)` instead of `filter(predicate)[0]` or similar patterns. | 62+6", + "unicorn(prefer-array-find) | Prefer `find` over filtering and accessing the first result. | Use `find(predicate)` instead of `filter(predicate)[0]` or similar patterns. | 160+6", + "unicorn(prefer-array-find) | Prefer `find` over filtering and accessing the first result. | Use `find(predicate)` instead of `filter(predicate)[0]` or similar patterns. | 132+6", + "unicorn(prefer-array-find) | Prefer `find` over filtering and accessing the first result. | Use `find(predicate)` instead of `filter(predicate)[0]` or similar patterns. | 190+6", + ], + ], + [ + "tsx", + "node.appendChild(child).appendChild(grandchild);", + [ + "jest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 0+47", + "vitest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 0+47", + "unicorn(prefer-dom-node-append) | Prefer `Node#append()` over `Node#appendChild()` for DOM nodes. | Replace `Node#appendChild()` with `Node#append()`. | 24+11", + "unicorn(prefer-dom-node-append) | Prefer `Node#append()` over `Node#appendChild()` for DOM nodes. | Replace `Node#appendChild()` with `Node#append()`. | 5+11", + ], + ], + [ + "tsx", + 'for (const div of document.body.getElementById("id").getElementsByClassName("class")) {\n console.log(div.getElementsByTagName("div"));\n }', + [ + "unicorn(prefer-query-selector) | Prefer `.querySelectorAll()` over `.getElementsByClassName()`. | It's better to use the same method to query DOM elements. This helps keep consistency and it lends itself to future improvements (e.g. more specific selectors). | 53+22", + "unicorn(prefer-query-selector) | Prefer `.querySelector()` over `.getElementById()`. | It's better to use the same method to query DOM elements. This helps keep consistency and it lends itself to future improvements (e.g. more specific selectors). | 32+14", + "unicorn(prefer-query-selector) | Prefer `.querySelectorAll()` over `.getElementsByTagName()`. | It's better to use the same method to query DOM elements. This helps keep consistency and it lends itself to future improvements (e.g. more specific selectors). | 120+20", + ], + ], + [ + "test.tsx", + "\nconst foo = 'bar';\nconst bar = 'baz';\nit('foo', () => {\n // stuff\n});\ntest('foo foo', () => {});\ntest('bar bar', () => {});\n\n// Nesting\n describe('other bar', () => {\n const thing = 123;\n test('is another bar w/ test', () => {\n });\n // With a comment\n it('is another bar w/ it', () => {\n });\n test.skip('skipping', () => {}); // Another comment\n it.skip('skipping too', () => {});\n });\n test\n .skip('skippy skip', () => {});\n ", + [ + "jest(padding-around-test-blocks) | Missing padding before it block | Make sure there is an empty new line before the it block | 39+0", + "vitest(padding-around-test-blocks) | Missing padding before it block | Make sure there is an empty new line before the it block | 39+0", + "jest(padding-around-test-blocks) | Missing padding before it block | Make sure there is an empty new line before the it block | 274+0", + "vitest(padding-around-test-blocks) | Missing padding before it block | Make sure there is an empty new line before the it block | 274+0", + "jest(padding-around-test-blocks) | Missing padding before it block | Make sure there is an empty new line before the it block | 380+0", + "vitest(padding-around-test-blocks) | Missing padding before it block | Make sure there is an empty new line before the it block | 380+0", + "jest(padding-around-test-blocks) | Missing padding before test block | Make sure there is an empty new line before the test block | 72+0", + "vitest(padding-around-test-blocks) | Missing padding before test block | Make sure there is an empty new line before the test block | 72+0", + "jest(padding-around-test-blocks) | Missing padding before test block | Make sure there is an empty new line before the test block | 99+0", + "vitest(padding-around-test-blocks) | Missing padding before test block | Make sure there is an empty new line before the test block | 99+0", + "jest(padding-around-test-blocks) | Missing padding before test block | Make sure there is an empty new line before the test block | 198+0", + "vitest(padding-around-test-blocks) | Missing padding before test block | Make sure there is an empty new line before the test block | 198+0", + "jest(padding-around-test-blocks) | Missing padding before test block | Make sure there is an empty new line before the test block | 323+0", + "vitest(padding-around-test-blocks) | Missing padding before test block | Make sure there is an empty new line before the test block | 323+0", + "jest(padding-around-test-blocks) | Missing padding before test block | Make sure there is an empty new line before the test block | 421+0", + "vitest(padding-around-test-blocks) | Missing padding before test block | Make sure there is an empty new line before the test block | 421+0", + ], + ], + [ + "test.tsx", + "\nconst foo = 'bar';\nconst bar = 'baz';\nit('foo', () => {\n // stuff\n});\ntest('foo foo', () => {});\ntest('bar bar', () => {});\n\n// Nesting\ndescribe('other bar', () => {\n const thing = 123;\n test('is another bar w/ test', () => {\n });\n // With a comment\n it('is another bar w/ it', () => {\n });\n test.skip('skipping', () => {}); // Another comment\n it.skip('skipping too', () => {});\n});\ntest\n .skip('skippy skip', () => {});\n ", + [ + "jest(padding-around-test-blocks) | Missing padding before it block | Make sure there is an empty new line before the it block | 39+0", + "vitest(padding-around-test-blocks) | Missing padding before it block | Make sure there is an empty new line before the it block | 39+0", + "jest(padding-around-test-blocks) | Missing padding before it block | Make sure there is an empty new line before the it block | 268+0", + "vitest(padding-around-test-blocks) | Missing padding before it block | Make sure there is an empty new line before the it block | 268+0", + "jest(padding-around-test-blocks) | Missing padding before it block | Make sure there is an empty new line before the it block | 371+0", + "vitest(padding-around-test-blocks) | Missing padding before it block | Make sure there is an empty new line before the it block | 371+0", + "jest(padding-around-test-blocks) | Missing padding before test block | Make sure there is an empty new line before the test block | 72+0", + "vitest(padding-around-test-blocks) | Missing padding before test block | Make sure there is an empty new line before the test block | 72+0", + "jest(padding-around-test-blocks) | Missing padding before test block | Make sure there is an empty new line before the test block | 99+0", + "vitest(padding-around-test-blocks) | Missing padding before test block | Make sure there is an empty new line before the test block | 99+0", + "jest(padding-around-test-blocks) | Missing padding before test block | Make sure there is an empty new line before the test block | 195+0", + "vitest(padding-around-test-blocks) | Missing padding before test block | Make sure there is an empty new line before the test block | 195+0", + "jest(padding-around-test-blocks) | Missing padding before test block | Make sure there is an empty new line before the test block | 315+0", + "vitest(padding-around-test-blocks) | Missing padding before test block | Make sure there is an empty new line before the test block | 315+0", + "jest(padding-around-test-blocks) | Missing padding before test block | Make sure there is an empty new line before the test block | 410+0", + "vitest(padding-around-test-blocks) | Missing padding before test block | Make sure there is an empty new line before the test block | 410+0", + ], + ], + [ + "test.tsx", + "\n aVariable\n .mockImplementation(() => Promise.reject(42))\n .mockImplementation(() => Promise.resolve(42))\n .mockReturnValue(Promise.reject(42))\n ", + [ + "jest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 17+199", + "vitest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 17+199", + 'jest(prefer-mock-promise-shorthand) | Prefer mock resolved/rejected shorthands for promises. | Prefer "mockRejectedValue" | 181+15', + 'vitest(prefer-mock-promise-shorthand) | Prefer mock resolved/rejected shorthands for promises. | Prefer "mockRejectedValue" | 181+15', + 'jest(prefer-mock-promise-shorthand) | Prefer mock resolved/rejected shorthands for promises. | Prefer "mockResolvedValue" | 114+18', + "jest(prefer-mock-return-shorthand) | Mock functions that return simple values should use `mockReturnValue/mockReturnValueOnce`. | Replace `mockImplementation` with `mockReturnValue`. | 114+18", + 'vitest(prefer-mock-promise-shorthand) | Prefer mock resolved/rejected shorthands for promises. | Prefer "mockResolvedValue" | 114+18', + "vitest(prefer-mock-return-shorthand) | Mock functions that return simple values should use `mockReturnValue/mockReturnValueOnce`. | Replace `mockImplementation` with `mockReturnValue`. | 114+18", + 'jest(prefer-mock-promise-shorthand) | Prefer mock resolved/rejected shorthands for promises. | Prefer "mockRejectedValue" | 48+18', + "jest(prefer-mock-return-shorthand) | Mock functions that return simple values should use `mockReturnValue/mockReturnValueOnce`. | Replace `mockImplementation` with `mockReturnValue`. | 48+18", + 'vitest(prefer-mock-promise-shorthand) | Prefer mock resolved/rejected shorthands for promises. | Prefer "mockRejectedValue" | 48+18', + "vitest(prefer-mock-return-shorthand) | Mock functions that return simple values should use `mockReturnValue/mockReturnValueOnce`. | Replace `mockImplementation` with `mockReturnValue`. | 48+18", + ], + ], + [ + "test.tsx", + "\n aVariable\n .mockReturnValueOnce(Promise.reject(42))\n .mockImplementation(() => Promise.resolve(42))\n .mockReturnValueOnce(Promise.reject(42))\n ", + [ + "jest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 17+198", + "vitest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 17+198", + 'jest(prefer-mock-promise-shorthand) | Prefer mock resolved/rejected shorthands for promises. | Prefer "mockRejectedValueOnce" | 176+19', + 'vitest(prefer-mock-promise-shorthand) | Prefer mock resolved/rejected shorthands for promises. | Prefer "mockRejectedValueOnce" | 176+19', + 'jest(prefer-mock-promise-shorthand) | Prefer mock resolved/rejected shorthands for promises. | Prefer "mockResolvedValue" | 109+18', + "jest(prefer-mock-return-shorthand) | Mock functions that return simple values should use `mockReturnValue/mockReturnValueOnce`. | Replace `mockImplementation` with `mockReturnValue`. | 109+18", + 'vitest(prefer-mock-promise-shorthand) | Prefer mock resolved/rejected shorthands for promises. | Prefer "mockResolvedValue" | 109+18', + "vitest(prefer-mock-return-shorthand) | Mock functions that return simple values should use `mockReturnValue/mockReturnValueOnce`. | Replace `mockImplementation` with `mockReturnValue`. | 109+18", + 'jest(prefer-mock-promise-shorthand) | Prefer mock resolved/rejected shorthands for promises. | Prefer "mockRejectedValueOnce" | 48+19', + 'vitest(prefer-mock-promise-shorthand) | Prefer mock resolved/rejected shorthands for promises. | Prefer "mockRejectedValueOnce" | 48+19', + ], + ], + [ + "test.tsx", + "aVariable.mockImplementation(() => Promise.reject(42)).mockImplementation(() => Promise.resolve(42)).mockReturnValue(Promise.reject(42))", + [ + "jest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 0+136", + "vitest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 0+136", + 'jest(prefer-mock-promise-shorthand) | Prefer mock resolved/rejected shorthands for promises. | Prefer "mockRejectedValue" | 101+15', + 'vitest(prefer-mock-promise-shorthand) | Prefer mock resolved/rejected shorthands for promises. | Prefer "mockRejectedValue" | 101+15', + 'jest(prefer-mock-promise-shorthand) | Prefer mock resolved/rejected shorthands for promises. | Prefer "mockResolvedValue" | 55+18', + "jest(prefer-mock-return-shorthand) | Mock functions that return simple values should use `mockReturnValue/mockReturnValueOnce`. | Replace `mockImplementation` with `mockReturnValue`. | 55+18", + 'vitest(prefer-mock-promise-shorthand) | Prefer mock resolved/rejected shorthands for promises. | Prefer "mockResolvedValue" | 55+18', + "vitest(prefer-mock-return-shorthand) | Mock functions that return simple values should use `mockReturnValue/mockReturnValueOnce`. | Replace `mockImplementation` with `mockReturnValue`. | 55+18", + 'jest(prefer-mock-promise-shorthand) | Prefer mock resolved/rejected shorthands for promises. | Prefer "mockRejectedValue" | 10+18', + "jest(prefer-mock-return-shorthand) | Mock functions that return simple values should use `mockReturnValue/mockReturnValueOnce`. | Replace `mockImplementation` with `mockReturnValue`. | 10+18", + 'vitest(prefer-mock-promise-shorthand) | Prefer mock resolved/rejected shorthands for promises. | Prefer "mockRejectedValue" | 10+18', + "vitest(prefer-mock-return-shorthand) | Mock functions that return simple values should use `mockReturnValue/mockReturnValueOnce`. | Replace `mockImplementation` with `mockReturnValue`. | 10+18", + ], + ], + [ + "test.tsx", + 'aVariable\n .mockImplementation(() => 42)\n .mockImplementation(async () => 42)\n .mockImplementation(() => Promise.resolve(42))\n .mockReturnValue("hello world")', + [ + "jest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 0+210", + "vitest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 0+210", + 'jest(prefer-mock-promise-shorthand) | Prefer mock resolved/rejected shorthands for promises. | Prefer "mockResolvedValue" | 119+18', + "jest(prefer-mock-return-shorthand) | Mock functions that return simple values should use `mockReturnValue/mockReturnValueOnce`. | Replace `mockImplementation` with `mockReturnValue`. | 119+18", + 'vitest(prefer-mock-promise-shorthand) | Prefer mock resolved/rejected shorthands for promises. | Prefer "mockResolvedValue" | 119+18', + "vitest(prefer-mock-return-shorthand) | Mock functions that return simple values should use `mockReturnValue/mockReturnValueOnce`. | Replace `mockImplementation` with `mockReturnValue`. | 119+18", + "jest(prefer-mock-return-shorthand) | Mock functions that return simple values should use `mockReturnValue/mockReturnValueOnce`. | Replace `mockImplementation` with `mockReturnValue`. | 25+18", + "vitest(prefer-mock-return-shorthand) | Mock functions that return simple values should use `mockReturnValue/mockReturnValueOnce`. | Replace `mockImplementation` with `mockReturnValue`. | 25+18", + ], + ], + [ + "test.tsx", + 'aVariable\n .mockImplementationOnce(() => Promise.reject(42))\n .mockImplementation(() => "hello sunshine")\n .mockReturnValueOnce(Promise.reject(42))', + [ + "jest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 0+186", + "vitest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 0+186", + 'jest(prefer-mock-promise-shorthand) | Prefer mock resolved/rejected shorthands for promises. | Prefer "mockRejectedValueOnce" | 147+19', + 'vitest(prefer-mock-promise-shorthand) | Prefer mock resolved/rejected shorthands for promises. | Prefer "mockRejectedValueOnce" | 147+19', + "jest(prefer-mock-return-shorthand) | Mock functions that return simple values should use `mockReturnValue/mockReturnValueOnce`. | Replace `mockImplementation` with `mockReturnValue`. | 89+18", + "vitest(prefer-mock-return-shorthand) | Mock functions that return simple values should use `mockReturnValue/mockReturnValueOnce`. | Replace `mockImplementation` with `mockReturnValue`. | 89+18", + 'jest(prefer-mock-promise-shorthand) | Prefer mock resolved/rejected shorthands for promises. | Prefer "mockRejectedValueOnce" | 25+22', + "jest(prefer-mock-return-shorthand) | Mock functions that return simple values should use `mockReturnValue/mockReturnValueOnce`. | Replace `mockImplementationOnce` with `mockReturnValueOnce`. | 25+22", + 'vitest(prefer-mock-promise-shorthand) | Prefer mock resolved/rejected shorthands for promises. | Prefer "mockRejectedValueOnce" | 25+22', + "vitest(prefer-mock-return-shorthand) | Mock functions that return simple values should use `mockReturnValue/mockReturnValueOnce`. | Replace `mockImplementationOnce` with `mockReturnValueOnce`. | 25+22", + ], + ], + [ + "test.tsx", + "\n import { database, isCity } from '../database';\n import { loadCities } from '../api';\n\n jest.mock('../api');\n\n const initializeCityDatabase = () => {\n database.addCity('Vienna');\n database.addCity('San Juan');\n database.addCity('Wellington');\n };\n\n const clearCityDatabase = () => {\n database.clear();\n };\n\n initializeCityDatabase();\n\n test('that persists cities', () => {\n expect(database.cities.length).toHaveLength(3);\n });\n\n test('city database has Vienna', () => {\n expect(isCity('Vienna')).toBeTruthy();\n });\n\n test('city database has San Juan', () => {\n expect(isCity('San Juan')).toBeTruthy();\n });\n\n describe('when loading cities from the api', () => {\n let consoleWarnSpy = jest.spyOn(console, 'warn');\n\n loadCities.mockResolvedValue(['Wellington', 'London']);\n\n it('does not duplicate cities', async () => {\n await database.loadCities();\n\n expect(database.cities).toHaveLength(4);\n });\n\n it('logs any duplicates', async () => {\n await database.loadCities();\n\n expect(consoleWarnSpy).toHaveBeenCalledWith(\n 'Ignored duplicate cities: Wellington',\n );\n });\n });\n\n clearCityDatabase();\n ", + [ + "jest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 506+24", + "jest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 1719+19", + "vitest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 506+24", + "vitest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 1719+19", + "jest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 1042+49", + "jest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 1113+54", + "vitest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 1042+49", + "vitest(require-hook) | Require setup and teardown code to be within a hook. | This should be done within a hook | 1113+54", + ], + ], + [ + "test.tsx", + "import { describe, it, test } from 'vitest';\ndescribe.todo.each([])('a', () => {}); it.todo.each([])('b', () => {}); test.only.todo('c'); it.only.todo('d'); describe.only.todo('e');", + [ + "vitest(warn-todo) | The use of `.todo` is not recommended. | Write an actual test and remove the `.todo` modifier before pushing/merging your changes. | 54+4", + "vitest(warn-todo) | The use of `.todo` is not recommended. | Write an actual test and remove the `.todo` modifier before pushing/merging your changes. | 171+4", + "jest(padding-around-test-blocks) | Missing padding before it block | Make sure there is an empty new line before the it block | 84+0", + "vitest(padding-around-test-blocks) | Missing padding before it block | Make sure there is an empty new line before the it block | 84+0", + "vitest(warn-todo) | The use of `.todo` is not recommended. | Write an actual test and remove the `.todo` modifier before pushing/merging your changes. | 87+4", + "jest(padding-around-test-blocks) | Missing padding before it block | Make sure there is an empty new line before the it block | 138+0", + "vitest(padding-around-test-blocks) | Missing padding before it block | Make sure there is an empty new line before the it block | 138+0", + "vitest(warn-todo) | The use of `.todo` is not recommended. | Write an actual test and remove the `.todo` modifier before pushing/merging your changes. | 146+4", + "jest(padding-around-test-blocks) | Missing padding before test block | Make sure there is an empty new line before the test block | 117+0", + "vitest(padding-around-test-blocks) | Missing padding before test block | Make sure there is an empty new line before the test block | 117+0", + "vitest(warn-todo) | The use of `.todo` is not recommended. | Write an actual test and remove the `.todo` modifier before pushing/merging your changes. | 127+4", + ], + ], + ], + ], [ "no-magic-numbers reports the keys of patterns, not those of object literals", { "categories": { "correctness": "off" }, "rules": { "no-magic-numbers": "error" } }, @@ -4734,6 +5801,40 @@ describe.concurrent("bun lint with .oxlintrc.json", () => { await expectErrors(table, config); }); + // oxlint sorts all the messages of a file by the range of their fix before it applies the fixes, and prints what is left in that + // order. Those without a fix compare equal, so they stay in the order of the run. + describe("--fix leaves what has no fix in the order of the run", () => { + const config = { + categories: { correctness: "off" }, + rules: { "no-magic-numbers": "error", "eqeqeq": "error", "id-length": "error" }, + }; + /** `others` lines with a message that has no fix, and before the line `at` one with a message that has one. */ + const left = async (others: number, at: number) => { + const lines = Array.from({ length: others }, (_, i) => `f(${100 + i});`); + lines.splice(at, 0, "g(typeof a == 'string');"); + const found = await diagnosticsOf( + { "a.js": lines.join("\n") + "\n" }, + config, + ["--fix"], + "Fixed 1 problem in 1 file\n", + ); + return found["a.js"].map(it => `${it.code} ${it.message}`); + }; + const inOrder = (others: number) => + Array.from({ length: others }, (_, i) => `eslint(no-magic-numbers) No magic number: ${100 + i}`); + + // As oxlint 1.87 prints them. + test.each([0, 15, 31])("32 messages, the one with the fix before line %i", async at => { + expect(await left(31, at)).toEqual(inOrder(31)); + }); + + // oxlint's sort is not stable, which shows from 33 messages on: it prints the lines 1, 3 to 16, 2, 19 to 34, 18. That is an + // accident of the sort of its standard library. Here they keep their order. + test("34 messages", async () => { + expect(await left(33, 16)).toEqual(inOrder(33)); + }); + }); + test("prints the errors of oxc's scopes about a file with @flow, and none of its parser", async () => { await expectErrors(flow); }); diff --git a/test/js/bun/glob/match.test.ts b/test/js/bun/glob/match.test.ts index 2e4ae8810b35..0d42f25d2630 100644 --- a/test/js/bun/glob/match.test.ts +++ b/test/js/bun/glob/match.test.ts @@ -2615,6 +2615,29 @@ describe("Glob.match", () => { ["**/x/!{é,}*", ["q/x/!a", "x/!é", "x/!"], ["q/x/a"]], ["**/[!a]/b", ["c/b", "x/c/b"], ["a/b", "x/a/b"]], ["a/**/b/**/c/*.ts", ["a/b/c/x.ts", "a/1/b/2/3/c/x.ts"], ["a/b/x.ts", "a/b/c/d/x.ts"]], + [ + "**/node_modules/**", + ["node_modules/a", "a/node_modules/b", "a/b/node_modules/", "/node_modules/a"], + ["node_modules", "a/node_modules", "xnode_modules/a", "node_modules_x/a", "a/node_modulesx/b", "src/a.ts", ""], + ], + ["!**/node_modules/**", ["src/a.ts", "node_modules"], ["a/node_modules/b", "node_modules/a"]], + ["**/a/b/**", ["a/b/c", "x/a/b/", "a/a/b/c"], ["a/b", "a/x/b/c", "x/a/bc/d"]], + ["a/**/b", ["a/b", "a/x/b", "a/x/y/b", "a/b/b"], ["a/xb", "ab", "a/b/c", "b", "a/"]], + ["a/**/b/c", ["a/b/c", "a/x/b/c", "a/b/b/c"], ["a/b/x/c", "a/xb/c", "a/b/c/d"]], + ["a/**/b/**/c", ["a/b/c", "a/x/b/y/c", "a/b/b/c/c", "a/b/x/y/c"], ["a/c/b", "a/b", "a/xb/c", "a/b/xc"]], + ["**/a/**/b/**", ["a/b/c", "x/a/y/b/z", "a/a/b/b/"], ["a/b", "b/a/c", "a/xb/c"]], + ["**/{a,ab}/**", ["a/x", "ab/x", "x/ab/y", "x/a/"], ["abc/x", "a", "ab", "xa/y", "x/aab/y"]], + [ + "**/{src,lib}/**/{test,spec}/**/*.{ts,js}", + ["src/test/a.ts", "packages/a/src/x/test/y/z.ts", "lib/spec/x/a.js", "x/lib/src/spec/a.ts"], + ["src/a.ts", "test/src/a.ts", "src/test/a.css", "src/tests/a.ts", "src/test/ts"], + ], + ["**/{a,b}/c/**", ["a/c/x", "x/b/c/", "a/b/c/d"], ["a/x/c/d", "c/a/x", "a/c"]], + ["**/{1..3}/**", ["1/a", "x/2/a", "3/"], ["4/a", "1..3/a", "1"]], + ["**/{a,}/**", ["a/x", "x//y", "x/a/", "/x"], ["x/y", "x"]], + ["**/{a}/**", ["a/x", "x/a/y"], ["{a}/x", "b/x"]], + ["**/x{a,b}/**", ["xa/c", "y/xb/c"], ["x/c", "xab/c", "a/c"]], + ["x/**/{a,b}/**/*[!a]", ["x/a/b", "x/y/b/c/d", "x/a/b/"], ["x/a/a", "x/a/ba", "x/c/d"]], ])("what follows the last `**/` matches the last segments: %s", (pattern, matches, others) => { const glob = new Glob(pattern); for (let round = 0; round < 12; round++) { @@ -2625,7 +2648,7 @@ describe("Glob.match", () => { test.each([ ["*[!abc]", ["d", "xyzd", "abcd"], ["", "xyzb", "a"]], - ["*[!a]", ["x/", "b"], ["xa", "x/a"]], + ["*[!a]", ["x/", "b", "ab", "é"], ["xa", "x/a", "a", "a/b/"]], ["*\\*", ["*", "ab*"], ["ab", "a*b"]], ["*\\[a\\]", ["[a]", "x[a]"], ["xa", "x[a"]], ["*?.ts", ["a.ts", "index.ts", "é.ts"], [".ts", "a.js", "a/.ts"]], @@ -2635,6 +2658,10 @@ describe("Glob.match", () => { ["*?[ab]/x", ["ca/x", "xxcb/x"], ["a/x", "cc/x", "ca/y"]], ["*?[^/]_*1", ["_xa_aab1", "ab_1"], ["a_1", "ab_2"]], ["*[0-9].log", ["1.log", "server12.log"], ["server.log", "1.logs", "a/1.log"]], + ["*?.ts", ["ab.ts", "a.ts", "index.ts", "é.ts"], [".ts", "a/b.ts"]], + ["[a-c]*[x-z]", ["ax", "abcxyz", "az"], ["a", "a/z", "dx", "abc"]], + ["a*[bc]?", ["abx", "axcx"], ["ab", "a/bx", "abxy"]], + ["*\\*?", ["*a", "x*a"], ["*", "a*", "", "x*/"]], ["*[+-9]/x", ["a1/x", "a//x"], ["aa/x"]], ["*{a,}", ["", "a", "xx", "xxa"], ["x/a"]], ["*{,a}/y", ["/y", "xxa/y", "xx/y"], ["xxa", "x/x/y"]], @@ -2651,7 +2678,7 @@ describe("Glob.match", () => { for (let round = 0; round < 12; round++) { expect(matches.filter(path => !glob.match(path) || !new Glob(pattern).match(path))).toEqual([]); expect(others.filter(path => glob.match(path) || new Glob(pattern).match(path))).toEqual([]); - expect(matches.filter(path => !glob.match(long + path))).toEqual([]); + if (pattern.startsWith("*")) expect(matches.filter(path => !glob.match(long + path))).toEqual([]); } }); @@ -2667,6 +2694,18 @@ describe("Glob.match", () => { ["{a,b{c,d}}e", ["ae", "bce", "bde"], ["be", "bcde"]], ["{a,b{c,{d,e}}}", ["a", "bc", "bd", "be"], ["b", "bf"]], ["a{,b}c", ["ac", "abc"], ["adc", "a", "abbc"]], + ["{a,b}{c,d}", ["ac", "bd", "ad"], ["a", "abcd", "cd"]], + ["x{a,ab}b/{c,d}", ["xab/c", "xabb/d"], ["xb/c", "xab/e", "xabbb/c"]], + ["{a/**/b,c}/d", ["a/b/d", "a/x/y/b/d", "c/d"], ["a/d", "a/b", "c/x/d", "a/x/b/d/d"]], + ["{src/**,lib/**}", ["src/a", "lib/a/b.ts", "src/"], ["src", "lib", "test/a", "srcx/a"]], + ["{**/*.ts,**/*.js}", ["a.ts", "x/y/a.js"], ["a.css", "x/a.tsx", "ts"]], + ["x/{**/a,b}", ["x/a", "x/y/a", "x/b"], ["x/y/b", "xa", "x/"]], + ["x{**/a,b}", ["xb", "x/a", "xy/a", "x/y/a", "xa"], ["ya", "x"]], + ["{a/**,b}c", ["bc", "a/c", "a/xc"], ["a/x/c", "ac", "a/"]], + ["{a*,b}*c", ["ac", "axc", "bc", "bxc"], ["a/c", "c"]], + ["{a/**/*,b}", ["a/x", "a/x/y", "b", "a/"], ["a", "c"]], + ["{a,b/**}/c", ["a/c", "b/c", "b/x/c", "b/x/y/c"], ["c", "a/x/c"]], + ["!{a/**/b,c}", ["d", "a/x"], ["a/b", "c", "a/x/b"]], ["x{a,bc}*", ["xa", "xbcd", "xab"], ["xb", "ybc"]], ["{a,ab}*c", ["ac", "abc", "abxc"], ["ab", "bc"]], ["{a,ab}c", ["ac", "abc"], ["abbc", "c"]], @@ -2733,6 +2772,28 @@ describe("Glob.match", () => { }, ); + test.each([ + ["\\{a,b\\}", ["{a,b}"], ["a", "b", "{a,b", "{a,b}x", ""]], + ["\\*.ts", ["*.ts"], ["a.ts", ".ts", "*.tsx", "x*.ts"]], + ["a\\[b\\].ts", ["a[b].ts"], ["ab.ts", "a[b]xts", "a[b].t"]], + ["é\\*é", ["é*é"], ["éxé", "é*", "*é"]], + ["a\\tb", ["a\tb"], ["atb", "a\\tb"]], + ["!\\*a", ["b", "*b"], ["*a"]], + ["a+b@c", ["a+b@c"], ["aab@c", "a+b@"]], + ["a\\/b", ["a/b"], ["a\\/b", "ab"]], + ["[!abc]", ["d", "/", "é"], ["a", "b", "dd", ""]], + ["[^a]b", ["cb", "/b"], ["ab", "b"]], + ["a[!b]c", ["axc", "a/c"], ["abc", "ac"]], + ["?[!a]?", ["xbx", "x/x"], ["xax", "x/", "/b/"]], + ["[/]a", ["/a"], ["aa", "a"]], + ])("a pattern with only one way to match, however often it's asked: %s", (pattern, matches, others) => { + const glob = new Glob(pattern); + for (let round = 0; round < 40; round++) { + expect(matches.filter(path => !glob.match(path) || !new Glob(pattern).match(path))).toEqual([]); + expect(others.filter(path => glob.match(path) || new Glob(pattern).match(path))).toEqual([]); + } + }); + test.each([ ["*[éa]", "é", "ü"], ["*\\é", "é", "ü"], From dcbdac3a5b86f2609a24f3afa85766f20d68465c Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 13 Oct 2026 05:51:25 +0000 Subject: [PATCH 49/49] lint, format: 18 files --- src/collections/index_sort.rs | 80 +- src/format/tailwind.rs | 3 + src/glob/matcher.rs | 855 +++++++++++++++------- src/lint/driver/fmt/config.rs | 10 +- src/lint/driver/fmt/for_rules.rs | 4 +- src/lint/driver/fmt/mod.rs | 66 +- src/lint/driver/fmt/tailwind.rs | 18 +- src/lint/driver/format/info.rs | 12 +- src/lint/jest/jest.rs | 9 +- src/lint/oxlint_order.rs | 6 +- src/lint/utils/sort.rs | 8 +- src/sema/check/sink.rs | 9 +- test/cli/format/format.test.ts | 9 + test/cli/format/tailwind/tailwind.test.ts | 53 +- test/cli/lint/defaults.test.ts | 4 +- test/cli/lint/oxc-errors.test.ts | 249 +++++++ test/cli/lint/prettier-rule.test.ts | 14 + test/js/bun/glob/match.test.ts | 17 + 18 files changed, 1085 insertions(+), 341 deletions(-) diff --git a/src/collections/index_sort.rs b/src/collections/index_sort.rs index 38dff66a499b..2bb66873ece3 100644 --- a/src/collections/index_sort.rs +++ b/src/collections/index_sort.rs @@ -3,19 +3,74 @@ use core::cmp::Ordering; -/// Stable sort of `indices` by `cmp` (called with the two indices being compared). +/// What the sorts of slices and `Vec`s below compare with. A `dyn FnMut` would do, but its vtable also has `call_once`, so each +/// of their comparisons would be compiled twice. +trait Compare { + fn compare(&mut self, a: u32, b: u32) -> Ordering; +} + +impl Ordering> Compare for F { + #[inline] + fn compare(&mut self, a: u32, b: u32) -> Ordering { + self(a, b) + } +} + +/// Either kind of comparison, so that both go through the same two instances of the standard library's sorts. +enum By<'a> { + Function(&'a mut dyn FnMut(u32, u32) -> Ordering), + Comparison(&'a mut dyn Compare), +} + +impl By<'_> { + #[inline] + fn compare(&mut self, a: u32, b: u32) -> Ordering { + match self { + By::Function(cmp) => cmp(a, b), + By::Comparison(cmp) => cmp.compare(a, b), + } + } +} + +#[inline(never)] +fn sort_stable(indices: &mut [u32], mut by: By<'_>) { + indices.sort_by(|&a, &b| by.compare(a, b)); +} + #[inline(never)] +fn sort_unstable(indices: &mut [u32], mut by: By<'_>) { + indices.sort_unstable_by(|&a, &b| by.compare(a, b)); +} + +/// Stable sort of `indices` by `cmp` (called with the two indices being compared). pub fn sort_indices(indices: &mut [u32], cmp: &mut dyn FnMut(u32, u32) -> Ordering) { - indices.sort_by(|&a, &b| cmp(a, b)); + sort_stable(indices, By::Function(cmp)); } /// Unstable sort of `indices` by `cmp`. -#[inline(never)] pub fn sort_indices_unstable(indices: &mut [u32], cmp: &mut dyn FnMut(u32, u32) -> Ordering) { - indices.sort_unstable_by(|&a, &b| cmp(a, b)); + sort_unstable(indices, By::Function(cmp)); } -/// `0..len` as a `Vec`, the usual starting point for the sorts above. +/// `0..len` in the order of `cmp`, ties in their order. +#[inline(never)] +fn sorted(len: usize, cmp: &mut dyn Compare) -> Vec { + let mut order = identity(len); + sort_stable(&mut order, By::Comparison(cmp)); + order +} + +/// `0..len` in the order of `cmp`, ties in any order. +#[inline(never)] +fn sorted_unstable(len: usize, cmp: &mut dyn Compare) -> Vec { + let mut order = identity(len); + sort_unstable(&mut order, By::Comparison(cmp)); + order +} + +/// `0..len` as a `Vec`, the usual starting point for the sorts above. Out of line: its vectorized loop is bigger than most +/// of what calls it. +#[inline(never)] pub fn identity(len: usize) -> Vec { (0..u32::try_from(len).expect("index sort length fits u32")).collect() } @@ -38,8 +93,7 @@ pub fn sort_vec_by(items: &mut Vec, mut cmp: impl FnMut(&T, &T) -> Orderin if items.len() < 2 { return; } - let mut order = identity(items.len()); - sort_indices(&mut order, &mut |a, b| { + let order = sorted(items.len(), &mut |a: u32, b: u32| { cmp(&items[a as usize], &items[b as usize]) }); apply_permutation(items, &order); @@ -50,8 +104,7 @@ pub fn sort_vec_unstable_by(items: &mut Vec, mut cmp: impl FnMut(&T, &T) - if items.len() < 2 { return; } - let mut order = identity(items.len()); - sort_indices_unstable(&mut order, &mut |a, b| { + let order = sorted_unstable(items.len(), &mut |a: u32, b: u32| { cmp(&items[a as usize], &items[b as usize]) }); apply_permutation(items, &order); @@ -86,8 +139,7 @@ pub fn sort_slice_by(items: &mut [T], mut cmp: impl FnMut(&T, &T) -> Ordering if items.len() < 2 { return; } - let mut order = identity(items.len()); - sort_indices(&mut order, &mut |a, b| { + let mut order = sorted(items.len(), &mut |a: u32, b: u32| { cmp(&items[a as usize], &items[b as usize]) }); apply_permutation_in_place(items, &mut order); @@ -98,8 +150,7 @@ pub fn sort_slice_unstable_by(items: &mut [T], mut cmp: impl FnMut(&T, &T) -> if items.len() < 2 { return; } - let mut order = identity(items.len()); - sort_indices_unstable(&mut order, &mut |a, b| { + let mut order = sorted_unstable(items.len(), &mut |a: u32, b: u32| { cmp(&items[a as usize], &items[b as usize]) }); apply_permutation_in_place(items, &mut order); @@ -131,8 +182,7 @@ pub fn sort_slice_by_cached_key(items: &mut [T], key: impl FnMut(&T) return; } let keys: Vec = items.iter().map(key).collect(); - let mut order = identity(items.len()); - sort_indices(&mut order, &mut |a, b| { + let mut order = sorted(items.len(), &mut |a: u32, b: u32| { keys[a as usize].cmp(&keys[b as usize]) }); apply_permutation_in_place(items, &mut order); diff --git a/src/format/tailwind.rs b/src/format/tailwind.rs index 9454ed314711..01ba75d8aa7a 100644 --- a/src/format/tailwind.rs +++ b/src/format/tailwind.rs @@ -17,6 +17,9 @@ pub type Rank = Option; pub trait Orders: std::fmt::Debug + Send + Sync + std::panic::RefUnwindSafe { /// The rank of each of `classes`, which have one blank between them. `None`: that is not known yet. fn ranks_of(&self, classes: &[u8]) -> Option>; + + /// Whether there is a Tailwind that can be asked. + fn has_tailwind(&self) -> bool; } /// Names, and patterns for names, which are written `"/^tw/"`. diff --git a/src/glob/matcher.rs b/src/glob/matcher.rs index 265955122cc0..5f1aa98c0bc6 100644 --- a/src/glob/matcher.rs +++ b/src/glob/matcher.rs @@ -236,40 +236,51 @@ fn match_from_first_byte( if is_plain && path.first() != Some(&first) { return MatchResult::NO; } + // An escaped character that stands for itself is as good as a literal. + let differs_at_once = || { + first == b'\\' + && glob.get(1).is_some_and(|it| { + path.first() != Some(it) + && !matches!(it, b'b' | b'n' | b'r' | b't' | b'/' | b'\\' | 0x80..) + }) + }; if let Some(shape) = shape { let uses = shape.uses.load(Ordering::Relaxed); // From now on a `Walk` decides. if uses == Shape::WALKS { - return match_warm(glob, shape, path, mode); + return match first { + b'\\' if differs_at_once() => MatchResult::NO, + _ => match_warm(glob, shape, path, mode), + }; } shape.count(glob, uses); } match first { _ if is_plain => match_literal(glob, shape, path, mode), - // An escaped character that stands for itself is as good as a literal. - b'\\' - if glob.get(1).is_some_and(|it| { - path.first() != Some(it) - && !matches!(it, b'b' | b'n' | b'r' | b't' | b'/' | b'\\' | 0x80..) - }) => - { - MatchResult::NO - } - // At least one character. - b'*' | b'?' if mode.dots == Dots::Match && matches!(glob, b"*?" | b"?*") => MatchResult { - matches: !path.is_empty() && is_one_segment(path, mode), - negated: false, + b'\\' if differs_at_once() => MatchResult::NO, + b'*' => match glob.get(1) { + Some(b'*') if is_globstar_before_text(glob, 0, 0) => { + match_after_globstar(glob, shape, path, Start::of(mode)) + } + Some(b'?' | b'[') if is_last_star(glob, 0) => { + match_last_star(glob, shape, path, Start::of(mode)) + } + _ => match_more(glob, shape, path, Start::of(mode), None), }, b'!' => match_negated(glob, shape, path, mode), b'\\' | b'?' | b'[' | 0x80.. => match_escapes_at_start(glob, shape, path, mode), - b'{' if mode.dots == Dots::Match && glob.ends_with(b"*}") => { - match_braces(glob, shape, path, mode) - } + b'{' => match_choices(glob, shape, path, Start::of(mode)), _ => match_more(glob, shape, path, Start::of(mode), None), } } +/// Matches a pattern that was written out for one alternative of a group. +#[inline(never)] +fn match_written(pattern: &[u8], text: &[u8], mode: Mode) -> bool { + match_from_first_byte(pattern, None, text, mode).matches +} + /// How a match is asked for. The platform is a value and not a type parameter, so that a build for Windows has everything once. /// Elsewhere nothing asks for it, so it isn't there, and asking for it doesn't compile. #[derive(Copy, Clone)] @@ -309,11 +320,6 @@ impl Mode { } } -#[inline(never)] -fn is_one_segment(path: &[u8], mode: Mode) -> bool { - !path.iter().any(|it| is_separator(*it, mode.is_windows())) -} - /// Where the loop starts. #[derive(Copy, Clone)] struct Start { @@ -367,12 +373,400 @@ fn match_literal(glob: &[u8], shape: Option<&Shape>, path: &[u8], mode: Mode) -> path_index: taken as u32, ..Start::of(mode) }; + if is_globstar_before_text(glob, 0, taken) { + return match_after_globstar(glob, shape, path, start); + } + if glob[taken] == b'{' { + return match_choices(glob, shape, path, start); + } + if is_last_star(glob, taken) { + return match_last_star(glob, shape, path, start); + } match matches!(glob[taken], b'\\' | b'?' | b'[' | 0x80..) { true => match_escapes(glob, shape, path, start), false => match_more(glob, shape, path, start, None), } } +/// There's a `**/` at `index` that is a whole segment, and a literal or a `{` after it. +#[inline(always)] +fn is_globstar_before_text(glob: &[u8], glob_start: usize, index: usize) -> bool { + matches!(glob[index..], [b'*', b'*', b'/', after, ..] if IS_TEXT[after as usize] || after == b'{') + && (index == glob_start || glob[index - 1] == b'/') +} + +/// How far `tail` goes on with segments that are a literal, or a group like `{a,b}` of literals, and that end with a `/`. +/// Also returns where the literal after them ends, and whether there was a group. +#[inline(always)] +fn plain_segments(tail: &[u8]) -> (usize, usize, bool) { + let (mut end, mut at, mut has_group) = (0, 0, false); + loop { + if tail.get(at) == Some(&b'{') { + let mut commas = 0; + loop { + let first = at + 1; + at = first; + while tail.get(at).is_some_and(|it| IS_TEXT[*it as usize]) { + at += 1; + } + match tail.get(at) { + Some(b',') if at > first => commas += 1, + Some(b'}') if at > first && commas > 0 => break, + _ => return (end, end, has_group), + } + } + at += 1; + if tail.get(at) != Some(&b'/') { + return (end, end, has_group); + } + has_group = true; + } else { + let first = at; + while tail.get(at).is_some_and(|it| IS_TEXT[*it as usize]) { + at += 1; + } + if at == first || tail.get(at) != Some(&b'/') { + return (end, at, has_group); + } + } + at += 1; + end = at; + } +} + +/// Returns how much of `text` the [`plain_segments`] `wanted` take, if it starts with them. +#[inline(always)] +fn take_plain_segments(wanted: &[u8], text: &[u8], is_windows: bool) -> Option { + let (mut at, mut taken) = (0, 0); + while let Some(&ch) = wanted.get(at) { + if ch != b'{' { + let found = *text.get(taken)?; + if found != ch && !(ch == b'/' && is_separator(found, is_windows)) { + return None; + } + at += 1; + taken += 1; + continue; + } + // The alternative that is the whole segment. + loop { + at += 1; + let mut len = 0; + while IS_TEXT[wanted[at] as usize] && text.get(taken + len) == Some(&wanted[at]) { + at += 1; + len += 1; + } + let fits = !IS_TEXT[wanted[at] as usize] + && text + .get(taken + len) + .is_some_and(|it| is_separator(*it, is_windows)); + while fits && wanted[at] != b'}' || IS_TEXT[wanted[at] as usize] { + at += 1; + } + if fits { + taken += len; + break; + } + if wanted[at] == b'}' { + return None; + } + } + at += 1; + } + Some(taken) +} + +/// `start` is at a `**/`. As long as [`plain_segments`] and a `**` follow, like in `**/node_modules/**`, the first place in the path +/// where they are is as good as any other. Literals up to the end of the pattern are how the path has to end. +#[inline(never)] +fn match_after_globstar( + glob: &[u8], + shape: Option<&Shape>, + path: &[u8], + mut start: Start, +) -> MatchResult { + let is_windows = start.mode.is_windows(); + #[cfg(debug_assertions)] + let expected = match_more(glob, None, path, start, None); + let matched = loop { + let tail = &glob[start.glob_index as usize + 3..]; + let rest = &path[start.path_index as usize..]; + if start.mode.dots != Dots::Match { + return match_more(glob, shape, path, start, None); + } + let (segments, literal, has_group) = plain_segments(tail); + if literal == tail.len() && literal > segments && !has_group { + let before = rest.len().checked_sub(literal); + break before.is_some_and(|before| { + take_plain_segments(tail, &rest[before..], is_windows).is_some() + && (before == 0 || is_separator(rest[before - 1], is_windows)) + }); + } + if segments == 0 || !matches!(tail[segments..], [b'*', b'*'] | [b'*', b'*', b'/', ..]) { + return match_more(glob, shape, path, start, None); + } + let mut from = 0; + let taken = loop { + if let Some(taken) = take_plain_segments(&tail[..segments], &rest[from..], is_windows) { + break Some(from + taken); + } + while let Some(&found) = rest.get(from) + && !is_separator(found, is_windows) + { + from += 1; + } + if from == rest.len() { + break None; + } + from += 1; + }; + let Some(taken) = taken else { + break false; + }; + if tail.len() == segments + 2 { + break true; + } + start.glob_index += 3 + segments as u32; + start.path_index += taken as u32; + if has_group { + start.seen = Seen::Choice; + } + }; + let negated = start.glob_start % 2 == 1; + let result = MatchResult { + matches: matched != negated, + negated, + }; + #[cfg(debug_assertions)] + debug_assert!(result == expected); + result +} + +/// There's a `*` at `index` and no literal after it, which the loop would be fast with. The pattern doesn't end like one that has +/// another `*` or a group. +#[inline(always)] +fn is_last_star(glob: &[u8], index: usize) -> bool { + match glob[index..] { + [b'*'] => true, + [b'*', b'?' | b'[', ..] => !matches!(glob[glob.len() - 1], b'}' | b')' | b'*'), + _ => false, + } +} + +/// `start` is at a `*`. If every token after it takes one character, they take the end of the path, and the `*` takes what's before. +#[inline(never)] +fn match_last_star( + glob: &[u8], + shape: Option<&Shape>, + path: &[u8], + mut start: Start, +) -> MatchResult { + #[cfg(debug_assertions)] + let expected = match_more(glob, None, path, start, None); + let mut at = start.glob_index as usize + 1; + let mut width = 0; + let mut is_fixed = start.mode.dots == Dots::Match && start.seen == Seen::OnlyRegular; + while is_fixed && let Some(&ch) = glob.get(at) { + at = match ch { + _ if IS_PLAIN[ch as usize] => at + 1, + b'?' if glob.get(at + 1) != Some(&b'(') => at + 1, + b'\\' if glob.get(at + 1).is_some_and(|it| *it < 128 && *it != b'/') => at + 2, + b'[' if let Some((_, close, _)) = ascii_class::(glob, at, 0) => close + 1, + _ => { + is_fixed = false; + at + } + }; + width += 1; + } + let rest = &path[start.path_index as usize..]; + let before = rest.len().saturating_sub(width); + if !is_fixed || !rest[before..].is_ascii() { + return match_more(glob, shape, path, start, None); + } + let is_windows = start.mode.is_windows(); + let fits = rest.len() >= width + && !rest[..before] + .iter() + .any(|it| is_separator(*it, is_windows)); + let result = if !fits || width == 0 { + let negated = start.glob_start % 2 == 1; + MatchResult { + matches: fits != negated, + negated, + } + } else { + start.glob_index += 1; + start.path_index += before as u32; + match_escapes(glob, shape, path, start) + }; + #[cfg(debug_assertions)] + debug_assert!(result == expected); + result +} + +/// `start` is at a `{`. If all that the pattern has from there on is that group, literals, `*` and `**`, each alternative with +/// what follows the group is a pattern of its own. Returns whether one of them matches the rest of the path. +#[inline(never)] +fn match_branches(glob: &[u8], path: &[u8], start: &Start) -> Option { + let open = start.glob_index as usize; + let starts_segment = open == start.glob_start as usize || glob[open - 1] == b'/'; + // Where the alternatives end. + let mut ends = [0; 4]; + let (mut count, mut close) = (0, 0); + let mut ends_with_globstar = false; + let mut at = open + 1; + while let Some(&ch) = glob.get(at) { + if !IS_TEXT[ch as usize] && ch != b'/' { + let is_inside = close == 0; + match ch { + b',' | b'}' if is_inside => { + *ends.get_mut(count)? = at; + count += 1; + if ch == b'}' { + close = at; + } + } + b'*' if glob.get(at + 1) == Some(&b'*') => { + // It has to be a whole segment once it's written out. + let before = glob[at - 1]; + let is_first = is_inside && (at - 1 == open || before == b','); + if before != b'/' && !(is_first && starts_segment) { + return None; + } + at += 1; + match glob.get(at + 1) { + None | Some(b'/') => {} + Some(b',' | b'}') if is_inside => ends_with_globstar = true, + _ => return None, + } + } + b'*' => {} + _ => return None, + } + } + at += 1; + } + let after = &glob[close + 1..]; + if count < 2 + || close == 0 + || after.first() == Some(&b'*') + || ends_with_globstar && !matches!(after.first(), None | Some(b'/')) + { + return None; + } + let rest = &path[start.path_index as usize..]; + let mode = Mode::on(Dots::Match, start.mode.is_windows()); + let mut written = [0; 64]; + let mut from = open + 1; + let mut matched = false; + for &to in &ends[..count] { + let alternative = &glob[from..to]; + from = to + 1; + // Most alternatives start with a literal that the path doesn't go on with. + if let Some(first) = alternative.first() + && IS_PLAIN[*first as usize] + && rest.first() != Some(first) + { + continue; + } + let mut pattern = alternative; + if !after.is_empty() { + let it = written.get_mut(..alternative.len() + after.len())?; + for (to, from) in std::iter::zip(&mut *it, alternative.iter().chain(after)) { + *to = *from; + } + pattern = it; + } + // That has a rule of its own. + if pattern.ends_with(b"**/*") { + return None; + } + if match_written(pattern, rest, mode) { + matched = true; + break; + } + } + #[cfg(debug_assertions)] + { + let mut parsed = Parsed::new(); + parsed.read(glob); + let (top, is_windows) = (start.glob_start, start.mode.is_windows()); + debug_assert!( + matched == match_groups(glob, top, path, &parsed, Dots::Match, false, is_windows) + ); + } + Some(matched) +} + +/// `start` is at a `{`. Takes groups that are for [`take_plain_choice`], and the literals between them. +#[inline(never)] +fn match_choices(glob: &[u8], shape: Option<&Shape>, path: &[u8], mut start: Start) -> MatchResult { + let is_windows = start.mode.is_windows(); + let negated = start.glob_start % 2 == 1; + let found = |matched: bool| MatchResult { + matches: matched != negated, + negated, + }; + if start.mode.dots != Dots::Match { + return match_more(glob, shape, path, start, None); + } + loop { + // One like `{src/**,lib/**}` isn't for that. + let taken = match glob.ends_with(b"*}") { + true => None, + false => take_plain_choice( + glob, + start.glob_index, + path, + start.path_index, + is_windows, + Pending::Nothing, + ), + }; + match taken { + Some(Taken::To(glob_index, path_index)) => { + (start.glob_index, start.path_index) = (glob_index, path_index); + start.seen = Seen::Choice; + } + Some(Taken::Nothing) => return found(false), + None => { + if let Some(matched) = match_branches(glob, path, &start) { + return found(matched); + } + if start.seen == Seen::OnlyRegular { + start.seen = Seen::NoPlainChoice; + } + return match_more(glob, shape, path, start, None); + } + } + let (mut index, mut path_index) = (start.glob_index as usize, start.path_index as usize); + while let Some(&wanted) = glob.get(index) { + let is_same = match path.get(path_index) { + Some(&found) if IS_PLAIN[wanted as usize] => found == wanted, + Some(&found) if wanted == b'/' => is_separator(found, is_windows), + None if IS_PLAIN[wanted as usize] || wanted == b'/' => false, + _ => break, + }; + if !is_same { + return found(false); + } + index += 1; + path_index += 1; + } + if index == glob.len() { + return found(path_index == path.len()); + } + (start.glob_index, start.path_index) = (index as u32, path_index as u32); + if glob[index] != b'{' { + return match is_globstar_before_text(glob, start.glob_start as usize, index) { + true => match_after_globstar(glob, shape, path, start), + false => match_more(glob, shape, path, start, None), + }; + } + } +} + /// [`match_literal`] for a pattern that starts with `!`. #[inline(never)] fn match_negated(glob: &[u8], shape: Option<&Shape>, path: &[u8], mode: Mode) -> MatchResult { @@ -407,46 +801,21 @@ fn match_negated(glob: &[u8], shape: Option<&Shape>, path: &[u8], mode: Mode) -> path_index: taken as u32, ..Start::of(mode) }; + if is_globstar_before_text(glob, glob_start, glob_start + taken) { + return match_after_globstar(glob, shape, path, start); + } + if *next == b'{' { + return match_choices(glob, shape, path, start); + } + if is_last_star(glob, glob_start + taken) { + return match_last_star(glob, shape, path, start); + } match matches!(next, b'\\' | b'?' | b'[' | 0x80..) { true => match_escapes(glob, shape, path, start), false => match_more(glob, shape, path, start, None), } } -/// The pattern is like `{src/**,lib/**}`. If it's short, each alternative is matched as a pattern of its own. -#[inline(never)] -fn match_braces(glob: &[u8], shape: Option<&Shape>, path: &[u8], mode: Mode) -> MatchResult { - let is_windows = mode.is_windows(); - let text = Text { - path, - path_index: 0, - is_committed: true, - is_windows, - }; - let written = match_written_out(glob, 0, &text, &mut { Layout::NONE }); - #[cfg(debug_assertions)] - if let Some(written) = written { - written.check(glob, 0, path, is_windows); - } - let start = match written { - Some(Written::Rest(matches)) => { - return MatchResult { - matches, - negated: false, - }; - } - Some(Written::Segment(false, ..)) => return MatchResult::NO, - Some(Written::Segment(true, glob_index, path_index)) => Start { - glob_index, - path_index, - seen: Seen::Choice, - ..Start::of(mode) - }, - None => Start::of(mode), - }; - match_more(glob, shape, path, start, None) -} - /// The same for a pattern that starts with `!`, or has other tokens among those bytes that take one character whatever follows: /// escapes, `?` and classes. #[inline(never)] @@ -552,7 +921,10 @@ fn match_escapes_from( seen, mode: start.mode, }; - match_more(glob, shape, path, start, None) + match is_last_star(glob, index) { + true => match_last_star(glob, shape, path, start), + false => match_more(glob, shape, path, start, None), + } } /// [`match_class`] for an ASCII character and a class that has neither escapes nor characters that aren't ASCII. @@ -636,17 +1008,32 @@ fn match_warm(glob: &[u8], shape: &Shape, path: &[u8], mode: Mode) -> MatchResul matches: matched != negated, negated, }; + if let Some(text) = &parsed.text { + let is_same = match is_windows { + true => { + text.len() == path.len() + && std::iter::zip(&**text, path).all(|(wanted, found)| { + wanted == found || *wanted == b'/' && *found == b'\\' + }) + } + false => **text == *path, + }; + return found(is_same); + } let mut is_ascii = true; let Some(first) = parsed.take_front(glob, parsed.top, path, is_windows) else { return found(false); }; - if dots == Dots::Match - && let Some(cache) = parsed - .program - .get() - .and_then(|it| it.warm_cache(is_windows)) + if let Some(cache) = parsed + .program + .get() + .and_then(|it| it.warm_cache(is_windows)) { - match cache.answer(&path[first as usize..]) { + let answer = match dots { + Dots::Match => cache.answer(&path[first as usize..]), + _ => cache.answer_without_dots(path, first as usize), + }; + match answer { Answer::Yes => return found(true), Answer::No => return found(false), Answer::Unknown => {} @@ -732,7 +1119,7 @@ fn match_more( let mut parsed = Parsed::new(); parsed.read(glob); debug_assert!( - !parsed.is_regular + !(parsed.is_regular || parsed.is_fixed && dots == Dots::Match) || matched == match_groups(glob, glob_start, path, &parsed, dots, false, is_windows), "the loop and the walk differ" @@ -754,7 +1141,7 @@ fn match_at_once( ) -> Option { let mut groups = Groups::new(shape); let parsed = groups.parsed(glob); - let walks = parsed.may_walk(); + let walks = parsed.may_walk(dots); walks.then(|| { match_groups( glob, @@ -1170,20 +1557,6 @@ fn run( { let is_literal = IS_PLAIN[wanted as usize] || wanted >= 128; let mut start = state.path_index as usize; - let mut can_start = true; - if matches!(wanted, b'{' | b'@' | b'+') { - let skipped = skip_to_start( - glob, - state.glob_index + 3, - path, - state.path_index, - true, - &mut kept.firsts, - is_windows, - ); - start = skipped.path_index as usize; - can_start = skipped.can_start; - } let next = loop { let mut end = start; while let Some(&found) = path.get(end) @@ -1200,9 +1573,6 @@ fn run( state.path_index = start as u32; state.globstar.path_index = next as u32; state.wildcard = state.globstar; - if !can_start { - break 'fallthrough; - } continue 'main_loop; } // `**` can still match nothing, so it's handled below. @@ -1222,7 +1592,7 @@ fn run( && state.glob_index != not_group_at => { if !STRICT - && state.seen != Seen::Odd + && !matches!(state.seen, Seen::Odd | Seen::NoPlainChoice) && !(kept.layout.open == state.glob_index && kept.layout.has_star) && let Some(taken) = take_plain_choice( glob, @@ -1252,6 +1622,9 @@ fn run( Taken::Nothing => break 'fallthrough, } } + if state.seen == Seen::NoPlainChoice { + state.seen = Seen::OnlyRegular; + } if !STRICT && state.seen != Seen::Odd && let Some(written) = match_written_out( @@ -1268,21 +1641,15 @@ fn run( { #[cfg(debug_assertions)] written.check(glob, state.glob_index as usize, path, is_windows); - match written { - Written::Rest(true) => return Outcome::Yes, - Written::Rest(false) => return Outcome::No, - // A `*` before the group was part of the segment. - Written::Segment(is_match, glob_index, path_index) => { - state.seen = Seen::Choice; - state.wildcard = state.globstar; - if !is_match { - break 'fallthrough; - } - state.glob_index = glob_index; - state.path_index = path_index; - continue 'main_loop; - } + state.seen = Seen::Choice; + // A `*` before the group was part of the segment. + state.wildcard = state.globstar; + if !written.is_match { + break 'fallthrough; } + state.glob_index = written.glob_index; + state.path_index = written.path_index; + continue 'main_loop; } stop!(Outcome::Group); } @@ -1304,9 +1671,11 @@ fn run( if glob.get(at + 3) == Some(&b'*') { skip_globstars(glob, &mut state.glob_index); } - // When the loop gets here for the first time. + // When the loop gets here for the first time. After an odd token it may have missed a way to get + // here, and has to go on to the next group as it always did, where a walk takes over. if !STRICT && is_whole + && state.seen != Seen::Odd && (state.globstar.path_index == 0 || state.globstar.glob_index != state.glob_index) && !glob.ends_with(b"**") @@ -1366,6 +1735,7 @@ fn run( state.path_index, dots == Dots::Match, before.path_index == 0 || before.glob_index != state.glob_index, + state.seen == Seen::Odd, &mut kept, is_windows, ); @@ -1433,33 +1803,6 @@ fn run( is_windows, ); } - } else if !STRICT - && matches!( - glob.get(state.glob_index as usize), - Some(b'{' | b'@' | b'+') - ) - { - // Same for a group after `**/`. - let skipped = skip_to_start( - glob, - state.glob_index, - path, - state.path_index, - true, - &mut kept.firsts, - is_windows, - ); - can_start = skipped.can_start; - let start = skipped.path_index; - if start != state.path_index { - state.path_index = start; - state.wildcard.path_index = start + 1; - state.skip_to_separator( - path, - is_end_invalid, - is_windows, - ); - } } if is_closed { state.wildcard.path_index = 0; @@ -1776,6 +2119,8 @@ enum Seen { /// Only regular tokens, and a group that it took for a [`Choice`]. That's right if the pattern is regular. It's also right /// if the loop fails or ends before it sees anything odd: what it hasn't seen doesn't matter then. Choice, + /// Only regular tokens, and [`take_plain_choice`] has already said that the group where the loop starts isn't for it. + NoPlainChoice, } /// What a match has found out about the groups of its pattern. @@ -1935,14 +2280,13 @@ struct Text<'a> { is_windows: bool, } -/// Result of [`match_written_out`]. +/// Result of [`match_written_out`]: whether the segment of the pattern that the group is in matches the segment of the path that +/// the loop is in, and the indexes where the two end. #[derive(Copy, Clone)] -enum Written { - /// Whether the segment of the pattern that the group is in matches the segment of the path that the loop is in, and the indexes - /// where the two end. - Segment(bool, u32, u32), - /// Whether the pattern, from that segment on, matches the rest of the path. - Rest(bool), +struct Written { + is_match: bool, + glob_index: u32, + path_index: u32, } #[cfg(debug_assertions)] @@ -1950,28 +2294,17 @@ impl Written { /// A walk has to come to the same. fn check(self, glob: &[u8], open: usize, path: &[u8], is_windows: bool) { let is_separator = |it: u8| it == b'/' || is_windows && it == b'\\'; - match self { - Written::Segment(is_match, end, to) => { - // A segment that starts with `!` can't be read on its own. - let Some(it) = Segment::of(glob, open) else { - return; - }; - let pattern = &glob[it.start as usize..it.end as usize]; - let to = to as usize; - let inside = path[..to].iter().rev(); - let from = to - inside.take_while(|it| !is_separator(**it)).count(); - let on = (it.program).on(pattern, &path[..to], from, is_windows, Dots::Match); - let walked = may_end_with(pattern, &path[from..to]) && on.walk(); - debug_assert!((is_match, end) == (walked, it.end)); - } - Written::Rest(is_match) => { - let (mut parsed, start) = (Parsed::new(), negations(glob) as u32); - parsed.read(glob); - let walked = - match_groups(glob, start, path, &parsed, Dots::Match, false, is_windows); - debug_assert!(is_match == walked); - } - } + // A segment that starts with `!` can't be read on its own. + let Some(it) = Segment::of(glob, open) else { + return; + }; + let pattern = &glob[it.start as usize..it.end as usize]; + let to = self.path_index as usize; + let inside = path[..to].iter().rev(); + let from = to - inside.take_while(|it| !is_separator(**it)).count(); + let on = (it.program).on(pattern, &path[..to], from, is_windows, Dots::Match); + let walked = may_end_with(pattern, &path[from..to]) && on.walk(); + debug_assert!((self.is_match, self.glob_index) == (walked, it.end)); } } @@ -1987,9 +2320,6 @@ struct Layout { count: u8, /// Index where what's written out ends. end: u32, - /// That's the end of the pattern, because the group has a `/` or a `**`. - is_rest: bool, - has_globstar: bool, /// The group has a `*`, so it's no [`Choice`]. has_star: bool, } @@ -2001,8 +2331,6 @@ impl Layout { commas: [0; 4], count: 0, end: 0, - is_rest: false, - has_globstar: false, has_star: false, }; @@ -2015,7 +2343,7 @@ impl Layout { let (mut count, mut close) = (0, 0); // How many groups are open, which of those have had a `,`, and how many groups there have been. let (mut depth, mut has_comma, mut groups) = (1, 0_u32, 1); - let (mut is_rest, mut has_globstar, mut has_star) = (false, false, false); + let mut has_star = false; let mut end = open + 1; while let Some(&ch) = glob.get(end) { match ch { @@ -2028,8 +2356,8 @@ impl Layout { } end += len - 1; } - b'/' if depth == 0 && !is_rest => break, - b'/' => is_rest = true, + b'/' if depth == 0 => break, + b'/' => return None, b',' if depth > 0 => { has_comma |= 1 << depth; if close == 0 && depth == 1 { @@ -2052,8 +2380,7 @@ impl Layout { b'*' => { has_star |= close == 0; if glob.get(end + 1) == Some(&b'*') { - (is_rest, has_globstar) = (true, true); - end += 1; + return None; } } b'?' => {} @@ -2068,8 +2395,7 @@ impl Layout { } end += 1; } - // A `**` right after the group is never a whole segment. - if depth > 0 || glob[close + 1..].starts_with(b"**") { + if depth > 0 { return None; } commas[count] = close as u32; @@ -2079,16 +2405,14 @@ impl Layout { commas, count: count as u8, end: end as u32, - is_rest, - has_globstar, has_star, }) } } /// Matches without a [`Program`]: writes the pattern out once for each alternative of the group at `open`, and matches that. -/// It writes out the segment that the group is in, which has to be a [`Segment`]. If the group has a `/` or a `**`, it's the rest of -/// the pattern. Returns `None` unless that is short and has few groups and alternatives, which makes this the cheaper way. +/// It writes out the segment that the group is in, which has to be a [`Segment`]. Returns `None` unless that is short and has few +/// groups and alternatives, which makes this the cheaper way. #[inline(never)] fn match_written_out( glob: &[u8], @@ -2102,15 +2426,9 @@ fn match_written_out( ..Layout::NONE }); } - let Layout { - commas, - count, - is_rest, - has_globstar, - .. - } = *layout; + let Layout { commas, count, .. } = *layout; let (close, end) = (layout.close as usize, layout.end as usize); - if close == 0 || is_rest && !text.is_committed { + if close == 0 { return None; } // The segment of the path that the loop is in. @@ -2127,14 +2445,12 @@ fn match_written_out( ) } }; - let path_end = match is_rest { - true => text.path.len(), - false => left.len() + right.iter().take_while(is_inside).count(), - }; + let path_end = left.len() + right.iter().take_while(is_inside).count(); let path = &text.path[path_start..path_end]; - let found = |is_match: bool| match is_rest { - true => Written::Rest(is_match), - false => Written::Segment(is_match, end as u32, path_end as u32), + let found = |is_match: bool| Written { + is_match, + glob_index: end as u32, + path_index: path_end as u32, }; let (before, after) = (&glob[start..open], &glob[close + 1..end]); // An alternative with nothing around it is a pattern as it stands. @@ -2186,41 +2502,18 @@ fn match_written_out( } pattern = &written[start..]; } - // A leading `!` would mean something else in a pattern of its own, and so would a leading `**` that doesn't start a segment. - if has_globstar && !has_plain_globstars(pattern) - || pattern.first() == Some(&b'!') - || pattern.starts_with(b"**") - && start > 0 - && glob[start - 1] != b'/' - && start != negations(glob) - { + // A leading `!` would mean something else in a pattern of its own. + if pattern.first() == Some(&b'!') { return None; } let mode = Mode::on(Dots::Match, text.is_windows); - if match_from_first_byte(pattern, None, path, mode).matches { + if match_written(pattern, path, mode) { return Some(found(true)); } } Some(found(false)) } -/// Returns true if every `**` in `pattern` is a whole segment of it, and it doesn't end with `**/*`, which has a rule of its own. -fn has_plain_globstars(pattern: &[u8]) -> bool { - let mut at = 0; - while at + 1 < pattern.len() { - if pattern[at] != b'*' || pattern[at + 1] != b'*' { - at += 1; - continue; - } - let starts_segment = at == 0 || pattern[at - 1] == b'/'; - if !starts_segment || !matches!(pattern.get(at + 2), None | Some(b'/')) { - return false; - } - at += 2; - } - !pattern.ends_with(b"**/*") -} - /// Returns false if `pattern` ends with literal bytes that `text` doesn't end with. fn may_end_with(pattern: &[u8], text: &[u8]) -> bool { let literal = pattern.iter().rev(); @@ -2320,21 +2613,21 @@ fn skip_ahead( path_index: u32, ignores_dots: bool, is_first_time: bool, + has_seen_odd_token: bool, kept: &mut Kept, is_windows: bool, ) -> Skipped { let is_separator = |it: u8| it == b'/' || is_windows && it == b'\\'; let next = glob[index as usize]; if matches!(next, b'{' | b'@' | b'+') { - return skip_to_start( - glob, - index, - path, - path_index, - false, - &mut kept.firsts, - is_windows, - ); + // The loop may have missed a way to get here. It has to get to the group, where a walk takes over. + if has_seen_odd_token { + return Skipped { + path_index, + can_start: true, + }; + } + return skip_to_start(glob, index, path, path_index, &mut kept.firsts, is_windows); } let mut at = path_index as usize; if !ignores_dots { @@ -2558,15 +2851,13 @@ struct Skipped { } /// Returns the first index at or after `path_index` where an alternative of the group at `index` could start, -/// or the end of the segment. With `by_segment`, only looks at the start of each segment and returns the last one -/// if none fits. +/// or the end of the segment. #[inline(never)] fn skip_to_start( glob: &[u8], index: u32, path: &[u8], mut path_index: u32, - by_segment: bool, firsts: &mut Firsts, is_windows: bool, ) -> Skipped { @@ -2575,7 +2866,7 @@ fn skip_to_start( firsts.read(glob, index); } // A group that ends a segment of the pattern ends one of the path. - if !by_segment && let Some(longest) = firsts.longest_at_end { + if let Some(longest) = firsts.longest_at_end { let rest = &path[path_index as usize..]; let len = rest .iter() @@ -2591,18 +2882,10 @@ fn skip_to_start( } let firsts = &firsts.bytes[..usize::from(firsts.count)]; while let Some(found) = path.get(path_index as usize) - && (by_segment || !is_separator(*found)) + && !is_separator(*found) && !firsts.contains(found) { - if !by_segment { - path_index += u32::from(rune_len_at(path, path_index as usize)); - continue; - } - let rest = &path[path_index as usize..]; - match rest.iter().position(|it| is_separator(*it)) { - Some(separator) => path_index += separator as u32 + 1, - None => break, - } + path_index += u32::from(rune_len_at(path, path_index as usize)); } let can_start = path .get(path_index as usize) @@ -2849,9 +3132,10 @@ impl Shape { let mut parsed = Parsed::new(); parsed.read(glob); parsed.is_matched_often = uses == Shape::WALKS_AFTER; + parsed.text = parsed.text_of(glob); parsed }); - if !parsed.may_walk() { + if !parsed.may_walk(Dots::Match) { self.uses.store(Shape::NEVER_WALKS, Ordering::Relaxed); } else if parsed.has_group || uses == Shape::WALKS_AFTER { self.uses.store(Shape::WALKS, Ordering::Relaxed); @@ -3041,7 +3325,7 @@ fn take_plain_choice( && let Some(len) = taken && len > shortest + 1 { - return take_between_stars(glob, open, path, path_index, is_windows); + return None; } } Some(_) if pending == Pending::StarBefore => return None, @@ -3270,36 +3554,6 @@ fn take_fixed_choice( }) } -/// [`take_plain_choice`] for a group between two `*`s: what counts is where in the segment it can end first. -#[cold] -#[inline(never)] -fn take_between_stars( - glob: &[u8], - open: u32, - path: &[u8], - path_index: u32, - is_windows: bool, -) -> Option { - let mut first = Taken::Nothing; - let (mut at, mut end) = (path_index, u32::MAX); - while at < end { - let taken = take_plain_choice(glob, open, path, at, is_windows, Pending::Globstar)?; - if let Taken::To(_, it) = taken - && it < end - { - (first, end) = (taken, it); - } - if path - .get(at as usize) - .is_none_or(|it| *it == b'/' || is_windows && *it == b'\\') - { - break; - } - at += 1; - } - Some(first) -} - /// A segment of a regular pattern that has a group which isn't a [`Choice`], and that can only match one whole segment of a path: it /// has no `/` and no `**`, and no `!(a)`, whose meaning depends on what follows the segment. For the loop it's like a `Choice` /// that takes the rest of a segment: it has a [`Program`] of its own, which says whether it matches the segment of the path. @@ -3345,6 +3599,13 @@ struct Parsed { /// The loop finds every match of each stretch of the pattern that has no groups. It doesn't if a class or an escape can match /// a separator, if a class starts with `(`, if a `**` isn't a whole segment, or if a bracket isn't closed. is_regular: bool, + /// All that keeps it from being regular are classes that can match a separator. It has neither `*` nor groups, so there's only + /// one way to match it, and the loop finds that. + is_fixed: bool, + /// It has only literal bytes that need no second look, which nothing compares faster than the loop. + is_plain_text: bool, + /// If all of it is literal, the one text that it matches. + text: Option>, /// The pattern has a group that isn't a [`Choice`]. has_group: bool, has_choice: bool, @@ -3400,6 +3661,9 @@ impl Parsed { Parsed { alternatives: SmallVec::new(), is_regular: false, + is_fixed: false, + is_plain_text: false, + text: None, has_group: false, has_choice: false, is_one_segment: false, @@ -3413,11 +3677,27 @@ impl Parsed { } } + fn text_of(&self, glob: &[u8]) -> Option> { + let is_literal = self.is_regular && self.wildcards == 0 && self.alternatives.is_empty(); + if !is_literal || self.is_plain_text || std::str::from_utf8(glob).is_err() { + return None; + } + let mut text = Vec::with_capacity(glob.len()); + let mut at = self.top; + while let Some(&ch) = glob.get(at as usize) { + let mut ch = ch; + unescape(&mut ch, glob, &mut at); + text.push(ch); + at += 1; + } + Some(text.into_boxed_slice()) + } + /// Returns true if a [`Walk`] gives the same answers as the loop, and is faster once it has a [`Cache`]. - fn may_walk(&self) -> bool { - self.is_regular + fn may_walk(&self, dots: Dots) -> bool { + (self.is_regular || self.is_fixed && dots == Dots::Match) && !self.is_decided_at_once - && (self.has_group || self.has_choice || self.wildcards >= 1) + && !self.is_plain_text } /// Finds all groups in one pass over `glob`. @@ -3452,6 +3732,8 @@ impl Parsed { i += 1; } let mut front_end = i; + self.is_plain_text = i == glob.len(); + let (mut has_star, mut has_separator_class) = (false, false); let mut is_one_segment = !strings::contains_char(&glob[top..i], b'/'); while let Some(&ch) = glob.get(i) { if IS_TEXT[ch as usize] { @@ -3473,6 +3755,7 @@ impl Parsed { }; group.is_plain &= IS_PLAIN[ch as usize]; wildcards += u32::from(matches!(ch, b'*' | b'?' | b'[')); + has_star |= ch == b'*'; match ch { b'/' => is_one_segment = false, b'?' | b'!' | b'+' | b'@' => {} @@ -3586,7 +3869,8 @@ impl Parsed { b'[' if i < no_class_from => match class_shape(glob, i) { Some((end, may_be_separator)) => { // The rules for dot segments overlook `[(`, so it can match where a `**` can't go on. - is_regular &= !may_be_separator && glob[i + 1] != b'('; + is_regular &= glob[i + 1] != b'('; + has_separator_class |= may_be_separator; i = end - 1; } None => { @@ -3598,7 +3882,8 @@ impl Parsed { } i += 1; } - self.is_regular = is_regular && depth == 0; + self.is_fixed = is_regular && depth == 0 && !has_star && found.is_empty(); + self.is_regular = is_regular && depth == 0 && !has_separator_class; self.has_group = has_group || has_choice && !self.is_regular; self.is_one_segment = is_one_segment; self.has_choice = has_choice; @@ -5176,6 +5461,34 @@ impl Cache { } } + /// [`Cache::answer`] for the other [`Dots`], which come to the same unless a segment from `first` on starts with a `.`. + #[inline(never)] + fn answer_without_dots(&self, path: &[u8], first: usize) -> Answer { + if first > 0 && path[first - 1] == b'.' { + return Answer::Unknown; + } + let mut state = self.end; + for index in (first..path.len()).rev() { + let byte = path[index]; + if byte == b'.' && (index == 0 || is_separator(path[index - 1], self.is_windows)) { + return Answer::Unknown; + } + state = self.steps[state as usize + self.columns[byte as usize] as usize] + .load(Ordering::Relaxed); + if state <= Cache::NOTHING { + return match state { + Cache::NOTHING => Answer::No, + _ => Answer::Unknown, + }; + } + } + match self.steps[state as usize - 1].load(Ordering::Relaxed) { + Cache::YES => Answer::Yes, + Cache::NO => Answer::No, + _ => Answer::Unknown, + } + } + /// `first` is the index of the path where the first node has to match. `dots` is `Dots::Match` unless the path has a closed /// segment. #[inline(always)] diff --git a/src/lint/driver/fmt/config.rs b/src/lint/driver/fmt/config.rs index 2132789782a3..67176e1085c0 100644 --- a/src/lint/driver/fmt/config.rs +++ b/src/lint/driver/fmt/config.rs @@ -389,7 +389,7 @@ fn settings(json: &Json, is_oxfmt: bool) -> Settings { let is_option = OPTIONS.contains(&&name[..]); let text = match value { // As JSON, a string too: the plugin that sorts classes refuses a value of the wrong type. - Json::Bool(_) | Json::Number(_) | Json::String(_) + Json::Null | Json::Bool(_) | Json::Number(_) | Json::String(_) if !is_oxfmt && name.starts_with(b"tailwind") => { let mut text = Vec::new(); @@ -1292,7 +1292,8 @@ impl<'c> Configs<'c> { missing.push(name); } } - if !missing.is_empty() { + // Without the flag the files are left alone because of them, which is said at the end. + if !missing.is_empty() && self.options.allow_unsupported { let end: &[u8] = match missing.len() { 1 => b" has no effect because bun fmt does not include that plugin", _ => b" have no effect because bun fmt does not include those plugins", @@ -1614,11 +1615,6 @@ impl<'c> Configs<'c> { (self.endings_of_plugins(scope, path)).any(|ending| path.ends_with(ending)) } - /// The same for a file that is formatted here too, without the plugin, if that is allowed. - pub(crate) fn is_changed_by_plugin(&self, scope: &Scope, path: &[u8]) -> bool { - !self.options.allow_unsupported && self.is_read_by_plugin(scope, path) - } - /// The plugin that adds the language of the file at `path`, which has `scope`, if Prettier reads it with one. pub(crate) fn plugin_that_reads(&self, scope: &Scope, path: &[u8]) -> Option<&'static [u8]> { PLUGINS_FOR_LANGUAGES diff --git a/src/lint/driver/fmt/for_rules.rs b/src/lint/driver/fmt/for_rules.rs index f25b1f059558..876b3e8cc095 100644 --- a/src/lint/driver/fmt/for_rules.rs +++ b/src/lint/driver/fmt/for_rules.rs @@ -268,7 +268,9 @@ impl<'e> ForRules<'e> { let kind = Kind::with_options(path, &resolved.options); tailwind::only_where_sorted(&mut resolved.options, kind); // As `bun fmt`, which then leaves the classes as they are. - if *allows_unsupported && configs.classes.has_no_package_for(path) { + if *allows_unsupported + && (resolved.options.tailwind.as_deref()).is_some_and(|it| !it.orders.has_tailwind()) + { resolved.options.tailwind = None; } let mut scratch = self.scratches.lock().pop().unwrap_or_default(); diff --git a/src/lint/driver/fmt/mod.rs b/src/lint/driver/fmt/mod.rs index 796028382958..1acf8d7f0daf 100644 --- a/src/lint/driver/fmt/mod.rs +++ b/src/lint/driver/fmt/mod.rs @@ -875,7 +875,7 @@ fn language_of(configs: &Configs, scope: &config::Scope, path: &[u8]) -> Languag } None => match files::language_of(path) { Language::Unknown if configs.is_read_by_plugin(scope, path) => Language::Other, - Language::Supported if configs.is_changed_by_plugin(scope, path) => Language::Other, + Language::Supported if configs.is_read_by_plugin(scope, path) => Language::Other, Language::Unknown if files::is_toml(path) && configs.is_native_in(scope) => { Language::Supported } @@ -1112,7 +1112,16 @@ impl Run<'_> { } // As for a file. Who gets the text back with 0 takes it for formatted. let allows = self.options.allow_unsupported; - if language == Language::Other { + // It is the plugin that is missing, not the language. + let plugin = (configs.plugin_that_reads(&scope, &path)) + .filter(|_| files::language_of(&path) == Language::Supported); + if let (Language::Other, Some(plugin)) = (language, plugin) { + self.say_not_installed(¬_installed); + if !allows { + self.say_left_for_plugins(1, &[plugin]); + return self.out; + } + } else if language == Language::Other { self.say_unsupported(&mut [(ending_of(&path), 1)], ¬_installed); if allows && !only_looks { self.out.stdout = text; @@ -1479,10 +1488,8 @@ impl Run<'_> { handed_over.push((index, target)); } Language::Other => { - if configs - .plugin_that_reads(&target.scope, &target.path) - .is_some() - { + let plugin = configs.plugin_that_reads(&target.scope, &target.path); + if plugin.is_some() { for package in [&b"prettier"[..]] .into_iter() .chain(configs.packages_of_plugins(&target.scope, Some(&target.path))) @@ -1492,6 +1499,20 @@ impl Run<'_> { } } } + // It is the plugin that is missing, not the language. + if let Some(plugin) = plugin + && files::language_of(&target.path) == Language::Supported + { + if options.allow_unsupported { + work.push((index, target)); + } else { + left_for_plugins.0 += 1; + if !left_for_plugins.1.contains(&plugin) { + left_for_plugins.1.push(plugin); + } + } + continue; + } let kind = ending_of(&target.path); match others.iter_mut().find(|it| it.0 == kind) { Some(entry) => entry.1 += 1, @@ -1639,8 +1660,8 @@ impl Run<'_> { let said = match (options.allow_unsupported, count) { (true, 1) => "\nClasses were not sorted in 1 file.".to_owned(), (true, _) => format!("\nClasses were not sorted in {count} files."), - (false, 1) => "\n1 file was not formatted.".to_owned(), - (false, _) => format!("\n{count} files were not formatted."), + (false, 1) => "\n1 file with classes was not formatted.".to_owned(), + (false, _) => format!("\n{count} files with classes were not formatted."), }; error.extend_from_slice(said.as_bytes()); if options.allow_unsupported { @@ -1861,6 +1882,8 @@ impl Run<'_> { // The tool that this stands in for would have formatted or checked them, so this comes last and is an error. if !others.is_empty() { self.say_unsupported(&mut others, ¬_installed); + } else { + self.say_not_installed(¬_installed); } self.out } @@ -1878,6 +1901,21 @@ impl Run<'_> { self.error(&[text.as_bytes(), &names.join(&b", "[..]), end.as_bytes()].concat()); } + /// The packages that would have to be installed for more files to go to the project's own Prettier. + fn say_not_installed(&mut self, not_installed: &[&[u8]]) { + if !not_installed.is_empty() { + let plugins = not_installed.join(&b", "[..]); + self.warn( + &[ + &b"Not installed: "[..], + &plugins, + b"\n Install prettier and every plugin in your config, and bun fmt will use them for these files.", + ] + .concat(), + ); + } + } + /// `others`: the endings of the files in another language, and how many have each. `not_installed`: the packages that would /// have to be installed for them to go to the project's own Prettier. fn say_unsupported(&mut self, others: &mut [(&[u8], usize)], not_installed: &[&[u8]]) { @@ -1895,17 +1933,7 @@ impl Run<'_> { "{count} {noun} not formatted because bun fmt does not support {its} language yet: " ); let text = [text.as_bytes(), &kinds.join(&b", "[..])].concat(); - if !not_installed.is_empty() { - let plugins = not_installed.join(&b", "[..]); - self.warn( - &[ - &b"Not installed: "[..], - &plugins, - b"\n Install prettier and every plugin in your config, and bun fmt will use them for these files.", - ] - .concat(), - ); - } + self.say_not_installed(not_installed); match self.options.allow_unsupported { true => self.warn(&text), false => { diff --git a/src/lint/driver/fmt/tailwind.rs b/src/lint/driver/fmt/tailwind.rs index 6d307b521b30..5cbd0df9afd7 100644 --- a/src/lint/driver/fmt/tailwind.rs +++ b/src/lint/driver/fmt/tailwind.rs @@ -160,6 +160,10 @@ impl std::fmt::Debug for OfGroup { impl std::panic::RefUnwindSafe for OfGroup {} impl Orders for OfGroup { + fn has_tailwind(&self) -> bool { + self.which.is_ok() + } + fn ranks_of(&self, classes: &[u8]) -> Option> { let mut known = self.classes.known.lock(); let which = match &self.which { @@ -299,7 +303,10 @@ pub(crate) fn option_of_plugin( /// What the plugin says about its options, which are `entries` of `sortTailwindcss`. pub(crate) fn warning_of_plugin(entries: &[(Vec, Json)]) -> Option> { - let get = |key: &[u8]| entries.iter().find(|it| it.0 == key)?.1.as_str(); + let get = |key: &[u8]| { + let path = entries.iter().find(|it| it.0 == key)?.1.as_str()?; + (!path.is_empty()).then_some(path) + }; let is_sheet = |path: &&[u8]| path.ends_with(b".css"); let old: &[u8] = match (get(b"stylesheet"), get(b"entryPoint"), get(b"config")) { (Some(_), _, Some(config)) | (_, Some(_), Some(config)) if !is_sheet(&config) => { @@ -333,7 +340,8 @@ pub(crate) fn for_file( .collect() }; let path_at = |key: &[u8]| { - let path = get(key)?.as_str()?; + // The plugin takes an empty one for none. + let path = get(key)?.as_str().filter(|it| !it.is_empty())?; Some(paths::resolve(base, &paths::from_native(path))) }; let is_on = |key: &[u8]| get(key).and_then(Json::as_bool) == Some(true); @@ -425,12 +433,6 @@ impl Classes { found.any(|it| it.0.ends_with(PLUGIN)) } - /// Whether the package has been looked for from the directory of `path`, in vain. - pub(crate) fn has_no_package_for(&self, path: &[u8]) -> bool { - let known = self.known.lock(); - matches!(known.by_directory.get(paths::dirname(path)), Some(None)) - } - /// Asks Tailwind about the classes that are not known. `Err`: for the user: there are files for which it cannot be /// asked. What it says about the others is known all the same. pub(crate) fn ask(&self, environment: &Environment) -> Result<(), Vec> { diff --git a/src/lint/driver/format/info.rs b/src/lint/driver/format/info.rs index c8e072952419..7525d550f8b5 100644 --- a/src/lint/driver/format/info.rs +++ b/src/lint/driver/format/info.rs @@ -130,16 +130,24 @@ impl<'r> Source<'r> { } /// [`Info::start`] + #[inline] pub(super) fn start(&self, message: &LintMessage) -> Position { // In a line after the first, which may start with a byte order mark, and before its end, the line is the one that - // ESLint counts, and the column is known with the offset. + // ESLint counts, and the column is known with the offset. A column can be past the end of its line. if message.line > 1 && self.lines.is_none() { let (at, column) = self.offsets.at(message.line, message.column); - if self.text.get(at).is_some_and(|it| *it != b'\n') { + let next_line = self.offsets.lines.start_of(message.line + 1); + let is_in_line = at < next_line.map_or(usize::MAX, |it| it as usize); + if is_in_line && self.text.get(at).is_some_and(|it| *it != b'\n') { let line = message.line as usize; return Position { line, column }; } } + self.start_by_offset(message) + } + + #[cold] + fn start_by_offset(&self, message: &LintMessage) -> Position { (self.span(message)).map_or_else(Default::default, |it| self.position(it.0)) } diff --git a/src/lint/jest/jest.rs b/src/lint/jest/jest.rs index aaf6a6373047..d4cea82d96f8 100644 --- a/src/lint/jest/jest.rs +++ b/src/lint/jest/jest.rs @@ -617,9 +617,12 @@ fn collect_possible_jest_call_node<'a>( .map(|id| Expr::from_raw(file, *id)) .filter(is_global) .for_each(|it| found.add_calls_of(it, u32::MAX, u32::MAX, frameworks)); - utils::sort::sort_unstable_by_key(&mut found.calls, |it| { - (it.span.start, std::cmp::Reverse(it.span.end)) - }); + // A test file has hundreds, so not through a call for each comparison. + let keys = found.calls.iter().enumerate(); + let mut keys: Vec = keys.map(|(at, it)| it.span.sort_key(0, at)).collect(); + keys.sort(); + let mut order: Vec = keys.iter().map(|&key| key as u32).collect(); + utils::sort::apply_permutation_in_place(&mut found.calls, &mut order); found } diff --git a/src/lint/oxlint_order.rs b/src/lint/oxlint_order.rs index 64830ef9d14d..ec70864f6727 100644 --- a/src/lint/oxlint_order.rs +++ b/src/lint/oxlint_order.rs @@ -288,9 +288,9 @@ fn merged(a: &[u64], b: &[u64]) -> Vec { all } -/// Puts `nodes`, which are by where the node starts, in the order in which a walk enters the nodes, those of one node by the rule. In -/// the high half of each is where the node starts, in the low half the place in `diagnostics` and in `found`, which has the nodes, each with what decides among -/// what one rule reports about it. +/// Puts `nodes`, which are by where the node starts, in the order in which a walk enters the nodes, those of one node by the +/// rule. In the high half of each is where the node starts, in the low half the place in `diagnostics` and in `found`, which has +/// the nodes, each with what decides among what one rule reports about it. fn sort_as_entered<'a>( file: &'a File<'a>, diagnostics: &[Diagnostic], diff --git a/src/lint/utils/sort.rs b/src/lint/utils/sort.rs index b13c6ec0cf33..54c8cc668a94 100644 --- a/src/lint/utils/sort.rs +++ b/src/lint/utils/sort.rs @@ -6,10 +6,10 @@ //! indices into something else is sorted as it is, by [`sort_indices`]. pub use bun_collections::index_sort::{ - sort_indices, sort_indices_unstable, sort_slice as sort, sort_slice_by as sort_by, - sort_slice_by_cached_key as sort_by_cached_key, sort_slice_by_key as sort_by_key, - sort_slice_unstable as sort_unstable, sort_slice_unstable_by as sort_unstable_by, - sort_slice_unstable_by_key as sort_unstable_by_key, + apply_permutation_in_place, sort_indices, sort_indices_unstable, sort_slice as sort, + sort_slice_by as sort_by, sort_slice_by_cached_key as sort_by_cached_key, + sort_slice_by_key as sort_by_key, sort_slice_unstable as sort_unstable, + sort_slice_unstable_by as sort_unstable_by, sort_slice_unstable_by_key as sort_unstable_by_key, }; /// What a hash table has, by `key`, which no two of them have in common. The order of the table itself depends on the machine, so diff --git a/src/sema/check/sink.rs b/src/sema/check/sink.rs index 8fc3f7e231d4..4f51de633689 100644 --- a/src/sema/check/sink.rs +++ b/src/sema/check/sink.rs @@ -315,8 +315,13 @@ impl super::Program<'_> { file => self.files.modules.get(file.idx()).map(|m| m.file_name()), }; let path = |file: FileId| path(file).map(crate::resolve::typescript_path); - (path(a.file), a.start, a.end, a.code, &a.args) - .cmp(&(path(b.file), b.start, b.end, b.code, &b.args)) + // Nearly always two of one file. + let by_path = match a.file == b.file { + true => std::cmp::Ordering::Equal, + false => path(a.file).cmp(&path(b.file)), + }; + by_path + .then_with(|| (a.start, a.end, a.code, &a.args).cmp(&(b.start, b.end, b.code, &b.args))) .then_with(|| compare_message_chain_size(&a.message_chain, &b.message_chain)) .then_with(|| compare_message_chain_content(&a.message_chain, &b.message_chain)) // `compareRelatedInfo` diff --git a/test/cli/format/format.test.ts b/test/cli/format/format.test.ts index 135b1bf985ab..4a52201b6582 100644 --- a/test/cli/format/format.test.ts +++ b/test/cli/format/format.test.ts @@ -1597,6 +1597,10 @@ exports.format = async (text, options) => { expect(result.stderr).toContain("Used your installed Prettier for 2 files"); expect(result.stderr).not.toContain("[warn]"); expect(result.exitCode).toBe(0); + // The flag is for what cannot be done, and this can. + const withFlag = await format(project, ["--allow-unsupported"], { reads: names }); + expect(withFlag.files).toEqual(result.files); + expect(withFlag.stderr).toContain("Used your installed Prettier for 2 files"); // Without Prettier only those files are left, and with the flag they are formatted without the plugin. const { ".prettierrc": config, "package.json": text } = project; @@ -1604,9 +1608,14 @@ exports.format = async (text, options) => { const left = await format(alone, [], { reads: ["package.json", "d.js"] }); expect(left.files).toEqual({ "package.json": text, "d.js": "d;\n" }); expect(left.stderr).toContain(`Not installed: prettier, ${plugin}`); + expect(left.stderr).toContain( + `[error] 1 file was not formatted because the Prettier config uses plugins that bun fmt does not include: ${plugin}\n[error] Use --allow-unsupported to format it without the plugins.\n`, + ); + expect(left.stderr).not.toContain("language"); expect(left.exitCode).toBe(2); const allowed = await format(alone, ["--allow-unsupported"], { reads: ["package.json"] }); expect(allowed.files).toEqual({ "package.json": '{\n "a": 1\n}\n' }); + expect(allowed.stderr).toContain(`[warn] Not installed: prettier, ${plugin}`); expect(allowed.exitCode).toBe(0); }, timeout, diff --git a/test/cli/format/tailwind/tailwind.test.ts b/test/cli/format/tailwind/tailwind.test.ts index 324e776265ea..475a83305ee7 100644 --- a/test/cli/format/tailwind/tailwind.test.ts +++ b/test/cli/format/tailwind/tailwind.test.ts @@ -347,7 +347,7 @@ if (now.join() !== before.join()) throw new Error("There is " + now.filter(it => expect(result.stderr.split("\n").filter(line => line.startsWith("[error]"))).toEqual([ `[error] caf\uFFFD/b.jsx: ${unknown} It was not formatted.`, `[error] sortTailwindcss: ${String(dir)}/caf\uFFFD/node_modules/tailwindcss: cannot load Tailwind CSS from a path that is not UTF-8`, - "[error] 1 file was not formatted.", + "[error] 1 file with classes was not formatted.", ]); expect(result.files).toEqual([sorted]); expect(readFileSync(inside("/b.jsx"), "utf8")).toBe(`${input}b ;\n`); @@ -449,7 +449,9 @@ if (now.join() !== before.join()) throw new Error("There is " + now.filter(it => expect(lists[0].stderr).toContain( `[${allows ? "warn" : "error"}] sortTailwindcss: ${join(String(dir), "three/tailwind.config.js").replaceAll("\\", "/")}: It has no such plugin.`, ); - expect(lists[0].stderr).toContain(allows ? "Classes were not sorted in 7 files." : "7 files were not formatted."); + expect(lists[0].stderr).toContain( + allows ? "Classes were not sorted in 7 files." : "7 files with classes were not formatted.", + ); expect(Array.from(lists[0].stderr.matchAll(/(\w+)\/tailwind\.config\.js: /g), it => it[1])).toEqual([ "a", "b", @@ -589,6 +591,11 @@ if (now.join() !== before.join()) throw new Error("There is " + now.filter(it => expect(result).toMatchObject({ stderr, files: [reversed], exitCode: 0 }); }); + test.each(["tailwindStylesheet", "tailwindEntryPoint", "tailwindConfig"])("an empty %s is none", async name => { + const result = await format({ ...project, ...config({ [name]: "" }), "a.jsx": input }, ["a.jsx"]); + expect(result).toMatchObject({ stderr: "", files: [sorted], exitCode: 0 }); + }); + test("a path is relative to the working directory if the config is the one that --config names", async () => { const files = { "config/.prettierrc": config({ tailwindStylesheet: "./app.css" })[".prettierrc"], @@ -630,6 +637,26 @@ if (now.join() !== before.join()) throw new Error("There is " + now.filter(it => 'Invalid tailwindPreserveWhitespace value. Expected true or false, but received "yes".', ], [{ tailwindStylesheet: 1 }, "Invalid tailwindStylesheet value. Expected a string, but received 1."], + [{ tailwindStylesheet: null }, "Invalid tailwindStylesheet value. Expected a string, but received null."], + [{ tailwindConfig: null }, "Invalid tailwindConfig value. Expected a string, but received null."], + [{ tailwindEntryPoint: null }, "Invalid tailwindEntryPoint value. Expected a string, but received null."], + [{ tailwindPackageName: null }, "Invalid tailwindPackageName value. Expected a string, but received null."], + [ + { tailwindFunctions: null }, + "Invalid tailwindFunctions value. Expected an array of a string, but received null.", + ], + [ + { tailwindAttributes: null }, + "Invalid tailwindAttributes value. Expected an array of a string, but received null.", + ], + [ + { tailwindPreserveWhitespace: null }, + "Invalid tailwindPreserveWhitespace value. Expected true or false, but received null.", + ], + [ + { tailwindPreserveDuplicates: null }, + "Invalid tailwindPreserveDuplicates value. Expected true or false, but received null.", + ], ])("a value of the wrong type is refused: %j", async (options, refusal) => { const result = await format({ ...project, ...config(options), "a.jsx": input }, ["a.jsx"]); expect(result.stderr).toBe(`[error] ${refusal}\n`); @@ -660,9 +687,8 @@ if (now.join() !== before.join()) throw new Error("There is " + now.filter(it => const files = { ".prettierrc": JSON.stringify({ plugins: [plugin, "prettier-plugin-other"] }), "a.jsx": input }; const result = await format({ ...project, ...files }, ["a.jsx"], ["--log-level=log", "--check"]); expect(result.stderr).toContain("plugins that bun fmt does not include: prettier-plugin-other\n"); - expect(result.stderr).toMatch( - /\[warn\] prettier-plugin-other in \S+ has no effect because bun fmt does not include that plugin\n/, - ); + // That it has no effect is said in a run that goes on without it. + expect(result.stderr).not.toContain("has no effect"); // Nothing is said about files that have not been looked at. expect(result.stdout).toBe("Checking formatting...\n"); expect(result.files).toEqual([input]); @@ -899,6 +925,23 @@ export async function createSorter({ configPath, stylesheetPath }) { }); }); + // Each stream has what Prettier's has. + test("--check says on standard output that an error occurred, and on standard error what it is", async () => { + const files = { ...config({}), "tailwind.config.js": 'throw new Error("boom");\n', "a.jsx": input }; + using dir = tempDir("bun-format-tailwind", { ...version3, ...installed, ...files }); + const result = await formatIn(String(dir), [], ["--log-level=log", "--check", "a.jsx"]); + expect(result.stdout).toBe( + "Checking formatting...\nError occurred when checking code style in the above file.\n", + ); + expect(result.stderr.split("\n")).toEqual([ + `[error] a.jsx: ${unknown} It was not formatted.`, + expect.stringMatching(/^\[error\] sortTailwindcss: \S+tailwind\.config\.js: boom$/), + "[error] 1 file with classes was not formatted.", + "", + ]); + expect(result.exitCode).toBe(2); + }); + test("a flag or [fmt] counts for more", async () => { const files = { ...config({}), "bunfig.toml": '[fmt.sortTailwindcss]\nfunctions = ["mine"]\n' }; const result = await format({ ...project, ...files, "a.jsx": 'mine("p-4 flex");\n' }, ["a.jsx"]); diff --git a/test/cli/lint/defaults.test.ts b/test/cli/lint/defaults.test.ts index 155329c9a64e..f8c2a585449c 100644 --- a/test/cli/lint/defaults.test.ts +++ b/test/cli/lint/defaults.test.ts @@ -134,9 +134,11 @@ describe.concurrent("globals", () => { ]; const words = ["test", "spec", "e2e", "cy", "tes", "tests", "Test", "atest"]; const extensions = ["js", "mjs", "cjs", "jsx", "ts", "mts", "cts", "tsx"]; + // To Windows and macOS `a.Test.js` is the file `a.test.js`. + const folderOf = (word: string) => (word === "Test" ? "cased/" : ""); const names = stems.flatMap(stem => separators.flatMap(separator => - words.flatMap(word => extensions.map(extension => `${stem}${separator}${word}.${extension}`)), + words.flatMap(word => extensions.map(extension => `${folderOf(word)}${stem}${separator}${word}.${extension}`)), ), ); names.push("a.test.js.js", "a.test.jsx.ts", "a.test.d.ts", "a.test..js", "a.spec.ts.mts"); diff --git a/test/cli/lint/oxc-errors.test.ts b/test/cli/lint/oxc-errors.test.ts index 41b08632c6a0..6bab28c9a78c 100644 --- a/test/cli/lint/oxc-errors.test.ts +++ b/test/cli/lint/oxc-errors.test.ts @@ -4284,6 +4284,215 @@ const tables: [title: string, config: object | undefined, rows: Row[]][] = [ ], ], ], + [ + "a rule that is called with a type annotation reports at its colon", + { + "categories": { "correctness": "off" }, + "plugins": ["typescript"], + "rules": { + "typescript/prefer-function-type": "error", + "typescript/method-signature-style": "error", + "typescript/no-explicit-any": "error", + "id-length": "error", + "func-style": "error", + }, + }, + [ + [ + "ts", + "export interface I {\n m(c: ((...a: any[]) => any) | { new (...a: any[]): any }): void;\n}", + [ + "eslint(id-length) | Identifier name is too short (< 2). | | 17+1", + "typescript(method-signature-style) | Use a property signature instead of a method signature. | Replace the method signature with a property whose type is a function type. | 23+64", + "eslint(id-length) | Identifier name is too short (< 2). | | 23+1", + "eslint(id-length) | Identifier name is too short (< 2). | | 25+1", + "typescript(prefer-function-type) | Enforce using function types instead of interfaces with call signatures. | The function type form `new (...a: any[]) => any` is generally preferred when possible for being more succinct. | 55+22", + "eslint(id-length) | Identifier name is too short (< 2). | | 33+1", + "typescript(no-explicit-any) | Unexpected `any`. Specify a different type. | Use `unknown` instead, this will force you to explicitly, and safely, assert the type is correct. | 36+3", + "typescript(no-explicit-any) | Unexpected `any`. Specify a different type. | Use `unknown` instead, this will force you to explicitly, and safely, assert the type is correct. | 46+3", + "eslint(id-length) | Identifier name is too short (< 2). | | 63+1", + "typescript(no-explicit-any) | Unexpected `any`. Specify a different type. | Use `unknown` instead, this will force you to explicitly, and safely, assert the type is correct. | 66+3", + "typescript(no-explicit-any) | Unexpected `any`. Specify a different type. | Use `unknown` instead, this will force you to explicitly, and safely, assert the type is correct. | 74+3", + ], + ], + [ + "ts", + "export const a: { (b: any): any } = (b: any) => b;", + [ + "eslint(id-length) | Identifier name is too short (< 2). | | 13+1", + "typescript(prefer-function-type) | Enforce using function types instead of interfaces with call signatures. | The function type form `(b: any) => any` is generally preferred when possible for being more succinct. | 18+13", + "eslint(id-length) | Identifier name is too short (< 2). | | 19+1", + "typescript(no-explicit-any) | Unexpected `any`. Specify a different type. | Use `unknown` instead, this will force you to explicitly, and safely, assert the type is correct. | 22+3", + "typescript(no-explicit-any) | Unexpected `any`. Specify a different type. | Use `unknown` instead, this will force you to explicitly, and safely, assert the type is correct. | 28+3", + "eslint(id-length) | Identifier name is too short (< 2). | | 37+1", + "typescript(no-explicit-any) | Unexpected `any`. Specify a different type. | Use `unknown` instead, this will force you to explicitly, and safely, assert the type is correct. | 40+3", + ], + ], + [ + "ts", + "export function f(a: { (b: any): void }): { (c: any): void } {\n return a;\n}", + [ + "eslint(func-style) | Expected a function expression. | Enforce the consistent use of either `function` declarations or expressions assigned to variables | 7+69", + "eslint(id-length) | Identifier name is too short (< 2). | | 16+1", + "eslint(id-length) | Identifier name is too short (< 2). | | 18+1", + "typescript(prefer-function-type) | Enforce using function types instead of interfaces with call signatures. | The function type form `(b: any) => void` is generally preferred when possible for being more succinct. | 23+14", + "eslint(id-length) | Identifier name is too short (< 2). | | 24+1", + "typescript(no-explicit-any) | Unexpected `any`. Specify a different type. | Use `unknown` instead, this will force you to explicitly, and safely, assert the type is correct. | 27+3", + "typescript(prefer-function-type) | Enforce using function types instead of interfaces with call signatures. | The function type form `(c: any) => void` is generally preferred when possible for being more succinct. | 44+14", + "eslint(id-length) | Identifier name is too short (< 2). | | 45+1", + "typescript(no-explicit-any) | Unexpected `any`. Specify a different type. | Use `unknown` instead, this will force you to explicitly, and safely, assert the type is correct. | 48+3", + ], + ], + ], + ], + [ + "import/newline-after-import reports a statement that requires when oxlint comes to the last call in it", + { + "categories": { "correctness": "off" }, + "plugins": ["import", "typescript", "unicorn"], + "rules": { + "import/newline-after-import": "error", + "import/first": "error", + "typescript/no-require-imports": "error", + "typescript/no-var-requires": "error", + "unicorn/prefer-module": "error", + "id-length": "error", + "func-style": "error", + "no-undef": "error", + }, + }, + [ + [ + "js", + 'const a = require("a");\nconst b = require("b");\nfoo(a, b);\nconst c = require("c");\nbar(c);', + [ + "eslint(no-undef) | 'require' is not defined. | Either define 'require' or remove the reference to it. If 'require' is a global variable, add it to the 'globals' configuration. | 10+7", + "eslint(no-undef) | 'require' is not defined. | Either define 'require' or remove the reference to it. If 'require' is a global variable, add it to the 'globals' configuration. | 34+7", + "eslint(no-undef) | 'require' is not defined. | Either define 'require' or remove the reference to it. If 'require' is a global variable, add it to the 'globals' configuration. | 69+7", + "eslint(no-undef) | 'bar' is not defined. | Either define 'bar' or remove the reference to it. If 'bar' is a global variable, add it to the 'globals' configuration. | 83+3", + "eslint(no-undef) | 'foo' is not defined. | Either define 'foo' or remove the reference to it. If 'foo' is a global variable, add it to the 'globals' configuration. | 48+3", + "eslint(id-length) | Identifier name is too short (< 2). | | 6+1", + 'typescript(no-require-imports) | Expected "import" statement instead of "require" call | Do not use CommonJS `require` calls | 10+12', + "typescript(no-var-requires) | Require statement not part of import statement. | Use ES module imports or `import = require` instead. | 10+12", + 'unicorn(prefer-module) | Do not use "require". | Prefer ES modules over CommonJS globals. | 10+7', + "eslint(id-length) | Identifier name is too short (< 2). | | 30+1", + "import(newline-after-import) | Expected 1 empty line after require statement not followed by another require. | Add empty line(s) after require | 24+23", + 'typescript(no-require-imports) | Expected "import" statement instead of "require" call | Do not use CommonJS `require` calls | 34+12', + "typescript(no-var-requires) | Require statement not part of import statement. | Use ES module imports or `import = require` instead. | 34+12", + 'unicorn(prefer-module) | Do not use "require". | Prefer ES modules over CommonJS globals. | 34+7', + "eslint(id-length) | Identifier name is too short (< 2). | | 65+1", + "import(newline-after-import) | Expected 1 empty line after require statement not followed by another require. | Add empty line(s) after require | 59+23", + 'typescript(no-require-imports) | Expected "import" statement instead of "require" call | Do not use CommonJS `require` calls | 69+12', + "typescript(no-var-requires) | Require statement not part of import statement. | Use ES module imports or `import = require` instead. | 69+12", + 'unicorn(prefer-module) | Do not use "require". | Prefer ES modules over CommonJS globals. | 69+7', + ], + ], + [ + "js", + 'import x from "x";\nconst a = require("a");\nfoo(a, x);\nfunction f() {\n const d = require("d");\n return d;\n}\nf();', + [ + "eslint(no-undef) | 'require' is not defined. | Either define 'require' or remove the reference to it. If 'require' is a global variable, add it to the 'globals' configuration. | 29+7", + "eslint(no-undef) | 'require' is not defined. | Either define 'require' or remove the reference to it. If 'require' is a global variable, add it to the 'globals' configuration. | 81+7", + "eslint(no-undef) | 'foo' is not defined. | Either define 'foo' or remove the reference to it. If 'foo' is a global variable, add it to the 'globals' configuration. | 43+3", + "import(newline-after-import) | Expected 1 empty line after import statement not followed by another import. | Add empty line(s) after import | 0+18", + "eslint(id-length) | Identifier name is too short (< 2). | | 7+1", + "eslint(id-length) | Identifier name is too short (< 2). | | 25+1", + "import(newline-after-import) | Expected 1 empty line after require statement not followed by another require. | Add empty line(s) after require | 19+23", + 'typescript(no-require-imports) | Expected "import" statement instead of "require" call | Do not use CommonJS `require` calls | 29+12', + "typescript(no-var-requires) | Require statement not part of import statement. | Use ES module imports or `import = require` instead. | 29+12", + 'unicorn(prefer-module) | Do not use "require". | Prefer ES modules over CommonJS globals. | 29+7', + "eslint(func-style) | Expected a function expression. | Enforce the consistent use of either `function` declarations or expressions assigned to variables | 54+54", + "eslint(id-length) | Identifier name is too short (< 2). | | 63+1", + "eslint(id-length) | Identifier name is too short (< 2). | | 77+1", + 'typescript(no-require-imports) | Expected "import" statement instead of "require" call | Do not use CommonJS `require` calls | 81+12', + "typescript(no-var-requires) | Require statement not part of import statement. | Use ES module imports or `import = require` instead. | 81+12", + 'unicorn(prefer-module) | Do not use "require". | Prefer ES modules over CommonJS globals. | 81+7', + ], + ], + [ + "js", + 'const a = require("a"), b = require("b");\nfoo(a, b);\nif (a) {\n const c = require("c");\n bar(c);\n}', + [ + "eslint(no-undef) | 'require' is not defined. | Either define 'require' or remove the reference to it. If 'require' is a global variable, add it to the 'globals' configuration. | 10+7", + "eslint(no-undef) | 'require' is not defined. | Either define 'require' or remove the reference to it. If 'require' is a global variable, add it to the 'globals' configuration. | 28+7", + "eslint(no-undef) | 'require' is not defined. | Either define 'require' or remove the reference to it. If 'require' is a global variable, add it to the 'globals' configuration. | 74+7", + "eslint(no-undef) | 'bar' is not defined. | Either define 'bar' or remove the reference to it. If 'bar' is a global variable, add it to the 'globals' configuration. | 90+3", + "eslint(no-undef) | 'foo' is not defined. | Either define 'foo' or remove the reference to it. If 'foo' is a global variable, add it to the 'globals' configuration. | 42+3", + "eslint(id-length) | Identifier name is too short (< 2). | | 6+1", + 'typescript(no-require-imports) | Expected "import" statement instead of "require" call | Do not use CommonJS `require` calls | 10+12', + "typescript(no-var-requires) | Require statement not part of import statement. | Use ES module imports or `import = require` instead. | 10+12", + 'unicorn(prefer-module) | Do not use "require". | Prefer ES modules over CommonJS globals. | 10+7', + "eslint(id-length) | Identifier name is too short (< 2). | | 24+1", + "import(newline-after-import) | Expected 1 empty line after require statement not followed by another require. | Add empty line(s) after require | 0+41", + 'typescript(no-require-imports) | Expected "import" statement instead of "require" call | Do not use CommonJS `require` calls | 28+12', + "typescript(no-var-requires) | Require statement not part of import statement. | Use ES module imports or `import = require` instead. | 28+12", + 'unicorn(prefer-module) | Do not use "require". | Prefer ES modules over CommonJS globals. | 28+7', + "eslint(id-length) | Identifier name is too short (< 2). | | 70+1", + 'typescript(no-require-imports) | Expected "import" statement instead of "require" call | Do not use CommonJS `require` calls | 74+12', + "typescript(no-var-requires) | Require statement not part of import statement. | Use ES module imports or `import = require` instead. | 74+12", + 'unicorn(prefer-module) | Do not use "require". | Prefer ES modules over CommonJS globals. | 74+7', + ], + ], + [ + "js", + 'import x from "x";\nfoo(x);\nimport y from "y";\nbar(y);\nconst z = require("z");\nbaz(z);', + [ + "import(first) | Import statements must come first | Move import statement to the top of the file | 27+18", + "eslint(no-undef) | 'require' is not defined. | Either define 'require' or remove the reference to it. If 'require' is a global variable, add it to the 'globals' configuration. | 64+7", + "eslint(no-undef) | 'foo' is not defined. | Either define 'foo' or remove the reference to it. If 'foo' is a global variable, add it to the 'globals' configuration. | 19+3", + "eslint(no-undef) | 'bar' is not defined. | Either define 'bar' or remove the reference to it. If 'bar' is a global variable, add it to the 'globals' configuration. | 46+3", + "eslint(no-undef) | 'baz' is not defined. | Either define 'baz' or remove the reference to it. If 'baz' is a global variable, add it to the 'globals' configuration. | 78+3", + "import(newline-after-import) | Expected 1 empty line after import statement not followed by another import. | Add empty line(s) after import | 0+18", + "eslint(id-length) | Identifier name is too short (< 2). | | 7+1", + "import(newline-after-import) | Expected 1 empty line after import statement not followed by another import. | Add empty line(s) after import | 27+18", + "eslint(id-length) | Identifier name is too short (< 2). | | 34+1", + "eslint(id-length) | Identifier name is too short (< 2). | | 60+1", + "import(newline-after-import) | Expected 1 empty line after require statement not followed by another require. | Add empty line(s) after require | 54+23", + 'typescript(no-require-imports) | Expected "import" statement instead of "require" call | Do not use CommonJS `require` calls | 64+12', + "typescript(no-var-requires) | Require statement not part of import statement. | Use ES module imports or `import = require` instead. | 64+12", + 'unicorn(prefer-module) | Do not use "require". | Prefer ES modules over CommonJS globals. | 64+7', + ], + ], + ], + ], + [ + "react/exhaustive-deps goes through the dependencies that are declared in the order of oxlint's hash table", + { "categories": { "correctness": "off" }, "plugins": ["react"], "rules": { "react/exhaustive-deps": ["error"] } }, + [ + [ + "tsx", + "import MutableStore from 'store';\n let z = {};\n\n function MyComponent(props) {\n let x = props.foo;\n {\n let y = props.bar;\n const fn = useCallback(() => {\n // nothing\n }, [MutableStore.hello.world, props.foo, x, y, z, global.stuff]);\n }\n }", + [ + "react-hooks(exhaustive-deps) | React Hook useCallback has an unnecessary dependency: MutableStore. | Consider removing it from the dependency array. Outer scope values aren't valid dependencies because mutating them doesn't re-render the component. | 249+24", + "react-hooks(exhaustive-deps) | React Hook useCallback has an unnecessary dependency: z. | Consider removing it from the dependency array. Outer scope values aren't valid dependencies because mutating them doesn't re-render the component. | 292+1", + "react-hooks(exhaustive-deps) | React Hook useCallback has an unnecessary dependency: global. | Consider removing it from the dependency array. Outer scope values aren't valid dependencies because mutating them doesn't re-render the component. | 295+12", + "react-hooks(exhaustive-deps) | React Hook useCallback has unnecessary dependency: MutableStore.hello.world | Either include it or remove the dependency array. | 248+60", + "react-hooks(exhaustive-deps) | React Hook useCallback has unnecessary dependency: z | Either include it or remove the dependency array. | 248+60", + "react-hooks(exhaustive-deps) | React Hook useCallback has unnecessary dependency: global.stuff | Either include it or remove the dependency array. | 248+60", + "react-hooks(exhaustive-deps) | React Hook useCallback has unnecessary dependency: y | Either include it or remove the dependency array. | 248+60", + "react-hooks(exhaustive-deps) | React Hook useCallback has unnecessary dependency: x | Either include it or remove the dependency array. | 248+60", + "react-hooks(exhaustive-deps) | React Hook useCallback has unnecessary dependency: props.foo | Either include it or remove the dependency array. | 248+60", + "react-hooks(exhaustive-deps) | React hook useCallback depends on `z`, which changes every render | Try memoizing this variable with `useRef` or `useCallback`. | 292+1 it will always cause this hook to re-evaluate | 46+1 `z` is declared here", + ], + ], + [ + "tsx", + "import MutableStore from 'store';\n let z = {};\n\n function MyComponent(props) {\n let x = props.foo;\n {\n let y = props.bar;\n const fn = useCallback(() => {\n // nothing\n }, [MutableStore?.hello?.world, props.foo, x, y, z, global?.stuff]);\n }\n }", + [ + "react-hooks(exhaustive-deps) | React Hook useCallback has an unnecessary dependency: MutableStore. | Consider removing it from the dependency array. Outer scope values aren't valid dependencies because mutating them doesn't re-render the component. | 249+26", + "react-hooks(exhaustive-deps) | React Hook useCallback has an unnecessary dependency: z. | Consider removing it from the dependency array. Outer scope values aren't valid dependencies because mutating them doesn't re-render the component. | 294+1", + "react-hooks(exhaustive-deps) | React Hook useCallback has an unnecessary dependency: global. | Consider removing it from the dependency array. Outer scope values aren't valid dependencies because mutating them doesn't re-render the component. | 297+13", + "react-hooks(exhaustive-deps) | React Hook useCallback has unnecessary dependency: MutableStore.hello.world | Either include it or remove the dependency array. | 248+63", + "react-hooks(exhaustive-deps) | React Hook useCallback has unnecessary dependency: z | Either include it or remove the dependency array. | 248+63", + "react-hooks(exhaustive-deps) | React Hook useCallback has unnecessary dependency: global.stuff | Either include it or remove the dependency array. | 248+63", + "react-hooks(exhaustive-deps) | React Hook useCallback has unnecessary dependency: y | Either include it or remove the dependency array. | 248+63", + "react-hooks(exhaustive-deps) | React Hook useCallback has unnecessary dependency: x | Either include it or remove the dependency array. | 248+63", + "react-hooks(exhaustive-deps) | React Hook useCallback has unnecessary dependency: props.foo | Either include it or remove the dependency array. | 248+63", + "react-hooks(exhaustive-deps) | React hook useCallback depends on `z`, which changes every render | Try memoizing this variable with `useRef` or `useCallback`. | 294+1 it will always cause this hook to re-evaluate | 46+1 `z` is declared here", + ], + ], + ], + ], [ "cases of oxlint's own tests that it does not report in the order of the text", { @@ -5801,6 +6010,46 @@ describe.concurrent("bun lint with .oxlintrc.json", () => { await expectErrors(table, config); }); + // As oxlint 1.87 prints them: it makes an offset of the line and the column, and a line and a column of that. + test("-f unix has what a plugin reports past the end of a line in the line where that is", async () => { + const plugin = ` + const places = { "a.js": [[2, 6]], "b.js": [[2, 20], [3, 11], [5, 3]] }; + export default { meta: { name: "pl" }, rules: { r: { create(context) { return { Program() { + for (const [line, column] of places[context.filename.slice(-4)]) { + context.report({ loc: { line, column }, message: "at " + line + ":" + column }); + } + } }; } } } };`; + using dir = tempDir("bun-lint-oxc-errors", { + ".oxlintrc.json": JSON.stringify({ + categories: { correctness: "off" }, + jsPlugins: ["./plugin.js"], + rules: { "pl/r": "warn" }, + ignorePatterns: ["plugin.js"], + }), + "plugin.js": plugin, + "a.js": "a;\nbb;\ncccccc;\n", + // The lines end in `\n`, `\r\n`, `\r` and `\n`, and one is empty. + "b.js": "let a = 1;\n/*😀*/ let b;\r\nlet é = 2;\rx\n\nlast", + }); + await using proc = spawn({ + cmd: [bunExe(), "lint", "-f", "unix", "a.js", "b.js"], + env, + cwd: String(dir), + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(stdout.split("\n").filter(Boolean).sort()).toEqual([ + "4 problems", + "a.js:3:3: at 2:6 [Warning/pl(r)]", + "b.js:3:7: at 2:20 [Warning/pl(r)]", + "b.js:4:1: at 3:11 [Warning/pl(r)]", + "b.js:6:3: at 5:3 [Warning/pl(r)]", + ]); + }); + // oxlint sorts all the messages of a file by the range of their fix before it applies the fixes, and prints what is left in that // order. Those without a fix compare equal, so they stay in the order of the run. describe("--fix leaves what has no fix in the order of the run", () => { diff --git a/test/cli/lint/prettier-rule.test.ts b/test/cli/lint/prettier-rule.test.ts index 45747bded94e..4330245c34a9 100644 --- a/test/cli/lint/prettier-rule.test.ts +++ b/test/cli/lint/prettier-rule.test.ts @@ -395,6 +395,20 @@ describe.concurrent("prettier/prettier", () => { `a.js:1:1: ${unchecked} [Error/bun(format)]`, `b.js:1:1: ${unchecked} [Error/bun(format)]`, ]); + // The plugin of Prettier brings a Tailwind CSS along, which `bun fmt` asks if the project has none. + const { ".oxfmtrc.json": _, ...rest } = classes; + const plugin = "prettier-plugin-tailwindcss"; + expect( + await reportsOf({ + ...rest, + ".prettierrc": `{ "plugins": ["${plugin}"] }`, + [`node_modules/${plugin}/package.json`]: "{}", + }), + ).toEqual([`a.js:1:1: ${unchecked} [Error/bun(format)]`, `b.js:1:1: ${unchecked} [Error/bun(format)]`]); + // A package of another name that is not there is as good as none. + expect( + await reportsOf({ ...rest, ".prettierrc": `{ "plugins": ["${plugin}"], "tailwindPackageName": "@acme/tw" }` }), + ).toEqual(["a.js:1:31: Delete `··` [Error/bun(format)]"]); }); // It is linted script by script, under the name of the file. `--fix` once made one comment of this script. diff --git a/test/js/bun/glob/match.test.ts b/test/js/bun/glob/match.test.ts index 0d42f25d2630..d9d5fb07b391 100644 --- a/test/js/bun/glob/match.test.ts +++ b/test/js/bun/glob/match.test.ts @@ -2772,6 +2772,23 @@ describe("Glob.match", () => { }, ); + test.each([ + ["**/*[!a]*{a,b}", ["/abc/aa", "x//abc/aa", "b/x//abc/aa"], ["/abc/ac", "c/cc"]], + ["**/*[^b]*@(a|b)", ["abc/b", "x/abc/b"], ["abc/c", "b"]], + ["*[^b]*{a,b}/x", ["abc/ab/x", "ca/x"], ["cc/x", "abc/cc/x"]], + ["a/**/*[^/]*[!a]*{a,b}", ["a/bcaa", "a/x/bc/a"], ["a/aa", "b/bcaa"]], + ["**/*[!a]*[^/]/**/*{,b}/*", ["b/./.a/é", "2/x//y/z", "a}/n/../"], ["a/a", "aa"]], + ["**/*[^b]**a*/**/{a,b}*/*", ["1/a/aa/aa", "1/a/aa/aa/x"], ["1/c/cc/cc"]], + ["**/*[!a]*[+-9]/**/*/{*a,b}/b", ["a,b/2/ba//a/b", "x/2/ba//a/b"], ["x/2/c/c/c"]], + ["**/*[!a]*[!a]/**/*/{,b}/*/*", ["ab/ab/x//x/+", "ab/ab/x//x/y"], ["aa/aa"]], + ])("a group after a `*` and a class that can match a separator: %s", (pattern, matches, others) => { + const glob = new Glob(pattern); + for (let round = 0; round < 12; round++) { + expect(matches.filter(path => !glob.match(path) || !new Glob(pattern).match(path))).toEqual([]); + expect(others.filter(path => glob.match(path) || new Glob(pattern).match(path))).toEqual([]); + } + }); + test.each([ ["\\{a,b\\}", ["{a,b}"], ["a", "b", "{a,b", "{a,b}x", ""]], ["\\*.ts", ["*.ts"], ["a.ts", ".ts", "*.tsx", "x*.ts"]],