forge_lint/sol/info/unused_error.rs
1use crate::{
2 linter::{Lint, ProjectLintEmitter, ProjectLintPass, ProjectSource},
3 sol::{Severity, SolLint, info::UnusedError},
4};
5use solar::{
6 ast::ContractKind,
7 interface::{Symbol, data_structures::Never, source_map::FileName},
8 sema::{
9 Gcx,
10 hir::{self, Visit as _},
11 ty::{Ty, TyKind},
12 },
13};
14use std::{
15 collections::{HashMap, HashSet},
16 ops::ControlFlow,
17};
18
19declare_forge_lint!(UNUSED_ERROR, Severity::Info, "unused-error", "custom error is never used");
20
21impl<'ast> ProjectLintPass<'ast> for UnusedError {
22 fn check_project(&mut self, ctx: &ProjectLintEmitter<'_, '_>, sources: &[ProjectSource<'ast>]) {
23 if !ctx.is_lint_enabled(UNUSED_ERROR.id()) {
24 return;
25 }
26
27 let gcx = ctx.gcx();
28 let hir = &gcx.hir;
29
30 // Map every input source's HIR `SourceId` to the corresponding `ProjectSource` index, so
31 // only errors declared in user-provided files are reported. Uses are still collected
32 // across the whole unit, so an error declared here and reverted in a dependency (or the
33 // other way around) is attributed correctly.
34 let input_source_idx: HashMap<hir::SourceId, usize> = hir
35 .sources_enumerated()
36 .filter_map(|(sid, src)| {
37 let path = match &src.file.name {
38 FileName::Real(p) => p,
39 _ => return None,
40 };
41 let idx = sources.iter().position(|s| &s.path == path)?;
42 Some((sid, idx))
43 })
44 .collect();
45
46 if input_source_idx.is_empty() {
47 return;
48 }
49
50 let used = collect_used_errors(gcx);
51
52 // Report input-file error declarations that no expression in the unit references.
53 for error_id in hir.error_ids() {
54 let error = hir.error(error_id);
55 let Some(&src_idx) = input_source_idx.get(&error.source) else { continue };
56 // Errors declared in interfaces and abstract contracts are ABI surface meant for
57 // implementers and off-chain consumers, which may live outside the compiled sources.
58 if let Some(contract_id) = error.contract
59 && matches!(
60 hir.contract(contract_id).kind,
61 ContractKind::Interface | ContractKind::AbstractContract
62 )
63 {
64 continue;
65 }
66 if !used.contains(&error_id) {
67 ctx.emit(&sources[src_idx], &UNUSED_ERROR, error.span);
68 }
69 }
70 }
71}
72
73/// Collects every [`hir::ErrorId`] referenced by an expression anywhere in the unit.
74///
75/// Resolved identifiers cover almost every use: the lowering resolves the full path of
76/// `revert Lib.Err(...)` into a single `Ident`, and `require(cond, Err(...))` or `Err.selector`
77/// reference the error through a resolved `Ident` as well. The one exception is a qualified
78/// member access such as `Lib.Err.selector`: the inner `Err` segment carries no resolution in
79/// the HIR, so it is resolved against the scope its base designates: the items of a contract,
80/// or, for a module alias, Solar's resolved source scope, which binds exactly the names the
81/// file declares and imports (aliases included) rather than the raw items of the transitively
82/// imported files.
83fn collect_used_errors(gcx: Gcx<'_>) -> HashSet<hir::ErrorId> {
84 let hir = &gcx.hir;
85 let mut used = HashSet::new();
86 // Walk every source of the unit: functions, modifiers, and variable initializers.
87 for source_id in hir.source_ids() {
88 let mut collector = UsedErrorCollector { gcx, hir, current_source: source_id, used };
89 let _ = collector.visit_nested_source(source_id);
90 used = collector.used;
91 }
92 used
93}
94
95/// A named scope a qualified member can resolve against.
96enum MemberScope {
97 /// A contract or library: its declared items.
98 Contract(hir::ContractId),
99 /// A module alias: Solar's resolved scope for that source.
100 Module(hir::SourceId),
101}
102
103struct UsedErrorCollector<'gcx> {
104 gcx: Gcx<'gcx>,
105 hir: &'gcx hir::Hir<'gcx>,
106 /// The source being walked: module member lookups are made from its viewpoint.
107 current_source: hir::SourceId,
108 used: HashSet<hir::ErrorId>,
109}
110
111impl<'gcx> hir::Visit<'gcx> for UsedErrorCollector<'gcx> {
112 type BreakValue = Never;
113
114 fn hir(&self) -> &'gcx hir::Hir<'gcx> {
115 self.hir
116 }
117
118 fn visit_expr(&mut self, expr: &'gcx hir::Expr<'gcx>) -> ControlFlow<Self::BreakValue> {
119 match &expr.kind {
120 // Direct resolved reference: `revert Err()`, `revert Lib.Err()` (fully resolved by
121 // the lowering), `require(cond, Err())`, `Err.selector`, ...
122 hir::ExprKind::Ident(resolutions) => {
123 // Symbols can be overloaded: consider every resolution.
124 for res in *resolutions {
125 if let hir::Res::Item(hir::ItemId::Error(error_id)) = res {
126 self.used.insert(*error_id);
127 }
128 }
129 }
130 // `Lib.Err.selector`: the `Err` member carries no resolution, so resolve it against
131 // the scope its base designates.
132 hir::ExprKind::Member(base, member) => {
133 for scope in self.base_scopes(base) {
134 match scope {
135 MemberScope::Contract(contract_id) => {
136 self.mark_named_error(contract_id, member.name);
137 }
138 // In the resolved scope an import alias binds under its local name to
139 // the exact declaration: mark that error, not a same-name lookalike.
140 MemberScope::Module(source_id) => {
141 for member_ty in self.module_members_named(source_id, member.name) {
142 if let TyKind::Error(_, error_id) = member_ty.kind {
143 self.used.insert(error_id);
144 }
145 }
146 }
147 }
148 }
149 }
150 _ => {}
151 }
152 self.walk_expr(expr)
153 }
154}
155
156impl<'gcx> UsedErrorCollector<'gcx> {
157 /// Marks as used the errors of the contract whose name matches `name`.
158 fn mark_named_error(&mut self, contract_id: hir::ContractId, name: Symbol) {
159 // Scopes cannot hold two same-name errors, but scan every item to stay conservative.
160 for item_id in self.hir.contract(contract_id).items {
161 if let hir::ItemId::Error(error_id) = item_id
162 && self.hir.error(*error_id).name.name == name
163 {
164 self.used.insert(*error_id);
165 }
166 }
167 }
168
169 /// Returns the types of the members named `name` in the resolved scope of module
170 /// `source_id`.
171 ///
172 /// `members_of` on a module type iterates Solar's resolved source scope: the file's own
173 /// declarations plus the names its imports actually bind, under their local (alias) names.
174 fn module_members_named(&self, source_id: hir::SourceId, name: Symbol) -> Vec<Ty<'gcx>> {
175 let module_ty = self.gcx.type_of_res(hir::Res::Namespace(source_id));
176 self.gcx
177 .members_of(module_ty, self.current_source, None)
178 .filter(|member| member.name == name)
179 .map(|member| member.ty)
180 .collect()
181 }
182
183 /// Returns the named scopes `expr` can designate: a contract or library through a resolved
184 /// identifier, a module alias, or a member chain leading to one (`NS.Lib`, `NS.Inner`).
185 fn base_scopes(&self, expr: &hir::Expr<'_>) -> Vec<MemberScope> {
186 let mut scopes = Vec::new();
187 match &expr.kind {
188 hir::ExprKind::Ident(resolutions) => {
189 for res in *resolutions {
190 match res {
191 hir::Res::Item(hir::ItemId::Contract(contract_id)) => {
192 scopes.push(MemberScope::Contract(*contract_id));
193 }
194 hir::Res::Namespace(source_id) => {
195 scopes.push(MemberScope::Module(*source_id));
196 }
197 _ => {}
198 }
199 }
200 }
201 // A chained scope access (`NS.Lib`, `NS.Inner`): resolve the name in the scopes of
202 // the base. Contracts do not nest named scopes, so only module bases descend.
203 hir::ExprKind::Member(inner_base, name) => {
204 for scope in self.base_scopes(inner_base) {
205 if let MemberScope::Module(source_id) = scope {
206 for member_ty in self.module_members_named(source_id, name.name) {
207 // A contract or library is a type-namespace item, so its member
208 // type comes wrapped as `Type(Contract(..))`; a nested module
209 // alias comes as a bare `Module(..)`.
210 let member_ty = match member_ty.kind {
211 TyKind::Type(inner) => inner,
212 _ => member_ty,
213 };
214 match member_ty.kind {
215 TyKind::Contract(contract_id) => {
216 scopes.push(MemberScope::Contract(contract_id));
217 }
218 TyKind::Module(inner_source) => {
219 scopes.push(MemberScope::Module(inner_source));
220 }
221 _ => {}
222 }
223 }
224 }
225 }
226 }
227 _ => {}
228 }
229 scopes
230 }
231}