1use std::collections::HashSet;
18use std::fmt;
19use std::ops::Range;
20use std::path::{Path, PathBuf};
21
22#[derive(Clone, Copy, PartialEq, Eq, Debug)]
24pub enum Class {
25 Comment,
27 String,
29 Keyword,
31 Type,
33 Number,
35}
36
37#[derive(Debug)]
39pub enum Error {
40 Read(kv_parser::Error),
42 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
68pub 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 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 #[must_use]
132 pub fn covers(&self, extension: &str) -> bool {
133 self.extensions.iter().any(|known| &**known == extension)
134 }
135
136 #[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#[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#[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}