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