///|
/// One piece of a text body, while whitespace is being adjusted.
priv struct TextPiece {
/// The text the piece DENOTES, after trimming.
mut text : String
/// The source text it ACCOUNTS FOR, which trimming does not touch.
///
/// These come apart on every line of an indented `@` body: the datum is the
/// line without its shared indentation, and the raw is the line as written.
/// Keeping only one of them would either change what the text says or make
/// the source unreconstructable.
raw : @raw.Raw
/// The port column where this piece starts, which is what makes the shared
/// indentation of the FIRST line -- the one that shares a line with the
/// opener -- comparable to the rest.
col0 : Int
span : @basic.Span
}
///|
priv enum Piece {
PText(TextPiece)
PComment(@raw.Raw)
PGroup(@ast.Node)
}
///|
fn Piece::is_newline(self : Piece) -> Bool {
match self {
PText(p) => p.text == "\n"
_ => false
}
}
///|
fn Piece::is_blank_text(self : Piece) -> Bool {
match self {
PText(p) => {
if p.text == "\n" {
return false
}
for c in p.text {
if !(c == ' ' || c == '\t' || c == '\u{B}' || c == '\u{C}' || c == '\r') {
return false
}
}
true
}
_ => false
}
}
///|
/// Turn the raw pieces of a `{...}` body into the content of a bracketed
/// sequence, and the raw text that falls off either end.
///
/// The eight steps of the specification, in order. What they add up to: a text
/// body reads the way it is written, so that
///
/// @a{
/// one
/// two
/// }
///
/// is the two lines and not the newlines and indentation around them, and
/// de-indenting the whole block does not change what it says.
fn adjust_content_space(
content : Array[ContentPiece],
) -> (@raw.Raw, Array[@ast.Node], @raw.Raw) {
let pieces : Array[Piece] = content.map(c => {
match c {
CText(t) =>
PText({
text: t.text,
raw: Str(t.raw_text()),
col0: t.span.start.col0,
span: t.span,
})
CComment(r) => PComment(r)
CGroup(n) => PGroup(n)
}
})
// 2. Trailing whitespace on each line goes, except on the opener's line
// before non-whitespace and the closer's line after it.
remove_trailing_spaces(pieces)
// 3-4. The space and newline immediately inside each end go.
let end2 = discard_immediate_space(pieces, from_end=true)
let end1 = discard_immediate_newline(pieces, from_end=true)
let start1 = discard_immediate_space(pieces, from_end=false)
let start2 = discard_immediate_newline(pieces, from_end=false)
// 7. Indentation shared by every non-empty line goes.
let min_col = get_min_column(pieces)
let trimmed = trim_shared_column(pieces, min_col, start2 is Some(_))
// 8. Comments attach to the piece that follows them.
let (nodes, trailing) = convert_content(trimmed)
let prefix = raw_of(start1).combine(raw_of(start2))
let suffix = trailing.combine(raw_of(end1)).combine(raw_of(end2))
(prefix, nodes, suffix)
}
///|
fn raw_of(p : Piece?) -> @raw.Raw {
match p {
Some(PText(t)) => t.raw
Some(PComment(r)) => r
_ => Empty
}
}
///|
/// Strip trailing whitespace from the text piece before each newline.
fn remove_trailing_spaces(pieces : Array[Piece]) -> Unit {
for i in 0..
if p.text != "\n" {
let mut end = p.text.length()
while end > 0 {
let c = p.text.get_char(end - 1)
match c {
Some(ch) if is_text_space(ch) => end = end - 1
_ => break
}
}
if end < p.text.length() {
// Only the datum is trimmed; the raw still accounts for the
// whitespace that was written.
p.text = p.text.clamped_view(end~).to_owned()
}
}
_ => ()
}
}
}
///|
fn is_text_space(c : Char) -> Bool {
c == ' ' ||
c == '\t' ||
c == '\n' ||
c == '\r' ||
c == '\u{B}' ||
c == '\u{C}'
}
///|
/// Drop a whitespace-only piece at one end when a newline is next to it.
fn discard_immediate_space(pieces : Array[Piece], from_end~ : Bool) -> Piece? {
if pieces.length() < 2 {
return None
}
let at = if from_end { pieces.length() - 1 } else { 0 }
let neighbour = if from_end { at - 1 } else { at + 1 }
if pieces[at].is_blank_text() && pieces[neighbour].is_newline() {
let p = pieces[at]
let _ = pieces.remove(at)
Some(p)
} else {
None
}
}
///|
/// Drop a newline at one end, unless it is all that is left.
fn discard_immediate_newline(pieces : Array[Piece], from_end~ : Bool) -> Piece? {
if pieces.length() < 2 {
return None
}
let at = if from_end { pieces.length() - 1 } else { 0 }
if pieces[at].is_newline() {
let p = pieces[at]
let _ = pieces.remove(at)
Some(p)
} else {
None
}
}
///|
/// The least indentation of any non-empty line.
///
/// A line's indentation is its leading whitespace PLUS the port column it
/// starts at, so that the first line -- which starts wherever the opener left
/// off -- is measured on the same scale as the rest. A line holding anything
/// other than text starts at its own column and contributes that.
fn get_min_column(pieces : Array[Piece]) -> Int? {
let mut min_col : Int? = None
let mut saw_nl = true
for p in pieces {
if p.is_newline() {
saw_nl = true
continue
}
match p {
PText(t) if saw_nl => {
if t.text == "" {
// Stripping trailing whitespace made the line blank.
continue
}
let n = leading_space_count(t.text) + t.col0
min_col = Some(
match min_col {
Some(m) => if n < m { n } else { m }
None => n
},
)
saw_nl = false
}
_ =>
if saw_nl {
min_col = Some(min_col.unwrap_or(0))
saw_nl = false
}
}
}
min_col
}
///|
fn leading_space_count(s : String) -> Int {
let mut i = 0
while i < s.length() {
match s.get_char(i) {
Some(c) if is_text_space(c) => i = i + 1
_ => break
}
}
i
}
///|
/// Remove the shared indentation, splitting what is left of each line's
/// leading whitespace into a piece of its own.
fn trim_shared_column(
pieces : Array[Piece],
min_col : Int?,
start_nl : Bool,
) -> Array[Piece] {
let out : Array[Piece] = []
let mut saw_nl = start_nl
let min = min_col.unwrap_or(0)
for p in pieces {
if p.is_newline() {
out.push(p)
saw_nl = true
continue
}
match p {
PText(t) if saw_nl => {
let n = leading_space_count(t.text)
let add_back = if n - min > 0 { n - min } else { 0 }
let rest = if n == 0 {
t.text
} else {
t.text.clamped_view(start=n).to_owned()
}
let dropped = rest.length() == 0
if add_back > 0 {
out.push(
PText({
text: " ".repeat(add_back),
// When the line had content, this whitespace is already part of
// that content's raw and must not be counted twice; when the line
// was nothing BUT indentation, this piece is all that is left to
// carry it.
raw: if dropped {
t.raw
} else {
Empty
},
col0: t.col0,
span: t.span,
}),
)
} else if dropped {
// A line that was nothing but shared indentation leaves its text
// behind as a comment, so the source can still be rebuilt.
out.push(PComment(t.raw))
}
if !dropped {
out.push(
PText({ text: rest, raw: t.raw, col0: t.col0 + n, span: t.span, }),
)
}
saw_nl = false
}
PComment(_) => out.push(p)
_ => {
out.push(p)
saw_nl = false
}
}
}
out
}
///|
/// Wrap each text run in a group, and hang comments off whatever follows them.
fn convert_content(pieces : Array[Piece]) -> (Array[@ast.Node], @raw.Raw) {
let out : Array[@ast.Node] = []
let mut pending : @raw.Raw = Empty
for p in pieces {
match p {
PComment(r) => pending = pending.combine(r)
PText(t) => {
let leaf = @ast.Node::new(Lit(Str(t.text)), t.span)
leaf.meta.raw = t.raw
let node = @ast.Node::new(Group([leaf]), t.span)
node.meta.prefix = pending
pending = Empty
out.push(node)
}
PGroup(n) => {
n.meta.prefix = pending.combine(n.meta.prefix)
pending = Empty
out.push(n)
}
}
}
(out, pending)
}