use plg_shared::BUILTINS;
use tower_lsp::lsp_types::{GotoDefinitionResponse, Location, Position, Range, Url};
use crate::buffer::word_at_position;
use crate::completion::stdlib_predicates;
pub fn compute(content: &str, position: Position, uri: &Url) -> Option<GotoDefinitionResponse> {
let name = word_at_position(content, position)?;
if name.is_empty() || is_engine_provided(&name) {
return None;
}
find_first_clause_head(content, &name, uri).map(GotoDefinitionResponse::Scalar)
}
fn is_engine_provided(name: &str) -> bool {
if BUILTINS.iter().any(|s| s.name == name) {
return true;
}
stdlib_predicates().iter().any(|(n, _)| n == name)
}
fn find_first_clause_head(content: &str, name: &str, uri: &Url) -> Option<Location> {
for (line_idx, line) in content.lines().enumerate() {
if let Some(col) = head_match_column(line, name) {
return Some(Location {
uri: uri.clone(),
range: Range {
start: Position {
line: line_idx as u32,
character: col as u32,
},
end: Position {
line: line_idx as u32,
character: (col + name.chars().count()) as u32,
},
},
});
}
}
None
}
fn head_match_column(line: &str, name: &str) -> Option<usize> {
let leading_ws = line.len() - line.trim_start().len();
let trimmed = line.trim_start();
if !trimmed.starts_with(name) {
return None;
}
let after = &trimmed[name.len()..];
let next = after.chars().next();
let is_head = match next {
Some('(') => true,
Some('.') => true,
Some(' ') | Some('\t') => {
let rest = after.trim_start();
rest.starts_with(":-") || rest.starts_with('.') || rest.is_empty()
}
None => true, _ => false,
};
if is_head { Some(leading_ws) } else { None }
}
#[cfg(test)]
mod tests {
use super::*;
fn uri() -> Url {
Url::parse("file:///tmp/test.pl").unwrap()
}
fn pos(line: u32, character: u32) -> Position {
Position { line, character }
}
fn scalar(resp: GotoDefinitionResponse) -> Location {
match resp {
GotoDefinitionResponse::Scalar(loc) => loc,
other => panic!("expected Scalar, got {other:?}"),
}
}
#[test]
fn finds_single_compound_definition() {
let src = "violation(F, sensitive_field) :- field(F).\n";
let resp = compute(src, pos(0, 3), &uri()).expect("definition");
let loc = scalar(resp);
assert_eq!(loc.range.start, pos(0, 0));
assert_eq!(loc.range.end, pos(0, 9));
}
#[test]
fn returns_first_clause_for_multi_clause_predicate() {
let src = "sensitive(ssn).\nsensitive(password).\nuse :- sensitive(ssn).\n";
let resp = compute(src, pos(2, 10), &uri()).expect("definition");
let loc = scalar(resp);
assert_eq!(loc.range.start.line, 0, "should jump to FIRST clause");
}
#[test]
fn finds_atom_only_clause() {
let src = "red.\nblue.\n";
let resp = compute(src, pos(0, 1), &uri()).expect("definition");
let loc = scalar(resp);
assert_eq!(loc.range.start, pos(0, 0));
}
#[test]
fn skips_builtins() {
let src = "p :- findall(X, q(X), L).\n";
assert!(compute(src, pos(0, 7), &uri()).is_none());
}
#[test]
fn skips_stdlib_predicates() {
let src = "p :- member(X, [1,2,3]).\n";
assert!(compute(src, pos(0, 7), &uri()).is_none());
}
#[test]
fn returns_none_when_no_definition_exists() {
let src = "p :- undefined_predicate(X).\n";
assert!(compute(src, pos(0, 10), &uri()).is_none());
}
#[test]
fn skips_uses_inside_clause_bodies() {
let src = "violation(X) :- field(X).\np :- violation(foo).\n";
let loc = scalar(compute(src, pos(1, 8), &uri()).expect("definition"));
assert_eq!(loc.range.start.line, 0);
}
#[test]
fn skips_partial_word_matches() {
let src = "not_violation(X) :- true.\nviolation(X) :- field(X).\n";
let loc = scalar(compute(src, pos(1, 3), &uri()).expect("definition"));
assert_eq!(loc.range.start.line, 1);
}
#[test]
fn skips_comments_that_mention_the_name() {
let src = "% violation(X) — old shape\nviolation(X) :- field(X).\n";
let loc = scalar(compute(src, pos(1, 3), &uri()).expect("definition"));
assert_eq!(loc.range.start.line, 1, "should not match the comment");
}
}