Skip to main content

diffkit/language/
ocaml.rs

1use std::collections::{BTreeMap, BTreeSet, VecDeque};
2use std::fs;
3use std::io;
4use std::path::{Path, PathBuf};
5use std::process::Command;
6use std::sync::atomic::{AtomicU64, Ordering};
7
8use tree_sitter::{Node, Parser};
9
10use super::{FileContext, FrontendResult, LanguageBackend, ProjectContext};
11use crate::model::{
12    CallLabel, CallSite, CallSiteId, CallSyntax, CallTarget, DispatchCandidate, DispatchEvidence,
13    DispatchResolution, FileAnalysis, FunctionInfo, LanguageFact, LanguageId, SourceSpan, SymbolId,
14    UnresolvedReason,
15};
16
17static OCAML_TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
18const OCAML_EXTRACTOR_SOURCE: &str = include_str!("../../support/ocaml/extract.ml");
19
20/// OCaml's source-label stage. Dune projects overlay compiler-libs Typedtree
21/// paths in `analyze_semantic_project`; standalone source sets retain
22/// conservative module/local resolution and the same graph contract.
23#[derive(Default)]
24pub struct OcamlFrontend;
25
26pub static OCAML_BACKEND: OcamlFrontend = OcamlFrontend;
27
28/// Analyze a Dune project from compiler-generated `.cmt` Typedtrees. Source
29/// parsing remains responsible for language-shaped labels; resolved `Path.t`
30/// values from compiler-libs are authoritative for call identity.
31pub fn analyze_semantic_project(root: &Path) -> FrontendResult<FileAnalysis> {
32    let root = root.canonicalize()?;
33    let build = Command::new("dune")
34        .args(["build", "@check", "--display", "quiet"])
35        .current_dir(&root)
36        .output()
37        .map_err(|error| {
38            io::Error::new(
39                error.kind(),
40                format!("failed to run Dune for OCaml semantic analysis: {error}"),
41            )
42        })?;
43    if !build.status.success() {
44        let diagnostic = String::from_utf8_lossy(&build.stderr).trim().to_owned();
45        return Err(io::Error::other(if diagnostic.is_empty() {
46            format!("Dune semantic build exited with {}", build.status)
47        } else {
48            diagnostic
49        })
50        .into());
51    }
52
53    let helper = OcamlExtractor::compile()?;
54    let mut cmt_files = Vec::new();
55    collect_files_with_extension(&root.join("_build"), "cmt", &mut cmt_files)?;
56    cmt_files.sort();
57    let mut events = Vec::new();
58    for cmt in cmt_files {
59        let output = Command::new(helper.executable()).arg(&cmt).output()?;
60        if !output.status.success() {
61            let diagnostic = String::from_utf8_lossy(&output.stderr).trim().to_owned();
62            return Err(io::Error::other(format!(
63                "compiler-libs could not read {}: {}",
64                cmt.display(),
65                diagnostic
66            ))
67            .into());
68        }
69        events.extend(parse_typed_events(
70            &root,
71            &String::from_utf8(output.stdout)?,
72        )?);
73    }
74
75    let mut source_files = Vec::new();
76    collect_ocaml_sources(&root, &mut source_files)?;
77    source_files.sort();
78    let mut analysis = FileAnalysis::default();
79    for file in source_files {
80        let source = fs::read_to_string(&file)?;
81        let module = ocaml_file_module_path(&root, &file);
82        let mut file_analysis = analyze_ocaml_syntax(
83            &FileContext {
84                path: &file,
85                module: &module,
86            },
87            &source,
88        )?;
89        apply_typed_events(&mut file_analysis, &events);
90        analysis.functions.append(&mut file_analysis.functions);
91        analysis.facts.append(&mut file_analysis.facts);
92        analysis
93            .source_files
94            .append(&mut file_analysis.source_files);
95    }
96    resolve_ocaml_function_values(&mut analysis);
97    Ok(analysis)
98}
99
100/// Analyze an OCaml source set without a Dune project. This is used for
101/// standalone source trees; Dune projects use `analyze_semantic_project`.
102pub fn analyze_source_project(root: &Path) -> FrontendResult<FileAnalysis> {
103    let root = root.canonicalize()?;
104    let mut files = Vec::new();
105    collect_ocaml_sources(&root, &mut files)?;
106    files.sort();
107    let mut analysis = FileAnalysis::default();
108    for file in files {
109        let source = fs::read_to_string(&file)?;
110        let module = ocaml_file_module_path(&root, &file);
111        let mut file_analysis = analyze_ocaml_syntax(
112            &FileContext {
113                path: &file,
114                module: &module,
115            },
116            &source,
117        )?;
118        if let Some(events) = standalone_typed_events(&file, &source)? {
119            apply_typed_events(&mut file_analysis, &events);
120        }
121        analysis.functions.append(&mut file_analysis.functions);
122        analysis.facts.append(&mut file_analysis.facts);
123        analysis
124            .source_files
125            .append(&mut file_analysis.source_files);
126    }
127    resolve_ocaml_function_values(&mut analysis);
128    Ok(analysis)
129}
130
131struct OcamlExtractor {
132    directory: PathBuf,
133}
134
135impl OcamlExtractor {
136    fn compile() -> FrontendResult<Self> {
137        let sequence = OCAML_TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
138        let directory = std::env::temp_dir().join(format!(
139            "diffkit-ocaml-extractor-{}-{sequence}",
140            std::process::id()
141        ));
142        fs::create_dir(&directory)?;
143        let extractor = Self { directory };
144        let source = extractor.directory.join("extract.ml");
145        fs::write(&source, OCAML_EXTRACTOR_SOURCE)?;
146        let output = Command::new("ocamlc")
147            .args(["-I", "+compiler-libs", "ocamlcommon.cma"])
148            .arg(&source)
149            .arg("-o")
150            .arg(extractor.executable())
151            .output()
152            .map_err(|error| {
153                io::Error::new(
154                    error.kind(),
155                    format!("failed to start ocamlc for compiler-libs adapter: {error}"),
156                )
157            })?;
158        if !output.status.success() {
159            let diagnostic = String::from_utf8_lossy(&output.stderr).trim().to_owned();
160            return Err(io::Error::other(format!(
161                "failed to compile compiler-libs adapter: {diagnostic}"
162            ))
163            .into());
164        }
165        Ok(extractor)
166    }
167
168    fn executable(&self) -> PathBuf {
169        self.directory.join("extract")
170    }
171}
172
173impl Drop for OcamlExtractor {
174    fn drop(&mut self) {
175        let _ = fs::remove_dir_all(&self.directory);
176    }
177}
178
179#[derive(Clone, Debug)]
180struct TypedCallEvent {
181    target: Option<String>,
182    signature: Option<String>,
183    span: SourceSpan,
184}
185
186impl LanguageBackend for OcamlFrontend {
187    fn language(&self) -> LanguageId {
188        LanguageId::new("ocaml")
189    }
190
191    fn extensions(&self) -> &'static [&'static str] {
192        &["ml", "mli"]
193    }
194
195    fn analyze_file(
196        &self,
197        context: &FileContext<'_>,
198        source: &str,
199    ) -> FrontendResult<FileAnalysis> {
200        let mut analysis = analyze_ocaml_syntax(context, source)?;
201        if let Some(events) = standalone_typed_events(context.path, source)? {
202            apply_typed_events(&mut analysis, &events);
203        }
204        resolve_ocaml_function_values(&mut analysis);
205        Ok(analysis)
206    }
207
208    fn analyze_project(&self, context: &ProjectContext<'_>) -> FrontendResult<FileAnalysis> {
209        if context.root.join("dune-project").is_file() {
210            analyze_semantic_project(context.root)
211        } else {
212            analyze_source_project(context.root)
213        }
214    }
215}
216
217fn standalone_typed_events(
218    source_path: &Path,
219    source: &str,
220) -> FrontendResult<Option<Vec<TypedCallEvent>>> {
221    if Command::new("ocamlc").arg("-version").output().is_err() {
222        return Ok(None);
223    }
224    let sequence = OCAML_TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
225    let directory = std::env::temp_dir().join(format!(
226        "diffkit-ocaml-source-{}-{sequence}",
227        std::process::id()
228    ));
229    fs::create_dir(&directory)?;
230    let capture = StandaloneOcamlCapture { directory };
231    let extension = source_path
232        .extension()
233        .and_then(|extension| extension.to_str())
234        .unwrap_or("ml");
235    let temporary_source = capture.directory.join(format!("input.{extension}"));
236    fs::write(&temporary_source, source)?;
237    let compile = Command::new("ocamlc")
238        .args(["-bin-annot", "-c"])
239        .arg(&temporary_source)
240        .current_dir(&capture.directory)
241        .output()?;
242    if !compile.status.success() {
243        // A standalone snippet may intentionally depend on surrounding
244        // modules. Keep the conservative graph, with unresolved calls marked
245        // explicitly, instead of pretending the compiler accepted it.
246        return Ok(None);
247    }
248    let annotation = if extension == "mli" {
249        capture.directory.join("input.cmti")
250    } else {
251        capture.directory.join("input.cmt")
252    };
253    if !annotation.is_file() {
254        return Ok(None);
255    }
256    let helper = OcamlExtractor::compile()?;
257    let output = Command::new(helper.executable()).arg(annotation).output()?;
258    if !output.status.success() {
259        return Ok(None);
260    }
261    let mut events = parse_typed_events(&capture.directory, &String::from_utf8(output.stdout)?)?;
262    for event in &mut events {
263        event.span.file = source_path.to_path_buf();
264    }
265    Ok(Some(events))
266}
267
268struct StandaloneOcamlCapture {
269    directory: PathBuf,
270}
271
272impl Drop for StandaloneOcamlCapture {
273    fn drop(&mut self) {
274        let _ = fs::remove_dir_all(&self.directory);
275    }
276}
277
278fn analyze_ocaml_syntax(context: &FileContext<'_>, source: &str) -> FrontendResult<FileAnalysis> {
279    let mut parser = Parser::new();
280    let language = if context.path.extension().and_then(|value| value.to_str()) == Some("mli") {
281        tree_sitter_ocaml::LANGUAGE_OCAML_INTERFACE
282    } else {
283        tree_sitter_ocaml::LANGUAGE_OCAML
284    };
285    parser
286        .set_language(&language.into())
287        .map_err(|error| io::Error::other(format!("failed to load OCaml grammar: {error}")))?;
288    let tree = parser
289        .parse(source, None)
290        .ok_or_else(|| io::Error::other("OCaml parser returned no syntax tree"))?;
291    if tree.root_node().has_error() {
292        return Err(io::Error::other(format!(
293            "failed to parse OCaml source: {}",
294            context.path.display()
295        ))
296        .into());
297    }
298
299    let mut analysis = FileAnalysis::default();
300    analysis.source_files.insert(context.path.to_path_buf());
301    let module = context
302        .module
303        .iter()
304        .map(|part| ocaml_module_name(part))
305        .collect::<Vec<_>>();
306    analyze_structure(
307        context.path,
308        &module,
309        tree.root_node(),
310        source,
311        &mut analysis,
312    );
313    resolve_local_callables(&mut analysis);
314    Ok(analysis)
315}
316
317fn collect_files_with_extension(
318    directory: &Path,
319    extension: &str,
320    files: &mut Vec<PathBuf>,
321) -> std::io::Result<()> {
322    if !directory.is_dir() {
323        return Ok(());
324    }
325    let mut entries = fs::read_dir(directory)?.collect::<Result<Vec<_>, _>>()?;
326    entries.sort_by_key(std::fs::DirEntry::path);
327    for entry in entries {
328        let path = entry.path();
329        let file_type = entry.file_type()?;
330        if file_type.is_dir() {
331            collect_files_with_extension(&path, extension, files)?;
332        } else if file_type.is_file()
333            && path.extension().and_then(|value| value.to_str()) == Some(extension)
334        {
335            files.push(path);
336        }
337    }
338    Ok(())
339}
340
341fn collect_ocaml_sources(directory: &Path, files: &mut Vec<PathBuf>) -> std::io::Result<()> {
342    let mut entries = fs::read_dir(directory)?.collect::<Result<Vec<_>, _>>()?;
343    entries.sort_by_key(std::fs::DirEntry::path);
344    for entry in entries {
345        let path = entry.path();
346        let file_type = entry.file_type()?;
347        if file_type.is_dir() {
348            if !matches!(
349                path.file_name().and_then(|name| name.to_str()),
350                Some("_build" | ".git" | "target" | "node_modules")
351            ) {
352                collect_ocaml_sources(&path, files)?;
353            }
354        } else if file_type.is_file()
355            && matches!(
356                path.extension().and_then(|value| value.to_str()),
357                Some("ml" | "mli")
358            )
359        {
360            files.push(path);
361        }
362    }
363    Ok(())
364}
365
366fn ocaml_file_module_path(root: &Path, file: &Path) -> Vec<String> {
367    let relative = file.strip_prefix(root).unwrap_or(file);
368    relative
369        .file_stem()
370        .and_then(|stem| stem.to_str())
371        .map(|stem| vec![stem.to_owned()])
372        .unwrap_or_default()
373}
374
375fn parse_typed_events(root: &Path, output: &str) -> FrontendResult<Vec<TypedCallEvent>> {
376    output
377        .lines()
378        .filter(|line| !line.trim().is_empty())
379        .map(|line| {
380            let fields = line.split('\t').collect::<Vec<_>>();
381            if fields.len() != 8 {
382                return Err(
383                    io::Error::other(format!("invalid compiler-libs event: {line}")).into(),
384                );
385            }
386            let file = PathBuf::from(fields[2]);
387            let file = if file.is_absolute() {
388                file
389            } else {
390                root.join(file)
391            };
392            let file = dune_source_path(root, &file).unwrap_or(file);
393            Ok(TypedCallEvent {
394                target: (fields[0] == "direct").then(|| fields[1].to_owned()),
395                signature: (!fields[7].is_empty()).then(|| fields[7].to_owned()),
396                span: SourceSpan {
397                    file,
398                    start_line: fields[3].parse()?,
399                    start_column: fields[4].parse()?,
400                    start_byte: None,
401                    end_line: fields[5].parse()?,
402                    end_column: fields[6].parse()?,
403                    end_byte: None,
404                },
405            })
406        })
407        .collect()
408}
409
410fn dune_source_path(root: &Path, file: &Path) -> Option<PathBuf> {
411    let relative = file
412        .strip_prefix(root.join("_build"))
413        .ok()
414        .or_else(|| file.strip_prefix("_build").ok())?;
415    let components = relative.components().collect::<Vec<_>>();
416    let skip = if components
417        .first()
418        .is_some_and(|component| component.as_os_str() == ".sandbox")
419    {
420        3
421    } else {
422        1
423    };
424    let candidate = components
425        .get(skip..)?
426        .iter()
427        .fold(root.to_path_buf(), |path, component| {
428            path.join(component.as_os_str())
429        });
430    candidate.is_file().then_some(candidate)
431}
432
433fn apply_typed_events(analysis: &mut FileAnalysis, events: &[TypedCallEvent]) {
434    let parameters = ocaml_parameters(&analysis.functions, &analysis.facts);
435    for function in &mut analysis.functions {
436        for call in &mut function.calls {
437            if matches!(
438                &call.target,
439                CallTarget::Direct(target) if target.container.is_some()
440            ) {
441                continue;
442            }
443            if matches!(call.target, CallTarget::Dynamic { .. }) {
444                continue;
445            }
446            let Some(event) = events
447                .iter()
448                .filter(|event| {
449                    ocaml_source_files_match(&event.span.file, &call.span.file)
450                        && ocaml_spans_overlap(&event.span, &call.span)
451                })
452                .min_by_key(|event| ocaml_span_distance(&event.span, &call.span))
453            else {
454                continue;
455            };
456            if let Some(signature) = &event.signature {
457                call.label.typed = Some(annotate_ocaml_call(&call.label.default, call, signature));
458            }
459            let is_callable_parameter = match &call.syntax {
460                CallSyntax::Path(parts) if parts.len() == 1 => parameters
461                    .get(&function.id)
462                    .is_some_and(|names| names.contains(&parts[0])),
463                _ => false,
464            };
465            call.target = match &event.target {
466                Some(target) => CallTarget::Direct(ocaml_path_symbol(target)),
467                None if is_callable_parameter => CallTarget::Unresolved,
468                None => CallTarget::Indirect {
469                    signature: event.signature.clone(),
470                    reason: UnresolvedReason::OpaqueInput,
471                },
472            };
473        }
474    }
475}
476
477fn annotate_ocaml_call(label: &str, call: &CallSite, signature: &str) -> String {
478    let arguments = ocaml_call_arguments(call);
479    if arguments.is_empty() {
480        return label.to_owned();
481    }
482    let mut types = split_ocaml_function_type(signature);
483    if types.len() <= 1 {
484        return label.to_owned();
485    }
486    types.pop(); // Return type is intentionally not rendered.
487    if types.len() < arguments.len() {
488        return label.to_owned();
489    }
490    let callee = match &call.syntax {
491        CallSyntax::Path(parts) => parts.join("."),
492        CallSyntax::SelfMethod(method) => method.clone(),
493        CallSyntax::Method { receiver, method } => format!("{receiver}#{method}"),
494    };
495    let types = &types[types.len() - arguments.len()..];
496    let annotated = arguments
497        .iter()
498        .zip(types)
499        .map(|(argument, ty)| {
500            let ty = ty.split_once(':').map_or(ty.as_str(), |(_, ty)| ty).trim();
501            format!("({argument}: {ty})")
502        })
503        .collect::<Vec<_>>()
504        .join(" ");
505    format!("{callee} {annotated}")
506}
507
508fn split_ocaml_function_type(signature: &str) -> Vec<String> {
509    let mut result = Vec::new();
510    let mut start = 0usize;
511    let mut depth = 0usize;
512    let bytes = signature.as_bytes();
513    let mut index = 0usize;
514    while index + 1 < bytes.len() {
515        match bytes[index] {
516            b'(' | b'[' | b'{' => depth += 1,
517            b')' | b']' | b'}' => depth = depth.saturating_sub(1),
518            b'-' if bytes[index + 1] == b'>' && depth == 0 => {
519                result.push(signature[start..index].trim().to_owned());
520                start = index + 2;
521                index += 1;
522            }
523            _ => {}
524        }
525        index += 1;
526    }
527    result.push(signature[start..].trim().to_owned());
528    result
529}
530
531fn ocaml_path_symbol(path: &str) -> SymbolId {
532    let mut parts = path
533        .trim_start_matches("global ")
534        .split('.')
535        .filter(|part| !part.is_empty())
536        .map(|part| part.rsplit("__").next().unwrap_or(part).to_owned())
537        .collect::<Vec<_>>();
538    let name = parts.pop().unwrap_or_else(|| path.to_owned());
539    SymbolId {
540        language: LanguageId::new("ocaml"),
541        module: parts,
542        container: None,
543        name,
544    }
545}
546
547fn ocaml_source_files_match(left: &Path, right: &Path) -> bool {
548    left == right
549        || left
550            .canonicalize()
551            .ok()
552            .zip(right.canonicalize().ok())
553            .is_some_and(|(left, right)| left == right)
554}
555
556fn ocaml_spans_overlap(left: &SourceSpan, right: &SourceSpan) -> bool {
557    (left.start_line, left.start_column) <= (right.end_line, right.end_column)
558        && (right.start_line, right.start_column) <= (left.end_line, left.end_column)
559}
560
561fn ocaml_span_distance(left: &SourceSpan, right: &SourceSpan) -> usize {
562    left.start_line.abs_diff(right.start_line) * 10_000
563        + left.start_column.abs_diff(right.start_column)
564        + left.end_line.abs_diff(right.end_line) * 10_000
565        + left.end_column.abs_diff(right.end_column)
566}
567
568fn ocaml_module_name(value: &str) -> String {
569    let mut characters = value.chars();
570    let Some(first) = characters.next() else {
571        return String::new();
572    };
573    first.to_uppercase().chain(characters).collect()
574}
575
576fn analyze_structure(
577    file: &Path,
578    module: &[String],
579    structure: Node<'_>,
580    source: &str,
581    analysis: &mut FileAnalysis,
582) {
583    let mut cursor = structure.walk();
584    for child in structure.named_children(&mut cursor) {
585        match child.kind() {
586            "module_definition" => analyze_module_definition(file, module, child, source, analysis),
587            "value_definition" => analyze_value_definition(file, module, child, source, analysis),
588            _ => {}
589        }
590    }
591}
592
593fn analyze_module_definition(
594    file: &Path,
595    module: &[String],
596    definition: Node<'_>,
597    source: &str,
598    analysis: &mut FileAnalysis,
599) {
600    let mut cursor = definition.walk();
601    for binding in definition
602        .named_children(&mut cursor)
603        .filter(|child| child.kind() == "module_binding")
604    {
605        let Some(name_node) = direct_named_child(binding, "module_name") else {
606            continue;
607        };
608        let Some(body) = binding.child_by_field_name("body") else {
609            continue;
610        };
611        let body = if body.kind() == "structure" {
612            body
613        } else if let Some(structure) = first_descendant_of_kind(body, "structure") {
614            structure
615        } else {
616            continue;
617        };
618
619        let mut nested_module = module.to_vec();
620        nested_module.push(node_text(name_node, source).to_owned());
621        analyze_structure(file, &nested_module, body, source, analysis);
622    }
623}
624
625fn first_descendant_of_kind<'tree>(node: Node<'tree>, kind: &str) -> Option<Node<'tree>> {
626    let mut cursor = node.walk();
627    for child in node.named_children(&mut cursor) {
628        if child.kind() == kind {
629            return Some(child);
630        }
631        if let Some(found) = first_descendant_of_kind(child, kind) {
632            return Some(found);
633        }
634    }
635    None
636}
637
638fn analyze_value_definition(
639    file: &Path,
640    module: &[String],
641    definition: Node<'_>,
642    source: &str,
643    analysis: &mut FileAnalysis,
644) {
645    let mut cursor = definition.walk();
646    for binding in definition
647        .named_children(&mut cursor)
648        .filter(|child| child.kind() == "let_binding")
649    {
650        if let Some(function) = function_from_binding(file, module, binding, source, analysis) {
651            let owner = function.id.clone();
652            analysis.functions.push(function);
653            if let Some(body) = binding.child_by_field_name("body") {
654                let mut ordinal = 0usize;
655                collect_nested_callables(
656                    file,
657                    module,
658                    &owner,
659                    body,
660                    source,
661                    analysis,
662                    &mut ordinal,
663                );
664            }
665        }
666    }
667}
668
669#[allow(clippy::too_many_arguments)]
670fn collect_nested_callables(
671    file: &Path,
672    module: &[String],
673    owner: &SymbolId,
674    node: Node<'_>,
675    source: &str,
676    analysis: &mut FileAnalysis,
677    ordinal: &mut usize,
678) {
679    if node.kind() == "let_binding" {
680        let facts_start = analysis.facts.len();
681        if let Some(mut function) = function_from_binding(file, module, node, source, analysis) {
682            let display_name = function.id.name.clone();
683            let old_id = function.id.clone();
684            let current_ordinal = *ordinal;
685            *ordinal += 1;
686            function.id.container = Some(owner.to_string());
687            function.id.name = format!("{display_name}#closure{current_ordinal}");
688            function.label = local_callable_label(&function.label, &display_name, current_ordinal);
689            for fact in &mut analysis.facts[facts_start..] {
690                if fact.subject == old_id {
691                    fact.subject = function.id.clone();
692                }
693            }
694            let nested_owner = function.id.clone();
695            analysis.functions.push(function);
696            if let Some(body) = node.child_by_field_name("body") {
697                let mut nested_ordinal = 0usize;
698                collect_nested_callables(
699                    file,
700                    module,
701                    &nested_owner,
702                    body,
703                    source,
704                    analysis,
705                    &mut nested_ordinal,
706                );
707            }
708            return;
709        }
710    }
711
712    let mut cursor = node.walk();
713    for child in node.named_children(&mut cursor) {
714        collect_nested_callables(file, module, owner, child, source, analysis, ordinal);
715    }
716}
717
718fn local_callable_label(label: &CallLabel, name: &str, ordinal: usize) -> CallLabel {
719    let rewrite = |text: &str| {
720        let arguments = text
721            .find(char::is_whitespace)
722            .map_or("", |start| &text[start..]);
723        format!("{name}{arguments} [closure#{ordinal}]")
724    };
725    CallLabel {
726        default: rewrite(&label.default),
727        typed: label.typed.as_deref().map(rewrite),
728    }
729}
730
731fn resolve_local_callables(analysis: &mut FileAnalysis) {
732    let functions = analysis.functions.clone();
733    for function in &mut analysis.functions {
734        for call in &mut function.calls {
735            if !matches!(call.target, CallTarget::Unresolved) {
736                continue;
737            }
738            let Some(name) = (match &call.syntax {
739                CallSyntax::Path(parts) if parts.len() == 1 => parts.first(),
740                CallSyntax::Path(_) | CallSyntax::SelfMethod(_) | CallSyntax::Method { .. } => None,
741            }) else {
742                continue;
743            };
744            let direct_owner = function.id.to_string();
745            let sibling_owner = function.id.container.as_deref();
746            let mut candidates = functions
747                .iter()
748                .filter(|candidate| {
749                    candidate.id.module == function.id.module
750                        && candidate.id.container.is_some()
751                        && local_callable_source_name(candidate) == name
752                        && (candidate.id.container.as_deref() == Some(direct_owner.as_str())
753                            || candidate.id.container.as_deref() == sibling_owner
754                            || candidate.id == function.id)
755                })
756                .collect::<Vec<_>>();
757            candidates.sort_by_key(|candidate| {
758                (
759                    candidate.span.start_line,
760                    candidate.span.start_column,
761                    candidate.id.clone(),
762                )
763            });
764            let candidate = candidates.into_iter().rev().find(|candidate| {
765                (candidate.span.start_line, candidate.span.start_column)
766                    <= (call.span.start_line, call.span.start_column)
767            });
768            let Some(candidate) = candidate else {
769                continue;
770            };
771            call.target = CallTarget::Direct(candidate.id.clone());
772            if let Some(ordinal) = candidate
773                .id
774                .name
775                .rsplit_once("#closure")
776                .map(|(_, ordinal)| ordinal)
777            {
778                call.label = call.label.with_suffix(&format!(" [closure#{ordinal}]"));
779            }
780        }
781    }
782}
783
784fn local_callable_source_name(function: &FunctionInfo) -> &str {
785    function
786        .id
787        .name
788        .rsplit_once("#closure")
789        .map_or(function.id.name.as_str(), |(name, _)| name)
790}
791
792#[derive(Clone, Debug, Default, Eq, PartialEq)]
793struct CallableFlow {
794    candidates: BTreeSet<SymbolId>,
795    unresolved_reasons: BTreeSet<UnresolvedReason>,
796}
797
798#[derive(Clone, Debug)]
799struct OcamlCallFlow {
800    caller: SymbolId,
801    target: SymbolId,
802    arguments: Vec<String>,
803}
804
805fn resolve_ocaml_function_values(analysis: &mut FileAnalysis) {
806    let mut functions = analysis.functions.clone();
807    let parameters = ocaml_parameters(&functions, &analysis.facts);
808
809    // Resolve ordinary local/module calls first. Typedtree paths already in
810    // `CallTarget::Direct` remain authoritative.
811    let symbol_index = functions.clone();
812    for function in &mut functions {
813        let parameter_names = parameters.get(&function.id).cloned().unwrap_or_default();
814        for call in &mut function.calls {
815            if !matches!(call.target, CallTarget::Unresolved) {
816                continue;
817            }
818            let CallSyntax::Path(parts) = &call.syntax else {
819                continue;
820            };
821            if parts.len() == 1 && parameter_names.contains(&parts[0]) {
822                continue;
823            }
824            if let Some(target) = resolve_ocaml_symbol(parts, &function.id, &symbol_index) {
825                call.target = CallTarget::Direct(target);
826            }
827        }
828    }
829
830    let mut called_parameters = functions
831        .iter()
832        .flat_map(|function| {
833            let names = parameters.get(&function.id).cloned().unwrap_or_default();
834            function.calls.iter().filter_map(move |call| {
835                let CallSyntax::Path(parts) = &call.syntax else {
836                    return None;
837                };
838                (parts.len() == 1)
839                    .then(|| names.iter().position(|name| name == &parts[0]))
840                    .flatten()
841                    .map(|index| (function.id.clone(), index))
842            })
843        })
844        .collect::<BTreeSet<_>>();
845
846    let function_index = functions.clone();
847    let mut call_flows = Vec::new();
848    for function in &functions {
849        for call in &function.calls {
850            let Some(target) = local_target(&call.target, &function_index) else {
851                continue;
852            };
853            call_flows.push(OcamlCallFlow {
854                caller: function.id.clone(),
855                target,
856                arguments: ocaml_call_arguments(call),
857            });
858        }
859    }
860    loop {
861        let previous_len = called_parameters.len();
862        for call in &call_flows {
863            let caller_parameters = parameters.get(&call.caller).cloned().unwrap_or_default();
864            for (target_parameter, argument) in call.arguments.iter().enumerate() {
865                if !called_parameters.contains(&(call.target.clone(), target_parameter)) {
866                    continue;
867                }
868                let outcomes = ocaml_callable_outcome_names(argument);
869                for (caller_parameter, name) in caller_parameters.iter().enumerate() {
870                    if outcomes.contains(name) {
871                        called_parameters.insert((call.caller.clone(), caller_parameter));
872                    }
873                }
874            }
875        }
876        if called_parameters.len() == previous_len {
877            break;
878        }
879    }
880    let (functions, facts) = specialize_ocaml_function_values(
881        &functions,
882        &analysis.facts,
883        &parameters,
884        &called_parameters,
885        &call_flows,
886    );
887    analysis.functions = functions;
888    analysis.facts = facts;
889}
890
891fn specialize_ocaml_function_values(
892    functions: &[FunctionInfo],
893    facts: &[LanguageFact],
894    parameters: &BTreeMap<SymbolId, Vec<String>>,
895    called_parameters: &BTreeSet<(SymbolId, usize)>,
896    call_flows: &[OcamlCallFlow],
897) -> (Vec<FunctionInfo>, Vec<LanguageFact>) {
898    let index = functions
899        .iter()
900        .enumerate()
901        .map(|(index, function)| (function.id.clone(), index))
902        .collect::<BTreeMap<_, _>>();
903    let mut incoming = vec![0usize; functions.len()];
904    for call in call_flows {
905        if let Some(target) = index.get(&call.target) {
906            incoming[*target] += 1;
907        }
908        for argument in &call.arguments {
909            if let Some(candidate) = argument_callable_symbol(argument, &call.caller, functions)
910                && let Some(target) = index.get(&candidate)
911            {
912                incoming[*target] += 1;
913            }
914        }
915    }
916
917    let mut pending = VecDeque::<(usize, Vec<CallableFlow>)>::new();
918    for (function, incoming) in incoming.iter().enumerate() {
919        if *incoming == 0 {
920            pending.push_back((
921                function,
922                root_ocaml_context(&functions[function], parameters, called_parameters),
923            ));
924        }
925    }
926    let mut visited = BTreeSet::new();
927    let mut covered = BTreeSet::new();
928    let mut specialized = Vec::new();
929    let mut specialized_facts = Vec::new();
930
931    loop {
932        while let Some((raw_index, context)) = pending.pop_front() {
933            let raw = &functions[raw_index];
934            let id = specialized_ocaml_id(raw, &context, called_parameters);
935            if !visited.insert(id.clone()) {
936                continue;
937            }
938            covered.insert(raw_index);
939            let names = parameters.get(&raw.id).cloned().unwrap_or_default();
940            let mut function = raw.clone();
941            function.id = id.clone();
942
943            for call in &mut function.calls {
944                let CallSyntax::Path(parts) = &call.syntax else {
945                    continue;
946                };
947                let parameter_index = (parts.len() == 1)
948                    .then(|| names.iter().position(|name| name == &parts[0]))
949                    .flatten();
950                if let Some(parameter_index) = parameter_index {
951                    let flow =
952                        context
953                            .get(parameter_index)
954                            .cloned()
955                            .unwrap_or_else(|| CallableFlow {
956                                candidates: BTreeSet::new(),
957                                unresolved_reasons: [UnresolvedReason::AnalysisLimit]
958                                    .into_iter()
959                                    .collect(),
960                            });
961                    let arguments = ocaml_call_arguments(call);
962                    let candidates = flow
963                        .candidates
964                        .iter()
965                        .filter_map(|target| {
966                            let target_index = *index.get(target)?;
967                            let target_function = &functions[target_index];
968                            let target_context = ocaml_call_context(
969                                target_function,
970                                &arguments,
971                                raw,
972                                &context,
973                                functions,
974                                parameters,
975                                called_parameters,
976                            );
977                            let target = specialized_ocaml_id(
978                                target_function,
979                                &target_context,
980                                called_parameters,
981                            );
982                            pending.push_back((target_index, target_context));
983                            Some(DispatchCandidate {
984                                target,
985                                label: CallLabel::new(ocaml_candidate_call_label(
986                                    target_function,
987                                    &arguments,
988                                )),
989                            })
990                        })
991                        .collect::<Vec<_>>();
992                    let resolution =
993                        match (candidates.is_empty(), !flow.unresolved_reasons.is_empty()) {
994                            (true, _) => DispatchResolution::Unresolved,
995                            (false, true) => DispatchResolution::Partial,
996                            (false, false) => DispatchResolution::Complete,
997                        };
998                    call.target = CallTarget::Dynamic {
999                        dispatch: SymbolId {
1000                            language: LanguageId::new("ocaml"),
1001                            module: raw.id.module.clone(),
1002                            container: Some(raw.id.name.clone()),
1003                            name: parts[0].clone(),
1004                        },
1005                        candidates,
1006                        resolution,
1007                        evidence: DispatchEvidence::ExactFlow,
1008                        unresolved_reasons: flow.unresolved_reasons,
1009                    };
1010                    continue;
1011                }
1012
1013                let Some(target) = local_target(&call.target, functions) else {
1014                    continue;
1015                };
1016                let Some(target_index) = index.get(&target).copied() else {
1017                    continue;
1018                };
1019                let target_function = &functions[target_index];
1020                let arguments = ocaml_call_arguments(call);
1021                let target_context = ocaml_call_context(
1022                    target_function,
1023                    &arguments,
1024                    raw,
1025                    &context,
1026                    functions,
1027                    parameters,
1028                    called_parameters,
1029                );
1030                let target =
1031                    specialized_ocaml_id(target_function, &target_context, called_parameters);
1032                pending.push_back((target_index, target_context));
1033                call.target = CallTarget::Direct(target);
1034            }
1035
1036            specialized_facts.extend(
1037                facts
1038                    .iter()
1039                    .filter(|fact| fact.subject == raw.id)
1040                    .cloned()
1041                    .map(|mut fact| {
1042                        fact.subject = id.clone();
1043                        fact
1044                    }),
1045            );
1046            specialized.push(function);
1047        }
1048
1049        let Some(uncovered) = (0..functions.len()).find(|index| !covered.contains(index)) else {
1050            break;
1051        };
1052        pending.push_back((
1053            uncovered,
1054            root_ocaml_context(&functions[uncovered], parameters, called_parameters),
1055        ));
1056    }
1057    (specialized, specialized_facts)
1058}
1059
1060fn root_ocaml_context(
1061    function: &FunctionInfo,
1062    parameters: &BTreeMap<SymbolId, Vec<String>>,
1063    called_parameters: &BTreeSet<(SymbolId, usize)>,
1064) -> Vec<CallableFlow> {
1065    parameters
1066        .get(&function.id)
1067        .into_iter()
1068        .flatten()
1069        .enumerate()
1070        .map(|(index, _)| CallableFlow {
1071            candidates: BTreeSet::new(),
1072            unresolved_reasons: called_parameters
1073                .contains(&(function.id.clone(), index))
1074                .then_some(UnresolvedReason::OpaqueInput)
1075                .into_iter()
1076                .collect(),
1077        })
1078        .collect()
1079}
1080
1081#[allow(clippy::too_many_arguments)]
1082fn ocaml_call_context(
1083    target: &FunctionInfo,
1084    arguments: &[String],
1085    caller: &FunctionInfo,
1086    caller_context: &[CallableFlow],
1087    functions: &[FunctionInfo],
1088    parameters: &BTreeMap<SymbolId, Vec<String>>,
1089    called_parameters: &BTreeSet<(SymbolId, usize)>,
1090) -> Vec<CallableFlow> {
1091    parameters
1092        .get(&target.id)
1093        .into_iter()
1094        .flatten()
1095        .enumerate()
1096        .map(|(index, _)| {
1097            if !called_parameters.contains(&(target.id.clone(), index)) {
1098                return CallableFlow::default();
1099            }
1100            arguments.get(index).map_or_else(
1101                || CallableFlow {
1102                    candidates: BTreeSet::new(),
1103                    unresolved_reasons: [UnresolvedReason::AnalysisLimit].into_iter().collect(),
1104                },
1105                |argument| {
1106                    argument_callable_flow(argument, caller, caller_context, functions, parameters)
1107                },
1108            )
1109        })
1110        .collect()
1111}
1112
1113fn specialized_ocaml_id(
1114    function: &FunctionInfo,
1115    context: &[CallableFlow],
1116    called_parameters: &BTreeSet<(SymbolId, usize)>,
1117) -> SymbolId {
1118    let bindings = context
1119        .iter()
1120        .enumerate()
1121        .filter(|(index, _)| called_parameters.contains(&(function.id.clone(), *index)))
1122        .map(|(index, flow)| {
1123            let mut values = flow
1124                .candidates
1125                .iter()
1126                .map(ToString::to_string)
1127                .collect::<Vec<_>>();
1128            for reason in &flow.unresolved_reasons {
1129                values.push(format!("?{reason}"));
1130            }
1131            format!("{index}={}", values.join("|"))
1132        })
1133        .collect::<Vec<_>>();
1134    if bindings.is_empty() {
1135        return function.id.clone();
1136    }
1137    let mut id = function.id.clone();
1138    id.name = format!("{}#ctx[{}]", id.name, bindings.join(","));
1139    id
1140}
1141
1142fn ocaml_parameters(
1143    functions: &[FunctionInfo],
1144    facts: &[LanguageFact],
1145) -> BTreeMap<SymbolId, Vec<String>> {
1146    functions
1147        .iter()
1148        .map(|function| {
1149            let parameters = facts
1150                .iter()
1151                .filter(|fact| fact.subject == function.id && fact.kind == "parameter")
1152                .map(|fact| normalize_ocaml_parameter_name(&fact.key))
1153                .collect::<Vec<_>>();
1154            (function.id.clone(), parameters)
1155        })
1156        .collect()
1157}
1158
1159fn normalize_ocaml_parameter_name(parameter: &str) -> String {
1160    parameter
1161        .trim_start_matches(['~', '?'])
1162        .split([':', '='])
1163        .next()
1164        .unwrap_or(parameter)
1165        .trim_matches(['(', ')', ' '])
1166        .to_owned()
1167}
1168
1169fn resolve_ocaml_symbol(
1170    parts: &[String],
1171    caller: &SymbolId,
1172    functions: &[FunctionInfo],
1173) -> Option<SymbolId> {
1174    let name = parts.last()?;
1175    let mut candidates = functions
1176        .iter()
1177        .filter(|candidate| {
1178            local_callable_source_name(candidate) == name
1179                && if parts.len() == 1 {
1180                    candidate.id.module == caller.module && candidate.id.container.is_none()
1181                } else {
1182                    let requested_module = &parts[..parts.len() - 1];
1183                    requested_module.len() <= candidate.id.module.len()
1184                        && candidate.id.module[candidate.id.module.len() - requested_module.len()..]
1185                            == *requested_module
1186                }
1187        })
1188        .map(|candidate| candidate.id.clone())
1189        .collect::<Vec<_>>();
1190    if candidates.is_empty() && parts.len() == 1 {
1191        candidates.extend(
1192            functions
1193                .iter()
1194                .filter(|candidate| local_callable_source_name(candidate) == name)
1195                .map(|candidate| candidate.id.clone()),
1196        );
1197    }
1198    candidates.sort();
1199    candidates.dedup();
1200    (candidates.len() == 1).then(|| candidates.remove(0))
1201}
1202
1203fn local_target(target: &CallTarget, functions: &[FunctionInfo]) -> Option<SymbolId> {
1204    let CallTarget::Direct(target) = target else {
1205        return None;
1206    };
1207    if functions.iter().any(|function| function.id == *target) {
1208        return Some(target.clone());
1209    }
1210    let target_parts = target.qualified_parts();
1211    let mut matches = functions
1212        .iter()
1213        .filter(|function| {
1214            let parts = function.id.qualified_parts();
1215            target_parts.len() <= parts.len()
1216                && parts[parts.len() - target_parts.len()..] == target_parts
1217        })
1218        .map(|function| function.id.clone())
1219        .collect::<Vec<_>>();
1220    (matches.len() == 1).then(|| matches.remove(0))
1221}
1222
1223fn argument_callable_flow(
1224    argument: &str,
1225    caller: &FunctionInfo,
1226    caller_context: &[CallableFlow],
1227    functions: &[FunctionInfo],
1228    parameters: &BTreeMap<SymbolId, Vec<String>>,
1229) -> CallableFlow {
1230    let argument = normalize_ocaml_callable_argument(argument);
1231    parse_ocaml_callable_flow(argument, caller, caller_context, functions, parameters)
1232        .unwrap_or_else(|| CallableFlow {
1233            candidates: BTreeSet::new(),
1234            unresolved_reasons: [UnresolvedReason::UnsupportedConstruct]
1235                .into_iter()
1236                .collect(),
1237        })
1238}
1239
1240fn parse_ocaml_callable_flow(
1241    expression: &str,
1242    caller: &FunctionInfo,
1243    caller_context: &[CallableFlow],
1244    functions: &[FunctionInfo],
1245    parameters: &BTreeMap<SymbolId, Vec<String>>,
1246) -> Option<CallableFlow> {
1247    let mut parser = Parser::new();
1248    parser
1249        .set_language(&tree_sitter_ocaml::LANGUAGE_OCAML.into())
1250        .ok()?;
1251    let tree = parser.parse(expression, None)?;
1252    if tree.root_node().has_error() {
1253        return None;
1254    }
1255    let node = tree.root_node().named_child(0)?;
1256    ocaml_callable_node_flow(
1257        node,
1258        expression,
1259        caller,
1260        caller_context,
1261        functions,
1262        parameters,
1263    )
1264}
1265
1266fn ocaml_callable_outcome_names(expression: &str) -> BTreeSet<String> {
1267    let expression = normalize_ocaml_callable_argument(expression);
1268    let mut parser = Parser::new();
1269    if parser
1270        .set_language(&tree_sitter_ocaml::LANGUAGE_OCAML.into())
1271        .is_err()
1272    {
1273        return BTreeSet::new();
1274    }
1275    let Some(tree) = parser.parse(expression, None) else {
1276        return BTreeSet::new();
1277    };
1278    if tree.root_node().has_error() {
1279        return BTreeSet::new();
1280    }
1281    let Some(node) = tree.root_node().named_child(0) else {
1282        return BTreeSet::new();
1283    };
1284    let mut names = BTreeSet::new();
1285    collect_ocaml_callable_outcomes(node, expression, &mut names);
1286    names
1287}
1288
1289fn collect_ocaml_callable_outcomes(node: Node<'_>, source: &str, names: &mut BTreeSet<String>) {
1290    if matches!(
1291        node.kind(),
1292        "value_path" | "value_name" | "parenthesized_operator"
1293    ) {
1294        names.insert(normalize_source(node_text(node, source)));
1295        return;
1296    }
1297    if matches!(
1298        node.kind(),
1299        "expression_item"
1300            | "parenthesized_expression"
1301            | "typed_expression"
1302            | "coercion_expression"
1303            | "let_expression"
1304            | "local_open_expression"
1305    ) {
1306        if let Some(expression) = node
1307            .child_by_field_name("body")
1308            .or_else(|| node.child_by_field_name("expression"))
1309            .or_else(|| node.named_child(0))
1310        {
1311            collect_ocaml_callable_outcomes(expression, source, names);
1312        }
1313        return;
1314    }
1315    if node.kind() == "sequence_expression" {
1316        let mut cursor = node.walk();
1317        if let Some(last) = node.named_children(&mut cursor).last() {
1318            collect_ocaml_callable_outcomes(last, source, names);
1319        }
1320        return;
1321    }
1322    if node.kind() == "if_expression" {
1323        let mut cursor = node.walk();
1324        for clause in node
1325            .named_children(&mut cursor)
1326            .filter(|child| matches!(child.kind(), "then_clause" | "else_clause"))
1327        {
1328            if let Some(expression) = clause.child_by_field_name("expression") {
1329                collect_ocaml_callable_outcomes(expression, source, names);
1330            }
1331        }
1332        return;
1333    }
1334    if matches!(node.kind(), "match_expression" | "function_expression") {
1335        let mut cursor = node.walk();
1336        for case in node
1337            .named_children(&mut cursor)
1338            .filter(|child| child.kind() == "match_case")
1339        {
1340            if let Some(body) = case.child_by_field_name("body") {
1341                collect_ocaml_callable_outcomes(body, source, names);
1342            }
1343        }
1344    }
1345}
1346
1347fn ocaml_callable_node_flow(
1348    node: Node<'_>,
1349    source: &str,
1350    caller: &FunctionInfo,
1351    caller_context: &[CallableFlow],
1352    functions: &[FunctionInfo],
1353    parameters: &BTreeMap<SymbolId, Vec<String>>,
1354) -> Option<CallableFlow> {
1355    if matches!(
1356        node.kind(),
1357        "value_path" | "value_name" | "parenthesized_operator"
1358    ) {
1359        return ocaml_callable_atom_flow(
1360            node_text(node, source),
1361            caller,
1362            caller_context,
1363            functions,
1364            parameters,
1365        );
1366    }
1367    if matches!(
1368        node.kind(),
1369        "expression_item"
1370            | "parenthesized_expression"
1371            | "typed_expression"
1372            | "coercion_expression"
1373            | "let_expression"
1374            | "local_open_expression"
1375    ) {
1376        let expression = node
1377            .child_by_field_name("body")
1378            .or_else(|| node.child_by_field_name("expression"))
1379            .or_else(|| node.named_child(0))?;
1380        return ocaml_callable_node_flow(
1381            expression,
1382            source,
1383            caller,
1384            caller_context,
1385            functions,
1386            parameters,
1387        );
1388    }
1389    if node.kind() == "sequence_expression" {
1390        let mut cursor = node.walk();
1391        let last = node.named_children(&mut cursor).last()?;
1392        return ocaml_callable_node_flow(
1393            last,
1394            source,
1395            caller,
1396            caller_context,
1397            functions,
1398            parameters,
1399        );
1400    }
1401    if node.kind() == "if_expression" {
1402        let mut flow = CallableFlow::default();
1403        let mut branches = 0usize;
1404        let mut cursor = node.walk();
1405        for clause in node
1406            .named_children(&mut cursor)
1407            .filter(|child| matches!(child.kind(), "then_clause" | "else_clause"))
1408        {
1409            let Some(expression) = clause.child_by_field_name("expression") else {
1410                flow.unresolved_reasons
1411                    .insert(UnresolvedReason::UnsupportedConstruct);
1412                continue;
1413            };
1414            branches += 1;
1415            merge_callable_flow(
1416                &mut flow,
1417                ocaml_callable_node_flow(
1418                    expression,
1419                    source,
1420                    caller,
1421                    caller_context,
1422                    functions,
1423                    parameters,
1424                )
1425                .unwrap_or_else(opaque_callable_flow),
1426            );
1427        }
1428        if branches < 2 {
1429            flow.unresolved_reasons
1430                .insert(UnresolvedReason::OpaqueInput);
1431        }
1432        return Some(flow);
1433    }
1434    if matches!(node.kind(), "match_expression" | "function_expression") {
1435        let mut flow = CallableFlow::default();
1436        let mut branches = 0usize;
1437        let mut cursor = node.walk();
1438        for case in node
1439            .named_children(&mut cursor)
1440            .filter(|child| child.kind() == "match_case")
1441        {
1442            let Some(body) = case.child_by_field_name("body") else {
1443                flow.unresolved_reasons
1444                    .insert(UnresolvedReason::UnsupportedConstruct);
1445                continue;
1446            };
1447            branches += 1;
1448            merge_callable_flow(
1449                &mut flow,
1450                ocaml_callable_node_flow(
1451                    body,
1452                    source,
1453                    caller,
1454                    caller_context,
1455                    functions,
1456                    parameters,
1457                )
1458                .unwrap_or_else(opaque_callable_flow),
1459            );
1460        }
1461        return (branches > 0).then_some(flow);
1462    }
1463    None
1464}
1465
1466fn ocaml_callable_atom_flow(
1467    argument: &str,
1468    caller: &FunctionInfo,
1469    caller_context: &[CallableFlow],
1470    functions: &[FunctionInfo],
1471    parameters: &BTreeMap<SymbolId, Vec<String>>,
1472) -> Option<CallableFlow> {
1473    let argument = normalize_ocaml_callable_argument(argument);
1474    if let Some(target) = argument_callable_symbol(argument, &caller.id, functions) {
1475        return Some(CallableFlow {
1476            candidates: BTreeSet::from([target]),
1477            unresolved_reasons: BTreeSet::new(),
1478        });
1479    }
1480    parameters
1481        .get(&caller.id)
1482        .and_then(|parameters| {
1483            parameters
1484                .iter()
1485                .position(|parameter| parameter == argument)
1486        })
1487        .map(|index| {
1488            caller_context
1489                .get(index)
1490                .cloned()
1491                .unwrap_or_else(opaque_callable_flow)
1492        })
1493}
1494
1495fn opaque_callable_flow() -> CallableFlow {
1496    CallableFlow {
1497        candidates: BTreeSet::new(),
1498        unresolved_reasons: [UnresolvedReason::OpaqueInput].into_iter().collect(),
1499    }
1500}
1501
1502fn merge_callable_flow(destination: &mut CallableFlow, source: CallableFlow) {
1503    destination.candidates.extend(source.candidates);
1504    destination
1505        .unresolved_reasons
1506        .extend(source.unresolved_reasons);
1507}
1508
1509fn argument_callable_symbol(
1510    argument: &str,
1511    caller: &SymbolId,
1512    functions: &[FunctionInfo],
1513) -> Option<SymbolId> {
1514    let argument = normalize_ocaml_callable_argument(argument);
1515    let parts = argument
1516        .split('.')
1517        .map(ToOwned::to_owned)
1518        .collect::<Vec<_>>();
1519    resolve_ocaml_symbol(&parts, caller, functions)
1520}
1521
1522fn normalize_ocaml_callable_argument(argument: &str) -> &str {
1523    argument
1524        .trim_start_matches(['~', '?'])
1525        .split_once(':')
1526        .map_or(argument, |(_, value)| value)
1527        .trim_matches(['(', ')', ' '])
1528}
1529
1530fn ocaml_call_arguments(call: &CallSite) -> Vec<String> {
1531    let callee = match &call.syntax {
1532        CallSyntax::Path(parts) => parts.join("."),
1533        CallSyntax::SelfMethod(method) => method.clone(),
1534        CallSyntax::Method { receiver, method } => format!("{receiver}#{method}"),
1535    };
1536    let remainder = call
1537        .label
1538        .default
1539        .strip_prefix(&callee)
1540        .unwrap_or(&call.label.default)
1541        .trim();
1542    split_ocaml_arguments(remainder)
1543}
1544
1545fn split_ocaml_arguments(arguments: &str) -> Vec<String> {
1546    let mut result = Vec::new();
1547    let mut start = None;
1548    let mut delimiters = Vec::new();
1549    let mut in_string = false;
1550    let mut escaped = false;
1551    for (index, character) in arguments.char_indices() {
1552        if in_string {
1553            if escaped {
1554                escaped = false;
1555            } else if character == '\\' {
1556                escaped = true;
1557            } else if character == '"' {
1558                in_string = false;
1559            }
1560            continue;
1561        }
1562        match character {
1563            '"' => {
1564                in_string = true;
1565                start.get_or_insert(index);
1566            }
1567            '(' | '[' | '{' => {
1568                delimiters.push(character);
1569                start.get_or_insert(index);
1570            }
1571            ')' | ']' | '}' => {
1572                delimiters.pop();
1573            }
1574            character if character.is_whitespace() && delimiters.is_empty() => {
1575                if let Some(argument_start) = start.take() {
1576                    result.push(arguments[argument_start..index].to_owned());
1577                }
1578            }
1579            _ => {
1580                start.get_or_insert(index);
1581            }
1582        }
1583    }
1584    if let Some(argument_start) = start {
1585        result.push(arguments[argument_start..].to_owned());
1586    }
1587    result
1588}
1589
1590fn ocaml_candidate_call_label(function: &FunctionInfo, arguments: &[String]) -> String {
1591    let mut name = local_callable_source_name(function).to_owned();
1592    if function.id.container.is_none() && !function.id.module.is_empty() {
1593        name = function
1594            .id
1595            .module
1596            .iter()
1597            .chain(std::iter::once(&name))
1598            .cloned()
1599            .collect::<Vec<_>>()
1600            .join(".");
1601    }
1602    let mut label = if arguments.is_empty() {
1603        name
1604    } else {
1605        format!("{name} {}", arguments.join(" "))
1606    };
1607    if let Some(ordinal) = function
1608        .id
1609        .name
1610        .rsplit_once("#closure")
1611        .map(|(_, ordinal)| ordinal)
1612    {
1613        label.push_str(&format!(" [closure#{ordinal}]"));
1614    }
1615    label
1616}
1617
1618fn function_from_binding(
1619    file: &Path,
1620    module: &[String],
1621    binding: Node<'_>,
1622    source: &str,
1623    analysis: &mut FileAnalysis,
1624) -> Option<FunctionInfo> {
1625    let pattern = binding.child_by_field_name("pattern")?;
1626    if !matches!(pattern.kind(), "value_name" | "value_pattern") {
1627        return None;
1628    }
1629    let name = node_text(pattern, source).trim();
1630    if !is_value_identifier(name) {
1631        return None;
1632    }
1633
1634    let binding_body = binding.child_by_field_name("body")?;
1635    let mut parameters = direct_named_children(binding, "parameter");
1636    let body = match binding_body.kind() {
1637        "fun_expression" => {
1638            parameters.extend(direct_named_children(binding_body, "parameter"));
1639            binding_body
1640                .child_by_field_name("body")
1641                .unwrap_or(binding_body)
1642        }
1643        "function_expression" => {
1644            if parameters.is_empty() {
1645                parameters.push(binding_body);
1646            }
1647            binding_body
1648        }
1649        _ if parameters.is_empty() => return None,
1650        _ => binding_body,
1651    };
1652
1653    let id = SymbolId {
1654        language: LanguageId::new("ocaml"),
1655        module: module.to_vec(),
1656        container: None,
1657        name: name.to_owned(),
1658    };
1659    let parameter_labels = parameters
1660        .iter()
1661        .map(|parameter| {
1662            if parameter.kind() == "function_expression" {
1663                "_".to_owned()
1664            } else {
1665                parameter_default_label(*parameter, source)
1666            }
1667        })
1668        .collect::<Vec<_>>();
1669    let default_label = ocaml_declaration_label(&id, &parameter_labels);
1670    let typed_label = typed_declaration_label(&id, &parameters, source);
1671
1672    for (index, parameter) in parameters.iter().enumerate() {
1673        if parameter.kind() == "function_expression" {
1674            continue;
1675        }
1676        let key = parameter_labels[index].clone();
1677        let value = parameter_pattern_and_type(*parameter)
1678            .1
1679            .map(|node| normalize_source(node_text(node, source)))
1680            .unwrap_or_default();
1681        analysis.facts.push(LanguageFact {
1682            subject: id.clone(),
1683            namespace: LanguageId::new("ocaml"),
1684            kind: "parameter".to_owned(),
1685            key,
1686            value,
1687            span: tree_sitter_span(file, *parameter),
1688        });
1689    }
1690
1691    let mut calls = Vec::new();
1692    collect_calls(file, body, source, true, &mut calls);
1693
1694    Some(FunctionInfo {
1695        id,
1696        label: match typed_label {
1697            Some(typed) => CallLabel::with_types(default_label, typed),
1698            None => CallLabel::new(default_label),
1699        },
1700        // Without an .mli index, structure bindings are conservatively treated
1701        // as exported entry candidates. Explicit --entry remains exact.
1702        public: true,
1703        calls,
1704        span: tree_sitter_span(file, binding),
1705    })
1706}
1707
1708fn collect_calls(
1709    file: &Path,
1710    node: Node<'_>,
1711    source: &str,
1712    is_callable_body: bool,
1713    calls: &mut Vec<CallSite>,
1714) {
1715    if node.kind() == "application_expression" {
1716        if let Some(function) = node.child_by_field_name("function") {
1717            if let Some(parts) = ocaml_value_path(function, source) {
1718                let syntax = CallSyntax::Path(parts);
1719                let span = tree_sitter_span(file, node);
1720                calls.push(CallSite {
1721                    id: CallSiteId::source(&syntax, &span),
1722                    syntax,
1723                    target: CallTarget::Unresolved,
1724                    label: CallLabel::new(normalize_source(node_text(node, source))),
1725                    span,
1726                });
1727            } else if function.kind() == "method_invocation" {
1728                let syntax = CallSyntax::Path(vec![normalize_source(node_text(function, source))]);
1729                let span = tree_sitter_span(file, node);
1730                calls.push(CallSite {
1731                    id: CallSiteId::source(&syntax, &span),
1732                    syntax,
1733                    target: CallTarget::Dynamic {
1734                        dispatch: SymbolId {
1735                            language: LanguageId::new("ocaml"),
1736                            module: Vec::new(),
1737                            container: None,
1738                            name: format!(
1739                                "object-method@{}:{}",
1740                                node.start_position().row + 1,
1741                                node.start_position().column
1742                            ),
1743                        },
1744                        candidates: Vec::new(),
1745                        resolution: DispatchResolution::Unresolved,
1746                        evidence: DispatchEvidence::ExactFlow,
1747                        unresolved_reasons: [UnresolvedReason::OpaqueInput].into_iter().collect(),
1748                    },
1749                    label: CallLabel::new(normalize_source(node_text(node, source))),
1750                    span,
1751                });
1752            }
1753        }
1754    } else if !is_callable_body && matches!(node.kind(), "fun_expression" | "function_expression") {
1755        // A nested closure owns its calls. It will receive its own callable ID
1756        // when local callable extraction is added.
1757        return;
1758    } else if node.kind() == "let_binding" && !direct_named_children(node, "parameter").is_empty() {
1759        // Likewise, do not attribute a local function's body to its enclosing
1760        // function.
1761        return;
1762    }
1763
1764    let mut cursor = node.walk();
1765    for child in node.named_children(&mut cursor) {
1766        collect_calls(file, child, source, false, calls);
1767    }
1768}
1769
1770fn ocaml_value_path(function: Node<'_>, source: &str) -> Option<Vec<String>> {
1771    if !matches!(
1772        function.kind(),
1773        "value_path" | "value_name" | "parenthesized_operator"
1774    ) {
1775        return None;
1776    }
1777    let raw = normalize_source(node_text(function, source));
1778    let parts = raw
1779        .split('.')
1780        .map(str::trim)
1781        .filter(|part| !part.is_empty())
1782        .map(ToOwned::to_owned)
1783        .collect::<Vec<_>>();
1784    (!parts.is_empty()).then_some(parts)
1785}
1786
1787fn direct_named_child<'tree>(node: Node<'tree>, kind: &str) -> Option<Node<'tree>> {
1788    let mut cursor = node.walk();
1789    node.named_children(&mut cursor)
1790        .find(|child| child.kind() == kind)
1791}
1792
1793fn direct_named_children<'tree>(node: Node<'tree>, kind: &str) -> Vec<Node<'tree>> {
1794    let mut cursor = node.walk();
1795    node.named_children(&mut cursor)
1796        .filter(|child| child.kind() == kind)
1797        .collect()
1798}
1799
1800fn node_text<'a>(node: Node<'_>, source: &'a str) -> &'a str {
1801    node.utf8_text(source.as_bytes())
1802        .expect("tree-sitter nodes always point into the parsed UTF-8 source")
1803}
1804
1805fn ocaml_declaration_label(id: &SymbolId, parameters: &[String]) -> String {
1806    let name = id
1807        .module
1808        .iter()
1809        .chain(std::iter::once(&id.name))
1810        .cloned()
1811        .collect::<Vec<_>>()
1812        .join(".");
1813    if parameters.is_empty() {
1814        name
1815    } else {
1816        format!("{name} {}", parameters.join(" "))
1817    }
1818}
1819
1820fn typed_declaration_label(id: &SymbolId, parameters: &[Node<'_>], source: &str) -> Option<String> {
1821    let mut found_type = false;
1822    let labels = parameters
1823        .iter()
1824        .map(|parameter| {
1825            if parameter.kind() == "function_expression" {
1826                return "_".to_owned();
1827            }
1828            let (label, typed) = parameter_typed_label(*parameter, source);
1829            found_type |= typed;
1830            label
1831        })
1832        .collect::<Vec<_>>();
1833    found_type.then(|| ocaml_declaration_label(id, &labels))
1834}
1835
1836fn parameter_default_label(parameter: Node<'_>, source: &str) -> String {
1837    let (pattern_node, ty) = parameter_pattern_and_type(parameter);
1838    if ty.is_none() {
1839        return normalize_source(node_text(parameter, source));
1840    }
1841
1842    let pattern = pattern_node
1843        .map(|pattern| normalize_source(node_text(pattern, source)))
1844        .unwrap_or_else(|| "_".to_owned());
1845    let Some(label) = direct_named_child(parameter, "label_name") else {
1846        return pattern;
1847    };
1848    let marker = node_text(parameter, source)
1849        .trim_start()
1850        .chars()
1851        .next()
1852        .filter(|marker| matches!(marker, '~' | '?'))
1853        .unwrap_or('~');
1854    let label = normalize_source(node_text(label, source));
1855    if pattern == label {
1856        format!("{marker}{label}")
1857    } else {
1858        format!("{marker}{label}:{pattern}")
1859    }
1860}
1861
1862fn parameter_typed_label(parameter: Node<'_>, source: &str) -> (String, bool) {
1863    let (pattern_node, ty) = parameter_pattern_and_type(parameter);
1864    let Some(ty) = ty else {
1865        return (parameter_default_label(parameter, source), false);
1866    };
1867    let pattern = pattern_node
1868        .map(|pattern| normalize_source(node_text(pattern, source)))
1869        .unwrap_or_else(|| "_".to_owned());
1870    let annotated = format!("({pattern} : {})", normalize_source(node_text(ty, source)));
1871    let Some(label) = direct_named_child(parameter, "label_name") else {
1872        return (annotated, true);
1873    };
1874    let marker = node_text(parameter, source)
1875        .trim_start()
1876        .chars()
1877        .next()
1878        .filter(|marker| matches!(marker, '~' | '?'))
1879        .unwrap_or('~');
1880    (
1881        format!(
1882            "{marker}{}:{annotated}",
1883            normalize_source(node_text(label, source))
1884        ),
1885        true,
1886    )
1887}
1888
1889fn parameter_pattern_and_type<'tree>(
1890    parameter: Node<'tree>,
1891) -> (Option<Node<'tree>>, Option<Node<'tree>>) {
1892    let pattern = parameter.child_by_field_name("pattern");
1893    match pattern {
1894        Some(pattern) if pattern.kind() == "typed_pattern" => (
1895            pattern.child_by_field_name("pattern"),
1896            pattern.child_by_field_name("type"),
1897        ),
1898        pattern => (pattern, parameter.child_by_field_name("type")),
1899    }
1900}
1901
1902fn is_value_identifier(value: &str) -> bool {
1903    let mut chars = value.chars();
1904    chars
1905        .next()
1906        .is_some_and(|first| first == '_' || first.is_ascii_lowercase())
1907        && chars.all(|character| {
1908            character == '_' || character == '\'' || character.is_ascii_alphanumeric()
1909        })
1910}
1911
1912fn tree_sitter_span(file: &Path, node: Node<'_>) -> SourceSpan {
1913    SourceSpan {
1914        file: file.to_path_buf(),
1915        start_line: node.start_position().row + 1,
1916        start_column: node.start_position().column,
1917        start_byte: Some(node.start_byte()),
1918        end_line: node.end_position().row + 1,
1919        end_column: node.end_position().column,
1920        end_byte: Some(node.end_byte()),
1921    }
1922}
1923
1924fn normalize_source(source: &str) -> String {
1925    let mut output = String::with_capacity(source.len());
1926    let mut in_string = false;
1927    let mut escaped = false;
1928    let mut pending_space = false;
1929
1930    for character in source.chars() {
1931        if in_string {
1932            output.push(character);
1933            if escaped {
1934                escaped = false;
1935            } else if character == '\\' {
1936                escaped = true;
1937            } else if character == '"' {
1938                in_string = false;
1939            }
1940            continue;
1941        }
1942
1943        if character == '"' {
1944            if pending_space && !output.is_empty() {
1945                output.push(' ');
1946            }
1947            pending_space = false;
1948            in_string = true;
1949            output.push(character);
1950        } else if character.is_whitespace() {
1951            pending_space = true;
1952        } else {
1953            if pending_space && !output.is_empty() {
1954                output.push(' ');
1955            }
1956            pending_space = false;
1957            output.push(character);
1958        }
1959    }
1960    output.trim().to_owned()
1961}
1962
1963#[cfg(test)]
1964mod tests {
1965    use std::path::Path;
1966
1967    use super::*;
1968
1969    #[test]
1970    fn extracts_module_functions_and_native_call_labels() {
1971        let source = include_str!("../../examples/ocaml/after.ml");
1972        let analysis = OcamlFrontend
1973            .analyze_file(
1974                &FileContext {
1975                    path: Path::new("after.ml"),
1976                    module: &[],
1977                },
1978                source,
1979            )
1980            .unwrap();
1981
1982        let save = analysis
1983            .functions
1984            .iter()
1985            .find(|function| function.id.module == ["Postgres"] && function.id.name == "save")
1986            .unwrap();
1987        assert_eq!(save.label.default, "Postgres.save order");
1988        assert_eq!(
1989            save.calls
1990                .iter()
1991                .map(|call| call.label.default.as_str())
1992                .collect::<Vec<_>>(),
1993            ["Sql.begin_tx order", "Sql.insert order", "Sql.commit order"]
1994        );
1995    }
1996
1997    #[test]
1998    fn keeps_labeled_optional_and_unit_application_syntax() {
1999        let source = r#"
2000            let run order =
2001              charge ~currency:"KRW" order 100;
2002              find ?limit:None ~tenant:"acme" order;
2003              commit ()
2004        "#;
2005        let analysis = OcamlFrontend
2006            .analyze_file(
2007                &FileContext {
2008                    path: Path::new("calls.ml"),
2009                    module: &[],
2010                },
2011                source,
2012            )
2013            .unwrap();
2014        let calls = &analysis.functions[0].calls;
2015        assert_eq!(calls[0].label.default, "charge ~currency:\"KRW\" order 100");
2016        assert_eq!(
2017            calls[1].label.default,
2018            "find ?limit:None ~tenant:\"acme\" order"
2019        );
2020        assert_eq!(calls[2].label.default, "commit ()");
2021    }
2022
2023    #[test]
2024    fn separates_ocaml_parameter_types_from_the_default_label() {
2025        let source = "let run (order : Order.t) = validate order";
2026        let analysis = OcamlFrontend
2027            .analyze_file(
2028                &FileContext {
2029                    path: Path::new("typed.ml"),
2030                    module: &[],
2031                },
2032                source,
2033            )
2034            .unwrap();
2035        let label = &analysis.functions[0].label;
2036        assert_eq!(label.default, "run order");
2037        assert_eq!(label.typed.as_deref(), Some("run (order : Order.t)"));
2038    }
2039
2040    #[test]
2041    fn source_project_resolves_calls_across_ocaml_modules() {
2042        let sequence = OCAML_TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
2043        let directory = std::env::temp_dir().join(format!(
2044            "diffkit-ocaml-source-project-{}-{sequence}",
2045            std::process::id()
2046        ));
2047        fs::create_dir(&directory).unwrap();
2048        fs::write(directory.join("a.ml"), "let entry value = B.save value\n").unwrap();
2049        fs::write(directory.join("b.ml"), "let save value = value\n").unwrap();
2050
2051        let analysis = analyze_source_project(&directory).unwrap();
2052        let entry = analysis
2053            .functions
2054            .iter()
2055            .find(|function| function.id.module == ["A"] && function.id.name == "entry")
2056            .unwrap();
2057        assert!(matches!(
2058            &entry.calls[0].target,
2059            CallTarget::Direct(target) if target.module == ["B"] && target.name == "save"
2060        ));
2061        fs::remove_dir_all(directory).unwrap();
2062    }
2063
2064    #[test]
2065    fn maps_dune_build_locations_back_to_source_files() {
2066        let sequence = OCAML_TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
2067        let directory = std::env::temp_dir().join(format!(
2068            "diffkit-ocaml-dune-path-{}-{sequence}",
2069            std::process::id()
2070        ));
2071        fs::create_dir_all(directory.join("src")).unwrap();
2072        fs::write(directory.join("src/service.ml"), "let run () = ()\n").unwrap();
2073        let mapped = dune_source_path(&directory, &directory.join("_build/default/src/service.ml"));
2074        assert_eq!(mapped, Some(directory.join("src/service.ml")));
2075        fs::remove_dir_all(directory).unwrap();
2076    }
2077
2078    #[test]
2079    fn compiler_libs_adapter_compiles_when_ocaml_is_installed() {
2080        if Command::new("ocamlc").arg("-version").output().is_err() {
2081            return;
2082        }
2083        let extractor = OcamlExtractor::compile().unwrap();
2084        assert!(extractor.executable().is_file());
2085    }
2086
2087    #[test]
2088    fn splits_curried_inferred_types_without_splitting_nested_arrows() {
2089        assert_eq!(
2090            split_ocaml_function_type("('a -> 'b) -> label:int -> 'a -> 'b"),
2091            ["('a -> 'b)", "label:int", "'a", "'b"]
2092        );
2093    }
2094
2095    #[test]
2096    fn extracts_functions_declared_inside_a_functor_body() {
2097        let source = r#"
2098            module Make (Store : sig val save : int -> unit end) = struct
2099              let run value = Store.save value
2100            end
2101        "#;
2102        let analysis = OcamlFrontend
2103            .analyze_file(
2104                &FileContext {
2105                    path: Path::new("functor.ml"),
2106                    module: &[],
2107                },
2108                source,
2109            )
2110            .unwrap();
2111        assert!(
2112            analysis
2113                .functions
2114                .iter()
2115                .any(|function| { function.id.module == ["Make"] && function.id.name == "run" })
2116        );
2117    }
2118}