diff --git a/crates/compass-languages/src/evidence/dart.rs b/crates/compass-languages/src/evidence/dart.rs index be3fc7db..d8f13014 100644 --- a/crates/compass-languages/src/evidence/dart.rs +++ b/crates/compass-languages/src/evidence/dart.rs @@ -26,6 +26,30 @@ impl LanguageProfile for Dart { } fn declaration_kind_for_node(node: Node<'_>, _source: &[u8]) -> Option<&'static str> { + if node.kind() == "method_signature" { + return dart_signature_kind(node); + } + if matches!( + node.kind(), + "constructor_signature" + | "factory_constructor_signature" + | "getter_signature" + | "setter_signature" + | "function_signature" + ) { + // These signatures are children of a method_signature wrapper in + // the Dart grammar. The wrapper owns the declaration range; keep + // the child from publishing a duplicate symbol. Top-level + // function_signature and declaration-wrapped constructors remain + // first-class evidence. + if node + .parent() + .is_some_and(|parent| parent.kind() == "method_signature") + { + return None; + } + return dart_signature_kind(node); + } if node.kind() == "declaration" && node.parent().is_some_and(|parent| { matches!( @@ -43,6 +67,25 @@ impl LanguageProfile for Dart { } fn declaration_name_nodes_for_node(node: Node<'_>) -> Vec> { + if node.kind() == "method_signature" { + return dart_signature_name(node).into_iter().collect(); + } + if matches!( + node.kind(), + "constructor_signature" + | "factory_constructor_signature" + | "getter_signature" + | "setter_signature" + | "function_signature" + ) { + if node + .parent() + .is_some_and(|parent| parent.kind() == "method_signature") + { + return Vec::new(); + } + return dart_signature_name(node).into_iter().collect(); + } if node.kind() == "declaration" { let mut cursor = node.walk(); if let Some(list) = node @@ -137,6 +180,66 @@ impl LanguageProfile for Dart { } } +fn dart_signature_inner(node: Node<'_>) -> Option> { + if node.kind() != "method_signature" { + return Some(node); + } + let mut cursor = node.walk(); + node.named_children(&mut cursor).find(|child| { + matches!( + child.kind(), + "constructor_signature" + | "factory_constructor_signature" + | "getter_signature" + | "setter_signature" + | "function_signature" + ) + }) +} + +fn dart_signature_kind(node: Node<'_>) -> Option<&'static str> { + let node = dart_signature_inner(node)?; + match node.kind() { + "constructor_signature" | "factory_constructor_signature" => Some("constructor"), + "getter_signature" | "setter_signature" => Some("property"), + "function_signature" => { + let mut current = node.parent(); + while let Some(parent) = current { + if matches!( + parent.kind(), + "class_body" | "extension_body" | "mixin_body" | "enum_body" + ) { + return Some("method"); + } + current = parent.parent(); + } + Some("function") + } + _ => None, + } +} + +fn dart_signature_name(node: Node<'_>) -> Option> { + let node = dart_signature_inner(node)?; + let mut identifiers = Vec::new(); + let mut cursor = node.walk(); + for child in node.named_children(&mut cursor) { + if child.kind() == "identifier" { + identifiers.push(child); + } + } + if matches!( + node.kind(), + "constructor_signature" | "factory_constructor_signature" + ) { + return identifiers + .get(1) + .copied() + .or_else(|| identifiers.first().copied()); + } + identifiers.first().copied() +} + pub(super) fn emit_tree_evidence( path: &Path, source_file: &str, diff --git a/crates/compass-languages/src/evidence/groovy.rs b/crates/compass-languages/src/evidence/groovy.rs index b4141e6e..5e367fdf 100644 --- a/crates/compass-languages/src/evidence/groovy.rs +++ b/crates/compass-languages/src/evidence/groovy.rs @@ -4,8 +4,9 @@ use std::path::Path; use tree_sitter::Node; +use super::build::range_for_byte_span; use super::model::{CandidateRelation, SemanticEvidenceBatch}; -use super::shared::{self, LanguageProfile, State}; +use super::shared::{self, LanguageProfile, ParsedImport, State}; use super::validate::EvidenceError; struct Groovy; @@ -17,6 +18,10 @@ impl LanguageProfile for Groovy { shared::package_name_from_source(source) } + fn parse_imports(statement: &str) -> Vec { + parse_groovy_import(statement) + } + fn has_source_supplement(declaration_count: usize) -> bool { declaration_count <= 1 } @@ -32,8 +37,13 @@ impl LanguageProfile for Groovy { fn should_collect_source_supplement(source: &[u8], declaration_count: usize) -> bool { Self::has_source_supplement(declaration_count) || std::str::from_utf8(source).is_ok_and(|text| { - text.lines() - .any(|line| groovy_spock_feature_declaration(line.trim()).is_some()) + text.lines().any(|line| { + let line = line.trim(); + line.starts_with("import ") + || line.contains(" extends ") + || line.contains(" implements ") + || groovy_spock_feature_declaration(line).is_some() + }) }) } @@ -60,6 +70,64 @@ pub(super) fn emit_tree_evidence( shared::emit_tree_evidence::(path, source_file, source, root) } +fn parse_groovy_import(statement: &str) -> Vec { + let trimmed = statement.trim().trim_end_matches(';').trim(); + let Some(rest) = trimmed.strip_prefix("import") else { + return Vec::new(); + }; + let mut rest = rest.trim(); + let _is_static = if let Some(value) = rest.strip_prefix("static") { + rest = value.trim(); + true + } else { + false + }; + let tokens = rest.split_whitespace().collect::>(); + let Some(target_token) = tokens.first() else { + return Vec::new(); + }; + let target = target_token.trim_matches(['\'', '"']).trim_end_matches(';'); + if target.is_empty() + || !target + .split('.') + .all(|part| part == "*" || shared::valid_name(part)) + { + return Vec::new(); + } + let alias = tokens + .windows(2) + .find(|pair| pair[0] == "as") + .map(|pair| pair[1].trim_matches(['\'', '"']).trim_end_matches(';')) + .filter(|value| shared::valid_name(value)); + if target.ends_with(".*") { + let prefix = target.trim_end_matches(".*"); + return vec![ParsedImport { + target: prefix.to_owned(), + binding_spelling: format!("{prefix}.*"), + local_spelling: alias.unwrap_or("*").to_owned(), + qualifier: Some(prefix.to_owned()), + alias: alias.is_some(), + prefix: true, + reexport: false, + }]; + } + let Some(spelling) = alias + .map(str::to_owned) + .or_else(|| target.rsplit('.').next().map(str::to_owned)) + else { + return Vec::new(); + }; + vec![ParsedImport { + target: target.to_owned(), + binding_spelling: spelling.clone(), + local_spelling: spelling, + qualifier: None, + alias: alias.is_some(), + prefix: false, + reexport: false, + }] +} + /// The pinned Groovy grammar intentionally exposes each top-level form as a /// bounded `command` node. Keep Groovy on the universal evidence route by /// extracting declaration/call spans from that command text rather than @@ -98,6 +166,19 @@ fn collect_groovy_source<'source>(state: &mut State<'source, Groovy>) -> Result< method = None; } + if trimmed.starts_with("import ") { + let trim_offset = line_without_newline + .len() + .saturating_sub(line_without_newline.trim_start().len()); + let range = range_for_byte_span( + state.source_file, + state.source, + line_start.saturating_add(trim_offset), + line_end, + ); + state.emit_imports(parse_groovy_import(trimmed), range)?; + } + if let Some((kind, name, name_offset)) = groovy_type_declaration(trimmed) { let parent = classes.last().map(|(index, _, _)| *index); let parent_scope = parent @@ -366,6 +447,13 @@ fn valid_groovy_type_name(value: &str) -> bool { fn groovy_method_declaration(line: &str) -> Option<(String, bool, usize)> { let open = line.find('(')?; let before = line.get(..open)?.trim_end(); + // A property initializer such as `List items = empty()` contains + // parentheses but is not a method declaration. Treating the call as a + // declaration invents a method target and makes imported static calls + // ambiguous. + if before.contains('=') { + return None; + } let name_end = before.len(); let name_start = before .char_indices() diff --git a/crates/compass-languages/src/evidence/scala.rs b/crates/compass-languages/src/evidence/scala.rs index d00ac62b..f389ddfb 100644 --- a/crates/compass-languages/src/evidence/scala.rs +++ b/crates/compass-languages/src/evidence/scala.rs @@ -32,7 +32,10 @@ impl LanguageProfile for Scala { // a real local field without a corresponding declaration. Walk only // the pattern child—not the initializer—to recover every binding // while deliberately ignoring type alternatives such as `Null`. - if matches!(node.kind(), "val_definition" | "var_definition") { + if matches!( + node.kind(), + "val_definition" | "var_definition" | "val_declaration" | "var_declaration" + ) { let mut names = Vec::new(); let mut cursor = node.walk(); for child in node.named_children(&mut cursor) { @@ -45,9 +48,77 @@ impl LanguageProfile for Scala { } } } + if node.kind().contains("function") || node.kind().contains("method") { + let mut cursor = node.walk(); + if let Some(operator) = node + .named_children(&mut cursor) + .find(|child| child.kind() == "operator_identifier") + { + return vec![operator]; + } + } shared::declaration_name(node).into_iter().collect() } + fn declaration_name_is_valid(name: &str) -> bool { + scala_reference_name(name) + } + + fn reference_name_is_valid(name: &str) -> bool { + scala_reference_name(name) + } + + fn is_type_reference_node(node: Node<'_>) -> bool { + matches!( + node.kind(), + "stable_type_identifier" + | "type_identifier" + | "simple_type" + | "user_type" + | "named_type" + | "type_reference" + | "class_type" + | "projected_type" + ) + } + + fn consumes_type_reference_children(node: Node<'_>) -> bool { + matches!(node.kind(), "stable_type_identifier" | "projected_type") + } + + fn split_type_reference(raw: &str) -> (Option, String) { + split_scala_type_reference(raw) + } + + fn qualified_type_reference_target<'source>( + state: &super::shared::State<'source, Self>, + node: Node<'_>, + raw: &str, + qualifier: Option<&str>, + spelling: &str, + ) -> Option { + let qualifier = qualifier?.trim_end_matches(".type"); + let root = qualifier.split(['.', '#']).next().unwrap_or_default(); + if root.is_empty() { + return None; + } + let base = state + .source_type_name(root) + .or_else(|| state.source_declared_type_at(root, node.start_byte()))?; + let suffix = qualifier + .get(root.len()..) + .unwrap_or_default() + .trim_start_matches(['.', '#']); + let mut target = base; + if !suffix.is_empty() { + target.push('.'); + target.push_str(suffix); + } + target.push(if raw.contains('#') { '#' } else { '.' }); + target.push_str(spelling); + Some(target) + } + fn parse_imports(statement: &str) -> Vec { parse_scala_import(statement) } @@ -216,16 +287,19 @@ fn collect_scala_receiver_calls<'source>( .len() .saturating_sub(line_without_newline.trim_start().len()); - if (trimmed.contains("def ") || trimmed.starts_with("extension")) - && let Some(owner_offset) = scala_definition_owner_offset(trimmed) - && let Some(owner) = state.source_callable_owner_for( - line_start - .saturating_add(trim_offset) - .saturating_add(owner_offset), - ) - { + let line_owner = state.source_callable_owner_for(line_start.saturating_add(trim_offset)); + if let Some(owner) = line_owner.or_else(|| { + scala_definition_owner_offset(trimmed).and_then(|owner_offset| { + state.source_callable_owner_for( + line_start + .saturating_add(trim_offset) + .saturating_add(owner_offset), + ) + }) + }) { let bindings = scala_typed_bindings(trimmed) .into_iter() + .chain(scala_value_typed_bindings(trimmed)) .filter_map(|(name, raw_type)| { state .source_type_name(&raw_type) @@ -233,11 +307,13 @@ fn collect_scala_receiver_calls<'source>( }) .collect::>(); if !bindings.is_empty() { - bindings_by_owner.insert(owner, bindings); + bindings_by_owner.entry(owner).or_default().extend(bindings); } } - for call in scala_calls(line_without_newline) { + let mut calls = scala_calls(line_without_newline); + calls.extend(scala_operator_calls(line_without_newline)); + for call in calls { let start = line_start.saturating_add(call.start); let end = line_start.saturating_add(call.end); let Some(owner) = state.source_callable_owner_for(start) else { @@ -264,9 +340,9 @@ fn collect_scala_receiver_calls<'source>( .and_then(|bindings| bindings.get(qualifier)) .cloned() .or_else(|| { - (!qualifier.contains('.')) - .then(|| state.source_type_name(qualifier)) - .flatten() + state + .source_type_name(qualifier) + .or_else(|| state.source_type_name_or_namespace(qualifier)) }) }) .or_else(|| { @@ -401,6 +477,108 @@ fn scala_typed_bindings(line: &str) -> Vec<(String, String)> { .collect() } +fn scala_value_typed_bindings(line: &str) -> Vec<(String, String)> { + let bytes = line.as_bytes(); + let mut bindings = Vec::new(); + for keyword in ["val", "var"] { + let mut search = 0_usize; + while let Some(relative) = line.get(search..).and_then(|rest| rest.find(keyword)) { + let start = search.saturating_add(relative); + let before_ok = + start == 0 || !scala_identifier_continue(bytes[start.saturating_sub(1)]); + let after = start.saturating_add(keyword.len()); + if !before_ok + || bytes + .get(after) + .is_some_and(|byte| scala_identifier_continue(*byte)) + { + search = after; + continue; + } + let rest = line.get(after..).unwrap_or_default().trim_start(); + let name_len = rest + .char_indices() + .take_while(|(_, character)| character.is_ascii_alphanumeric() || *character == '_') + .map(|(index, character)| index + character.len_utf8()) + .last() + .unwrap_or_default(); + let name = rest.get(..name_len).unwrap_or_default(); + let Some(colon) = rest.get(name_len..).and_then(|value| value.find(':')) else { + search = after; + continue; + }; + let type_start = name_len.saturating_add(colon).saturating_add(1); + let raw_type = rest + .get(type_start..) + .unwrap_or_default() + .split(['=', ';', '\n', '\r']) + .next() + .unwrap_or_default() + .trim(); + if scala_identifier(name) && !raw_type.is_empty() { + bindings.push((name.to_owned(), raw_type.to_owned())); + } + search = after; + } + } + bindings +} + +fn scala_operator_calls(line: &str) -> Vec { + let mut calls = Vec::new(); + let mut characters = line.char_indices().peekable(); + while let Some((start, character)) = characters.next() { + if !scala_operator_character(character) { + continue; + } + let mut end = start.saturating_add(character.len_utf8()); + while let Some(&(next_start, next_character)) = characters.peek() { + if !scala_operator_character(next_character) { + break; + } + end = next_start.saturating_add(next_character.len_utf8()); + characters.next(); + } + let spelling = line.get(start..end).unwrap_or_default(); + if spelling.is_empty() + || matches!( + spelling, + "=" | "=>" | "<-" | ":=" | "<:" | ":>" | "<%" | ">:" + ) + || spelling.contains('=') + { + continue; + } + let left = line.get(..start).unwrap_or_default().trim_end(); + if left.is_empty() + || left.ends_with("def") + || left.ends_with("val") + || left.ends_with("var") + { + continue; + } + let qualifier_start = left + .char_indices() + .rev() + .find(|(_, character)| { + !(character.is_ascii_alphanumeric() || *character == '_' || *character == '.') + }) + .map_or(0, |(index, character)| index + character.len_utf8()); + let qualifier = left.get(qualifier_start..).unwrap_or_default().trim(); + if !scala_identifier_or_path(qualifier) { + continue; + } + calls.push(ScalaCall { + qualifier: Some(qualifier.to_owned()), + spelling: spelling.to_owned(), + start, + end, + constructor: false, + }); + } + calls +} + fn scala_definition_owner_offset(line: &str) -> Option { let def_start = line .split_whitespace() @@ -461,3 +639,72 @@ fn scala_identifier_start(byte: u8) -> bool { fn scala_identifier_continue(byte: u8) -> bool { byte.is_ascii_alphanumeric() || byte == b'_' } + +fn scala_operator_character(character: char) -> bool { + matches!( + character, + '!' | '#' + | '%' + | '&' + | '*' + | '+' + | '-' + | '/' + | ':' + | '<' + | '=' + | '>' + | '?' + | '@' + | '^' + | '|' + | '~' + ) || matches!( + character, + '\u{2190}'..='\u{21ff}' + | '\u{2200}'..='\u{22ff}' + | '\u{2300}'..='\u{23ff}' + | '\u{2500}'..='\u{257f}' + | '\u{25a0}'..='\u{25ff}' + | '\u{2600}'..='\u{27ff}' + | '\u{2900}'..='\u{29ff}' + | '\u{2a00}'..='\u{2aff}' + | '\u{2b00}'..='\u{2bff}' + ) +} + +fn scala_reference_name(value: &str) -> bool { + let value = value.trim(); + (!value.is_empty() && value.len() <= 512) + && (shared::valid_name(value) || value.chars().all(scala_operator_character)) +} + +fn scala_identifier_or_path(value: &str) -> bool { + let value = value.trim(); + !value.is_empty() && value.split('.').all(scala_identifier) +} + +fn split_scala_type_reference(raw: &str) -> (Option, String) { + let cleaned = raw + .trim() + .trim_matches(['`', '\'', '"']) + .trim_end_matches(['?', '!']); + let separator = cleaned + .rfind('#') + .map(|index| (index, 1_usize)) + .or_else(|| cleaned.rfind("::").map(|index| (index, 2_usize))) + .or_else(|| cleaned.rfind('.').map(|index| (index, 1_usize))); + let Some((index, width)) = separator else { + return (None, cleaned.to_owned()); + }; + let qualifier = cleaned.get(..index).unwrap_or_default().trim(); + let spelling = cleaned + .get(index.saturating_add(width)..) + .unwrap_or_default() + .trim(); + if qualifier.is_empty() || spelling.is_empty() { + (None, cleaned.to_owned()) + } else { + (Some(qualifier.to_owned()), spelling.to_owned()) + } +} diff --git a/crates/compass-languages/src/evidence/shared.rs b/crates/compass-languages/src/evidence/shared.rs index c022808e..a563d2ee 100644 --- a/crates/compass-languages/src/evidence/shared.rs +++ b/crates/compass-languages/src/evidence/shared.rs @@ -75,6 +75,34 @@ pub(super) trait LanguageProfile: Sized { valid_name(name) } + /// Reference spellings can be broader than declaration names. Scala + /// symbolic methods are the first current example (`+`, `>>`, and so on). + fn reference_name_is_valid(name: &str) -> bool { + valid_name(name) + } + + fn is_type_reference_node(node: Node<'_>) -> bool { + is_type_leaf(node.kind()) + } + + fn consumes_type_reference_children(_node: Node<'_>) -> bool { + false + } + + fn split_type_reference(raw: &str) -> (Option, String) { + split_qualified(raw) + } + + fn qualified_type_reference_target<'source>( + _state: &State<'source, Self>, + _node: Node<'_>, + _raw: &str, + _qualifier: Option<&str>, + _spelling: &str, + ) -> Option { + None + } + fn ignores_type_reference(_spelling: &str) -> bool { false } @@ -596,7 +624,7 @@ impl<'source, P: LanguageProfile> State<'source, P> { .filter(|declaration| { declaration.start <= byte && byte < declaration.end - && is_nominal_type_kind(&declaration.kind) + && is_nominal_type_kind_for::

(&declaration.kind) }) .max_by_key(|declaration| declaration.start) .map(|declaration| declaration.qualified.clone()) @@ -612,12 +640,69 @@ impl<'source, P: LanguageProfile> State<'source, P> { let mut types = values .iter() .filter_map(|index| self.declarations.get(*index)) - .filter(|declaration| is_nominal_type_kind(&declaration.kind)) + .filter(|declaration| is_nominal_type_kind_for::

(&declaration.kind)) .map(|declaration| declaration.qualified.clone()) .collect::>(); (types.len() == 1).then(|| types.pop_first()).flatten() } + pub(super) fn source_declared_type_at(&self, name: &str, byte: usize) -> Option { + let declaration = self + .declarations + .iter() + .filter(|declaration| { + declaration.name == name + && declaration.start <= byte + && matches!(declaration.kind.as_str(), "field" | "property") + }) + .max_by_key(|declaration| declaration.start)?; + let text = std::str::from_utf8( + self.source + .get(declaration.start..declaration.end) + .unwrap_or_default(), + ) + .ok()?; + let raw_type = text + .split_once(':')? + .1 + .split(['=', ';', '{', '\n', '\r']) + .next()? + .trim() + .trim_end_matches('?') + .trim(); + (!raw_type.is_empty()) + .then(|| self.source_type_name(raw_type)) + .flatten() + } + + pub(super) fn source_import_target( + &self, + spelling: &str, + qualifier: Option<&str>, + ) -> Option { + let mut matches = self + .imports + .iter() + .filter(|import| { + if let Some(qualifier) = qualifier { + import.qualifier.as_deref() == Some(qualifier) + && (import.spelling == spelling || import.prefix) + } else { + (import.qualifier.is_none() && import.spelling == spelling) + || (import.prefix && import.spelling.ends_with(".*")) + } + }) + .map(|import| { + if import.prefix { + format!("{}.{}", import.target, spelling) + } else { + import.target.clone() + } + }) + .collect::>(); + (matches.len() == 1).then(|| matches.pop_first()).flatten() + } + pub(super) fn source_type_name_or_namespace(&self, raw: &str) -> Option { self.source_type_name(raw).or_else(|| { let (qualifier, spelling) = split_qualified(raw); @@ -638,7 +723,9 @@ impl<'source, P: LanguageProfile> State<'source, P> { start: usize, end: usize, ) -> Result<(), EvidenceError> { - if !self.supports(LanguageCapability::HierarchyDispatch) || !valid_name(spelling) { + if !self.supports(LanguageCapability::HierarchyDispatch) + || !P::reference_name_is_valid(spelling) + { return Ok(()); } let (owner_id, owner_scope) = { @@ -697,7 +784,7 @@ impl<'source, P: LanguageProfile> State<'source, P> { end: usize, constructor_node: bool, ) -> Result<(), EvidenceError> { - if !valid_name(spelling) + if !P::reference_name_is_valid(spelling) || (self.name_ranges.contains(&(start, end)) && !self.is_ignored_declaration_name(start, end)) { @@ -760,8 +847,8 @@ impl<'source, P: LanguageProfile> State<'source, P> { end: usize, base_set_complete: bool, ) -> Result<(), EvidenceError> { - let (qualifier, spelling) = split_qualified(raw_target); - if !valid_name(&spelling) { + let (qualifier, spelling) = P::split_type_reference(raw_target); + if !P::reference_name_is_valid(&spelling) { return Ok(()); } let (owner_id, owner_scope) = { @@ -782,6 +869,7 @@ impl<'source, P: LanguageProfile> State<'source, P> { let qualified_name = exact .and_then(|index| self.declarations.get(index)) .map(|declaration| declaration.qualified.clone()) + .or_else(|| self.source_import_target(&spelling, qualifier.as_deref())) .or_else(|| { qualifier .as_deref() @@ -1002,8 +1090,11 @@ impl<'source, P: LanguageProfile> State<'source, P> { if is_call_node(node.kind()) || self.is_identifier_call(node) { self.emit_call(node)?; } - if is_type_leaf(node.kind()) { + if P::is_type_reference_node(node) { self.emit_type_reference(node)?; + if P::consumes_type_reference_children(node) { + return Ok(()); + } } if self.supports(LanguageCapability::Members) && is_member_node(node.kind()) { self.emit_member_access(node)?; @@ -1028,8 +1119,8 @@ impl<'source, P: LanguageProfile> State<'source, P> { return Ok(()); }; let raw = self.text(callee); - let (qualifier, spelling) = split_qualified(&raw); - if !valid_name(&spelling) { + let (qualifier, spelling) = P::split_type_reference(&raw); + if !P::reference_name_is_valid(&spelling) { return Ok(()); } let owner = self @@ -1094,33 +1185,7 @@ impl<'source, P: LanguageProfile> State<'source, P> { .flatten() .or_else(|| self.resolve_local(spelling, qualifier)) }; - let imported_target = (qualifier.is_none()) - .then(|| { - self.imports - .iter() - .find(|import| { - import.spelling == spelling && import.qualifier.as_deref() == qualifier - }) - .map(|import| import.target.clone()) - }) - .flatten() - .or_else(|| { - qualifier.and_then(|prefix| { - self.imports - .iter() - .find(|import| { - (import.spelling == spelling || import.prefix) - && import.qualifier.as_deref() == Some(prefix) - }) - .map(|import| { - if import.prefix { - format!("{}.{}", import.target, spelling) - } else { - import.target.clone() - } - }) - }) - }); + let imported_target = self.source_import_target(spelling, qualifier); if exact.is_some() { self.resolved_occurrences .insert((role, range_start, range_end, spelling.to_owned())); @@ -1176,8 +1241,8 @@ impl<'source, P: LanguageProfile> State<'source, P> { if P::ignores_type_reference(&raw) { return Ok(()); } - let (qualifier, spelling) = split_qualified(&raw); - if !valid_name(&spelling) || spelling.len() > 256 { + let (qualifier, spelling) = P::split_type_reference(&raw); + if !P::reference_name_is_valid(&spelling) || spelling.len() > 256 { return Ok(()); } let owner = self.owner_for(node.start_byte()); @@ -1209,6 +1274,8 @@ impl<'source, P: LanguageProfile> State<'source, P> { range_for_node(self.source_file, node), )?; let exact = self.resolve_local(&spelling, qualifier.as_deref()); + let qualified_target = + P::qualified_type_reference_target(self, node, &raw, qualifier.as_deref(), &spelling); self.builder.relate( relation, &owner_id, @@ -1218,9 +1285,11 @@ impl<'source, P: LanguageProfile> State<'source, P> { ResolutionConstraint { exact_target_declaration_id: exact.map(|index| self.declarations[index].id.clone()), exact_language: Some(P::LANGUAGE.to_owned()), - qualified_name: qualifier - .as_ref() - .map(|prefix| format!("{prefix}.{spelling}")), + qualified_name: qualified_target.or_else(|| { + qualifier + .as_ref() + .map(|prefix| format!("{prefix}.{spelling}")) + }), allowed_target_kinds: vec![ "class".to_owned(), "enum".to_owned(), @@ -1246,7 +1315,7 @@ impl<'source, P: LanguageProfile> State<'source, P> { let spelling = spelling .trim_matches(|character: char| !character.is_ascii_alphanumeric() && character != '_'); let qualifier = qualifier.trim(); - if !valid_name(spelling) || qualifier.is_empty() { + if !P::reference_name_is_valid(spelling) || qualifier.is_empty() { return Ok(()); } let owner = self.owner_for(node.start_byte()); @@ -1528,7 +1597,13 @@ pub(super) fn shared_declaration_kind(kind: &str) -> Option<&'static str> { // phantom nested symbols and can make a real base target ambiguous. if matches!( lower.as_str(), - "interfaces" | "constructor_param" | "constructor_parameter" + "interfaces" + | "constructor_param" + | "constructor_parameter" + | "field_expression" + | "member_access" + | "property_access" + | "selector" ) || lower.starts_with("generics_") { // Type-shaped nodes (including generic/class type syntax) carry @@ -1654,6 +1729,10 @@ fn is_nominal_type_kind(kind: &str) -> bool { ) } +fn is_nominal_type_kind_for(kind: &str) -> bool { + is_nominal_type_kind(kind) || (P::LANGUAGE == "scala" && kind == "module") +} + fn join_name(prefix: &str, name: &str) -> String { if prefix.is_empty() { name.to_owned() diff --git a/crates/compass-languages/tests/language_wave_universal_conformance.rs b/crates/compass-languages/tests/language_wave_universal_conformance.rs index adb9c60a..97066bf4 100644 --- a/crates/compass-languages/tests/language_wave_universal_conformance.rs +++ b/crates/compass-languages/tests/language_wave_universal_conformance.rs @@ -368,7 +368,8 @@ class Generated {} #[test] fn dart_instance_fields_publish_field_declarations() -> Result<(), Box> { - let source = br#"library wave; + let source = br#"// GENERATED CODE - DO NOT MODIFY BY HAND +library wave; class UserStore { final String value, other; } @@ -718,3 +719,195 @@ class Store { ); Ok(()) } + +#[test] +fn scala_nested_objects_symbolic_calls_and_path_types_keep_identity() -> Result<(), Box> +{ + let source = r#"package sample +trait HasOuter { type Item } +class Uses { + val outer: HasOuter + def use(value: outer.Item): outer.Item = value + def project(value: HasOuter#Item): HasOuter#Item = value +} +object Outer { + object Inner { + def +(other: Inner): Inner = this + def combine(): Inner = this + } + def run(): Unit = { + val value: Inner = Inner + value + value + Inner.combine() + } +} +"#; + let mut engine = Engine::default(); + let evidence = engine.extract_source_universal_evidence( + Path::new("src/Models.scala"), + "src/Models.scala", + source.as_bytes(), + )?; + validate_evidence(&evidence, EvidenceLimits::default())?; + + assert!( + evidence.declarations.iter().any(|declaration| { + declaration.kind == "function" + && declaration.name == "+" + && declaration.qualified_name == "sample.Outer.Inner.+" + }), + "missing nested Scala symbolic method: {:?}", + evidence.declarations + ); + assert!( + evidence + .declarations + .iter() + .all(|declaration| declaration.qualified_name != "sample.Outer.run.Inner"), + "Scala initializer type became a phantom field: {:?}", + evidence.declarations + ); + assert!(evidence.declarations.iter().any(|declaration| { + declaration.name == "Inner" && declaration.qualified_name == "sample.Outer.Inner" + })); + assert!(evidence.candidates.iter().any(|candidate| { + candidate.relation == CandidateRelation::Owns && candidate.target_spelling == "+" + })); + assert!(evidence.candidates.iter().any(|candidate| { + candidate.relation == CandidateRelation::Calls + && candidate.target_spelling == "+" + && candidate.constraints.hierarchy.is_some() + && candidate + .constraints + .hierarchy + .as_ref() + .is_some_and(|hierarchy| { + matches!( + hierarchy, + compass_languages::HierarchyConstraint::ReceiverDispatch { + receiver_qualified_name, + .. + } if receiver_qualified_name == "sample.Outer.Inner" + ) + }) + })); + assert!(evidence.occurrences.iter().any(|occurrence| { + occurrence.role == SemanticRole::TypeReference + && occurrence.spelling == "Item" + && occurrence.qualifier.as_deref() == Some("outer") + })); + assert!(evidence.candidates.iter().any(|candidate| { + candidate.relation == CandidateRelation::References + && candidate.target_spelling == "Item" + && candidate.constraints.qualified_name.as_deref() == Some("sample.HasOuter.Item") + })); + assert!(evidence.candidates.iter().any(|candidate| { + candidate.relation == CandidateRelation::References + && candidate.target_spelling == "Item" + && candidate.constraints.qualified_name.as_deref() == Some("sample.HasOuter#Item") + })); + Ok(()) +} + +#[test] +fn dart_generated_signatures_recover_named_constructors_and_properties() +-> Result<(), Box> { + let source = br#"library wave; +part 'generated.g.dart'; +class User { + User._(); + factory User.fromJson(Map json) => User._(); + String get displayName => 'x'; + set displayName(String value) {} + User copyWith() => User._(); +} +User _$UserFromJson(Map json) => User._(); +"#; + let mut engine = Engine::default(); + let evidence = engine.extract_source_universal_evidence( + Path::new("lib/user.dart"), + "lib/user.dart", + source, + )?; + validate_evidence(&evidence, EvidenceLimits::default())?; + for (name, kind, qualified) in [ + ("_", "constructor", "wave.User._"), + ("fromJson", "constructor", "wave.User.fromJson"), + ("displayName", "property", "wave.User.displayName"), + ("copyWith", "method", "wave.User.copyWith"), + ("_$UserFromJson", "function", "wave._$UserFromJson"), + ] { + assert!( + evidence.declarations.iter().any(|declaration| { + declaration.name == name + && declaration.kind == kind + && declaration.qualified_name == qualified + }), + "missing Dart generated declaration {name} {kind}: {:#?}", + evidence.declarations + ); + } + assert!(evidence.candidates.iter().any(|candidate| { + candidate.relation == CandidateRelation::Embeds + && candidate.target_spelling == "generated.g.dart" + })); + Ok(()) +} + +#[test] +fn groovy_imports_and_base_types_use_exact_qualified_targets() -> Result<(), Box> { + let source = br#"package sample +import java.util.List +import static java.util.Collections.emptyList as empty +interface Contract {} +class Base {} +class Child extends Base implements Contract, List { + List values = empty() + Base make() { new Base() } +} +"#; + let mut engine = Engine::default(); + let evidence = engine.extract_source_universal_evidence( + Path::new("src/Child.groovy"), + "src/Child.groovy", + source, + )?; + validate_evidence(&evidence, EvidenceLimits::default())?; + assert!(evidence.bindings.iter().any(|binding| { + binding.kind == BindingKind::Import + && binding.spelling == "List" + && binding.qualified_target == "java.util.List" + })); + assert!(evidence.bindings.iter().any(|binding| { + binding.kind == BindingKind::ImportAlias + && binding.spelling == "empty" + && binding.qualified_target == "java.util.Collections.emptyList" + })); + for (relation, spelling, qualified) in [ + (CandidateRelation::Extends, "Base", Some("sample.Base")), + ( + CandidateRelation::Implements, + "Contract", + Some("sample.Contract"), + ), + ( + CandidateRelation::Implements, + "List", + Some("java.util.List"), + ), + ] { + assert!( + evidence.candidates.iter().any(|candidate| { + candidate.relation == relation + && candidate.target_spelling == spelling + && candidate.constraints.qualified_name.as_deref() == qualified + }), + "missing Groovy base candidate {relation:?} {spelling}: {:#?}", + evidence.candidates + ); + } + assert!(!evidence.declarations.iter().any(|declaration| { + declaration.name == "empty" && declaration.qualified_name == "sample.Child.empty" + })); + Ok(()) +} diff --git a/scripts/independent_language_oracle.py b/scripts/independent_language_oracle.py index 4c9c7df6..43a21239 100644 --- a/scripts/independent_language_oracle.py +++ b/scripts/independent_language_oracle.py @@ -416,33 +416,57 @@ def _scan_file( import_pattern = re.compile( r"\b(?:import|export|part|use)\s+([^;\n{}]+)", re.MULTILINE ) + imported_targets: dict[str, str] = {} for match in import_pattern.finditer(masked): words = match.group(1).strip().split() if not words: continue - target = words[0].strip("'\"") + static_import = language == "groovy" and words[0] == "static" + target_index = 1 if static_import else 0 + if target_index >= len(words): + continue + target = words[target_index].strip("'\"") if not target: continue + local_spelling = target.rsplit(".", 1)[-1].removesuffix(".*") + if language == "groovy": + alias_index = next( + ( + index + for index, word in enumerate(words[target_index + 1 :], target_index + 1) + if word == "as" and index + 1 < len(words) + ), + None, + ) + if alias_index is not None: + local_spelling = words[alias_index + 1].strip("'\"") + if local_spelling and target != "*": + imported_targets[local_spelling] = target.removesuffix(".*") + target_start = match.start(1) + if language == "groovy": + target_start += match.group(1).find(target) start, end, line = _byte_range( source, offsets, line_starts, - match.start(1), - match.start(1) + len(target), + target_start, + target_start + len(target), ) relation = "reexports" if match.group(0).lstrip().startswith("export") else "imports" - relations.append( - { - "relation": relation, - "capability": "imports", - "ownerQualifiedName": package or relative, - "targetSpelling": target, - "qualifier": None, - "startByte": start, - "endByte": end, - "startLine": line, - } - ) + import_relation = { + "relation": relation, + "capability": "imports", + "ownerQualifiedName": package or relative, + "targetSpelling": target, + "qualifier": None, + "startByte": start, + "endByte": end, + "startLine": line, + } + if language == "groovy": + import_relation["localSpelling"] = local_spelling + import_relation["qualifiedTarget"] = target.removesuffix(".*") + relations.append(import_relation) call_pattern = re.compile( rf"(?P{IDENTIFIER}(?:(?:\.|::|#){IDENTIFIER})*)\s*\(", @@ -493,31 +517,73 @@ def _scan_file( } ) - base_pattern = re.compile( - rf"\b(?:class|struct|enum|actor|trait|object|interface|extension)\s+(?P{IDENTIFIER})\s*:\s*(?P{IDENTIFIER}(?:(?:\.|::){IDENTIFIER})*)", - re.MULTILINE, - ) - for match in base_pattern.finditer(masked): - start, end, line = _byte_range( - source, - offsets, - line_starts, - match.start("base"), - match.end("base"), + if language == "groovy": + groovy_base_pattern = re.compile( + rf"\b(?:class|interface|trait|enum)\s+(?P{IDENTIFIER})(?P[^{{;\n]*)", + re.MULTILINE, ) - owner = _qualified_name(package, match.group("name")) or match.group("name") - relations.append( - { - "relation": "extends", - "capability": "inheritance", - "ownerQualifiedName": owner, - "targetSpelling": match.group("base"), - "qualifier": None, - "startByte": start, - "endByte": end, - "startLine": line, - } + for match in groovy_base_pattern.finditer(masked): + owner = _qualified_name(package, match.group("name")) or match.group("name") + clauses = match.group("clauses") + for clause in re.finditer( + rf"\b(?Pextends|implements)\s+(?P.*?)(?=\b(?:extends|implements)\b|$)", + clauses, + ): + values = re.sub(r"<[^>]*>", "", clause.group("values")) + for value_match in re.finditer( + rf"(?P{IDENTIFIER}(?:(?:\.|::){IDENTIFIER})*)", + values, + ): + base = value_match.group("base") + start_index = match.start("clauses") + clause.start("values") + value_match.start("base") + end_index = start_index + len(base) + start, end, line = _byte_range( + source, + offsets, + line_starts, + start_index, + end_index, + ) + relation = clause.group("keyword") + item = { + "relation": relation, + "capability": "inheritance", + "ownerQualifiedName": owner, + "targetSpelling": base, + "qualifier": None, + "startByte": start, + "endByte": end, + "startLine": line, + } + if base in imported_targets: + item["qualifiedTarget"] = imported_targets[base] + relations.append(item) + else: + base_pattern = re.compile( + rf"\b(?:class|struct|enum|actor|trait|object|interface|extension)\s+(?P{IDENTIFIER})\s*:\s*(?P{IDENTIFIER}(?:(?:\.|::){IDENTIFIER})*)", + re.MULTILINE, ) + for match in base_pattern.finditer(masked): + start, end, line = _byte_range( + source, + offsets, + line_starts, + match.start("base"), + match.end("base"), + ) + owner = _qualified_name(package, match.group("name")) or match.group("name") + relations.append( + { + "relation": "extends", + "capability": "inheritance", + "ownerQualifiedName": owner, + "targetSpelling": match.group("base"), + "qualifier": None, + "startByte": start, + "endByte": end, + "startLine": line, + } + ) declarations_json = [ { @@ -633,7 +699,7 @@ def _validate_provider_relation( qualifier = relation.get("qualifier") if qualifier is not None and not isinstance(qualifier, str): raise OracleError(f"parser provider qualifier in {relative} is not a string") - return { + normalized = { "relation": relation["relation"], "capability": relation["capability"], "ownerQualifiedName": relation["ownerQualifiedName"], @@ -643,6 +709,13 @@ def _validate_provider_relation( "endByte": end, "startLine": line, } + for field in ("localSpelling", "qualifiedTarget"): + value = relation.get(field) + if value is not None: + if not isinstance(value, str) or not value.strip(): + raise OracleError(f"parser provider {field} in {relative} is invalid") + normalized[field] = value + return normalized def _run_parser_provider( diff --git a/scripts/providers/groovy_oracle.java b/scripts/providers/groovy_oracle.java index b18eb78c..5727570d 100644 --- a/scripts/providers/groovy_oracle.java +++ b/scripts/providers/groovy_oracle.java @@ -47,6 +47,8 @@ private record Relation( String owner, String target, String qualifier, + String localSpelling, + String qualifiedTarget, int start, int end, int line) {} @@ -124,15 +126,18 @@ private static final class Emitter extends ClassCodeVisitorSupport { private final String path; private final String source; private final SourceText text; + private final String packageName; + private final Map importedTypes = new TreeMap<>(); private final List relations = new ArrayList<>(); private final List owners = new ArrayList<>(); private final Set emitted = new HashSet<>(); private SourceUnit sourceUnit; - Emitter(String path, String source, SourceText text) { + Emitter(String path, String source, SourceText text, String packageName) { this.path = path; this.source = source; this.text = text; + this.packageName = packageName == null ? "" : packageName.replaceAll("\\.+$", ""); } List relations() { @@ -148,12 +153,24 @@ void setSourceUnit(SourceUnit sourceUnit) { this.sourceUnit = sourceUnit; } + void registerTypeImport(String local, String target) { + if (local != null && !local.isEmpty() && target != null && !target.isEmpty()) { + importedTypes.put(local, target); + } + } + private String owner() { return owners.isEmpty() ? path : String.join(".", owners); } private void add(String relation, String capability, String target, ASTNode node, String qualifier, String explicitOwner) { + addQualified(relation, capability, target, node, qualifier, explicitOwner, null, null); + } + + private void addQualified(String relation, String capability, String target, ASTNode node, + String qualifier, String explicitOwner, + String localSpelling, String qualifiedTarget) { if (target == null || target.trim().isEmpty()) return; Span span = text.span(node); if (span == null || span.end() <= span.start()) return; @@ -163,7 +180,7 @@ private void add(String relation, String capability, String target, ASTNode node + span.start() + "\u0000" + span.end(); if (emitted.add(key)) { relations.add(new Relation(relation, capability, owner, cleanTarget, qualifier, - span.start(), span.end(), span.line())); + localSpelling, qualifiedTarget, span.start(), span.end(), span.line())); } } @@ -183,6 +200,15 @@ private String typeName(ClassNode node) { return dollar >= 0 ? name.substring(dollar + 1) : node.getNameWithoutPackage(); } + private String qualifiedTypeName(ClassNode node) { + if (node == null || node.getName() == null || node.getName().isEmpty()) return ""; + String name = node.getName(); + String imported = importedTypes.get(name); + if (imported != null && !imported.isEmpty()) return imported; + if (name.indexOf('.') >= 0 || packageName.isEmpty()) return name; + return packageName + "." + name; + } + private void enter(String name, ASTNode node) { declaration(name, node); owners.add(name); @@ -207,10 +233,12 @@ public void visitClass(ClassNode node) { enter(name, node); ClassNode superClass = node.getUnresolvedSuperClass(); if (superClass != null && !"java.lang.Object".equals(superClass.getName())) { - add("extends", "base_types", typeName(superClass), node, null, owner()); + addQualified("extends", "base_types", typeName(superClass), node, null, owner(), + null, qualifiedTypeName(superClass)); } for (ClassNode iface : node.getInterfaces()) { - add("implements", "base_types", typeName(iface), node, null, owner()); + addQualified("implements", "base_types", typeName(iface), node, null, owner(), + null, qualifiedTypeName(iface)); } super.visitClass(node); leave(); @@ -330,6 +358,8 @@ private static String relationJson(Relation relation) { + ",\"ownerQualifiedName\":" + json(relation.owner()) + ",\"targetSpelling\":" + json(relation.target()) + ",\"qualifier\":" + json(relation.qualifier()) + + (relation.localSpelling() == null ? "" : ",\"localSpelling\":" + json(relation.localSpelling())) + + (relation.qualifiedTarget() == null ? "" : ",\"qualifiedTarget\":" + json(relation.qualifiedTarget())) + ",\"startByte\":" + relation.start() + ",\"endByte\":" + relation.end() + ",\"startLine\":" + relation.line() + "}"; @@ -350,19 +380,51 @@ private static String fileJson(Path root, String relative) { SourceUnit sourceUnit = unit.addSource(relative, source); unit.compile(Phases.CONVERSION); ModuleNode module = sourceUnit.getAST(); - Emitter emitter = new Emitter(relative, source, new SourceText(source)); + Emitter emitter = new Emitter(relative, source, new SourceText(source), module.getPackageName()); emitter.setSourceUnit(sourceUnit); emitter.visitImports(module); - for (ImportNode ignored : module.getImports()) { - // Imports are emitted below with the exact AST import span. - } for (ImportNode importNode : module.getImports()) { - String target = importNode.getClassName(); - if (target != null && !target.isEmpty()) emitter.add("imports", "imports", target, importNode, null, null); + String className = importNode.getClassName(); + if (className == null || className.isEmpty()) continue; + String fieldName = importNode.getFieldName(); + String target = importNode.isStatic() && fieldName != null && !fieldName.isEmpty() + ? className + "." + fieldName + : className; + String local = importNode.getAlias(); + if (local == null || local.isEmpty()) { + local = importNode.isStatic() && fieldName != null && !fieldName.isEmpty() + ? fieldName + : className.substring(className.lastIndexOf('.') + 1); + } + if (!importNode.isStatic()) emitter.registerTypeImport(local, target); + emitter.addQualified("imports", "imports", target, importNode, null, null, + local, target); + } + for (Map.Entry entry : module.getStaticImports().entrySet()) { + ImportNode importNode = entry.getValue(); + String className = importNode.getClassName(); + String fieldName = importNode.getFieldName(); + if (className == null || className.isEmpty() + || fieldName == null || fieldName.isEmpty()) continue; + String target = className + "." + fieldName; + String local = importNode.getAlias(); + if (local == null || local.isEmpty()) local = entry.getKey(); + emitter.addQualified("imports", "imports", target, importNode, null, null, + local, target); } for (ImportNode importNode : module.getStarImports()) { String target = importNode.getPackageName(); - if (target != null && !target.isEmpty()) emitter.add("imports", "imports", target, importNode, null, null); + if (target != null && !target.isEmpty()) { + emitter.addQualified("imports", "imports", target, importNode, null, null, + "*", target); + } + } + for (ImportNode importNode : module.getStaticStarImports().values()) { + String target = importNode.getClassName(); + if (target != null && !target.isEmpty()) { + emitter.addQualified("imports", "imports", target, importNode, null, null, + "*", target); + } } for (ClassNode classNode : module.getClasses()) emitter.visitClass(classNode); for (MethodNode method : module.getMethods()) emitter.visitMethod(method); diff --git a/scripts/tests/test_universal_source_oracle.py b/scripts/tests/test_universal_source_oracle.py index 02e63c38..fb7a15be 100644 --- a/scripts/tests/test_universal_source_oracle.py +++ b/scripts/tests/test_universal_source_oracle.py @@ -251,6 +251,60 @@ def test_include_and_exclude_globs_define_the_complete_inventory(self) -> None: ) self.assertEqual(["lib/main.swift"], [item["path"] for item in document["files"]]) + def test_groovy_oracle_preserves_import_aliases_and_all_base_clauses(self) -> None: + with tempfile.TemporaryDirectory(prefix="compass-groovy-oracle-bases-") as directory: + root = Path(directory) + source = root / "Child.groovy" + source.write_text( + """package sample +import java.util.List +import static java.util.Collections.emptyList as empty +interface Contract {} +class Base {} +class Child extends Base implements Contract, List { + List values = empty() +} +""", + encoding="utf-8", + ) + document = run_oracle( + root, + language="groovy", + provider="groovy-provider", + toolchain="pinned test toolchain", + suffixes=(".groovy",), + ) + relations = document["files"][0]["relations"] + self.assertTrue( + any( + relation["relation"] == "imports" + and relation.get("localSpelling") == "List" + and relation.get("qualifiedTarget") == "java.util.List" + for relation in relations + ) + ) + self.assertTrue( + any( + relation["relation"] == "imports" + and relation.get("localSpelling") == "empty" + and relation.get("qualifiedTarget") + == "java.util.Collections.emptyList" + for relation in relations + ) + ) + self.assertEqual( + { + (relation["relation"], relation["targetSpelling"]) + for relation in relations + if relation["capability"] == "inheritance" + }, + { + ("extends", "Base"), + ("implements", "Contract"), + ("implements", "List"), + }, + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/qualification/language-wave/groovy/ImportsAndBases.groovy b/tests/qualification/language-wave/groovy/ImportsAndBases.groovy new file mode 100644 index 00000000..9e558bda --- /dev/null +++ b/tests/qualification/language-wave/groovy/ImportsAndBases.groovy @@ -0,0 +1,11 @@ +package wave + +import java.util.List +import static java.util.Collections.emptyList as empty + +interface Contract {} +class Base {} +class Child extends Base implements Contract, List { + List values = empty() + Base make() { new Base() } +}