Skip to main content

logicaffeine_lsp/
rename.rs

1use lsp_types::{Position, Range, TextEdit, Url, WorkspaceEdit};
2use std::collections::HashMap;
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",
21        "Set",
22        "If",
23        "While",
24        "Repeat",
25        "Return",
26        "Show",
27        "Give",
28        "Push",
29        "Pop",
30        "Call",
31        "Inspect",
32        "Check",
33        "Assert",
34        "Trust",
35        "Escape",
36        "Be",
37        "New",
38        "Otherwise",
39        "Else",
40    ];
41    if reserved.contains(&new_name) {
42        return Some(format!("'{}' is a reserved keyword", new_name));
43    }
44    None
45}
46
47/// Handle rename request.
48///
49/// Finds all definitions and references with the given name and returns
50/// a workspace edit that renames them all.
51pub fn rename(
52    doc: &DocumentState,
53    position: Position,
54    new_name: String,
55    uri: &Url,
56) -> Option<WorkspaceEdit> {
57    if validate_new_name(&new_name).is_some() {
58        return None;
59    }
60
61    let offset = doc.line_index.offset(position);
62
63    // ★ THROUGH THE SHARED RULE, so a name written inside a `View:` renames like any other. The
64    // token under the cursor there is the whole `RawBlock`, which is not a name anything defines.
65    let (old_name, _) = crate::document::name_at(doc, offset)?;
66    let old_name = old_name.as_str();
67
68    let mut edits = Vec::new();
69
70    // Rename definitions
71    for def in doc.symbol_index.definitions_of(old_name) {
72        if def.span != logicaffeine_language::token::Span::default() {
73            edits.push(TextEdit {
74                range: Range {
75                    start: doc.line_index.position(def.span.start),
76                    end: doc.line_index.position(def.span.end),
77                },
78                new_text: new_name.clone(),
79            });
80        }
81    }
82
83    // Rename references
84    for reference in doc.symbol_index.references_to(old_name) {
85        edits.push(TextEdit {
86            range: Range {
87                start: doc.line_index.position(reference.span.start),
88                end: doc.line_index.position(reference.span.end),
89            },
90            new_text: new_name.clone(),
91        });
92    }
93
94    if edits.is_empty() {
95        return None;
96    }
97
98    let mut changes = HashMap::new();
99    changes.insert(uri.clone(), edits);
100
101    Some(WorkspaceEdit {
102        changes: Some(changes),
103        ..Default::default()
104    })
105}
106
107/// Handle prepare rename request.
108///
109/// Returns the range and placeholder text for the rename.
110pub fn prepare_rename(doc: &DocumentState, position: Position) -> Option<(Range, String)> {
111    let offset = doc.line_index.offset(position);
112
113    // ★ A NAME RECORDED FROM INSIDE A TEMPLATE IS ALREADY KNOWN TO BE A NAME — the parser put it
114    // there because the template MENTIONS it. So the token-kind gate below, which exists to stop
115    // an author renaming a period, is asked only of a name that came from a token in the first
116    // place; asking it of a `RawBlock` would refuse every rename inside a `View:`.
117    if let Some(reference) = doc
118        .symbol_index
119        .references
120        .iter()
121        .find(|r| offset >= r.span.start && offset < r.span.end)
122    {
123        return Some((
124            Range {
125                start: doc.line_index.position(reference.span.start),
126                end: doc.line_index.position(reference.span.end),
127            },
128            reference.name.clone(),
129        ));
130    }
131
132    let token = doc
133        .tokens
134        .iter()
135        .find(|t| offset >= t.span.start && offset < t.span.end)?;
136
137    let text = doc.source.get(token.span.start..token.span.end)?;
138
139    // Only allow renaming name-bearing tokens
140    match &token.kind {
141        logicaffeine_language::token::TokenType::Identifier
142        | logicaffeine_language::token::TokenType::ProperName(_)
143        | logicaffeine_language::token::TokenType::Noun(_)
144        | logicaffeine_language::token::TokenType::Adjective(_)
145        | logicaffeine_language::token::TokenType::Verb { .. } => {}
146        _ => return None,
147    }
148
149    Some((
150        Range {
151            start: doc.line_index.position(token.span.start),
152            end: doc.line_index.position(token.span.end),
153        },
154        text.to_string(),
155    ))
156}
157
158#[cfg(test)]
159#[path = "../../logicaffeine_tests/unit/logicaffeine_lsp/rename/tests.rs"]
160mod tests;