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