Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions crates/oxc_linter/src/rules/eslint/valid_typeof.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use oxc_ast::{AstKind, ast::Expression};
use oxc_diagnostics::OxcDiagnostic;
use oxc_macros::declare_oxc_lint;
use oxc_span::{GetSpan, Span};
use oxc_span::{GetSpan, Span, best_match};
use oxc_syntax::operator::UnaryOperator;
use schemars::JsonSchema;
use serde::Deserialize;
Expand All @@ -10,7 +10,6 @@ use crate::{
AstNode,
context::LintContext,
rule::{DefaultRuleConfig, Rule},
utils::best_match,
};

fn not_string(help: Option<&'static str>, span: Span) -> OxcDiagnostic {
Expand Down
3 changes: 1 addition & 2 deletions crates/oxc_linter/src/rules/nextjs/no_typos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,12 @@ use oxc_ast::{
};
use oxc_diagnostics::OxcDiagnostic;
use oxc_macros::declare_oxc_lint;
use oxc_span::Span;
use oxc_span::{Span, best_match};

use crate::{
AstNode,
context::{ContextHost, LintContext},
rule::Rule,
utils::best_match,
};

fn no_typos_diagnostic(typo: &str, suggestion: &str, span: Span) -> OxcDiagnostic {
Expand Down
5 changes: 2 additions & 3 deletions crates/oxc_linter/src/utils/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ use oxc_syntax::identifier::{is_identifier_part, is_identifier_start};

mod comment;
mod config;
mod edit_distance;
mod express;
mod jest;
mod jsdoc;
Expand All @@ -29,8 +28,8 @@ mod vitest;
mod vue;

pub use self::{
comment::*, config::*, edit_distance::*, express::*, jest::*, jsdoc::*, nextjs::*, promise::*,
react::*, react_perf::*, regex::*, typescript::*, unicorn::*, url::*, vitest::*, vue::*,
comment::*, config::*, express::*, jest::*, jsdoc::*, nextjs::*, promise::*, react::*,
react_perf::*, regex::*, typescript::*, unicorn::*, url::*, vitest::*, vue::*,
};

/// List of Jest rules that have Vitest equivalents.
Expand Down
77 changes: 70 additions & 7 deletions crates/oxc_semantic/src/checker/javascript.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use rustc_hash::FxHashMap;
use oxc_allocator::GetAddress;
use oxc_ast::{AstKind, ModuleDeclarationKind, ast::*};
use oxc_ecmascript::{BoundNames, IsSimpleParameterList, PropName};
use oxc_span::{GetSpan, ModuleKind, Span};
use oxc_span::{GetSpan, ModuleKind, Span, best_match};
use oxc_syntax::{
class::ClassId,
number::NumberBase,
Expand All @@ -15,22 +15,38 @@ use oxc_syntax::{

use crate::{IsGlobalReference, builder::SemanticBuilder, class::Element, diagnostics};

/// Threshold for edit distance when suggesting similar names
const SUGGESTION_THRESHOLD: usize = 2;

/// It is a Syntax Error if any element of the ExportedBindings of ModuleItemList
/// does not also occur in either the VarDeclaredNames of ModuleItemList, or the LexicallyDeclaredNames of ModuleItemList.
pub fn check_unresolved_exports(program: &Program<'_>, ctx: &SemanticBuilder<'_>) {
if ctx.source_type.is_typescript() || !ctx.source_type.is_module() {
return;
}

let mut available_names: Option<Vec<&str>> = None;
for stmt in &program.body {
if let Statement::ExportNamedDeclaration(decl) = stmt {
for specifier in &decl.specifiers {
if let ModuleExportName::IdentifierReference(ident) = &specifier.local
&& ident.is_global_reference(&ctx.scoping)
{
ctx.errors
.borrow_mut()
.push(diagnostics::undefined_export(&ident.name, ident.span));
let names = available_names.get_or_insert_with(|| {
let root_scope_id = ctx.scoping.root_scope_id();
ctx.scoping
.get_bindings(root_scope_id)
.keys()
.map(oxc_span::Ident::as_str)
.collect()
});
let suggestion =
best_match(&ident.name, names.iter().copied(), SUGGESTION_THRESHOLD);
ctx.errors.borrow_mut().push(diagnostics::undefined_export(
&ident.name,
suggestion,
ident.span,
));
}
}
}
Expand Down Expand Up @@ -338,11 +354,30 @@ pub fn check_private_identifier_outside_class(

fn check_private_identifier(ctx: &SemanticBuilder<'_>) {
if let Some(class_id) = ctx.class_table_builder.current_class_id {
let mut available_names: Option<Vec<&str>> = None;
for reference in ctx.class_table_builder.classes.iter_private_identifiers(class_id) {
if !ctx.class_table_builder.classes.ancestors(class_id).any(|class_id| {
ctx.class_table_builder.classes.has_private_definition(class_id, reference.name)
}) {
ctx.error(diagnostics::private_field_undeclared(&reference.name, reference.span));
let names = available_names.get_or_insert_with(|| {
let mut names = Vec::new();
for ancestor_class_id in ctx.class_table_builder.classes.ancestors(class_id) {
for element in &ctx.class_table_builder.classes.elements[ancestor_class_id]
{
if element.is_private {
names.push(element.name.as_ref());
}
}
}
names
});
let suggestion =
best_match(&reference.name, names.iter().copied(), SUGGESTION_THRESHOLD);
ctx.error(diagnostics::private_field_undeclared(
&reference.name,
suggestion,
reference.span,
));
}
}
}
Expand Down Expand Up @@ -784,12 +819,20 @@ pub fn check_switch_statement<'a>(stmt: &SwitchStatement<'a>, ctx: &SemanticBuil

pub fn check_break_statement(stmt: &BreakStatement, ctx: &SemanticBuilder<'_>) {
// It is a Syntax Error if this BreakStatement is not nested, directly or indirectly (but not crossing function or static initialization block boundaries), within an IterationStatement or a SwitchStatement.

let mut available_labels: Option<Vec<&str>> = None;
for node_kind in ctx.nodes.ancestor_kinds(ctx.current_node_id) {
match node_kind {
AstKind::Program(_) => {
return stmt.label.as_ref().map_or_else(
|| ctx.error(diagnostics::invalid_break(stmt.span)),
|label| ctx.error(diagnostics::invalid_label_target(label.span)),
|label| {
let labels =
available_labels.get_or_insert_with(|| collect_label_names(ctx));
let suggestion =
best_match(&label.name, labels.iter().copied(), SUGGESTION_THRESHOLD);
ctx.error(diagnostics::invalid_label_target(suggestion, label.span));
},
);
}
AstKind::Function(_) | AstKind::StaticBlock(_) => {
Expand Down Expand Up @@ -820,12 +863,20 @@ pub fn check_break_statement(stmt: &BreakStatement, ctx: &SemanticBuilder<'_>) {

pub fn check_continue_statement(stmt: &ContinueStatement, ctx: &SemanticBuilder<'_>) {
// It is a Syntax Error if this ContinueStatement is not nested, directly or indirectly (but not crossing function or static initialization block boundaries), within an IterationStatement.

let mut available_labels: Option<Vec<&str>> = None;
for node_kind in ctx.nodes.ancestor_kinds(ctx.current_node_id) {
match node_kind {
AstKind::Program(_) => {
return stmt.label.as_ref().map_or_else(
|| ctx.error(diagnostics::invalid_continue(stmt.span)),
|label| ctx.error(diagnostics::invalid_label_target(label.span)),
|label| {
let labels =
available_labels.get_or_insert_with(|| collect_label_names(ctx));
let suggestion =
best_match(&label.name, labels.iter().copied(), SUGGESTION_THRESHOLD);
ctx.error(diagnostics::invalid_label_target(suggestion, label.span));
},
);
}
AstKind::Function(_) | AstKind::StaticBlock(_) => {
Expand Down Expand Up @@ -861,6 +912,18 @@ pub fn check_continue_statement(stmt: &ContinueStatement, ctx: &SemanticBuilder<
}
}

fn collect_label_names<'a>(ctx: &'_ SemanticBuilder<'a>) -> Vec<&'a str> {
let mut labels = Vec::new();
for node_kind in ctx.nodes.ancestor_kinds(ctx.current_node_id) {
if let AstKind::LabeledStatement(labeled_statement) = node_kind {
labels.push(labeled_statement.label.name.as_str());
} else if matches!(node_kind, AstKind::Function(_) | AstKind::StaticBlock(_)) {
break;
}
}
labels
}

pub fn check_labeled_statement(stmt: &LabeledStatement, ctx: &SemanticBuilder<'_>) {
for node_kind in ctx.nodes.ancestor_kinds(ctx.current_node_id) {
match node_kind {
Expand Down
29 changes: 22 additions & 7 deletions crates/oxc_semantic/src/diagnostics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,13 @@ pub fn static_and_instance_private_identifier(x0: &str, span1: Span, span2: Span
}

#[cold]
pub fn undefined_export(x0: &str, span1: Span) -> OxcDiagnostic {
OxcDiagnostic::error(format!("Export '{x0}' is not defined")).with_label(span1)
pub fn undefined_export(x0: &str, suggestion: Option<&str>, span1: Span) -> OxcDiagnostic {
let mut diagnostic =
OxcDiagnostic::error(format!("Export '{x0}' is not defined")).with_label(span1);
if let Some(suggestion) = suggestion {
diagnostic = diagnostic.with_help(format!("Did you mean '{suggestion}'?"));
}
diagnostic
}

#[cold]
Expand Down Expand Up @@ -68,9 +73,15 @@ pub fn private_not_in_class(x0: &str, span1: Span) -> OxcDiagnostic {
}

#[cold]
pub fn private_field_undeclared(x0: &str, span1: Span) -> OxcDiagnostic {
OxcDiagnostic::error(format!("Private field '#{x0}' must be declared in an enclosing class"))
.with_label(span1)
pub fn private_field_undeclared(x0: &str, suggestion: Option<&str>, span1: Span) -> OxcDiagnostic {
let mut diagnostic = OxcDiagnostic::error(format!(
"Private field '#{x0}' must be declared in an enclosing class"
))
.with_label(span1);
if let Some(suggestion) = suggestion {
diagnostic = diagnostic.with_help(format!("Did you mean '#{suggestion}'?"));
}
diagnostic
}

#[cold]
Expand Down Expand Up @@ -168,8 +179,12 @@ pub fn invalid_label_jump_target(span: Span) -> OxcDiagnostic {
}

#[cold]
pub fn invalid_label_target(span: Span) -> OxcDiagnostic {
OxcDiagnostic::error("Use of undefined label").with_label(span)
pub fn invalid_label_target(suggestion: Option<&str>, span: Span) -> OxcDiagnostic {
let mut diagnostic = OxcDiagnostic::error("Use of undefined label").with_label(span);
if let Some(suggestion) = suggestion {
diagnostic = diagnostic.with_help(format!("Did you mean '{suggestion}'?"));
}
diagnostic
}

#[cold]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
//! Levenshtein edit distance calculation for suggesting similar names.

/// Returns the Levenshtein edit distance between `a` and `b`.
///
/// Uses a two-row dynamic programming algorithm to keep memory usage small.
Expand Down Expand Up @@ -33,6 +35,10 @@ pub fn best_match<'a>(
let mut best: Option<(&'a str, usize)> = None;

for candidate in candidates {
// no need to calculate distance if length difference exceeds threshold
if candidate.len().abs_diff(needle.len()) > threshold {
continue;
}
let distance = min_edit_distance(candidate, needle);
if distance == 0 {
return None;
Expand All @@ -47,3 +53,38 @@ pub fn best_match<'a>(

best.map(|(candidate, _)| candidate)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_min_edit_distance() {
assert_eq!(min_edit_distance("", ""), 0);
assert_eq!(min_edit_distance("a", "a"), 0);
assert_eq!(min_edit_distance("abc", "abc"), 0);
assert_eq!(min_edit_distance("", "abc"), 3);
assert_eq!(min_edit_distance("abc", ""), 3);
assert_eq!(min_edit_distance("abc", "def"), 3);
assert_eq!(min_edit_distance("sitting", "kitten"), 3);
}

#[test]
fn test_best_match() {
let candidates = vec!["apple", "banana", "cherry"];

// Exact match returns None
assert_eq!(best_match("apple", candidates.clone(), 2), None);

// Close match within threshold
assert_eq!(best_match("aple", candidates.clone(), 2), Some("apple"));
assert_eq!(best_match("banan", candidates.clone(), 2), Some("banana"));

// No match within threshold
assert_eq!(best_match("xyz", candidates.clone(), 2), None);

// Empty candidates
let empty: Vec<&str> = vec![];
assert_eq!(best_match("test", empty, 2), None);
}
}
2 changes: 2 additions & 0 deletions crates/oxc_span/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
//! <https://doc.rust-lang.org/beta/nightly-rustc/rustc_span>

mod cmp;
mod edit_distance;
mod source_type;
mod span;

pub use cmp::ContentEq;
pub use edit_distance::{best_match, min_edit_distance};
pub use oxc_str::ident;
pub use oxc_str::{
ArenaIdentHashMap, Atom, CompactStr, Ident, IdentHashMap, IdentHashSet,
Expand Down
1 change: 1 addition & 0 deletions tasks/coverage/snapshots/parser_babel.snap
Original file line number Diff line number Diff line change
Expand Up @@ -1082,6 +1082,7 @@ Expect to Parse: tasks/coverage/babel/packages/babel-parser/test/fixtures/typesc
· ───────
2 │ function decrypt() {}
╰────
help: Did you mean 'decrypt'?

× Export 'encrypt' is not defined
╭─[babel/packages/babel-parser/test/fixtures/core/scope/undecl-export-as-default/input.js:1:10]
Expand Down
Loading
Loading