Skip to main content

bock_lsp/
rename.rs

1//! `textDocument/rename` support — validation of candidate names.
2//!
3//! The edit set itself comes from [`crate::references::find_occurrences`];
4//! this module owns the rule for whether a proposed new name is legal:
5//!
6//! - it must lex as a single Bock identifier (same character rules as the
7//!   lexer: alphabetic or `_` first, alphanumeric or `_` after);
8//! - it must not be a reserved keyword (`fn`, `let`, `match`, `Self`,
9//!   `Ok`, …) or the bare wildcard `_`;
10//! - it must keep the name's capitalization class. Bock's lexer assigns
11//!   uppercase-initial names a different token kind (`TypeIdent`) than
12//!   other names (`Ident`), so changing the class would change how every
13//!   occurrence re-lexes and break the program.
14
15use bock_lexer::keyword_lookup;
16use thiserror::Error;
17
18/// Why a proposed rename target is not a usable Bock name.
19#[derive(Debug, Clone, PartialEq, Eq, Error)]
20pub enum RenameError {
21    /// The new name does not lex as a single Bock identifier.
22    #[error("`{0}` is not a valid Bock identifier")]
23    InvalidIdentifier(String),
24    /// The new name is a reserved keyword.
25    #[error("`{0}` is a reserved Bock keyword and cannot be used as a name")]
26    ReservedKeyword(String),
27    /// The new name changes the capitalization class of the old name.
28    /// Uppercase-initial names lex as type identifiers; everything else
29    /// lexes as value identifiers — renaming across that boundary would
30    /// change how every occurrence parses.
31    #[error(
32        "`{new}` does not match the capitalization of `{old}`: names starting \
33         with an uppercase letter are type identifiers, all others are value \
34         identifiers"
35    )]
36    CapitalizationMismatch {
37        /// The symbol's current name.
38        old: String,
39        /// The rejected replacement.
40        new: String,
41    },
42}
43
44/// Validate `new_name` as a replacement for `old_name`.
45///
46/// # Errors
47///
48/// Returns a [`RenameError`] describing the first rule the candidate
49/// violates; see the module docs for the rules.
50pub fn validate_new_name(old_name: &str, new_name: &str) -> Result<(), RenameError> {
51    if !is_identifier_shaped(new_name) {
52        return Err(RenameError::InvalidIdentifier(new_name.to_string()));
53    }
54    if keyword_lookup(new_name).is_some() {
55        return Err(RenameError::ReservedKeyword(new_name.to_string()));
56    }
57    if starts_uppercase(old_name) != starts_uppercase(new_name) {
58        return Err(RenameError::CapitalizationMismatch {
59            old: old_name.to_string(),
60            new: new_name.to_string(),
61        });
62    }
63    Ok(())
64}
65
66/// `true` if `name` lexes as one identifier token: alphabetic or `_` first,
67/// alphanumeric or `_` after, and not the bare wildcard `_`.
68fn is_identifier_shaped(name: &str) -> bool {
69    if name == "_" {
70        // `_` alone is the wildcard token, not an identifier.
71        return false;
72    }
73    let mut chars = name.chars();
74    let Some(first) = chars.next() else {
75        return false;
76    };
77    if !(first.is_alphabetic() || first == '_') {
78        return false;
79    }
80    chars.all(|c| c.is_alphanumeric() || c == '_')
81}
82
83/// Capitalization class used by the lexer: uppercase-initial names lex as
84/// `TypeIdent`, everything else as `Ident`.
85fn starts_uppercase(name: &str) -> bool {
86    name.starts_with(char::is_uppercase)
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    #[test]
94    fn accepts_simple_lowercase_rename() {
95        assert_eq!(validate_new_name("answer", "result"), Ok(()));
96    }
97
98    #[test]
99    fn accepts_underscores_and_digits() {
100        assert_eq!(validate_new_name("answer", "the_answer_42"), Ok(()));
101        assert_eq!(validate_new_name("answer", "_private"), Ok(()));
102    }
103
104    #[test]
105    fn accepts_uppercase_rename_for_uppercase_name() {
106        assert_eq!(validate_new_name("Point", "Coordinate"), Ok(()));
107    }
108
109    #[test]
110    fn rejects_keywords() {
111        for kw in ["fn", "let", "mut", "match", "if", "true", "false"] {
112            assert_eq!(
113                validate_new_name("answer", kw),
114                Err(RenameError::ReservedKeyword(kw.to_string())),
115                "`{kw}` must be rejected",
116            );
117        }
118    }
119
120    #[test]
121    fn rejects_constructor_keywords() {
122        // `Ok` / `Some` / `Self` are keyword tokens in Bock's lexer, not
123        // ordinary type identifiers.
124        for kw in ["Ok", "Err", "Some", "None", "Self"] {
125            assert_eq!(
126                validate_new_name("Point", kw),
127                Err(RenameError::ReservedKeyword(kw.to_string())),
128                "`{kw}` must be rejected",
129            );
130        }
131    }
132
133    #[test]
134    fn rejects_non_identifier_shapes() {
135        for bad in ["", "_", "123abc", "foo-bar", "foo bar", "a.b", "x!"] {
136            assert_eq!(
137                validate_new_name("answer", bad),
138                Err(RenameError::InvalidIdentifier(bad.to_string())),
139                "`{bad}` must be rejected",
140            );
141        }
142    }
143
144    #[test]
145    fn rejects_capitalization_class_change() {
146        assert!(matches!(
147            validate_new_name("Point", "point"),
148            Err(RenameError::CapitalizationMismatch { .. }),
149        ));
150        assert!(matches!(
151            validate_new_name("answer", "Answer"),
152            Err(RenameError::CapitalizationMismatch { .. }),
153        ));
154    }
155
156    #[test]
157    fn error_messages_are_descriptive() {
158        let err = validate_new_name("answer", "fn").expect_err("keyword rejected");
159        assert!(err.to_string().contains("reserved"), "got: {err}");
160        let err = validate_new_name("Point", "point").expect_err("case rejected");
161        assert!(err.to_string().contains("capitalization"), "got: {err}");
162    }
163}