1use std::collections::HashMap;
2use tower_lsp::lsp_types::{Position, Range, TextEdit, Url, WorkspaceEdit};
3
4use crate::document::DocumentState;
5
6pub 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
30pub 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 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 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 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
92pub 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 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 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 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 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 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 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 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 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}