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}