tsv_svelte 0.8.0

Svelte formatter and parser compatible with Svelte's parse() — part of tsv
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
//! Lifted runs: a hoisted root section written **between** two template nodes.
//!
//! The canonical reorder takes a `<script>`, `<style>` or `<svelte:options>` out of the template
//! together with the comments that travel with it (its leading run and its region-end trail —
//! [`Printer::hoisted_comments`]), so the template nodes written on either side of that run
//! become neighbours. The compiler never saw the run between them either (a lifted section is
//! not a fragment node, and a comment renders nothing), so the neighbours join as if the run
//! had never been there. That is a fact about the whole template layout — text-edge trims, the
//! glue tests, blank-line reads, the fill, the global-element glue — and every one of those
//! readers asks it positionally, off source bytes and node spans that still have the run in
//! between. So it is answered once, ahead of the parse the layout reads: the document is
//! rewritten in the OUTPUT's own layout — every section run in canonical order, the template
//! with each such run's neighbours joined, the `<style>` run last — and the rewritten document
//! is what gets formatted, the same shape as the template-tail respell (`format_respelled_tail`).
//!
//! Writing every run where the output prints it, rather than only the runs that sat between
//! template nodes, is what keeps the comments where they were: the rewritten document holds its
//! comments in the order the output does, so it reads them the way the output's own next pass
//! reads them. A comment the original's classification and the output's disagree on (a
//! region-end trail that ends up directly above another section) therefore lands where the
//! output would put it on its next pass, never somewhere neither would.
//!
//! The gap carries three facts, read off every whitespace segment between the two neighbours —
//! the one before the run, the one after it, and the ones inside it (between a travelling
//! comment and its section, between two runs in a row):
//!
//! - **presence**: any whitespace at all separates them; none keeps them glued, which the
//!   rewrite makes literal adjacency, so the seam the section left never takes a break;
//! - **newline**: any segment holding one makes the gap a line break;
//! - **blank line**: only the segment BEFORE or AFTER the run can hold one for the neighbours.
//!   A blank inside the run belongs to the run — it travels with the comments and prints there
//!   — and the lines the section itself occupied are not a blank line either, so a newline on
//!   each side of it is one line break.
//!
//! A glued seam joins two texts into one, and a text is decoded whole, so the join can complete
//! a character reference neither half spelled: `x&amp` + `;y` rendered `x&;y` read apart and
//! reads `x&y` joined. The first character after such a seam is spelled as a reference
//! ([`glued_seam_respell`]), which decodes to itself and whose `&` ends the reference in front of
//! it, so the joined text decodes as the halves did — asked of the text joined so far, since one
//! text can run through several seams.
//!
//! A run at the document's start or end (template content on one side only) has no pair of
//! neighbours to join: the whitespace beside it stays where it is, at the fragment's edge, which
//! trims it. A section inside a `format-ignore` range moves too, cut out by [`range_cuts`] — the
//! cuts the range's own verbatim slice makes (`Printer::build_ignore_range_doc`), so the range
//! prints the same bytes either way.
//! A document with no run between template nodes (≈ every component) is not rewritten at all.

use super::text::has_authored_blank_line;
use super::{HoistedComments, HoistedSection, Printer, push_char_reference, root_sections};
use crate::ast::{self, FragmentNode};
use smallvec::SmallVec;
use tsv_lang::Span;

/// One hoisted section with the comments that travel with it, as its source pieces in order.
struct LiftedRun {
    section: HoistedSection,
    section_span: Span,
    /// From the first travelling comment (or the section) to the trail (or the section).
    extent: Span,
    /// The leading comments, the section, and the region-end trail, in source order — the
    /// whitespace between consecutive pieces is the run's INSIDE.
    pieces: SmallVec<[Span; 4]>,
    /// Whether template content sits on both sides of the run.
    between: bool,
    /// Whether the section sits inside a `format-ignore` range.
    in_range: bool,
}

/// How the whitespace between two neighbours separates them ([module docs](self)), weakest
/// first: a gap of several segments separates as its strongest segment does.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum Separation {
    /// No whitespace: the neighbours stay glued.
    Glued,
    /// Whitespace with no line break.
    Space,
    /// A line break.
    Line,
    /// A blank line, which only an edge segment can hold for the neighbours.
    Blank,
}

impl Separation {
    /// How one whitespace segment separates; `edge` is whether it is the segment before or
    /// after the run group, the only two a blank line counts from.
    ///
    /// An edge segment is collapsible whitespace by construction (the run's neighbours are cut
    /// at it), so a blank line there is any second newline — what
    /// [`has_authored_blank_line`] reads when no content byte can sit between the two.
    fn of(segment: &str, edge: bool) -> Self {
        debug_assert!(
            !edge || ast::is_collapsible_ws_str(segment),
            "an edge segment is collapsible whitespace"
        );
        if edge && has_authored_blank_line(segment) {
            Self::Blank
        } else if segment.contains('\n') {
            Self::Line
        } else if segment.is_empty() {
            Self::Glued
        } else {
            Self::Space
        }
    }

    /// The one gap that stands for the segments.
    fn spelling(self) -> &'static str {
        match self {
            Self::Blank => "\n\n",
            Self::Line => "\n",
            Self::Space => " ",
            Self::Glued => "",
        }
    }
}

/// The last index of the group that starts at `i`: the items after it that each `joined` the
/// one before.
fn group_end<T>(items: &[T], i: usize, joined: impl Fn(&T, &T) -> bool) -> usize {
    let mut j = i;
    while j + 1 < items.len() && joined(&items[j], &items[j + 1]) {
        j += 1;
    }
    j
}

/// The collapsible whitespace on either side of `first..last`: the run before it (stopping at
/// `floor`) and the run after it.
fn ws_edges(source: &str, floor: u32, first: u32, last: u32) -> (Span, Span) {
    let before_len = ast::collapsible_ws_suffix_len(&source[floor as usize..first as usize]);
    let after_len = ast::collapsible_ws_prefix_len(&source[last as usize..]);
    (
        Span::new(first - before_len as u32, first),
        Span::new(last, last + after_len as u32),
    )
}

/// Whether nothing but collapsible whitespace sits between `a` and `b`.
fn ws_only_between(source: &str, a: Span, b: Span) -> bool {
    ast::is_collapsible_ws_str(&source[a.end as usize..b.start as usize])
}

/// Whether `b` can continue a character reference an `&` has opened: a name's letters and
/// digits, or the `#` of a numeric one.
fn continues_reference(b: u8) -> bool {
    b.is_ascii_alphanumeric() || b == b'#'
}

/// The character reference `text` leaves open at its end — the run from its last `&`, when every
/// byte after that `&` could still continue one — or `""` when none is open.
fn open_reference(text: &str) -> &str {
    match text.bytes().rposition(|b| !continues_reference(b)) {
        Some(at) if text.as_bytes()[at] == b'&' => &text[at..],
        _ => "",
    }
}

/// Advance `open` — the reference a joined text leaves open so far ([`open_reference`]) — past
/// the next `piece` of that text.
fn extend_open_reference(open: &mut String, piece: &str) {
    if piece.bytes().all(continues_reference) {
        // The piece continues whatever reference is open, and opens none of its own.
        if !open.is_empty() {
            open.push_str(piece);
        }
    } else {
        open.clear();
        open.push_str(open_reference(piece));
    }
}

/// The character to spell as a reference at a glued seam ([module docs](self)): `right`'s first
/// character, when the reference `left` leaves open would read on into `right` once the two are
/// one text.
///
/// Svelte decodes each text node on its own, so two texts the join glues decoded apart: `x&amp`
/// and `;y` rendered `x&;y`, and `x&am` and `p;y` rendered `x&amp;y`. Joined, the next parse
/// decodes them as one text, and both read `x&y`. Spelled as a reference (`x&amp&#x3B;y`,
/// `x&am&#x70;;y`), the first character after the seam decodes to itself, and the `&` that opens
/// it ends the reference in front of it, so the joined text decodes as the two did.
///
/// The test is whether the two sides decode differently joined than apart, over the open
/// reference and the run at `right`'s head of bytes that could continue one: nothing outside
/// those two windows can take part, since a reference consumes no byte outside that class and
/// the `&` opening another ends it. `left` is the text joined so far, not the neighbour alone, so
/// a reference split across several seams is caught at the one that completes it.
///
/// The test reads tsv's decoder, which reads a reference at least as far as Svelte's does, so it
/// never misses a seam Svelte's would complete; it can only answer yes where Svelte's would not.
/// That happens for one code, zero: Svelte leaves `&#0…` as literal text (its `!code` guard)
/// where tsv decodes it to NUL, so `x&#0` + `0y` is respelled `x&#0&#x30;y` although Svelte reads
/// the join as it reads the halves. The respell decodes to the character it replaces, so the page
/// is the same either way.
fn glued_seam_respell(left: &str, right: &str) -> Option<char> {
    let open = open_reference(left);
    if open.is_empty() {
        return None;
    }
    let continuation_len = right
        .bytes()
        .position(|b| !(continues_reference(b) || b == b';'))
        .unwrap_or(right.len());
    if continuation_len == 0 {
        return None;
    }
    let continuation = &right[..continuation_len];
    fn decode(text: &str) -> std::borrow::Cow<'_, str> {
        crate::html::decode_character_references(text, crate::html::ReferenceContext::Text)
    }
    let apart = decode(open).into_owned() + &decode(continuation);
    // The continuation is ASCII, so its first byte is `right`'s first character.
    (decode(&format!("{open}{continuation}")) != apart).then(|| char::from(right.as_bytes()[0]))
}

/// One cut a `format-ignore` range's slice makes to let the hoisted sections written in it go:
/// the source it removes, and what stands in that place ([`range_cuts`]).
pub(super) struct RangeCut {
    /// The sections and the whitespace on either side of them.
    pub(super) removed: Span,
    pub(super) seam: RangeSeam,
    /// The character after `removed`, spelled as a reference in its place and stepped over, when
    /// the seam is glued and the text before the cut leaves open a reference that character would
    /// continue ([`glued_seam_respell`]).
    pub(super) respell: Option<char>,
}

/// What joins the two neighbours of a [`RangeCut`].
pub(super) enum RangeSeam {
    /// Source bytes printed as written: the run before or after the sections, or one line break
    /// of a run between them.
    Kept(Span),
    /// A plain space, for a run between sections that holds no line break.
    Space,
    /// Nothing: no whitespace separates the neighbours.
    Glued,
}

impl RangeCut {
    /// Where the slice resumes after the cut: past `removed`, and past the character `respell`
    /// has written in its place.
    pub(super) fn resume(&self) -> u32 {
        self.removed.end + self.respell.map_or(0, |ch| ch.len_utf8() as u32)
    }
}

impl RangeSeam {
    /// The seam's text.
    pub(super) fn text<'s>(&self, source: &'s str) -> &'s str {
        match self {
            Self::Kept(span) => span.extract(source),
            Self::Space => " ",
            Self::Glued => "",
        }
    }
}

/// The cuts a `format-ignore` range makes for the hoisted `sections` written in it (in source
/// order, every one past `floor` — the end of the range's start marker, or wherever the caller
/// has already emitted to).
///
/// A range is frozen, but a section in it still moves to its canonical position, and the nodes on
/// either side of it become neighbours. Sections with only whitespace between them are one seam,
/// joined as if they had never been there, the way [`Separation`] joins a lifted run outside a
/// range: of the run before the sections, the runs between them and the run after them, the
/// strongest stands for the whole seam, and the rest go with the sections. A run before or after
/// is kept byte for byte, since the range's bytes are the author's, and wins a tie (the run after
/// over the run before). A run between two sections is respelled: one of its line breaks, or a
/// single space when it holds none (which renders the same as the tab or spaces it stands for).
/// It never counts as a blank line — a blank line there belongs to the sections, as a blank
/// inside a lifted run does — so it wins only where neither edge run holds what it does:
/// whitespace alone, or a line break.
///
/// A glued seam joins the texts on its two sides into one, so the first character after it is
/// spelled as a reference when the text joined so far leaves one open that the character would
/// continue ([`RangeCut::respell`], [`glued_seam_respell`]) — asked across the range's cuts, since
/// one text can run through several.
///
/// A run before the sections stops at `floor`, which is the start marker's end for the first
/// cut; a run after them stops at the next non-whitespace byte, which the end marker guarantees
/// comes before the range closes. Nothing before `floor` can take part in a seam's reference:
/// the range's start marker stands between it and the first section.
pub(super) fn range_cuts(source: &str, floor: u32, sections: &[Span]) -> SmallVec<[RangeCut; 2]> {
    let mut cuts = SmallVec::new();
    let mut floor = floor;
    // The reference the joined slice leaves open so far, and where the slice's next uncut piece
    // starts.
    let mut open = String::new();
    let mut piece_start = floor;
    let mut i = 0;
    while i < sections.len() {
        let j = group_end(sections, i, |&a, &b| ws_only_between(source, a, b));
        let (before, after) = ws_edges(source, floor, sections[i].start, sections[j].end);

        let edge_seam = |run: Span, sep: Separation| {
            if sep == Separation::Glued {
                RangeSeam::Glued
            } else {
                RangeSeam::Kept(run)
            }
        };
        let mut sep = Separation::of(after.extract(source), true);
        let mut seam = edge_seam(after, sep);
        let before_sep = Separation::of(before.extract(source), true);
        if before_sep > sep {
            sep = before_sep;
            seam = edge_seam(before, sep);
        }
        for pair in sections[i..=j].windows(2) {
            let between = Span::new(pair[0].end, pair[1].start);
            let text = between.extract(source);
            let between_sep = Separation::of(text, false);
            if between_sep > sep {
                sep = between_sep;
                // The run is whitespace alone, so its first newline byte is its line break.
                seam = match text.bytes().position(|b| b == b'\n') {
                    Some(at) => {
                        let at = between.start + at as u32;
                        RangeSeam::Kept(Span::new(at, at + 1))
                    }
                    None => RangeSeam::Space,
                };
            }
        }
        extend_open_reference(
            &mut open,
            &source[piece_start as usize..before.start as usize],
        );
        let respell = match seam {
            RangeSeam::Glued => glued_seam_respell(&open, &source[after.end as usize..]),
            RangeSeam::Kept(_) | RangeSeam::Space => None,
        };
        // Whitespace ends the open reference, and so does the respell's closing `;`.
        if !matches!(seam, RangeSeam::Glued) || respell.is_some() {
            open.clear();
        }
        let cut = RangeCut {
            removed: Span::new(before.start, after.end),
            seam,
            respell,
        };
        piece_start = cut.resume();
        cuts.push(cut);
        floor = after.end;
        i = j + 1;
    }
    cuts
}

impl Printer<'_> {
    /// The document rewritten in the output's layout ([module docs](self)) — or `None` when no
    /// section run sits between template nodes.
    ///
    /// Layout: a leading byte-order mark, then the `<svelte:options>`, module and instance runs
    /// in that order, then the template with every run taken out, then the `<style>` run. The
    /// document's end reads the way the original's did: what the original parse trimmed from it
    /// (JavaScript's `trimEnd`, [`tsv_lang::trim_end_js_whitespace`]) stays out, and a character
    /// the original rendered that the template now ends on is spelled as a reference. A character
    /// after a glued seam that would complete a reference the text before it left open is
    /// spelled as one too ([`glued_seam_respell`]).
    pub(super) fn lifted_runs_source(
        &self,
        root: &ast::Root<'_>,
        sections: &HoistedComments,
    ) -> Option<String> {
        let mut runs = self.section_runs(root, sections);
        if !runs.iter().any(|run| run.between) {
            return None;
        }
        // A section inside a `format-ignore` range travels with no comments (the range keeps
        // every comment in it where it stands), and leaves the range through the cuts the
        // range's own verbatim slice makes ([`range_cuts`]).
        let nodes = &root.fragment.nodes;
        let ranges: SmallVec<[Span; 2]> = self
            .ignore_ranges(&root.fragment)
            .pairs
            .iter()
            .map(|&(start, end)| Span::new(nodes[start].span().start, nodes[end].span().end))
            .collect();
        for run in &mut runs {
            run.in_range = ranges.iter().any(|range| range.contains(run.section_span));
        }
        if !runs.iter().any(|run| run.between && !run.in_range) {
            return None;
        }
        runs.sort_unstable_by_key(|run| run.extent.start);

        let source = self.source;
        let bom_len = if source.starts_with('\u{feff}') {
            '\u{feff}'.len_utf8()
        } else {
            0
        };
        let mut out = String::with_capacity(source.len() + runs.len() * 2 + 2);
        out.push_str(&source[..bom_len]);
        let mut heads: SmallVec<[&LiftedRun; 4]> = runs
            .iter()
            .filter(|run| run.section != HoistedSection::Style)
            .collect();
        heads.sort_by_key(|run| run.section.slot());
        for run in heads {
            out.push_str(run.extent.extract(source));
            out.push_str("\n\n");
        }

        let mut template = String::with_capacity(source.len());
        let mut cursor = bom_len;
        let mut i = 0;
        while i < runs.len() {
            if runs[i].in_range {
                // Cut the way the range's verbatim slice cuts it ([`range_cuts`]), so the range
                // prints the same bytes either way.
                // Runs in two different ranges are grouped here too; the markers between them
                // are not whitespace, so `range_cuts` cuts them apart.
                let j = group_end(&runs, i, |_, b| b.in_range);
                let sections: SmallVec<[Span; 4]> = runs[i..=j]
                    .iter()
                    .map(|run| {
                        debug_assert_eq!(
                            run.extent, run.section_span,
                            "a section in a range travels with no comments"
                        );
                        run.section_span
                    })
                    .collect();
                for cut in range_cuts(source, cursor as u32, &sections) {
                    template.push_str(&source[cursor..cut.removed.start as usize]);
                    template.push_str(cut.seam.text(source));
                    if let Some(ch) = cut.respell {
                        push_char_reference(&mut template, ch);
                    }
                    cursor = cut.resume() as usize;
                }
                i = j + 1;
                continue;
            }
            // A group: consecutive runs with nothing but whitespace between them share one
            // pair of neighbours (or one fragment edge).
            let j = group_end(&runs, i, |a, b| {
                !b.in_range && ws_only_between(source, a.extent, b.extent)
            });
            let group = &runs[i..=j];
            let first = group[0].extent.start;
            let last = group[group.len() - 1].extent.end;
            if group[0].between {
                let (before, after) = ws_edges(source, cursor as u32, first, last);
                let mut sep = Separation::of(before.extract(source), true)
                    .max(Separation::of(after.extract(source), true));
                for (k, run) in group.iter().enumerate() {
                    for pair in run.pieces.windows(2) {
                        let inside = Span::new(pair[0].end, pair[1].start);
                        sep = sep.max(Separation::of(inside.extract(source), false));
                    }
                    if let Some(next) = group.get(k + 1) {
                        let between = Span::new(run.extent.end, next.extent.start);
                        sep = sep.max(Separation::of(between.extract(source), false));
                    }
                }
                template.push_str(&source[cursor..before.start as usize]);
                template.push_str(sep.spelling());
                cursor = after.end as usize;
                // The seam glues two texts into one; the text joined so far is `template`.
                if sep == Separation::Glued
                    && let Some(ch) = glued_seam_respell(&template, &source[cursor..])
                {
                    push_char_reference(&mut template, ch);
                    cursor += ch.len_utf8();
                }
            } else {
                template.push_str(&source[cursor..first as usize]);
                cursor = last as usize;
            }
            i = j + 1;
        }
        // What the original parse trimmed from the end of the document stays trimmed: those
        // characters never rendered, and a run written after them would make them render.
        let kept_end = tsv_lang::trim_end_js_whitespace(source).len();
        template.push_str(&source[cursor..kept_end.max(cursor)]);

        match runs.iter().find(|run| run.section == HoistedSection::Style) {
            Some(style) => {
                out.push_str(&template);
                out.push_str("\n\n");
                out.push_str(style.extent.extract(source));
                out.push('\n');
            }
            None => {
                // The template now ends the document, and a character the original rendered —
                // it stood before a run the rewrite moved away — would be trimmed there by the
                // next parse. Spelled as a reference it survives, as the template-tail respell
                // spells it (`Printer::template_tail_respell`).
                let content = ast::trim_end_collapsible_ws(&template);
                match content.chars().next_back() {
                    Some(ch) if crate::whitespace::is_end_trimmed_content(ch) => {
                        let at = content.len() - ch.len_utf8();
                        out.push_str(&template[..at]);
                        push_char_reference(&mut out, ch);
                        out.push_str(&template[at + ch.len_utf8()..]);
                    }
                    _ => out.push_str(&template),
                }
            }
        }
        Some(out)
    }

    /// Every hoisted section's run, marked with whether template content sits on both sides of
    /// it.
    fn section_runs(
        &self,
        root: &ast::Root<'_>,
        sections: &HoistedComments,
    ) -> SmallVec<[LiftedRun; 4]> {
        let nodes = &root.fragment.nodes;
        let travels = sections.travelling(nodes.len());
        let is_content =
            |i: usize, n: &FragmentNode<'_>| !(n.is_whitespace_only_text() || travels[i]);
        let content = nodes
            .iter()
            .enumerate()
            .position(|(i, n)| is_content(i, n))
            .map(|first| {
                let last = nodes
                    .iter()
                    .enumerate()
                    .rposition(|(i, n)| is_content(i, n))
                    .unwrap_or(first);
                (nodes[first].span().start, nodes[last].span().end)
            });

        let mut runs = SmallVec::new();
        for (span, section) in root_sections(root) {
            let Some(span) = span else { continue };
            let mut run = self.lifted_run(span, section, sections, &root.fragment);
            run.between = content
                .is_some_and(|(start, end)| start < run.extent.start && run.extent.end < end);
            runs.push(run);
        }
        runs
    }

    /// The run of `section`: its leading comments, the section, its trail.
    fn lifted_run(
        &self,
        section_span: Span,
        section: HoistedSection,
        sections: &HoistedComments,
        fragment: &ast::Fragment<'_>,
    ) -> LiftedRun {
        let comments = &sections[section];
        let comment_span = |i: usize| Self::fragment_comment(fragment, i).map(|c| c.span);
        let mut pieces: SmallVec<[Span; 4]> = comments
            .leading
            .iter()
            .filter_map(|&i| comment_span(i))
            .collect();
        pieces.push(section_span);
        pieces.extend(comments.trail.and_then(comment_span));
        let extent = Span::new(pieces[0].start, pieces[pieces.len() - 1].end);
        LiftedRun {
            section,
            section_span,
            extent,
            pieces,
            between: false,
            in_range: false,
        }
    }
}