Skip to main content

ocomment_core/
transform.rs

1use crate::{
2    ByteSpan, Comment, CommentKind, Edit, ExternalSpanError, Language, Layout, PreparedScanner,
3    ScanReport, SourceMap, TransformOptions, TransformPlan, TransformResult,
4    scanner::{
5        disposition, keep_yaml_structural_trails, lines_a_removal_must_swallow, scan,
6        unicode_line_terminator_width,
7    },
8};
9use unicode_width::UnicodeWidthChar;
10
11/// Scan `source` and produce the bytes a removal would write.
12///
13/// This is [`scan`] followed by the edits its report calls for. Nothing is
14/// written anywhere: the caller gets the new bytes, the edits that made them,
15/// the report they were decided from, and a
16/// [`SourceMap`](crate::SourceMap) between the two.
17///
18/// A source the scanner reported invalid — an unterminated comment or string —
19/// is returned byte for byte with no edits at all, unless
20/// [`ScanOptions::force_invalid`](crate::ScanOptions::force_invalid) is set.
21///
22/// # Examples
23///
24/// ```
25/// use ocomment_core::{Language, TransformOptions, transform};
26///
27/// let result = transform(
28///     b"let x = 1; // note\n",
29///     Language::Rust,
30///     TransformOptions::default(),
31/// );
32/// assert_eq!(result.output, b"let x = 1; \n");
33/// assert_eq!(result.report.comments.len(), 1);
34/// assert_eq!(result.edits.len(), 1);
35///
36/// // An unterminated comment leaves the file alone.
37/// let broken = transform(b"x /* no end", Language::C, TransformOptions::default());
38/// assert!(!broken.report.valid);
39/// assert_eq!(broken.output, b"x /* no end");
40/// ```
41pub fn transform(source: &[u8], language: Language, options: TransformOptions) -> TransformResult {
42    transform_plan(source, language, options).finish(source)
43}
44
45/// Scan `source` and compute its edits without building output bytes or a
46/// source map.
47///
48/// This is the lazy counterpart of [`transform`]. It is useful for checkers
49/// and report writers that only need the report or edit list.
50pub fn transform_plan(
51    source: &[u8],
52    language: Language,
53    options: TransformOptions,
54) -> TransformPlan {
55    let force_invalid = options.scan.force_invalid;
56    let report = scan(source, language, options.scan);
57    plan_report(source, report, options.layout, force_invalid)
58}
59
60impl PreparedScanner {
61    /// Scan and plan edits with this scanner's already-compiled policy.
62    pub fn transform_plan(
63        &self,
64        source: &[u8],
65        language: Language,
66        layout: Layout,
67    ) -> TransformPlan {
68        let report = self.scan(source, language);
69        plan_report(source, report, layout, self.options().force_invalid)
70    }
71
72    /// Produce a complete transformation with this scanner's compiled policy.
73    pub fn transform(&self, source: &[u8], language: Language, layout: Layout) -> TransformResult {
74        self.transform_plan(source, language, layout).finish(source)
75    }
76
77    /// Validate externally supplied spans and plan their edits with this
78    /// scanner's already-compiled policy.
79    pub fn transform_spans_plan(
80        &self,
81        source: &[u8],
82        language: Language,
83        spans: &[(ByteSpan, CommentKind)],
84        layout: Layout,
85    ) -> Result<TransformPlan, ExternalSpanError> {
86        let report = self.scan_spans(source, language, spans)?;
87        Ok(plan_report(
88            source,
89            report,
90            layout,
91            self.options().force_invalid,
92        ))
93    }
94
95    /// Validate and classify externally supplied spans without planning edits.
96    pub fn scan_spans(
97        &self,
98        source: &[u8],
99        language: Language,
100        spans: &[(ByteSpan, CommentKind)],
101    ) -> Result<ScanReport, ExternalSpanError> {
102        external_report(source, language, spans, self)
103    }
104}
105
106/// Transform a scanner's already-classified comment spans using the same
107/// policy, layout, edit validation, and source-map engine as built-in scans.
108///
109/// This is the safe hand-off point for declarative or WASM scanners. Spans
110/// must be non-empty, sorted, non-overlapping, and contained in `source`.
111///
112/// The report that comes back carries no diagnostics and is always valid: the
113/// external scanner, not this crate, judged whether the source lexed.
114///
115/// # Errors
116///
117/// Returns [`ExternalSpanError`] naming the first span that reaches past the
118/// end of `source`, covers no bytes, or starts before its predecessor ends,
119/// or reporting a `keep_regex`/`remove_regex` entry that would not compile.
120/// Nothing is transformed when validation fails.
121///
122/// # Examples
123///
124/// ```
125/// use ocomment_core::{
126///     ByteSpan, CommentKind, ExternalSpanError, Language, TransformOptions, transform_spans,
127/// };
128///
129/// let source = b"a/* ordinary */b/* directive */";
130/// let result = transform_spans(
131///     source,
132///     Language::Unknown,
133///     &[
134///         (ByteSpan::new(1, 15), CommentKind::Block),
135///         (ByteSpan::new(16, source.len()), CommentKind::Directive),
136///     ],
137///     TransformOptions::default(),
138/// )
139/// .unwrap();
140/// // The same policy the built-in scanners get: the directive is kept.
141/// assert_eq!(result.output, b"a b/* directive */");
142///
143/// let bad = transform_spans(
144///     source,
145///     Language::Unknown,
146///     &[(ByteSpan::new(2, source.len() + 1), CommentKind::Block)],
147///     TransformOptions::default(),
148/// );
149/// assert!(matches!(bad, Err(ExternalSpanError::OutOfBounds { .. })));
150/// ```
151pub fn transform_spans(
152    source: &[u8],
153    language: Language,
154    spans: &[(ByteSpan, CommentKind)],
155    options: TransformOptions,
156) -> Result<TransformResult, ExternalSpanError> {
157    let prepared = PreparedScanner::new(options.scan)
158        .map_err(|error| ExternalSpanError::InvalidPattern(error.to_string()))?;
159    Ok(prepared
160        .transform_spans_plan(source, language, spans, options.layout)?
161        .finish(source))
162}
163
164fn external_report(
165    source: &[u8],
166    language: Language,
167    spans: &[(ByteSpan, CommentKind)],
168    prepared: &PreparedScanner,
169) -> Result<ScanReport, ExternalSpanError> {
170    let mut cursor = 0;
171    let mut comments = Vec::with_capacity(spans.len());
172    for (index, (span, kind)) in spans.iter().copied().enumerate() {
173        if span.start > span.end || span.end > source.len() {
174            return Err(ExternalSpanError::OutOfBounds {
175                index,
176                source_len: source.len(),
177            });
178        }
179        if span.is_empty() {
180            return Err(ExternalSpanError::Empty { index });
181        }
182        if index > 0 && span.start < cursor {
183            return Err(ExternalSpanError::OrderOrOverlap { index });
184        }
185        cursor = span.end;
186        comments.push(Comment {
187            span,
188            kind,
189            disposition: disposition(
190                kind,
191                prepared.options(),
192                &source[span.start..span.end],
193                &prepared.patterns,
194            ),
195        });
196    }
197    /* NOTE: The one verdict a comment's own bytes cannot reach, so it is
198     * applied to the hand-off as a built-in scan applies it: a YAML block
199     * scalar leaning on the comment that ends it keeps that comment, whoever
200     * found it. Without this the report would promise a removal that
201     * `lines_a_removal_must_swallow` cannot make safe. */
202    keep_yaml_structural_trails(source, language, &mut comments);
203    Ok(ScanReport {
204        language,
205        comments,
206        diagnostics: Vec::new(),
207        valid: true,
208    })
209}
210
211pub(crate) fn transform_report(
212    source: &[u8],
213    report: crate::ScanReport,
214    options: TransformOptions,
215) -> TransformResult {
216    plan_report(source, report, options.layout, options.scan.force_invalid).finish(source)
217}
218
219pub(crate) fn plan_report(
220    source: &[u8],
221    report: crate::ScanReport,
222    layout: Layout,
223    force_invalid: bool,
224) -> TransformPlan {
225    let edits = if report.valid || force_invalid {
226        /* NOTE: The one hole whose own bytes carry meaning, so every layout has
227         * to be told where not to leave one. `compact` takes the line already;
228         * what it does not know on its own is how far past the line to go
229         * under a `|+` body. */
230        let swallow = lines_a_removal_must_swallow(source, report.language, &report.comments);
231        match layout {
232            Layout::Lines => line_edits(source, &report.comments, &swallow),
233            Layout::Columns => column_edits(source, &report.comments, &swallow),
234            Layout::Compact => compact_edits(source, &report.comments, &swallow),
235        }
236    } else {
237        Vec::new()
238    };
239    TransformPlan { edits, report }
240}
241
242impl TransformPlan {
243    /// Apply this plan and build its source map.
244    ///
245    /// # Panics
246    ///
247    /// Panics when `source` is not the source the plan was made for and an
248    /// edit consequently lies outside it, just like [`apply_edits`].
249    pub fn finish(self, source: &[u8]) -> TransformResult {
250        let output = apply_edits(source, &self.edits);
251        let source_map = SourceMap::from_edits(source.len(), &self.edits);
252        TransformResult {
253            output,
254            edits: self.edits,
255            report: self.report,
256            source_map,
257        }
258    }
259
260    /// Build only the transformed bytes, leaving the plan available.
261    pub fn output(&self, source: &[u8]) -> Vec<u8> {
262        apply_edits(source, &self.edits)
263    }
264
265    /// Build only the source map, leaving the plan available.
266    pub fn source_map(&self, source_len: usize) -> SourceMap {
267        SourceMap::from_edits(source_len, &self.edits)
268    }
269}
270
271/// Apply sorted, non-overlapping half-open edits.
272///
273/// The bytes outside the edited spans are copied through untouched, which is
274/// what makes a transformation byte-preserving: a BOM, CRLF line endings, a
275/// missing final newline, and bytes that are not UTF-8 at all all survive.
276///
277/// # Panics
278///
279/// Panics if an edit has `start > end`, starts before its predecessor ends, or
280/// reaches past the end of `source`. The edits of a
281/// [`TransformResult`] always satisfy that contract; edits assembled by hand
282/// have to be sorted first.
283///
284/// # Examples
285///
286/// ```
287/// use ocomment_core::{ByteSpan, Edit, Language, TransformOptions, apply_edits, transform};
288///
289/// let edits = [Edit {
290///     span: ByteSpan::new(3, 8),
291///     replacement: b"there".to_vec(),
292/// }];
293/// assert_eq!(apply_edits(b"hi world", &edits), b"hi there");
294///
295/// // Re-applying a transformation's own edits reproduces its output.
296/// let source = b"let x = 1; // note\n";
297/// let result = transform(source, Language::Rust, TransformOptions::default());
298/// assert_eq!(apply_edits(source, &result.edits), result.output);
299/// ```
300pub fn apply_edits(source: &[u8], edits: &[Edit]) -> Vec<u8> {
301    let mut cursor = 0;
302    let output_len = edits.iter().fold(source.len(), |length, edit| {
303        length
304            .saturating_sub(edit.span.len())
305            .saturating_add(edit.replacement.len())
306    });
307    let mut output = Vec::with_capacity(output_len);
308    for edit in edits {
309        assert!(
310            edit.span.start <= edit.span.end,
311            "edit has an inverted span"
312        );
313        assert!(edit.span.start >= cursor, "edits overlap or are not sorted");
314        assert!(edit.span.end <= source.len(), "edit is outside the source");
315        output.extend_from_slice(&source[cursor..edit.span.start]);
316        output.extend_from_slice(&edit.replacement);
317        cursor = edit.span.end;
318    }
319    output.extend_from_slice(&source[cursor..]);
320    output
321}
322
323/// The edits [`Layout::Lines`] makes: one per removed comment, over exactly
324/// the bytes that comment covers — save where `swallow` names a whole line,
325/// because there the hole itself would say something (see
326/// [`lines_a_removal_must_swallow`]). This is the layout that promises line
327/// numbers and those lines are the one place it cannot keep that promise.
328fn line_edits(source: &[u8], comments: &[Comment], swallow: &[Option<ByteSpan>]) -> Vec<Edit> {
329    let mut edits = Vec::new();
330    let mut floor = 0usize;
331    for (index, comment) in comments.iter().enumerate() {
332        if !comment.disposition.is_remove() {
333            continue;
334        }
335        let edit = match swallow.get(index).copied().flatten() {
336            Some(line) => Edit {
337                span: ByteSpan::new(line.start.max(floor), line.end),
338                replacement: Vec::new(),
339            },
340            None => Edit {
341                span: comment.span,
342                replacement: if comment.kind == CommentKind::HtmlComment {
343                    Vec::new()
344                } else {
345                    line_replacement(source, comment.span)
346                },
347            },
348        };
349        floor = edit.span.end;
350        edits.push(edit);
351    }
352    edits
353}
354
355/// What [`Layout::Lines`] leaves in place of a removed comment: the line
356/// terminators the comment spanned, so every following line keeps its number,
357/// and a single space when the comment was all that kept two tokens apart. A
358/// comment that spanned a terminator needs no space of its own, because a
359/// newline is a lexical separator already.
360fn line_replacement(source: &[u8], span: ByteSpan) -> Vec<u8> {
361    let mut output = newline_sequence(&source[span.start..span.end]);
362    if output.is_empty() && has_non_whitespace_neighbors(source, span) {
363        output.push(b' ');
364    }
365    output
366}
367
368/// The edits [`Layout::Columns`] makes: one per removed comment, of spaces as
369/// wide as the comment was — save where `swallow` names a line, because a line
370/// of spaces under a YAML block scalar body is indented into it (see
371/// [`lines_a_removal_must_swallow`]). This is the layout that promises columns
372/// and those lines are the one place it cannot keep that promise.
373///
374/// The display column is threaded from one edit to the next so every source
375/// byte is inspected at most once. It also reflects an explicitly removed HTML
376/// comment: because that edit emits no bytes, the newlines it covered do not
377/// move the column the edits after it are measured from.
378fn column_edits(source: &[u8], comments: &[Comment], swallow: &[Option<ByteSpan>]) -> Vec<Edit> {
379    let mut edits = Vec::new();
380    let mut cursor = 0usize;
381    let mut column = 0usize;
382    for (index, comment) in comments.iter().enumerate() {
383        if !comment.disposition.is_remove() {
384            continue;
385        }
386        /* NOTE: A swallowed line takes its terminator with it, so what follows
387         * starts a line of its own in the output as it did in the source and
388         * the column count begins again there. */
389        if let Some(line) = swallow.get(index).copied().flatten() {
390            let span = ByteSpan::new(line.start.max(cursor), line.end);
391            cursor = span.end;
392            column = 0;
393            edits.push(Edit {
394                span,
395                replacement: Vec::new(),
396            });
397            continue;
398        }
399        column = advance_display_column(source, cursor, comment.span.start, column);
400        let (replacement, next) = if comment.kind == CommentKind::HtmlComment {
401            (Vec::new(), column)
402        } else {
403            column_replacement(source, comment.span, column)
404        };
405        cursor = comment.span.end;
406        column = next;
407        edits.push(Edit {
408            span: comment.span,
409            replacement,
410        });
411    }
412    edits
413}
414
415/// The edits [`Layout::Compact`] makes: [`Layout::Lines`], plus the promise
416/// that a line which held nothing but a removed comment goes away instead of
417/// staying behind as a blank one.
418///
419/// Whether a comment was alone on its line is judged from the bytes of the
420/// original source, so a line holding two comments and nothing else keeps its
421/// terminator: neither of them was alone on it.
422///
423/// The start of the current line is tracked forward through the whole source,
424/// comment bodies included, so a comment beginning on a line that an earlier
425/// comment ended is still measured from that line's real beginning.
426///
427/// `swallow` names the lines whose hole would carry meaning, and it reaches
428/// further than a line: under a `|+` body it takes the empty lines the comment
429/// was sheltering too (see [`lines_a_removal_must_swallow`]). Taking the line
430/// is what `compact` does anyway, so this only ever widens what it takes, and
431/// it is what keeps all three layouts writing the same bytes there.
432fn compact_edits(source: &[u8], comments: &[Comment], swallow: &[Option<ByteSpan>]) -> Vec<Edit> {
433    let mut edits = Vec::new();
434    let mut scan = 0usize;
435    let mut line_start = 0usize;
436    let mut floor = 0usize;
437    for (index, comment) in comments.iter().enumerate() {
438        if !comment.disposition.is_remove() {
439            continue;
440        }
441        if let Some(line) = swallow.get(index).copied().flatten() {
442            let span = ByteSpan::new(line.start.max(floor), line.end.max(floor));
443            floor = span.end;
444            scan = span.end;
445            line_start = span.end;
446            edits.push(Edit {
447                span,
448                replacement: Vec::new(),
449            });
450            continue;
451        }
452        while scan < comment.span.start {
453            match unicode_line_terminator_width(source, scan) {
454                Some(width) if scan + width <= comment.span.start => {
455                    scan += width;
456                    line_start = scan;
457                }
458                _ => scan += 1,
459            }
460        }
461        /* NOTE: The next comment of any disposition, kept ones included: the
462         * blanks an edit swallows must never reach into one. */
463        let ceiling = comments
464            .get(index + 1)
465            .map_or(source.len(), |next| next.span.start)
466            .max(comment.span.end);
467        let edit = compact_edit(source, comment, line_start, floor, ceiling);
468        floor = edit.span.end;
469        edits.push(edit);
470    }
471    edits
472}
473
474/// One [`Layout::Compact`] edit.
475///
476/// `line_start` is where the line holding `comment` begins, `floor` is the end
477/// of the previous edit and `ceiling` the start of the next comment, so the
478/// span that comes back is sorted and non-overlapping with its neighbours
479/// however a scanner laid the comments out.
480fn compact_edit(
481    source: &[u8],
482    comment: &Comment,
483    line_start: usize,
484    floor: usize,
485    ceiling: usize,
486) -> Edit {
487    let span = comment.span;
488    /* NOTE: An HTML comment closes up completely under every layout, the
489     * newlines it spanned included, so it never counts as ending a line by
490     * spanning one and never puts a terminator back. */
491    let html = comment.kind == CommentKind::HtmlComment;
492    let interior = first_line_terminator(source, span);
493    let tail = line_tail(source, span.end);
494    let head_code = source[line_start..span.start]
495        .iter()
496        .any(|byte| !byte.is_ascii_whitespace());
497    let ends_the_line = tail.is_some() || (interior.is_some() && !html);
498    let start = if ends_the_line {
499        blank_start(source, span.start, floor.max(line_start))
500    } else {
501        span.start
502    };
503    let eats_the_terminator = if html {
504        !head_code
505    } else {
506        interior.is_some() || !head_code
507    };
508    let end = match tail {
509        Some((blanks, terminator)) => blanks + if eats_the_terminator { terminator } else { 0 },
510        None => span.end,
511    };
512    let replacement = if html {
513        Vec::new()
514    } else if !ends_the_line {
515        /* NOTE: An interior comment: the line goes on after it, so keeping the
516         * two tokens either side apart is the whole story, exactly as under
517         * `lines`. */
518        line_replacement(source, span)
519    } else if let Some(terminator) = interior.filter(|_| head_code) {
520        /* NOTE: The code before the comment keeps its own line, and the
521         * terminator that ended that line was inside the comment. */
522        terminator.to_vec()
523    } else {
524        /* NOTE: Nothing that survives on this line follows the comment, so the
525         * line terminator - the one kept after it or the one that ended the
526         * code line - is separator enough. */
527        Vec::new()
528    };
529    Edit {
530        span: ByteSpan::new(start, end.min(ceiling)),
531        replacement,
532    }
533}
534
535/// The first line terminator inside a comment, as the bytes that wrote it, so
536/// a CRLF file keeps its CRLF. A terminator that would reach past the end of
537/// the comment is not one: the same rule [`newline_sequence`] applies.
538fn first_line_terminator(source: &[u8], span: ByteSpan) -> Option<&[u8]> {
539    let mut index = span.start;
540    while index < span.end {
541        match unicode_line_terminator_width(source, index) {
542            Some(width) if index + width <= span.end => return Some(&source[index..index + width]),
543            _ => index += 1,
544        }
545    }
546    None
547}
548
549/// How the line a comment ended on runs out: where the blanks after the
550/// comment stop, and how wide the line terminator there is — `0` at the end of
551/// the source. `None` when something other than blanks follows on that line,
552/// which is what makes the comment an interior one rather than the last thing
553/// on its line.
554fn line_tail(source: &[u8], from: usize) -> Option<(usize, usize)> {
555    let mut index = from;
556    loop {
557        if let Some(width) = unicode_line_terminator_width(source, index) {
558            return Some((index, width));
559        }
560        match source.get(index) {
561            None => return Some((index, 0)),
562            Some(byte) if byte.is_ascii_whitespace() => index += 1,
563            Some(_) => return None,
564        }
565    }
566}
567
568/// Where the run of blanks that ends at `at` begins. It never reaches before
569/// `floor` and never crosses a line terminator, so trimming what a removal
570/// left at the end of a line can never touch the line before it.
571fn blank_start(source: &[u8], at: usize, floor: usize) -> usize {
572    let mut index = at;
573    while index > floor
574        && source[index - 1].is_ascii_whitespace()
575        && unicode_line_terminator_width(source, index - 1).is_none()
576    {
577        index -= 1;
578    }
579    index
580}
581
582fn newline_sequence(bytes: &[u8]) -> Vec<u8> {
583    let mut output = Vec::new();
584    let mut index = 0;
585    while index < bytes.len() {
586        if let Some(width) = unicode_line_terminator_width(bytes, index) {
587            output.extend_from_slice(&bytes[index..index + width]);
588            index += width;
589        } else {
590            index += 1;
591        }
592    }
593    output
594}
595
596fn has_non_whitespace_neighbors(source: &[u8], span: ByteSpan) -> bool {
597    source
598        .get(span.start.wrapping_sub(1))
599        .is_some_and(|byte| !byte.is_ascii_whitespace())
600        && source
601            .get(span.end)
602            .is_some_and(|byte| !byte.is_ascii_whitespace())
603}
604
605fn column_replacement(source: &[u8], span: ByteSpan, mut column: usize) -> (Vec<u8>, usize) {
606    let mut output = Vec::with_capacity(span.len());
607    let mut index = span.start;
608    while index < span.end {
609        if let Some(width) = unicode_line_terminator_width(source, index)
610            && index + width <= span.end
611        {
612            output.extend_from_slice(&source[index..index + width]);
613            index += width;
614            column = 0;
615            continue;
616        }
617        match source[index] {
618            b'\t' => {
619                let width = 8 - (column % 8);
620                output.extend(std::iter::repeat_n(b' ', width));
621                column += width;
622                index += 1;
623            }
624            byte if byte.is_ascii() => {
625                output.push(b' ');
626                column += 1;
627                index += 1;
628            }
629            _ => {
630                if let Some((character, length)) = utf8_character(source, index, span.end) {
631                    let width = character.width().unwrap_or(0);
632                    output.extend(std::iter::repeat_n(b' ', width));
633                    column += width;
634                    index += length;
635                } else {
636                    // NOTE: Invalid source bytes each occupy one conservative display column.
637                    output.push(b' ');
638                    column += 1;
639                    index += 1;
640                }
641            }
642        }
643    }
644    (output, column)
645}
646
647fn advance_display_column(source: &[u8], mut index: usize, end: usize, mut column: usize) -> usize {
648    while index < end {
649        if let Some(width) = unicode_line_terminator_width(source, index)
650            && index + width <= end
651        {
652            index += width;
653            column = 0;
654            continue;
655        }
656        if source[index] == b'\t' {
657            column += 8 - (column % 8);
658            index += 1;
659        } else if source[index].is_ascii() {
660            column += 1;
661            index += 1;
662        } else if let Some((character, length)) = utf8_character(source, index, end) {
663            column += character.width().unwrap_or(0);
664            index += length;
665        } else {
666            column += 1;
667            index += 1;
668        }
669    }
670    column
671}
672
673fn utf8_character(source: &[u8], index: usize, end: usize) -> Option<(char, usize)> {
674    let length = match *source.get(index)? {
675        0xc2..=0xdf => 2,
676        0xe0..=0xef => 3,
677        0xf0..=0xf4 => 4,
678        _ => return None,
679    };
680    let bytes = source.get(index..index.checked_add(length)?)?;
681    if index + length > end {
682        return None;
683    }
684    let text = std::str::from_utf8(bytes).ok()?;
685    Some((text.chars().next()?, length))
686}
687
688#[cfg(test)]
689mod tests {
690    use super::*;
691    use crate::{Policy, ScanOptions};
692    use proptest::prelude::*;
693
694    #[test]
695    fn preserves_crlf_and_separates_tokens() {
696        let result = transform(b"a/* x\r\ny */b", Language::C, TransformOptions::default());
697        assert_eq!(result.output, b"a\r\nb");
698        let joined = transform(b"a/*x*/b", Language::C, TransformOptions::default());
699        assert_eq!(joined.output, b"a b");
700    }
701
702    #[test]
703    fn invalid_input_is_not_edited_without_force() {
704        let result = transform(b"x /* no end", Language::C, TransformOptions::default());
705        assert!(!result.report.valid);
706        assert!(result.edits.is_empty());
707        assert_eq!(result.output, b"x /* no end");
708    }
709
710    #[test]
711    fn external_spans_use_the_normal_policy_and_validate_boundaries() {
712        let source = b"a/* ordinary */b/* directive */";
713        let result = transform_spans(
714            source,
715            Language::Unknown,
716            &[
717                (ByteSpan::new(1, 15), CommentKind::Block),
718                (ByteSpan::new(16, source.len()), CommentKind::Directive),
719            ],
720            TransformOptions::default(),
721        )
722        .unwrap();
723        assert_eq!(result.output, b"a b/* directive */");
724        assert!(matches!(
725            transform_spans(
726                source,
727                Language::Unknown,
728                &[(ByteSpan::new(2, source.len() + 1), CommentKind::Block)],
729                TransformOptions::default(),
730            ),
731            Err(ExternalSpanError::OutOfBounds { .. })
732        ));
733    }
734
735    #[test]
736    fn source_map_prefers_the_following_segment_at_edit_boundaries() {
737        let source = b"ab/* remove */cd";
738        let result = transform(source, Language::C, TransformOptions::default());
739        let edit = &result.edits[0];
740        assert_eq!(result.source_map.original_to_output(0), Some(0));
741        assert_eq!(
742            result.source_map.original_to_output(edit.span.start),
743            Some(edit.span.start)
744        );
745        assert_eq!(
746            result.source_map.original_to_output(edit.span.end),
747            Some(edit.span.start + edit.replacement.len())
748        );
749        assert_eq!(
750            result.source_map.original_to_output(source.len()),
751            Some(result.output.len())
752        );
753        assert_eq!(
754            result.source_map.output_to_original(result.output.len()),
755            Some(source.len())
756        );
757    }
758
759    #[test]
760    fn html_is_byte_identical_in_safe_mode() {
761        let input = b"a<!-- visible\ncomment -->b";
762        assert_eq!(
763            transform(input, Language::Html, TransformOptions::default()).output,
764            input
765        );
766        let options = TransformOptions {
767            scan: ScanOptions {
768                policy: Policy::All,
769                ..Default::default()
770            },
771            ..Default::default()
772        };
773        assert_eq!(transform(input, Language::Html, options).output, b"ab");
774    }
775
776    proptest! {
777        #[test]
778        fn transform_is_idempotent(left in "[a-z ]{0,30}", body in "[a-z ]{0,30}", right in "[a-z ]{0,30}") {
779            let input = format!("{left}/*{body}*/{right}").into_bytes();
780            let first = transform(&input, Language::C, TransformOptions::default()).output;
781            let second = transform(&first, Language::C, TransformOptions::default()).output;
782            prop_assert_eq!(first, second);
783        }
784    }
785}