|
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,87 @@ 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. Only |
| 5063 | +// direct members are considered (not inherited/extension members); that is the |
| 5064 | +// common typo case and keeps this off the hot path of successful lookups. |
| 5065 | +static Name* findClosestInScopeName(Name* name, Scope* scope) |
| 5066 | +{ |
| 5067 | + if (!name) |
| 5068 | + return nullptr; |
| 5069 | + |
| 5070 | + const UnownedStringSlice target = getUnownedStringSliceText(name); |
| 5071 | + if (target.getLength() == 0) |
| 5072 | + return nullptr; |
| 5073 | + |
| 5074 | + // Lower-case the target once for a case-insensitive comparison. |
| 5075 | + StringBuilder targetLowerBuilder; |
| 5076 | + for (auto c : target) |
| 5077 | + targetLowerBuilder.appendChar(CharUtil::toLower(c)); |
| 5078 | + const String targetLower = targetLowerBuilder.produceString(); |
| 5079 | + |
| 5080 | + // Scale the allowed edit distance with the identifier length and cap it, so |
| 5081 | + // that we only offer genuinely-close names (e.g. `lenght` -> `length`, or |
| 5082 | + // `f_a` -> `f_b`) and never a wildly different one (e.g. the keyword `case` |
| 5083 | + // -> the module `core`, a distance-2 edit on a 4-char name). Roughly one edit |
| 5084 | + // per three characters with a floor of one, matching the heuristic used by |
| 5085 | + // other compilers. Names shorter than 3 chars are too short to suggest against. |
| 5086 | + if (target.getLength() < 3) |
| 5087 | + return nullptr; |
| 5088 | + const Index maxDistance = Math::Min<Index>(3, Math::Max<Index>(1, target.getLength() / 3)); |
| 5089 | + |
| 5090 | + Name* best = nullptr; |
| 5091 | + Index bestDistance = maxDistance + 1; |
| 5092 | + |
| 5093 | + for (Scope* s = scope; s; s = s->parent) |
| 5094 | + { |
| 5095 | + for (Scope* sib = s; sib; sib = sib->nextSibling) |
| 5096 | + { |
| 5097 | + auto containerDecl = sib->containerDecl; |
| 5098 | + if (!containerDecl) |
| 5099 | + continue; |
| 5100 | + |
| 5101 | + for (auto candidateDecl : containerDecl->getDirectMemberDecls()) |
| 5102 | + { |
| 5103 | + Name* candidateName = candidateDecl->getName(); |
| 5104 | + if (!candidateName || candidateName == name) |
| 5105 | + continue; |
| 5106 | + |
| 5107 | + // Don't suggest names from the core module. Its global scope holds |
| 5108 | + // thousands of builtins, so almost any identifier finds a spurious |
| 5109 | + // close match there (e.g. `instance` -> `distance`); restricting to |
| 5110 | + // user-written declarations keeps suggestions quiet and relevant. |
| 5111 | + if (isFromCoreModule(candidateDecl)) |
| 5112 | + continue; |
| 5113 | + |
| 5114 | + const UnownedStringSlice candidateText = getUnownedStringSliceText(candidateName); |
| 5115 | + if (candidateText.getLength() == 0) |
| 5116 | + continue; |
| 5117 | + |
| 5118 | + StringBuilder candidateLowerBuilder; |
| 5119 | + for (auto c : candidateText) |
| 5120 | + candidateLowerBuilder.appendChar(CharUtil::toLower(c)); |
| 5121 | + const String candidateLower = candidateLowerBuilder.produceString(); |
| 5122 | + |
| 5123 | + const Index distance = StringUtil::calcLevenshteinDistance( |
| 5124 | + targetLower.getUnownedSlice(), |
| 5125 | + candidateLower.getUnownedSlice()); |
| 5126 | + if (distance < bestDistance) |
| 5127 | + { |
| 5128 | + bestDistance = distance; |
| 5129 | + best = candidateName; |
| 5130 | + } |
| 5131 | + } |
| 5132 | + } |
| 5133 | + } |
| 5134 | + |
| 5135 | + return (bestDistance <= maxDistance) ? best : nullptr; |
| 5136 | +} |
| 5137 | + |
5056 | 5138 | Expr* SemanticsExprVisitor::visitVarExpr(VarExpr* expr) |
5057 | 5139 | { |
5058 | 5140 | // If we've already resolved this expression, don't try again. |
@@ -5097,8 +5179,18 @@ Expr* SemanticsExprVisitor::visitVarExpr(VarExpr* expr) |
5097 | 5179 | } |
5098 | 5180 |
|
5099 | 5181 | if (!diagnosed) |
5100 | | - getSink()->diagnose( |
5101 | | - Diagnostics::UndefinedIdentifier{.name = expr->name, .location = expr->loc}); |
| 5182 | + { |
| 5183 | + // If a similarly-spelled identifier is in scope, attach a "did you mean |
| 5184 | + // 'length'?" note to the error; this turns a bare "undefined identifier |
| 5185 | + // 'lenght'" into an actionable hint. The note only renders when |
| 5186 | + // `suggestionLocation` is valid, so a null suggestion is harmless. |
| 5187 | + auto suggestion = findClosestInScopeName(expr->name, expr->scope); |
| 5188 | + getSink()->diagnose(Diagnostics::UndefinedIdentifier{ |
| 5189 | + .name = expr->name, |
| 5190 | + .suggestion = suggestion, |
| 5191 | + .location = expr->loc, |
| 5192 | + .suggestionLocation = suggestion ? expr->loc : SourceLoc{}}); |
| 5193 | + } |
5102 | 5194 |
|
5103 | 5195 | return resultExpr; |
5104 | 5196 | } |
|
0 commit comments