use brink_format::{
LineContent, LineEntry, LinePart, PluralCategory, PluralResolver, SelectKey, Value,
};
use crate::program::Program;
use crate::value_ops;
#[derive(Debug, Clone)]
pub enum OutputPart {
Text(String),
LineRef {
container_idx: u32,
line_idx: u16,
slots: Vec<Value>,
flags: brink_format::LineFlags,
},
ValueRef(Value),
Newline,
Spring,
Glue,
Checkpoint,
Tag(String),
}
impl OutputPart {
pub fn resolve(
&self,
program: &Program,
line_tables: &[Vec<LineEntry>],
resolver: Option<&dyn PluralResolver>,
) -> String {
resolve_part(self, program, line_tables, resolver, &[])
}
fn is_content(&self) -> bool {
match self {
Self::Text(s) => !s.trim().is_empty(),
Self::LineRef { flags, .. } => {
!flags.contains(brink_format::LineFlags::ALL_WS)
&& !flags.contains(brink_format::LineFlags::EMPTY)
}
Self::ValueRef(_) => true,
_ => false,
}
}
}
fn resolve_part(
part: &OutputPart,
program: &Program,
line_tables: &[Vec<LineEntry>],
resolver: Option<&dyn PluralResolver>,
fragments: &[Fragment],
) -> String {
match part {
OutputPart::Text(s) => s.clone(),
OutputPart::LineRef {
container_idx,
line_idx,
slots,
..
} => resolve_line_ref(
program,
line_tables,
*container_idx,
*line_idx,
slots,
resolver,
fragments,
),
OutputPart::ValueRef(Value::FragmentRef(idx)) => {
let idx = *idx as usize;
if let Some(frag) = fragments.get(idx) {
resolve_parts(&frag.parts, program, line_tables, resolver, fragments)
} else {
String::new()
}
}
OutputPart::ValueRef(val) => value_ops::stringify(val, program),
OutputPart::Newline
| OutputPart::Spring
| OutputPart::Glue
| OutputPart::Checkpoint
| OutputPart::Tag(_) => String::new(),
}
}
fn resolve_line_ref(
program: &Program,
line_tables: &[Vec<LineEntry>],
container_idx: u32,
line_idx: u16,
slots: &[Value],
resolver: Option<&dyn PluralResolver>,
fragments: &[Fragment],
) -> String {
let scope_idx = program.scope_table_idx(container_idx) as usize;
let lines = &line_tables[scope_idx];
let Some(entry) = lines.get(line_idx as usize) else {
return String::new();
};
match &entry.content {
LineContent::Plain(s) => s.clone(),
LineContent::Template(parts) => {
let mut result = String::new();
for part in parts {
let owned;
let fragment: &str = match part {
LinePart::Literal(s) => s.as_str(),
LinePart::Slot(n) => {
owned = slots
.get(*n as usize)
.map(|v| match v {
Value::FragmentRef(idx) => {
let idx = *idx as usize;
fragments.get(idx).map_or_else(String::new, |frag| {
resolve_parts(
&frag.parts,
program,
line_tables,
resolver,
fragments,
)
})
}
other => value_ops::stringify(other, program),
})
.unwrap_or_default();
owned.as_str()
}
LinePart::Select {
slot,
variants,
default,
} => {
owned =
resolve_select(*slot, variants, default, slots, resolver).to_string();
owned.as_str()
}
};
if fragment.is_empty() {
continue;
}
if (result.is_empty() || result.ends_with(' ')) && fragment.starts_with(' ') {
result.push_str(fragment.trim_start());
} else {
result.push_str(fragment);
}
}
result
}
}
}
fn resolve_select<'a>(
slot: u8,
variants: &'a [(SelectKey, String)],
default: &'a str,
slots: &[Value],
resolver: Option<&dyn PluralResolver>,
) -> &'a str {
let Some(val) = slots.get(slot as usize) else {
return default;
};
#[expect(clippy::cast_possible_truncation)]
let n: Option<i64> = match val {
Value::Int(i) => Some(i64::from(*i)),
Value::Float(f) => Some(*f as i64),
_ => None,
};
if let Some(n) = n {
#[expect(clippy::cast_possible_truncation)]
let n32 = n as i32;
for (key, text) in variants {
if let SelectKey::Exact(e) = key
&& *e == n32
{
return text;
}
}
}
if let Value::String(s) = val {
for (key, text) in variants {
if let SelectKey::Keyword(k) = key
&& k == s.as_ref()
{
return text;
}
}
}
if let (Some(n), Some(r)) = (n, resolver) {
let cardinal: PluralCategory = r.cardinal(n, None);
for (key, text) in variants {
if let SelectKey::Cardinal(cat) = key
&& *cat == cardinal
{
return text;
}
}
let ordinal: PluralCategory = r.ordinal(n);
for (key, text) in variants {
if let SelectKey::Ordinal(cat) = key
&& *cat == ordinal
{
return text;
}
}
}
default
}
#[derive(Debug, Clone)]
pub struct Fragment {
pub parts: Vec<OutputPart>,
pub tags: Vec<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct OutputBuffer {
pub(crate) transcript: Vec<OutputPart>,
pub(crate) cursor: usize,
capture: Vec<OutputPart>,
capture_depth: usize,
fragments: Vec<Fragment>,
fragment_capture: Vec<OutputPart>,
fragment_depth: usize,
fragment_pending_tags: Vec<Vec<String>>,
}
impl OutputBuffer {
pub fn new() -> Self {
Self {
transcript: Vec::new(),
cursor: 0,
capture: Vec::new(),
capture_depth: 0,
fragments: Vec::new(),
fragment_capture: Vec::new(),
fragment_depth: 0,
fragment_pending_tags: Vec::new(),
}
}
fn target(&mut self) -> &mut Vec<OutputPart> {
if self.capture_depth > 0 {
&mut self.capture
} else if self.fragment_depth > 0 {
&mut self.fragment_capture
} else {
&mut self.transcript
}
}
pub(crate) fn target_len(&self) -> usize {
if self.capture_depth > 0 {
self.capture.len()
} else if self.fragment_depth > 0 {
self.fragment_capture.len()
} else {
self.transcript.len()
}
}
pub(crate) fn trim_function_end(&mut self, start: usize) {
let target = self.target();
while target.len() > start {
match target.last() {
Some(OutputPart::Newline | OutputPart::Spring) => {
target.pop();
}
Some(OutputPart::Text(s)) if s.trim().is_empty() => {
target.pop();
}
Some(OutputPart::LineRef { flags, .. })
if flags.contains(brink_format::LineFlags::ALL_WS) =>
{
target.pop();
}
_ => break,
}
}
}
#[cfg(test)]
pub fn push_text(&mut self, text: &str) {
if text.is_empty() {
return;
}
if !self.has_content() && text.trim().is_empty() {
return;
}
let text = if text.starts_with(char::is_whitespace) && self.ends_in_whitespace() {
text.trim_start()
} else {
text
};
if !text.is_empty() {
self.target().push(OutputPart::Text(text.to_owned()));
}
}
pub fn push_newline(&mut self) {
let has_content = if self.capture_depth > 0 {
self.has_content()
} else {
self.unread_has_content_or_spring()
};
if !has_content || self.ends_in_newline() {
return;
}
self.target().push(OutputPart::Newline);
}
fn has_content(&self) -> bool {
if self.capture_depth > 0 {
self.capture
.iter()
.rev()
.take_while(|p| !matches!(p, OutputPart::Checkpoint))
.any(OutputPart::is_content)
} else {
self.transcript[self.cursor..]
.iter()
.rev()
.any(OutputPart::is_content)
}
}
fn unread_has_content_or_spring(&self) -> bool {
self.transcript[self.cursor..]
.iter()
.any(|p| p.is_content() || matches!(p, OutputPart::Spring))
}
fn ends_in_newline(&self) -> bool {
let target = if self.capture_depth > 0 {
&self.capture
} else {
&self.transcript
};
matches!(target.last(), Some(OutputPart::Newline))
}
#[cfg(test)]
fn ends_in_whitespace(&self) -> bool {
let target = if self.capture_depth > 0 {
&self.capture
} else {
&self.transcript
};
match target.last() {
Some(OutputPart::Text(s)) => s.ends_with(char::is_whitespace),
Some(OutputPart::LineRef { flags, .. }) => {
flags.contains(brink_format::LineFlags::ENDS_WITH_WS)
}
_ => false,
}
}
pub fn push_glue(&mut self) {
self.target().push(OutputPart::Glue);
}
pub fn push_spring(&mut self) {
let target = self.target();
if !matches!(target.last(), Some(OutputPart::Spring)) {
target.push(OutputPart::Spring);
}
}
pub fn push_line_ref(
&mut self,
container_idx: u32,
line_idx: u16,
slots: Vec<Value>,
flags: brink_format::LineFlags,
) {
if !self.has_content()
&& (flags.contains(brink_format::LineFlags::ALL_WS)
|| flags.contains(brink_format::LineFlags::EMPTY))
{
return;
}
self.target().push(OutputPart::LineRef {
container_idx,
line_idx,
slots,
flags,
});
}
pub fn push_value_ref(&mut self, value: Value) {
if matches!(value, Value::Null) {
return;
}
if !self.has_content()
&& let Value::String(ref s) = value
&& s.trim().is_empty()
{
return;
}
self.target().push(OutputPart::ValueRef(value));
}
pub fn push_tag(&mut self, tag: String) {
self.target().push(OutputPart::Tag(tag));
}
pub fn has_checkpoint(&self) -> bool {
self.capture_depth > 0
}
pub fn begin_capture(&mut self) {
self.capture_depth += 1;
self.capture.push(OutputPart::Checkpoint);
}
pub fn end_capture(
&mut self,
program: &Program,
line_tables: &[Vec<LineEntry>],
resolver: Option<&dyn PluralResolver>,
) -> Option<String> {
let cp_idx = self
.capture
.iter()
.rposition(|p| matches!(p, OutputPart::Checkpoint))?;
let captured: Vec<OutputPart> = self.capture.drain(cp_idx..).collect();
let captured = &captured[1..];
self.capture_depth = self.capture_depth.saturating_sub(1);
Some(resolve_parts(
captured,
program,
line_tables,
resolver,
&self.fragments,
))
}
pub fn begin_fragment(&mut self) {
self.fragment_depth += 1;
self.fragment_capture.push(OutputPart::Checkpoint);
self.fragment_pending_tags.push(Vec::new());
}
#[expect(clippy::cast_possible_truncation)]
pub fn end_fragment(&mut self) -> Option<u32> {
let cp_idx = self
.fragment_capture
.iter()
.rposition(|p| matches!(p, OutputPart::Checkpoint))?;
let captured: Vec<OutputPart> = self.fragment_capture.drain(cp_idx..).collect();
let parts: Vec<OutputPart> = captured.into_iter().skip(1).collect();
let tags = self.fragment_pending_tags.pop().unwrap_or_default();
let idx = self.fragments.len() as u32;
self.fragments.push(Fragment { parts, tags });
self.fragment_depth = self.fragment_depth.saturating_sub(1);
Some(idx)
}
pub fn in_fragment_capture(&self) -> bool {
self.fragment_depth > 0
}
pub fn push_fragment_tag(&mut self, tag: String) {
if let Some(pending) = self.fragment_pending_tags.last_mut() {
pending.push(tag);
}
}
pub fn fragment_tags(&self, idx: u32) -> Option<&[String]> {
self.fragments.get(idx as usize).map(|f| f.tags.as_slice())
}
pub fn fragments(&self) -> &[Fragment] {
&self.fragments
}
pub fn fragment(&self, idx: u32) -> Option<&[OutputPart]> {
self.fragments.get(idx as usize).map(|f| f.parts.as_slice())
}
pub fn resolve_fragment(
&self,
idx: u32,
program: &Program,
line_tables: &[Vec<LineEntry>],
resolver: Option<&dyn PluralResolver>,
) -> String {
match self.fragment(idx) {
Some(parts) => resolve_parts(parts, program, line_tables, resolver, &self.fragments),
None => String::new(),
}
}
pub(crate) fn has_completed_line(&self) -> bool {
if self.has_checkpoint() {
return false;
}
let unread = &self.transcript[self.cursor..];
if unread.is_empty() {
return false;
}
if !unread.iter().any(|p| matches!(p, OutputPart::Newline)) {
return false;
}
let mut remove = vec![false; unread.len()];
mark_glue_removals(unread, &mut remove);
let mut after_glue = false;
let mut found_newline = false;
for (i, part) in unread.iter().enumerate() {
if remove[i] {
if matches!(part, OutputPart::Glue) {
after_glue = true;
}
continue;
}
if part.is_content() {
if found_newline {
return true;
}
after_glue = false;
} else {
match part {
OutputPart::Newline if !after_glue => {
found_newline = true;
}
OutputPart::Glue => {
after_glue = true;
}
_ => {}
}
}
}
false
}
pub(crate) fn take_first_line(
&mut self,
program: &Program,
line_tables: &[Vec<LineEntry>],
resolver: Option<&dyn PluralResolver>,
) -> Option<(String, Vec<String>)> {
if self.has_checkpoint() {
return None;
}
let unread = &self.transcript[self.cursor..];
if unread.is_empty() {
return None;
}
let mut remove = vec![false; unread.len()];
mark_glue_removals(unread, &mut remove);
let mut after_glue = false;
let mut candidate_newline: Option<usize> = None;
for (i, part) in unread.iter().enumerate() {
if remove[i] {
if matches!(part, OutputPart::Glue) {
after_glue = true;
}
continue;
}
if part.is_content() {
if candidate_newline.is_some() {
break;
}
after_glue = false;
} else {
match part {
OutputPart::Newline if !after_glue => {
candidate_newline = Some(i);
}
OutputPart::Glue => {
after_glue = true;
}
_ => {}
}
}
}
let split_at = candidate_newline?;
let slice = &self.transcript[self.cursor..=self.cursor + split_at];
let mut lines = resolve_lines(slice, program, line_tables, resolver, &self.fragments);
if lines.is_empty() {
return None;
}
self.cursor += split_at + 1;
let (mut text, tags) = lines.swap_remove(0);
text.push('\n');
Some((text, tags))
}
#[cfg(test)]
pub fn flush(&mut self) -> String {
debug_assert!(
!self.has_checkpoint(),
"flush() called with active checkpoints"
);
let unread = &self.transcript[self.cursor..];
let program = test_dummy_program();
let result = resolve_parts(unread, &program, &[], None, &self.fragments);
self.cursor = self.transcript.len();
result
}
pub fn flush_lines(
&mut self,
program: &Program,
line_tables: &[Vec<LineEntry>],
resolver: Option<&dyn PluralResolver>,
) -> Vec<(String, Vec<String>)> {
debug_assert!(
!self.has_checkpoint(),
"flush_lines() called with active checkpoints"
);
let unread = &self.transcript[self.cursor..];
let result = resolve_lines(unread, program, line_tables, resolver, &self.fragments);
self.cursor = self.transcript.len();
result
}
pub(crate) fn has_unread(&self) -> bool {
self.cursor < self.transcript.len()
}
pub fn transcript(&self) -> &[OutputPart] {
&self.transcript
}
pub fn reset_cursor(&mut self) {
self.cursor = 0;
}
pub fn transcript_len(&self) -> usize {
self.transcript.len()
}
}
fn mark_glue_removals(parts: &[OutputPart], remove: &mut [bool]) {
for (i, part) in parts.iter().enumerate() {
if matches!(part, OutputPart::Glue) {
for j in (0..i).rev() {
if remove[j] {
continue;
}
match &parts[j] {
OutputPart::Newline => {
remove[j] = true;
break;
}
OutputPart::Glue
| OutputPart::Checkpoint
| OutputPart::Tag(_)
| OutputPart::Spring => {}
OutputPart::Text(s) if s.trim().is_empty() => {}
OutputPart::Text(_) | OutputPart::LineRef { .. } | OutputPart::ValueRef(_) => {
break;
}
}
}
remove[i] = true;
}
}
}
fn resolve_parts(
parts: &[OutputPart],
program: &Program,
line_tables: &[Vec<LineEntry>],
resolver: Option<&dyn PluralResolver>,
fragments: &[Fragment],
) -> String {
let mut remove = vec![false; parts.len()];
mark_glue_removals(parts, &mut remove);
let mut out = String::new();
let mut after_glue = false;
for (i, part) in parts.iter().enumerate() {
if remove[i] {
if matches!(part, OutputPart::Glue) {
after_glue = true;
}
continue;
}
match part {
OutputPart::Text(_) | OutputPart::LineRef { .. } | OutputPart::ValueRef(_) => {
let s = resolve_part(part, program, line_tables, resolver, fragments);
let s = if s.starts_with(char::is_whitespace) && out.ends_with(char::is_whitespace)
{
s.trim_start()
} else {
&s
};
out.push_str(s);
if !s.trim().is_empty() {
after_glue = false;
}
}
OutputPart::Spring => {
if !out.is_empty() && !out.ends_with(' ') && !out.ends_with('\n') {
out.push(' ');
}
}
OutputPart::Newline => {
if !after_glue {
let trimmed_len = out.trim_end_matches([' ', '\t']).len();
out.truncate(trimmed_len);
out.push('\n');
}
}
OutputPart::Glue | OutputPart::Checkpoint | OutputPart::Tag(_) => {
after_glue = true;
}
}
}
out
}
pub(crate) fn resolve_lines(
parts: &[OutputPart],
program: &Program,
line_tables: &[Vec<LineEntry>],
resolver: Option<&dyn PluralResolver>,
fragments: &[Fragment],
) -> Vec<(String, Vec<String>)> {
if parts.is_empty() {
return Vec::new();
}
let mut remove = vec![false; parts.len()];
mark_glue_removals(parts, &mut remove);
let mut lines: Vec<(String, Vec<String>)> = Vec::new();
let mut current_text = String::new();
let mut current_tags: Vec<String> = Vec::new();
let mut after_glue = false;
for (i, part) in parts.iter().enumerate() {
if remove[i] {
if matches!(part, OutputPart::Glue) {
after_glue = true;
}
continue;
}
match part {
OutputPart::Text(_) | OutputPart::LineRef { .. } | OutputPart::ValueRef(_) => {
let s = resolve_part(part, program, line_tables, resolver, fragments);
let s = if s.starts_with(char::is_whitespace)
&& current_text.ends_with(char::is_whitespace)
{
s.trim_start()
} else {
&s
};
current_text.push_str(s);
if !s.trim().is_empty() {
after_glue = false;
}
}
OutputPart::Spring => {
if !current_text.is_empty()
&& !current_text.ends_with(' ')
&& !current_text.ends_with('\n')
{
current_text.push(' ');
}
}
OutputPart::Newline => {
if !after_glue {
let trimmed = current_text.trim().to_string();
lines.push((trimmed, std::mem::take(&mut current_tags)));
current_text = String::new();
}
}
OutputPart::Tag(tag) => {
current_tags.push(tag.clone());
}
OutputPart::Glue | OutputPart::Checkpoint => {
after_glue = true;
}
}
}
let trimmed = current_text.trim().to_string();
lines.push((trimmed, current_tags));
lines
}
#[cfg(test)]
fn test_dummy_program() -> Program {
use std::collections::HashMap;
Program {
containers: vec![],
address_map: HashMap::new(),
scope_ids: vec![],
source_checksum: 0,
globals: vec![],
global_map: HashMap::new(),
name_table: vec![],
address_by_path: HashMap::new(),
root_idx: 0,
list_literals: vec![],
list_item_map: HashMap::new(),
list_defs: vec![],
list_def_map: HashMap::new(),
external_fns: HashMap::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
impl OutputBuffer {
fn test_flush_lines(&mut self) -> Vec<(String, Vec<String>)> {
let p = test_dummy_program();
self.flush_lines(&p, &[], None)
}
fn test_take_first_line(&mut self) -> Option<(String, Vec<String>)> {
let p = test_dummy_program();
self.take_first_line(&p, &[], None)
}
fn test_end_capture(&mut self) -> Option<String> {
let p = test_dummy_program();
self.end_capture(&p, &[], None)
}
}
#[test]
fn simple_text() {
let mut buf = OutputBuffer::new();
buf.push_text("hello");
assert_eq!(buf.flush(), "hello");
}
#[test]
fn text_with_newline() {
let mut buf = OutputBuffer::new();
buf.push_text("hello");
buf.push_newline();
buf.push_text("world");
assert_eq!(buf.flush(), "hello\nworld");
}
#[test]
fn glue_removes_newline() {
let mut buf = OutputBuffer::new();
buf.push_text("hello");
buf.push_newline();
buf.push_glue();
buf.push_text("world");
assert_eq!(buf.flush(), "helloworld");
}
#[test]
fn glue_preserves_leading_whitespace_in_text() {
let mut buf = OutputBuffer::new();
buf.push_text("hello");
buf.push_newline();
buf.push_glue();
buf.push_text(" world");
assert_eq!(buf.flush(), "hello world");
}
#[test]
fn double_flush_is_empty() {
let mut buf = OutputBuffer::new();
buf.push_text("hello");
let _ = buf.flush();
assert_eq!(buf.flush(), "");
}
#[test]
fn leading_newline_suppressed() {
let mut buf = OutputBuffer::new();
buf.push_newline();
buf.push_text("hello");
assert_eq!(buf.flush(), "hello");
}
#[test]
fn leading_whitespace_only_text_suppressed() {
let mut buf = OutputBuffer::new();
buf.push_text(" ");
buf.push_text("hello");
assert_eq!(buf.flush(), "hello");
}
#[test]
fn adjacent_whitespace_collapsed() {
let mut buf = OutputBuffer::new();
buf.push_text("Hello ");
buf.push_text(" right back");
assert_eq!(buf.flush(), "Hello right back");
}
#[test]
fn leading_whitespace_after_flush_suppressed() {
let mut buf = OutputBuffer::new();
buf.push_text("first");
let _ = buf.flush();
buf.push_text(" ");
buf.push_text("second");
assert_eq!(buf.flush(), "second");
}
#[test]
fn duplicate_newline_suppressed() {
let mut buf = OutputBuffer::new();
buf.push_text("hello");
buf.push_newline();
buf.push_newline();
buf.push_text("world");
assert_eq!(buf.flush(), "hello\nworld");
}
#[test]
fn leading_newline_after_flush_suppressed() {
let mut buf = OutputBuffer::new();
buf.push_text("first");
let _ = buf.flush();
buf.push_newline();
buf.push_text("second");
assert_eq!(buf.flush(), "second");
}
#[test]
fn begin_end_capture_basic() {
let mut buf = OutputBuffer::new();
buf.push_text("before");
buf.begin_capture();
buf.push_text("captured");
let result = buf.test_end_capture();
assert_eq!(result, Some("captured".to_owned()));
assert_eq!(buf.flush(), "before");
}
#[test]
fn nested_captures() {
let mut buf = OutputBuffer::new();
buf.push_text("outer");
buf.begin_capture();
buf.push_text("middle");
buf.begin_capture();
buf.push_text("inner");
let inner = buf.test_end_capture();
assert_eq!(inner, Some("inner".to_owned()));
let middle = buf.test_end_capture();
assert_eq!(middle, Some("middle".to_owned()));
assert_eq!(buf.flush(), "outer");
}
#[test]
fn capture_with_glue() {
let mut buf = OutputBuffer::new();
buf.begin_capture();
buf.push_text("hello");
buf.push_newline();
buf.push_glue();
buf.push_text(" world");
let result = buf.test_end_capture();
assert_eq!(result, Some("hello world".to_owned()));
}
#[test]
fn end_capture_no_checkpoint_returns_none() {
let mut buf = OutputBuffer::new();
buf.push_text("hello");
assert_eq!(buf.test_end_capture(), None);
}
#[test]
fn has_content_respects_checkpoint() {
let mut buf = OutputBuffer::new();
buf.push_text("before");
buf.begin_capture();
assert!(!buf.has_content());
buf.push_text("after");
assert!(buf.has_content());
}
#[test]
fn glue_eats_following_newline() {
let mut buf = OutputBuffer::new();
buf.push_text("fifty");
buf.push_newline();
buf.push_glue();
buf.push_text("-");
buf.push_glue();
buf.push_newline();
buf.push_text("eight");
assert_eq!(buf.flush(), "fifty-eight");
}
#[test]
fn trailing_whitespace_before_newline_trimmed() {
let mut buf = OutputBuffer::new();
buf.push_text("A ");
buf.push_newline();
buf.push_text("X");
assert_eq!(buf.flush(), "A\nX");
}
#[test]
fn glue_preserves_text_whitespace() {
let mut buf = OutputBuffer::new();
buf.push_text("Some ");
buf.push_glue();
buf.push_newline();
buf.push_text("content");
buf.push_glue();
buf.push_text(" with glue.");
assert_eq!(buf.flush(), "Some content with glue.");
}
#[test]
fn glue_skips_whitespace_only_text_to_find_newline() {
let mut buf = OutputBuffer::new();
buf.push_text("a");
buf.push_newline();
buf.push_text(" ");
buf.push_glue();
buf.push_text("b");
assert_eq!(buf.flush(), "a b");
}
#[test]
fn flush_lines_associates_tags_with_lines() {
let mut buf = OutputBuffer::new();
buf.push_text("line one");
buf.push_newline();
buf.push_text("line two");
buf.push_tag("my_tag".to_string());
buf.push_newline();
buf.push_text("line three");
let lines = buf.test_flush_lines();
assert_eq!(lines.len(), 3);
assert_eq!(lines[0].0, "line one");
assert!(lines[0].1.is_empty());
assert_eq!(lines[1].0, "line two");
assert_eq!(lines[1].1, vec!["my_tag"]);
assert_eq!(lines[2].0, "line three");
assert!(lines[2].1.is_empty());
}
#[test]
fn flush_lines_tag_on_last_line() {
let mut buf = OutputBuffer::new();
buf.push_text("only line");
buf.push_tag("t".to_string());
let lines = buf.test_flush_lines();
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].0, "only line");
assert_eq!(lines[0].1, vec!["t"]);
}
#[test]
fn flush_lines_resolves_glue() {
let mut buf = OutputBuffer::new();
buf.push_text("hello");
buf.push_newline();
buf.push_glue();
buf.push_text(" world");
let lines = buf.test_flush_lines();
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].0, "hello world");
}
#[test]
fn flush_lines_empty_buffer_returns_no_lines() {
let mut buf = OutputBuffer::new();
let lines = buf.test_flush_lines();
assert!(
lines.is_empty(),
"empty buffer should produce no lines, got: {lines:?}"
);
}
#[test]
fn has_completed_line_empty() {
let buf = OutputBuffer::new();
assert!(!buf.has_completed_line());
}
#[test]
fn has_completed_line_text_only() {
let mut buf = OutputBuffer::new();
buf.push_text("hello");
assert!(!buf.has_completed_line());
}
#[test]
fn has_completed_line_text_newline_only() {
let mut buf = OutputBuffer::new();
buf.push_text("hello");
buf.push_newline();
assert!(!buf.has_completed_line());
}
#[test]
fn has_completed_line_text_newline_text() {
let mut buf = OutputBuffer::new();
buf.push_text("hello");
buf.push_newline();
buf.push_text("world");
assert!(buf.has_completed_line());
}
#[test]
fn has_completed_line_glue_eats_newline() {
let mut buf = OutputBuffer::new();
buf.push_text("hello");
buf.push_newline();
buf.push_glue();
buf.push_text("world");
assert!(!buf.has_completed_line());
}
#[test]
fn has_completed_line_during_capture() {
let mut buf = OutputBuffer::new();
buf.push_text("hello");
buf.push_newline();
buf.push_text("world");
buf.begin_capture();
assert!(!buf.has_completed_line());
}
#[test]
fn take_first_line_basic() {
let mut buf = OutputBuffer::new();
buf.push_text("hello");
buf.push_newline();
buf.push_text("world");
let result = buf.test_take_first_line();
assert!(result.is_some());
let (text, tags) = result.unwrap();
assert_eq!(text, "hello\n");
assert!(tags.is_empty());
assert_eq!(buf.flush(), "world");
}
#[test]
fn take_first_line_with_tags() {
let mut buf = OutputBuffer::new();
buf.push_text("tagged line");
buf.push_tag("my_tag".to_string());
buf.push_newline();
buf.push_text("next line");
let (text, tags) = buf.test_take_first_line().unwrap();
assert_eq!(text, "tagged line\n");
assert_eq!(tags, vec!["my_tag"]);
assert_eq!(buf.flush(), "next line");
}
#[test]
fn take_first_line_multiple_lines() {
let mut buf = OutputBuffer::new();
buf.push_text("line one");
buf.push_newline();
buf.push_text("line two");
buf.push_newline();
buf.push_text("line three");
let (text1, _) = buf.test_take_first_line().unwrap();
assert_eq!(text1, "line one\n");
let (text2, _) = buf.test_take_first_line().unwrap();
assert_eq!(text2, "line two\n");
assert!(!buf.has_completed_line());
assert_eq!(buf.flush(), "line three");
}
#[test]
fn take_first_line_matches_flush_lines() {
let parts = |buf: &mut OutputBuffer| {
buf.push_text("A ");
buf.push_tag("t1".to_string());
buf.push_newline();
buf.push_text("B");
buf.push_newline();
buf.push_text("C");
};
let mut buf1 = OutputBuffer::new();
parts(&mut buf1);
let all_lines = buf1.test_flush_lines();
let first_from_flush = &all_lines[0].0;
let mut buf2 = OutputBuffer::new();
parts(&mut buf2);
let (first_from_take, tags) = buf2.test_take_first_line().unwrap();
let first_trimmed = first_from_take.trim_end_matches('\n');
assert_eq!(first_trimmed, first_from_flush);
assert_eq!(tags, all_lines[0].1);
}
#[test]
fn take_first_line_glue_preserves_subsequent() {
let mut buf = OutputBuffer::new();
buf.push_text("hello");
buf.push_newline();
buf.push_glue();
buf.push_text(" world");
buf.push_newline();
buf.push_text("next");
let (text, _) = buf.test_take_first_line().unwrap();
assert_eq!(text, "hello world\n");
assert_eq!(buf.flush(), "next");
}
#[test]
fn take_first_line_none_when_empty() {
let mut buf = OutputBuffer::new();
assert!(buf.test_take_first_line().is_none());
}
#[test]
fn take_first_line_none_when_no_newline() {
let mut buf = OutputBuffer::new();
buf.push_text("no newline");
assert!(buf.test_take_first_line().is_none());
}
fn resolve_template(parts: Vec<LinePart>, slots: &[Value]) -> String {
use crate::program::LinkedContainer;
use brink_format::{CountingFlags, DefinitionId, DefinitionTag, LineEntry, LineFlags};
use std::collections::HashMap;
let id = DefinitionId::new(DefinitionTag::Address, 0);
let program = Program {
containers: vec![LinkedContainer {
id,
bytecode: vec![],
counting_flags: CountingFlags::empty(),
path_hash: 0,
scope_table_idx: 0,
}],
address_map: HashMap::new(),
scope_ids: vec![id],
source_checksum: 0,
globals: vec![],
global_map: HashMap::new(),
name_table: vec![],
address_by_path: HashMap::new(),
root_idx: 0,
list_literals: vec![],
list_item_map: HashMap::new(),
list_defs: vec![],
list_def_map: HashMap::new(),
external_fns: HashMap::new(),
};
let line_tables = vec![vec![LineEntry {
content: LineContent::Template(parts),
source_hash: 0,
flags: LineFlags::empty(),
audio_ref: None,
slot_info: vec![],
source_location: None,
}]];
resolve_line_ref(&program, &line_tables, 0, 0, slots, None, &[])
}
#[test]
fn template_collapses_double_space_from_empty_slot() {
let result = resolve_template(
vec![
LinePart::Literal("Hello ".into()),
LinePart::Slot(0),
LinePart::Literal(" world".into()),
],
&[Value::Null],
);
assert_eq!(result, "Hello world");
}
#[test]
fn template_preserves_spaces_with_nonempty_slot() {
let result = resolve_template(
vec![
LinePart::Literal("Hello ".into()),
LinePart::Slot(0),
LinePart::Literal(" world".into()),
],
&[Value::String("dear".into())],
);
assert_eq!(result, "Hello dear world");
}
#[test]
fn template_multiple_empty_slots_collapse() {
let result = resolve_template(
vec![
LinePart::Literal("a ".into()),
LinePart::Slot(0),
LinePart::Literal(" ".into()),
LinePart::Slot(1),
LinePart::Literal(" b".into()),
],
&[Value::Null, Value::Null],
);
assert_eq!(result, "a b");
}
#[test]
fn template_empty_string_slot_same_as_null() {
let result = resolve_template(
vec![
LinePart::Literal("Hello ".into()),
LinePart::Slot(0),
LinePart::Literal(" world".into()),
],
&[Value::String("".into())],
);
assert_eq!(result, "Hello world");
}
}