Skip to main content

logicaffeine_lsp/
rename.rs

1use std::collections::HashMap;
2use tower_lsp::lsp_types::{Position, Range, TextEdit, Url, WorkspaceEdit};
3
4use crate::document::DocumentState;
5
6/// Validate a proposed new name for a rename operation.
7/// Returns an error message if invalid, None if valid.
8pub fn validate_new_name(new_name: &str) -> Option<String> {
9    if new_name.is_empty() {
10        return Some("Name cannot be empty".to_string());
11    }
12    if new_name.chars().any(|c| c.is_whitespace()) {
13        return Some("Name cannot contain whitespace".to_string());
14    }
15    let first = new_name.chars().next().unwrap();
16    if !first.is_alphabetic() && first != '_' {
17        return Some("Name must start with a letter or underscore".to_string());
18    }
19    let reserved = [
20        "Let", "Set", "If", "While", "Repeat", "Return", "Show", "Give",
21        "Push", "Pop", "Call", "Inspect", "Check", "Assert", "Trust",
22        "Escape", "Be", "New", "Otherwise", "Else",
23    ];
24    if reserved.contains(&new_name) {
25        return Some(format!("'{}' is a reserved keyword", new_name));
26    }
27    None
28}
29
30/// Handle rename request.
31///
32/// Finds all definitions and references with the given name and returns
33/// a workspace edit that renames them all.
34pub fn rename(
35    doc: &DocumentState,
36    position: Position,
37    new_name: String,
38    uri: &Url,
39) -> Option<WorkspaceEdit> {
40    if validate_new_name(&new_name).is_some() {
41        return None;
42    }
43
44    let offset = doc.line_index.offset(position);
45
46    // Find the token at cursor
47    let token = doc.tokens.iter().find(|t| {
48        offset >= t.span.start && offset < t.span.end
49    })?;
50
51    let old_name = doc.source.get(token.span.start..token.span.end)?;
52
53    let mut edits = Vec::new();
54
55    // Rename definitions
56    for def in doc.symbol_index.definitions_of(old_name) {
57        if def.span != logicaffeine_language::token::Span::default() {
58            edits.push(TextEdit {
59                range: Range {
60                    start: doc.line_index.position(def.span.start),
61                    end: doc.line_index.position(def.span.end),
62                },
63                new_text: new_name.clone(),
64            });
65        }
66    }
67
68    // Rename references
69    for reference in doc.symbol_index.references_to(old_name) {
70        edits.push(TextEdit {
71            range: Range {
72                start: doc.line_index.position(reference.span.start),
73                end: doc.line_index.position(reference.span.end),
74            },
75            new_text: new_name.clone(),
76        });
77    }
78
79    if edits.is_empty() {
80        return None;
81    }
82
83    let mut changes = HashMap::new();
84    changes.insert(uri.clone(), edits);
85
86    Some(WorkspaceEdit {
87        changes: Some(changes),
88        ..Default::default()
89    })
90}
91
92/// Handle prepare rename request.
93///
94/// Returns the range and placeholder text for the rename.
95pub fn prepare_rename(
96    doc: &DocumentState,
97    position: Position,
98) -> Option<(Range, String)> {
99    let offset = doc.line_index.offset(position);
100
101    let token = doc.tokens.iter().find(|t| {
102        offset >= t.span.start && offset < t.span.end
103    })?;
104
105    let text = doc.source.get(token.span.start..token.span.end)?;
106
107    // Only allow renaming name-bearing tokens
108    match &token.kind {
109        logicaffeine_language::token::TokenType::Identifier
110        | logicaffeine_language::token::TokenType::ProperName(_)
111        | logicaffeine_language::token::TokenType::Noun(_)
112        | logicaffeine_language::token::TokenType::Adjective(_)
113        | logicaffeine_language::token::TokenType::Verb { .. } => {}
114        _ => return None,
115    }
116
117    Some((
118        Range {
119            start: doc.line_index.position(token.span.start),
120            end: doc.line_index.position(token.span.end),
121        },
122        text.to_string(),
123    ))
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use crate::document::DocumentState;
130
131    fn make_doc(source: &str) -> DocumentState {
132        DocumentState::new(source.to_string(), 1)
133    }
134
135    fn test_uri() -> Url {
136        Url::parse("file:///test.logos").unwrap()
137    }
138
139    #[test]
140    fn rename_variable_produces_edits() {
141        let doc = make_doc("## Main\n    Let x be 5.\n    Show x.\n");
142        // Position on "x" in "Show x."
143        let pos = Position { line: 2, character: 9 };
144        let result = rename(&doc, pos, "y".to_string(), &test_uri());
145        assert!(result.is_some(), "Expected rename edits");
146        let ws_edit = result.unwrap();
147        let changes = ws_edit.changes.unwrap();
148        let edits = changes.get(&test_uri()).unwrap();
149        assert!(edits.len() >= 2, "Expected edits for definition and reference, got {}", edits.len());
150        for edit in edits {
151            assert_eq!(edit.new_text, "y");
152        }
153    }
154
155    #[test]
156    fn rename_unknown_returns_none() {
157        let doc = make_doc("## Main\n    Let x be 5.\n");
158        // Position in whitespace
159        let pos = Position { line: 0, character: 50 };
160        let result = rename(&doc, pos, "y".to_string(), &test_uri());
161        assert!(result.is_none());
162    }
163
164    #[test]
165    fn prepare_rename_for_variable() {
166        let doc = make_doc("## Main\n    Let x be 5.\n    Show x.\n");
167        // Position on "x" in "Show x."
168        let pos = Position { line: 2, character: 9 };
169        let result = prepare_rename(&doc, pos);
170        assert!(result.is_some(), "Expected prepare_rename to succeed for variable 'x'");
171        let (range, text) = result.unwrap();
172        assert_eq!(text, "x");
173        assert_eq!(range.start.line, 2);
174    }
175
176    #[test]
177    fn rename_edits_at_correct_positions() {
178        let source = "## Main\n    Let x be 5.\n    Show x.\n";
179        let doc = make_doc(source);
180        let pos = Position { line: 2, character: 9 };
181        let result = rename(&doc, pos, "y".to_string(), &test_uri());
182        assert!(result.is_some());
183        let ws_edit = result.unwrap();
184        let changes = ws_edit.changes.unwrap();
185        let edits = changes.get(&test_uri()).unwrap();
186        for edit in edits {
187            let start = doc.line_index.offset(edit.range.start);
188            let end = doc.line_index.offset(edit.range.end);
189            let old_text = &source[start..end];
190            assert_eq!(old_text, "x", "Edit range should point to 'x', got '{}'", old_text);
191        }
192    }
193
194    #[test]
195    fn rename_includes_definition_and_references() {
196        let doc = make_doc("## Main\n    Let x be 5.\n    Show x.\n");
197        let pos = Position { line: 2, character: 9 };
198        let result = rename(&doc, pos, "y".to_string(), &test_uri());
199        assert!(result.is_some());
200        let ws_edit = result.unwrap();
201        let changes = ws_edit.changes.unwrap();
202        let edits = changes.get(&test_uri()).unwrap();
203        // Should include at least: 1 definition + 1+ references
204        assert!(edits.len() >= 2,
205            "Expected at least 2 edits (def + ref), got {}", edits.len());
206    }
207
208    #[test]
209    fn prepare_rename_rejects_more_tokens() {
210        let doc = make_doc("## Main\n    Let x be 5.\n");
211        // Position on "be" (a keyword)
212        let pos = Position { line: 1, character: 10 };
213        let result = prepare_rename(&doc, pos);
214        assert!(result.is_none(), "'Be' keyword should not be renameable");
215        // Position on "5" (a Number)
216        let pos2 = Position { line: 1, character: 14 };
217        let result2 = prepare_rename(&doc, pos2);
218        assert!(result2.is_none(), "Number should not be renameable");
219    }
220
221    #[test]
222    fn rename_rejects_empty_name() {
223        let doc = make_doc("## Main\n    Let x be 5.\n    Show x.\n");
224        let pos = Position { line: 2, character: 9 };
225        let result = rename(&doc, pos, "".to_string(), &test_uri());
226        assert!(result.is_none(), "Empty name should be rejected");
227    }
228
229    #[test]
230    fn rename_rejects_whitespace_name() {
231        let doc = make_doc("## Main\n    Let x be 5.\n    Show x.\n");
232        let pos = Position { line: 2, character: 9 };
233        let result = rename(&doc, pos, "foo bar".to_string(), &test_uri());
234        assert!(result.is_none(), "Name with spaces should be rejected");
235    }
236
237    #[test]
238    fn rename_rejects_reserved_keyword() {
239        let doc = make_doc("## Main\n    Let x be 5.\n    Show x.\n");
240        let pos = Position { line: 2, character: 9 };
241        let result = rename(&doc, pos, "Let".to_string(), &test_uri());
242        assert!(result.is_none(), "Reserved keyword should be rejected");
243    }
244
245    #[test]
246    fn rename_rejects_numeric_start() {
247        let doc = make_doc("## Main\n    Let x be 5.\n    Show x.\n");
248        let pos = Position { line: 2, character: 9 };
249        let result = rename(&doc, pos, "3abc".to_string(), &test_uri());
250        assert!(result.is_none(), "Name starting with digit should be rejected");
251    }
252
253    #[test]
254    fn rename_accepts_valid_name() {
255        assert!(validate_new_name("myVar").is_none());
256        assert!(validate_new_name("_private").is_none());
257        assert!(validate_new_name("x").is_none());
258    }
259
260    #[test]
261    fn prepare_rename_rejects_keywords() {
262        let doc = make_doc("## Main\n    Let x be 5.\n");
263        // Position on "Let" keyword (line 1, character 4)
264        let pos = Position { line: 1, character: 4 };
265        let result = prepare_rename(&doc, pos);
266        assert!(result.is_none(), "Keywords should not be renameable");
267    }
268}