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