use crate::{
config::PolicyTrace,
files::{NO_LANGUAGE, STDIN_PATH, SkippedFile},
};
use anyhow::Result;
use clap::ValueEnum;
#[cfg(test)]
use ocomment_core::TransformResult;
use ocomment_core::{
ByteSpan, Comment, CommentKind, Disposition, DispositionExplanation, DispositionPatterns, Edit,
Language, Policy, ScanOptions, ScanReport, SourceMap, TransformPlan, explain_comment_with,
};
use serde::{Serialize, Serializer, ser::SerializeSeq};
use serde_json::{Value, json};
use similar::{Algorithm, ChangeTag, capture_diff_slices, group_diff_ops};
use std::{
collections::BTreeMap,
io::{self, BufWriter, Write},
path::{Component, Path, PathBuf},
};
use unicode_width::UnicodeWidthChar;
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)]
pub enum OutputFormat {
#[default]
Human,
Json,
Jsonl,
Sarif,
Github,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Operation {
Check,
Scan,
Diff,
Fix,
}
#[derive(Clone, Copy, Debug, Default)]
pub struct Presentation {
pub color: bool,
pub hyperlinks: bool,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum Verbosity {
Quiet,
#[default]
Normal,
Verbose,
}
#[derive(Clone, Copy, Debug)]
pub struct RenderOptions {
pub format: OutputFormat,
pub operation: Operation,
pub presentation: Presentation,
pub verbosity: Verbosity,
pub preview: bool,
pub explain: bool,
pub dry_run: bool,
pub force_invalid: bool,
pub applied: bool,
pub policy: Policy,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct Summary {
pub files_scanned: usize,
pub files_with_removable: usize,
pub removable_comments: usize,
pub kept_comments: usize,
pub files_changed: usize,
pub comments_removed: usize,
pub invalid_files: usize,
pub skipped_by_reason: BTreeMap<String, usize>,
pub named_skips: usize,
pub io_errors: usize,
}
impl Summary {
pub fn compute(files: &[ProcessedFile], skipped: &[SkippedFile], operation: Operation) -> Self {
let mut summary = Self {
files_scanned: files.len(),
..Self::default()
};
for file in files {
let removable = removable_count(file);
summary.removable_comments += removable;
summary.kept_comments += file.result.report.comments.len() - removable;
if removable > 0 {
summary.files_with_removable += 1;
}
if !file.result.report.valid {
summary.invalid_files += 1;
}
if file.result.changed() {
summary.files_changed += 1;
if operation == Operation::Fix {
summary.comments_removed += removable;
}
}
}
for item in skipped {
if item.error {
summary.io_errors += 1;
} else if item.explicit {
summary.named_skips += 1;
} else {
*summary
.skipped_by_reason
.entry(skip_label(&item.reason).to_owned())
.or_default() += 1;
}
}
summary
}
fn skipped_files(&self) -> usize {
self.skipped_by_reason.values().sum()
}
}
fn removable_count(file: &ProcessedFile) -> usize {
file.result
.report
.comments
.iter()
.filter(|comment| comment.disposition.is_remove())
.count()
}
pub(crate) fn skip_label(reason: &str) -> &str {
if reason.starts_with("larger than ") {
"too large"
} else if reason.starts_with("binary file") {
"binary"
} else if reason.starts_with("language disabled") {
"language disabled"
} else if reason == NO_LANGUAGE {
"unknown language"
} else {
reason
}
}
const PROTECTED_PREAMBLE: &str = "required source preamble";
fn protected_preambles(files: &[ProcessedFile]) -> usize {
files
.iter()
.flat_map(|file| &file.result.report.comments)
.filter(|comment| {
matches!(&comment.disposition, Disposition::Keep { reason } if reason == PROTECTED_PREAMBLE)
})
.count()
}
fn plural(count: usize, noun: &str) -> String {
format!("{count} {noun}{}", if count == 1 { "" } else { "s" })
}
fn comments(count: usize, adjective: &str) -> String {
let space = if adjective.is_empty() { "" } else { " " };
plural(count, &format!("{adjective}{space}comment"))
}
#[derive(Clone, Debug)]
pub struct ProcessedFile {
pub path: PathBuf,
pub source: Vec<u8>,
pub language: Language,
pub result: ProcessedResult,
}
#[derive(Clone, Debug)]
pub struct ProcessedResult {
pub report: ScanReport,
pub edits: Vec<Edit>,
pub source_map: Option<SourceMap>,
output: Option<Vec<u8>>,
changed: bool,
}
impl ProcessedResult {
pub fn report(report: ScanReport, changed: bool) -> Self {
Self {
report,
edits: Vec::new(),
source_map: None,
output: None,
changed,
}
}
pub fn plan(
source: &[u8],
plan: TransformPlan,
materialize_output: bool,
materialize_source_map: bool,
) -> Self {
let changed = plan.edits.iter().any(|edit| {
source.get(edit.span.start..edit.span.end) != Some(edit.replacement.as_slice())
});
let output = materialize_output.then(|| plan.output(source));
let source_map = materialize_source_map.then(|| plan.source_map(source.len()));
Self {
report: plan.report,
edits: plan.edits,
source_map,
output,
changed,
}
}
#[cfg(test)]
pub fn complete(result: TransformResult) -> Self {
let changed = !result.edits.is_empty();
Self {
report: result.report,
edits: result.edits,
source_map: Some(result.source_map),
output: Some(result.output),
changed,
}
}
pub const fn changed(&self) -> bool {
self.changed
}
pub fn output(&self) -> &[u8] {
self.output
.as_deref()
.expect("this operation requested transformed source bytes")
}
pub fn source_map(&self) -> &SourceMap {
self.source_map
.as_ref()
.expect("this output format requested a source map")
}
}
#[derive(Serialize)]
struct JsonFile<'a> {
path: String,
language: Language,
changed: bool,
report: &'a ocomment_core::ScanReport,
edits: &'a [ocomment_core::Edit],
source_map: &'a SourceMap,
}
pub fn removable_label(kind: CommentKind) -> String {
format!("removable {kind} comment")
}
pub fn kept_label(kind: CommentKind, reason: &str) -> String {
format!("{}: {reason}", kept_prefix(kind))
}
fn kept_prefix(kind: CommentKind) -> String {
format!("kept {kind} comment")
}
#[derive(Clone, Debug)]
pub struct FileExplanation {
pub options: ScanOptions,
pub trace: PolicyTrace,
}
pub type Explanations = BTreeMap<PathBuf, FileExplanation>;
struct Explainer<'a> {
material: &'a FileExplanation,
patterns: DispositionPatterns,
}
impl<'a> Explainer<'a> {
fn new(material: &'a FileExplanation) -> Self {
Self {
patterns: DispositionPatterns::compile(&material.options)
.unwrap_or_else(|_| DispositionPatterns::empty()),
material,
}
}
}
fn explanation_line(
file: &ProcessedFile,
comment: &Comment,
explainer: &Explainer<'_>,
options: &RenderOptions,
) -> String {
let material = explainer.material;
let start = comment.span.start.min(file.source.len());
let end = comment.span.end.clamp(start, file.source.len());
let verdict = explain_comment_with(
&explainer.patterns,
comment,
&file.source[start..end],
file.language,
&material.options,
);
let tail = match material.trace.origin_of(&verdict, &material.options) {
Some(origin) => format!(" ({origin})"),
None => next_step(&verdict),
};
format!(
" {}{}{}",
color("\x1b[2m", options.presentation.color),
fold(&format!("{verdict}{tail}")),
color("\x1b[0m", options.presentation.color)
)
}
fn write_explanation(
output: &mut impl Write,
file: &ProcessedFile,
comment: &Comment,
explainer: Option<&Explainer<'_>>,
options: &RenderOptions,
) -> Result<()> {
let Some(explainer) = explainer else {
return Ok(());
};
wrote(writeln!(
output,
"{}",
explanation_line(file, comment, explainer, options)
))
}
fn next_step(verdict: &DispositionExplanation) -> String {
match verdict {
DispositionExplanation::ProtectedPreamble => {
"; add --force-protected to remove it".to_owned()
}
DispositionExplanation::KeptHtml => format!(
"; use --remove-kind {} or --policy all to remove it",
CommentKind::HtmlComment
),
DispositionExplanation::KeptDirective { kind, .. } => {
format!("; use --remove-kind {kind} or --policy all to remove it")
}
DispositionExplanation::KeptStructural { .. } => {
"; the comment under it has to go first".to_owned()
}
_ => String::new(),
}
}
const PREVIEW_COLUMNS: usize = 72;
fn preview(source: &[u8], span: ByteSpan, max_columns: usize) -> String {
let start = span.start.min(source.len());
let end = span.end.clamp(start, source.len());
truncate(
fold(&String::from_utf8_lossy(&source[start..end])),
max_columns,
)
}
pub(crate) fn sanitize_line(text: &str) -> String {
truncate(fold(text), PREVIEW_COLUMNS)
}
pub(crate) fn sanitize_message(text: &str) -> String {
fold(text)
}
pub(crate) fn sanitize_path(text: &str) -> String {
text.chars()
.map(|character| {
if is_control(character) {
'\u{fffd}'
} else {
character
}
})
.collect()
}
pub(crate) fn sanitize_source_line(text: &str) -> String {
let mut line = String::with_capacity(text.len());
let mut column = 0usize;
for character in text.chars() {
if character == '\t' {
let width = TAB_WIDTH - (column % TAB_WIDTH);
line.extend(std::iter::repeat_n(' ', width));
column += width;
} else if is_control(character) {
line.push('\u{fffd}');
column += 1;
} else {
line.push(character);
column += columns(character);
}
}
truncate(line, PREVIEW_COLUMNS)
}
const TAB_WIDTH: usize = 8;
fn fold(text: &str) -> String {
let mut folded = String::with_capacity(text.len());
let mut pending_space = false;
for character in text.chars() {
if matches!(character, ' ' | '\t' | '\r' | '\n' | '\u{c}') {
pending_space = !folded.is_empty();
continue;
}
if pending_space {
folded.push(' ');
pending_space = false;
}
folded.push(if is_control(character) {
'\u{fffd}'
} else {
character
});
}
folded
}
fn is_control(character: char) -> bool {
matches!(
character,
'\u{0}'..='\u{1f}'
| '\u{7f}'..='\u{9f}'
| '\u{61c}'
| '\u{200e}'..='\u{200f}'
| '\u{2028}'..='\u{2029}'
| '\u{202a}'..='\u{202e}'
| '\u{2066}'..='\u{2069}'
| '\u{feff}'
)
}
fn columns(character: char) -> usize {
UnicodeWidthChar::width(character).unwrap_or(0)
}
const PREVIEW_CHARS_PER_COLUMN: usize = 4;
fn truncate(text: String, max_columns: usize) -> String {
let max_chars = max_columns.saturating_mul(PREVIEW_CHARS_PER_COLUMN);
if text.chars().map(columns).sum::<usize>() <= max_columns && text.chars().count() <= max_chars
{
return text;
}
let column_budget = max_columns.saturating_sub(1);
let char_budget = max_chars.saturating_sub(1);
let mut cut = String::with_capacity(text.len());
let mut width = 0usize;
for (taken, character) in text.chars().enumerate() {
if taken >= char_budget {
break;
}
width += columns(character);
if width > column_budget {
break;
}
cut.push(character);
}
cut.push('\u{2026}');
cut
}
fn preview_suffix(source: &[u8], span: ByteSpan, options: &RenderOptions) -> String {
if !options.preview {
return String::new();
}
let text = preview(source, span, PREVIEW_COLUMNS);
if text.is_empty() {
return String::new();
}
format!(
": {}{text}{}",
color("\x1b[2m", options.presentation.color),
color("\x1b[0m", options.presentation.color)
)
}
pub type Stdout = BufWriter<io::StdoutLock<'static>>;
pub fn stdout() -> Stdout {
BufWriter::new(io::stdout().lock())
}
#[derive(Debug)]
pub struct OutputPipeClosed;
impl std::fmt::Display for OutputPipeClosed {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("the reader of standard output closed the pipe")
}
}
impl std::error::Error for OutputPipeClosed {}
pub fn finish(writer: &mut impl Write) -> Result<()> {
wrote(writer.flush())
}
pub fn wrote(result: io::Result<()>) -> Result<()> {
result.map_err(output_failure)
}
fn output_failure(error: io::Error) -> anyhow::Error {
if error.kind() == io::ErrorKind::BrokenPipe {
return anyhow::Error::new(OutputPipeClosed);
}
anyhow::Error::new(error).context("cannot write standard output")
}
pub fn note(writer: &mut impl Write, line: &str) -> Result<()> {
match writeln!(writer, "{line}") {
Err(error) if error.kind() != io::ErrorKind::BrokenPipe => {
Err(anyhow::Error::new(error).context("cannot write standard error"))
}
_ => Ok(()),
}
}
fn write_error(error: serde_json::Error) -> anyhow::Error {
output_failure(io::Error::from(error))
}
pub fn render(
files: &[ProcessedFile],
skipped: &[SkippedFile],
options: &RenderOptions,
) -> Result<()> {
render_explained(files, skipped, options, &Explanations::new())
}
pub fn render_explained(
files: &[ProcessedFile],
skipped: &[SkippedFile],
options: &RenderOptions,
explanations: &Explanations,
) -> Result<()> {
let mut output = stdout();
match options.format {
OutputFormat::Human => render_human(&mut output, files, skipped, options, explanations),
OutputFormat::Json => render_json(&mut output, files, skipped),
OutputFormat::Jsonl => render_jsonl(&mut output, files, skipped),
OutputFormat::Sarif => render_sarif(&mut output, files, skipped),
OutputFormat::Github => render_github(&mut output, files, skipped, options.verbosity),
}?;
finish(&mut output)
}
fn render_human(
output: &mut impl Write,
files: &[ProcessedFile],
skipped: &[SkippedFile],
options: &RenderOptions,
explanations: &Explanations,
) -> Result<()> {
let operation = options.operation;
let presentation = options.presentation;
let quiet = options.verbosity == Verbosity::Quiet;
let verbose = options.verbosity == Verbosity::Verbose;
for file in files {
if operation == Operation::Diff && file.result.changed() {
wrote(output.write_all(&unified_diff(
&file.path,
&file.source,
file.result.output(),
)))?;
continue;
}
let reports_comments = match operation {
Operation::Scan => !file.result.report.comments.is_empty(),
Operation::Fix => false,
Operation::Check | Operation::Diff if quiet => false,
Operation::Check | Operation::Diff if options.explain => {
!file.result.report.comments.is_empty()
}
Operation::Check | Operation::Diff => file
.result
.report
.comments
.iter()
.any(|comment| comment.disposition.is_remove()),
};
let lines = (!file.result.report.diagnostics.is_empty() || reports_comments)
.then(|| LineIndex::new(&file.source));
for diagnostic in &file.result.report.diagnostics {
let (line, column) = lines
.as_ref()
.expect("a diagnostic requested a line index")
.line_column(diagnostic.span.start);
wrote(writeln!(
output,
"{}:{line}:{column}: {}{}[{}]{}: {}",
display_path(&file.path, presentation.hyperlinks),
color("\x1b[31m", presentation.color),
diagnostic.severity,
sanitize_message(&diagnostic.code),
color("\x1b[0m", presentation.color),
sanitize_message(&diagnostic.message)
))?;
}
let explainer = options
.explain
.then(|| explanations.get(&file.path))
.flatten()
.map(Explainer::new);
let explainer = explainer.as_ref();
if operation == Operation::Scan {
for comment in &file.result.report.comments {
let (line, column) = lines
.as_ref()
.expect("a scan listing requested a line index")
.line_column(comment.span.start);
wrote(writeln!(
output,
"{}:{line}:{column}: {} {} {}..{}{}",
display_path(&file.path, presentation.hyperlinks),
comment.kind,
comment.disposition,
comment.span.start,
comment.span.end,
preview_suffix(&file.source, comment.span, options)
))?;
write_explanation(output, file, comment, explainer, options)?;
}
} else if quiet {
continue;
} else if operation == Operation::Fix {
if options.applied && file.result.changed() {
wrote(writeln!(
output,
"fixed {}: removed {}",
display_path(&file.path, presentation.hyperlinks),
comments(removable_count(file), "")
))?;
}
} else {
for comment in &file.result.report.comments {
let removable = comment.disposition.is_remove();
if !options.explain && !removable {
continue;
}
let (line, column) = lines
.as_ref()
.expect("a finding requested a line index")
.line_column(comment.span.start);
wrote(writeln!(
output,
"{}:{line}:{column}: {}{}{}{}",
display_path(&file.path, presentation.hyperlinks),
color(
if removable { "\x1b[33m" } else { "\x1b[32m" },
presentation.color
),
if removable {
removable_label(comment.kind)
} else {
kept_prefix(comment.kind)
},
color("\x1b[0m", presentation.color),
preview_suffix(&file.source, comment.span, options)
))?;
write_explanation(output, file, comment, explainer, options)?;
}
}
}
let skips = skip_lines(skipped, presentation, options.verbosity);
if operation != Operation::Diff {
for line in &skips {
wrote(writeln!(output, "{line}"))?;
}
}
finish(output)?;
let stderr = io::stderr();
let mut report = stderr.lock();
if operation == Operation::Diff && options.dry_run {
for line in &skips {
note(&mut report, line)?;
}
}
if quiet {
return Ok(());
}
let summary = Summary::compute(files, skipped, operation);
let folded = !verbose && skipped.iter().any(|item| !item.error && !item.explicit);
if verbose && let Some(line) = kind_breakdown(files, options) {
note(&mut report, &line)?;
}
note(&mut report, &summary_report(&summary, options, folded))?;
if options.policy == Policy::All {
let protected = protected_preambles(files);
if protected > 0 {
let pronoun = if protected == 1 { "it" } else { "them" };
note(
&mut report,
&format!(
"{} kept; add --force-protected to remove {pronoun}.",
comments(protected, "protected preamble")
),
)?;
}
}
if summary.invalid_files > 0 && !options.force_invalid {
let (verb, pronoun) = if summary.invalid_files == 1 {
("has", "it")
} else {
("have", "them")
};
note(
&mut report,
&format!(
"{} {verb} invalid syntax; nothing was written for {pronoun} \
(use --force-invalid to apply known-safe edits).",
plural(summary.invalid_files, "file")
),
)?;
}
Ok(())
}
pub(crate) fn skip_is_visible(item: &SkippedFile, verbosity: Verbosity) -> bool {
item.error
|| (verbosity != Verbosity::Quiet && (item.explicit || verbosity == Verbosity::Verbose))
}
pub(crate) fn skip_lines(
skipped: &[SkippedFile],
presentation: Presentation,
verbosity: Verbosity,
) -> Vec<String> {
skipped
.iter()
.filter(|item| skip_is_visible(item, verbosity))
.map(|item| {
format!(
"{}: {}: {}",
display_path(&item.path, presentation.hyperlinks),
if item.error { "error" } else { "skipped" },
sanitize_message(&item.reason)
)
})
.collect()
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(crate) struct InteractiveOutcome {
pub removed: usize,
pub reviewed: usize,
pub offered: usize,
pub changed: usize,
pub scanned: usize,
}
pub(crate) fn interactive_summary(outcome: InteractiveOutcome) -> String {
if outcome.offered == 0 {
return format!("Nothing to fix in {}.", plural(outcome.scanned, "file"));
}
let unreviewed = outcome.offered.saturating_sub(outcome.reviewed);
let tail = if unreviewed == 0 {
String::new()
} else {
format!(" ({} not reviewed)", comments(unreviewed, ""))
};
format!(
"Removed {} of {} in {}{tail} ({} scanned).",
outcome.removed,
comments(outcome.reviewed, ""),
plural(outcome.changed, "file"),
plural(outcome.scanned, "file")
)
}
fn summary_report(summary: &Summary, options: &RenderOptions, folded: bool) -> String {
let skips = skip_clause(summary, folded);
let nothing = nothing_to(options);
let mut report = if summary.files_scanned > 0 {
format!("{}{skips}", summary_line(summary, options))
} else if !skips.is_empty() {
format!("Nothing to {nothing}:{skips}")
} else if summary.named_skips > 0 {
format!("Nothing to {nothing}.")
} else {
summary_line(summary, options)
};
if summary.io_errors > 0 {
report.push_str(&format!(" {}.", plural(summary.io_errors, "I/O error")));
}
report
}
fn nothing_to(options: &RenderOptions) -> &'static str {
match options.operation {
Operation::Check => "check",
Operation::Fix => "fix",
Operation::Diff if options.dry_run => "fix",
Operation::Diff => "diff",
Operation::Scan => "scan",
}
}
fn summary_line(summary: &Summary, options: &RenderOptions) -> String {
let scanned = plural(summary.files_scanned, "file");
let found = || {
format!(
"Found {} in {} ({scanned} scanned).",
comments(summary.removable_comments, "removable"),
plural(summary.files_with_removable, "file")
)
};
match options.operation {
Operation::Diff if options.dry_run => {
if summary.removable_comments == 0 {
return format!("Nothing to fix in {scanned}.");
}
format!(
"Would remove {} in {}. Rerun without --dry-run to apply.",
comments(summary.removable_comments, ""),
plural(summary.files_with_removable, "file")
)
}
Operation::Check | Operation::Diff => {
if summary.removable_comments == 0 {
return format!("No removable comments in {scanned}.");
}
let next = if options.operation == Operation::Diff {
"apply the patch"
} else if summary.removable_comments == 1 {
"remove it"
} else {
"remove them"
};
format!("{} Run `ocomment fix` to {next}.", found())
}
Operation::Fix => {
if options.applied && summary.files_changed > 0 {
format!(
"Removed {} in {} ({scanned} scanned).",
comments(summary.comments_removed, ""),
plural(summary.files_changed, "file")
)
} else if summary.removable_comments == 0 {
format!("Nothing to fix in {scanned}.")
} else {
found()
}
}
Operation::Scan => format!(
"Scanned {scanned}: {} ({} removable, {} kept).",
comments(summary.removable_comments + summary.kept_comments, ""),
summary.removable_comments,
summary.kept_comments
),
}
}
fn skip_clause(summary: &Summary, folded: bool) -> String {
let total = summary.skipped_files();
if total == 0 {
return String::new();
}
let reasons: Vec<_> = summary
.skipped_by_reason
.iter()
.map(|(label, count)| format!("{label}: {count}"))
.collect();
let hint = if folded { "; use -v to list" } else { "" };
format!(
" {} skipped ({}{hint}).",
plural(total, "file"),
reasons.join(", ")
)
}
fn kind_breakdown(files: &[ProcessedFile], options: &RenderOptions) -> Option<String> {
let verb = if options.operation == Operation::Fix && options.applied {
"removed"
} else {
"removable"
};
let mut removable = [0usize; CommentKind::ALL.len()];
let mut kept = [0usize; CommentKind::ALL.len()];
for file in files {
for comment in &file.result.report.comments {
let slot = CommentKind::ALL
.iter()
.position(|kind| *kind == comment.kind)
.expect("CommentKind::ALL lists every kind");
if comment.disposition.is_remove() {
removable[slot] += 1;
} else {
kept[slot] += 1;
}
}
}
let mut parts = Vec::new();
for (slot, kind) in CommentKind::ALL.into_iter().enumerate() {
if removable[slot] > 0 {
parts.push(format!("{kind} {} {verb}", removable[slot]));
}
if kept[slot] > 0 {
parts.push(format!("{kind} {} kept", kept[slot]));
}
}
(!parts.is_empty()).then(|| format!("kinds: {}", parts.join(", ")))
}
pub(crate) fn color(code: &'static str, enabled: bool) -> &'static str {
if enabled { code } else { "" }
}
fn display_path(path: &Path, hyperlinks: bool) -> String {
let display = sanitize_path(&path.display().to_string());
if !hyperlinks {
return display;
}
let absolute = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
#[cfg(unix)]
let target = {
use std::os::unix::ffi::OsStrExt;
percent_encode(absolute.as_os_str().as_bytes())
};
#[cfg(not(unix))]
let target = percent_encode(absolute.to_string_lossy().as_bytes());
format!("\x1b]8;;file://{target}\x1b\\{display}\x1b]8;;\x1b\\")
}
fn percent_encode(path: impl AsRef<[u8]>) -> String {
let path = path.as_ref();
let mut encoded = String::with_capacity(path.len());
for byte in path.iter().copied() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~' | b'/') {
encoded.push(char::from(byte));
} else {
push_percent_encoded(&mut encoded, byte);
}
}
encoded
}
fn push_percent_encoded(output: &mut String, byte: u8) {
output.push('%');
output.push(HEX[usize::from(byte >> 4)]);
output.push(HEX[usize::from(byte & 0xf)]);
}
const HEX: [char; 16] = [
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F',
];
fn render_json(
output: &mut impl Write,
files: &[ProcessedFile],
skipped: &[SkippedFile],
) -> Result<()> {
#[derive(Serialize)]
struct Document<'a> {
version: u8,
files: JsonFiles<'a>,
skipped: JsonSkipped<'a>,
}
serde_json::to_writer_pretty(
&mut *output,
&Document {
version: 1,
files: JsonFiles(files),
skipped: JsonSkipped(skipped),
},
)
.map_err(write_error)?;
wrote(writeln!(output))?;
Ok(())
}
struct JsonFiles<'a>(&'a [ProcessedFile]);
impl Serialize for JsonFiles<'_> {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut sequence = serializer.serialize_seq(Some(self.0.len()))?;
for file in self.0 {
sequence.serialize_element(&json_file(file))?;
}
sequence.end()
}
}
struct JsonSkipped<'a>(&'a [SkippedFile]);
impl Serialize for JsonSkipped<'_> {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
#[derive(Serialize)]
struct Entry<'a> {
path: std::borrow::Cow<'a, str>,
reason: &'a str,
error: bool,
}
let mut sequence = serializer.serialize_seq(Some(self.0.len()))?;
for item in self.0 {
sequence.serialize_element(&Entry {
path: item.path.to_string_lossy(),
reason: &item.reason,
error: item.error,
})?;
}
sequence.end()
}
}
fn render_jsonl(
output: &mut impl Write,
files: &[ProcessedFile],
skipped: &[SkippedFile],
) -> Result<()> {
for file in files {
serde_json::to_writer(&mut *output, &json_file(file)).map_err(write_error)?;
wrote(writeln!(output))?;
}
for item in skipped {
serde_json::to_writer(
&mut *output,
&json!({"type": "skip", "path": item.path.to_string_lossy(), "reason": item.reason, "error": item.error}),
)
.map_err(write_error)?;
wrote(writeln!(output))?;
}
Ok(())
}
fn json_file(file: &ProcessedFile) -> JsonFile<'_> {
JsonFile {
path: file.path.to_string_lossy().into_owned(),
language: file.language,
changed: file.result.changed(),
report: &file.result.report,
edits: &file.result.edits,
source_map: file.result.source_map(),
}
}
const TOOL_INFORMATION_URI: &str = "https://github.com/P4suta/OComment";
const KIND_HELP_URI: &str = "https://github.com/P4suta/OComment#why-was-this-comment-kept";
const SRCROOT: &str = "%SRCROOT%";
const DIAGNOSTIC_DESCRIPTION: &str =
"A problem OComment met while scanning the file; the message on the result says what it was.";
fn report_path_bytes(path: &Path) -> Vec<u8> {
#[cfg(unix)]
let bytes = {
use std::os::unix::ffi::OsStrExt;
path.as_os_str().as_bytes().to_vec()
};
#[cfg(not(unix))]
let bytes = path.to_string_lossy().replace('\\', "/").into_bytes();
let segments: Vec<&[u8]> = bytes
.split(|byte| *byte == b'/')
.filter(|segment| *segment != b".")
.collect();
if segments.is_empty() {
return bytes;
}
let mut normalized = Vec::with_capacity(bytes.len());
for (index, segment) in segments.into_iter().enumerate() {
if index > 0 {
normalized.push(b'/');
}
normalized.extend_from_slice(segment);
}
normalized
}
fn report_path(path: &Path) -> String {
lossless_text(&report_path_bytes(path))
}
fn lossless_text(mut bytes: &[u8]) -> String {
let mut text = String::with_capacity(bytes.len());
while !bytes.is_empty() {
match std::str::from_utf8(bytes) {
Ok(valid) => {
text.push_str(valid);
break;
}
Err(error) => {
let valid = error.valid_up_to();
text.push_str(
std::str::from_utf8(&bytes[..valid])
.expect("the UTF-8 error identifies a valid prefix"),
);
let invalid = error.error_len().unwrap_or(bytes.len() - valid);
for byte in &bytes[valid..valid + invalid] {
push_percent_encoded(&mut text, *byte);
}
bytes = &bytes[valid + invalid..];
}
}
}
text
}
fn sarif_uri(path: &Path) -> String {
if path == Path::new(STDIN_PATH) {
return STDIN_PATH.to_owned();
}
let mut encoded = String::new();
for byte in report_path_bytes(path) {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~' | b'/' | b':') {
encoded.push(char::from(byte));
} else {
push_percent_encoded(&mut encoded, byte);
}
}
encoded
}
fn github_path(path: &Path) -> String {
let bytes = report_path_bytes(path);
let mut escaped = String::with_capacity(bytes.len());
let mut remaining = bytes.as_slice();
while !remaining.is_empty() {
match std::str::from_utf8(remaining) {
Ok(valid) => {
escaped.push_str(&github_escape(valid));
break;
}
Err(error) => {
let valid = error.valid_up_to();
escaped.push_str(&github_escape(
std::str::from_utf8(&remaining[..valid])
.expect("the UTF-8 error identifies a valid prefix"),
));
let invalid = error.error_len().unwrap_or(remaining.len() - valid);
for byte in &remaining[valid..valid + invalid] {
push_percent_encoded(&mut escaped, *byte);
}
remaining = &remaining[valid + invalid..];
}
}
}
escaped
}
fn artifact_location(path: &Path) -> Value {
let repository_path = report_path(path);
let uri = sarif_uri(path);
if under_source_root(path) {
let uri = if reads_as_a_drive_letter(&repository_path) {
format!("./{uri}")
} else {
uri
};
json!({"uri": uri, "uriBaseId": SRCROOT})
} else {
json!({"uri": uri})
}
}
fn reads_as_a_drive_letter(uri: &str) -> bool {
let mut head = uri.split('/').next().unwrap_or_default().chars();
matches!(
(head.next(), head.next(), head.next()),
(Some(letter), Some(':'), None) if letter.is_ascii_alphabetic()
)
}
fn under_source_root(path: &Path) -> bool {
path != Path::new(STDIN_PATH)
&& path
.components()
.all(|component| matches!(component, Component::Normal(_) | Component::CurDir))
&& path
.components()
.any(|component| matches!(component, Component::Normal(_)))
}
struct SarifRules {
entries: Vec<Value>,
indices: BTreeMap<String, usize>,
}
impl SarifRules {
fn new() -> Self {
let mut rules = Self {
entries: Vec::new(),
indices: BTreeMap::new(),
};
for kind in CommentKind::ALL {
rules.describe(
&format!("removable-{kind}"),
"note",
&format!("Removable {kind} comment"),
&format!(
"A {kind} comment OComment can remove without changing what the file does."
),
KIND_HELP_URI,
);
}
rules
}
fn describe(&mut self, id: &str, level: &str, short: &str, full: &str, help: &str) -> usize {
if let Some(&index) = self.indices.get(id) {
return index;
}
let index = self.entries.len();
self.entries.push(json!({
"id": id,
"shortDescription": {"text": short},
"fullDescription": {"text": full},
"helpUri": help,
"defaultConfiguration": {"level": level},
}));
self.indices.insert(id.to_owned(), index);
index
}
fn kind(&self, kind: CommentKind) -> usize {
let id = format!("removable-{kind}");
*self
.indices
.get(&id)
.expect("every comment kind is described")
}
fn index(&self, id: &str) -> usize {
*self
.indices
.get(id)
.expect("every emitted SARIF result has a prepared rule")
}
}
fn sentence_case(code: &str) -> String {
let spelled = code.replace('-', " ");
let mut characters = spelled.chars();
match characters.next() {
Some(first) => first.to_uppercase().collect::<String>() + characters.as_str(),
None => spelled,
}
}
fn sarif_level(severity: ocomment_core::Severity) -> &'static str {
match severity {
ocomment_core::Severity::Error => "error",
ocomment_core::Severity::Warning => "warning",
ocomment_core::Severity::Info | ocomment_core::Severity::Hint => "note",
}
}
struct SarifResults<'a> {
files: &'a [ProcessedFile],
skipped: &'a [SkippedFile],
rules: &'a SarifRules,
}
impl Serialize for SarifResults<'_> {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut results = serializer.serialize_seq(None)?;
for file in self.files {
if file.result.report.diagnostics.is_empty()
&& !file
.result
.report
.comments
.iter()
.any(|comment| comment.disposition.is_remove())
{
continue;
}
let location = artifact_location(&file.path);
let lines = LineIndex::new(&file.source);
for comment in file
.result
.report
.comments
.iter()
.filter(|comment| comment.disposition.is_remove())
{
let (line, column) = lines.line_column(comment.span.start);
let (end_line, end_column) = lines.line_column(comment.span.end);
let (fix_span, replacement) = fix_for_span(file, comment.span);
let (fix_line, fix_column) = lines.line_column(fix_span.start);
let (fix_end_line, fix_end_column) = lines.line_column(fix_span.end);
let kind = comment.kind.as_str();
results.serialize_element(&json!({
"ruleId": format!("removable-{kind}"),
"ruleIndex": self.rules.kind(comment.kind),
"level": "note",
"message": {"text": removable_label(comment.kind)},
"locations": [{"physicalLocation": {
"artifactLocation": location.clone(),
"region": {"startLine": line, "startColumn": column,
"endLine": end_line, "endColumn": end_column}
}}],
"fixes": [{
"description": {"text": "Remove comment with OComment"},
"artifactChanges": [{
"artifactLocation": location.clone(),
"replacements": [{"deletedRegion": {
"startLine": fix_line, "startColumn": fix_column,
"endLine": fix_end_line, "endColumn": fix_end_column
}, "insertedContent": {"text": replacement}}]
}]
}]
}))?;
}
for diagnostic in &file.result.report.diagnostics {
let (line, column) = lines.line_column(diagnostic.span.start);
let (end_line, end_column) = lines.line_column(diagnostic.span.end);
let level = sarif_level(diagnostic.severity);
results.serialize_element(&json!({
"ruleId": diagnostic.code,
"ruleIndex": self.rules.index(&diagnostic.code),
"level": level,
"message": {"text": diagnostic.message},
"locations": [{"physicalLocation": {
"artifactLocation": location.clone(),
"region": {"startLine": line, "startColumn": column,
"endLine": end_line, "endColumn": end_column}
}}]
}))?;
}
}
for item in self.skipped {
let (id, level) = if item.error {
("io-error", "error")
} else {
("skipped-file", "note")
};
results.serialize_element(&json!({
"ruleId": id,
"ruleIndex": self.rules.index(id),
"level": level,
"message": {"text": item.reason},
"locations": [{"physicalLocation": {
"artifactLocation": artifact_location(&item.path)
}}]
}))?;
}
results.end()
}
}
fn render_sarif(
output: &mut impl Write,
files: &[ProcessedFile],
skipped: &[SkippedFile],
) -> Result<()> {
let mut rules = SarifRules::new();
for file in files {
for diagnostic in &file.result.report.diagnostics {
rules.describe(
&diagnostic.code,
sarif_level(diagnostic.severity),
&sentence_case(&diagnostic.code),
DIAGNOSTIC_DESCRIPTION,
TOOL_INFORMATION_URI,
);
}
}
for item in skipped {
let (id, level, short, full) = if item.error {
(
"io-error",
"error",
"File could not be read",
"A file OComment could not read or write; the message on the result carries the operating-system error.",
)
} else {
(
"skipped-file",
"note",
"Skipped file",
"A file OComment did not scan; the message on the result says why it was left alone.",
)
};
rules.describe(id, level, short, full, TOOL_INFORMATION_URI);
}
#[derive(Serialize)]
struct Document<'a> {
version: &'static str,
#[serde(rename = "$schema")]
schema: &'static str,
runs: &'a [Run<'a>],
}
#[derive(Serialize)]
struct Run<'a> {
tool: Tool<'a>,
results: SarifResults<'a>,
}
#[derive(Serialize)]
struct Tool<'a> {
driver: Driver<'a>,
}
#[derive(Serialize)]
struct Driver<'a> {
name: &'static str,
version: &'static str,
#[serde(rename = "informationUri")]
information_uri: &'static str,
rules: &'a [Value],
}
let runs = [Run {
tool: Tool {
driver: Driver {
name: "ocomment",
version: env!("CARGO_PKG_VERSION"),
information_uri: TOOL_INFORMATION_URI,
rules: &rules.entries,
},
},
results: SarifResults {
files,
skipped,
rules: &rules,
},
}];
serde_json::to_writer_pretty(
&mut *output,
&Document {
version: "2.1.0",
schema: "https://json.schemastore.org/sarif-2.1.0.json",
runs: &runs,
},
)
.map_err(write_error)?;
wrote(writeln!(output))?;
Ok(())
}
fn fix_for_span(file: &ProcessedFile, span: ByteSpan) -> (ByteSpan, String) {
file.result
.edits
.iter()
.find(|edit| edit.span.start <= span.start && edit.span.end >= span.end)
.map_or_else(
|| (span, String::new()),
|edit| {
(
edit.span,
String::from_utf8_lossy(&edit.replacement).into_owned(),
)
},
)
}
fn render_github(
output: &mut impl Write,
files: &[ProcessedFile],
skipped: &[SkippedFile],
verbosity: Verbosity,
) -> Result<()> {
for file in files {
if file.result.report.diagnostics.is_empty()
&& !file
.result
.report
.comments
.iter()
.any(|comment| comment.disposition.is_remove())
{
continue;
}
let lines = LineIndex::new(&file.source);
for comment in file
.result
.report
.comments
.iter()
.filter(|comment| comment.disposition.is_remove())
{
let (line, column) = lines.line_column(comment.span.start);
wrote(writeln!(
output,
"::notice file={},line={line},col={column}::{}",
github_path(&file.path),
removable_label(comment.kind)
))?;
}
for diagnostic in &file.result.report.diagnostics {
let (line, column) = lines.line_column(diagnostic.span.start);
wrote(writeln!(
output,
"::error file={},line={line},col={column},title={}::{}",
github_path(&file.path),
github_escape(&diagnostic.code),
github_escape(&diagnostic.message)
))?;
}
}
let visibility = match verbosity {
Verbosity::Quiet => Verbosity::Normal,
loud => loud,
};
for item in skipped
.iter()
.filter(|item| skip_is_visible(item, visibility))
{
wrote(writeln!(
output,
"::{} file={},title={}::{}",
if item.error { "error" } else { "notice" },
github_path(&item.path),
if item.error {
"OComment I/O error"
} else {
"OComment skipped file"
},
github_escape(&item.reason)
))?;
}
Ok(())
}
pub fn unified_diff(path: &Path, original: &[u8], transformed: &[u8]) -> Vec<u8> {
let old = byte_lines(original);
let new = byte_lines(transformed);
let mut output = Vec::new();
output.extend_from_slice(b"--- ");
output.extend_from_slice(&git_patch_path(b"a/", path));
output.extend_from_slice(b"\n+++ ");
output.extend_from_slice(&git_patch_path(b"b/", path));
output.push(b'\n');
let ops = capture_diff_slices(Algorithm::Myers, &old, &new);
for group in group_diff_ops(ops, 3) {
let old_start = group.first().map_or(0, |op| op.old_range().start) + 1;
let new_start = group.first().map_or(0, |op| op.new_range().start) + 1;
let old_len: usize = group.iter().map(|op| op.old_range().len()).sum();
let new_len: usize = group.iter().map(|op| op.new_range().len()).sum();
output.extend_from_slice(
format!("@@ -{old_start},{old_len} +{new_start},{new_len} @@\n").as_bytes(),
);
for op in group {
for change in op.iter_changes(&old, &new) {
let prefix = match change.tag() {
ChangeTag::Delete => b'-',
ChangeTag::Insert => b'+',
ChangeTag::Equal => b' ',
};
output.push(prefix);
output.extend_from_slice(change.value());
if !change.value().ends_with(b"\n") {
output.extend_from_slice(b"\n\\ No newline at end of file\n");
}
}
}
}
output
}
fn byte_lines(bytes: &[u8]) -> Vec<&[u8]> {
let mut lines = Vec::new();
let mut start = 0;
for (index, byte) in bytes.iter().enumerate() {
if *byte == b'\n' {
lines.push(&bytes[start..=index]);
start = index + 1;
}
}
if start < bytes.len() {
lines.push(&bytes[start..]);
}
lines
}
fn git_patch_path(prefix: &[u8], path: &Path) -> Vec<u8> {
#[cfg(unix)]
let bytes = {
use std::os::unix::ffi::OsStrExt;
path.as_os_str().as_bytes().to_vec()
};
#[cfg(not(unix))]
let bytes = path.to_string_lossy().replace('\\', "/").into_bytes();
let mut full = Vec::with_capacity(prefix.len() + bytes.len());
full.extend_from_slice(prefix);
full.extend_from_slice(&bytes);
let quoted = full
.iter()
.any(|byte| *byte < b' ' || *byte >= 0x7f || matches!(*byte, b'"' | b'\\'));
if !quoted {
return full;
}
let mut output = Vec::with_capacity(full.len() + 2);
output.push(b'"');
for byte in full {
match byte {
b'\n' => output.extend_from_slice(br"\n"),
b'\r' => output.extend_from_slice(br"\r"),
b'\t' => output.extend_from_slice(br"\t"),
0x08 => output.extend_from_slice(br"\b"),
0x0c => output.extend_from_slice(br"\f"),
b'"' => output.extend_from_slice(br#"\""#),
b'\\' => output.extend_from_slice(br"\\"),
b' '..=b'~' => output.push(byte),
_ => output.extend_from_slice(format!("\\{byte:03o}").as_bytes()),
}
}
output.push(b'"');
output
}
#[derive(Clone, Debug, Default)]
pub(crate) struct LineIndex {
breaks: Vec<usize>,
source_len: usize,
}
impl LineIndex {
pub(crate) fn new(source: &[u8]) -> Self {
let mut breaks = Vec::new();
let mut index = 0usize;
while index < source.len() {
if source[index] == b'\r' {
let after_first = index + 1;
let crlf = source.get(index + 1) == Some(&b'\n');
breaks.push((after_first << 1) | usize::from(crlf));
index = after_first + usize::from(crlf);
} else if source[index] == b'\n' {
index += 1;
breaks.push(index << 1);
} else {
index += 1;
}
}
Self {
breaks,
source_len: source.len(),
}
}
pub(crate) fn line_column(&self, offset: usize) -> (usize, usize) {
let offset = offset.min(self.source_len);
let line_breaks = self
.breaks
.partition_point(|line_break| (*line_break >> 1) <= offset);
let start = line_breaks.checked_sub(1).map_or(0, |index| {
let encoded = self.breaks[index];
((encoded >> 1) + (encoded & 1)).min(offset)
});
(line_breaks + 1, offset - start + 1)
}
}
fn github_escape(text: &str) -> String {
let mut escaped = String::with_capacity(text.len());
for character in text.chars() {
match character {
'%' => escaped.push_str("%25"),
'\r' => escaped.push_str("%0D"),
'\n' => escaped.push_str("%0A"),
':' => escaped.push_str("%3A"),
',' => escaped.push_str("%2C"),
character if is_control(character) => {
for byte in character.to_string().bytes() {
push_percent_encoded(&mut escaped, byte);
}
}
character => escaped.push(character),
}
}
escaped
}
pub fn changed(files: &[ProcessedFile]) -> bool {
files.iter().any(|file| file.result.changed())
}
pub fn invalid(files: &[ProcessedFile]) -> bool {
files.iter().any(|file| !file.result.report.valid)
}
#[allow(dead_code)]
fn _span(_: ByteSpan) -> Value {
Value::Null
}
#[cfg(test)]
mod tests {
use super::*;
fn linear_line_column(source: &[u8], offset: usize) -> (usize, usize) {
let offset = offset.min(source.len());
let mut line = 1usize;
let mut start = 0usize;
let mut index = 0usize;
while index < offset {
if source[index] == b'\r' {
index += if source.get(index + 1) == Some(&b'\n') && index + 1 < offset {
2
} else {
1
};
line += 1;
start = index;
} else if source[index] == b'\n' {
index += 1;
line += 1;
start = index;
} else {
index += 1;
}
}
(line, offset - start + 1)
}
#[test]
fn line_index_matches_the_previous_walk_at_every_offset() {
let alphabet = *b"x\r\n";
for length in 0..=7usize {
let variants = 3usize.pow(length as u32);
for mut variant in 0..variants {
let mut source = Vec::with_capacity(length);
for _ in 0..length {
source.push(alphabet[variant % alphabet.len()]);
variant /= alphabet.len();
}
let lines = LineIndex::new(&source);
for offset in 0..=source.len() + 2 {
assert_eq!(
lines.line_column(offset),
linear_line_column(&source, offset),
"source={source:?}, offset={offset}"
);
}
}
}
}
#[test]
fn a_hyperlink_target_encodes_every_byte_a_url_may_not_carry() {
assert_eq!(
percent_encode("/tmp/plain-file_name.rs~"),
"/tmp/plain-file_name.rs~"
);
assert_eq!(percent_encode("/tmp/a b#c%d.rs"), "/tmp/a%20b%23c%25d.rs");
assert_eq!(
percent_encode("/tmp/evil\u{1b}[2Jname.rs"),
"/tmp/evil%1B%5B2Jname.rs"
);
assert_eq!(percent_encode("/tmp/\u{e9}.rs"), "/tmp/%C3%A9.rs");
}
#[test]
fn a_sanitized_path_keeps_its_spacing_and_loses_its_controls() {
assert_eq!(sanitize_path(" lead.rs "), " lead.rs ");
assert_eq!(sanitize_path("ta\tb.rs"), "ta\u{fffd}b.rs");
assert_eq!(sanitize_path("two spaces.rs"), "two spaces.rs");
assert_eq!(sanitize_path("a\nb\u{1b}c.rs"), "a\u{fffd}b\u{fffd}c.rs");
}
#[test]
fn report_path_spells_a_path_the_way_a_repository_does() {
assert_eq!(report_path(Path::new("./a.rs")), "a.rs");
assert_eq!(report_path(Path::new("sub/./doc.rs")), "sub/doc.rs");
assert_eq!(report_path(Path::new("./sub/./doc.rs")), "sub/doc.rs");
#[cfg(windows)]
{
assert_eq!(report_path(Path::new(r"sub\doc.rs")), "sub/doc.rs");
assert_eq!(report_path(Path::new(r".\sub\.\doc.rs")), "sub/doc.rs");
}
#[cfg(unix)]
{
assert_eq!(report_path(Path::new(r"sub\doc.rs")), r"sub\doc.rs");
assert_eq!(sarif_uri(Path::new(r"sub\doc.rs")), "sub%5Cdoc.rs");
}
assert_eq!(report_path(Path::new("../sibling/a.rs")), "../sibling/a.rs");
assert_eq!(report_path(Path::new("/tmp/a.rs")), "/tmp/a.rs");
assert_eq!(report_path(Path::new(STDIN_PATH)), STDIN_PATH);
assert_eq!(report_path(Path::new(".")), ".");
}
#[cfg(unix)]
#[test]
fn machine_path_encoders_preserve_raw_unix_bytes_without_sharing_syntax() {
use std::{ffi::OsString, os::unix::ffi::OsStringExt};
let path = PathBuf::from(OsString::from_vec(b"odd \xff,\n\x1b.rs".to_vec()));
assert_eq!(sarif_uri(&path), "odd%20%FF%2C%0A%1B.rs");
assert_eq!(github_path(&path), "odd %FF%2C%0A%1B.rs");
assert!(!sarif_uri(&path).contains('\u{fffd}'));
assert!(!github_path(&path).contains('\u{fffd}'));
let raw_invalid = PathBuf::from(OsString::from_vec(b"odd \xff.rs".to_vec()));
let literal_percent = Path::new("odd %FF.rs");
assert_eq!(github_path(&raw_invalid), "odd %FF.rs");
assert_eq!(github_path(literal_percent), "odd %25FF.rs");
assert_ne!(github_path(literal_percent), github_path(&raw_invalid));
}
#[test]
fn only_a_path_inside_the_tree_is_reported_against_the_source_root() {
for inside in ["a.rs", "sub/doc.rs", "./sub/doc.rs"] {
assert_eq!(
artifact_location(Path::new(inside))["uriBaseId"],
json!(SRCROOT),
"`{inside}` is not reported against the source root"
);
}
for outside in ["../sibling/a.rs", "/tmp/a.rs", STDIN_PATH] {
let location = artifact_location(Path::new(outside));
assert_eq!(
location.get("uriBaseId"),
None,
"`{outside}` claims to be under the source root"
);
}
}
#[test]
fn a_first_segment_that_reads_as_a_drive_letter_is_disambiguated() {
let location = artifact_location(Path::new("c:/a.rs"));
assert_eq!(location["uri"], json!("./c:/a.rs"));
assert_eq!(location["uriBaseId"], json!(SRCROOT));
assert_eq!(artifact_location(Path::new("c:"))["uri"], json!("./c:"));
for plain in ["a.rs", "sub/doc.rs", "cc:/a.rs", "sub/c:/a.rs"] {
assert_eq!(
artifact_location(Path::new(plain))["uri"],
json!(sarif_uri(Path::new(plain))),
"`{plain}` was disambiguated and had no need of it"
);
}
assert_eq!(
artifact_location(Path::new("/tmp/c:/a.rs"))["uri"],
json!("/tmp/c:/a.rs"),
"a path under no base was rewritten"
);
}
#[test]
fn a_rule_is_described_once_and_keeps_its_index() {
let mut rules = SarifRules::new();
assert_eq!(rules.entries.len(), CommentKind::ALL.len());
assert_eq!(rules.kind(CommentKind::Line), 0);
let first = rules.describe("io-error", "error", "short", "full", TOOL_INFORMATION_URI);
assert_eq!(first, CommentKind::ALL.len());
let again = rules.describe("io-error", "note", "other", "other", TOOL_INFORMATION_URI);
assert_eq!(first, again, "a second sighting described the rule twice");
assert_eq!(
rules.entries[first]["defaultConfiguration"]["level"],
"error"
);
assert_eq!(rules.entries.len(), CommentKind::ALL.len() + 1);
}
#[test]
fn a_diagnostic_code_reads_back_as_a_title() {
assert_eq!(
sentence_case("unterminated-comment"),
"Unterminated comment"
);
assert_eq!(sentence_case("nesting-limit"), "Nesting limit");
assert_eq!(sentence_case(""), "");
}
fn preview_of(source: &[u8], max_columns: usize) -> String {
preview(source, ByteSpan::new(0, source.len()), max_columns)
}
#[test]
fn preview_collapses_every_run_of_whitespace_and_trims() {
assert_eq!(
preview_of(b" /*\r\n\tkeep\t this tidy \x0c*/ ", 72),
"/* keep this tidy */"
);
}
#[test]
fn preview_truncates_on_display_width_without_splitting_a_wide_character() {
let source = "ab漢字漢字漢字ab".as_bytes();
assert_eq!(preview_of(source, 20), "ab漢字漢字漢字ab");
let cut = preview_of(source, 10);
assert_eq!(cut, "ab漢字漢…");
assert!(cut.ends_with('…'), "truncation is unmarked: {cut}");
let width: usize = cut
.chars()
.map(|ch| unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0))
.sum();
assert!(width <= 10, "`{cut}` is {width} columns wide");
}
#[test]
fn preview_replaces_control_characters_with_the_replacement_character() {
let source = b"// \x1b[31m\x07 \xc2\x9b\x7f bell";
let rendered = preview_of(source, 72);
assert_eq!(rendered, "// \u{fffd}[31m\u{fffd} \u{fffd}\u{fffd} bell");
assert!(
!rendered.contains('\x1b'),
"an escape sequence survived: {rendered:?}"
);
}
#[test]
fn preview_replaces_invalid_utf8_bytes() {
assert_eq!(
preview_of(b"// \xff\xfe end", 72),
"// \u{fffd}\u{fffd} end"
);
}
#[test]
fn preview_replaces_bidirectional_and_separator_controls() {
let source = "// \u{202e}reverse\u{202c} \u{200e}\u{200f} \u{2066}iso\u{2069} \
\u{2028}\u{2029} \u{61c}\u{feff} end";
assert_eq!(
preview_of(source.as_bytes(), 72),
"// \u{fffd}reverse\u{fffd} \u{fffd}\u{fffd} \u{fffd}iso\u{fffd} \
\u{fffd}\u{fffd} \u{fffd}\u{fffd} end"
);
for character in [
'\u{61c}', '\u{200e}', '\u{200f}', '\u{202a}', '\u{202b}', '\u{202c}', '\u{202d}',
'\u{202e}', '\u{2066}', '\u{2067}', '\u{2068}', '\u{2069}', '\u{2028}', '\u{2029}',
'\u{feff}',
] {
assert!(
is_control(character),
"U+{:04X} still reaches the terminal",
character as u32
);
}
}
#[test]
fn preview_caps_the_character_count_of_a_zero_width_run() {
let source = format!("a{}", "\u{301}".repeat(1000));
let rendered = preview_of(source.as_bytes(), 8);
assert!(
rendered.chars().count() <= 8 * 4,
"preview is {} characters wide",
rendered.chars().count()
);
assert!(rendered.ends_with('\u{2026}'), "truncation is unmarked");
}
#[test]
fn a_source_line_keeps_its_shape_and_loses_its_control_characters() {
assert_eq!(
sanitize_source_line(" let x = 1; // note"),
" let x = 1; // note",
"the indentation of a shown line was collapsed"
);
assert_eq!(
sanitize_source_line("\tif (x) {"),
" if (x) {",
"a tab did not reach its eight-column stop"
);
assert_eq!(
sanitize_source_line("a\u{1b}[2Jb\u{202e}c"),
"a\u{fffd}[2Jb\u{fffd}c",
"an escape sequence reached the terminal verbatim"
);
let capped = sanitize_source_line(&"v".repeat(PREVIEW_COLUMNS * 3));
let width: usize = capped
.chars()
.map(|ch| unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0))
.sum();
assert!(
width <= PREVIEW_COLUMNS,
"a shown line ran to {width} columns and pushed the question off the screen"
);
}
#[test]
fn the_interactive_summary_pluralizes_both_of_its_nouns() {
assert_eq!(
interactive_summary(InteractiveOutcome {
removed: 1,
reviewed: 1,
offered: 1,
changed: 1,
scanned: 1,
}),
"Removed 1 of 1 comment in 1 file (1 file scanned)."
);
assert_eq!(
interactive_summary(InteractiveOutcome {
removed: 2,
reviewed: 5,
offered: 5,
changed: 3,
scanned: 4,
}),
"Removed 2 of 5 comments in 3 files (4 files scanned)."
);
}
#[test]
fn an_interactive_run_with_nothing_to_offer_borrows_the_fix_wording() {
assert_eq!(
interactive_summary(InteractiveOutcome {
scanned: 3,
..InteractiveOutcome::default()
}),
"Nothing to fix in 3 files."
);
assert_eq!(
interactive_summary(InteractiveOutcome {
scanned: 1,
..InteractiveOutcome::default()
}),
"Nothing to fix in 1 file."
);
}
#[test]
fn a_stopped_interactive_run_counts_the_questions_it_asked() {
assert_eq!(
interactive_summary(InteractiveOutcome {
removed: 1,
reviewed: 2,
offered: 9,
changed: 1,
scanned: 4,
}),
"Removed 1 of 2 comments in 1 file (7 comments not reviewed) (4 files scanned)."
);
assert_eq!(
interactive_summary(InteractiveOutcome {
removed: 0,
reviewed: 1,
offered: 2,
changed: 0,
scanned: 1,
}),
"Removed 0 of 1 comment in 0 files (1 comment not reviewed) (1 file scanned)."
);
}
#[test]
fn sanitize_line_replaces_controls_and_caps_the_width() {
assert_eq!(
sanitize_line("\u{1b}[2J\u{1b}[1;31mv1.0\tPWNED\u{1b}[0m"),
"\u{fffd}[2J\u{fffd}[1;31mv1.0 PWNED\u{fffd}[0m"
);
let capped = sanitize_line(&"v".repeat(PREVIEW_COLUMNS * 3));
let width: usize = capped
.chars()
.map(|ch| unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0))
.sum();
assert!(
width <= PREVIEW_COLUMNS,
"`{capped}` is {width} columns wide"
);
assert!(capped.ends_with('\u{2026}'), "truncation is unmarked");
}
#[test]
fn preview_reads_only_the_span() {
let source = b"let x = 1; // TODO remove\n";
assert_eq!(preview(source, ByteSpan::new(11, 25), 72), "// TODO remove");
}
}