use gdck_config::IndentStyle;
pub(crate) const TAB_WIDTH: usize = 4;
#[derive(Debug, Clone)]
pub(crate) struct Doc {
kind: DocKind,
hard: bool,
}
#[derive(Debug, Clone)]
enum DocKind {
Nil,
Text(String),
Concat(Vec<Doc>),
Line,
SoftLine,
HardLine,
Group(Box<Doc>),
Indent(u8, Box<Doc>),
IfBreak(Box<Doc>, Box<Doc>),
Flat(Box<Doc>),
Verbatim(String),
}
impl Doc {
pub(crate) fn nil() -> Self {
Self {
kind: DocKind::Nil,
hard: false,
}
}
pub(crate) fn text(text: impl Into<String>) -> Self {
let text = text.into();
debug_assert!(
!text.contains('\n'),
"Doc::text must not contain a newline: {text:?}"
);
Self {
kind: DocKind::Text(text),
hard: false,
}
}
pub(crate) fn verbatim(text: impl Into<String>) -> Self {
Self {
kind: DocKind::Verbatim(text.into()),
hard: false,
}
}
pub(crate) fn literal(text: impl Into<String>) -> Self {
let text = text.into();
if text.contains('\n') {
Self::verbatim(text)
} else {
Self::text(text)
}
}
pub(crate) fn concat(parts: Vec<Doc>) -> Self {
let hard = parts.iter().any(|part| part.hard);
Self {
kind: DocKind::Concat(parts),
hard,
}
}
pub(crate) fn line() -> Self {
Self {
kind: DocKind::Line,
hard: false,
}
}
pub(crate) fn soft_line() -> Self {
Self {
kind: DocKind::SoftLine,
hard: false,
}
}
pub(crate) fn hard_line() -> Self {
Self {
kind: DocKind::HardLine,
hard: true,
}
}
pub(crate) fn break_parent() -> Self {
Self {
kind: DocKind::Nil,
hard: true,
}
}
pub(crate) fn group(inner: Doc) -> Self {
let hard = inner.hard;
Self {
kind: DocKind::Group(Box::new(inner)),
hard,
}
}
pub(crate) fn indent(inner: Doc) -> Self {
Self::indent_by(1, inner)
}
pub(crate) fn indent_by(levels: u8, inner: Doc) -> Self {
let hard = inner.hard;
Self {
kind: DocKind::Indent(levels, Box::new(inner)),
hard,
}
}
pub(crate) fn flat(inner: Doc) -> Self {
Self {
kind: DocKind::Flat(Box::new(inner)),
hard: false,
}
}
pub(crate) fn is_space(&self) -> bool {
matches!(&self.kind, DocKind::Text(text) if text == " ")
}
pub(crate) fn if_break(broken: Doc, flat: Doc) -> Self {
Self {
kind: DocKind::IfBreak(Box::new(broken), Box::new(flat)),
hard: false,
}
}
}
pub(crate) fn join(parts: Vec<Doc>, separator: &Doc) -> Doc {
let mut out = Vec::with_capacity(parts.len().saturating_mul(2));
for (index, part) in parts.into_iter().enumerate() {
if index > 0 {
out.push(separator.clone());
}
out.push(part);
}
Doc::concat(out)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Mode {
Flat,
Break,
}
type Cmd<'a> = (u8, Mode, &'a Doc);
#[allow(clippy::too_many_lines)]
pub(crate) fn render(doc: &Doc, width: usize, style: IndentStyle) -> String {
let unit = match style {
IndentStyle::Tabs => "\t".to_string(),
IndentStyle::Spaces(n) => " ".repeat(n as usize),
};
let unit_width = match style {
IndentStyle::Tabs => TAB_WIDTH,
IndentStyle::Spaces(n) => n as usize,
};
let mut out = String::new();
let mut column = 0usize;
let mut pending_indent: Option<u8> = None;
let mut stack: Vec<Cmd<'_>> = vec![(0, Mode::Break, doc)];
while let Some((indent, mode, doc)) = stack.pop() {
match &doc.kind {
DocKind::Nil => {}
DocKind::Text(text) => {
if let Some(level) = pending_indent.take() {
for _ in 0..level {
out.push_str(&unit);
}
}
out.push_str(text);
column += display_width(text);
}
DocKind::Concat(parts) => {
for part in parts.iter().rev() {
stack.push((indent, mode, part));
}
}
DocKind::Indent(levels, inner) => {
stack.push((indent.saturating_add(*levels), mode, inner));
}
DocKind::Group(inner) => {
let inner_mode = if mode == Mode::Flat {
Mode::Flat
} else if doc.hard {
Mode::Break
} else if fits(
width.saturating_sub(column),
(indent, Mode::Flat, inner),
&stack,
) {
Mode::Flat
} else {
Mode::Break
};
stack.push((indent, inner_mode, inner));
}
DocKind::Line => match mode {
Mode::Flat => {
if let Some(level) = pending_indent.take() {
for _ in 0..level {
out.push_str(&unit);
}
}
out.push(' ');
column += 1;
}
Mode::Break => {
new_line(
&mut out,
&mut column,
&mut pending_indent,
indent,
unit_width,
);
}
},
DocKind::SoftLine => {
if mode == Mode::Break {
new_line(
&mut out,
&mut column,
&mut pending_indent,
indent,
unit_width,
);
}
}
DocKind::HardLine => {
new_line(
&mut out,
&mut column,
&mut pending_indent,
indent,
unit_width,
);
}
DocKind::IfBreak(broken, flat) => {
let chosen = if mode == Mode::Break { broken } else { flat };
stack.push((indent, mode, chosen));
}
DocKind::Flat(inner) => stack.push((indent, Mode::Flat, inner)),
DocKind::Verbatim(text) => {
if let Some(level) = pending_indent.take() {
for _ in 0..level {
out.push_str(&unit);
}
}
out.push_str(text);
column = match text.rsplit_once('\n') {
Some((_, last)) => display_width(last),
None => column + display_width(text),
};
}
}
}
out
}
fn new_line(
out: &mut String,
column: &mut usize,
pending_indent: &mut Option<u8>,
indent: u8,
unit_width: usize,
) {
out.push('\n');
*pending_indent = Some(indent);
*column = indent as usize * unit_width;
}
#[allow(clippy::cast_possible_wrap)]
fn fits(remaining: usize, first: Cmd<'_>, rest: &[Cmd<'_>]) -> bool {
let mut remaining = remaining as isize;
let mut queue: Vec<Cmd<'_>> = vec![first];
let mut rest_index = rest.len();
loop {
if remaining < 0 {
return false;
}
let (indent, mode, doc) = if let Some(cmd) = queue.pop() {
cmd
} else {
if rest_index == 0 {
return true;
}
rest_index -= 1;
rest[rest_index]
};
match &doc.kind {
DocKind::Nil => {}
DocKind::Text(text) => remaining -= display_width(text) as isize,
DocKind::Concat(parts) => {
for part in parts.iter().rev() {
queue.push((indent, mode, part));
}
}
DocKind::Indent(levels, inner) => {
queue.push((indent.saturating_add(*levels), mode, inner));
}
DocKind::Group(inner) => {
let inner_mode = if doc.hard { Mode::Break } else { Mode::Flat };
queue.push((indent, inner_mode, inner));
}
DocKind::Line => match mode {
Mode::Flat => remaining -= 1,
Mode::Break => return true,
},
DocKind::SoftLine => {
if mode == Mode::Break {
return true;
}
}
DocKind::HardLine => return true,
DocKind::IfBreak(broken, flat) => {
let chosen = if mode == Mode::Break { broken } else { flat };
queue.push((indent, mode, chosen));
}
DocKind::Flat(inner) => queue.push((indent, Mode::Flat, inner)),
DocKind::Verbatim(text) => {
if text.contains('\n') {
return true;
}
remaining -= display_width(text) as isize;
}
}
}
}
fn display_width(text: &str) -> usize {
text.chars()
.map(|c| if c == '\t' { TAB_WIDTH } else { 1 })
.sum()
}
#[cfg(test)]
mod tests {
use super::*;
fn render_tabs(doc: &Doc, width: usize) -> String {
render(doc, width, IndentStyle::Tabs)
}
#[test]
fn a_group_that_fits_stays_flat() {
let doc = Doc::group(Doc::concat(vec![
Doc::text("f("),
Doc::soft_line(),
Doc::text("a"),
Doc::soft_line(),
Doc::text(")"),
]));
assert_eq!(render_tabs(&doc, 100), "f(a)");
}
#[test]
fn a_group_that_does_not_fit_breaks() {
let doc = Doc::group(Doc::concat(vec![
Doc::text("f("),
Doc::indent(Doc::concat(vec![Doc::soft_line(), Doc::text("argument")])),
Doc::soft_line(),
Doc::text(")"),
]));
assert_eq!(render_tabs(&doc, 5), "f(\n\targument\n)");
}
#[test]
fn a_hard_line_forces_every_enclosing_group() {
let doc = Doc::group(Doc::concat(vec![
Doc::text("a"),
Doc::line(),
Doc::hard_line(),
Doc::text("b"),
]));
assert_eq!(render_tabs(&doc, 100), "a\n\nb");
}
#[test]
fn blank_lines_carry_no_trailing_whitespace() {
let doc = Doc::indent(Doc::concat(vec![
Doc::hard_line(),
Doc::text("a"),
Doc::hard_line(),
Doc::hard_line(),
Doc::text("b"),
]));
assert_eq!(render_tabs(&doc, 100), "\n\ta\n\n\tb");
}
#[test]
fn if_break_picks_the_branch_matching_the_group() {
let trailing_comma = Doc::if_break(Doc::text(","), Doc::nil());
let build = |width| {
let doc = Doc::group(Doc::concat(vec![
Doc::text("["),
Doc::indent(Doc::concat(vec![
Doc::soft_line(),
Doc::text("1"),
trailing_comma.clone(),
])),
Doc::soft_line(),
Doc::text("]"),
]));
render_tabs(&doc, width)
};
assert_eq!(build(100), "[1]");
assert_eq!(build(2), "[\n\t1,\n]");
}
#[test]
fn fits_accounts_for_text_queued_after_the_group() {
let doc = Doc::concat(vec![
Doc::group(Doc::concat(vec![
Doc::text("("),
Doc::soft_line(),
Doc::text("ab"),
Doc::soft_line(),
Doc::text(")"),
])),
Doc::text(" trailing"),
]);
assert_eq!(render_tabs(&doc, 8), "(\nab\n) trailing");
}
#[test]
fn indentation_follows_the_configured_style() {
let doc = Doc::indent(Doc::concat(vec![Doc::hard_line(), Doc::text("x")]));
assert_eq!(render(&doc, 100, IndentStyle::Tabs), "\n\tx");
assert_eq!(render(&doc, 100, IndentStyle::Spaces(4)), "\n x");
}
#[test]
fn two_indent_levels_are_distinct_from_one() {
let doc = Doc::indent_by(2, Doc::concat(vec![Doc::hard_line(), Doc::text("x")]));
assert_eq!(render_tabs(&doc, 100), "\n\t\tx");
}
#[test]
fn a_tab_counts_as_four_columns_when_measuring() {
let build = |width| {
let doc = Doc::indent(Doc::concat(vec![
Doc::hard_line(),
Doc::group(Doc::concat(vec![
Doc::text("abcdef"),
Doc::soft_line(),
Doc::text("g"),
])),
]));
render_tabs(&doc, width)
};
assert_eq!(build(11), "\n\tabcdefg");
assert_eq!(build(10), "\n\tabcdef\n\tg");
}
}