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>>,
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 {
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)
}
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(),
})
}
#[must_use]
pub fn covers(&self, extension: &str) -> bool {
self.extensions.iter().any(|known| &**known == extension)
}
#[must_use]
pub fn covers_language(&self, name: &str) -> bool {
self.names.iter().any(|known| &**known == name) || self.covers(name)
}
#[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)
.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"))
}
#[must_use]
pub fn for_path(path: &Path) -> Option<Syntax> {
let extension = path.extension()?.to_str()?;
lookup(&|syntax| syntax.covers(extension))
}
#[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"),
];