1use std::{
19 collections::HashSet,
20 fmt,
21 ops::Range,
22 path::{Path, PathBuf},
23};
24
25#[derive(Clone, Copy, PartialEq, Eq, Debug)]
27pub enum Class {
28 Comment,
30 String,
32 Keyword,
34 Type,
36 Number,
38}
39
40#[derive(Debug)]
42pub enum Error {
43 Read(kv_parser::Error),
45 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
71pub 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 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 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 #[must_use]
153 pub fn covers(&self, extension: &str) -> bool {
154 self.extensions.iter().any(|known| &**known == extension)
155 }
156
157 #[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 #[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#[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#[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#[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];