oo-ide 0.0.4

∞ is a terminal IDE focused on low distraction, high usability.
Documentation
//! Language identification for files.
//!
//! Provides [`LanguageId`] — a lightweight newtype over `Cow<'static, str>` —
//! and [`detect_language`], which maps a file path to a language using first
//! the file extension then a mime-type fallback via `mime_guess`.
//!
//! Built-in IDs match the LSP `languageId` convention (lowercase, e.g. "rust",
//! "cpp"). Using `Cow` keeps zero-allocation for the built-in static strings
//! while allowing owned `String`s for future config-driven mappings.

use std::borrow::Cow;
use std::path::Path;

/// A language identifier string (e.g. `"rust"`, `"cpp"`, `"python"`).
///
/// Built-in languages are represented as `&'static str` wrapped in
/// `Cow::Borrowed`, so no allocation occurs for the common case.
/// Future config-driven language IDs may use `Cow::Owned`.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct LanguageId(pub Cow<'static, str>);

impl LanguageId {
    /// Construct from any `&'static str` or `String`.
    pub fn new(s: impl Into<Cow<'static, str>>) -> Self {
        Self(s.into())
    }

    /// Borrow the inner string slice.
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl std::fmt::Display for LanguageId {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.0)
    }
}

impl AsRef<str> for LanguageId {
    fn as_ref(&self) -> &str {
        self.as_str()
    }
}

// ---------------------------------------------------------------------------
// Built-in mappings
// ---------------------------------------------------------------------------

/// Map a lowercased file extension to a language ID.
fn lang_from_ext(ext: &str) -> Option<&'static str> {
    match ext {
        "rs" => Some("rust"),
        "py" | "pyw" | "pyi" => Some("python"),
        "ts" => Some("typescript"),
        "tsx" => Some("typescriptreact"),
        "js" | "mjs" | "cjs" => Some("javascript"),
        "jsx" => Some("javascriptreact"),
        "go" => Some("go"),
        "c" | "h" => Some("c"),
        "cpp" | "cc" | "cxx" | "hpp" | "hxx" | "h++" => Some("cpp"),
        "java" => Some("java"),
        "cs" => Some("csharp"),
        "rb" => Some("ruby"),
        "lua" => Some("lua"),
        "zig" => Some("zig"),
        "toml" => Some("toml"),
        "yaml" | "yml" => Some("yaml"),
        "json" | "jsonc" => Some("json"),
        "md" | "markdown" => Some("markdown"),
        "sh" | "bash" | "zsh" => Some("shellscript"),
        "fish" => Some("fish"),
        "html" | "htm" => Some("html"),
        "css" => Some("css"),
        "scss" => Some("scss"),
        "sql" => Some("sql"),
        "xml" => Some("xml"),
        "svelte" => Some("svelte"),
        "vue" => Some("vue"),
        "kt" | "kts" => Some("kotlin"),
        "swift" => Some("swift"),
        "r" => Some("r"),
        "dart" => Some("dart"),
        "ex" | "exs" => Some("elixir"),
        "hs" => Some("haskell"),
        "ml" | "mli" => Some("ocaml"),
        "clj" | "cljs" => Some("clojure"),
        "erl" | "hrl" => Some("erlang"),
        "nim" => Some("nim"),
        "tf" | "tfvars" => Some("terraform"),
        "dockerfile" => Some("dockerfile"),
        _ => None,
    }
}

/// Map a mime type string to a language ID.
fn lang_from_mime(mime: &str) -> Option<&'static str> {
    // Strip optional parameters (e.g. "text/x-rust; charset=utf-8").
    let mime = mime.split(';').next().unwrap_or(mime).trim();
    match mime {
        // Rust
        "text/rust" | "text/x-rust" => Some("rust"),
        // Python
        "text/x-python" | "text/x-python3" | "application/x-python-code" => Some("python"),
        // Web
        "application/json" | "text/json" => Some("json"),
        "text/html" => Some("html"),
        "text/css" => Some("css"),
        "text/javascript" | "application/javascript" | "application/x-javascript" => {
            Some("javascript")
        }
        "text/typescript" | "application/typescript" => Some("typescript"),
        // Markup / data
        "text/markdown" | "text/x-markdown" => Some("markdown"),
        "text/x-yaml" | "application/yaml" | "application/x-yaml" => Some("yaml"),
        "application/toml" => Some("toml"),
        "text/xml" | "application/xml" => Some("xml"),
        // Shell
        "text/x-sh" | "application/x-sh" => Some("shellscript"),
        // SQL
        "text/x-sql" | "application/sql" => Some("sql"),
        _ => None,
    }
}

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------

/// Detect the language of a file from its path.
///
/// Detection order:
/// 1. File extension (case-insensitive).
/// 2. MIME type guessed from the path via `mime_guess` (fallback).
///
/// Returns `None` for unknown file types.
///
/// # Examples
/// ```ignore
/// use std::path::Path;
/// use oo_ide::language::detect_language;
/// assert_eq!(detect_language(Path::new("main.rs")).unwrap().as_str(), "rust");
/// assert!(detect_language(Path::new("unknown.xyz")).is_none());
/// ```
pub fn detect_language(path: &Path) -> Option<LanguageId> {
    // 1. Extension-based lookup.
    if let Some(id) = path
        .extension()
        .and_then(|e| e.to_str())
        .map(|e| e.to_ascii_lowercase())
        .as_deref()
        .and_then(lang_from_ext)
    {
        return Some(LanguageId(Cow::Borrowed(id)));
    }

    // 2. Special-case bare file names without extensions (e.g. "Dockerfile").
    if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
        let lower = name.to_ascii_lowercase();
        if let Some(id) = lang_from_ext(&lower) {
            return Some(LanguageId(Cow::Borrowed(id)));
        }
    }

    // 3. MIME-type fallback.
    let mime = mime_guess::from_path(path).first_raw()?;
    let id = lang_from_mime(mime)?;
    Some(LanguageId(Cow::Borrowed(id)))
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

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

    fn id(path: &str) -> Option<String> {
        detect_language(Path::new(path)).map(|l| l.as_str().to_string())
    }

    #[test]
    fn rust_extension() {
        assert_eq!(id("main.rs"), Some("rust".into()));
    }

    #[test]
    fn cpp_extensions() {
        assert_eq!(id("foo.cpp"), Some("cpp".into()));
        assert_eq!(id("foo.hpp"), Some("cpp".into()));
        assert_eq!(id("foo.cc"), Some("cpp".into()));
    }

    #[test]
    fn python_extension() {
        assert_eq!(id("script.py"), Some("python".into()));
        assert_eq!(id("types.pyi"), Some("python".into()));
    }

    #[test]
    fn toml_extension() {
        assert_eq!(id("Cargo.toml"), Some("toml".into()));
    }

    #[test]
    fn yaml_extensions() {
        assert_eq!(id("config.yaml"), Some("yaml".into()));
        assert_eq!(id("config.yml"), Some("yaml".into()));
    }

    #[test]
    fn unknown_extension_returns_none() {
        assert_eq!(id("archive.tar"), None);
        assert_eq!(id("image.png"), None);
        assert_eq!(id("binary.exe"), None);
        assert_eq!(id("no_extension"), None);
    }

    #[test]
    fn case_insensitive_extension() {
        assert_eq!(id("main.RS"), Some("rust".into()));
        assert_eq!(id("main.Py"), Some("python".into()));
    }

    #[test]
    fn dockerfile_bare_name() {
        assert_eq!(id("Dockerfile"), Some("dockerfile".into()));
    }

    #[test]
    fn language_id_display() {
        let lang = LanguageId::new("rust");
        assert_eq!(lang.to_string(), "rust");
        assert_eq!(lang.as_str(), "rust");
    }

    #[test]
    fn language_id_owned() {
        let lang = LanguageId::new(String::from("my-lang"));
        assert_eq!(lang.as_str(), "my-lang");
    }

    #[test]
    fn language_id_equality() {
        assert_eq!(LanguageId::new("rust"), LanguageId::new("rust"));
        assert_ne!(LanguageId::new("rust"), LanguageId::new("python"));
    }
}