Acord/viewport/src/editor/eval.rs

829 lines
32 KiB
Rust

use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Instant;
use iced_widget::markdown;
use crate::blocks;
use crate::table_block::TableBlock;
use crate::text_block::TextBlock;
use crate::text_widget::{self, Action, Motion};
use super::types::{
Anchor, ComputedTable, ComputedTree, InlineResult, TableIndex,
EVAL_DEBOUNCE_MS, LONG_PRESS_MS,
};
use super::types::{resolve_ref_key, ERROR_PREFIX, RESULT_PREFIX};
use super::RenderMode;
impl super::EditorState {
/// maps a line number in concatenated module source back to a per-block anchor
pub(super) fn map_line_to_anchor(
boundaries: &[(usize, crate::selection::BlockId)],
global_line: usize,
) -> Anchor {
let mut best_id = boundaries.first().map(|b| b.1).unwrap_or(0);
let mut best_start = 0;
for &(start, id) in boundaries {
if start <= global_line {
best_id = id;
best_start = start;
} else {
break;
}
}
Anchor {
block_id: best_id,
after_line: global_line - best_start,
}
}
pub fn tick(&mut self) {
if self.heavy_pending && self.last_edit.elapsed().as_millis() >= 300 {
self.heavy_token = self.heavy_token.wrapping_add(1);
self.heavy_pending = false;
}
if self.render_mode != RenderMode::Live { return; }
if self.eval_dirty && self.last_edit.elapsed().as_millis() >= EVAL_DEBOUNCE_MS {
self.eval_dirty = false;
self.run_eval();
}
{
let block_start = self.layout.get(self.focused_block)
.and_then(|id| self.registry.get(id))
.map(|b| b.start_line())
.unwrap_or(0);
let intra = self.content().cursor().position.line;
let global_line = block_start + intra;
if global_line != self.prev_cursor_line {
self.prev_cursor_line = global_line;
if !self.eval_dirty {
self.run_eval();
}
}
}
let due = self.inline_press.as_ref().is_some_and(|s| {
!s.fired_long_press && s.started_at.elapsed().as_millis() >= LONG_PRESS_MS
});
if due {
if let Some(s) = self.inline_press.as_mut() {
s.fired_long_press = true;
let bid = s.block_id;
let line = s.after_line;
self.copy_inline_result(bid, line);
}
}
let block_ids: Vec<crate::selection::BlockId> = self.layout.clone();
for id in block_ids {
if let Some(block) = self.registry.get_mut(&id) {
if let Some(tb) = block.as_any_mut().downcast_mut::<TableBlock>() {
tb.check_hover_spillover();
}
}
}
}
pub(super) fn reparse(&mut self) {
let text = self.get_clean_text();
self.parsed = markdown::parse(&text).collect();
self.rebuild_modules();
self.refresh_text_caches();
self.heavy_pending = true;
}
pub(super) fn build_block_infos(&self) -> Vec<crate::module::BlockInfo> {
use crate::heading_block::HeadingBlock;
use crate::module::BlockInfo;
self.layout.iter().filter_map(|&id| {
let block = self.registry.get(&id)?;
let tag = block.kind_tag();
let (heading_level, heading_text) = if let Some(hb) = block.as_any().downcast_ref::<HeadingBlock>() {
(hb.level.as_u8(), hb.text.clone())
} else {
(0, String::new())
};
let text_content = if tag == "text" { block.to_md() } else { String::new() };
Some(BlockInfo { id, kind_tag: tag, heading_level, heading_text, text_content })
}).collect()
}
/// rebuilds the module list and applies heading-based table names
pub(super) fn rebuild_modules(&mut self) {
use crate::module::{compute_modules, detect_table_names};
let infos = self.build_block_infos();
self.modules = compute_modules(&infos);
let names = detect_table_names(&infos);
for assignment in names {
if let Some(block) = self.registry.get_mut(&assignment.table_id) {
if let Some(tb) = block.as_any_mut().downcast_mut::<TableBlock>() {
tb.table_name = Some(assignment.name);
}
}
}
}
/// registers every non-eval-result table on the interpreter and returns the alias index
pub(super) fn register_visible_tables(
&self,
interp: &mut acord_core::interp::Interpreter,
focused_block_idx: usize,
) -> TableIndex {
use crate::module::{
compute_positional_ids, detect_table_names, normalize_name, TableNameScope,
};
let infos = self.build_block_infos();
let table_names = detect_table_names(&infos);
let pos_ids = compute_positional_ids(&infos);
let mut block_to_module: HashMap<crate::selection::BlockId, String> = HashMap::new();
for m in &self.modules {
for &bid in &m.block_ids {
block_to_module.insert(bid, m.name.clone());
}
}
let focused_id = self.layout.get(focused_block_idx).copied();
let focused_module_name = focused_id.and_then(|id| block_to_module.get(&id).cloned());
interp.set_current_block(focused_module_name.as_deref());
let mut keys_map: HashMap<String, crate::selection::BlockId> = HashMap::new();
let mut canonical: HashMap<crate::selection::BlockId, String> = HashMap::new();
for (table_id, pos_name, _pos_block_pos) in &pos_ids.tables {
let Some(block) = self.registry.get(table_id) else { continue };
let Some(tb) = block.as_any().downcast_ref::<TableBlock>() else { continue };
if tb.is_eval_result { continue; }
let rows = tb.rows.clone();
let heading = table_names.iter().find(|a| a.table_id == *table_id);
let module_name = block_to_module.get(table_id).cloned();
let canonical_key = match heading {
Some(h) => {
let hname = normalize_name(&h.name);
match h.scope {
TableNameScope::Global => hname,
TableNameScope::BlockScoped => {
if let Some(ref m) = module_name {
format!("{}::{}", m, hname)
} else {
hname
}
}
}
}
None => pos_name.to_lowercase(),
};
canonical.insert(*table_id, canonical_key.clone());
let mut keys: Vec<String> = vec![pos_name.to_lowercase(), canonical_key.clone()];
if let Some(h) = heading {
let hname = normalize_name(&h.name);
if h.scope == TableNameScope::BlockScoped {
if module_name.as_deref() == focused_module_name.as_deref() {
keys.push(hname);
}
}
}
if let Some(ref m) = module_name {
keys.push(format!("{}::{}", m, pos_name.to_lowercase()));
}
keys.sort();
keys.dedup();
for k in &keys {
interp.register_table(k, rows.clone());
keys_map.insert(k.clone(), *table_id);
}
}
TableIndex { keys: keys_map, canonical }
}
/// returns true if any visible table contains a `/=` formula cell
pub(super) fn any_visible_cell_formulas(&self) -> bool {
for block in self.registry.values() {
if let Some(tb) = block.as_any().downcast_ref::<TableBlock>() {
if tb.is_eval_result { continue; }
if tb.rows.iter().any(|row| row.iter().any(|c| c.trim_start().starts_with("/="))) {
return true;
}
}
}
false
}
/// parses, topo-sorts, and evaluates every visible cell formula
pub(super) fn evaluate_cell_formulas(
&mut self,
interp: &mut acord_core::interp::Interpreter,
table_index: &TableIndex,
) {
use acord_core::interp::{parse_formula_with_spice, ParsedFormula, Value};
struct Cell {
table_key: String,
col: u32,
row: u32,
block_id: crate::selection::BlockId,
ast: ParsedFormula,
}
let mut formulas: Vec<Cell> = Vec::new();
let mut parse_errors: Vec<(crate::selection::BlockId, u32, u32, String)> = Vec::new();
let mut seen_blocks: std::collections::HashSet<crate::selection::BlockId> =
std::collections::HashSet::new();
for (_, &block_id) in &table_index.keys {
if !seen_blocks.insert(block_id) { continue; }
let Some(block) = self.registry.get(&block_id) else { continue };
let Some(tb) = block.as_any().downcast_ref::<TableBlock>() else { continue };
let canonical = match table_index.canonical.get(&block_id) {
Some(k) => k.clone(),
None => continue,
};
for (r, row) in tb.rows.iter().enumerate() {
for (c, cell) in row.iter().enumerate() {
let trimmed = cell.trim_start();
let Some(body) = trimmed.strip_prefix("/=") else { continue };
match parse_formula_with_spice(body, interp.spice_enabled()) {
Ok(ast) => formulas.push(Cell {
table_key: canonical.clone(),
col: c as u32,
row: r as u32,
block_id,
ast,
}),
Err(e) => parse_errors.push((block_id, c as u32, r as u32, e)),
}
}
}
}
self.computed_cells.retain(|k, _| !seen_blocks.contains(&k.0));
for (bid, c, r, e) in parse_errors {
self.computed_cells.insert((bid, c, r), Value::Error(format!("parse: {}", e)));
}
if formulas.is_empty() {
return;
}
let node_key: HashMap<(String, u32, u32), usize> = formulas.iter().enumerate()
.map(|(i, f)| ((f.table_key.clone(), f.col, f.row), i))
.collect();
let mut edges: Vec<Vec<usize>> = vec![Vec::new(); formulas.len()];
let mut in_degree: Vec<usize> = vec![0; formulas.len()];
for (i, f) in formulas.iter().enumerate() {
let refs = f.ast.refs(&f.table_key);
for r in refs {
let resolved = resolve_ref_key(&r, table_index);
if let Some(key) = resolved {
if let Some(&dep) = node_key.get(&(key, r.cell.0, r.cell.1)) {
if dep != i {
edges[dep].push(i);
in_degree[i] += 1;
}
}
}
}
}
let mut queue: std::collections::VecDeque<usize> = in_degree.iter().enumerate()
.filter_map(|(i, &d)| if d == 0 { Some(i) } else { None })
.collect();
let mut order: Vec<usize> = Vec::new();
while let Some(i) = queue.pop_front() {
order.push(i);
let next = std::mem::take(&mut edges[i]);
for j in next {
in_degree[j] -= 1;
if in_degree[j] == 0 {
queue.push_back(j);
}
}
}
let ordered: std::collections::HashSet<usize> = order.iter().copied().collect();
for i in &order {
let f = &formulas[*i];
interp.set_current_table(Some(&f.table_key));
let result = match interp.eval_formula(&f.ast) {
Ok(v) => v,
Err(e) => Value::Error(e),
};
interp.set_current_table(None);
if !result.is_error() {
let display = result.display();
for (alias_key, &bid) in &table_index.keys {
if bid == f.block_id {
interp.write_cell_raw(alias_key, f.col, f.row, &display);
}
}
}
self.computed_cells.insert((f.block_id, f.col, f.row), result);
}
for i in 0..formulas.len() {
if ordered.contains(&i) { continue; }
let f = &formulas[i];
self.computed_cells.insert((f.block_id, f.col, f.row), Value::Error("cycle".into()));
}
}
/// applies cell writes logged by the interpreter to live tables
pub(super) fn apply_table_writes(
&mut self,
writes: Vec<acord_core::interp::TableWrite>,
table_index: &TableIndex,
) {
for w in writes {
let Some(&block_id) = table_index.keys.get(&w.table_key) else { continue };
let Some(block) = self.registry.get_mut(&block_id) else { continue };
let Some(tb) = block.as_any_mut().downcast_mut::<TableBlock>() else { continue };
let (c, r) = (w.cell.0 as usize, w.cell.1 as usize);
while tb.rows.len() <= r { tb.rows.push(Vec::new()); }
let target_cols = (c + 1).max(tb.col_widths.len());
while tb.col_widths.len() < target_cols { tb.col_widths.push(120.0); }
while tb.row_heights.len() < tb.rows.len() { tb.row_heights.push(None); }
while tb.rows[r].len() <= c { tb.rows[r].push(String::new()); }
tb.rows[r][c] = w.value;
}
}
/// reparses the full text and reconciles block structure changes.
pub(super) fn check_block_structure(&mut self) {
let cursor = self.content().cursor();
let full = self.full_text();
let lang = self.lang_str();
let old_count = self.block_count();
{
let mut block_vec = self.registry_to_vec();
blocks::reparse_incremental(&mut block_vec, &full, &lang);
self.replace_blocks(block_vec);
}
if self.focused_block >= self.block_count() {
self.set_focused_block(self.block_count().saturating_sub(1));
}
if self.block_count() != old_count {
if let Some(bi) = self.block_index_at_line(cursor.position.line) {
self.set_focused_block(bi);
}
}
self.rebuild_modules();
self.refresh_text_caches();
}
/// returns the concatenated text of all text blocks in a module
pub(super) fn module_source_text(&self, module: &crate::module::Module) -> String {
let mut parts = Vec::new();
for &bid in &module.block_ids {
if let Some(block) = self.registry.get(&bid) {
if block.kind_tag() == "text" {
parts.push(block.to_md());
}
}
}
parts.join("\n")
}
/// builds an interpreter pre-populated with root and use-declared module exports.
pub(super) fn build_eval_interpreter(&self, block_idx: usize) -> acord_core::interp::Interpreter {
use acord_core::interp;
let mut eval_interp = interp::Interpreter::new();
let block_id = match self.layout.get(block_idx) {
Some(&id) => id,
None => return eval_interp,
};
let my_module = self.modules.iter().find(|m| m.block_ids.contains(&block_id));
let is_root = my_module.map(|m| m.is_root).unwrap_or(false);
if !is_root {
if let Some(root) = self.modules.iter().find(|m| m.is_root) {
let mut visited: std::collections::HashSet<String> = std::collections::HashSet::new();
let root_exports = self.resolve_module_exports(root, &mut visited);
eval_interp.import_all(&root_exports);
}
}
let use_block_ids: Vec<crate::selection::BlockId> = my_module
.map(|m| m.block_ids.clone())
.unwrap_or_default();
let my_module_name = my_module.map(|m| m.name.clone()).unwrap_or_default();
for &bid in &use_block_ids {
if let Some(block) = self.registry.get(&bid) {
if let Some(tb) = block.as_any().downcast_ref::<TextBlock>() {
let text = tb.content.text();
let use_decls = interp::extract_use_declarations(&text);
for decl in &use_decls {
let mut visited: std::collections::HashSet<String> = std::collections::HashSet::new();
if !my_module_name.is_empty() {
visited.insert(my_module_name.clone());
}
self.apply_use_decl(decl, &mut visited, &mut eval_interp);
}
}
}
}
eval_interp
}
/// recursively evaluates a module with use declarations resolved.
pub(super) fn resolve_module_exports(
&self,
module: &crate::module::Module,
visited: &mut std::collections::HashSet<String>,
) -> acord_core::interp::ModuleExports {
use acord_core::interp;
if !module.name.is_empty() && !visited.insert(module.name.clone()) {
return interp::ModuleExports::default();
}
let mut interp = interp::Interpreter::new();
if !module.is_root {
if let Some(root) = self.modules.iter().find(|m| m.is_root) {
if root.name != module.name {
let root_exports = self.resolve_module_exports(root, visited);
interp.import_all(&root_exports);
}
}
}
let module_text = self.module_source_text(module);
let use_decls = interp::extract_use_declarations(&module_text);
for decl in &use_decls {
self.apply_use_decl(decl, visited, &mut interp);
}
crate::eval::evaluate_document_with_interp(&mut interp, &module_text);
interp.exports()
}
/// flags use targets the language handles internally.
pub(super) fn is_builtin_use(module: &str) -> bool {
module == "spice" || module.starts_with("spice::")
|| module == "ring" || module.starts_with("ring::")
}
/// merges a single use decl's exports into the given interpreter.
pub(super) fn apply_use_decl(
&self,
decl: &acord_core::interp::UseDecl,
visited: &mut std::collections::HashSet<String>,
target: &mut acord_core::interp::Interpreter,
) {
let root = decl.root();
if Self::is_builtin_use(root) { return; }
if let Some(local) = self.modules.iter().find(|m| m.name == root) {
let exports = self.resolve_module_exports(local, visited);
if decl.wildcard || decl.segments.len() < 2 {
target.import_all(&exports);
} else if let Some(item) = decl.segments.last() {
target.import_item(&exports, item);
}
return;
}
self.apply_external_use_decl(decl, visited, target);
}
/// resolves a sibling-note use decl against either the whole file or a named H2 submodule.
fn apply_external_use_decl(
&self,
decl: &acord_core::interp::UseDecl,
visited: &mut std::collections::HashSet<String>,
target: &mut acord_core::interp::Interpreter,
) {
use acord_core::interp;
let root = decl.root();
let cycle_key = format!("__ext__{}", root);
if !visited.insert(cycle_key) { return; }
let dir = self.notes_dir();
let path = dir.join(format!("{}.md", root));
let Ok(bytes) = std::fs::read(&path) else { return };
let (text_bytes, _archive) = crate::sidecar::extract_from_md(&bytes);
let Ok(raw) = String::from_utf8(text_bytes) else { return };
let loaded = crate::sidecar::extract_archive(&raw);
let clean = super::strip_result_lines(&loaded.markdown);
let lang = self.lang_str();
let ext_blocks = blocks::parse_blocks(&clean, &lang);
let ext_infos = build_block_infos_from(&ext_blocks);
let ext_modules = crate::module::compute_modules(&ext_infos);
let item_name: Option<&str> = if decl.wildcard {
None
} else if decl.segments.len() >= 2 {
Some(decl.segments[1].as_str())
} else {
None
};
let submodule_match = item_name.and_then(|item| {
ext_modules.iter().find(|m| m.name == item)
});
if let Some(submod) = submodule_match {
let mut nested = interp::Interpreter::new();
for d in interp::extract_use_declarations(&clean) {
self.apply_use_decl(&d, visited, &mut nested);
}
if !submod.is_root {
if let Some(root) = ext_modules.iter().find(|m| m.is_root) {
let root_text = module_text_from_blocks(root, &ext_blocks);
crate::eval::evaluate_document_with_interp(&mut nested, &root_text);
}
}
let submod_text = module_text_from_blocks(submod, &ext_blocks);
crate::eval::evaluate_document_with_interp(&mut nested, &submod_text);
target.import_all(&nested.exports());
return;
}
let mut nested = interp::Interpreter::new();
for d in interp::extract_use_declarations(&clean) {
self.apply_use_decl(&d, visited, &mut nested);
}
crate::eval::evaluate_document_with_interp(&mut nested, &clean);
let exports = nested.exports();
if decl.wildcard || item_name.is_none() {
target.import_all(&exports);
} else if let Some(item) = item_name {
target.import_item(&exports, item);
}
}
/// resolves the directory sibling notes live in.
pub(super) fn notes_dir(&self) -> PathBuf {
let from_settings = self.settings_view.auto_save_dir.trim();
if !from_settings.is_empty() {
return PathBuf::from(from_settings);
}
if let Ok(home) = std::env::var("HOME") {
return PathBuf::from(home).join(".acord").join("notes");
}
PathBuf::from(".")
}
pub(super) fn run_eval(&mut self) {
self.rebuild_modules();
let focused_id = match self.layout.get(self.focused_block) {
Some(&id) => id,
None => return,
};
let module = match self.modules.iter().find(|m| m.block_ids.contains(&focused_id)) {
Some(m) => m.clone(),
None => return,
};
let mut source_parts: Vec<String> = Vec::new();
let mut boundaries: Vec<(usize, crate::selection::BlockId)> = Vec::new();
let mut cumulative = 0usize;
let mut block_ids: Vec<crate::selection::BlockId> = Vec::new();
for &bid in &module.block_ids {
if let Some(block) = self.registry.get(&bid) {
if block.kind_tag() == "text" {
boundaries.push((cumulative, bid));
block_ids.push(bid);
let text = block.to_md();
let lc = text.lines().count().max(1);
source_parts.push(text);
cumulative += lc;
}
}
}
let source = source_parts.join("\n");
self.scan_images(&boundaries, &block_ids);
let has_text_eval = source.lines().any(|l| l.trim_start().starts_with("/="));
let has_cell_formulas = self.any_visible_cell_formulas();
if !has_text_eval && !has_cell_formulas {
self.clear_layers_for_blocks(&block_ids);
self.computed_cells.clear();
return;
}
let mut interp = self.build_eval_interpreter(self.focused_block);
let table_keys = self.register_visible_tables(&mut interp, self.focused_block);
self.evaluate_cell_formulas(&mut interp, &table_keys);
let doc = crate::eval::evaluate_document_with_interp(&mut interp, &source);
let writes = interp.drain_table_writes();
self.apply_table_writes(writes, &table_keys);
self.clear_layers_for_blocks(&block_ids);
for r in &doc.results {
let anchor = Self::map_line_to_anchor(&boundaries, r.line);
if r.format == "table" {
match serde_json::from_str::<Vec<Vec<String>>>(&r.result) {
Ok(rows) if !rows.is_empty() => {
let col_count = rows.iter().map(|r| r.len()).max().unwrap_or(0);
let mut col_widths = vec![120.0f32; col_count];
for row in &rows {
for (ci, cell) in row.iter().enumerate() {
let w = cell.len() as f32 * 8.0 + 16.0;
if ci < col_widths.len() && w > col_widths[ci] {
col_widths[ci] = w;
}
}
}
self.computed_tables.push(ComputedTable {
anchor,
rows,
col_widths,
});
continue;
}
_ => {}
}
self.eval_results.push(InlineResult {
anchor,
text: format!("{}{}", RESULT_PREFIX, r.result),
is_error: false,
});
} else if r.format == "tree" {
match serde_json::from_str::<serde_json::Value>(&r.result) {
Ok(data) => {
self.computed_trees.push(ComputedTree { anchor, data });
}
Err(_) => {
self.eval_results.push(InlineResult {
anchor,
text: format!("{}{}", RESULT_PREFIX, r.result),
is_error: false,
});
}
}
} else {
self.eval_results.push(InlineResult {
anchor,
text: format!("{}{}", RESULT_PREFIX, r.result),
is_error: false,
});
}
}
for e in &doc.errors {
let anchor = Self::map_line_to_anchor(&boundaries, e.line);
self.eval_results.push(InlineResult {
anchor,
text: format!("{}{}", ERROR_PREFIX, e.error),
is_error: true,
});
}
}
/// evaluates every module in document order
pub(super) fn run_eval_all(&mut self) {
self.rebuild_modules();
self.eval_results.clear();
self.computed_tables.clear();
self.computed_trees.clear();
self.computed_cells.clear();
let saved = self.focused_block;
let modules: Vec<crate::module::Module> = self.modules.clone();
for module in &modules {
let anchor_idx = module.block_ids.iter()
.find_map(|bid| self.layout.iter().position(|id| id == bid));
if let Some(idx) = anchor_idx {
self.focused_block = idx;
self.run_eval();
}
}
self.focused_block = saved;
}
/// returns the inline result text for a given anchor
pub(super) fn inline_result_value(&self, block_id: crate::selection::BlockId, after_line: usize) -> Option<String> {
let r = self.eval_results.iter().find(|r| {
r.anchor.block_id == block_id && r.anchor.after_line == after_line && !r.is_error
})?;
Some(r.text.trim_start_matches(RESULT_PREFIX).trim().to_string())
}
/// reads one line from the text block with the given id.
pub(super) fn read_line_at(&self, block_id: crate::selection::BlockId, line_idx: usize) -> Option<String> {
let block = self.registry.get(&block_id)?;
let tb = block.as_any().downcast_ref::<TextBlock>()?;
tb.content.line(line_idx).map(|l| l.text.to_string())
}
/// copies the source line and result value pair to the clipboard.
pub(super) fn copy_inline_result(&mut self, block_id: crate::selection::BlockId, after_line: usize) {
let value = match self.inline_result_value(block_id, after_line) {
Some(v) => v,
None => return,
};
let line = self.read_line_at(block_id, after_line).unwrap_or_default();
let trimmed = line.trim_end();
self.pending_clipboard = Some(format!("{trimmed} {RESULT_PREFIX}{value}"));
}
/// copies the result and inserts a let binding below the source line.
pub(super) fn handle_result_extract(&mut self, block_id: crate::selection::BlockId, after_line: usize) {
let value = match self.inline_result_value(block_id, after_line) {
Some(v) => v,
None => return,
};
self.copy_inline_result(block_id, after_line);
let block_idx = match self.layout.iter().position(|id| *id == block_id) {
Some(i) => i,
None => return,
};
if self.text_block_at(block_idx).is_none() { return; }
self.push_undo_snapshot();
self.redo_stack.clear();
self.set_focused_block(block_idx);
let content = self.content_mut();
content.perform(Action::Move(Motion::DocumentStart));
for _ in 0..after_line {
content.perform(Action::Move(Motion::Down));
}
content.perform(Action::Move(Motion::End));
let paste = format!("\n\nlet = {value}");
content.perform(Action::Edit(text_widget::Edit::Paste(Arc::new(paste))));
let back = 3 + value.chars().count();
for _ in 0..back {
content.perform(Action::Move(Motion::Left));
}
self.last_edit = Instant::now();
self.eval_dirty = true;
self.reparse();
}
}
/// builds BlockInfos from a free-standing block list.
fn build_block_infos_from(blocks: &[crate::blocks::BoxedBlock]) -> Vec<crate::module::BlockInfo> {
use crate::heading_block::HeadingBlock;
use crate::module::BlockInfo;
blocks.iter().map(|block| {
let tag = block.kind_tag();
let (heading_level, heading_text) = if let Some(hb) = block.as_any().downcast_ref::<HeadingBlock>() {
(hb.level.as_u8(), hb.text.clone())
} else {
(0, String::new())
};
let text_content = if tag == "text" { block.to_md() } else { String::new() };
BlockInfo { id: block.id(), kind_tag: tag, heading_level, heading_text, text_content }
}).collect()
}
/// concatenates the text-block sources within a module against an arbitrary block list.
fn module_text_from_blocks(module: &crate::module::Module, blocks: &[crate::blocks::BoxedBlock]) -> String {
let mut parts = Vec::new();
for &bid in &module.block_ids {
if let Some(block) = blocks.iter().find(|b| b.id() == bid) {
if block.kind_tag() == "text" {
parts.push(block.to_md());
}
}
}
parts.join("\n")
}
pub(super) fn parse_let_binding(line: &str) -> Option<String> {
let rest = line.strip_prefix("let ")?;
let eq_pos = rest.find('=')?;
if rest.as_bytes().get(eq_pos + 1) == Some(&b'=') {
return None;
}
let name_part = rest[..eq_pos].trim();
let name = if let Some(colon) = name_part.find(':') {
name_part[..colon].trim()
} else {
name_part
};
if name.is_empty() || !name.chars().all(|c| c.is_alphanumeric() || c == '_') {
return None;
}
Some(name.to_string())
}