Skip to main content

idet_core/
syntax.rs

1//! Syntax highlighting described by a config file, one per language.
2//!
3//! A language file is a flat list of `key value` lines read by [`kv_parser`]:
4//!
5//! ```text
6//! extensions rs
7//! names rust rs
8//! line_comment //
9//! block_comment /* */ nested
10//! strings " \
11//! keywords fn let mut if else match impl pub struct
12//! types u8 u32 usize String Vec Option Result
13//! ```
14//!
15//! Every key is optional. [`Syntax::spans`] scans a text once and reports what
16//! it found; picking colours for the [`Class`]es is left to the frontend.
17
18use std::{
19    collections::HashSet,
20    fmt,
21    ops::Range,
22    path::{Path, PathBuf},
23};
24
25/// What a span of text is, for the frontend to pick a colour for.
26#[derive(Clone, Copy, PartialEq, Eq, Debug)]
27pub enum Class {
28    /// A line or block comment, including its delimiters.
29    Comment,
30    /// A string literal, including its quotes.
31    String,
32    /// A word listed under `keywords`.
33    Keyword,
34    /// A word listed under `types`.
35    Type,
36    /// A word starting with a digit.
37    Number,
38}
39
40/// Why a language file could not be read.
41#[derive(Debug)]
42pub enum Error {
43    /// The file could not be opened or parsed as key-value lines.
44    Read(kv_parser::Error),
45    /// A rule was there but did not have the words it needs.
46    Rule(&'static str),
47}
48
49impl fmt::Display for Error {
50    fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
51        match self {
52            Self::Read(error) => write!(formatter, "{error}"),
53            Self::Rule(key) => write!(formatter, "malformed `{key}` rule"),
54        }
55    }
56}
57
58impl std::error::Error for Error {}
59
60struct Block {
61    start: Box<str>,
62    end: Box<str>,
63    nested: bool,
64}
65
66struct Quote {
67    delimiter: char,
68    escape: Option<char>,
69}
70
71/// The rules of one language, ready to scan text with.
72pub struct Syntax {
73    extensions: Vec<Box<str>>,
74    names: Vec<Box<str>>,
75    line_comment: Option<Box<str>>,
76    block: Option<Block>,
77    quote: Option<Quote>,
78    keywords: HashSet<Box<str>>,
79    types: HashSet<Box<str>>,
80}
81
82impl Syntax {
83    /// Reads a language file.
84    ///
85    /// # Errors
86    ///
87    /// Returns [`Error::Read`] if the file cannot be opened or holds a
88    /// duplicate key, and [`Error::Rule`] if a rule lacks the words it needs —
89    /// `block_comment` without both delimiters, or `strings` without one.
90    pub fn load(path: &Path) -> Result<Self, Error> {
91        let map = kv_parser::file_to_key_value_map(path).map_err(Error::Read)?;
92        Self::from_map(&map)
93    }
94
95    /// Parses a language definition from `text`, the same format [`load`](Self::load)
96    /// reads from a file.
97    ///
98    /// # Errors
99    ///
100    /// Returns [`Error::Read`] if the text holds a duplicate key, and
101    /// [`Error::Rule`] if a rule lacks the words it needs.
102    pub fn parse(text: &str) -> Result<Self, Error> {
103        let map = kv_parser::text_to_key_value_map(text).map_err(Error::Read)?;
104        Self::from_map(&map)
105    }
106
107    fn from_map(map: &std::collections::HashMap<Box<str>, Box<str>>) -> Result<Self, Error> {
108        let words = |key: &str| -> Vec<Box<str>> {
109            map.get(key)
110                .map(|value| value.split_whitespace().map(Box::from).collect())
111                .unwrap_or_default()
112        };
113        let block = match map.get("block_comment") {
114            None => None,
115            Some(value) => {
116                let parts: Vec<&str> = value.split_whitespace().collect();
117                let [start, end, rest @ ..] = parts.as_slice() else {
118                    return Err(Error::Rule("block_comment"));
119                };
120                Some(Block {
121                    start: Box::from(*start),
122                    end: Box::from(*end),
123                    nested: rest.contains(&"nested"),
124                })
125            }
126        };
127        let quote = match map.get("strings") {
128            None => None,
129            Some(value) => {
130                let mut parts = value.split_whitespace();
131                let Some(delimiter) = parts.next().and_then(|part| part.chars().next()) else {
132                    return Err(Error::Rule("strings"));
133                };
134                Some(Quote {
135                    delimiter,
136                    escape: parts.next().and_then(|part| part.chars().next()),
137                })
138            }
139        };
140        Ok(Self {
141            extensions: words("extensions"),
142            names: words("names"),
143            line_comment: map.get("line_comment").map(|value| Box::from(&**value)),
144            block,
145            quote,
146            keywords: words("keywords").into_iter().collect(),
147            types: words("types").into_iter().collect(),
148        })
149    }
150
151    /// Whether this language claims files ending in `extension`.
152    #[must_use]
153    pub fn covers(&self, extension: &str) -> bool {
154        self.extensions.iter().any(|known| &**known == extension)
155    }
156
157    /// Whether this language answers to `name`, the way a markdown fence names
158    /// one. Both the `names` line and the extensions count, so ```` ```rust ````
159    /// and ```` ```rs ```` find the same language.
160    #[must_use]
161    pub fn covers_language(&self, name: &str) -> bool {
162        self.names.iter().any(|known| &**known == name) || self.covers(name)
163    }
164
165    /// Scans `text` and reports every span that carries a [`Class`].
166    ///
167    /// The ranges are byte offsets into `text`, in order and without overlap.
168    /// Whatever is not covered has no class and stays unstyled.
169    #[must_use]
170    pub fn spans(&self, text: &str) -> Vec<(Range<usize>, Class)> {
171        let mut found = Vec::new();
172        let mut index = 0;
173        while let Some(rest) = text.get(index..) {
174            if rest.is_empty() {
175                break;
176            }
177            if let Some(length) = self.comment_length(rest) {
178                found.push((index..index + length, Class::Comment));
179                index += length;
180                continue;
181            }
182            if let Some(length) = self.string_length(rest) {
183                found.push((index..index + length, Class::String));
184                index += length;
185                continue;
186            }
187            let character = rest.chars().next().unwrap_or_default();
188            if is_word(character) {
189                let length = rest
190                    .find(|character: char| !is_word(character))
191                    .unwrap_or(rest.len());
192                if let Some(word) = rest.get(..length)
193                    && let Some(class) = self.word_class(word, character)
194                {
195                    found.push((index..index + length, class));
196                }
197                index += length;
198                continue;
199            }
200            index += character.len_utf8();
201        }
202        found
203    }
204
205    fn word_class(&self, word: &str, first: char) -> Option<Class> {
206        if first.is_ascii_digit() {
207            return Some(Class::Number);
208        }
209        if self.keywords.contains(word) {
210            return Some(Class::Keyword);
211        }
212        if self.types.contains(word) {
213            return Some(Class::Type);
214        }
215        None
216    }
217
218    fn comment_length(&self, rest: &str) -> Option<usize> {
219        if let Some(prefix) = &self.line_comment
220            && rest.starts_with(&**prefix)
221        {
222            return Some(rest.find('\n').unwrap_or(rest.len()));
223        }
224        let block = self.block.as_ref()?;
225        if !rest.starts_with(&*block.start) {
226            return None;
227        }
228        let mut depth = 1usize;
229        let mut offset = block.start.len();
230        while let Some(tail) = rest.get(offset..) {
231            if tail.is_empty() {
232                break;
233            }
234            if tail.starts_with(&*block.end) {
235                offset += block.end.len();
236                depth -= 1;
237                if depth == 0 {
238                    return Some(offset);
239                }
240                continue;
241            }
242            if block.nested && tail.starts_with(&*block.start) {
243                offset += block.start.len();
244                depth += 1;
245                continue;
246            }
247            offset += tail.chars().next().map_or(1, char::len_utf8);
248        }
249        Some(rest.len())
250    }
251
252    fn string_length(&self, rest: &str) -> Option<usize> {
253        let quote = self.quote.as_ref()?;
254        if !rest.starts_with(quote.delimiter) {
255            return None;
256        }
257        let opening = quote.delimiter.len_utf8();
258        let mut characters = rest.get(opening..)?.chars();
259        let mut offset = opening;
260        while let Some(character) = characters.next() {
261            offset += character.len_utf8();
262            if Some(character) == quote.escape {
263                offset += characters.next().map_or(0, char::len_utf8);
264                continue;
265            }
266            if character == quote.delimiter {
267                return Some(offset);
268            }
269        }
270        Some(rest.len())
271    }
272}
273
274fn is_word(character: char) -> bool {
275    character.is_alphanumeric() || character == '_'
276}
277
278/// Where language files live: `$XDG_CONFIG_HOME/idet/syntax`, or
279/// `$HOME/.config/idet/syntax` when that variable is unset, empty or relative.
280#[must_use]
281pub fn directory() -> Option<PathBuf> {
282    let base = std::env::var_os("XDG_CONFIG_HOME")
283        .map(PathBuf::from)
284        .filter(|path| path.is_absolute())
285        .or_else(|| std::env::var_os("HOME").map(|home| Path::new(&home).join(".config")))?;
286    Some(base.join("idet").join("syntax"))
287}
288
289/// Loads the language covering `path`s extension, if one is configured.
290///
291/// Files that fail to parse are skipped, so one broken language file does not
292/// take the working ones down with it.
293#[must_use]
294pub fn for_path(path: &Path) -> Option<Syntax> {
295    let extension = path.extension()?.to_str()?;
296    lookup(&|syntax| syntax.covers(extension))
297}
298
299/// Loads the language answering to `name`, the way a markdown fence names one:
300/// `rust`, `bash`, `python`. Extensions count as names too, so `rs` finds the
301/// same language as `rust`.
302///
303/// Files that fail to parse are skipped, as in [`for_path`].
304#[must_use]
305pub fn for_language(name: &str) -> Option<Syntax> {
306    lookup(&|syntax| syntax.covers_language(name))
307}
308
309fn lookup(wanted: &dyn Fn(&Syntax) -> bool) -> Option<Syntax> {
310    if let Some(dir) = directory()
311        && let Ok(entries) = std::fs::read_dir(&dir)
312    {
313        let found = entries
314            .flatten()
315            .filter(|entry| entry.path().extension().is_some_and(|kind| kind == "idet"))
316            .filter_map(|entry| Syntax::load(&entry.path()).ok())
317            .find(|syntax| wanted(syntax));
318        if found.is_some() {
319            return found;
320        }
321    }
322    BUILTINS
323        .iter()
324        .filter_map(|text| Syntax::parse(text).ok())
325        .find(|syntax| wanted(syntax))
326}
327
328const BUILTINS: [&str; 11] = [
329    include_str!("../syntax/rust.idet"),
330    include_str!("../syntax/toml.idet"),
331    include_str!("../syntax/json.idet"),
332    include_str!("../syntax/shell.idet"),
333    include_str!("../syntax/python.idet"),
334    include_str!("../syntax/javascript.idet"),
335    include_str!("../syntax/typescript.idet"),
336    include_str!("../syntax/c.idet"),
337    include_str!("../syntax/cpp.idet"),
338    include_str!("../syntax/yaml.idet"),
339    include_str!("../syntax/markdown.idet"),
340];