idet-core 0.4.0

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
//! 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>>,
    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)?;
        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"),
            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)
    }

    /// 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.
#[must_use]
pub fn directory() -> Option<PathBuf> {
    let base = std::env::var_os("XDG_CONFIG_HOME")
        .map(PathBuf::from)
        .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()?;
    let entries = std::fs::read_dir(directory()?).ok()?;
    entries
        .flatten()
        .filter(|entry| entry.path().extension().is_some_and(|kind| kind == "idet"))
        .filter_map(|entry| Syntax::load(&entry.path()).ok())
        .find(|syntax| syntax.covers(extension))
}