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