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 let Some(rest) = text.get(index..) {
145            if rest.is_empty() {
146                break;
147            }
148            if let Some(length) = self.comment_length(rest) {
149                found.push((index..index + length, Class::Comment));
150                index += length;
151                continue;
152            }
153            if let Some(length) = self.string_length(rest) {
154                found.push((index..index + length, Class::String));
155                index += length;
156                continue;
157            }
158            let character = rest.chars().next().unwrap_or_default();
159            if is_word(character) {
160                let length = rest
161                    .find(|character: char| !is_word(character))
162                    .unwrap_or(rest.len());
163                if let Some(word) = rest.get(..length)
164                    && let Some(class) = self.word_class(word, character)
165                {
166                    found.push((index..index + length, class));
167                }
168                index += length;
169                continue;
170            }
171            index += character.len_utf8();
172        }
173        found
174    }
175
176    fn word_class(&self, word: &str, first: char) -> Option<Class> {
177        if first.is_ascii_digit() {
178            return Some(Class::Number);
179        }
180        if self.keywords.contains(word) {
181            return Some(Class::Keyword);
182        }
183        if self.types.contains(word) {
184            return Some(Class::Type);
185        }
186        None
187    }
188
189    fn comment_length(&self, rest: &str) -> Option<usize> {
190        if let Some(prefix) = &self.line_comment
191            && rest.starts_with(&**prefix)
192        {
193            return Some(rest.find('\n').unwrap_or(rest.len()));
194        }
195        let block = self.block.as_ref()?;
196        if !rest.starts_with(&*block.start) {
197            return None;
198        }
199        let mut depth = 1usize;
200        let mut offset = block.start.len();
201        while let Some(tail) = rest.get(offset..) {
202            if tail.is_empty() {
203                break;
204            }
205            if tail.starts_with(&*block.end) {
206                offset += block.end.len();
207                depth -= 1;
208                if depth == 0 {
209                    return Some(offset);
210                }
211                continue;
212            }
213            if block.nested && tail.starts_with(&*block.start) {
214                offset += block.start.len();
215                depth += 1;
216                continue;
217            }
218            offset += tail.chars().next().map_or(1, char::len_utf8);
219        }
220        Some(rest.len())
221    }
222
223    fn string_length(&self, rest: &str) -> Option<usize> {
224        let quote = self.quote.as_ref()?;
225        if !rest.starts_with(quote.delimiter) {
226            return None;
227        }
228        let opening = quote.delimiter.len_utf8();
229        let mut characters = rest.get(opening..)?.chars();
230        let mut offset = opening;
231        while let Some(character) = characters.next() {
232            offset += character.len_utf8();
233            if Some(character) == quote.escape {
234                offset += characters.next().map_or(0, char::len_utf8);
235                continue;
236            }
237            if character == quote.delimiter {
238                return Some(offset);
239            }
240        }
241        Some(rest.len())
242    }
243}
244
245fn is_word(character: char) -> bool {
246    character.is_alphanumeric() || character == '_'
247}
248
249/// Where language files live: `$XDG_CONFIG_HOME/idet/syntax`, or
250/// `$HOME/.config/idet/syntax` when that variable is unset.
251#[must_use]
252pub fn directory() -> Option<PathBuf> {
253    let base = std::env::var_os("XDG_CONFIG_HOME")
254        .map(PathBuf::from)
255        .or_else(|| std::env::var_os("HOME").map(|home| Path::new(&home).join(".config")))?;
256    Some(base.join("idet").join("syntax"))
257}
258
259/// Loads the language covering `path`s extension, if one is configured.
260///
261/// Files that fail to parse are skipped, so one broken language file does not
262/// take the working ones down with it.
263#[must_use]
264pub fn for_path(path: &Path) -> Option<Syntax> {
265    let extension = path.extension()?.to_str()?;
266    let entries = std::fs::read_dir(directory()?).ok()?;
267    entries
268        .flatten()
269        .filter(|entry| entry.path().extension().is_some_and(|kind| kind == "idet"))
270        .filter_map(|entry| Syntax::load(&entry.path()).ok())
271        .find(|syntax| syntax.covers(extension))
272}