Skip to main content

logicaffeine_lsp/
definition.rs

1use tower_lsp::lsp_types::{GotoDefinitionResponse, Location, Position, Range, Url};
2
3use crate::document::DocumentState;
4
5/// Handle go-to-definition request.
6///
7/// Given a cursor position, find the token at that position, look up its
8/// name in the symbol index, and return the definition's location.
9pub fn goto_definition(
10    doc: &DocumentState,
11    position: Position,
12    uri: &Url,
13) -> Option<GotoDefinitionResponse> {
14    let offset = doc.line_index.offset(position);
15
16    // Find the token at the cursor position
17    let token = doc.tokens.iter().find(|t| {
18        offset >= t.span.start && offset < t.span.end
19    })?;
20
21    let name = doc.source.get(token.span.start..token.span.end)?;
22
23    // Look up definitions for this name
24    let defs = doc.symbol_index.definitions_of(name);
25    if defs.is_empty() {
26        return None;
27    }
28
29    let locations: Vec<Location> = defs
30        .iter()
31        .filter(|d| d.span != logicaffeine_language::token::Span::default())
32        .map(|d| Location {
33            uri: uri.clone(),
34            range: Range {
35                start: doc.line_index.position(d.span.start),
36                end: doc.line_index.position(d.span.end),
37            },
38        })
39        .collect();
40
41    if locations.is_empty() {
42        None
43    } else if locations.len() == 1 {
44        Some(GotoDefinitionResponse::Scalar(locations.into_iter().next().unwrap()))
45    } else {
46        Some(GotoDefinitionResponse::Array(locations))
47    }
48}
49
50#[cfg(test)]
51mod tests {
52    use super::*;
53    use crate::document::DocumentState;
54
55    fn make_doc(source: &str) -> DocumentState {
56        DocumentState::new(source.to_string(), 1)
57    }
58
59    fn test_uri() -> Url {
60        Url::parse("file:///test.logos").unwrap()
61    }
62
63    #[test]
64    fn goto_definition_of_variable() {
65        let doc = make_doc("## Main\n    Let x be 5.\n    Show x.\n");
66        // Position on "x" in "Show x." (line 2, character 9)
67        let pos = Position { line: 2, character: 9 };
68        let result = goto_definition(&doc, pos, &test_uri());
69        assert!(result.is_some(), "Expected definition for 'x'");
70        match result.unwrap() {
71            GotoDefinitionResponse::Scalar(loc) => {
72                assert_eq!(loc.range.start.line, 1, "Definition should be on line 1");
73            }
74            GotoDefinitionResponse::Array(locs) => {
75                assert!(!locs.is_empty(), "Expected at least one location");
76                assert_eq!(locs[0].range.start.line, 1, "Definition should be on line 1");
77            }
78            _ => panic!("Unexpected response type"),
79        }
80    }
81
82    #[test]
83    fn goto_definition_whitespace_returns_none() {
84        let doc = make_doc("## Main\n    Let x be 5.\n");
85        // Position past end of source → no token
86        let pos = Position { line: 0, character: 50 };
87        let result = goto_definition(&doc, pos, &test_uri());
88        assert!(result.is_none(), "Position past end of source should return None");
89    }
90
91    #[test]
92    fn goto_def_returns_none_for_keyword() {
93        let doc = make_doc("## Main\n    Let x be 5.\n");
94        // Position on "Let" keyword — 'Let' is a keyword, not a definition name
95        let pos = Position { line: 1, character: 4 };
96        let result = goto_definition(&doc, pos, &test_uri());
97        // "Let" is a keyword; it might or might not have a definition.
98        // The important thing is it doesn't panic and returns a sensible result.
99        if let Some(resp) = &result {
100            match resp {
101                GotoDefinitionResponse::Scalar(loc) => {
102                    assert_ne!(loc.range.start, loc.range.end, "Should have a non-empty range");
103                }
104                GotoDefinitionResponse::Array(locs) => {
105                    assert!(!locs.is_empty());
106                }
107                _ => {}
108            }
109        }
110    }
111
112    #[test]
113    fn goto_def_correct_span_range() {
114        let doc = make_doc("## Main\n    Let x be 5.\n    Show x.\n");
115        let pos = Position { line: 2, character: 9 };
116        let result = goto_definition(&doc, pos, &test_uri());
117        assert!(result.is_some(), "Expected definition for 'x'");
118        match result.unwrap() {
119            GotoDefinitionResponse::Scalar(loc) => {
120                // 'x' is a single character, so the range should span exactly 1 char
121                assert_eq!(loc.range.start.line, loc.range.end.line, "Single-char name should be on same line");
122                let char_diff = loc.range.end.character - loc.range.start.character;
123                assert_eq!(char_diff, 1, "Range should span exactly 1 character for 'x', got {}", char_diff);
124            }
125            GotoDefinitionResponse::Array(locs) => {
126                let loc = &locs[0];
127                let char_diff = loc.range.end.character - loc.range.start.character;
128                assert_eq!(char_diff, 1, "Range should span exactly 1 character for 'x', got {}", char_diff);
129            }
130            _ => panic!("Unexpected response type"),
131        }
132    }
133
134    #[test]
135    fn goto_definition_returns_correct_uri() {
136        let doc = make_doc("## Main\n    Let x be 5.\n    Show x.\n");
137        let uri = test_uri();
138        let pos = Position { line: 2, character: 9 };
139        if let Some(GotoDefinitionResponse::Scalar(loc)) = goto_definition(&doc, pos, &uri) {
140            assert_eq!(loc.uri, uri);
141        }
142    }
143}