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#[derive(Default)]
24pub struct OcamlFrontend;
25
26pub static OCAML_BACKEND: OcamlFrontend = OcamlFrontend;
27
28pub 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
100pub 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 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(); 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 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 ¶meters,
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, ¶meter_labels);
1670 let typed_label = typed_declaration_label(&id, ¶meters, 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 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 return;
1758 } else if node.kind() == "let_binding" && !direct_named_children(node, "parameter").is_empty() {
1759 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}