1use somni_parser::Location;
21
22use crate::error::TemplateError;
23
24#[derive(Clone, Debug, PartialEq, Eq)]
26pub enum BlockStyle {
27 Paired {
29 open: String,
31 close: String,
33 },
34 Line {
39 prefix: String,
41 },
42}
43
44#[derive(Clone, Debug, PartialEq, Eq)]
46pub struct Syntax {
47 pub expr: (String, String),
49 pub block: BlockStyle,
51 pub text_prefix: Option<String>,
60}
61
62impl Default for Syntax {
63 fn default() -> Self {
64 Self::brackets()
65 }
66}
67
68const FRONTMATTER_KEYS: &[&str] = &["expr", "block", "text_prefix"];
70
71impl Syntax {
72 pub fn brackets() -> Self {
74 Self {
75 expr: ("{{".into(), "}}".into()),
76 block: BlockStyle::Paired {
77 open: "{%".into(),
78 close: "%}".into(),
79 },
80 text_prefix: None,
81 }
82 }
83
84 pub fn lines() -> Self {
86 Self {
87 expr: ("{{".into(), "}}".into()),
88 block: BlockStyle::Line { prefix: "#".into() },
89 text_prefix: None,
90 }
91 }
92
93 pub fn validate(&self) -> Result<(), TemplateError> {
98 if let Some(tp) = &self.text_prefix {
99 if tp.is_empty() {
100 return Err(TemplateError::new(
101 "`text_prefix` must not be empty",
102 Location { start: 0, end: 0 },
103 ));
104 }
105 if let BlockStyle::Line { prefix } = &self.block {
106 if prefix == tp {
107 return Err(TemplateError::new(
108 "`text_prefix` must differ from the block line prefix",
109 Location { start: 0, end: 0 },
110 ));
111 }
112 }
113 }
114 Ok(())
115 }
116
117 pub fn with_frontmatter(&self, front: &str) -> Result<Self, TemplateError> {
130 let mut syntax = self.clone();
131 let mut offset = 0usize;
132 let mut seen = [false; FRONTMATTER_KEYS.len()];
133
134 for line in front.split_inclusive('\n') {
135 let line_start = offset;
136 offset += line.len();
137
138 let trimmed = line.trim();
139 if trimmed.is_empty() || trimmed.starts_with('#') {
141 continue;
142 }
143
144 let (key, value) = trimmed.split_once(':').ok_or_else(|| {
145 TemplateError::new(
146 format!("frontmatter line is not `key: value`: {trimmed:?}"),
147 line_span(front, line_start, line.len()),
148 )
149 })?;
150
151 let key = key.trim();
152 let Some(idx) = FRONTMATTER_KEYS.iter().position(|&k| k == key) else {
153 return Err(TemplateError::new(
154 format!("unknown frontmatter key: {key:?}"),
155 line_span(front, line_start, line.len()),
156 ));
157 };
158 if seen[idx] {
159 return Err(TemplateError::new(
160 format!("`{key}` specified more than once"),
161 line_span(front, line_start, line.len()),
162 ));
163 }
164 seen[idx] = true;
165
166 let mut parts = value.split_whitespace();
167 match key {
168 "expr" => {
169 let open = parts.next().ok_or_else(|| {
170 TemplateError::new(
171 "`expr` needs an opening delimiter",
172 line_span(front, line_start, line.len()),
173 )
174 })?;
175 let close = parts.next().ok_or_else(|| {
176 TemplateError::new(
177 "`expr` needs a closing delimiter",
178 line_span(front, line_start, line.len()),
179 )
180 })?;
181 syntax.expr = (open.to_string(), close.to_string());
182 }
183 "block" => match parts.next() {
184 Some("line") => {
185 let prefix = parts.next().ok_or_else(|| {
186 TemplateError::new(
187 "`block: line` needs a prefix",
188 line_span(front, line_start, line.len()),
189 )
190 })?;
191 if syntax.text_prefix.as_deref() == Some(prefix) {
192 return Err(TemplateError::new(
193 "`text_prefix` must differ from the block line prefix",
194 line_span(front, line_start, line.len()),
195 ));
196 }
197 syntax.block = BlockStyle::Line {
198 prefix: prefix.to_string(),
199 };
200 }
201 Some("paired") => {
202 let open = parts.next().ok_or_else(|| {
203 TemplateError::new(
204 "`block: paired` needs an opening delimiter",
205 line_span(front, line_start, line.len()),
206 )
207 })?;
208 let close = parts.next().ok_or_else(|| {
209 TemplateError::new(
210 "`block: paired` needs a closing delimiter",
211 line_span(front, line_start, line.len()),
212 )
213 })?;
214 syntax.block = BlockStyle::Paired {
215 open: open.to_string(),
216 close: close.to_string(),
217 };
218 }
219 other => {
220 return Err(TemplateError::new(
221 format!("unknown block style: {other:?}"),
222 line_span(front, line_start, line.len()),
223 ));
224 }
225 },
226 "text_prefix" => {
227 let prefix = parts.next().ok_or_else(|| {
228 TemplateError::new(
229 "`text_prefix` needs a prefix",
230 line_span(front, line_start, line.len()),
231 )
232 })?;
233 if let BlockStyle::Line {
234 prefix: block_prefix,
235 } = &syntax.block
236 {
237 if block_prefix == prefix {
238 return Err(TemplateError::new(
239 "`text_prefix` must differ from the block line prefix",
240 line_span(front, line_start, line.len()),
241 ));
242 }
243 }
244 syntax.text_prefix = Some(prefix.to_string());
245 }
246 _ => unreachable!("key matched FRONTMATTER_KEYS"),
247 }
248 }
249
250 syntax.validate()?;
251 Ok(syntax)
252 }
253}
254
255pub fn split_frontmatter(source: &str) -> Option<(&str, &str)> {
263 let rest = source
264 .strip_prefix("---\r\n")
265 .or_else(|| source.strip_prefix("---\n"))?;
266
267 if let Some(end) = rest.find("\n---\n") {
270 let front = rest[..end].strip_suffix('\r').unwrap_or(&rest[..end]);
271 let body = &rest[end + "\n---\n".len()..];
272 return Some((front, body));
273 }
274 if let Some(end) = rest.find("\n---\r\n") {
275 let front = rest[..end].strip_suffix('\r').unwrap_or(&rest[..end]);
276 let body = &rest[end + "\n---\r\n".len()..];
277 return Some((front, body));
278 }
279 None
280}
281
282pub fn resolve_syntax<'a>(
289 source: &'a str,
290 base: &Syntax,
291) -> Result<(Syntax, &'a str, usize), TemplateError> {
292 let Some((front, body)) = split_frontmatter(source) else {
293 base.validate()?;
294 return Ok((base.clone(), source, 0));
295 };
296
297 let front_start = source
299 .strip_prefix("---\r\n")
300 .or_else(|| source.strip_prefix("---\n"))
301 .map(|rest| source.len() - rest.len())
302 .expect("split_frontmatter found a fence");
303
304 let syntax = base.with_frontmatter(front).map_err(|err| {
305 TemplateError::new(
306 err.message,
307 Location {
308 start: err.location.start + front_start,
309 end: err.location.end + front_start,
310 },
311 )
312 })?;
313 let body_offset = source.len() - body.len();
315 Ok((syntax, body, body_offset))
316}
317
318fn line_span(front: &str, line_start: usize, line_len: usize) -> Location {
319 let end = (line_start + line_len).min(front.len());
320 let content_end = front.as_bytes()[line_start..end]
322 .iter()
323 .rposition(|&b| b != b'\n' && b != b'\r')
324 .map(|i| line_start + i + 1)
325 .unwrap_or(line_start);
326 Location {
327 start: line_start,
328 end: content_end.max(line_start),
329 }
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335
336 #[test]
337 fn split_no_frontmatter() {
338 assert_eq!(split_frontmatter("hello"), None);
339 assert_eq!(split_frontmatter("---\nno close"), None);
340 }
341
342 #[test]
343 fn split_lf_and_crlf() {
344 assert_eq!(
345 split_frontmatter("---\nblock: line #\n---\nbody"),
346 Some(("block: line #", "body"))
347 );
348 assert_eq!(
349 split_frontmatter("---\r\nblock: line #\r\n---\r\nbody"),
350 Some(("block: line #", "body"))
351 );
352 }
353
354 #[test]
355 fn frontmatter_overlays_base() {
356 let base = Syntax {
357 expr: ("[[".into(), "]]".into()),
358 block: BlockStyle::Paired {
359 open: "{%".into(),
360 close: "%}".into(),
361 },
362 text_prefix: None,
363 };
364 let merged = base
365 .with_frontmatter("block: line //\ntext_prefix: //>")
366 .unwrap();
367 assert_eq!(merged.expr, ("[[".into(), "]]".into()));
368 assert_eq!(
369 merged.block,
370 BlockStyle::Line {
371 prefix: "//".into()
372 }
373 );
374 assert_eq!(merged.text_prefix.as_deref(), Some("//>"));
375 }
376
377 #[test]
378 fn frontmatter_rejects_duplicate_keys() {
379 for (front, duplicate_line) in [
380 ("block: line #\nblock: paired {% %}", "block: paired {% %}"),
381 ("expr: {{ }}\nexpr: [[ ]]", "expr: [[ ]]"),
382 ] {
383 let err = Syntax::brackets().with_frontmatter(front).unwrap_err();
384 assert!(
385 err.message.contains("more than once"),
386 "unexpected message for {front:?}: {}",
387 err.message
388 );
389 assert_eq!(err.location.extract(front), duplicate_line);
390 }
391 }
392
393 #[test]
394 fn frontmatter_ignores_hash_comments() {
395 let merged = Syntax::brackets()
396 .with_frontmatter(
397 "# use C-style block comments\n\
398 block: paired /* */\n\
399 # keep default expr\n",
400 )
401 .unwrap();
402 assert_eq!(merged.expr, ("{{".into(), "}}".into()));
403 assert_eq!(
404 merged.block,
405 BlockStyle::Paired {
406 open: "/*".into(),
407 close: "*/".into(),
408 }
409 );
410 }
411
412 #[test]
413 fn resolve_prefers_frontmatter_over_base() {
414 let base = Syntax::lines();
415 let source = "---\nblock: paired /* */\n---\n/* if x */y/* endif */";
416 let (syntax, body, body_offset) = resolve_syntax(source, &base).unwrap();
417 assert_eq!(body, "/* if x */y/* endif */");
418 assert_eq!(&source[body_offset..], body);
419 assert_eq!(
420 syntax.block,
421 BlockStyle::Paired {
422 open: "/*".into(),
423 close: "*/".into(),
424 }
425 );
426 assert_eq!(syntax.expr, base.expr);
428 }
429}