idet-core 0.4.1

Editing logic for text editors, without a frontend
Documentation
//! Syntax highlighting described by a config file, one per language.
//!
//! A language file is a flat list of `key value` lines read by [`kv_parser`]:
//!
//! ```text
//! extensions rs
//! names rust rs
//! line_comment //
//! block_comment /* */ nested
//! strings " \
//! keywords fn let mut if else match impl pub struct
//! types u8 u32 usize String Vec Option Result
//! ```
//!
//! Every key is optional. [`Syntax::spans`] scans a text once and reports what
//! it found; picking colours for the [`Class`]es is left to the frontend.

use std::{
    collections::HashSet,
    fmt,
    ops::Range,
    path::{Path, PathBuf},
};

/// What a span of text is, for the frontend to pick a colour for.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Class {
    /// A line or block comment, including its delimiters.
    Comment,
    /// A string literal, including its quotes.
    String,
    /// A word listed under `keywords`.
    Keyword,
    /// A word listed under `types`.
    Type,
    /// A word starting with a digit.
    Number,
}

/// Why a language file could not be read.
#[derive(Debug)]
pub enum Error {
    /// The file could not be opened or parsed as key-value lines.
    Read(kv_parser::Error),
    /// A rule was there but did not have the words it needs.
    Rule(&'static str),
}

impl fmt::Display for Error {
    fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
        match self {
            Self::Read(error) => write!(formatter, "{error}"),
            Self::Rule(key) => write!(formatter, "malformed `{key}` rule"),
        }
    }
}

impl std::error::Error for Error {}

struct Block {
    start: Box<str>,
    end: Box<str>,
    nested: bool,
}

struct Quote {
    delimiter: char,
    escape: Option<char>,
}

/// The rules of one language, ready to scan text with.
pub struct Syntax {
    extensions: Vec<Box<str>>,
    names: Vec<Box<str>>,
    line_comment: Option<Box<str>>,
    block: Option<Block>,
    quote: Option<Quote>,
    keywords: HashSet<Box<str>>,
    types: HashSet<Box<str>>,
}

impl Syntax {
    /// Reads a language file.
    ///
    /// # Errors
    ///
    /// Returns [`Error::Read`] if the file cannot be opened or holds a
    /// duplicate key, and [`Error::Rule`] if a rule lacks the words it needs —
    /// `block_comment` without both delimiters, or `strings` without one.
    pub fn load(path: &Path) -> Result<Self, Error> {
        let map = kv_parser::file_to_key_value_map(path).map_err(Error::Read)?;
        Self::from_map(&map)
    }

    /// Parses a language definition from `text`, the same format [`load`](Self::load)
    /// reads from a file.
    ///
    /// # Errors
    ///
    /// Returns [`Error::Read`] if the text holds a duplicate key, and
    /// [`Error::Rule`] if a rule lacks the words it needs.
    pub fn parse(text: &str) -> Result<Self, Error> {
        let map = kv_parser::text_to_key_value_map(text).map_err(Error::Read)?;
        Self::from_map(&map)
    }

    fn from_map(map: &std::collections::HashMap<Box<str>, Box<str>>) -> Result<Self, Error> {
        let words = |key: &str| -> Vec<Box<str>> {
            map.get(key)
                .map(|value| value.split_whitespace().map(Box::from).collect())
                .unwrap_or_default()
        };
        let block = match map.get("block_comment") {
            None => None,
            Some(value) => {
                let parts: Vec<&str> = value.split_whitespace().collect();
                let [start, end, rest @ ..] = parts.as_slice() else {
                    return Err(Error::Rule("block_comment"));
                };
                Some(Block {
                    start: Box::from(*start),
                    end: Box::from(*end),
                    nested: rest.contains(&"nested"),
                })
            }
        };
        let quote = match map.get("strings") {
            None => None,
            Some(value) => {
                let mut parts = value.split_whitespace();
                let Some(delimiter) = parts.next().and_then(|part| part.chars().next()) else {
                    return Err(Error::Rule("strings"));
                };
                Some(Quote {
                    delimiter,
                    escape: parts.next().and_then(|part| part.chars().next()),
                })
            }
        };
        Ok(Self {
            extensions: words("extensions"),
            names: words("names"),
            line_comment: map.get("line_comment").map(|value| Box::from(&**value)),
            block,
            quote,
            keywords: words("keywords").into_iter().collect(),
            types: words("types").into_iter().collect(),
        })
    }

    /// Whether this language claims files ending in `extension`.
    #[must_use]
    pub fn covers(&self, extension: &str) -> bool {
        self.extensions.iter().any(|known| &**known == extension)
    }

    /// Whether this language answers to `name`, the way a markdown fence names
    /// one. Both the `names` line and the extensions count, so ```` ```rust ````
    /// and ```` ```rs ```` find the same language.
    #[must_use]
    pub fn covers_language(&self, name: &str) -> bool {
        self.names.iter().any(|known| &**known == name) || self.covers(name)
    }

    /// Scans `text` and reports every span that carries a [`Class`].
    ///
    /// The ranges are byte offsets into `text`, in order and without overlap.
    /// Whatever is not covered has no class and stays unstyled.
    #[must_use]
    pub fn spans(&self, text: &str) -> Vec<(Range<usize>, Class)> {
        let mut found = Vec::new();
        let mut index = 0;
        while let Some(rest) = text.get(index..) {
            if rest.is_empty() {
                break;
            }
            if let Some(length) = self.comment_length(rest) {
                found.push((index..index + length, Class::Comment));
                index += length;
                continue;
            }
            if let Some(length) = self.string_length(rest) {
                found.push((index..index + length, Class::String));
                index += length;
                continue;
            }
            let character = rest.chars().next().unwrap_or_default();
            if is_word(character) {
                let length = rest
                    .find(|character: char| !is_word(character))
                    .unwrap_or(rest.len());
                if let Some(word) = rest.get(..length)
                    && let Some(class) = self.word_class(word, character)
                {
                    found.push((index..index + length, class));
                }
                index += length;
                continue;
            }
            index += character.len_utf8();
        }
        found
    }

    fn word_class(&self, word: &str, first: char) -> Option<Class> {
        if first.is_ascii_digit() {
            return Some(Class::Number);
        }
        if self.keywords.contains(word) {
            return Some(Class::Keyword);
        }
        if self.types.contains(word) {
            return Some(Class::Type);
        }
        None
    }

    fn comment_length(&self, rest: &str) -> Option<usize> {
        if let Some(prefix) = &self.line_comment
            && rest.starts_with(&**prefix)
        {
            return Some(rest.find('\n').unwrap_or(rest.len()));
        }
        let block = self.block.as_ref()?;
        if !rest.starts_with(&*block.start) {
            return None;
        }
        let mut depth = 1usize;
        let mut offset = block.start.len();
        while let Some(tail) = rest.get(offset..) {
            if tail.is_empty() {
                break;
            }
            if tail.starts_with(&*block.end) {
                offset += block.end.len();
                depth -= 1;
                if depth == 0 {
                    return Some(offset);
                }
                continue;
            }
            if block.nested && tail.starts_with(&*block.start) {
                offset += block.start.len();
                depth += 1;
                continue;
            }
            offset += tail.chars().next().map_or(1, char::len_utf8);
        }
        Some(rest.len())
    }

    fn string_length(&self, rest: &str) -> Option<usize> {
        let quote = self.quote.as_ref()?;
        if !rest.starts_with(quote.delimiter) {
            return None;
        }
        let opening = quote.delimiter.len_utf8();
        let mut characters = rest.get(opening..)?.chars();
        let mut offset = opening;
        while let Some(character) = characters.next() {
            offset += character.len_utf8();
            if Some(character) == quote.escape {
                offset += characters.next().map_or(0, char::len_utf8);
                continue;
            }
            if character == quote.delimiter {
                return Some(offset);
            }
        }
        Some(rest.len())
    }
}

fn is_word(character: char) -> bool {
    character.is_alphanumeric() || character == '_'
}

/// Where language files live: `$XDG_CONFIG_HOME/idet/syntax`, or
/// `$HOME/.config/idet/syntax` when that variable is unset, empty or relative.
#[must_use]
pub fn directory() -> Option<PathBuf> {
    let base = std::env::var_os("XDG_CONFIG_HOME")
        .map(PathBuf::from)
        .filter(|path| path.is_absolute())
        .or_else(|| std::env::var_os("HOME").map(|home| Path::new(&home).join(".config")))?;
    Some(base.join("idet").join("syntax"))
}

/// Loads the language covering `path`s extension, if one is configured.
///
/// Files that fail to parse are skipped, so one broken language file does not
/// take the working ones down with it.
#[must_use]
pub fn for_path(path: &Path) -> Option<Syntax> {
    let extension = path.extension()?.to_str()?;
    lookup(&|syntax| syntax.covers(extension))
}

/// Loads the language answering to `name`, the way a markdown fence names one:
/// `rust`, `bash`, `python`. Extensions count as names too, so `rs` finds the
/// same language as `rust`.
///
/// Files that fail to parse are skipped, as in [`for_path`].
#[must_use]
pub fn for_language(name: &str) -> Option<Syntax> {
    lookup(&|syntax| syntax.covers_language(name))
}

fn lookup(wanted: &dyn Fn(&Syntax) -> bool) -> Option<Syntax> {
    if let Some(dir) = directory()
        && let Ok(entries) = std::fs::read_dir(&dir)
    {
        let found = entries
            .flatten()
            .filter(|entry| entry.path().extension().is_some_and(|kind| kind == "idet"))
            .filter_map(|entry| Syntax::load(&entry.path()).ok())
            .find(|syntax| wanted(syntax));
        if found.is_some() {
            return found;
        }
    }
    BUILTINS
        .iter()
        .filter_map(|text| Syntax::parse(text).ok())
        .find(|syntax| wanted(syntax))
}

const BUILTINS: [&str; 11] = [
    include_str!("../syntax/rust.idet"),
    include_str!("../syntax/toml.idet"),
    include_str!("../syntax/json.idet"),
    include_str!("../syntax/shell.idet"),
    include_str!("../syntax/python.idet"),
    include_str!("../syntax/javascript.idet"),
    include_str!("../syntax/typescript.idet"),
    include_str!("../syntax/c.idet"),
    include_str!("../syntax/cpp.idet"),
    include_str!("../syntax/yaml.idet"),
    include_str!("../syntax/markdown.idet"),
];