summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMartin Haug <mhaug@live.de>2020-09-01 13:10:48 +0200
committerMartin Haug <mhaug@live.de>2020-09-01 13:10:48 +0200
commitd986bc4b0a35957c3f5cece82497c69e957f5c79 (patch)
treeacbf164c604f9c085866b23d7500b9282be12f2a
parent798c8a10c861c6185f1fdee3b0ce3c46dcd96700 (diff)
Add section headings 👨‍🦲
Co-authored-by: Laurenz Mädje <laurmaedje@gmail.com>
-rw-r--r--src/layout/tree.rs16
-rw-r--r--src/syntax/parsing.rs250
-rw-r--r--src/syntax/tokens.rs14
-rw-r--r--src/syntax/tree.rs25
-rw-r--r--tests/coma.typ2
5 files changed, 216 insertions, 91 deletions
diff --git a/src/layout/tree.rs b/src/layout/tree.rs
index aa0e0df1..faecc42b 100644
--- a/src/layout/tree.rs
+++ b/src/layout/tree.rs
@@ -6,7 +6,7 @@ use super::*;
use crate::style::LayoutStyle;
use crate::syntax::decoration::Decoration;
use crate::syntax::span::{Span, Spanned};
-use crate::syntax::tree::{CallExpr, Code, SyntaxNode, SyntaxTree};
+use crate::syntax::tree::{CallExpr, Code, Heading, SyntaxNode, SyntaxTree};
use crate::{DynFuture, Feedback, Pass};
/// Layout a syntax tree into a collection of boxes.
@@ -81,6 +81,8 @@ impl<'a> TreeLayouter<'a> {
self.layout_text(text).await;
}
+ SyntaxNode::Heading(heading) => self.layout_heading(heading).await,
+
SyntaxNode::Raw(lines) => self.layout_raw(lines).await,
SyntaxNode::Code(block) => self.layout_code(block).await,
@@ -114,6 +116,18 @@ impl<'a> TreeLayouter<'a> {
);
}
+ async fn layout_heading(&mut self, heading: &Heading) {
+ let style = self.style.text.clone();
+ self.style.text.font_scale *= 1.5 - 0.1 * heading.level.v.min(5) as f64;
+ self.style.text.bolder = true;
+
+ self.layout_parbreak();
+ self.layout_tree(&heading.tree).await;
+ self.layout_parbreak();
+
+ self.style.text = style;
+ }
+
async fn layout_raw(&mut self, lines: &[String]) {
// TODO: Make this more efficient.
let fallback = self.style.text.fallback.clone();
diff --git a/src/syntax/parsing.rs b/src/syntax/parsing.rs
index 6a8b8103..19434bab 100644
--- a/src/syntax/parsing.rs
+++ b/src/syntax/parsing.rs
@@ -5,7 +5,7 @@ use std::str::FromStr;
use super::decoration::Decoration;
use super::span::{Pos, Span, Spanned};
use super::tokens::{is_newline_char, Token, TokenMode, Tokens};
-use super::tree::{CallExpr, Code, Expr, SyntaxNode, SyntaxTree, TableExpr};
+use super::tree::{CallExpr, Code, Expr, Heading, SyntaxNode, SyntaxTree, TableExpr};
use super::Ident;
use crate::color::RgbaColor;
use crate::compute::table::SpannedEntry;
@@ -20,6 +20,7 @@ struct Parser<'s> {
tokens: Tokens<'s>,
peeked: Option<Option<Spanned<Token<'s>>>>,
delimiters: Vec<(Pos, Token<'static>)>,
+ at_block_or_line_start: bool,
feedback: Feedback,
}
@@ -29,6 +30,7 @@ impl<'s> Parser<'s> {
tokens: Tokens::new(src, TokenMode::Body),
peeked: None,
delimiters: vec![],
+ at_block_or_line_start: true,
feedback: Feedback::new(),
}
}
@@ -44,100 +46,152 @@ impl Parser<'_> {
fn parse_body_contents(&mut self) -> SyntaxTree {
let mut tree = SyntaxTree::new();
- while let Some(token) = self.peek() {
- tree.push(match token.v {
- // Starting from two newlines counts as a paragraph break, a single
- // newline does not.
- Token::Space(newlines) => self.with_span(if newlines < 2 {
- SyntaxNode::Spacing
- } else {
- SyntaxNode::Parbreak
- }),
+ self.at_block_or_line_start = true;
+ while !self.eof() {
+ if let Some(node) = self.parse_node() {
+ tree.push(node);
+ }
+ }
- Token::LineComment(_) | Token::BlockComment(_) => {
- self.eat();
- continue;
- }
+ tree
+ }
- Token::LeftBracket => {
- self.parse_bracket_call(false).map(SyntaxNode::Call)
+ fn parse_node(&mut self) -> Option<Spanned<SyntaxNode>> {
+ let token = self.peek()?;
+ let end = Span::at(token.span.end);
+
+ // Set block or line start to false because most nodes have that effect, but
+ // remember the old value to actually check it for hashtags and because comments
+ // and spaces want to retain it.
+ let was_at_block_or_line_start = self.at_block_or_line_start;
+ self.at_block_or_line_start = false;
+
+ Some(match token.v {
+ // Starting from two newlines counts as a paragraph break, a single
+ // newline does not.
+ Token::Space(n) => {
+ if n == 0 {
+ self.at_block_or_line_start = was_at_block_or_line_start;
+ } else if n >= 1 {
+ self.at_block_or_line_start = true;
}
- Token::Star => self.with_span(SyntaxNode::ToggleBolder),
- Token::Underscore => self.with_span(SyntaxNode::ToggleItalic),
- Token::Backslash => self.with_span(SyntaxNode::Linebreak),
+ self.with_span(if n >= 2 {
+ SyntaxNode::Parbreak
+ } else {
+ SyntaxNode::Spacing
+ })
+ },
- Token::Raw { raw, terminated } => {
- if !terminated {
- error!(
- @self.feedback, Span::at(token.span.end),
- "expected backtick",
- );
- }
- self.with_span(SyntaxNode::Raw(unescape_raw(raw)))
- }
+ Token::LineComment(_) | Token::BlockComment(_) => {
+ self.at_block_or_line_start = was_at_block_or_line_start;
+ self.eat();
+ return None;
+ }
- Token::Code { lang, raw, terminated } => {
- if !terminated {
- error!(
- @self.feedback, Span::at(token.span.end),
- "expected backticks",
- );
- }
+ Token::LeftBracket => {
+ let call = self.parse_bracket_call(false);
+ self.at_block_or_line_start = false;
+ call.map(SyntaxNode::Call)
+ }
- let lang = lang.and_then(|lang| {
- if let Some(ident) = Ident::new(lang.v) {
- Some(Spanned::new(ident, lang.span))
- } else {
- error!(@self.feedback, lang.span, "invalid identifier");
- None
- }
- });
+ Token::Star => self.with_span(SyntaxNode::ToggleBolder),
+ Token::Underscore => self.with_span(SyntaxNode::ToggleItalic),
+ Token::Backslash => self.with_span(SyntaxNode::Linebreak),
+
+ Token::Hashtag if was_at_block_or_line_start => {
+ self.parse_heading().map(SyntaxNode::Heading)
+ }
+
+ Token::Raw { raw, terminated } => {
+ if !terminated {
+ error!(@self.feedback, end, "expected backtick");
+ }
+ self.with_span(SyntaxNode::Raw(unescape_raw(raw)))
+ }
- let mut lines = unescape_code(raw);
- let block = lines.len() > 1;
+ Token::Code { lang, raw, terminated } => {
+ if !terminated {
+ error!(@self.feedback, end, "expected backticks");
+ }
- if lines.last().map(|s| s.is_empty()).unwrap_or(false) {
- lines.pop();
+ let lang = lang.and_then(|lang| {
+ if let Some(ident) = Ident::new(lang.v) {
+ Some(Spanned::new(ident, lang.span))
+ } else {
+ error!(@self.feedback, lang.span, "invalid identifier");
+ None
}
+ });
- self.with_span(SyntaxNode::Code(Code { lang, lines, block }))
+ let mut lines = unescape_code(raw);
+ let block = lines.len() > 1;
+
+ if lines.last().map(|s| s.is_empty()).unwrap_or(false) {
+ lines.pop();
}
- Token::Text(text) => self.with_span(SyntaxNode::Text(text.to_string())),
+ self.with_span(SyntaxNode::Code(Code { lang, lines, block }))
+ }
- Token::UnicodeEscape { sequence, terminated } => {
- if !terminated {
- error!(
- @self.feedback, Span::at(token.span.end),
- "expected closing brace",
- );
- }
+ Token::Text(text) => self.with_span(SyntaxNode::Text(text.to_string())),
+ Token::Hashtag => self.with_span(SyntaxNode::Text("#".to_string())),
- if let Some(c) = unescape_char(sequence) {
- self.with_span(SyntaxNode::Text(c.to_string()))
- } else {
- self.eat();
- error!(
- @self.feedback, token.span,
- "invalid unicode escape sequence",
- );
- continue;
- }
+ Token::UnicodeEscape { sequence, terminated } => {
+ if !terminated {
+ error!(@self.feedback, end, "expected closing brace");
}
- unexpected => {
+ if let Some(c) = unescape_char(sequence) {
+ self.with_span(SyntaxNode::Text(c.to_string()))
+ } else {
+ error!(@self.feedback, token.span, "invalid unicode escape sequence");
self.eat();
- error!(
- @self.feedback, token.span,
- "unexpected {}", unexpected.name(),
- );
- continue;
+ return None;
}
- });
+ }
+
+ unexpected => {
+ error!(@self.feedback, token.span, "unexpected {}", unexpected.name());
+ self.eat();
+ return None;
+ }
+ })
+ }
+
+ fn parse_heading(&mut self) -> Spanned<Heading> {
+ let start = self.pos();
+ self.assert(Token::Hashtag);
+
+ let mut level = 0;
+ while self.peekv() == Some(Token::Hashtag) {
+ level += 1;
+ self.eat();
}
- tree
+ let span = Span::new(start, self.pos());
+ let level = Spanned::new(level, span);
+
+ if level.v > 5 {
+ warning!(
+ @self.feedback, level.span,
+ "section depth larger than 6 has no effect",
+ );
+ }
+
+ self.skip_white();
+
+ let mut tree = SyntaxTree::new();
+ while !self.eof()
+ && !matches!(self.peekv(), Some(Token::Space(n)) if n >= 1)
+ {
+ if let Some(node) = self.parse_node() {
+ tree.push(node);
+ }
+ }
+
+ let span = Span::new(start, self.pos());
+ Spanned::new(Heading { level, tree }, span)
}
}
@@ -798,6 +852,15 @@ mod tests {
SyntaxNode::Text(text.to_string())
}
+ macro_rules! H {
+ ($level:expr, $($tts:tt)*) => {
+ SyntaxNode::Heading(Heading {
+ level: Spanned::zero($level),
+ tree: Tree![@$($tts)*],
+ })
+ };
+ }
+
macro_rules! R {
($($line:expr),* $(,)?) => {
SyntaxNode::Raw(vec![$($line.to_string()),*])
@@ -1000,6 +1063,15 @@ mod tests {
}
#[test]
+ fn test_parse_groups() {
+ e!("[)" => s(0,1, 0,2, "expected function name, found closing paren"),
+ s(0,2, 0,2, "expected closing bracket"));
+
+ e!("[v:{]}" => s(0,4, 0,4, "expected closing brace"),
+ s(0,5, 0,6, "unexpected closing brace"));
+ }
+
+ #[test]
fn test_parse_simple_nodes() {
t!("" => );
t!("hi" => T("hi"));
@@ -1050,12 +1122,32 @@ mod tests {
}
#[test]
- fn test_parse_groups() {
- e!("[)" => s(0,1, 0,2, "expected function name, found closing paren"),
- s(0,2, 0,2, "expected closing bracket"));
+ fn test_parse_headings() {
+ t!("## Hello world!" => H![1, T("Hello"), S, T("world!")]);
- e!("[v:{]}" => s(0,4, 0,4, "expected closing brace"),
- s(0,5, 0,6, "unexpected closing brace"));
+ // Handle various whitespace usages.
+ t!("####Simple" => H![3, T("Simple")]);
+ t!(" # Whitespace!" => S, H![0, T("Whitespace!")]);
+ t!(" /* TODO: Improve */ ## Analysis" => S, S, H!(1, T("Analysis")));
+
+ // Complex heading contents.
+ t!("Some text [box][### Valuable facts]" => T("Some"), S, T("text"), S,
+ F!("box"; Tree![H!(2, T("Valuable"), S, T("facts"))])
+ );
+ t!("### Grandiose stuff [box][Get it \n\n straight]" => H![2,
+ T("Grandiose"), S, T("stuff"), S,
+ F!("box"; Tree![T("Get"), S, T("it"), P, T("straight")])
+ ]);
+ t!("###### Multiline \\ headings" => H![5, T("Multiline"), S, L, S, T("headings")]);
+
+ // Things that should not become headings.
+ t!("\\## Text" => T("#"), T("#"), S, T("Text"));
+ t!(" ###### # Text" => S, H!(5, T("#"), S, T("Text")));
+ t!("I am #1" => T("I"), S, T("am"), S, T("#"), T("1"));
+ t!("[box][\n] # hi" => F!("box"; Tree![S]), S, T("#"), S, T("hi"));
+
+ // Depth warnings.
+ e!("########" => s(0,0, 0,8, "section depth larger than 6 has no effect"));
}
#[test]
diff --git a/src/syntax/tokens.rs b/src/syntax/tokens.rs
index e333968b..f6ae834e 100644
--- a/src/syntax/tokens.rs
+++ b/src/syntax/tokens.rs
@@ -78,10 +78,12 @@ pub enum Token<'s> {
Star,
/// An underscore in body-text.
Underscore,
-
/// A backslash followed by whitespace in text.
Backslash,
+ /// A hashtag token in the body can indicate compute mode or headings.
+ Hashtag,
+
/// A unicode escape sequence.
UnicodeEscape {
/// The escape sequence between two braces.
@@ -144,6 +146,7 @@ impl<'s> Token<'s> {
Star => "star",
Underscore => "underscore",
Backslash => "backslash",
+ Hashtag => "hashtag",
UnicodeEscape { .. } => "unicode escape sequence",
Raw { .. } => "raw text",
Code { .. } => "code block",
@@ -265,6 +268,9 @@ impl<'s> Iterator for Tokens<'s> {
'_' if self.mode == Body => Underscore,
'`' if self.mode == Body => self.read_raw_or_code(),
+ // Sections.
+ '#' if self.mode == Body => Hashtag,
+
// Non-breaking spaces.
'~' if self.mode == Body => Text("\u{00A0}"),
@@ -282,7 +288,7 @@ impl<'s> Iterator for Tokens<'s> {
let val = match n {
c if c.is_whitespace() => true,
'[' | ']' | '{' | '}' | '/' | '*' => true,
- '\\' | '_' | '`' | '~' if body => true,
+ '\\' | '_' | '`' | '#' | '~' if body => true,
':' | '=' | ',' | '"' | '(' | ')' if !body => true,
'+' | '-' if !body && !last_was_e => true,
_ => false,
@@ -442,7 +448,7 @@ impl<'s> Tokens<'s> {
fn read_escaped(&mut self) -> Token<'s> {
fn is_escapable(c: char) -> bool {
match c {
- '[' | ']' | '\\' | '/' | '*' | '_' | '`' | '"' | '~' => true,
+ '[' | ']' | '\\' | '/' | '*' | '_' | '`' | '"' | '#' | '~' => true,
_ => false,
}
}
@@ -674,6 +680,8 @@ mod tests {
t!(Body, "[func]*bold*" => L, T("func"), R, Star, T("bold"), Star);
t!(Body, "hi_you_ there" => T("hi"), Underscore, T("you"), Underscore, S(0), T("there"));
t!(Body, "`raw`" => Raw("raw", true));
+ t!(Body, "# hi" => Hashtag, S(0), T("hi"));
+ t!(Body, "#()" => Hashtag, T("()"));
t!(Body, "`[func]`" => Raw("[func]", true));
t!(Body, "`]" => Raw("]", false));
t!(Body, "`\\``" => Raw("\\`", true));
diff --git a/src/syntax/tree.rs b/src/syntax/tree.rs
index 7295ec04..94dfc124 100644
--- a/src/syntax/tree.rs
+++ b/src/syntax/tree.rs
@@ -31,6 +31,8 @@ pub enum SyntaxNode {
ToggleBolder,
/// Plain text.
Text(String),
+ /// Section headings.
+ Heading(Heading),
/// Lines of raw text.
Raw(Vec<String>),
/// An optionally highlighted (multi-line) code block.
@@ -39,6 +41,22 @@ pub enum SyntaxNode {
Call(CallExpr),
}
+/// A section heading.
+#[derive(Debug, Clone, PartialEq)]
+pub struct Heading {
+ /// The section depth (how many hashtags minus 1).
+ pub level: Spanned<u8>,
+ pub tree: SyntaxTree,
+}
+
+/// A code block.
+#[derive(Debug, Clone, PartialEq)]
+pub struct Code {
+ pub lang: Option<Spanned<Ident>>,
+ pub lines: Vec<String>,
+ pub block: bool,
+}
+
/// An expression.
#[derive(Clone, PartialEq)]
pub enum Expr {
@@ -197,10 +215,3 @@ impl CallExpr {
}
}
}
-/// A code block.
-#[derive(Debug, Clone, PartialEq)]
-pub struct Code {
- pub lang: Option<Spanned<Ident>>,
- pub lines: Vec<String>,
- pub block: bool,
-}
diff --git a/tests/coma.typ b/tests/coma.typ
index 3a886699..1271cd86 100644
--- a/tests/coma.typ
+++ b/tests/coma.typ
@@ -15,7 +15,7 @@
[v: 6mm]
[align: center][
- *3. Übungsblatt Computerorientierte Mathematik II* [v: 2mm]
+ #### 3. Übungsblatt Computerorientierte Mathematik II* [v: 2mm]
*Abgabe: 03.05.2019* (bis 10:10 Uhr in MA 001) [v: 2mm]
*Alle Antworten sind zu beweisen.*
]