cairo_lint/lang/
mod.rs

1use std::collections::{HashSet, VecDeque};
2use std::sync::Arc;
3
4use cairo_lang_defs::ids::{LanguageElementId, ModuleId};
5use cairo_lang_defs::plugin::PluginDiagnostic;
6use cairo_lang_filesystem::db::{ext_as_virtual, get_parent_and_mapping, translate_location};
7use cairo_lang_filesystem::ids::{CodeOrigin, FileId, FileLongId, SpanInFile, Tracked};
8use cairo_lang_parser::db::ParserGroup;
9use cairo_lang_syntax::node::SyntaxNode;
10use cairo_lang_syntax::node::helpers::QueryAttrs;
11use cairo_lang_syntax::node::kind::SyntaxKind;
12use cairo_lang_utils::ordered_hash_set::OrderedHashSet;
13use if_chain::if_chain;
14
15use crate::context::{
16    get_all_checking_functions, get_name_for_diagnostic_message, is_lint_enabled_by_default,
17};
18use crate::{CairoLintToolMetadata, CorelibContext};
19
20use crate::mappings::{get_origin_module_item_as_syntax_node, get_origin_syntax_node};
21
22mod db;
23use cairo_lang_defs::db::DefsGroup;
24pub use db::{LinterAnalysisDatabase, LinterAnalysisDatabaseBuilder};
25use salsa::Database;
26
27#[derive(PartialEq, Eq, Hash, Debug, Clone)]
28pub struct LinterDiagnosticParams {
29    pub only_generated_files: bool,
30    pub tool_metadata: CairoLintToolMetadata,
31}
32
33pub trait LinterGroup: Database {
34    fn linter_diagnostics<'db>(
35        &'db self,
36        params: LinterDiagnosticParams,
37        module_id: ModuleId<'db>,
38    ) -> &'db Vec<PluginDiagnostic<'db>> {
39        linter_diagnostics(self.as_dyn_database(), params, module_id)
40    }
41
42    fn node_resultants<'db>(&'db self, node: SyntaxNode<'db>) -> Option<&'db Vec<SyntaxNode<'db>>> {
43        node_resultants(self.as_dyn_database(), (), node).as_ref()
44    }
45
46    fn file_and_subfiles_with_corresponding_modules<'db>(
47        &'db self,
48        file: FileId<'db>,
49    ) -> &'db Option<(HashSet<FileId<'db>>, HashSet<ModuleId<'db>>)> {
50        file_and_subfiles_with_corresponding_modules(self.as_dyn_database(), file)
51    }
52
53    fn find_generated_nodes<'db>(
54        &'db self,
55        node_descendant_files: Arc<[FileId<'db>]>,
56        node: SyntaxNode<'db>,
57    ) -> &'db OrderedHashSet<SyntaxNode<'db>> {
58        find_generated_nodes(self.as_dyn_database(), node_descendant_files, node)
59    }
60
61    fn corelib_context<'db>(&'db self) -> &'db CorelibContext<'db> {
62        corelib_context(self.as_dyn_database())
63    }
64}
65
66impl<T: Database + ?Sized> LinterGroup for T {}
67
68#[tracing::instrument(skip_all, level = "trace")]
69#[salsa::tracked(returns(ref))]
70fn linter_diagnostics<'db>(
71    db: &'db dyn Database,
72    params: LinterDiagnosticParams,
73    module_id: ModuleId<'db>,
74) -> Vec<PluginDiagnostic<'db>> {
75    let mut diags: Vec<(PluginDiagnostic, FileId)> = Vec::new();
76    let Ok(module_data) = module_id.module_data(db) else {
77        return Vec::default();
78    };
79    for item in module_data.items(db) {
80        let mut item_diagnostics = Vec::new();
81        let module_file = db.module_main_file(module_id).unwrap();
82        let item_file = item.stable_location(db).file_id(db).long(db);
83        let is_generated_item =
84            matches!(item_file, FileLongId::Virtual(_) | FileLongId::External(_));
85
86        if is_generated_item && !params.only_generated_files {
87            let item_syntax_node = item.stable_location(db).stable_ptr().lookup(db);
88            let origin_node = get_origin_module_item_as_syntax_node(db, item);
89
90            if_chain! {
91                if let Some(node) = origin_node;
92                if let Some(resultants) = db.node_resultants(node);
93                // Check if the item has only a single resultant, as if there is multiple resultants,
94                // we would generate different diagnostics for each of resultants.
95                // If we don't check this, we might generate different diagnostics for the same item,
96                // which is a very unpredictable behavior.
97                if resultants.len() == 1;
98                // We don't do the `==` check here, as the origin node always has the proc macro attributes.
99                // It also means that if the macro changed anything in the original item code,
100                // we won't be processing it, as it might lead to unexpected behavior.
101                if node.get_text_without_trivia(db).long(db).as_str().contains(item_syntax_node.get_text_without_trivia(db).long(db).as_str());
102                then {
103                    let checking_functions = get_all_checking_functions();
104                    for checking_function in checking_functions {
105                        checking_function(db, item, &mut item_diagnostics);
106                    }
107
108                    diags.extend(item_diagnostics.into_iter().filter_map(|mut diag| {
109                      let ptr = diag.stable_ptr;
110                      diag.stable_ptr = get_origin_syntax_node(db, &ptr)?.stable_ptr(db);
111                      Some((diag, module_file))}));
112                }
113            }
114        } else if !is_generated_item || params.only_generated_files {
115            let checking_functions = get_all_checking_functions();
116            for checking_function in checking_functions {
117                checking_function(db, item, &mut item_diagnostics);
118            }
119
120            diags.extend(item_diagnostics.into_iter().filter_map(|diag| {
121                // If the diagnostic is not mapped to an on-disk file, it mean that it's an inline macro diagnostic.
122                get_origin_syntax_node(db, &diag.stable_ptr).map(|_| (diag, module_file))
123            }));
124        }
125    }
126
127    diags
128        .into_iter()
129        .filter(|diag: &(PluginDiagnostic, FileId)| {
130            let diagnostic = &diag.0;
131            let node = diagnostic.stable_ptr.lookup(db);
132            let allowed_name = get_name_for_diagnostic_message(&diagnostic.message).unwrap();
133            let default_allowed = is_lint_enabled_by_default(&diagnostic.message).unwrap();
134            let is_rule_allowed_globally = *params
135                .tool_metadata
136                .get(allowed_name)
137                .unwrap_or(&default_allowed);
138            !node_has_ascendants_with_allow_name_attr(db, node, allowed_name)
139                && is_rule_allowed_globally
140        })
141        .map(|diag| diag.0)
142        .collect()
143}
144
145#[tracing::instrument(level = "trace", skip(db))]
146#[salsa::tracked(returns(ref))]
147fn node_resultants<'db>(
148    db: &'db dyn Database,
149    _: Tracked,
150    node: SyntaxNode<'db>,
151) -> Option<Vec<SyntaxNode<'db>>> {
152    let main_file = node.stable_ptr(db).file_id(db);
153
154    let (files, _) = db
155        .file_and_subfiles_with_corresponding_modules(main_file)
156        .as_ref()?;
157
158    let files: Arc<[FileId]> = files
159        .iter()
160        .filter(|file| **file != main_file)
161        .cloned()
162        .collect();
163    let resultants = db.find_generated_nodes(files, node);
164
165    Some(resultants.into_iter().cloned().collect())
166}
167
168#[tracing::instrument(level = "trace", skip(db))]
169#[salsa::tracked(returns(ref))]
170pub fn file_and_subfiles_with_corresponding_modules<'db>(
171    db: &'db dyn Database,
172    file: FileId<'db>,
173) -> Option<(HashSet<FileId<'db>>, HashSet<ModuleId<'db>>)> {
174    let mut modules: HashSet<_> = db.file_modules(file).ok()?.iter().copied().collect();
175    let mut files = HashSet::from([file]);
176    // Collect descendants of `file`
177    // and modules from all virtual files that are descendants of `file`.
178    //
179    // Caveat: consider a situation `file1` --(child)--> `file2` with file contents:
180    // - `file1`: `mod file2_origin_module { #[file2]fn sth() {} }`
181    // - `file2`: `mod mod_from_file2 { }`
182    //  It is important that `file2` content contains a module.
183    //
184    // Problem: in this situation it is not enough to call `db.file_modules(file1_id)` since
185    //  `mod_from_file2` won't be in the result of this query.
186    // Solution: we can find file id of `file2`
187    //  (note that we only have file id of `file1` at this point)
188    //  in `db.module_files(mod_from_file1_from_which_file2_origins)`.
189    //  Then we can call `db.file_modules(file2_id)` to obtain module id of `mod_from_file2`.
190    //  We repeat this procedure until there is nothing more to collect.
191    let mut modules_queue: VecDeque<_> = modules.iter().copied().collect();
192    while let Some(module_id) = modules_queue.pop_front() {
193        for file_id in db.module_files(module_id).ok()?.iter() {
194            if files.insert(*file_id) {
195                for module_id in db.file_modules(*file_id).ok()?.iter() {
196                    if modules.insert(*module_id) {
197                        modules_queue.push_back(*module_id);
198                    }
199                }
200            }
201        }
202    }
203    Some((files, modules))
204}
205
206#[tracing::instrument(level = "trace", skip(db))]
207#[salsa::tracked(returns(ref))]
208pub fn find_generated_nodes<'db>(
209    db: &'db dyn Database,
210    node_descendant_files: Arc<[FileId<'db>]>,
211    node: SyntaxNode<'db>,
212) -> OrderedHashSet<SyntaxNode<'db>> {
213    let start_file = node.stable_ptr(db).file_id(db);
214
215    let mut result = OrderedHashSet::default();
216
217    let mut is_replaced = false;
218
219    for file in node_descendant_files.iter().cloned() {
220        let Some((
221            SpanInFile {
222                file_id: parent,
223                span: _,
224            },
225            mappings,
226        )) = get_parent_and_mapping(db, file)
227        else {
228            continue;
229        };
230
231        if parent != start_file {
232            continue;
233        }
234
235        let Ok(file_syntax) = db.file_syntax(file) else {
236            continue;
237        };
238
239        let mappings: Vec<_> = mappings
240            .iter()
241            .filter(|mapping| match mapping.origin {
242                CodeOrigin::CallSite(_) => true,
243                CodeOrigin::Start(start) => start == node.span(db).start,
244                CodeOrigin::Span(span) => node.span(db).contains(span),
245            })
246            .cloned()
247            .collect();
248        if mappings.is_empty() {
249            continue;
250        }
251
252        let is_replacing_og_item = match file.long(db) {
253            FileLongId::Virtual(vfs) => vfs.original_item_removed,
254            FileLongId::External(id) => ext_as_virtual(db, *id).original_item_removed,
255            _ => unreachable!(),
256        };
257
258        let mut new_nodes: OrderedHashSet<_> = Default::default();
259
260        for mapping in &mappings {
261            for token in file_syntax.lookup_offset(db, mapping.span.start).tokens(db) {
262                // Skip end of the file terminal, which is also a syntax tree leaf.
263                // As `ModuleItemList` and `TerminalEndOfFile` have the same parent,
264                // which is the `SyntaxFile`, so we don't want to take the `SyntaxFile`
265                // as an additional resultant.
266                if token.kind(db) == SyntaxKind::TerminalEndOfFile {
267                    continue;
268                }
269                let nodes: Vec<_> = token
270                    .ancestors_with_self(db)
271                    .map_while(|new_node| {
272                        translate_location(&mappings, new_node.span(db))
273                            .map(|span_in_parent| (new_node, span_in_parent))
274                    })
275                    .take_while(|(_, span_in_parent)| node.span(db).contains(*span_in_parent))
276                    .collect();
277
278                if let Some((last_node, _)) = nodes.last().cloned() {
279                    let (new_node, _) = nodes
280                        .into_iter()
281                        .rev()
282                        .take_while(|(node, _)| node.span(db) == last_node.span(db))
283                        .last()
284                        .unwrap();
285
286                    new_nodes.insert(new_node);
287                }
288            }
289        }
290
291        // If there is no node found, don't mark it as potentially replaced.
292        if !new_nodes.is_empty() {
293            is_replaced = is_replaced || is_replacing_og_item;
294        }
295
296        for new_node in new_nodes {
297            result.extend(
298                find_generated_nodes(db, Arc::clone(&node_descendant_files), new_node)
299                    .into_iter()
300                    .cloned(),
301            );
302        }
303    }
304
305    if !is_replaced {
306        result.insert(node);
307    }
308
309    result
310}
311
312#[salsa::tracked(returns(ref))]
313fn corelib_context<'db>(db: &'db dyn Database) -> CorelibContext<'db> {
314    CorelibContext::new(db)
315}
316
317#[tracing::instrument(skip_all, level = "trace")]
318fn node_has_ascendants_with_allow_name_attr<'db>(
319    db: &'db dyn Database,
320    node: SyntaxNode<'db>,
321    allowed_name: &'static str,
322) -> bool {
323    for node in node.ancestors_with_self(db) {
324        if node.has_attr_with_arg(db, "allow", allowed_name) {
325            return true;
326        }
327    }
328    false
329}