Skip to main content

mir_analyzer/
class.rs

1/// Class analyzer — validates class definitions after codebase finalization.
2///
3/// Checks performed (all codebase-level, no AST required):
4///   - Concrete class implements all abstract parent methods
5///   - Concrete class implements all interface methods
6///   - Overriding method does not reduce visibility
7///   - Overriding method return type is covariant with parent
8///   - Overriding method does not override a final method
9///   - Class does not extend a final class
10use std::collections::{HashMap, HashSet};
11use std::sync::Arc;
12
13use mir_codebase::storage::{MethodStorage, Visibility};
14use mir_codebase::Codebase;
15use mir_issues::{Issue, IssueKind, Location};
16
17// ---------------------------------------------------------------------------
18// ClassAnalyzer
19// ---------------------------------------------------------------------------
20
21pub struct ClassAnalyzer<'a> {
22    codebase: &'a Codebase,
23    /// Only report issues for classes defined in these files (empty = all files).
24    analyzed_files: HashSet<Arc<str>>,
25    /// Source text keyed by file path, used to extract snippets for class-level issues.
26    sources: HashMap<Arc<str>, &'a str>,
27}
28
29impl<'a> ClassAnalyzer<'a> {
30    pub fn new(codebase: &'a Codebase) -> Self {
31        Self {
32            codebase,
33            analyzed_files: HashSet::new(),
34            sources: HashMap::new(),
35        }
36    }
37
38    pub fn with_files(
39        codebase: &'a Codebase,
40        files: HashSet<Arc<str>>,
41        file_data: &'a [(Arc<str>, String)],
42    ) -> Self {
43        let sources: HashMap<Arc<str>, &'a str> = file_data
44            .iter()
45            .map(|(f, s)| (f.clone(), s.as_str()))
46            .collect();
47        Self {
48            codebase,
49            analyzed_files: files,
50            sources,
51        }
52    }
53
54    /// Run all class-level checks and return every discovered issue.
55    pub fn analyze_all(&self) -> Vec<Issue> {
56        let mut issues = Vec::new();
57
58        let class_keys: Vec<Arc<str>> = self
59            .codebase
60            .classes
61            .iter()
62            .map(|e| e.key().clone())
63            .collect();
64
65        for fqcn in &class_keys {
66            let cls = match self.codebase.classes.get(fqcn.as_ref()) {
67                Some(c) => c,
68                None => continue,
69            };
70
71            // Skip classes from vendor / stub files — only check user-analyzed files
72            if !self.analyzed_files.is_empty() {
73                let in_analyzed = cls
74                    .location
75                    .as_ref()
76                    .map(|loc| self.analyzed_files.contains(&loc.file))
77                    .unwrap_or(false);
78                if !in_analyzed {
79                    continue;
80                }
81            }
82
83            // ---- 1. Final-class extension check --------------------------------
84            if let Some(parent_fqcn) = &cls.parent {
85                if let Some(parent) = self.codebase.classes.get(parent_fqcn.as_ref()) {
86                    if parent.is_final {
87                        let loc = issue_location(cls.location.as_ref(), fqcn);
88                        let mut issue = Issue::new(
89                            IssueKind::FinalClassExtended {
90                                parent: parent_fqcn.to_string(),
91                                child: fqcn.to_string(),
92                            },
93                            loc,
94                        );
95                        if let Some(snippet) = extract_snippet(cls.location.as_ref(), &self.sources)
96                        {
97                            issue = issue.with_snippet(snippet);
98                        }
99                        issues.push(issue);
100                    }
101                }
102            }
103
104            // Skip abstract classes for "must implement" checks
105            if cls.is_abstract {
106                // Still check override compatibility for abstract classes
107                self.check_overrides(&cls, &mut issues);
108                continue;
109            }
110
111            // ---- 2. Abstract parent methods must be implemented ----------------
112            self.check_abstract_methods_implemented(&cls, &mut issues);
113
114            // ---- 3. Interface methods must be implemented ----------------------
115            self.check_interface_methods_implemented(&cls, &mut issues);
116
117            // ---- 4. Method override compatibility ------------------------------
118            self.check_overrides(&cls, &mut issues);
119        }
120
121        // ---- 5. Circular inheritance detection --------------------------------
122        self.check_circular_class_inheritance(&mut issues);
123        self.check_circular_interface_inheritance(&mut issues);
124
125        issues
126    }
127
128    // -----------------------------------------------------------------------
129    // Check: all abstract methods from ancestor chain are implemented
130    // -----------------------------------------------------------------------
131
132    fn check_abstract_methods_implemented(
133        &self,
134        cls: &mir_codebase::storage::ClassStorage,
135        issues: &mut Vec<Issue>,
136    ) {
137        let fqcn = &cls.fqcn;
138
139        // Walk every ancestor class and collect abstract methods
140        for ancestor_fqcn in &cls.all_parents {
141            let ancestor = match self.codebase.classes.get(ancestor_fqcn.as_ref()) {
142                Some(a) => a,
143                None => continue,
144            };
145
146            for (method_name, method) in &ancestor.own_methods {
147                if !method.is_abstract {
148                    continue;
149                }
150
151                // Check if the concrete class (or any closer ancestor) provides it
152                if cls
153                    .get_method(method_name.as_ref())
154                    .map(|m| !m.is_abstract)
155                    .unwrap_or(false)
156                {
157                    continue; // implemented
158                }
159
160                let loc = issue_location(cls.location.as_ref(), fqcn);
161                let mut issue = Issue::new(
162                    IssueKind::UnimplementedAbstractMethod {
163                        class: fqcn.to_string(),
164                        method: method_name.to_string(),
165                    },
166                    loc,
167                );
168                if let Some(snippet) = extract_snippet(cls.location.as_ref(), &self.sources) {
169                    issue = issue.with_snippet(snippet);
170                }
171                issues.push(issue);
172            }
173        }
174    }
175
176    // -----------------------------------------------------------------------
177    // Check: all interface methods are implemented
178    // -----------------------------------------------------------------------
179
180    fn check_interface_methods_implemented(
181        &self,
182        cls: &mir_codebase::storage::ClassStorage,
183        issues: &mut Vec<Issue>,
184    ) {
185        let fqcn = &cls.fqcn;
186
187        // Collect all interfaces (direct + from ancestors)
188        let all_ifaces: Vec<Arc<str>> = cls
189            .all_parents
190            .iter()
191            .filter(|p| self.codebase.interfaces.contains_key(p.as_ref()))
192            .cloned()
193            .collect();
194
195        for iface_fqcn in &all_ifaces {
196            let iface = match self.codebase.interfaces.get(iface_fqcn.as_ref()) {
197                Some(i) => i,
198                None => continue,
199            };
200
201            for (method_name, _method) in &iface.own_methods {
202                // Check if the class provides a concrete implementation
203                let implemented = cls
204                    .get_method(method_name.as_ref())
205                    .map(|m| !m.is_abstract)
206                    .unwrap_or(false);
207
208                if !implemented {
209                    let loc = issue_location(cls.location.as_ref(), fqcn);
210                    let mut issue = Issue::new(
211                        IssueKind::UnimplementedInterfaceMethod {
212                            class: fqcn.to_string(),
213                            interface: iface_fqcn.to_string(),
214                            method: method_name.to_string(),
215                        },
216                        loc,
217                    );
218                    if let Some(snippet) = extract_snippet(cls.location.as_ref(), &self.sources) {
219                        issue = issue.with_snippet(snippet);
220                    }
221                    issues.push(issue);
222                }
223            }
224        }
225    }
226
227    // -----------------------------------------------------------------------
228    // Check: override compatibility
229    // -----------------------------------------------------------------------
230
231    fn check_overrides(&self, cls: &mir_codebase::storage::ClassStorage, issues: &mut Vec<Issue>) {
232        let fqcn = &cls.fqcn;
233
234        for (method_name, own_method) in &cls.own_methods {
235            // PHP does not enforce constructor signature compatibility
236            if method_name.as_ref() == "__construct" {
237                continue;
238            }
239
240            // Find parent definition (if any) — search ancestor chain
241            let parent_method = self.find_parent_method(cls, method_name.as_ref());
242
243            let parent = match parent_method {
244                Some(m) => m,
245                None => continue, // not an override
246            };
247
248            let loc = issue_location(own_method.location.as_ref(), fqcn);
249
250            // ---- a. Cannot override a final method -------------------------
251            if parent.is_final {
252                let mut issue = Issue::new(
253                    IssueKind::FinalMethodOverridden {
254                        class: fqcn.to_string(),
255                        method: method_name.to_string(),
256                        parent: parent.fqcn.to_string(),
257                    },
258                    loc.clone(),
259                );
260                if let Some(snippet) = extract_snippet(own_method.location.as_ref(), &self.sources)
261                {
262                    issue = issue.with_snippet(snippet);
263                }
264                issues.push(issue);
265            }
266
267            // ---- b. Visibility must not be reduced -------------------------
268            if visibility_reduced(own_method.visibility, parent.visibility) {
269                let mut issue = Issue::new(
270                    IssueKind::OverriddenMethodAccess {
271                        class: fqcn.to_string(),
272                        method: method_name.to_string(),
273                    },
274                    loc.clone(),
275                );
276                if let Some(snippet) = extract_snippet(own_method.location.as_ref(), &self.sources)
277                {
278                    issue = issue.with_snippet(snippet);
279                }
280                issues.push(issue);
281            }
282
283            // ---- c. Return type must be covariant --------------------------
284            // Only check when both sides have an explicit return type.
285            // Skip when:
286            //   - Parent type is from a docblock (PHP doesn't enforce docblock override compat)
287            //   - Either type contains a named object (needs codebase for inheritance check)
288            //   - Either type contains TSelf/TStaticObject (always compatible with self)
289            if let (Some(child_ret), Some(parent_ret)) =
290                (&own_method.return_type, &parent.return_type)
291            {
292                let parent_from_docblock = parent_ret.from_docblock;
293                let involves_named_objects = self.type_has_named_objects(child_ret)
294                    || self.type_has_named_objects(parent_ret);
295                let involves_self_static = self.type_has_self_or_static(child_ret)
296                    || self.type_has_self_or_static(parent_ret);
297
298                if !parent_from_docblock
299                    && !involves_named_objects
300                    && !involves_self_static
301                    && !child_ret.is_subtype_of_simple(parent_ret)
302                    && !parent_ret.is_mixed()
303                    && !child_ret.is_mixed()
304                    && !self.return_type_has_template(parent_ret)
305                {
306                    issues.push(
307                        Issue::new(
308                            IssueKind::MethodSignatureMismatch {
309                                class: fqcn.to_string(),
310                                method: method_name.to_string(),
311                                detail: format!(
312                                    "return type '{}' is not a subtype of parent '{}'",
313                                    child_ret, parent_ret
314                                ),
315                            },
316                            loc.clone(),
317                        )
318                        .with_snippet(method_name.to_string()),
319                    );
320                }
321            }
322
323            // ---- d. Required param count must not increase -----------------
324            let parent_required = parent
325                .params
326                .iter()
327                .filter(|p| !p.is_optional && !p.is_variadic)
328                .count();
329            let child_required = own_method
330                .params
331                .iter()
332                .filter(|p| !p.is_optional && !p.is_variadic)
333                .count();
334
335            if child_required > parent_required {
336                issues.push(
337                    Issue::new(
338                        IssueKind::MethodSignatureMismatch {
339                            class: fqcn.to_string(),
340                            method: method_name.to_string(),
341                            detail: format!(
342                                "overriding method requires {} argument(s) but parent requires {}",
343                                child_required, parent_required
344                            ),
345                        },
346                        loc.clone(),
347                    )
348                    .with_snippet(method_name.to_string()),
349                );
350            }
351
352            // ---- e. Param types must not be narrowed (contravariance) --------
353            // For each positional param present in both parent and child:
354            //   parent_param_type must be a subtype of child_param_type.
355            //   (Child may widen; it must not narrow.)
356            // Skip when:
357            //   - Either side has no type hint
358            //   - Either type is mixed
359            //   - Either type contains a named object (needs codebase for inheritance check)
360            //   - Either type contains TSelf/TStaticObject
361            //   - Either type contains a template param
362            let shared_len = parent.params.len().min(own_method.params.len());
363            for i in 0..shared_len {
364                let parent_param = &parent.params[i];
365                let child_param = &own_method.params[i];
366
367                let (parent_ty, child_ty) = match (&parent_param.ty, &child_param.ty) {
368                    (Some(p), Some(c)) => (p, c),
369                    _ => continue,
370                };
371
372                if parent_ty.is_mixed()
373                    || child_ty.is_mixed()
374                    || self.type_has_named_objects(parent_ty)
375                    || self.type_has_named_objects(child_ty)
376                    || self.type_has_self_or_static(parent_ty)
377                    || self.type_has_self_or_static(child_ty)
378                    || self.return_type_has_template(parent_ty)
379                    || self.return_type_has_template(child_ty)
380                {
381                    continue;
382                }
383
384                // Contravariance: parent_ty must be subtype of child_ty.
385                // If not, child has narrowed the param type.
386                if !parent_ty.is_subtype_of_simple(child_ty) {
387                    issues.push(
388                        Issue::new(
389                            IssueKind::MethodSignatureMismatch {
390                                class: fqcn.to_string(),
391                                method: method_name.to_string(),
392                                detail: format!(
393                                    "parameter ${} type '{}' is narrower than parent type '{}'",
394                                    child_param.name, child_ty, parent_ty
395                                ),
396                            },
397                            loc.clone(),
398                        )
399                        .with_snippet(method_name.to_string()),
400                    );
401                    break; // one issue per method is enough
402                }
403            }
404        }
405    }
406
407    // -----------------------------------------------------------------------
408    // Helpers
409    // -----------------------------------------------------------------------
410
411    /// Returns true if the type contains template params or class-strings with unknown types.
412    /// Used to suppress MethodSignatureMismatch on generic parent return types.
413    /// Checks recursively into array key/value types.
414    fn return_type_has_template(&self, ty: &mir_types::Union) -> bool {
415        use mir_types::Atomic;
416        ty.types.iter().any(|atomic| match atomic {
417            Atomic::TTemplateParam { .. } => true,
418            Atomic::TClassString(Some(inner)) => !self.codebase.type_exists(inner.as_ref()),
419            Atomic::TNamedObject { fqcn, type_params } => {
420                // Bare name with no namespace separator is likely a template param
421                (!fqcn.contains('\\') && !self.codebase.type_exists(fqcn.as_ref()))
422                    // Also check if any type params are templates
423                    || type_params.iter().any(|tp| self.return_type_has_template(tp))
424            }
425            Atomic::TArray { key, value } | Atomic::TNonEmptyArray { key, value } => {
426                self.return_type_has_template(key) || self.return_type_has_template(value)
427            }
428            Atomic::TList { value } | Atomic::TNonEmptyList { value } => {
429                self.return_type_has_template(value)
430            }
431            _ => false,
432        })
433    }
434
435    /// Returns true if the type contains any named-object atomics (TNamedObject)
436    /// at any level (including inside array key/value types).
437    /// Named-object subtyping requires codebase inheritance lookup, so we skip
438    /// the simple structural check for these.
439    fn type_has_named_objects(&self, ty: &mir_types::Union) -> bool {
440        use mir_types::Atomic;
441        ty.types.iter().any(|a| match a {
442            Atomic::TNamedObject { .. } => true,
443            Atomic::TArray { key, value } | Atomic::TNonEmptyArray { key, value } => {
444                self.type_has_named_objects(key) || self.type_has_named_objects(value)
445            }
446            Atomic::TList { value } | Atomic::TNonEmptyList { value } => {
447                self.type_has_named_objects(value)
448            }
449            _ => false,
450        })
451    }
452
453    /// Returns true if the type contains TSelf or TStaticObject (late-static types).
454    /// These are always considered compatible with their bound class type.
455    fn type_has_self_or_static(&self, ty: &mir_types::Union) -> bool {
456        use mir_types::Atomic;
457        ty.types
458            .iter()
459            .any(|a| matches!(a, Atomic::TSelf { .. } | Atomic::TStaticObject { .. }))
460    }
461
462    /// Find a method with the given name in the closest ancestor (not the class itself).
463    fn find_parent_method(
464        &self,
465        cls: &mir_codebase::storage::ClassStorage,
466        method_name: &str,
467    ) -> Option<MethodStorage> {
468        // Walk all_parents in order (closest ancestor first)
469        for ancestor_fqcn in &cls.all_parents {
470            if let Some(ancestor_cls) = self.codebase.classes.get(ancestor_fqcn.as_ref()) {
471                if let Some(m) = ancestor_cls.own_methods.get(method_name) {
472                    return Some(m.clone());
473                }
474            } else if let Some(iface) = self.codebase.interfaces.get(ancestor_fqcn.as_ref()) {
475                if let Some(m) = iface.own_methods.get(method_name) {
476                    return Some(m.clone());
477                }
478            }
479        }
480        None
481    }
482
483    // -----------------------------------------------------------------------
484    // Check: circular class inheritance (class A extends B extends A)
485    // -----------------------------------------------------------------------
486
487    fn check_circular_class_inheritance(&self, issues: &mut Vec<Issue>) {
488        let mut globally_done: HashSet<String> = HashSet::new();
489
490        let mut class_keys: Vec<Arc<str>> = self
491            .codebase
492            .classes
493            .iter()
494            .map(|e| e.key().clone())
495            .collect();
496        class_keys.sort();
497
498        for start_fqcn in &class_keys {
499            if globally_done.contains(start_fqcn.as_ref()) {
500                continue;
501            }
502
503            // Walk the parent chain, tracking order for cycle reporting.
504            let mut chain: Vec<Arc<str>> = Vec::new();
505            let mut chain_set: HashSet<String> = HashSet::new();
506            let mut current: Arc<str> = start_fqcn.clone();
507
508            loop {
509                if globally_done.contains(current.as_ref()) {
510                    // Known safe — stop here.
511                    for node in &chain {
512                        globally_done.insert(node.to_string());
513                    }
514                    break;
515                }
516                if !chain_set.insert(current.to_string()) {
517                    // current is already in chain → cycle detected.
518                    let cycle_start = chain
519                        .iter()
520                        .position(|p| p.as_ref() == current.as_ref())
521                        .unwrap_or(0);
522                    let cycle_nodes = &chain[cycle_start..];
523
524                    // Report on the lexicographically last class in the cycle
525                    // that belongs to an analyzed file (or any if filter is empty).
526                    let offender = cycle_nodes
527                        .iter()
528                        .filter(|n| self.class_in_analyzed_files(n))
529                        .max_by(|a, b| a.as_ref().cmp(b.as_ref()));
530
531                    if let Some(offender) = offender {
532                        let cls = self.codebase.classes.get(offender.as_ref());
533                        let loc = issue_location(
534                            cls.as_ref().and_then(|c| c.location.as_ref()),
535                            offender,
536                        );
537                        let mut issue = Issue::new(
538                            IssueKind::CircularInheritance {
539                                class: offender.to_string(),
540                            },
541                            loc,
542                        );
543                        if let Some(snippet) = extract_snippet(
544                            cls.as_ref().and_then(|c| c.location.as_ref()),
545                            &self.sources,
546                        ) {
547                            issue = issue.with_snippet(snippet);
548                        }
549                        issues.push(issue);
550                    }
551
552                    for node in &chain {
553                        globally_done.insert(node.to_string());
554                    }
555                    break;
556                }
557
558                chain.push(current.clone());
559
560                let parent = self
561                    .codebase
562                    .classes
563                    .get(current.as_ref())
564                    .and_then(|c| c.parent.clone());
565
566                match parent {
567                    Some(p) => current = p,
568                    None => {
569                        for node in &chain {
570                            globally_done.insert(node.to_string());
571                        }
572                        break;
573                    }
574                }
575            }
576        }
577    }
578
579    // -----------------------------------------------------------------------
580    // Check: circular interface inheritance (interface I1 extends I2 extends I1)
581    // -----------------------------------------------------------------------
582
583    fn check_circular_interface_inheritance(&self, issues: &mut Vec<Issue>) {
584        let mut globally_done: HashSet<String> = HashSet::new();
585
586        let mut iface_keys: Vec<Arc<str>> = self
587            .codebase
588            .interfaces
589            .iter()
590            .map(|e| e.key().clone())
591            .collect();
592        iface_keys.sort();
593
594        for start_fqcn in &iface_keys {
595            if globally_done.contains(start_fqcn.as_ref()) {
596                continue;
597            }
598            let mut in_stack: Vec<Arc<str>> = Vec::new();
599            let mut stack_set: HashSet<String> = HashSet::new();
600            self.dfs_interface_cycle(
601                start_fqcn.clone(),
602                &mut in_stack,
603                &mut stack_set,
604                &mut globally_done,
605                issues,
606            );
607        }
608    }
609
610    fn dfs_interface_cycle(
611        &self,
612        fqcn: Arc<str>,
613        in_stack: &mut Vec<Arc<str>>,
614        stack_set: &mut HashSet<String>,
615        globally_done: &mut HashSet<String>,
616        issues: &mut Vec<Issue>,
617    ) {
618        if globally_done.contains(fqcn.as_ref()) {
619            return;
620        }
621        if stack_set.contains(fqcn.as_ref()) {
622            // Cycle: find cycle nodes from in_stack.
623            let cycle_start = in_stack
624                .iter()
625                .position(|p| p.as_ref() == fqcn.as_ref())
626                .unwrap_or(0);
627            let cycle_nodes = &in_stack[cycle_start..];
628
629            let offender = cycle_nodes
630                .iter()
631                .filter(|n| self.iface_in_analyzed_files(n))
632                .max_by(|a, b| a.as_ref().cmp(b.as_ref()));
633
634            if let Some(offender) = offender {
635                let iface = self.codebase.interfaces.get(offender.as_ref());
636                let loc =
637                    issue_location(iface.as_ref().and_then(|i| i.location.as_ref()), offender);
638                let mut issue = Issue::new(
639                    IssueKind::CircularInheritance {
640                        class: offender.to_string(),
641                    },
642                    loc,
643                );
644                if let Some(snippet) = extract_snippet(
645                    iface.as_ref().and_then(|i| i.location.as_ref()),
646                    &self.sources,
647                ) {
648                    issue = issue.with_snippet(snippet);
649                }
650                issues.push(issue);
651            }
652            return;
653        }
654
655        stack_set.insert(fqcn.to_string());
656        in_stack.push(fqcn.clone());
657
658        let extends = self
659            .codebase
660            .interfaces
661            .get(fqcn.as_ref())
662            .map(|i| i.extends.clone())
663            .unwrap_or_default();
664
665        for parent in extends {
666            self.dfs_interface_cycle(parent, in_stack, stack_set, globally_done, issues);
667        }
668
669        in_stack.pop();
670        stack_set.remove(fqcn.as_ref());
671        globally_done.insert(fqcn.to_string());
672    }
673
674    fn class_in_analyzed_files(&self, fqcn: &Arc<str>) -> bool {
675        if self.analyzed_files.is_empty() {
676            return true;
677        }
678        self.codebase
679            .classes
680            .get(fqcn.as_ref())
681            .map(|c| {
682                c.location
683                    .as_ref()
684                    .map(|loc| self.analyzed_files.contains(&loc.file))
685                    .unwrap_or(false)
686            })
687            .unwrap_or(false)
688    }
689
690    fn iface_in_analyzed_files(&self, fqcn: &Arc<str>) -> bool {
691        if self.analyzed_files.is_empty() {
692            return true;
693        }
694        self.codebase
695            .interfaces
696            .get(fqcn.as_ref())
697            .map(|i| {
698                i.location
699                    .as_ref()
700                    .map(|loc| self.analyzed_files.contains(&loc.file))
701                    .unwrap_or(false)
702            })
703            .unwrap_or(false)
704    }
705}
706
707/// Returns true if `child_vis` is strictly less visible than `parent_vis`.
708fn visibility_reduced(child_vis: Visibility, parent_vis: Visibility) -> bool {
709    // Public > Protected > Private (in terms of access)
710    // Reducing means going from more visible to less visible.
711    matches!(
712        (parent_vis, child_vis),
713        (Visibility::Public, Visibility::Protected)
714            | (Visibility::Public, Visibility::Private)
715            | (Visibility::Protected, Visibility::Private)
716    )
717}
718
719/// Build an issue location from the stored codebase Location (which now carries line/col).
720/// Falls back to a dummy location using the FQCN as the file path when no Location is stored.
721fn issue_location(
722    storage_loc: Option<&mir_codebase::storage::Location>,
723    fqcn: &Arc<str>,
724) -> Location {
725    match storage_loc {
726        Some(loc) => Location {
727            file: loc.file.clone(),
728            line: loc.line,
729            col_start: loc.col,
730            col_end: loc.col,
731        },
732        None => Location {
733            file: fqcn.clone(),
734            line: 1,
735            col_start: 0,
736            col_end: 0,
737        },
738    }
739}
740
741/// Extract the first line of source text covered by `storage_loc` as a snippet.
742fn extract_snippet(
743    storage_loc: Option<&mir_codebase::storage::Location>,
744    sources: &HashMap<Arc<str>, &str>,
745) -> Option<String> {
746    let loc = storage_loc?;
747    let src = *sources.get(&loc.file)?;
748    let start = loc.start as usize;
749    let end = loc.end as usize;
750    if start >= src.len() {
751        return None;
752    }
753    let end = end.min(src.len());
754    let span_text = &src[start..end];
755    // Take only the first line to keep the snippet concise.
756    let first_line = span_text.lines().next().unwrap_or(span_text);
757    Some(first_line.trim().to_string())
758}