Skip to main content

brokk_bifrost_ruby/
diagnostics.rs

1//! Ruby's semantic diagnostics: proof-gated unresolved-constant reporting.
2//!
3//! Every candidate this pass reaches leaves one typed outcome in the
4//! [`SemanticDiagnosticReport`]: a resolution, a complete absence proof, or a
5//! typed reason why absence could not be proven. A candidate is never dropped
6//! in silence, and an error is only ever published behind a
7//! [`SemanticAbsenceProof`] over a surface that was complete.
8//!
9//! Unlike Go's, Python's and PHP's, Ruby's pass routes through the *graph*
10//! semantic index rather than a `BoundedDefinitionLookup`, so it follows
11//! `graph::resolver` across the crate line rather than being independently
12//! movable. `analyzer/ruby/diagnostics.rs` in `brokk-bifrost-analysis` keeps the
13//! downcast that produces the arguments and implements [`RubyGemSurface`], which
14//! is what this crate cannot name: the activated semantic-model overlay and the
15//! retained gem-discovery evidence.
16//!
17//! # What this pass judges
18//!
19//! Exactly one candidate shape: the terminal of an explicit constant path
20//! (`Widget::Config`). Each terminal is checked against two surfaces, the
21//! visible workspace closure and the activated gem packs, and reported absent
22//! only when the surface that owns it was complete.
23//!
24//! # What this pass does not judge, and why
25//!
26//! A **bare constant** (`Widget`, `String`) is not a candidate. Ruby's top-level
27//! constant surface includes the core library, everything `Object` inherits, and
28//! whatever every loaded gem defined as a side effect. Bifrost publishes no core
29//! Ruby surface, so a miss against the surfaces this pass can see would prove
30//! nothing about a name that Ruby itself supplies.
31//!
32//! A **method** is not a candidate either, and this is not a temporary gap. Gem
33//! packs publish a gem's own declarations and nothing above them: there is no
34//! published `Object`, `Module`, `Class`, `Kernel` or `BasicObject`, and
35//! `SemanticModelOverlay::universal_root_for_language` supplies an implicit root
36//! only for Java and Scala. So even a gem whose surface is fully RBS-complete is
37//! missing `new`, `name`, `send`, `freeze` and every other inherited member, and
38//! a member miss against it would be a false positive on the very first line of
39//! ordinary code (`Widget.new`). `method_missing`, `define_method`,
40//! `class << self` and `extend` widen the same surface further at run time.
41//! Proving a Ruby member absent needs a published core ancestry that does not
42//! exist yet, so this pass never asks the question. See #1624.
43
44use crate::declarations::{extract_name_path, parse_ruby_tree};
45use crate::graph::RubyGraphSource;
46use crate::graph::extractor::ruby_type_owner;
47use crate::graph::resolver::RubySemanticIndex;
48use crate::graph::syntax::is_declaration_constant;
49use crate::graph_support::RubySource;
50use crate::imports::{parse_ruby_require_call, ruby_symbol_name, ruby_zeitwerk_visible_files_for};
51use crate::syntax::single_static_string_content_node;
52use brokk_bifrost_core::analyzer::model::{
53    Range, SemanticAbsenceProof, SemanticDiagnostic, SemanticDiagnosticDomain,
54    SemanticDiagnosticIncompleteReason, SemanticDiagnosticReport,
55};
56use brokk_bifrost_core::analyzer::semantic_diagnostics::{node_range, node_text};
57use brokk_bifrost_core::analyzer::structural::resolution::BoundaryStatus;
58use brokk_bifrost_core::analyzer::tree_walk::collect_parse_errors;
59use brokk_bifrost_core::analyzer::{CodeUnit, ProjectFile};
60use brokk_bifrost_core::hash::HashSet;
61use brokk_bifrost_core::text_utils::compute_line_starts;
62use std::borrow::Cow;
63use tree_sitter::Node;
64
65pub const RUBY_UNRECOGNIZED_SYMBOL: &str = "ruby_unrecognized_symbol";
66pub const RUBY_SEMANTIC_DIAGNOSTIC_SOURCE: &str = "bifrost-ruby";
67const MAX_RUBY_SEMANTIC_DIAGNOSTIC_BYTES: usize = 512 * 1024;
68pub const MAX_RUBY_SEMANTIC_DIAGNOSTICS: usize = 200;
69pub const MAX_RUBY_DIAGNOSTIC_VISIBLE_FILES: usize = 64;
70pub const MAX_RUBY_DIAGNOSTIC_VISIBLE_SOURCE_BYTES: usize = 2 * 1024 * 1024;
71
72/// What the analyzer's retained gem evidence proves about one name a Ruby file
73/// reaches outside the visible workspace closure.
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub enum RubyGemBoundary {
76    /// An activated gem pack publishes the name.
77    Indexed,
78    /// An activated gem pack owns the name's namespace, claims that surface
79    /// complete, and does not publish the name. The absence is therefore proven
80    /// at [`BoundaryStatus::ExternalIndexed`], in the carried domain.
81    Absent(SemanticDiagnosticDomain),
82    /// No activated pack owns the namespace at all, so the packs say nothing
83    /// either way. The reason states how far retained discovery could see, so a
84    /// caller with its own complete surface can still prove absence on that one.
85    Unpublished(SemanticDiagnosticIncompleteReason),
86    /// A pack owns the namespace but retained state cannot decide, for this
87    /// typed reason.
88    Incomplete(SemanticDiagnosticIncompleteReason),
89}
90
91/// The retained gem surface a Ruby diagnostic request may read.
92///
93/// Every method answers only from state a host already published. An
94/// implementation must never run Bundler, read a gem archive, walk a gem
95/// directory, or start dependency discovery: a missing answer is
96/// [`RubyGemBoundary::Unpublished`] or [`RubyGemBoundary::Incomplete`], never a
97/// blocking call.
98pub trait RubyGemSurface {
99    /// Classify `terminal` under the constant path `owner_path`, which is the
100    /// AST-derived segment list of the reference's `scope` (`["Widget"]` for
101    /// `Widget::Config`) and is never empty.
102    fn constant_boundary(&self, owner_path: &[String], terminal: &str) -> RubyGemBoundary;
103
104    /// Classify the gem that a `require` argument loads. Never `Absent`: a load
105    /// path that no pack covers is a boundary this pass cannot see past, not a
106    /// missing file.
107    fn require_boundary(&self, require_path: &str) -> RubyGemBoundary;
108}
109
110/// A surface that has acquired nothing. Every boundary is unknown, which is the
111/// honest answer for an analyzer no host has activated gem packs on.
112#[derive(Debug, Clone, Copy, Default)]
113pub struct UnacquiredRubyGems;
114
115impl RubyGemSurface for UnacquiredRubyGems {
116    fn constant_boundary(&self, _owner_path: &[String], _terminal: &str) -> RubyGemBoundary {
117        RubyGemBoundary::Unpublished(unknown_dependency_reason())
118    }
119
120    fn require_boundary(&self, _require_path: &str) -> RubyGemBoundary {
121        RubyGemBoundary::Unpublished(unknown_dependency_reason())
122    }
123}
124
125fn unknown_dependency_reason() -> SemanticDiagnosticIncompleteReason {
126    SemanticDiagnosticIncompleteReason::MissingDependencyDiscovery {
127        boundary: BoundaryStatus::ExternalUnknown,
128    }
129}
130
131/// Collect Ruby semantic diagnostics and the proof or suppression behind each.
132pub fn collect_ruby_semantic_diagnostics(
133    graph: RubyGraphSource<'_>,
134    ruby: &dyn RubySource,
135    gems: &dyn RubyGemSurface,
136    file: &ProjectFile,
137    source: &str,
138) -> SemanticDiagnosticReport {
139    let mut report = SemanticDiagnosticReport::new();
140    if source.len() > MAX_RUBY_SEMANTIC_DIAGNOSTIC_BYTES {
141        report.push_incomplete(None, vec![SemanticDiagnosticIncompleteReason::Truncated]);
142        return report;
143    }
144    let Some(tree) = parse_ruby_tree(source) else {
145        report.push_incomplete(
146            None,
147            vec![SemanticDiagnosticIncompleteReason::UnsupportedSemantics {
148                detail: "Ruby source did not parse".to_string(),
149            }],
150        );
151        return report;
152    };
153    let mut parse_errors = Vec::new();
154    collect_parse_errors(tree.root_node(), &mut parse_errors);
155    if !parse_errors.is_empty() {
156        // The parse errors themselves reach the host through the analyzer's
157        // parse-diagnostic path. What the semantic report records is that the
158        // tree this pass would have judged is not trustworthy, so no name in
159        // the file was checked at all.
160        report.push_incomplete(
161            None,
162            vec![SemanticDiagnosticIncompleteReason::UnsupportedSemantics {
163                detail: "Ruby source has parse errors".to_string(),
164            }],
165        );
166        return report;
167    }
168    if let Some(detail) = open_runtime_boundary_detail(tree.root_node(), source) {
169        // A run-time constant boundary makes every constant in the file
170        // unjudgeable: the set of bindings is decided while the program runs.
171        report.push_incomplete(
172            None,
173            vec![SemanticDiagnosticIncompleteReason::DynamicBehavior { detail }],
174        );
175        return report;
176    }
177    if let Some(reason) = unresolved_load_directive_reason(ruby, gems, file) {
178        report.push_incomplete(None, vec![reason]);
179        return report;
180    }
181
182    let semantic = RubySemanticIndex::build_for_lookup(graph, ruby);
183    let Some(mut visible_files) =
184        semantic.visible_files_from_bounded(file, MAX_RUBY_DIAGNOSTIC_VISIBLE_FILES)
185    else {
186        report.push_incomplete(None, vec![SemanticDiagnosticIncompleteReason::Truncated]);
187        return report;
188    };
189    // Zeitwerk widens what the file can see rather than blinding the pass: the
190    // autoloaded tree is visible to every consumer, so its declarations resolve.
191    // What it also does is let the *file tree* define a constant this pass never
192    // reads, through an inflection or a custom loader root Bifrost does not
193    // model, so a miss under Zeitwerk stays unproven (`zeitwerk_open`).
194    let zeitwerk_open = match ruby_zeitwerk_visible_files_for(ruby, file) {
195        Some(zeitwerk_files) => {
196            visible_files.extend(zeitwerk_files.iter().cloned());
197            if visible_files.len() > MAX_RUBY_DIAGNOSTIC_VISIBLE_FILES {
198                report.push_incomplete(None, vec![SemanticDiagnosticIncompleteReason::Truncated]);
199                return report;
200            }
201            true
202        }
203        None => false,
204    };
205    if let Some(reason) = visible_surface_reason(graph, ruby, gems, file, source, &visible_files) {
206        report.push_incomplete(None, vec![reason]);
207        return report;
208    }
209
210    let line_starts = compute_line_starts(source);
211    let mut collector = RubyDiagnosticCollector {
212        semantic,
213        ruby,
214        gems,
215        file,
216        source,
217        line_starts: &line_starts,
218        visible_files,
219        zeitwerk_open,
220        report,
221    };
222    collector.scan_tree(tree.root_node());
223    collector.report
224}
225
226struct RubyDiagnosticCollector<'a> {
227    semantic: RubySemanticIndex<'a>,
228    ruby: &'a dyn RubySource,
229    gems: &'a dyn RubyGemSurface,
230    file: &'a ProjectFile,
231    source: &'a str,
232    line_starts: &'a [usize],
233    visible_files: HashSet<ProjectFile>,
234    /// Whether Zeitwerk can define a constant from the project file tree that
235    /// this pass did not read, which downgrades every absence to unproven.
236    zeitwerk_open: bool,
237    report: SemanticDiagnosticReport,
238}
239
240enum ScanFrame<'tree> {
241    Node(Node<'tree>),
242    ExitNamespace(usize),
243}
244
245impl RubyDiagnosticCollector<'_> {
246    fn scan_tree(&mut self, root: Node<'_>) {
247        let mut lexical_stack = Vec::new();
248        let mut stack = vec![ScanFrame::Node(root)];
249        while let Some(frame) = stack.pop() {
250            if self.report.diagnostics().len() >= MAX_RUBY_SEMANTIC_DIAGNOSTICS {
251                self.report
252                    .push_incomplete(None, vec![SemanticDiagnosticIncompleteReason::Truncated]);
253                return;
254            }
255            match frame {
256                ScanFrame::Node(node) => self.scan_node(node, &mut lexical_stack, &mut stack),
257                ScanFrame::ExitNamespace(len) => lexical_stack.truncate(len),
258            }
259        }
260    }
261
262    fn scan_node<'tree>(
263        &mut self,
264        node: Node<'tree>,
265        lexical_stack: &mut Vec<String>,
266        stack: &mut Vec<ScanFrame<'tree>>,
267    ) {
268        match node.kind() {
269            "class" | "module" => {
270                let Some(owner) = ruby_type_owner(
271                    &self.semantic,
272                    self.file,
273                    &self.visible_files,
274                    lexical_stack,
275                    node,
276                    self.source,
277                ) else {
278                    // The declaration's own owner did not resolve, so no
279                    // constant inside it can be placed in a namespace.
280                    self.report.push_incomplete(
281                        Some(node_range(node, self.line_starts)),
282                        vec![SemanticDiagnosticIncompleteReason::UnsupportedSemantics {
283                            detail: "declaration namespace did not resolve".to_string(),
284                        }],
285                    );
286                    return;
287                };
288                let previous_len = lexical_stack.len();
289                lexical_stack.push(owner);
290                stack.push(ScanFrame::ExitNamespace(previous_len));
291                if let Some(body) = node.child_by_field_name("body") {
292                    stack.push(ScanFrame::Node(body));
293                }
294            }
295            "scope_resolution" => self.check_explicit_path(node, lexical_stack),
296            // A bare constant is not a candidate: see this module's header.
297            "constant" => {}
298            "assignment" | "operator_assignment" => {
299                if let Some(right) = node.child_by_field_name("right") {
300                    stack.push(ScanFrame::Node(right));
301                }
302            }
303            "string" | "comment" => {}
304            _ => push_named_children(stack, node),
305        }
306    }
307
308    fn check_explicit_path(&mut self, node: Node<'_>, lexical_stack: &[String]) {
309        if is_declaration_constant(node) {
310            return;
311        }
312        let Some(owner_node) = node.child_by_field_name("scope") else {
313            return;
314        };
315        let Some(terminal_node) = node.child_by_field_name("name") else {
316            return;
317        };
318        let terminal = node_text(terminal_node, self.source);
319        if terminal.is_empty() {
320            return;
321        }
322        let range = node_range(terminal_node, self.line_starts);
323
324        // The visible workspace closure is checked first: a project file that
325        // declares the whole path settles the question at the strongest
326        // boundary there is, whatever the packs also publish.
327        if self
328            .semantic
329            .resolve_project_local_constant(
330                self.file,
331                &self.visible_files,
332                lexical_stack,
333                node,
334                self.source,
335            )
336            .is_some()
337        {
338            self.report
339                .push_resolved(range, BoundaryStatus::WorkspaceLocal);
340            return;
341        }
342
343        let owner_path = extract_name_path(owner_node, self.source);
344        let owner_unit = self.semantic.resolve_project_local_constant(
345            self.file,
346            &self.visible_files,
347            lexical_stack,
348            owner_node,
349            self.source,
350        );
351        match self.gems.constant_boundary(&owner_path.segments, terminal) {
352            RubyGemBoundary::Indexed => self
353                .report
354                .push_resolved(range, BoundaryStatus::ExternalIndexed),
355            RubyGemBoundary::Absent(domain) => {
356                // A pack proved its own namespace complete and does not
357                // publish the terminal. A workspace file that reopens the same
358                // namespace can still add it, so the workspace surface has to
359                // be complete too before the two agree.
360                if let Some(detail) = self.workspace_reopen_detail(owner_unit.as_ref()) {
361                    self.report.push_incomplete(
362                        Some(range),
363                        vec![SemanticDiagnosticIncompleteReason::DynamicBehavior { detail }],
364                    );
365                    return;
366                }
367                self.push_absent(range, domain, terminal, BoundaryStatus::ExternalIndexed);
368            }
369            RubyGemBoundary::Unpublished(reason) => {
370                // No pack owns the namespace, so the visible workspace closure
371                // is the only surface that can answer.
372                match owner_unit {
373                    Some(owner) => match self.owner_escape_detail(&owner) {
374                        Some(detail) => self.report.push_incomplete(
375                            Some(range),
376                            vec![SemanticDiagnosticIncompleteReason::DynamicBehavior { detail }],
377                        ),
378                        None => self.push_absent(
379                            range,
380                            SemanticDiagnosticDomain::LexicalScope {
381                                file: self.file.rel_path().to_path_buf(),
382                                range,
383                            },
384                            terminal,
385                            BoundaryStatus::WorkspaceLocal,
386                        ),
387                    },
388                    None => self.report.push_incomplete(Some(range), vec![reason]),
389                }
390            }
391            RubyGemBoundary::Incomplete(reason) => {
392                self.report.push_incomplete(Some(range), vec![reason])
393            }
394        }
395    }
396
397    /// Publish one absence, unless Zeitwerk can still define the constant from a
398    /// project file this pass did not read.
399    fn push_absent(
400        &mut self,
401        range: Range,
402        domain: SemanticDiagnosticDomain,
403        terminal: &str,
404        boundary: BoundaryStatus,
405    ) {
406        if self.zeitwerk_open {
407            self.report.push_incomplete(
408                Some(range),
409                vec![SemanticDiagnosticIncompleteReason::DynamicBehavior {
410                    detail:
411                        "Zeitwerk autoloading can define this constant from the project file tree"
412                            .to_string(),
413                }],
414            );
415            return;
416        }
417        self.report.push_absent(
418            SemanticAbsenceProof {
419                range,
420                domain,
421                boundary,
422            },
423            SemanticDiagnostic {
424                range,
425                source: RUBY_SEMANTIC_DIAGNOSTIC_SOURCE,
426                kind: RUBY_UNRECOGNIZED_SYMBOL,
427                message: format!("Unrecognized Ruby constant `{terminal}`"),
428            },
429        );
430    }
431
432    /// Why a workspace declaration of the same namespace keeps a pack's
433    /// complete surface from settling the question.
434    ///
435    /// Ruby classes are open: a project file that reopens a gem's class or
436    /// module adds to the surface the pack described, so the pack's completeness
437    /// covers only what the gem itself declared.
438    fn workspace_reopen_detail(&self, owner: Option<&CodeUnit>) -> Option<String> {
439        let owner = owner?;
440        Some(self.owner_escape_detail(owner).unwrap_or_else(|| {
441            format!(
442                "a workspace file reopens `{}`, which an activated gem pack also declares",
443                owner.fq_name()
444            )
445        }))
446    }
447
448    /// Why a workspace owner's constant surface is not complete, if it is not.
449    fn owner_escape_detail(&self, owner: &CodeUnit) -> Option<String> {
450        let fq_name = owner.fq_name();
451        if !owner.is_module() {
452            return Some(format!(
453                "class `{fq_name}` can inherit constants from ancestors this pass does not enumerate"
454            ));
455        }
456        let facts = self.ruby.semantic_facts();
457        if facts
458            .ancestors
459            .get(&fq_name)
460            .is_some_and(|ancestors| !ancestors.is_empty())
461        {
462            return Some(format!(
463                "`{fq_name}` has ancestors that can supply constants"
464            ));
465        }
466        if facts.mixin_included_owners.contains_key(&fq_name) {
467            return Some(format!(
468                "`{fq_name}` includes a module that can supply constants"
469            ));
470        }
471        if facts.mixin_prepended_owners.contains_key(&fq_name) {
472            return Some(format!(
473                "`{fq_name}` prepends a module that can supply constants"
474            ));
475        }
476        if facts.mixin_class_owners.contains_key(&fq_name) {
477            return Some(format!(
478                "`{fq_name}` extends a module that can supply constants"
479            ));
480        }
481        None
482    }
483}
484
485fn push_named_children<'tree>(stack: &mut Vec<ScanFrame<'tree>>, node: Node<'tree>) {
486    let mut cursor = node.walk();
487    let children: Vec<_> = node.named_children(&mut cursor).collect();
488    for child in children.into_iter().rev() {
489        stack.push(ScanFrame::Node(child));
490    }
491}
492
493/// The run-time constant boundary this file opens, named, if it opens one.
494fn open_runtime_boundary_detail(root: Node<'_>, source: &str) -> Option<String> {
495    let mut stack = vec![root];
496    while let Some(node) = stack.pop() {
497        if node.kind() == "call"
498            && let Some(method) = node.child_by_field_name("method")
499        {
500            let name = node_text(method, source);
501            match name {
502                "const_get" | "const_set" | "remove_const" | "const_missing" | "class_eval"
503                | "module_eval" | "eval" => {
504                    return Some(format!(
505                        "`{name}` can define or read a constant at run time"
506                    ));
507                }
508                "autoload" => {
509                    return Some("`autoload` defers a constant to a run-time load".to_string());
510                }
511                "require" | "require_relative" | "load"
512                    if parse_ruby_require_call(node, source).is_none() =>
513                {
514                    return Some(format!(
515                        "`{name}` takes an argument this pass cannot resolve statically"
516                    ));
517                }
518                _ => {}
519            }
520        }
521        if defines_const_missing_dynamically(node, source) {
522            return Some("`const_missing` is defined dynamically".to_string());
523        }
524        if matches!(node.kind(), "method" | "singleton_method")
525            && node
526                .child_by_field_name("name")
527                .is_some_and(|name| node_text(name, source) == "const_missing")
528        {
529            return Some("`const_missing` is defined in this file".to_string());
530        }
531        let mut cursor = node.walk();
532        stack.extend(node.named_children(&mut cursor));
533    }
534    None
535}
536
537fn defines_const_missing_dynamically(node: Node<'_>, source: &str) -> bool {
538    if node.kind() != "call" {
539        return false;
540    }
541    let Some(method) = node.child_by_field_name("method") else {
542        return false;
543    };
544    if !matches!(
545        node_text(method, source),
546        "define_method" | "define_singleton_method"
547    ) {
548        return false;
549    }
550    let Some(arguments) = node.child_by_field_name("arguments") else {
551        return false;
552    };
553    let mut cursor = arguments.walk();
554    let Some(name) = arguments.named_children(&mut cursor).next() else {
555        return false;
556    };
557    ruby_symbol_name(name, source).as_deref() == Some("const_missing")
558        || single_static_string_content_node(name)
559            .is_some_and(|content| node_text(content, source) == "const_missing")
560}
561
562/// Why a load directive this file issues keeps its constant surface open.
563///
564/// A `require` that names no project file loads a gem or a caller-supplied load
565/// path. When an activated pack covers that gem the boundary is closed and the
566/// gem's declarations answer for it; otherwise the reason states how far
567/// retained discovery could see.
568fn unresolved_load_directive_reason(
569    ruby: &dyn RubySource,
570    gems: &dyn RubyGemSurface,
571    file: &ProjectFile,
572) -> Option<SemanticDiagnosticIncompleteReason> {
573    for import in ruby.import_info_of(file).iter() {
574        if crate::imports::resolve_required_file(file, import).is_some() {
575            continue;
576        }
577        let Some(load_path) = import.identifier.as_deref() else {
578            return Some(SemanticDiagnosticIncompleteReason::UnsupportedSemantics {
579                detail: format!("load directive `{}` names no path", import.raw_snippet),
580            });
581        };
582        if import.raw_snippet.starts_with("require_relative") {
583            return Some(SemanticDiagnosticIncompleteReason::UnsupportedSemantics {
584                detail: format!("`require_relative \"{load_path}\"` names no project file"),
585            });
586        }
587        match gems.require_boundary(load_path) {
588            RubyGemBoundary::Indexed => {}
589            RubyGemBoundary::Unpublished(reason) | RubyGemBoundary::Incomplete(reason) => {
590                return Some(reason);
591            }
592            RubyGemBoundary::Absent(_) => {
593                unreachable!("require_boundary never proves a load path absent")
594            }
595        }
596    }
597    None
598}
599
600/// Why the visible closure this file resolves against is not a surface absence
601/// can be proven on, if it is not.
602fn visible_surface_reason(
603    graph: RubyGraphSource<'_>,
604    ruby: &dyn RubySource,
605    gems: &dyn RubyGemSurface,
606    file: &ProjectFile,
607    source: &str,
608    visible_files: &HashSet<ProjectFile>,
609) -> Option<SemanticDiagnosticIncompleteReason> {
610    let mut remaining_bytes = MAX_RUBY_DIAGNOSTIC_VISIBLE_SOURCE_BYTES;
611    for visible_file in visible_files {
612        // The requested file's own directives were classified before the
613        // closure was built; every other visible file is classified here
614        // against the same activated packs.
615        if visible_file != file
616            && let Some(reason) = unresolved_load_directive_reason(ruby, gems, visible_file)
617        {
618            return Some(reason);
619        }
620        let visible_source = if visible_file == file {
621            (source.len() <= remaining_bytes).then_some(Cow::Borrowed(source))
622        } else {
623            graph
624                .index
625                .project()
626                .read_source_limited(visible_file, remaining_bytes)
627                .ok()
628                .flatten()
629                .map(Cow::Owned)
630        };
631        let Some(visible_source) = visible_source else {
632            return Some(SemanticDiagnosticIncompleteReason::Truncated);
633        };
634        let Some(next_remaining_bytes) = remaining_bytes.checked_sub(visible_source.len()) else {
635            return Some(SemanticDiagnosticIncompleteReason::Truncated);
636        };
637        remaining_bytes = next_remaining_bytes;
638        let Some(tree) = parse_ruby_tree(&visible_source) else {
639            return Some(SemanticDiagnosticIncompleteReason::UnsupportedSemantics {
640                detail: format!(
641                    "visible file {} did not parse",
642                    visible_file.rel_path().display()
643                ),
644            });
645        };
646        let mut parse_errors = Vec::new();
647        collect_parse_errors(tree.root_node(), &mut parse_errors);
648        if !parse_errors.is_empty() {
649            return Some(SemanticDiagnosticIncompleteReason::UnsupportedSemantics {
650                detail: format!(
651                    "visible file {} has parse errors",
652                    visible_file.rel_path().display()
653                ),
654            });
655        }
656        if let Some(detail) = open_runtime_boundary_detail(tree.root_node(), &visible_source) {
657            return Some(SemanticDiagnosticIncompleteReason::DynamicBehavior {
658                detail: format!(
659                    "visible file {}: {detail}",
660                    visible_file.rel_path().display()
661                ),
662            });
663        }
664    }
665    None
666}