1use sim_kernel::{Error, Expr, NumberLiteral, Result, Symbol};
7
8#[derive(Clone, Debug, PartialEq, Eq)]
15pub struct DocValue {
16 pub text: String,
18 pub format: DocFormat,
21 pub blocks: Vec<DocBlock>,
23}
24
25#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub enum DocFormat {
28 Text,
30 Markdown,
32}
33
34#[derive(Clone, Debug, PartialEq, Eq)]
37pub struct DocBlock {
38 pub kind: DocBlockKind,
40 pub text: String,
43 pub start: usize,
45 pub end: usize,
47 pub heading_path: Vec<String>,
49 pub level: Option<usize>,
51}
52
53#[derive(Clone, Copy, Debug, PartialEq, Eq)]
55pub enum DocBlockKind {
56 Heading,
58 Paragraph,
60}
61
62#[derive(Clone, Debug, PartialEq, Eq)]
67pub struct DocChunk {
68 pub text: String,
70 pub start: usize,
72 pub end: usize,
74 pub heading_path: Vec<String>,
76}
77
78#[derive(Clone, Copy, Debug, PartialEq, Eq)]
80pub enum ChunkOp {
81 Fixed(usize),
84 Recursive {
87 max: usize,
89 },
90 Heading,
92}
93
94pub fn decode_document(text: &str) -> DocValue {
116 let mut blocks = Vec::new();
117 let mut headings: Vec<String> = Vec::new();
118 let mut paragraph: Option<(usize, usize, Vec<String>)> = None;
119 let mut saw_heading = false;
120
121 for (line_start, line_end, line) in line_segments(text) {
122 if let Some((level, title)) = heading(line) {
123 flush_paragraph(text, &mut blocks, &mut paragraph);
124 saw_heading = true;
125 while headings.len() >= level {
126 headings.pop();
127 }
128 headings.push(title.clone());
129 blocks.push(DocBlock {
130 kind: DocBlockKind::Heading,
131 text: title,
132 start: line_start,
133 end: line_end,
134 heading_path: headings.clone(),
135 level: Some(level),
136 });
137 } else if line.trim().is_empty() {
138 flush_paragraph(text, &mut blocks, &mut paragraph);
139 } else if let Some((_, end, _)) = &mut paragraph {
140 *end = line_end;
141 } else {
142 paragraph = Some((line_start, line_end, headings.clone()));
143 }
144 }
145
146 flush_paragraph(text, &mut blocks, &mut paragraph);
147
148 DocValue {
149 text: text.to_owned(),
150 format: if saw_heading {
151 DocFormat::Markdown
152 } else {
153 DocFormat::Text
154 },
155 blocks,
156 }
157}
158
159pub fn chunk(doc: &DocValue, op: ChunkOp) -> Vec<DocChunk> {
177 match op {
178 ChunkOp::Fixed(max) => fixed_range(&doc.text, 0, doc.text.len(), max, Vec::new()),
179 ChunkOp::Recursive { max } => recursive_chunks(doc, max),
180 ChunkOp::Heading => heading_chunks(doc),
181 }
182}
183
184impl DocValue {
185 pub fn as_expr(&self) -> Expr {
188 Expr::Map(vec![
189 key("kind", Expr::Symbol(Symbol::new("doc"))),
190 key("format", Expr::Symbol(Symbol::new(self.format.name()))),
191 key("text", Expr::String(self.text.clone())),
192 key(
193 "blocks",
194 Expr::List(self.blocks.iter().map(DocBlock::as_expr).collect()),
195 ),
196 ])
197 }
198
199 pub fn from_expr(expr: &Expr) -> Result<Self> {
203 match expr {
204 Expr::String(text) => Ok(decode_document(text)),
205 Expr::Map(entries) => {
206 let kind = map_field(entries, "kind")
207 .ok_or_else(|| Error::Eval("document value requires kind field".to_owned()))?;
208 let Expr::Symbol(symbol) = kind else {
209 return Err(Error::Eval("document kind must be a symbol".to_owned()));
210 };
211 if symbol.name.as_ref() != "doc" {
212 return Err(Error::Eval("document kind must be doc".to_owned()));
213 }
214 let text = map_string(entries, "text")?;
215 Ok(decode_document(text))
216 }
217 _ => Err(Error::TypeMismatch {
218 expected: "document value",
219 found: "non-document",
220 }),
221 }
222 }
223}
224
225impl DocFormat {
226 fn name(self) -> &'static str {
227 match self {
228 Self::Text => "text",
229 Self::Markdown => "markdown",
230 }
231 }
232}
233
234impl DocBlock {
235 fn as_expr(&self) -> Expr {
236 let mut entries = vec![
237 key("kind", Expr::Symbol(Symbol::new(self.kind.name()))),
238 key("text", Expr::String(self.text.clone())),
239 key("start", number(self.start)),
240 key("end", number(self.end)),
241 key("heading_path", string_list(&self.heading_path)),
242 ];
243 if let Some(level) = self.level {
244 entries.push(key("level", number(level)));
245 }
246 Expr::Map(entries)
247 }
248}
249
250impl DocBlockKind {
251 fn name(self) -> &'static str {
252 match self {
253 Self::Heading => "heading",
254 Self::Paragraph => "paragraph",
255 }
256 }
257}
258
259impl DocChunk {
260 pub fn as_expr(&self) -> Expr {
263 Expr::Map(vec![
264 key("kind", Expr::Symbol(Symbol::new("doc-chunk"))),
265 key("text", Expr::String(self.text.clone())),
266 key("start", number(self.start)),
267 key("end", number(self.end)),
268 key("heading_path", string_list(&self.heading_path)),
269 ])
270 }
271}
272
273fn recursive_chunks(doc: &DocValue, max: usize) -> Vec<DocChunk> {
274 if max == 0 {
275 return Vec::new();
276 }
277 let mut chunks = Vec::new();
278 for block in doc
279 .blocks
280 .iter()
281 .filter(|block| block.kind == DocBlockKind::Paragraph)
282 {
283 if block.end.saturating_sub(block.start) <= max {
284 chunks.push(chunk_for_range(
285 &doc.text,
286 block.start,
287 block.end,
288 block.heading_path.clone(),
289 ));
290 } else {
291 chunks.extend(fixed_range(
292 &doc.text,
293 block.start,
294 block.end,
295 max,
296 block.heading_path.clone(),
297 ));
298 }
299 }
300 if chunks.is_empty() && !doc.text.is_empty() {
301 chunks.extend(fixed_range(&doc.text, 0, doc.text.len(), max, Vec::new()));
302 }
303 chunks
304}
305
306fn heading_chunks(doc: &DocValue) -> Vec<DocChunk> {
307 let chunks = doc
308 .blocks
309 .iter()
310 .filter(|block| block.kind == DocBlockKind::Paragraph)
311 .map(|block| {
312 chunk_for_range(
313 &doc.text,
314 block.start,
315 block.end,
316 block.heading_path.clone(),
317 )
318 })
319 .collect::<Vec<_>>();
320 if chunks.is_empty() && !doc.text.is_empty() {
321 vec![chunk_for_range(&doc.text, 0, doc.text.len(), Vec::new())]
322 } else {
323 chunks
324 }
325}
326
327fn fixed_range(
328 text: &str,
329 start: usize,
330 end: usize,
331 max: usize,
332 heading_path: Vec<String>,
333) -> Vec<DocChunk> {
334 if max == 0 || start >= end {
335 return Vec::new();
336 }
337 let mut chunks = Vec::new();
338 let mut cursor = start;
339 while cursor < end {
340 let next = char_boundary_at_or_before(text, cursor.saturating_add(max).min(end), cursor);
341 let next = if next == cursor {
342 next_char_boundary_after(text, cursor).min(end)
343 } else {
344 next
345 };
346 chunks.push(chunk_for_range(text, cursor, next, heading_path.clone()));
347 cursor = next;
348 }
349 chunks
350}
351
352fn chunk_for_range(text: &str, start: usize, end: usize, heading_path: Vec<String>) -> DocChunk {
353 DocChunk {
354 text: text[start..end].to_owned(),
355 start,
356 end,
357 heading_path,
358 }
359}
360
361fn char_boundary_at_or_before(text: &str, mut index: usize, floor: usize) -> usize {
362 while index > floor && !text.is_char_boundary(index) {
363 index -= 1;
364 }
365 index
366}
367
368fn next_char_boundary_after(text: &str, index: usize) -> usize {
369 text[index..]
370 .char_indices()
371 .nth(1)
372 .map(|(offset, _)| index + offset)
373 .unwrap_or(text.len())
374}
375
376fn flush_paragraph(
377 source: &str,
378 blocks: &mut Vec<DocBlock>,
379 paragraph: &mut Option<(usize, usize, Vec<String>)>,
380) {
381 let Some((start, end, heading_path)) = paragraph.take() else {
382 return;
383 };
384 blocks.push(DocBlock {
385 kind: DocBlockKind::Paragraph,
386 text: source[start..end].to_owned(),
387 start,
388 end,
389 heading_path,
390 level: None,
391 });
392}
393
394fn line_segments(source: &str) -> Vec<(usize, usize, &str)> {
395 let mut segments = Vec::new();
396 let mut offset = 0;
397 for segment in source.split_inclusive('\n') {
398 let start = offset;
399 offset += segment.len();
400 let mut end = offset;
401 if segment.ends_with('\n') {
402 end -= 1;
403 if end > start && source.as_bytes()[end - 1] == b'\r' {
404 end -= 1;
405 }
406 }
407 segments.push((start, end, &source[start..end]));
408 }
409 segments
410}
411
412fn heading(line: &str) -> Option<(usize, String)> {
413 let trimmed = line.trim_start();
414 let level = trimmed.bytes().take_while(|byte| *byte == b'#').count();
415 if level == 0 || level > 6 {
416 return None;
417 }
418 let rest = &trimmed[level..];
419 if !rest.starts_with(char::is_whitespace) {
420 return None;
421 }
422 let title = rest.trim();
423 (!title.is_empty()).then(|| (level, title.to_owned()))
424}
425
426fn key(name: &str, value: Expr) -> (Expr, Expr) {
427 (Expr::Symbol(Symbol::new(name)), value)
428}
429
430fn number(value: usize) -> Expr {
431 Expr::Number(NumberLiteral {
432 domain: Symbol::qualified("numbers", "f64"),
433 canonical: value.to_string(),
434 })
435}
436
437fn string_list(values: &[String]) -> Expr {
438 Expr::List(values.iter().cloned().map(Expr::String).collect())
439}
440
441use sim_value::access::entry_field as map_field;
442
443fn map_string<'a>(entries: &'a [(Expr, Expr)], key: &str) -> Result<&'a str> {
444 match map_field(entries, key) {
445 Some(Expr::String(value)) => Ok(value),
446 Some(_) => Err(Error::Eval(format!(
447 "document {key} field must be a string"
448 ))),
449 None => Err(Error::Eval(format!("document value requires {key} field"))),
450 }
451}