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
11pub fn transform(source: &[u8], language: Language, options: TransformOptions) -> TransformResult {
42 transform_plan(source, language, options).finish(source)
43}
44
45pub 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 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 pub fn transform(&self, source: &[u8], language: Language, layout: Layout) -> TransformResult {
74 self.transform_plan(source, language, layout).finish(source)
75 }
76
77 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 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
106pub 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 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 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 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 pub fn output(&self, source: &[u8]) -> Vec<u8> {
262 apply_edits(source, &self.edits)
263 }
264
265 pub fn source_map(&self, source_len: usize) -> SourceMap {
267 SourceMap::from_edits(source_len, &self.edits)
268 }
269}
270
271pub 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
323fn 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
355fn 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
368fn 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 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
415fn 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 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
474fn 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 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 line_replacement(source, span)
519 } else if let Some(terminator) = interior.filter(|_| head_code) {
520 terminator.to_vec()
523 } else {
524 Vec::new()
528 };
529 Edit {
530 span: ByteSpan::new(start, end.min(ceiling)),
531 replacement,
532 }
533}
534
535fn 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
549fn 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
568fn 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 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}