use crate::ResolvedDialect;
use brink_syntax::SyntaxNode;
use rowan::TextRange;
use serde::Serialize;
use crate::hir::projection::{ProjectedSpan, Projection, SpanKind};
use crate::line_index::LineIndex;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum LineElement {
KnotHeader,
StitchHeader,
Narrative,
Choice,
Gather,
Divert,
Logic,
VarDecl,
Comment,
Todo,
Include,
External,
Tag,
Blank,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct WeavePosition {
pub depth: u32,
pub element: WeaveElement,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum WeaveElement {
TopLevel,
ChoiceLine {
sticky: bool,
},
ChoiceBody,
GatherContinuation,
ConditionalBranch,
SequenceBranch,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct LineContext {
pub element: LineElement,
pub weave: WeavePosition,
pub has_tags: bool,
pub standalone: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub option_path: Option<Vec<u32>>,
pub block_comment: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dialect: Option<DialectLineInfo>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct DialectLineInfo {
pub kind: String,
pub attrs: Vec<(String, String)>,
pub hidden_spans: Vec<(u32, u32)>,
pub content_span: Option<(u32, u32)>,
pub nature: crate::ElementNature,
}
impl Default for LineContext {
fn default() -> Self {
Self {
element: LineElement::Blank,
weave: WeavePosition {
depth: 0,
element: WeaveElement::TopLevel,
},
has_tags: false,
standalone: false,
option_path: None,
block_comment: false,
dialect: None,
}
}
}
pub fn line_contexts(source: &str, root: &SyntaxNode, projection: &Projection) -> Vec<LineContext> {
line_contexts_from_trivia(
source,
&crate::trivia::line_trivia(source, root, line_count_for(source)),
projection,
)
}
pub fn line_contexts_native(
source: &str,
root: &brink_syntax_native::SyntaxNode,
projection: &Projection,
) -> Vec<LineContext> {
line_contexts_from_trivia(
source,
&crate::trivia::line_trivia_native(source, root, line_count_for(source)),
projection,
)
}
#[must_use]
pub fn line_count_for(source: &str) -> usize {
let line_count = source.lines().count().max(1);
if source.ends_with('\n') {
line_count + 1
} else {
line_count
}
}
fn line_contexts_from_trivia(
source: &str,
trivia: &[crate::trivia::LineTrivia],
projection: &Projection,
) -> Vec<LineContext> {
let mut ctx = vec![LineContext::default(); trivia.len()];
let idx = LineIndex::new(source);
for (i, t) in trivia.iter().enumerate() {
if t.comment {
ctx[i].element = LineElement::Comment;
} else if t.todo {
ctx[i].element = LineElement::Todo;
} else if t.tag && ctx[i].element == LineElement::Blank {
ctx[i].element = LineElement::Tag;
}
if t.block_comment {
ctx[i].block_comment = true;
}
}
let mut cond_ranges: Vec<(TextRange, WeaveElement)> = Vec::new();
apply_structural_view(projection, source, &idx, &mut ctx, &mut cond_ranges);
let src_lines: Vec<&str> = source.split('\n').collect();
for i in 1..ctx.len() {
if ctx[i].element != LineElement::Blank
|| !src_lines.get(i).is_none_or(|l| l.trim().is_empty())
{
continue;
}
let prev = ctx[i - 1].weave;
if matches!(
prev.element,
WeaveElement::ChoiceLine { .. } | WeaveElement::ChoiceBody
) {
ctx[i].weave = WeavePosition {
depth: prev.depth,
element: WeaveElement::ChoiceBody,
};
}
}
detect_gathers(source, &mut ctx);
apply_conditional_scaffold(source, &idx, &cond_ranges, &mut ctx);
for i in 0..ctx.len() {
if !matches!(
ctx[i].weave.element,
WeaveElement::ChoiceLine { .. } | WeaveElement::ChoiceBody
) {
continue;
}
let from_stack = projection.lines.get(i).and_then(|stack| {
stack
.containers
.iter()
.rev()
.find(|c| c.kind == SpanKind::Choice)
.and_then(|c| projection.option_paths.get(&c.handle))
});
ctx[i].option_path = match from_stack {
Some(path) => Some(path.clone()),
None if i > 0 => ctx[i - 1].option_path.clone(),
None => None,
};
}
detect_sigil_logic_lines(source, &mut ctx);
ctx
}
pub fn line_contexts_with_dialect(
source: &str,
root: &SyntaxNode,
projection: &Projection,
dialect: &ResolvedDialect,
) -> Vec<LineContext> {
let mut ctx = line_contexts(source, root, projection);
apply_dialect(source, dialect, &mut ctx);
ctx
}
pub fn line_contexts_with_dialect_native(
source: &str,
root: &brink_syntax_native::SyntaxNode,
projection: &Projection,
dialect: &ResolvedDialect,
) -> Vec<LineContext> {
let mut ctx = line_contexts_native(source, root, projection);
apply_dialect(source, dialect, &mut ctx);
ctx
}
fn apply_dialect(source: &str, dialect: &ResolvedDialect, ctx: &mut [LineContext]) {
let lines: Vec<&str> = source.split('\n').collect();
let is_blank = |i: usize| lines.get(i).is_some_and(|l| l.trim().is_empty());
for (i, line) in lines.iter().enumerate() {
if i >= ctx.len() {
break;
}
if ctx[i].element != LineElement::Narrative || is_blank(i) {
continue;
}
let leading_ws = leading_ws_len(line);
let trimmed = &line[leading_ws as usize..];
if let Some(m) = dialect.classify(trimmed, leading_ws) {
let nature = dialect
.nature_of(&m.kind)
.unwrap_or(crate::ElementNature::Narrative);
ctx[i].dialect = Some(DialectLineInfo {
kind: m.kind,
attrs: m.attrs,
hidden_spans: m.hidden_spans,
content_span: m.content_span,
nature,
});
}
}
let mut run_carry: Vec<(String, String)> = Vec::new();
for i in 0..ctx.len() {
if is_blank(i) {
run_carry.clear();
continue;
}
if i > 0
&& ctx[i].element == LineElement::Narrative
&& chain_eligible_weave(ctx[i].weave.element)
&& ctx[i].dialect.is_none()
&& let Some(prev_kind) = ctx[i - 1].dialect.as_ref().map(|d| d.kind.clone())
&& let Some(rule) = dialect.chain_rule_after(&prev_kind)
{
let carried: Vec<(String, String)> = rule
.carry
.iter()
.filter_map(|name| run_carry.iter().find(|(k, _)| k == name).cloned())
.collect();
let nature = dialect
.nature_of(&rule.becomes)
.unwrap_or(crate::ElementNature::Narrative);
ctx[i].dialect = Some(DialectLineInfo {
kind: rule.becomes.clone(),
attrs: carried,
hidden_spans: Vec::new(),
content_span: None,
nature,
});
}
if let Some(d) = &ctx[i].dialect {
for (k, v) in &d.attrs {
if let Some(existing) = run_carry.iter_mut().find(|(ek, _)| ek == k) {
existing.1.clone_from(v);
} else {
run_carry.push((k.clone(), v.clone()));
}
}
} else {
run_carry.clear();
}
}
}
fn chain_eligible_weave(element: WeaveElement) -> bool {
matches!(
element,
WeaveElement::TopLevel | WeaveElement::ConditionalBranch | WeaveElement::SequenceBranch
)
}
#[expect(clippy::cast_possible_truncation)]
fn leading_ws_len(line: &str) -> u32 {
(line.len() - line.trim_start_matches(' ').len()) as u32
}
fn apply_structural_view(
projection: &Projection,
source: &str,
idx: &LineIndex,
ctx: &mut [LineContext],
cond_ranges: &mut Vec<(TextRange, WeaveElement)>,
) {
let containers: Vec<&ProjectedSpan> = projection
.spans
.iter()
.filter(|s| s.handle.is_some())
.collect();
let src_lines: Vec<&str> = source.split('\n').collect();
let mut deferred_gathers: Vec<(usize, WeavePosition)> = Vec::new();
for span in &projection.spans {
let start_line = idx.line_col(span.range.start()).0 as usize;
match span.kind {
SpanKind::VarDecl | SpanKind::ConstDecl | SpanKind::ListDecl => {
set_element(ctx, start_line, LineElement::VarDecl);
}
SpanKind::External => set_element(ctx, start_line, LineElement::External),
SpanKind::Include => set_element(ctx, start_line, LineElement::Include),
SpanKind::Knot if span.handle.is_some() => {
set_element(ctx, start_line, LineElement::KnotHeader);
}
SpanKind::Stitch if span.handle.is_some() => {
set_element(ctx, start_line, LineElement::StitchHeader);
}
SpanKind::Choice => {
if start_line < ctx.len() {
ctx[start_line].element = LineElement::Choice;
ctx[start_line].weave = WeavePosition {
depth: span.weave_depth.unwrap_or(0),
element: WeaveElement::ChoiceLine {
sticky: span.sticky.unwrap_or(false),
},
};
}
}
SpanKind::DivertStmt
| SpanKind::DivertTerminal
| SpanKind::TunnelStmt
| SpanKind::ThreadStmt
if !on_choice_first_line(&containers, idx, span.range) =>
{
set_element_weave(
ctx,
start_line,
LineElement::Divert,
derive_weave(&containers, span.range),
);
if start_line < ctx.len() {
ctx[start_line].standalone =
matches!(span.kind, SpanKind::DivertStmt | SpanKind::DivertTerminal);
}
}
SpanKind::TempDecl | SpanKind::Logic
if !on_choice_first_line(&containers, idx, span.range) =>
{
set_element_weave(
ctx,
start_line,
LineElement::Logic,
derive_weave(&containers, span.range),
);
}
SpanKind::Content => {
fill_content_lines(span.range, idx, ctx, derive_weave(&containers, span.range));
}
SpanKind::Tag => {
if start_line < ctx.len() {
ctx[start_line].has_tags = true;
}
}
SpanKind::Label => {
apply_label_span(span, &containers, &src_lines, idx, &mut deferred_gathers);
}
SpanKind::Conditional => {
cond_ranges.push((span.range, WeaveElement::ConditionalBranch));
}
SpanKind::Sequence => {
cond_ranges.push((span.range, WeaveElement::SequenceBranch));
}
_ => {}
}
}
for (line, weave) in deferred_gathers {
if line < ctx.len() {
ctx[line].element = LineElement::Gather;
ctx[line].weave = weave;
}
}
}
fn apply_label_span(
span: &ProjectedSpan,
containers: &[&ProjectedSpan],
src_lines: &[&str],
idx: &LineIndex,
deferred_gathers: &mut Vec<(usize, WeavePosition)>,
) {
let start_line = idx.line_col(span.range.start()).0 as usize;
if span.weave_depth.is_none() {
let choice_first_line = containing(containers, SpanKind::Choice, span.range)
.map(|c| idx.line_col(c.range.start()).0 as usize);
if choice_first_line == Some(start_line) {
return;
}
}
let depth = span.weave_depth.unwrap_or_else(|| {
src_lines
.get(start_line)
.map_or(0, |l| gather_sigil_depth(l.trim_start()))
});
deferred_gathers.push((
start_line,
WeavePosition {
depth,
element: WeaveElement::GatherContinuation,
},
));
}
fn gather_sigil_depth(trimmed: &str) -> u32 {
let mut depth = 0u32;
let bytes = trimmed.as_bytes();
let mut pos = 0;
while pos < bytes.len() && bytes[pos] == b'-' {
if bytes.get(pos + 1) == Some(&b'>') {
break;
}
depth += 1;
pos += 1;
while pos < bytes.len() && bytes[pos] == b' ' {
pos += 1;
}
}
depth
}
fn on_choice_first_line(containers: &[&ProjectedSpan], idx: &LineIndex, range: TextRange) -> bool {
containing(containers, SpanKind::Choice, range)
.is_some_and(|c| idx.line_col(c.range.start()).0 == idx.line_col(range.start()).0)
}
fn innermost<'a>(spans: impl Iterator<Item = &'a ProjectedSpan>) -> Option<&'a ProjectedSpan> {
let mut best: Option<&'a ProjectedSpan> = None;
for s in spans {
if best.is_none_or(|b| s.range.len() <= b.range.len()) {
best = Some(s);
}
}
best
}
fn containing<'a>(
containers: &[&'a ProjectedSpan],
kind: SpanKind,
range: TextRange,
) -> Option<&'a ProjectedSpan> {
innermost(
containers
.iter()
.copied()
.filter(|c| c.kind == kind && c.range.contains_range(range)),
)
}
fn derive_weave(containers: &[&ProjectedSpan], range: TextRange) -> WeavePosition {
let inner = innermost(
containers
.iter()
.copied()
.filter(|c| weave_container(c.kind) && c.range.contains_range(range)),
);
let Some(c) = inner else {
return WeavePosition {
depth: 0,
element: WeaveElement::TopLevel,
};
};
match c.kind {
SpanKind::Choice => WeavePosition {
depth: c.weave_depth.unwrap_or(0),
element: WeaveElement::ChoiceBody,
},
SpanKind::Gather => WeavePosition {
depth: c.weave_depth.unwrap_or(0),
element: WeaveElement::GatherContinuation,
},
SpanKind::ConditionalBranch | SpanKind::SequenceBranch => {
let depth = innermost(containers.iter().copied().filter(|w| {
matches!(w.kind, SpanKind::Choice | SpanKind::Gather)
&& w.range.contains_range(range)
}))
.and_then(|w| w.weave_depth)
.unwrap_or(0);
WeavePosition {
depth,
element: if c.kind == SpanKind::ConditionalBranch {
WeaveElement::ConditionalBranch
} else {
WeaveElement::SequenceBranch
},
}
}
_ => WeavePosition {
depth: 0,
element: WeaveElement::TopLevel,
},
}
}
fn weave_container(kind: SpanKind) -> bool {
matches!(
kind,
SpanKind::Choice
| SpanKind::Gather
| SpanKind::ConditionalBranch
| SpanKind::SequenceBranch
)
}
fn set_element(ctx: &mut [LineContext], line: usize, element: LineElement) {
if line < ctx.len() {
ctx[line].element = element;
}
}
fn set_element_weave(
ctx: &mut [LineContext],
line: usize,
element: LineElement,
weave: WeavePosition,
) {
if line < ctx.len() {
ctx[line].element = element;
ctx[line].weave = weave;
}
}
fn fill_content_lines(
range: TextRange,
idx: &LineIndex,
ctx: &mut [LineContext],
weave: WeavePosition,
) {
let start_line = idx.line_col(range.start()).0 as usize;
let (end_line_raw, end_col) = idx.line_col(range.end());
let end_line = if end_col == 0 && end_line_raw as usize > start_line {
end_line_raw as usize - 1
} else {
end_line_raw as usize
};
for line in start_line..=end_line {
if line < ctx.len() && ctx[line].element == LineElement::Blank {
ctx[line].element = LineElement::Narrative;
ctx[line].weave = weave;
}
}
}
fn detect_gathers(source: &str, ctx: &mut [LineContext]) {
for (i, line) in source.lines().enumerate() {
if i >= ctx.len() {
break;
}
if !matches!(ctx[i].element, LineElement::Blank | LineElement::Narrative) {
continue;
}
let trimmed = line.trim_start();
if trimmed.starts_with('-') && !trimmed.starts_with("->") {
ctx[i].element = LineElement::Gather;
if ctx[i].weave.element == WeaveElement::TopLevel {
let mut depth = 0u32;
let mut pos = 0;
let bytes = trimmed.as_bytes();
while pos < bytes.len() && bytes[pos] == b'-' {
depth += 1;
pos += 1;
while pos < bytes.len() && bytes[pos] == b' ' {
pos += 1;
}
}
ctx[i].weave = WeavePosition {
depth,
element: WeaveElement::GatherContinuation,
};
}
}
}
}
fn apply_conditional_scaffold(
source: &str,
idx: &LineIndex,
cond_ranges: &[(TextRange, WeaveElement)],
ctx: &mut [LineContext],
) {
let lines: Vec<&str> = source.split('\n').collect();
for (range, weave_element) in cond_ranges {
let start_line = idx.line_col(range.start()).0 as usize;
let (end_line_raw, end_col) = idx.line_col(range.end());
let end_line = if end_col == 0 && end_line_raw as usize > start_line {
end_line_raw as usize - 1
} else {
end_line_raw as usize
};
for i in start_line..=end_line {
if i >= ctx.len() || i >= lines.len() {
break;
}
let trimmed = lines[i].trim();
if trimmed.is_empty() {
continue;
}
if matches!(ctx[i].element, LineElement::Blank | LineElement::Gather)
&& is_conditional_branch_header_line(trimmed)
{
ctx[i].element = LineElement::Logic;
ctx[i].weave = WeavePosition {
depth: 0,
element: WeaveElement::TopLevel,
};
continue;
}
if ctx[i].element != LineElement::Blank {
continue;
}
if is_conditional_brace_scaffold_line(trimmed) {
ctx[i].element = LineElement::Logic;
ctx[i].weave = WeavePosition {
depth: 0,
element: WeaveElement::TopLevel,
};
continue;
}
ctx[i].element = LineElement::Narrative;
ctx[i].weave = WeavePosition {
depth: 0,
element: *weave_element,
};
}
}
}
fn is_conditional_brace_scaffold_line(trimmed: &str) -> bool {
if trimmed == "{" || trimmed == "}" {
return true;
}
trimmed.starts_with('{') && trimmed.ends_with(':')
}
fn is_conditional_branch_header_line(trimmed: &str) -> bool {
trimmed.starts_with('-') && !trimmed.starts_with("->") && trimmed.ends_with(':')
}
fn detect_sigil_logic_lines(source: &str, ctx: &mut [LineContext]) {
for (i, line) in source.lines().enumerate() {
if i >= ctx.len() {
break;
}
if line.trim_start().starts_with('~') {
ctx[i].element = LineElement::Logic;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{FileId, hir};
fn make_contexts(source: &str) -> Vec<LineContext> {
let parse = brink_syntax::parse(source);
let file_id = FileId(0);
let ast = parse.tree();
let (hir, _, _) = hir::lower(file_id, &ast);
let projection = crate::hir::projection::project_hir_structural(&hir, source);
line_contexts(source, &parse.syntax(), &projection)
}
fn make_native_contexts(source: &str) -> Vec<LineContext> {
let parse = brink_syntax_native::parse(source);
let file_id = FileId(0);
let ast = parse.tree();
let (hir, _, _) = crate::hir::lower_native::lower(file_id, &ast);
let projection = crate::hir::projection::project_hir_structural(&hir, source);
line_contexts_native(source, &parse.syntax(), &projection)
}
#[test]
fn todo_lines_classify_as_todo() {
let source = "TODO: top-level note
TODOS are narrative
// TODO in a comment
=== k ===
TODO knot note
";
let ctx = make_contexts(source);
assert_eq!(ctx[0].element, LineElement::Todo);
assert_eq!(ctx[1].element, LineElement::Narrative);
assert_eq!(ctx[2].element, LineElement::Comment);
assert_eq!(ctx[4].element, LineElement::Todo);
}
#[test]
fn knot_and_stitch_headers() {
let source = "=== my_knot ===\n= my_stitch\nHello\n";
let ctx = make_contexts(source);
assert_eq!(ctx[0].element, LineElement::KnotHeader);
assert_eq!(ctx[1].element, LineElement::StitchHeader);
assert_eq!(ctx[2].element, LineElement::Narrative);
}
#[test]
fn choice_depth_from_hir() {
let source = "=== start ===\n* Choice one\n* * Nested choice\n";
let ctx = make_contexts(source);
assert_eq!(ctx[1].element, LineElement::Choice);
assert_eq!(ctx[1].weave.depth, 1);
assert_eq!(ctx[2].element, LineElement::Choice);
assert_eq!(ctx[2].weave.depth, 2);
}
#[test]
fn divert_and_logic() {
let source = "=== start ===\n~ temp x = 5\n-> END\n";
let ctx = make_contexts(source);
assert_eq!(ctx[1].element, LineElement::Logic);
assert_eq!(ctx[2].element, LineElement::Divert);
}
#[test]
fn var_and_include() {
let source = "VAR x = 5\nINCLUDE other.ink\n";
let ctx = make_contexts(source);
assert_eq!(ctx[0].element, LineElement::VarDecl);
assert_eq!(ctx[1].element, LineElement::Include);
}
#[test]
fn comments() {
let source = "// A comment\nHello\n";
let ctx = make_contexts(source);
assert_eq!(ctx[0].element, LineElement::Comment);
}
#[test]
fn blank_lines() {
let source = "\n\nHello\n";
let ctx = make_contexts(source);
assert_eq!(ctx[0].element, LineElement::Blank);
assert_eq!(ctx[1].element, LineElement::Blank);
}
#[test]
fn native_block_comment_uses_the_native_cst() {
let source = "flow main() {\n/* a\nblock */\nHello -> END\n}\n";
let ctx = make_native_contexts(source);
assert_eq!(ctx[1].element, LineElement::Comment, "{ctx:?}");
assert!(ctx[1].block_comment, "{ctx:?}");
assert_eq!(ctx[2].element, LineElement::Comment, "{ctx:?}");
assert!(ctx[2].block_comment, "{ctx:?}");
assert!(!ctx[3].block_comment, "{ctx:?}");
}
#[test]
fn native_line_comment_and_tag() {
let source = "flow main() {\n// a comment\n# a_tag\nHello -> END\n}\n";
let ctx = make_native_contexts(source);
assert_eq!(ctx[1].element, LineElement::Comment, "{ctx:?}");
assert_eq!(ctx[2].element, LineElement::Tag, "{ctx:?}");
}
#[test]
fn choice_body_text_classified() {
let source = "=== start ===\n* Choice one\n Body text here\n";
let ctx = make_contexts(source);
assert_eq!(ctx[2].element, LineElement::Narrative);
assert_eq!(ctx[2].weave.element, WeaveElement::ChoiceBody);
assert_eq!(ctx[2].weave.depth, 1);
}
#[test]
fn gather_after_choice_with_label() {
let source = "=== start ===\n* [Go back]\n- (gather) g\n";
let ctx = make_contexts(source);
assert_eq!(ctx[2].element, LineElement::Gather);
assert_eq!(ctx[2].weave.depth, 1);
assert_eq!(ctx[2].weave.element, WeaveElement::GatherContinuation);
}
#[test]
fn gather_after_choice_bare() {
let source = "=== start ===\n* Choice\n- bare gather\n";
let ctx = make_contexts(source);
assert_eq!(ctx[2].element, LineElement::Gather);
assert_eq!(ctx[2].weave.depth, 1);
}
#[test]
fn gather_empty_sigil() {
let source = "=== start ===\n* Choice\n- \n";
let ctx = make_contexts(source);
assert_eq!(ctx[2].element, LineElement::Gather);
}
#[test]
fn choice_body_blank_line_inherits_body_weave() {
let source = "=== start ===\n* Choice one\n \n\n- done\n";
let ctx = make_contexts(source);
assert_eq!(ctx[2].element, LineElement::Blank);
assert_eq!(ctx[2].weave.element, WeaveElement::ChoiceBody);
assert_eq!(ctx[2].weave.depth, 1);
assert_eq!(ctx[3].element, LineElement::Blank);
assert_eq!(ctx[3].weave.element, WeaveElement::ChoiceBody);
assert_eq!(ctx[4].element, LineElement::Gather);
assert_eq!(ctx[4].weave.element, WeaveElement::GatherContinuation);
}
#[test]
fn option_paths_follow_weave_lineage() {
let source = "\
=== start ===
* First
First body.
* Second
* * Nested under second
- (g)
* After gather
";
let ctx = make_contexts(source);
assert_eq!(ctx[1].option_path.as_deref(), Some(&[0u32][..]), "First");
assert_eq!(ctx[2].option_path.as_deref(), Some(&[0u32][..]), "body");
assert_eq!(ctx[3].option_path.as_deref(), Some(&[1u32][..]), "Second");
assert_eq!(
ctx[4].option_path.as_deref(),
Some(&[1u32, 0u32][..]),
"nested lineage"
);
assert_eq!(ctx[5].option_path, None, "gather line has no option path");
assert_eq!(
ctx[6].option_path.as_deref(),
Some(&[0u32][..]),
"gather closed the group — new set restarts at 0"
);
}
#[test]
fn standalone_is_a_structural_fact() {
let source = "\
=== start ===
-> hub
=== hub ===
-> tunnel ->
<- threaded
-> DONE
=== tunnel ===
->->
=== threaded ===
-> END
";
let ctx = make_contexts(source);
assert!(ctx[1].standalone, "plain divert is standalone");
assert!(!ctx[3].standalone, "tunnel call is not");
assert!(!ctx[4].standalone, "thread start is not");
assert!(ctx[5].standalone, "-> DONE is standalone");
assert!(ctx[9].standalone, "-> END is standalone");
}
#[test]
fn sticky_choice() {
let source = "=== start ===\n+ Sticky choice\n";
let ctx = make_contexts(source);
assert_eq!(ctx[1].element, LineElement::Choice);
assert!(matches!(
ctx[1].weave.element,
WeaveElement::ChoiceLine { sticky: true }
));
}
}
#[cfg(test)]
mod dialect_tests {
use super::*;
use crate::{FileId, ResolvedDialect, hir};
fn make_dialect_contexts(source: &str) -> Vec<LineContext> {
let parse = brink_syntax::parse(source);
let file_id = FileId(0);
let ast = parse.tree();
let (hir, _, _) = hir::lower(file_id, &ast);
let dialect = ResolvedDialect::compile(&crate::DialogueDialect::default())
.expect("at-cue preset compiles");
let projection = crate::hir::projection::project_hir_structural(&hir, source);
line_contexts_with_dialect(source, &parse.syntax(), &projection, &dialect)
}
#[test]
fn character_cue_classifies_with_hidden_geometry() {
let source = "=== start ===\n@Alice:<>\nHello there.\n";
let ctx = make_dialect_contexts(source);
let d = ctx[1].dialect.as_ref().expect("cue classified");
assert_eq!(d.kind, "character");
assert_eq!(d.attrs, vec![("speaker".to_owned(), "Alice".to_owned())]);
assert_eq!(d.hidden_spans, vec![(0, 1), (6, 9)]);
assert_eq!(d.content_span, Some((1, 6)));
}
#[test]
fn narrative_after_cue_chains_to_dialogue_and_carries_speaker() {
let source = "=== start ===\n@Alice:<>\nHello there.\n";
let ctx = make_dialect_contexts(source);
let d = ctx[2].dialect.as_ref().expect("chained to dialogue");
assert_eq!(d.kind, "dialogue");
assert_eq!(d.attrs, vec![("speaker".to_owned(), "Alice".to_owned())]);
}
#[test]
fn parenthetical_between_cue_and_dialogue_keeps_chain_alive() {
let source = "=== start ===\n@Alice:<>\n(warmly)<>\nHello there.\n";
let ctx = make_dialect_contexts(source);
assert_eq!(
ctx[2].dialect.as_ref().expect("parenthetical").kind,
"parenthetical"
);
let dialogue = ctx[3].dialect.as_ref().expect("chained");
assert_eq!(dialogue.kind, "dialogue");
assert_eq!(
dialogue.attrs,
vec![("speaker".to_owned(), "Alice".to_owned())]
);
}
#[test]
fn blank_line_breaks_the_chain() {
let source = "=== start ===\n@Alice:<>\n\nHello there.\n";
let ctx = make_dialect_contexts(source);
assert!(ctx[2].dialect.is_none());
assert!(ctx[3].dialect.is_none());
assert_eq!(ctx[3].element, LineElement::Narrative);
}
#[test]
fn cue_inside_choice_body_classifies_but_does_not_chain() {
let source = "=== start ===\n* Choice\n @Alice:<>\n Hello there.\n";
let ctx = make_dialect_contexts(source);
let cue = ctx[2]
.dialect
.as_ref()
.expect("cue classified in choice body");
assert_eq!(cue.kind, "character");
assert_eq!(ctx[2].weave.element, WeaveElement::ChoiceBody);
assert_eq!(ctx[2].weave.depth, 1, "depth preserved inside choice body");
assert!(
ctx[3].dialect.is_none(),
"choice-body narrative must not chain to dialogue"
);
assert_eq!(ctx[3].weave.element, WeaveElement::ChoiceBody);
}
#[test]
fn plain_narrative_prose_does_not_classify() {
let source = "=== start ===\nJust some narrative text.\n";
let ctx = make_dialect_contexts(source);
assert!(ctx[1].dialect.is_none());
}
#[test]
fn negative_fixture_channel_prose_is_not_a_cue() {
let source = "=== start ===\n@channel: hello\n";
let ctx = make_dialect_contexts(source);
assert!(ctx[1].dialect.is_none());
}
#[test]
fn no_dialect_registered_means_no_classification() {
let source = "=== start ===\n@Alice:<>\n";
let parse = brink_syntax::parse(source);
let file_id = FileId(0);
let ast = parse.tree();
let (hir, _, _) = hir::lower(file_id, &ast);
let projection = crate::hir::projection::project_hir_structural(&hir, source);
let ctx = line_contexts(source, &parse.syntax(), &projection);
assert!(ctx[1].dialect.is_none());
}
#[test]
fn sigil_logic_line_after_dialogue_is_not_swallowed_into_chain() {
let source = "=== leave ===\n@Solstice:<>\nAwwww... I have to get going now, Minnie. Sorry!\n~ change_party_member(2, false)\n-> END\n";
let ctx = make_dialect_contexts(source);
assert_eq!(ctx[1].dialect.as_ref().expect("cue").kind, "character");
assert_eq!(
ctx[2].dialect.as_ref().expect("chained dialogue").kind,
"dialogue"
);
assert_eq!(
ctx[3].element,
LineElement::Logic,
"sigil line must classify as Logic, not be swallowed into the dialogue chain"
);
assert!(
ctx[3].dialect.is_none(),
"a Logic line must never carry a dialect classification"
);
}
#[test]
fn if_else_conditional_scaffold_classifies_as_logic() {
let source =
"=== start ===\n{\n - get_variable(16) == 2: -> leave\n - else: -> busy\n}\n";
let ctx = make_dialect_contexts(source);
assert_eq!(ctx[1].element, LineElement::Logic, "opening brace");
assert_eq!(ctx[2].element, LineElement::Divert, "if-arm divert");
assert_eq!(ctx[3].element, LineElement::Divert, "else-arm divert");
assert_eq!(ctx[4].element, LineElement::Logic, "closing brace");
}
#[test]
fn conditional_arm_dialogue_classifies_and_chains() {
let source = "=== start ===\n{ get_variable(17) >= 1:\n @Solstice:<>\n Hello, this is Sols.\n @Minnie:<>\n Uhhhh... I have no idea.\n- else:\n @Solstice:<>\n Hello?\n}\n-> END\n";
let ctx = make_dialect_contexts(source);
assert_eq!(ctx[1].element, LineElement::Logic, "opening scaffold line");
assert_eq!(ctx[2].weave.element, WeaveElement::ConditionalBranch);
assert_eq!(ctx[2].dialect.as_ref().expect("cue").kind, "character");
assert_eq!(
ctx[3].dialect.as_ref().expect("chained dialogue").kind,
"dialogue"
);
assert_eq!(ctx[4].dialect.as_ref().expect("cue").kind, "character");
assert_eq!(
ctx[5].dialect.as_ref().expect("chained dialogue").kind,
"dialogue"
);
assert_eq!(
ctx[6].element,
LineElement::Logic,
"`- else:` is conditional scaffold, not a weave gather"
);
assert_eq!(ctx[7].weave.element, WeaveElement::ConditionalBranch);
assert_eq!(ctx[7].dialect.as_ref().expect("cue").kind, "character");
assert_eq!(
ctx[8].dialect.as_ref().expect("chained dialogue").kind,
"dialogue"
);
assert_eq!(ctx[9].element, LineElement::Logic, "closing brace");
assert_eq!(ctx[10].element, LineElement::Divert);
}
#[test]
fn narrative_with_standalone_inline_conditional_keeps_narrative_class() {
let source = "=== start ===\n{visited: You were here before.}\nNext.\n";
let ctx = make_dialect_contexts(source);
assert_eq!(
ctx[1].element,
LineElement::Narrative,
"a standalone inline conditional used as narrative must not be swept to Logic"
);
assert_eq!(ctx[2].element, LineElement::Narrative);
}
#[test]
fn narrative_with_trailing_interpolation_keeps_narrative_class() {
let source = "=== start ===\nYou have {gold}\nMore text.\n";
let ctx = make_dialect_contexts(source);
assert_eq!(
ctx[1].element,
LineElement::Narrative,
"narrative ending in an interpolation must not be swept to Logic"
);
}
#[test]
fn choice_body_cue_still_does_not_chain_inside_conditional_gate_change() {
let source = "=== start ===\n* Choice\n @Alice:<>\n Hello there.\n";
let ctx = make_dialect_contexts(source);
assert_eq!(ctx[2].dialect.as_ref().expect("cue").kind, "character");
assert!(ctx[3].dialect.is_none(), "choice-body chain stays off");
}
}