kotlin-codegen 0.2.0

A declaration model and renderer for generating Kotlin source code
Documentation
//! Kotlin identifiers: validity, sanitizing, and back-tick escaping.
//!
//! A program generating Kotlin gets its names from somewhere else — a Rust
//! field, a C symbol, a JSON key — and some of those are never legal Kotlin.
//! These are the primitives for dealing with that, at the layer that actually
//! writes the file.
//!
//! Two strategies are offered, and which one you want is your business:
//! [`mangle_kotlin_ident`] *changes* the name into a legal one, while
//! [`escape_kotlin_ident`] *keeps* it by wrapping it in back-ticks.

/// Kotlin's hard keywords — reserved everywhere, so never usable as a plain
/// identifier. Soft and modifier keywords (`data`, `value`, `inline`,
/// `operator`, …) are contextual and *are* valid identifiers, so they are
/// deliberately absent.
pub const KOTLIN_HARD_KEYWORDS: &[&str] = &[
    "as",
    "break",
    "class",
    "continue",
    "do",
    "else",
    "false",
    "for",
    "fun",
    "if",
    "in",
    "interface",
    "is",
    "null",
    "object",
    "package",
    "return",
    "super",
    "this",
    "throw",
    "true",
    "try",
    "typealias",
    "typeof",
    "val",
    "var",
    "when",
    "while",
];

/// Whether `s` is one of [`KOTLIN_HARD_KEYWORDS`].
pub fn is_kotlin_hard_keyword(s: &str) -> bool {
    KOTLIN_HARD_KEYWORDS.contains(&s)
}

/// Whether `s` is a legal plain Kotlin identifier: non-empty, first character a
/// letter or `_`, the rest letters / digits / `_`, and not a hard keyword.
///
/// Back-ticked identifiers admit far more (see [`escape_kotlin_ident`]) and are
/// deliberately not accepted here — this is the predicate for a name that can
/// be written bare.
///
/// ```
/// use kotlin_codegen::is_valid_kotlin_ident;
/// assert!(is_valid_kotlin_ident("myValue"));
/// assert!(!is_valid_kotlin_ident("object")); // hard keyword
/// assert!(!is_valid_kotlin_ident("2fast")); // leading digit
/// assert!(!is_valid_kotlin_ident("my-name")); // illegal character
/// assert!(!is_valid_kotlin_ident("")); // empty
/// ```
pub fn is_valid_kotlin_ident(s: &str) -> bool {
    let mut chars = s.chars();
    let Some(first) = chars.next() else {
        return false;
    };
    if !(first == '_' || first.is_alphabetic()) {
        return false;
    }
    if !chars.all(|c| c == '_' || c.is_alphanumeric()) {
        return false;
    }
    !is_kotlin_hard_keyword(s)
}

/// Turn any string into a legal Kotlin identifier, deterministically and
/// idempotently:
///
/// * a character that is not a letter, digit or `_` becomes `_`;
/// * a leading digit gets a `_` prefix;
/// * an empty string becomes `_`;
/// * a hard keyword gets a trailing `_`.
///
/// Applying it to its own output changes nothing, so it is safe to run over
/// names that may already have been mangled.
///
/// ```
/// use kotlin_codegen::mangle_kotlin_ident;
/// assert_eq!(mangle_kotlin_ident("my-name"), "my_name");
/// assert_eq!(mangle_kotlin_ident("2fast"), "_2fast");
/// assert_eq!(mangle_kotlin_ident("object"), "object_");
/// assert_eq!(mangle_kotlin_ident(""), "_");
/// // already legal, and idempotent
/// assert_eq!(mangle_kotlin_ident("myValue"), "myValue");
/// assert_eq!(mangle_kotlin_ident("object_"), "object_");
/// ```
pub fn mangle_kotlin_ident(s: &str) -> String {
    if is_valid_kotlin_ident(s) {
        return s.to_string();
    }
    let mut out = String::with_capacity(s.len() + 1);
    for (i, c) in s.chars().enumerate() {
        if c == '_' || c.is_alphanumeric() {
            if i == 0 && c.is_numeric() {
                out.push('_');
            }
            out.push(c);
        } else {
            out.push('_');
        }
    }
    if out.is_empty() {
        out.push('_');
    }
    if is_kotlin_hard_keyword(&out) {
        out.push('_');
    }
    out
}

/// Keep a name Kotlin would otherwise reject by wrapping it in back-ticks —
/// the alternative to [`mangle_kotlin_ident`], which changes it instead.
///
/// A name that is already legal is returned unchanged, so this is safe to
/// apply unconditionally. Characters that are illegal even inside back-ticks
/// (`` ` ``, line breaks, and the JVM's `.;[]/<>:\`) become `_`.
///
/// ```
/// use kotlin_codegen::escape_kotlin_ident;
/// assert_eq!(escape_kotlin_ident("myValue"), "myValue");
/// assert_eq!(escape_kotlin_ident("object"), "`object`");
/// assert_eq!(escape_kotlin_ident("my name"), "`my name`");
/// assert_eq!(escape_kotlin_ident("a.b"), "`a_b`");
/// ```
pub fn escape_kotlin_ident(s: &str) -> String {
    if is_valid_kotlin_ident(s) {
        return s.to_string();
    }
    let cleaned: String = s
        .chars()
        .map(|c| {
            if matches!(
                c,
                '`' | '\n' | '\r' | '.' | ';' | '[' | ']' | '/' | '<' | '>' | ':' | '\\'
            ) {
                '_'
            } else {
                c
            }
        })
        .collect();
    if cleaned.is_empty() {
        return "`_`".to_string();
    }
    format!("`{cleaned}`")
}

/// Whether `s` is an already back-ticked identifier — the form
/// [`escape_kotlin_ident`] produces, and a legal way to write a name Kotlin
/// would otherwise reject.
///
/// ```
/// use kotlin_codegen::is_escaped_kotlin_ident;
/// assert!(is_escaped_kotlin_ident("`object`"));
/// assert!(is_escaped_kotlin_ident("`my name`"));
/// assert!(!is_escaped_kotlin_ident("object")); // not escaped
/// assert!(!is_escaped_kotlin_ident("``")); // empty inside
/// assert!(!is_escaped_kotlin_ident("`a.b`")); // illegal even in back-ticks
/// ```
pub fn is_escaped_kotlin_ident(s: &str) -> bool {
    let Some(inner) = s.strip_prefix('`').and_then(|r| r.strip_suffix('`')) else {
        return false;
    };
    !inner.is_empty()
        && !inner.contains(|c| {
            matches!(
                c,
                '`' | '\n' | '\r' | '.' | ';' | '[' | ']' | '/' | '<' | '>' | ':' | '\\'
            )
        })
}

/// Whether `s` can be written as a name in Kotlin source: either a plain
/// [identifier](is_valid_kotlin_ident) or an
/// [escaped](is_escaped_kotlin_ident) one.
///
/// This is the predicate a *checker* wants — rejecting back-ticked names would
/// fire on output [`escape_kotlin_ident`] itself produces.
///
/// ```
/// use kotlin_codegen::is_writable_kotlin_ident;
/// assert!(is_writable_kotlin_ident("myValue"));
/// assert!(is_writable_kotlin_ident("`object`"));
/// assert!(!is_writable_kotlin_ident("object"));
/// ```
pub fn is_writable_kotlin_ident(s: &str) -> bool {
    is_valid_kotlin_ident(s) || is_escaped_kotlin_ident(s)
}

/// Whether `s` is a legal Kotlin package path: one or more
/// [valid identifiers](is_valid_kotlin_ident) separated by single dots. The
/// empty string is legal — it is the default (root) package.
///
/// ```
/// use kotlin_codegen::is_valid_kotlin_package;
/// assert!(is_valid_kotlin_package("io.zenoh.jni"));
/// assert!(is_valid_kotlin_package("")); // default package
/// assert!(!is_valid_kotlin_package("io..jni")); // empty segment
/// assert!(!is_valid_kotlin_package("io.object")); // keyword segment
/// ```
pub fn is_valid_kotlin_package(s: &str) -> bool {
    s.is_empty() || s.split('.').all(is_valid_kotlin_ident)
}

/// [Mangle](mangle_kotlin_ident) each segment of a dot-separated package path,
/// dropping empty segments (a leading, trailing or doubled dot). Idempotent.
///
/// ```
/// use kotlin_codegen::mangle_kotlin_package;
/// assert_eq!(mangle_kotlin_package("fun.my-pkg"), "fun_.my_pkg");
/// assert_eq!(mangle_kotlin_package("io..jni."), "io.jni");
/// assert_eq!(mangle_kotlin_package("io.zenoh"), "io.zenoh");
/// ```
pub fn mangle_kotlin_package(path: &str) -> String {
    path.split('.')
        .filter(|s| !s.is_empty())
        .map(mangle_kotlin_ident)
        .collect::<Vec<_>>()
        .join(".")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn hard_keywords_are_sorted_and_unique() {
        // Sorted so the list stays reviewable as it grows.
        let mut sorted = KOTLIN_HARD_KEYWORDS.to_vec();
        sorted.sort_unstable();
        sorted.dedup();
        assert_eq!(sorted.as_slice(), KOTLIN_HARD_KEYWORDS);
    }

    #[test]
    fn soft_keywords_are_valid_identifiers() {
        // `data`, `value`, `sealed` and friends are contextual: Kotlin accepts
        // them as names, so mangling them would be wrong.
        for s in ["data", "value", "sealed", "inline", "operator", "companion"] {
            assert!(is_valid_kotlin_ident(s), "{s} should be a valid identifier");
            assert_eq!(mangle_kotlin_ident(s), s);
        }
    }

    #[test]
    fn mangling_always_produces_a_valid_identifier() {
        for s in [
            "", "2fast", "my-name", "a b", "object", "val", "___", "é", "9", "...", "a.b.c",
        ] {
            let m = mangle_kotlin_ident(s);
            assert!(is_valid_kotlin_ident(&m), "{s:?} mangled to invalid {m:?}");
            // Idempotent: mangling the result changes nothing.
            assert_eq!(mangle_kotlin_ident(&m), m, "not idempotent for {s:?}");
        }
    }

    #[test]
    fn unicode_letters_are_valid() {
        assert!(is_valid_kotlin_ident("café"));
        assert!(is_valid_kotlin_ident("Привет"));
        assert_eq!(mangle_kotlin_ident("café"), "café");
    }

    #[test]
    fn package_mangling_always_produces_a_valid_package() {
        for s in ["", "fun.my-pkg", "io..jni.", "1.2.3", "..."] {
            let m = mangle_kotlin_package(s);
            assert!(
                is_valid_kotlin_package(&m),
                "{s:?} mangled to invalid {m:?}"
            );
            assert_eq!(mangle_kotlin_package(&m), m, "not idempotent for {s:?}");
        }
    }

    #[test]
    fn escaping_leaves_legal_names_alone() {
        for s in ["myValue", "_x", "café"] {
            assert_eq!(escape_kotlin_ident(s), s);
        }
    }

    #[test]
    fn escaping_strips_characters_illegal_even_in_backticks() {
        assert_eq!(escape_kotlin_ident("a`b"), "`a_b`");
        assert_eq!(escape_kotlin_ident("a\nb"), "`a_b`");
        assert_eq!(escape_kotlin_ident("a/b"), "`a_b`");
        assert_eq!(escape_kotlin_ident(""), "`_`");
    }
}