Skip to main content

kotlin_codegen/
ident.rs

1//! Kotlin identifiers: validity, sanitizing, and back-tick escaping.
2//!
3//! A program generating Kotlin gets its names from somewhere else — a Rust
4//! field, a C symbol, a JSON key — and some of those are never legal Kotlin.
5//! These are the primitives for dealing with that, at the layer that actually
6//! writes the file.
7//!
8//! Two strategies are offered, and which one you want is your business:
9//! [`mangle_kotlin_ident`] *changes* the name into a legal one, while
10//! [`escape_kotlin_ident`] *keeps* it by wrapping it in back-ticks.
11
12/// Kotlin's hard keywords — reserved everywhere, so never usable as a plain
13/// identifier. Soft and modifier keywords (`data`, `value`, `inline`,
14/// `operator`, …) are contextual and *are* valid identifiers, so they are
15/// deliberately absent.
16pub const KOTLIN_HARD_KEYWORDS: &[&str] = &[
17    "as",
18    "break",
19    "class",
20    "continue",
21    "do",
22    "else",
23    "false",
24    "for",
25    "fun",
26    "if",
27    "in",
28    "interface",
29    "is",
30    "null",
31    "object",
32    "package",
33    "return",
34    "super",
35    "this",
36    "throw",
37    "true",
38    "try",
39    "typealias",
40    "typeof",
41    "val",
42    "var",
43    "when",
44    "while",
45];
46
47/// Whether `s` is one of [`KOTLIN_HARD_KEYWORDS`].
48pub fn is_kotlin_hard_keyword(s: &str) -> bool {
49    KOTLIN_HARD_KEYWORDS.contains(&s)
50}
51
52/// Whether `s` is a legal plain Kotlin identifier: non-empty, first character a
53/// letter or `_`, the rest letters / digits / `_`, and not a hard keyword.
54///
55/// Back-ticked identifiers admit far more (see [`escape_kotlin_ident`]) and are
56/// deliberately not accepted here — this is the predicate for a name that can
57/// be written bare.
58///
59/// ```
60/// use kotlin_codegen::is_valid_kotlin_ident;
61/// assert!(is_valid_kotlin_ident("myValue"));
62/// assert!(!is_valid_kotlin_ident("object")); // hard keyword
63/// assert!(!is_valid_kotlin_ident("2fast")); // leading digit
64/// assert!(!is_valid_kotlin_ident("my-name")); // illegal character
65/// assert!(!is_valid_kotlin_ident("")); // empty
66/// ```
67pub fn is_valid_kotlin_ident(s: &str) -> bool {
68    let mut chars = s.chars();
69    let Some(first) = chars.next() else {
70        return false;
71    };
72    if !(first == '_' || first.is_alphabetic()) {
73        return false;
74    }
75    if !chars.all(|c| c == '_' || c.is_alphanumeric()) {
76        return false;
77    }
78    !is_kotlin_hard_keyword(s)
79}
80
81/// Turn any string into a legal Kotlin identifier, deterministically and
82/// idempotently:
83///
84/// * a character that is not a letter, digit or `_` becomes `_`;
85/// * a leading digit gets a `_` prefix;
86/// * an empty string becomes `_`;
87/// * a hard keyword gets a trailing `_`.
88///
89/// Applying it to its own output changes nothing, so it is safe to run over
90/// names that may already have been mangled.
91///
92/// ```
93/// use kotlin_codegen::mangle_kotlin_ident;
94/// assert_eq!(mangle_kotlin_ident("my-name"), "my_name");
95/// assert_eq!(mangle_kotlin_ident("2fast"), "_2fast");
96/// assert_eq!(mangle_kotlin_ident("object"), "object_");
97/// assert_eq!(mangle_kotlin_ident(""), "_");
98/// // already legal, and idempotent
99/// assert_eq!(mangle_kotlin_ident("myValue"), "myValue");
100/// assert_eq!(mangle_kotlin_ident("object_"), "object_");
101/// ```
102pub fn mangle_kotlin_ident(s: &str) -> String {
103    if is_valid_kotlin_ident(s) {
104        return s.to_string();
105    }
106    let mut out = String::with_capacity(s.len() + 1);
107    for (i, c) in s.chars().enumerate() {
108        if c == '_' || c.is_alphanumeric() {
109            if i == 0 && c.is_numeric() {
110                out.push('_');
111            }
112            out.push(c);
113        } else {
114            out.push('_');
115        }
116    }
117    if out.is_empty() {
118        out.push('_');
119    }
120    if is_kotlin_hard_keyword(&out) {
121        out.push('_');
122    }
123    out
124}
125
126/// Keep a name Kotlin would otherwise reject by wrapping it in back-ticks —
127/// the alternative to [`mangle_kotlin_ident`], which changes it instead.
128///
129/// A name that is already legal is returned unchanged, so this is safe to
130/// apply unconditionally. Characters that are illegal even inside back-ticks
131/// (`` ` ``, line breaks, and the JVM's `.;[]/<>:\`) become `_`.
132///
133/// ```
134/// use kotlin_codegen::escape_kotlin_ident;
135/// assert_eq!(escape_kotlin_ident("myValue"), "myValue");
136/// assert_eq!(escape_kotlin_ident("object"), "`object`");
137/// assert_eq!(escape_kotlin_ident("my name"), "`my name`");
138/// assert_eq!(escape_kotlin_ident("a.b"), "`a_b`");
139/// ```
140pub fn escape_kotlin_ident(s: &str) -> String {
141    if is_valid_kotlin_ident(s) {
142        return s.to_string();
143    }
144    let cleaned: String = s
145        .chars()
146        .map(|c| {
147            if matches!(
148                c,
149                '`' | '\n' | '\r' | '.' | ';' | '[' | ']' | '/' | '<' | '>' | ':' | '\\'
150            ) {
151                '_'
152            } else {
153                c
154            }
155        })
156        .collect();
157    if cleaned.is_empty() {
158        return "`_`".to_string();
159    }
160    format!("`{cleaned}`")
161}
162
163/// Whether `s` is an already back-ticked identifier — the form
164/// [`escape_kotlin_ident`] produces, and a legal way to write a name Kotlin
165/// would otherwise reject.
166///
167/// ```
168/// use kotlin_codegen::is_escaped_kotlin_ident;
169/// assert!(is_escaped_kotlin_ident("`object`"));
170/// assert!(is_escaped_kotlin_ident("`my name`"));
171/// assert!(!is_escaped_kotlin_ident("object")); // not escaped
172/// assert!(!is_escaped_kotlin_ident("``")); // empty inside
173/// assert!(!is_escaped_kotlin_ident("`a.b`")); // illegal even in back-ticks
174/// ```
175pub fn is_escaped_kotlin_ident(s: &str) -> bool {
176    let Some(inner) = s.strip_prefix('`').and_then(|r| r.strip_suffix('`')) else {
177        return false;
178    };
179    !inner.is_empty()
180        && !inner.contains(|c| {
181            matches!(
182                c,
183                '`' | '\n' | '\r' | '.' | ';' | '[' | ']' | '/' | '<' | '>' | ':' | '\\'
184            )
185        })
186}
187
188/// Whether `s` can be written as a name in Kotlin source: either a plain
189/// [identifier](is_valid_kotlin_ident) or an
190/// [escaped](is_escaped_kotlin_ident) one.
191///
192/// This is the predicate a *checker* wants — rejecting back-ticked names would
193/// fire on output [`escape_kotlin_ident`] itself produces.
194///
195/// ```
196/// use kotlin_codegen::is_writable_kotlin_ident;
197/// assert!(is_writable_kotlin_ident("myValue"));
198/// assert!(is_writable_kotlin_ident("`object`"));
199/// assert!(!is_writable_kotlin_ident("object"));
200/// ```
201pub fn is_writable_kotlin_ident(s: &str) -> bool {
202    is_valid_kotlin_ident(s) || is_escaped_kotlin_ident(s)
203}
204
205/// Whether `s` is a legal Kotlin package path: one or more
206/// [valid identifiers](is_valid_kotlin_ident) separated by single dots. The
207/// empty string is legal — it is the default (root) package.
208///
209/// ```
210/// use kotlin_codegen::is_valid_kotlin_package;
211/// assert!(is_valid_kotlin_package("io.zenoh.jni"));
212/// assert!(is_valid_kotlin_package("")); // default package
213/// assert!(!is_valid_kotlin_package("io..jni")); // empty segment
214/// assert!(!is_valid_kotlin_package("io.object")); // keyword segment
215/// ```
216pub fn is_valid_kotlin_package(s: &str) -> bool {
217    s.is_empty() || s.split('.').all(is_valid_kotlin_ident)
218}
219
220/// [Mangle](mangle_kotlin_ident) each segment of a dot-separated package path,
221/// dropping empty segments (a leading, trailing or doubled dot). Idempotent.
222///
223/// ```
224/// use kotlin_codegen::mangle_kotlin_package;
225/// assert_eq!(mangle_kotlin_package("fun.my-pkg"), "fun_.my_pkg");
226/// assert_eq!(mangle_kotlin_package("io..jni."), "io.jni");
227/// assert_eq!(mangle_kotlin_package("io.zenoh"), "io.zenoh");
228/// ```
229pub fn mangle_kotlin_package(path: &str) -> String {
230    path.split('.')
231        .filter(|s| !s.is_empty())
232        .map(mangle_kotlin_ident)
233        .collect::<Vec<_>>()
234        .join(".")
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    #[test]
242    fn hard_keywords_are_sorted_and_unique() {
243        // Sorted so the list stays reviewable as it grows.
244        let mut sorted = KOTLIN_HARD_KEYWORDS.to_vec();
245        sorted.sort_unstable();
246        sorted.dedup();
247        assert_eq!(sorted.as_slice(), KOTLIN_HARD_KEYWORDS);
248    }
249
250    #[test]
251    fn soft_keywords_are_valid_identifiers() {
252        // `data`, `value`, `sealed` and friends are contextual: Kotlin accepts
253        // them as names, so mangling them would be wrong.
254        for s in ["data", "value", "sealed", "inline", "operator", "companion"] {
255            assert!(is_valid_kotlin_ident(s), "{s} should be a valid identifier");
256            assert_eq!(mangle_kotlin_ident(s), s);
257        }
258    }
259
260    #[test]
261    fn mangling_always_produces_a_valid_identifier() {
262        for s in [
263            "", "2fast", "my-name", "a b", "object", "val", "___", "é", "9", "...", "a.b.c",
264        ] {
265            let m = mangle_kotlin_ident(s);
266            assert!(is_valid_kotlin_ident(&m), "{s:?} mangled to invalid {m:?}");
267            // Idempotent: mangling the result changes nothing.
268            assert_eq!(mangle_kotlin_ident(&m), m, "not idempotent for {s:?}");
269        }
270    }
271
272    #[test]
273    fn unicode_letters_are_valid() {
274        assert!(is_valid_kotlin_ident("café"));
275        assert!(is_valid_kotlin_ident("Привет"));
276        assert_eq!(mangle_kotlin_ident("café"), "café");
277    }
278
279    #[test]
280    fn package_mangling_always_produces_a_valid_package() {
281        for s in ["", "fun.my-pkg", "io..jni.", "1.2.3", "..."] {
282            let m = mangle_kotlin_package(s);
283            assert!(
284                is_valid_kotlin_package(&m),
285                "{s:?} mangled to invalid {m:?}"
286            );
287            assert_eq!(mangle_kotlin_package(&m), m, "not idempotent for {s:?}");
288        }
289    }
290
291    #[test]
292    fn escaping_leaves_legal_names_alone() {
293        for s in ["myValue", "_x", "café"] {
294            assert_eq!(escape_kotlin_ident(s), s);
295        }
296    }
297
298    #[test]
299    fn escaping_strips_characters_illegal_even_in_backticks() {
300        assert_eq!(escape_kotlin_ident("a`b"), "`a_b`");
301        assert_eq!(escape_kotlin_ident("a\nb"), "`a_b`");
302        assert_eq!(escape_kotlin_ident("a/b"), "`a_b`");
303        assert_eq!(escape_kotlin_ident(""), "`_`");
304    }
305}