use ingot_source::{SourceMap, Span};
use ingot_syntax::{Expr, PolicyAction, Program, Stmt, StringLit};
use serde::Serialize;
use crate::{source_range, SourceRange};
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Canvas {
pub agent: String,
pub blocks: Vec<Block>,
pub edges: Vec<Edge>,
pub boundaries: Vec<Boundary>,
pub policy: Vec<PolicyBlockView>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Block {
pub id: String,
pub kind: BlockKind,
pub span: SourceRange,
pub move_unit: SourceRange,
pub binding: Option<String>,
pub source: String,
pub leaves: Vec<Leaf>,
pub children: Vec<Block>,
pub boundaries: Vec<Boundary>,
pub editable: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum BlockKind {
ModelCall,
Question,
ToolCall,
MemoryWrite,
Check,
Marker,
Output,
Container,
FanOut,
Unreadable,
Unknown,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Leaf {
pub role: String,
pub kind: LeafKind,
pub span: SourceRange,
pub text: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum LeafKind {
Text,
Name,
Type,
Number,
PolicyAction,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Boundary {
pub index: usize,
pub byte: u32,
pub indent: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Edge {
pub from: String,
pub to: String,
pub name: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PolicyBlockView {
pub subject: String,
pub span: SourceRange,
pub move_unit: SourceRange,
pub source: String,
pub leaves: Vec<Leaf>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CanvasEdit {
pub start_byte: u32,
pub end_byte: u32,
pub expected: String,
pub new_text: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EditRefused {
OutsideFile { start: u32, end: u32, len: u32 },
Stale { expected: String, found: String },
}
impl std::fmt::Display for EditRefused {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
EditRefused::OutsideFile { start, end, len } => write!(
f,
"the edit covers bytes {start}..{end} of a {len}-byte file"
),
EditRefused::Stale { .. } => f.write_str(
"this file changed since the canvas read it; reload before editing again",
),
}
}
}
impl std::error::Error for EditRefused {}
pub fn apply(source: &str, edit: &CanvasEdit) -> Result<String, EditRefused> {
let len = source.len() as u32;
if edit.start_byte > edit.end_byte
|| edit.end_byte > len
|| !source.is_char_boundary(edit.start_byte as usize)
|| !source.is_char_boundary(edit.end_byte as usize)
{
return Err(EditRefused::OutsideFile {
start: edit.start_byte,
end: edit.end_byte,
len,
});
}
let found = &source[edit.start_byte as usize..edit.end_byte as usize];
if found != edit.expected {
return Err(EditRefused::Stale {
expected: edit.expected.clone(),
found: found.to_string(),
});
}
let mut out = String::with_capacity(source.len() + edit.new_text.len());
out.push_str(&source[..edit.start_byte as usize]);
out.push_str(&edit.new_text);
out.push_str(&source[edit.end_byte as usize..]);
Ok(out)
}
pub fn replace_leaf(source: &str, leaf: &Leaf, new_text: impl Into<String>) -> CanvasEdit {
CanvasEdit {
start_byte: leaf.span.start_byte,
end_byte: leaf.span.end_byte,
expected: slice(source, leaf.span.start_byte, leaf.span.end_byte),
new_text: new_text.into(),
}
}
pub fn delete_block(source: &str, block: &Block) -> CanvasEdit {
CanvasEdit {
start_byte: block.move_unit.start_byte,
end_byte: block.move_unit.end_byte,
expected: slice(source, block.move_unit.start_byte, block.move_unit.end_byte),
new_text: String::new(),
}
}
pub fn insert_at(boundary: &Boundary, text: impl AsRef<str>) -> CanvasEdit {
CanvasEdit {
start_byte: boundary.byte,
end_byte: boundary.byte,
expected: String::new(),
new_text: format!("{}{}\n", boundary.indent, text.as_ref().trim_end()),
}
}
pub fn move_block(
source: &str,
blocks: &[Block],
block: &Block,
to: &Boundary,
) -> Option<CanvasEdit> {
let from = blocks.iter().position(|other| other.id == block.id)?;
if to.index == from || to.index == from + 1 {
return None;
}
let unit_start = block.move_unit.start_byte;
let unit_end = block.move_unit.end_byte;
let cut = slice(source, unit_start, unit_end);
let (start, end, new_text) = if to.index < from {
let start = to.byte;
let between = slice(source, start, unit_start);
(start, unit_end, format!("{cut}{between}"))
} else {
let end = to.byte;
let between = slice(source, unit_end, end);
(unit_start, end, format!("{between}{cut}"))
};
Some(CanvasEdit {
start_byte: start,
end_byte: end,
expected: slice(source, start, end),
new_text,
})
}
pub fn move_unit(source: &str, start: u32, end: u32, floor: u32) -> (u32, u32) {
let mut unit_start = line_start(source, start);
while unit_start > floor {
let previous = line_start(source, unit_start.saturating_sub(1));
if previous < floor {
break;
}
let line = slice(source, previous, unit_start);
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with("//") {
unit_start = previous;
} else {
break;
}
}
(unit_start, line_end(source, end))
}
fn line_start(source: &str, byte: u32) -> u32 {
source[..byte as usize]
.rfind('\n')
.map(|index| index as u32 + 1)
.unwrap_or(0)
}
fn line_end(source: &str, byte: u32) -> u32 {
match source[byte as usize..].find('\n') {
Some(offset) => byte + offset as u32 + 1,
None => source.len() as u32,
}
}
fn slice(source: &str, start: u32, end: u32) -> String {
source
.get(start as usize..end as usize)
.unwrap_or_default()
.to_string()
}
fn indent_of(source: &str, byte: u32) -> String {
let start = line_start(source, byte);
source[start as usize..]
.chars()
.take_while(|character| *character == ' ' || *character == '\t')
.collect()
}
pub fn canvas_of(
map: &SourceMap,
program: &Program,
source: &str,
agent: Option<&str>,
) -> Option<Canvas> {
let decl = match agent {
Some(name) => program.agents.iter().find(|decl| decl.name.text == name)?,
None => program.agents.first()?,
};
let flow = decl.flow.as_ref()?;
let mut next = 0usize;
let floor = line_start(source, flow.span.start);
let blocks = render_block_list(map, source, &flow.statements, floor, &mut next);
let boundaries = boundaries_for(source, &blocks, flow.span);
let edges = derive_edges(&blocks);
let policy = decl
.policy
.as_ref()
.map(|block| {
block
.rules
.iter()
.map(|rule| {
let span = rule.span;
let floor = line_start(source, block.span.start);
let (unit_start, unit_end) = move_unit(source, span.start, span.end, floor);
PolicyBlockView {
subject: rule.subject.text.clone(),
span: source_range(map, span),
move_unit: source_range(map, Span::new(span.file, unit_start, unit_end)),
source: slice(source, span.start, span.end),
leaves: policy_leaves(map, source, rule),
}
})
.collect()
})
.unwrap_or_default();
Some(Canvas {
agent: decl.name.text.clone(),
blocks,
edges,
boundaries,
policy,
})
}
fn render_block_list(
map: &SourceMap,
source: &str,
statements: &[Stmt],
floor: u32,
next: &mut usize,
) -> Vec<Block> {
statements
.iter()
.map(|statement| render_block(map, source, statement, floor, next))
.collect()
}
fn render_block(
map: &SourceMap,
source: &str,
statement: &Stmt,
floor: u32,
next: &mut usize,
) -> Block {
let span = statement_span(statement);
let id = format!("b{}", *next);
*next += 1;
let (kind, binding, children_source) = classify(statement);
let (unit_start, unit_end) = move_unit(source, span.start, span.end, floor);
let children = match children_source {
Some(inner) => {
let inner_floor = line_start(source, span.start);
render_block_list(map, source, inner, inner_floor, next)
}
None => Vec::new(),
};
let boundaries = if children.is_empty() {
Vec::new()
} else {
boundaries_for(source, &children, span)
};
Block {
id,
kind,
span: source_range(map, span),
move_unit: source_range(map, Span::new(span.file, unit_start, unit_end)),
binding,
source: slice(source, span.start, span.end),
leaves: leaves_of(map, source, statement),
children,
boundaries,
editable: !matches!(kind, BlockKind::Unreadable | BlockKind::Unknown),
}
}
fn classify(statement: &Stmt) -> (BlockKind, Option<String>, Option<&[Stmt]>) {
match statement {
Stmt::Bind { name, value, .. } => {
let binding = Some(name.text.clone());
match value {
Expr::Ask { .. } => (BlockKind::ModelCall, binding, None),
Expr::Consult { .. } => (BlockKind::Question, binding, None),
Expr::Call { .. } => (BlockKind::ToolCall, binding, None),
Expr::ParallelMap { body, .. } => (BlockKind::FanOut, binding, Some(body)),
_ => (BlockKind::Unknown, binding, None),
}
}
Stmt::StateWrite { .. } => (BlockKind::MemoryWrite, None, None),
Stmt::Verify { .. } => (BlockKind::Check, None, None),
Stmt::Emit { output, .. } => (BlockKind::Output, Some(output.text.clone()), None),
Stmt::Checkpoint { .. } => (BlockKind::Marker, None, None),
Stmt::If { then_branch, .. } => (BlockKind::Container, None, Some(then_branch)),
Stmt::Loop { body, .. } => (BlockKind::Container, None, Some(body)),
Stmt::Expr { value, .. } => match value {
Expr::Call { .. } => (BlockKind::ToolCall, None, None),
_ => (BlockKind::Unknown, None, None),
},
Stmt::Error { .. } => (BlockKind::Unreadable, None, None),
}
}
fn statement_span(statement: &Stmt) -> Span {
match statement {
Stmt::Bind { span, .. }
| Stmt::StateWrite { span, .. }
| Stmt::Expr { span, .. }
| Stmt::Verify { span, .. }
| Stmt::Emit { span, .. }
| Stmt::If { span, .. }
| Stmt::Loop { span, .. }
| Stmt::Checkpoint { span, .. }
| Stmt::Error { span } => *span,
}
}
fn boundaries_for(source: &str, blocks: &[Block], container: Span) -> Vec<Boundary> {
let mut boundaries = Vec::with_capacity(blocks.len() + 1);
for (index, block) in blocks.iter().enumerate() {
boundaries.push(Boundary {
index,
byte: block.move_unit.start_byte,
indent: indent_of(source, block.span.start_byte),
});
}
let (byte, indent) = match blocks.last() {
Some(last) => (
last.move_unit.end_byte,
indent_of(source, last.span.start_byte),
),
None => (line_end(source, container.start), String::new()),
};
boundaries.push(Boundary {
index: blocks.len(),
byte,
indent,
});
boundaries
}
fn derive_edges(blocks: &[Block]) -> Vec<Edge> {
let mut edges = Vec::new();
for (index, block) in blocks.iter().enumerate() {
let Some(name) = &block.binding else { continue };
for later in &blocks[index + 1..] {
if mentions(&later.source, name) {
edges.push(Edge {
from: block.id.clone(),
to: later.id.clone(),
name: name.clone(),
});
}
}
}
edges
}
fn mentions(text: &str, name: &str) -> bool {
let bytes = text.as_bytes();
let mut from = 0usize;
while let Some(found) = text[from..].find(name) {
let at = from + found;
let before_ok = at == 0 || !is_word_byte(bytes[at - 1]);
let after = at + name.len();
let after_ok = after >= bytes.len() || !is_word_byte(bytes[after]);
if before_ok && after_ok {
return true;
}
from = at + name.len().max(1);
}
false
}
fn is_word_byte(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || byte == b'_'
}
fn leaves_of(map: &SourceMap, source: &str, statement: &Stmt) -> Vec<Leaf> {
let mut leaves = Vec::new();
match statement {
Stmt::Bind { value, .. } => expr_leaves(map, source, value, &mut leaves),
Stmt::Expr { value, .. } => expr_leaves(map, source, value, &mut leaves),
Stmt::Emit { value, .. } => expr_leaves(map, source, value, &mut leaves),
Stmt::StateWrite { value, field, .. } => {
push(
map,
source,
"field",
LeafKind::Name,
field.span,
&mut leaves,
);
expr_leaves(map, source, value, &mut leaves);
}
Stmt::Verify {
validator, args, ..
} => {
push(
map,
source,
"verifier",
LeafKind::Name,
validator.span,
&mut leaves,
);
for arg in args {
expr_leaves(map, source, &arg.value, &mut leaves);
}
}
Stmt::Checkpoint { label, .. } => {
push_string(map, source, "label", label, &mut leaves);
}
Stmt::If { condition, .. } => expr_leaves(map, source, condition, &mut leaves),
Stmt::Loop { .. } | Stmt::Error { .. } => {}
}
leaves
}
fn expr_leaves(map: &SourceMap, source: &str, expr: &Expr, leaves: &mut Vec<Leaf>) {
match expr {
Expr::Ask { result, args, .. } => {
push(map, source, "type", LeafKind::Type, result.span(), leaves);
for arg in args {
let role = arg.name.as_ref().map(|name| name.text.as_str());
arg_leaf(map, source, role.unwrap_or("prompt"), &arg.value, leaves);
}
}
Expr::Consult { args, .. } => {
for arg in args {
let role = arg.name.as_ref().map(|name| name.text.as_str());
arg_leaf(map, source, role.unwrap_or("question"), &arg.value, leaves);
}
}
Expr::Call { callee, args, .. } => {
push(map, source, "tool", LeafKind::Name, callee.span, leaves);
for arg in args {
let role = arg.name.as_ref().map(|name| name.text.as_str());
arg_leaf(map, source, role.unwrap_or("argument"), &arg.value, leaves);
}
}
Expr::Str(literal) => push_string(map, source, "text", literal, leaves),
Expr::Path(path) => push(map, source, "name", LeafKind::Name, path.span, leaves),
Expr::ParallelMap { source: over, .. } => {
if let Expr::Path(path) = &**over {
push(map, source, "over", LeafKind::Name, path.span, leaves);
}
}
_ => {}
}
}
fn arg_leaf(map: &SourceMap, source: &str, role: &str, value: &Expr, leaves: &mut Vec<Leaf>) {
match value {
Expr::Str(literal) => push_string(map, source, role, literal, leaves),
Expr::Path(path) => push(map, source, role, LeafKind::Name, path.span, leaves),
Expr::Int { span, .. } | Expr::Float { span, .. } => {
push(map, source, role, LeafKind::Number, *span, leaves)
}
_ => {}
}
}
fn push_string(
map: &SourceMap,
source: &str,
role: &str,
literal: &StringLit,
leaves: &mut Vec<Leaf>,
) {
push(map, source, role, LeafKind::Text, literal.span, leaves);
}
fn push(
map: &SourceMap,
source: &str,
role: &str,
kind: LeafKind,
span: Span,
leaves: &mut Vec<Leaf>,
) {
leaves.push(Leaf {
role: role.to_string(),
kind,
span: source_range(map, span),
text: slice(source, span.start, span.end),
});
}
fn policy_leaves(map: &SourceMap, source: &str, rule: &ingot_syntax::PolicyRule) -> Vec<Leaf> {
let mut leaves = Vec::new();
push(
map,
source,
"subject",
LeafKind::Name,
rule.subject.span,
&mut leaves,
);
let action = match &rule.action {
PolicyAction::Allow { span, .. }
| PolicyAction::Deny { span, .. }
| PolicyAction::RequireApproval { span } => *span,
};
push(
map,
source,
"action",
LeafKind::PolicyAction,
action,
&mut leaves,
);
leaves
}