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