|
12 | 12 | // * `slang-check-conversion.cpp` is responsible for the logic of handling type conversion/coercion |
13 | 13 |
|
14 | 14 | #include "../core/slang-math.h" |
| 15 | +#include "../core/slang-string-util.h" |
15 | 16 | #include "core/slang-char-util.h" |
16 | 17 | #include "slang-ast-decl.h" |
17 | 18 | #include "slang-ast-natural-layout.h" |
@@ -5053,6 +5054,110 @@ Expr* SemanticsExprVisitor::visitInvokeExpr(InvokeExpr* expr) |
5053 | 5054 | return checkedExpr; |
5054 | 5055 | } |
5055 | 5056 |
|
| 5057 | +// Find the in-scope identifier whose spelling is closest to `name`, to power a |
| 5058 | +// "did you mean ...?" suggestion when `name` failed to resolve. Walks the scope |
| 5059 | +// chain (and each scope's sibling chain) collecting the names of direct members, |
| 5060 | +// computes the case-insensitive Levenshtein distance to each, and returns the |
| 5061 | +// single closest candidate within a small distance threshold. Returns nullptr if |
| 5062 | +// nothing is close enough (so the caller can simply omit the suggestion), or if |
| 5063 | +// two candidates tie at the closest distance (so the output never depends on |
| 5064 | +// import/scope-walk order). |
| 5065 | +// |
| 5066 | +// Only direct members of the lexical scope chain are considered; inherited and |
| 5067 | +// extension members reached via the `this`-parameter breadcrumb in real lookup |
| 5068 | +// are deliberately not searched, to keep this off the hot path of successful |
| 5069 | +// lookups. `semantics` is used to skip candidates the user could not access. |
| 5070 | +static Name* findClosestInScopeName(SemanticsVisitor* semantics, Name* name, Scope* scope) |
| 5071 | +{ |
| 5072 | + if (!name) |
| 5073 | + return nullptr; |
| 5074 | + |
| 5075 | + const UnownedStringSlice target = getUnownedStringSliceText(name); |
| 5076 | + |
| 5077 | + // Scale the allowed edit distance with the identifier length and cap it, so |
| 5078 | + // that we only offer genuinely-close names (e.g. `lenght` -> `length`, or |
| 5079 | + // `f_a` -> `f_b`) and never a wildly different one (e.g. the keyword `case` |
| 5080 | + // -> the module `core`, a distance-2 edit on a 4-char name). Roughly one edit |
| 5081 | + // per three characters with a floor of one, matching the heuristic used by |
| 5082 | + // other compilers. Names shorter than 3 chars are too short to suggest against. |
| 5083 | + if (target.getLength() < 3) |
| 5084 | + return nullptr; |
| 5085 | + const Index maxDistance = Math::Min<Index>(3, Math::Max<Index>(1, target.getLength() / 3)); |
| 5086 | + |
| 5087 | + Index bestDistance = maxDistance + 1; |
| 5088 | + // The *distinct* candidate names sharing the current best distance. Names are |
| 5089 | + // deduped (the "nub" of the candidate list) so that, e.g., two overloads |
| 5090 | + // both called `length` count once: they would print the identical |
| 5091 | + // suggestion, so they are not a genuine ambiguity. A suggestion is offered |
| 5092 | + // only when this set ends up with exactly one name. |
| 5093 | + HashSet<Name*> bestNames; |
| 5094 | + |
| 5095 | + for (Scope* s = scope; s; s = s->parent) |
| 5096 | + { |
| 5097 | + for (Scope* sib = s; sib; sib = sib->nextSibling) |
| 5098 | + { |
| 5099 | + auto containerDecl = sib->containerDecl; |
| 5100 | + if (!containerDecl) |
| 5101 | + continue; |
| 5102 | + |
| 5103 | + for (auto candidateDecl : containerDecl->getDirectMemberDecls()) |
| 5104 | + { |
| 5105 | + Name* candidateName = candidateDecl->getName(); |
| 5106 | + if (!candidateName || candidateName == name) |
| 5107 | + continue; |
| 5108 | + |
| 5109 | + // Don't suggest names from the core module. Its global scope holds |
| 5110 | + // thousands of builtins, so almost any identifier finds a spurious |
| 5111 | + // close match there (e.g. `instance` -> `distance`); restricting to |
| 5112 | + // user-written declarations keeps suggestions quiet and relevant. |
| 5113 | + if (isFromCoreModule(candidateDecl)) |
| 5114 | + continue; |
| 5115 | + |
| 5116 | + const UnownedStringSlice candidateText = getUnownedStringSliceText(candidateName); |
| 5117 | + if (candidateText.getLength() == 0) |
| 5118 | + continue; |
| 5119 | + |
| 5120 | + // Cheap length pre-filter: |lenA - lenB| is a lower bound on the |
| 5121 | + // edit distance, so skip candidates that cannot possibly be |
| 5122 | + // within `maxDistance` before paying for the O(lenA*lenB) DP (and |
| 5123 | + // before forcing any lazy member materialization downstream). |
| 5124 | + if (Math::Abs(candidateText.getLength() - target.getLength()) > maxDistance) |
| 5125 | + continue; |
| 5126 | + |
| 5127 | + // Don't suggest a declaration the user could not have referenced: |
| 5128 | + // real lookup already filtered inaccessible (`private`/`internal`) |
| 5129 | + // candidates, so offering one as a "did you mean" would name a |
| 5130 | + // forbidden symbol (and leak imported module contents via typos). |
| 5131 | + if (!semantics->isDeclVisibleFromScope(makeDeclRef(candidateDecl), scope)) |
| 5132 | + continue; |
| 5133 | + |
| 5134 | + const Index distance = |
| 5135 | + StringUtil::calcLevenshteinDistanceCaseInsensitive(target, candidateText); |
| 5136 | + if (distance < bestDistance) |
| 5137 | + { |
| 5138 | + bestDistance = distance; |
| 5139 | + bestNames.clear(); |
| 5140 | + bestNames.add(candidateName); |
| 5141 | + } |
| 5142 | + else if (distance == bestDistance) |
| 5143 | + { |
| 5144 | + bestNames.add(candidateName); |
| 5145 | + } |
| 5146 | + } |
| 5147 | + } |
| 5148 | + } |
| 5149 | + |
| 5150 | + // Offer a suggestion only when there is a single, unambiguous closest name. |
| 5151 | + // Multiple distinct names at the best distance would make the chosen one |
| 5152 | + // depend on import/scope-walk order, so suppress the suggestion instead. |
| 5153 | + if (bestDistance > maxDistance || bestNames.getCount() != 1) |
| 5154 | + return nullptr; |
| 5155 | + Name* best = nullptr; |
| 5156 | + for (auto n : bestNames) |
| 5157 | + best = n; |
| 5158 | + return best; |
| 5159 | +} |
| 5160 | + |
5056 | 5161 | Expr* SemanticsExprVisitor::visitVarExpr(VarExpr* expr) |
5057 | 5162 | { |
5058 | 5163 | // If we've already resolved this expression, don't try again. |
@@ -5097,8 +5202,18 @@ Expr* SemanticsExprVisitor::visitVarExpr(VarExpr* expr) |
5097 | 5202 | } |
5098 | 5203 |
|
5099 | 5204 | if (!diagnosed) |
5100 | | - getSink()->diagnose( |
5101 | | - Diagnostics::UndefinedIdentifier{.name = expr->name, .location = expr->loc}); |
| 5205 | + { |
| 5206 | + // If a similarly-spelled identifier is in scope, attach a "did you mean |
| 5207 | + // 'length'?" note to the error; this turns a bare "undefined identifier |
| 5208 | + // 'lenght'" into an actionable hint. The note only renders when |
| 5209 | + // `suggestionLocation` is valid, so a null suggestion is harmless. |
| 5210 | + auto suggestion = findClosestInScopeName(this, expr->name, expr->scope); |
| 5211 | + getSink()->diagnose(Diagnostics::UndefinedIdentifier{ |
| 5212 | + .name = expr->name, |
| 5213 | + .suggestion = suggestion, |
| 5214 | + .location = expr->loc, |
| 5215 | + .suggestionLocation = suggestion ? expr->loc : SourceLoc{}}); |
| 5216 | + } |
5102 | 5217 |
|
5103 | 5218 | return resultExpr; |
5104 | 5219 | } |
|
0 commit comments