lisette-emit 0.1.20

Little language inspired by Rust that compiles to Go
Documentation
use std::borrow::Cow;

use crate::definitions::enum_layout::ENUM_TAG_FIELD;
use syntax::types::Type;

pub(crate) const GO_IMPORT_PREFIX: &str = "go:";

pub(crate) fn is_go_import(id: &str) -> bool {
    id.starts_with(GO_IMPORT_PREFIX)
}

pub(crate) const PRELUDE_MODULE: &str = "prelude";

pub(crate) const PRELUDE_PREFIX: &str = "prelude.";

pub(crate) const GO_STDLIB_PKG: &str = "lisette";

pub const PRELUDE_IMPORT_PATH: &str = "github.com/ivov/lisette/prelude";

pub(crate) use syntax::types::unqualified_name;

pub(crate) fn capitalize_first(s: &str) -> String {
    let mut chars = s.chars();
    match chars.next() {
        None => String::new(),
        Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
    }
}

pub(crate) fn make_exported(name: &str) -> String {
    escape_keyword(&capitalize_first(name)).into_owned()
}

pub(crate) fn snake_to_camel(s: &str) -> String {
    s.split('_').map(capitalize_first).collect()
}

pub(crate) fn go_package_name(module: &str) -> &str {
    module.rsplit('/').next().unwrap_or(module)
}

pub(crate) fn module_of_type_id(id: &str) -> &str {
    if let Some(slash_pos) = id.rfind('/') {
        let after_slash = slash_pos + 1;
        if let Some(dot_offset) = id[after_slash..].find('.') {
            return &id[..after_slash + dot_offset];
        }
    }
    id.split('.').next().unwrap_or(id)
}

pub(crate) fn sanitize_package_name(name: &str) -> Cow<'_, str> {
    let has_bad_chars = name.chars().any(|c| !c.is_ascii_alphanumeric() && c != '_');
    let starts_with_digit = name.starts_with(|c: char| c.is_ascii_digit());
    let is_reserved = GO_KEYWORDS.contains(&name) || GO_BUILTINS.contains(&name);

    if !has_bad_chars && !starts_with_digit && !is_reserved {
        return Cow::Borrowed(name);
    }

    let mut result: String = name
        .chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() || c == '_' {
                c
            } else {
                '_'
            }
        })
        .collect();

    if result.starts_with(|c: char| c.is_ascii_digit()) {
        result.insert(0, '_');
    }

    if GO_KEYWORDS.contains(&result.as_str()) || GO_BUILTINS.contains(&result.as_str()) {
        result.push('_');
    }

    Cow::Owned(result)
}

/// Go reserved keywords that cannot be used as identifiers.
/// See: https://go.dev/ref/spec#Keywords
const GO_KEYWORDS: &[&str] = &[
    "break",
    "case",
    "chan",
    "const",
    "continue",
    "default",
    "defer",
    "else",
    "fallthrough",
    "for",
    "func",
    "go",
    "goto",
    "if",
    "import",
    "interface",
    "map",
    "package",
    "range",
    "return",
    "select",
    "struct",
    "switch",
    "type",
    "var",
];

pub(crate) struct ResolvedName {
    pub(crate) name: String,
    pub(crate) needs_stdlib: bool,
}

impl ResolvedName {
    fn stdlib(name: String) -> Self {
        Self {
            name,
            needs_stdlib: true,
        }
    }

    fn local(name: String) -> Self {
        Self {
            name,
            needs_stdlib: false,
        }
    }
}

/// Convert a qualified Lisette name to its Go equivalent.
///
/// # Examples
/// - `"prelude.Option"` → `"lisette.Option"` (needs_stdlib: true)
/// - `"prelude.Slice.filter"` → `"lisette.SliceFilter"` (needs_stdlib: true)
/// - `"mymodule.foo"` → `"mymodule_foo"` (needs_stdlib: false)
/// - `"range"` → `"range_"` (Go keyword escaped)
pub(crate) fn resolve(name: &str) -> ResolvedName {
    if let Some(rest) = name.strip_prefix(PRELUDE_PREFIX) {
        let go_name: String = rest.split('.').map(snake_to_camel).collect();
        ResolvedName::stdlib(format!("{}.{}", GO_STDLIB_PKG, go_name))
    } else {
        ResolvedName::local(escape_reserved(&name.replace('.', "_")).into_owned())
    }
}

pub(crate) fn variant(
    identifier: &str,
    ty: &Type,
    current_module: &str,
    module_alias: Option<&str>,
) -> ResolvedName {
    let Type::Nominal { id, .. } = ty else {
        return ResolvedName::local(identifier.replace('.', "_"));
    };

    variant_by_id(identifier, id, current_module, module_alias)
}

pub(crate) fn variant_by_id(
    identifier: &str,
    enum_id: &str,
    current_module: &str,
    module_alias: Option<&str>,
) -> ResolvedName {
    let is_prelude = enum_id.starts_with(PRELUDE_PREFIX);
    let enum_module = module_of_type_id(enum_id);
    let enum_name = enum_id.split('.').next_back().unwrap_or(enum_id);
    let variant_name = identifier.split('.').next_back().unwrap_or(identifier);

    let needs_qualifier = !is_prelude && enum_module != current_module;

    if is_prelude {
        ResolvedName::stdlib(format!("{}.{enum_name}{variant_name}", GO_STDLIB_PKG))
    } else if variant_name == ENUM_TAG_FIELD {
        let base = format!("{enum_name}Tag_");
        if needs_qualifier {
            let pkg = module_alias.unwrap_or_else(|| go_package_name(enum_module));
            ResolvedName::local(format!("{pkg}.{base}"))
        } else {
            ResolvedName::local(base)
        }
    } else {
        let base = format!("{enum_name}{variant_name}");
        if needs_qualifier {
            let pkg = module_alias.unwrap_or_else(|| go_package_name(enum_module));
            ResolvedName::local(format!("{pkg}.{base}"))
        } else {
            ResolvedName::local(base)
        }
    }
}

/// Go predeclared identifiers (builtin functions and constants) that should
/// not be shadowed by user-defined names.
/// See: https://go.dev/ref/spec#Predeclared_identifiers
///
/// Note: `complex`, `panic`, and `real` are excluded because they are also
/// exposed as Lisette builtins that map directly to Go builtins.
const GO_BUILTINS: &[&str] = &[
    // Builtin functions
    "any",
    "append",
    "cap",
    "clear",
    "close",
    "copy",
    "delete",
    "imag",
    "init",
    "len",
    "make",
    "max",
    "min",
    "new",
    "print",
    "println",
    "recover",
    // Predeclared types
    "bool",
    "byte",
    "comparable",
    "complex64",
    "complex128",
    "error",
    "float32",
    "float64",
    "int",
    "int8",
    "int16",
    "int32",
    "int64",
    "rune",
    "string",
    "uint",
    "uint8",
    "uint16",
    "uint32",
    "uint64",
    "uintptr",
    // Predeclared constants
    "false",
    "iota",
    "nil",
    "true",
];

pub(crate) fn escape_keyword(name: &str) -> Cow<'_, str> {
    if GO_KEYWORDS.contains(&name) {
        Cow::Owned(format!("{}_", name))
    } else {
        Cow::Borrowed(name)
    }
}

pub(crate) fn escape_reserved(name: &str) -> Cow<'_, str> {
    if GO_KEYWORDS.contains(&name) || GO_BUILTINS.contains(&name) {
        Cow::Owned(format!("{}_", name))
    } else {
        Cow::Borrowed(name)
    }
}

pub(crate) fn qualify_method(
    type_id: &str,
    method: &str,
    current_module: &str,
    is_public: bool,
    module_alias: Option<&str>,
) -> ResolvedName {
    let Some((module, type_name)) = type_id.split_once('.') else {
        let method_name = if is_public {
            capitalize_first(method)
        } else {
            method.to_string()
        };
        return ResolvedName::local(format!("{}_{}", type_id, method_name));
    };

    if module == PRELUDE_MODULE {
        ResolvedName::stdlib(format!(
            "{}.{}{}",
            GO_STDLIB_PKG,
            type_name,
            snake_to_camel(method)
        ))
    } else if module == current_module {
        let method_name = if is_public {
            capitalize_first(method)
        } else {
            method.to_string()
        };
        ResolvedName::local(format!("{}_{}", type_name, method_name))
    } else {
        let pkg = module_alias.unwrap_or_else(|| go_package_name(module));
        ResolvedName::local(format!(
            "{}.{}_{}",
            pkg,
            type_name,
            capitalize_first(method)
        ))
    }
}