Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

## Unreleased

- Fix: Preserve all markup boundary (nil, space or break) in formatting to retain the evaluation result (still with some limitations).

For example, the code below now will not change after formatting.
```typst
#[- first // note: leading spaces
- second ]
```

- Bump `typst` to v0.14.2 to address the wasmi CVE; the affected package is a transitive, test-only dependency.

## v0.14.4 - [2026-01-10]
Expand Down
193 changes: 107 additions & 86 deletions crates/typstyle-core/src/pretty/markup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use super::{
};
use crate::{ext::StrExt, pretty::util::is_only_one_and};

#[derive(Debug, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MarkupScope {
/// The top-level markup.
Document,
Expand All @@ -22,12 +22,6 @@ enum MarkupScope {
InlineItem,
}

impl MarkupScope {
fn can_trim(&self) -> bool {
matches!(self, Self::Item | Self::InlineItem)
}
}

impl<'a> PrettyPrinter<'a> {
pub fn convert_markup(&'a self, ctx: Context, markup: Markup<'a>) -> ArenaDoc<'a> {
self.convert_markup_impl(ctx, markup, MarkupScope::Document)
Expand Down Expand Up @@ -153,7 +147,7 @@ impl<'a> PrettyPrinter<'a> {
}
_ => FlowItem::none(),
});
self.indent(body)
self.hang(body)
}

fn convert_list_item_like(&'a self, ctx: Context, item: &'a SyntaxNode) -> ArenaDoc<'a> {
Expand All @@ -179,7 +173,7 @@ impl<'a> PrettyPrinter<'a> {
}
_ => FlowItem::none(),
});
self.indent(body)
self.hang(body)
}

fn convert_markup_impl(
Expand All @@ -198,54 +192,34 @@ impl<'a> PrettyPrinter<'a> {
return self.convert_space(ctx, space);
}

let repr = collect_markup_repr(markup);
let repr = collect_markup_repr(markup, scope);
let body = if self.config.wrap_text && scope != MarkupScope::InlineItem {
self.convert_markup_body_reflow(ctx, &repr)
} else {
self.convert_markup_body(ctx, &repr)
};

// Add line or space (if any) to both sides.
// Only turn space into, not the other way around.
let get_delim = |bound: Boundary| {
if scope == MarkupScope::Document || scope.can_trim() {
// should not add extra lines to the document
return if bound == Boundary::Break {
self.arena.hardline()
// Preserve boundary spaces in all cases.
let get_delim = |bound: Boundary| match bound {
Boundary::Nil => self.arena.nil(),
Boundary::WeakNilOrBreak => {
if self.config.wrap_text {
self.arena.line_()
} else {
self.arena.nil()
};
}
match bound {
Boundary::Nil => self.arena.nil(),
Boundary::NilOrBreak => {
if (scope.can_trim() || ctx.break_suppressed) && !self.config.wrap_text {
self.arena.nil()
} else {
self.arena.line_()
}
}
Boundary::WeakNilOrBreak => {
if self.config.wrap_text {
self.arena.line_()
} else {
self.arena.nil()
}
}
Boundary::Space(n) => {
if scope.can_trim() {
// the space can be safely eaten
self.arena.nil()
} else if self.config.wrap_text {
self.arena.line()
} else if self.config.collapse_markup_spaces {
self.arena.space()
} else {
self.arena.spaces(n)
}
}
Boundary::Space(n) => {
if self.config.wrap_text {
self.arena.line()
} else if self.config.collapse_markup_spaces {
self.arena.space()
} else {
self.arena.spaces(n)
}
Boundary::Break | Boundary::WeakBreak => self.arena.hardline(),
}
Boundary::Break => self.arena.hardline(),
};

let open = get_delim(repr.start_bound);
Expand Down Expand Up @@ -423,17 +397,13 @@ struct MarkupRepr<'a> {
enum Boundary {
/// Should add no blank.
Nil,
/// Beside blocky elements. Can turn to a linebreak when multiline.
NilOrBreak,
/// Can turn to a linebreak if not in document scope and text-wrapping enabled,
/// as there are already spaces after comments.
WeakNilOrBreak,
/// n spaces.
Space(usize),
/// Always breaks.
Break,
/// Always breaks if not in document scope.
WeakBreak,
}

impl Boundary {
Expand All @@ -444,29 +414,12 @@ impl Boundary {
Self::Space(space.len())
}
}

pub fn strip_space(self) -> Self {
match self {
Self::Space(_) => Self::NilOrBreak,
_ => self,
}
}
}

// Break markup into lines, split by stmt, parbreak, newline, multiline raw,
// equation if a line contains text, it will be skipped by the formatter
// to keep the original format.
fn collect_markup_repr(markup: Markup<'_>) -> MarkupRepr<'_> {
/// A subset of "blocky" elements that we cannot safely handle currently.
/// By default show rule, these elements seem to have weak spaces on both sides.
/// But this behavior can be changed by wrapping them in a box.
fn is_special_block_elem(it: &SyntaxNode) -> bool {
matches!(
it.kind(),
SyntaxKind::ListItem | SyntaxKind::EnumItem | SyntaxKind::TermItem
)
}

fn collect_markup_repr(markup: Markup<'_>, scope: MarkupScope) -> MarkupRepr<'_> {
let mut repr = MarkupRepr {
lines: vec![],
start_bound: Boundary::Nil,
Expand Down Expand Up @@ -496,9 +449,6 @@ fn collect_markup_repr(markup: Markup<'_>) -> MarkupRepr<'_> {
) {
current_line.mixed_text = true;
}
if current_line.nodes.is_empty() && is_special_block_elem(node) {
repr.start_bound = repr.start_bound.strip_space();
}
current_line.nodes.push(node);
false
}
Expand All @@ -523,45 +473,116 @@ fn collect_markup_repr(markup: Markup<'_>) -> MarkupRepr<'_> {
repr.end_bound = Boundary::from_space(last.text());
last_line.nodes.pop();
} else {
if is_special_block_elem(last) {
repr.end_bound = repr.end_bound.strip_space();
}
break;
}
}
}

// Check boundary through comments
if repr.start_bound == Boundary::Nil
// Try to trim spaces according to scope
try_trim_spaces(&mut repr, scope);

// Special (but unsafe) case: Ensure break before list items in content blocks
if scope == MarkupScope::ContentBlock {
ensure_break_before_list(&mut repr);
}

repr
}

fn try_trim_spaces(repr: &mut MarkupRepr<'_>, scope: MarkupScope) {
fn is_not_comment(node: &&&SyntaxNode) -> bool {
!is_comment_node(node)
}

// Check boundary through comments (except in Document scope)
if scope != MarkupScope::Document
&& repr.start_bound == Boundary::Nil
&& let Some(first_line) = repr.lines.first()
{
match first_line.nodes.iter().find(|it| !is_comment_node(it)) {
Some(it) if is_special_block_elem(it) => {
repr.start_bound = Boundary::NilOrBreak;
}
match first_line.nodes.iter().find(is_not_comment) {
Some(it) if it.kind() == SyntaxKind::Space => {
repr.start_bound = Boundary::WeakNilOrBreak;
}
None if !first_line.nodes.is_empty() => repr.start_bound = Boundary::WeakBreak,
None if !first_line.nodes.is_empty() => repr.start_bound = Boundary::Break,
_ => {}
}
}
if repr.end_bound == Boundary::Nil
&& let Some(last_line) = repr.lines.last()
{
match last_line.nodes.iter().rfind(|it| !is_comment_node(it)) {
Some(it) if is_special_block_elem(it) => {
repr.end_bound = Boundary::NilOrBreak;
}
match last_line.nodes.iter().rfind(is_not_comment) {
Some(it) if it.kind() == SyntaxKind::Space => {
repr.end_bound = Boundary::WeakNilOrBreak;
}
None if !last_line.nodes.is_empty() => repr.end_bound = Boundary::WeakBreak,
None if !last_line.nodes.is_empty() => repr.end_bound = Boundary::Break,
_ => {}
}
}

repr
// Special case: For leading spaces in Document scope, if we already have a space after,
// we can safely remove it.
// Since we always add trailing linebreaks after the document, we do not check for it.
fn may_produce_content(node: &&&SyntaxNode) -> bool {
!matches!(
node.kind(),
SyntaxKind::LineComment
| SyntaxKind::BlockComment
| SyntaxKind::Hash
| SyntaxKind::LetBinding
| SyntaxKind::SetRule
| SyntaxKind::ShowRule
| SyntaxKind::Import // `import` always returns none
)
}

if scope == MarkupScope::Document
&& matches!(repr.start_bound, Boundary::Space(_))
&& let Some(first_line) = repr.lines.first()
{
match first_line.nodes.iter().find(may_produce_content) {
Some(it) if it.kind() == SyntaxKind::Space => {
repr.start_bound = Boundary::Nil;
}
None if !first_line.nodes.is_empty() => repr.start_bound = Boundary::Nil,
_ => {}
}
}
}

fn ensure_break_before_list(repr: &mut MarkupRepr<'_>) {
// SPECIAL CASE: Force line break before first list/enum/term item in content blocks.
//
// PROBLEM: In Typst, there are no "list" nodes - only individual list/enum/term item nodes.
// The compiler groups items using indentation levels. When multiple items exist and the
// first item appears on the first line without a preceding break, subsequent items lose
// proper alignment because indentation becomes inconsistent.
//
// SOLUTION: Force a line break before the first item when:
// - Markup contains multiple list/enum/term items
// - First line contains one such item
// - We're in a ContentBlock scope (avoids affecting nested items)
//
// LIMITATION: This changes evaluation semantics by adding line breaks where none existed.
// Without this fix, formatted code would break item hierarchy and cause incorrect rendering.
if repr.start_bound != Boundary::Nil {
return;
}
let has_list_item = |line: &MarkupLine| {
line.nodes.iter().any(|node| {
matches!(
node.kind(),
SyntaxKind::ListItem | SyntaxKind::EnumItem | SyntaxKind::TermItem
)
})
};

if let Some(first_line) = repr.lines.first()
&& has_list_item(first_line)
&& repr.lines.iter().skip(1).any(has_list_item)
{
// Force a break to preserve alignment of subsequent items
repr.start_bound = Boundary::Break;
}
}

fn is_block_equation(it: &SyntaxNode) -> bool {
Expand Down
7 changes: 7 additions & 0 deletions crates/typstyle-core/src/pretty/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,20 @@ impl<'a> PrettyPrinter<'a> {

/// Utilities
impl<'a> PrettyPrinter<'a> {
#[inline]
pub(crate) fn indent(&'a self, doc: ArenaDoc<'a>) -> ArenaDoc<'a> {
doc.nest(self.config.tab_spaces as isize)
}

#[inline]
pub(crate) fn block_indent(&'a self, doc: ArenaDoc<'a>) -> ArenaDoc<'a> {
self.indent(self.arena.line_() + doc) + self.arena.line_()
}

#[inline]
pub(crate) fn hang(&'a self, doc: ArenaDoc<'a>) -> ArenaDoc<'a> {
self.indent(doc).align()
}
}

impl<'a> PrettyPrinter<'a> {
Expand Down
20 changes: 20 additions & 0 deletions docs/pages/features/markup.typ
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

#show: render-examples

We strive to ensure that the eval results remain unchanged as much as possible. However, there are still some tricky (rare) cases that we cannot handle well, listed below.

= Lists and Enumerations

== List Indentation
Expand Down Expand Up @@ -42,6 +44,24 @@ Lists within content blocks are properly formatted with surrounding linebreaks:
}
```

#callout.caution[
For now, we can only safely handle the case that there is at most one list item in the markup body if that item appears on the first line.
In other cases, we will prepend a line break to prevent mis-alignment of items, which is unsafe, as it changes the representation (one space prepended).

```typst
#[- first // note: leading spaces
- second ] // this is safe
```
```typst
#[- a
- b
- c
] // this is unsafe
```

This is a known limitation.
]

= Text Wrapping

When text wrapping is enabled with `--wrap-text`, typstyle intelligently wraps long lines while preserving important formatting and semantic structure:
Expand Down
3 changes: 1 addition & 2 deletions tests/fixtures/articles/snap/book.typ-0.snap
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,7 @@ input_file: tests/fixtures/articles/book.typ
"7mile",
),
language: "en",
summary: [
// begin of summary
summary: [ // begin of summary
#prefix-chapter(
"introduction.typ",
)[Introduction]
Expand Down
3 changes: 1 addition & 2 deletions tests/fixtures/articles/snap/book.typ-120.snap
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,7 @@ input_file: tests/fixtures/articles/book.typ
repository-edit: "https://github.com/Myriad-Dreamin/typst-book/edit/main/github-pages/docs/{path}",
authors: ("Myriad-Dreamin", "7mile"),
language: "en",
summary: [
// begin of summary
summary: [ // begin of summary
#prefix-chapter("introduction.typ")[Introduction]
= User Guide
- #chapter("guide/installation.typ", section: "1")[Installation]
Expand Down
Loading
Loading