Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .spelling
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ C-SERDE
C-SMART-PTR
CONTRIBUTING.md
CPUs
CRLF
Cargo.toml
Changelog
Chrono
Expand Down
22 changes: 11 additions & 11 deletions crates/cargo-heather/src/checker/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,30 +50,30 @@ pub(crate) fn check(content: &str, expected_header: &str, kind: FileKind) -> Che
/// Returns the [`CheckResult`] for the *input* alongside the rewritten
/// content. When the input is already [`CheckResult::Ok`], the returned
/// content is byte-equivalent to the input.
pub(crate) fn fix(content: &str, expected_header: &str, kind: FileKind) -> (CheckResult, String) {
pub(crate) fn fix(content: &str, expected_header: &str, kind: FileKind, line_ending: &str) -> (CheckResult, String) {
let style = kind.comment_style();
let result = check(content, expected_header, kind);
let new_content = match (&result, kind) {
(CheckResult::Ok, _) => content.to_owned(),
(CheckResult::Missing, FileKind::PowerShell) => strip::prepend_after_optional_shebang(content, expected_header, style),
(CheckResult::Mismatch { .. }, FileKind::PowerShell) => strip::fix_shebang_content(content, expected_header, style),
(_, FileKind::CargoScript) => strip::fix_script_content(content, expected_header, style),
(CheckResult::Missing, _) => prepend_header(content, expected_header, style),
(CheckResult::Missing, FileKind::PowerShell) => strip::prepend_after_optional_shebang(content, expected_header, style, line_ending),
(CheckResult::Mismatch { .. }, FileKind::PowerShell) => strip::fix_shebang_content(content, expected_header, style, line_ending),
(_, FileKind::CargoScript) => strip::fix_script_content(content, expected_header, style, line_ending),
(CheckResult::Missing, _) => prepend_header(content, expected_header, style, line_ending),
(CheckResult::Mismatch { .. }, _) => {
let stripped = strip::strip_existing_header(content, style);
prepend_header(&stripped, expected_header, style)
let stripped = strip::strip_existing_header(content, style, line_ending);
prepend_header(&stripped, expected_header, style, line_ending)
}
};
(result, new_content)
}

/// Prepend the license header comment to file content.
fn prepend_header(content: &str, header_text: &str, style: CommentStyle) -> String {
let comment = style.format_header(header_text);
fn prepend_header(content: &str, header_text: &str, style: CommentStyle, line_ending: &str) -> String {
let comment = style.format_header(header_text, line_ending);
if content.is_empty() {
format!("{comment}\n")
format!("{comment}{line_ending}")
} else {
format!("{comment}\n\n{content}")
format!("{comment}{line_ending}{line_ending}{content}")
}
}

Expand Down
53 changes: 30 additions & 23 deletions crates/cargo-heather/src/checker/strip.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,18 +55,18 @@ fn find_header_end(lines: &[&str], style: CommentStyle) -> Option<usize> {
///
/// If no header is found, returns content unchanged. Trailing newline is
/// preserved if and only if the original content had one.
pub(super) fn strip_existing_header(content: &str, style: CommentStyle) -> String {
pub(super) fn strip_existing_header(content: &str, style: CommentStyle, line_ending: &str) -> String {
let lines: Vec<&str> = content.lines().collect();

let Some(body_start) = find_header_end(&lines, style) else {
return content.to_owned();
};

let remaining = lines[body_start..].join("\n");
let remaining = lines[body_start..].join(line_ending);
if remaining.is_empty() {
remaining
} else if content.ends_with('\n') {
format!("{remaining}\n")
format!("{remaining}{line_ending}")
} else {
remaining
}
Expand All @@ -77,72 +77,79 @@ pub(super) fn strip_existing_header(content: &str, style: CommentStyle) -> Strin
/// Shared by both the `Missing` path (which passes `body_start = 0` to
/// preserve all content) and the `Mismatch` path (which skips stripped
/// header lines).
fn reassemble_after_shebang(shebang: &str, header_text: &str, body_lines: &[&str], body_start: usize, style: CommentStyle) -> String {
let header_comment = style.format_header(header_text);
let rest = body_lines[body_start..].join("\n");
fn reassemble_after_shebang(
shebang: &str,
header_text: &str,
body_lines: &[&str],
body_start: usize,
style: CommentStyle,
line_ending: &str,
) -> String {
let header_comment = style.format_header(header_text, line_ending);
let rest = body_lines[body_start..].join(line_ending);

if rest.is_empty() {
format!("{shebang}\n{header_comment}\n")
format!("{shebang}{line_ending}{header_comment}{line_ending}")
} else {
format!("{shebang}\n{header_comment}\n\n{rest}\n")
format!("{shebang}{line_ending}{header_comment}{line_ending}{line_ending}{rest}{line_ending}")
}
}

/// Replace or insert a header after an optional shebang line.
pub(super) fn fix_shebang_content(content: &str, header_text: &str, style: CommentStyle) -> String {
pub(super) fn fix_shebang_content(content: &str, header_text: &str, style: CommentStyle, line_ending: &str) -> String {
let mut iter = content.lines();
let Some(first) = iter.next() else {
return format!("{}\n", style.format_header(header_text));
return format!("{}{line_ending}", style.format_header(header_text, line_ending));
};

if !first.trim().starts_with("#!") {
let stripped = strip_existing_header(content, style);
return super::prepend_header(&stripped, header_text, style);
let stripped = strip_existing_header(content, style, line_ending);
return super::prepend_header(&stripped, header_text, style, line_ending);
}

let body_lines: Vec<&str> = iter.collect();
let body_start = find_header_end(&body_lines, style).unwrap_or(0);
reassemble_after_shebang(first, header_text, &body_lines, body_start, style)
reassemble_after_shebang(first, header_text, &body_lines, body_start, style, line_ending)
}

/// Prepend a header after an optional shebang line, preserving all
/// existing content (including descriptive comment blocks).
///
/// Used for `CheckResult::Missing` where no header needs to be stripped.
pub(super) fn prepend_after_optional_shebang(content: &str, header_text: &str, style: CommentStyle) -> String {
pub(super) fn prepend_after_optional_shebang(content: &str, header_text: &str, style: CommentStyle, line_ending: &str) -> String {
let mut iter = content.lines();
let Some(first) = iter.next() else {
return format!("{}\n", style.format_header(header_text));
return format!("{}{line_ending}", style.format_header(header_text, line_ending));
};

if !first.trim().starts_with("#!") {
return super::prepend_header(content, header_text, style);
return super::prepend_header(content, header_text, style, line_ending);
}

let body_lines: Vec<&str> = iter.collect();
reassemble_after_shebang(first, header_text, &body_lines, 0, style)
reassemble_after_shebang(first, header_text, &body_lines, 0, style, line_ending)
}

/// Replace the header inside a cargo-script frontmatter.
///
/// Preserves the shebang and opening `---`, strips any existing header block
/// (per [`find_header_end`]), then inserts the new header. If no header is
/// found, the body is preserved verbatim (leading blanks included).
pub(super) fn fix_script_content(content: &str, header_text: &str, style: CommentStyle) -> String {
pub(super) fn fix_script_content(content: &str, header_text: &str, style: CommentStyle, line_ending: &str) -> String {
let mut iter = content.lines();
let shebang = iter.next().unwrap_or("");
let dash_open = iter.next().unwrap_or("---");
let body_lines: Vec<&str> = iter.collect();

let body_start = find_header_end(&body_lines, style).unwrap_or(0);

let header_comment = style.format_header(header_text);
let rest = body_lines[body_start..].join("\n");
let header_comment = style.format_header(header_text, line_ending);
let rest = body_lines[body_start..].join(line_ending);

if rest.is_empty() {
format!("{shebang}\n{dash_open}\n{header_comment}\n")
format!("{shebang}{line_ending}{dash_open}{line_ending}{header_comment}{line_ending}")
} else {
format!("{shebang}\n{dash_open}\n{header_comment}\n\n{rest}\n")
format!("{shebang}{line_ending}{dash_open}{line_ending}{header_comment}{line_ending}{line_ending}{rest}{line_ending}")
}
}

Expand Down Expand Up @@ -213,7 +220,7 @@ mod tests {

fn main() {}
";
let stripped = strip_existing_header(content, CommentStyle::DoubleSlash);
let stripped = strip_existing_header(content, CommentStyle::DoubleSlash, "\n");
assert_eq!(stripped, "fn main() {}\n", "all 5 wrong header lines must be removed");
}
}
4 changes: 2 additions & 2 deletions crates/cargo-heather/src/comment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ impl CommentStyle {
///
/// Converts plain-text header into commented lines.
#[must_use]
pub fn format_header(self, header_text: &str) -> String {
pub fn format_header(self, header_text: &str, line_ending: &str) -> String {
Comment thread
Vaiz marked this conversation as resolved.
header_text
.lines()
.map(|line| {
Expand All @@ -133,7 +133,7 @@ impl CommentStyle {
}
})
.collect::<Vec<_>>()
.join("\n")
.join(line_ending)
}

/// Returns `true` if `trimmed` is a header comment line for this style.
Expand Down
70 changes: 69 additions & 1 deletion crates/cargo-heather/src/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ pub fn check<R: Read>(mut reader: R, expected_header: &str, kind: FileKind) -> i
/// already has the correct header, the output is byte-equivalent to the
/// input.
///
/// Line endings are preserved: if the input uses CRLF (`\r\n`), the
/// output will too. The detected line-ending style is passed through
/// to all internal formatting and reassembly helpers.
///
/// # Errors
///
/// Returns the underlying [`io::Error`] if the reader fails, the content
Expand All @@ -45,8 +49,72 @@ pub fn fix<R: Read, W: Write>(mut reader: R, mut writer: W, expected_header: &st
writer.write_all(content.as_bytes())?;
Ok(CheckResult::Ok)
} else {
let (result, new_content) = checker::fix(&content, expected_header, kind);
let line_ending = if content.contains("\r\n") { "\r\n" } else { "\n" };
let (result, new_content) = checker::fix(&content, expected_header, kind, line_ending);
writer.write_all(new_content.as_bytes())?;
Comment thread
Vaiz marked this conversation as resolved.
Ok(result)
}
}

#[cfg(test)]
mod tests {
use super::*;

const HEADER: &str = "Copyright (c) Microsoft Corporation.\nLicensed under the MIT License.";

#[test]
fn fix_preserves_crlf_when_adding_missing_header() {
let input = b"fn main() {}\r\n";
let mut output: Vec<u8> = Vec::new();
let result = fix(&input[..], &mut output, HEADER, FileKind::Rust).unwrap();
assert_eq!(result, CheckResult::Missing);
let text = String::from_utf8(output).unwrap();
assert!(text.contains("\r\n"), "output must use CRLF when input uses CRLF, got: {text:?}");
assert!(!text.contains("\r\n\r\n\r\n"), "must not have triple CRLF, got: {text:?}");
assert!(text.ends_with("fn main() {}\r\n"));
}

#[test]
fn fix_preserves_lf_when_adding_missing_header() {
let input = b"fn main() {}\n";
let mut output: Vec<u8> = Vec::new();
let result = fix(&input[..], &mut output, HEADER, FileKind::Rust).unwrap();
assert_eq!(result, CheckResult::Missing);
let text = String::from_utf8(output).unwrap();
assert!(!text.contains("\r\n"), "output must use LF when input uses LF, got: {text:?}");
}

#[test]
fn fix_preserves_crlf_when_replacing_wrong_header() {
let input = b"// Wrong header.\r\n\r\nfn main() {}\r\n";
let mut output: Vec<u8> = Vec::new();
let result = fix(&input[..], &mut output, HEADER, FileKind::Rust).unwrap();
assert!(matches!(result, CheckResult::Missing | CheckResult::Mismatch { .. }));
let text = String::from_utf8(output).unwrap();
assert!(text.contains("\r\n"), "output must use CRLF when input uses CRLF, got: {text:?}");
assert!(
!text.contains('\n') || !text.contains("\r\n") || text.replace("\r\n", "").find('\n').is_none(),
"must not mix bare LF with CRLF"
);
}

#[test]
fn fix_correct_crlf_header_is_byte_equivalent() {
let input = "// Copyright (c) Microsoft Corporation.\r\n// Licensed under the MIT License.\r\n\r\nfn main() {}\r\n";
let mut output: Vec<u8> = Vec::new();
let result = fix(input.as_bytes(), &mut output, HEADER, FileKind::Rust).unwrap();
assert_eq!(result, CheckResult::Ok);
assert_eq!(output, input.as_bytes(), "correct header must be written unchanged");
}

#[test]
fn fix_preserves_crlf_toml_missing_header() {
let input = b"[package]\r\nname = \"foo\"\r\n";
let mut output: Vec<u8> = Vec::new();
let result = fix(&input[..], &mut output, HEADER, FileKind::Toml).unwrap();
assert_eq!(result, CheckResult::Missing);
let text = String::from_utf8(output).unwrap();
assert!(text.contains("\r\n"), "TOML output must preserve CRLF");
assert!(text.ends_with("[package]\r\nname = \"foo\"\r\n"));
}
}
Loading