use std::{
collections::HashSet,
fmt,
ops::Range,
path::{Path, PathBuf},
};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Class {
Comment,
String,
Keyword,
Type,
Number,
}
#[derive(Debug)]
pub enum Error {
Read(kv_parser::Error),
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>,
}
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 {
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(),
})
}
#[must_use]
pub fn covers(&self, extension: &str) -> bool {
self.extensions.iter().any(|known| &**known == extension)
}
#[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 == '_'
}
#[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"))
}
#[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))
}