use oxc_diagnostics::OxcDiagnostic;
use oxc_span::Span;
use serde::Serialize;
use vize_carton::CompactString;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
Error,
Warning,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum HelpLevel {
None,
Short,
#[default]
Full,
}
impl HelpLevel {
pub fn process(&self, help: &str) -> Option<String> {
match self {
HelpLevel::None => None,
HelpLevel::Short => Some(strip_markdown_first_line(help)),
HelpLevel::Full => Some(help.to_string()),
}
}
}
fn strip_markdown_first_line(text: &str) -> String {
let mut in_code_block = false;
for line in text.lines() {
let trimmed = line.trim();
if trimmed.starts_with("```") {
in_code_block = !in_code_block;
continue;
}
if in_code_block {
continue;
}
if trimmed.is_empty() {
continue;
}
let stripped = trimmed.replace("**", "").replace("__", "").replace('`', "");
let stripped = stripped.trim_start_matches('#').trim();
if stripped.is_empty() {
continue;
}
return stripped.to_string();
}
text.lines().next().unwrap_or(text).to_string()
}
const ANSI_BOLD: &str = "\x1b[1m";
const ANSI_BOLD_OFF: &str = "\x1b[22m";
const ANSI_UNDERLINE: &str = "\x1b[4m";
const ANSI_UNDERLINE_OFF: &str = "\x1b[24m";
const ANSI_CYAN: &str = "\x1b[36m";
const ANSI_CYAN_OFF: &str = "\x1b[39m";
const ANSI_DIM: &str = "\x1b[2m";
const ANSI_DIM_OFF: &str = "\x1b[22m";
fn render_markdown_to_ansi(text: &str) -> String {
let mut result = String::with_capacity(text.len() + 64);
let mut in_code_block = false;
for line in text.lines() {
let trimmed = line.trim();
if trimmed.starts_with("```") {
in_code_block = !in_code_block;
continue;
}
if !result.is_empty() {
result.push('\n');
}
if in_code_block {
result.push_str(ANSI_DIM);
result.push_str(" ");
result.push_str(line);
result.push_str(ANSI_DIM_OFF);
continue;
}
if let Some(header_content) = trimmed.strip_prefix("# ") {
result.push_str(ANSI_BOLD);
result.push_str(ANSI_UNDERLINE);
result.push_str(header_content);
result.push_str(ANSI_UNDERLINE_OFF);
result.push_str(ANSI_BOLD_OFF);
continue;
}
if let Some(header_content) = trimmed.strip_prefix("## ") {
result.push_str(ANSI_BOLD);
result.push_str(ANSI_UNDERLINE);
result.push_str(header_content);
result.push_str(ANSI_UNDERLINE_OFF);
result.push_str(ANSI_BOLD_OFF);
continue;
}
if let Some(header_content) = trimmed.strip_prefix("### ") {
result.push_str(ANSI_BOLD);
result.push_str(header_content);
result.push_str(ANSI_BOLD_OFF);
continue;
}
render_inline_markdown(&mut result, line);
}
result
}
fn render_inline_markdown(out: &mut String, line: &str) {
let bytes = line.as_bytes();
let len = bytes.len();
let mut i = 0;
while i < len {
if bytes[i] == b'`' {
if let Some(end) = find_closing_backtick(bytes, i + 1) {
out.push_str(ANSI_CYAN);
out.push_str(&line[i + 1..end]);
out.push_str(ANSI_CYAN_OFF);
i = end + 1;
continue;
}
}
if i + 1 < len && bytes[i] == b'*' && bytes[i + 1] == b'*' {
if let Some(end) = find_closing_double(bytes, i + 2, b'*') {
out.push_str(ANSI_BOLD);
render_inline_markdown(out, &line[i + 2..end]);
out.push_str(ANSI_BOLD_OFF);
i = end + 2;
continue;
}
}
if i + 1 < len && bytes[i] == b'_' && bytes[i + 1] == b'_' {
if let Some(end) = find_closing_double(bytes, i + 2, b'_') {
out.push_str(ANSI_BOLD);
render_inline_markdown(out, &line[i + 2..end]);
out.push_str(ANSI_BOLD_OFF);
i = end + 2;
continue;
}
}
out.push(bytes[i] as char);
i += 1;
}
}
fn find_closing_backtick(bytes: &[u8], start: usize) -> Option<usize> {
(start..bytes.len()).find(|&i| bytes[i] == b'`')
}
fn find_closing_double(bytes: &[u8], start: usize, ch: u8) -> Option<usize> {
let mut i = start;
while i + 1 < bytes.len() {
if bytes[i] == ch && bytes[i + 1] == ch {
return Some(i);
}
i += 1;
}
None
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HelpRenderTarget {
Ansi,
PlainText,
Markdown,
}
pub fn render_help(markdown: &str, target: HelpRenderTarget) -> String {
match target {
HelpRenderTarget::Ansi => render_markdown_to_ansi(markdown),
HelpRenderTarget::PlainText => strip_markdown(markdown),
HelpRenderTarget::Markdown => markdown.to_string(),
}
}
fn strip_markdown(text: &str) -> String {
let mut result = String::with_capacity(text.len());
let mut in_code_block = false;
for line in text.lines() {
let trimmed = line.trim();
if trimmed.starts_with("```") {
in_code_block = !in_code_block;
continue;
}
if in_code_block {
if !result.is_empty() {
result.push('\n');
}
result.push_str(" ");
result.push_str(trimmed);
continue;
}
if trimmed.is_empty() {
if !result.is_empty() && !result.ends_with('\n') {
result.push('\n');
}
continue;
}
if !result.is_empty() && !result.ends_with('\n') {
result.push('\n');
}
let content = trimmed
.strip_prefix("### ")
.or_else(|| trimmed.strip_prefix("## "))
.or_else(|| trimmed.strip_prefix("# "))
.unwrap_or(trimmed);
let content = content.replace("**", "").replace("__", "").replace('`', "");
result.push_str(content.trim());
}
let trimmed = result.trim_end();
trimmed.to_string()
}
#[derive(Debug, Clone, Serialize)]
pub struct TextEdit {
pub start: u32,
pub end: u32,
pub new_text: String,
}
impl TextEdit {
#[inline]
pub fn new(start: u32, end: u32, new_text: impl Into<String>) -> Self {
Self {
start,
end,
new_text: new_text.into(),
}
}
#[inline]
pub fn insert(offset: u32, text: impl Into<String>) -> Self {
Self::new(offset, offset, text)
}
#[inline]
pub fn delete(start: u32, end: u32) -> Self {
Self::new(start, end, "")
}
#[inline]
pub fn replace(start: u32, end: u32, text: impl Into<String>) -> Self {
Self::new(start, end, text)
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Fix {
pub message: String,
pub edits: Vec<TextEdit>,
}
impl Fix {
#[inline]
pub fn new(message: impl Into<String>, edit: TextEdit) -> Self {
Self {
message: message.into(),
edits: vec![edit],
}
}
#[inline]
pub fn with_edits(message: impl Into<String>, edits: Vec<TextEdit>) -> Self {
Self {
message: message.into(),
edits,
}
}
#[inline]
pub fn apply(&self, source: &str) -> String {
let mut result = source.to_string();
let mut edits = self.edits.clone();
edits.sort_by(|a, b| b.start.cmp(&a.start));
for edit in edits {
let start = edit.start as usize;
let end = edit.end as usize;
if start <= result.len() && end <= result.len() {
result.replace_range(start..end, &edit.new_text);
}
}
result
}
}
#[derive(Debug, Clone)]
pub struct LintDiagnostic {
pub rule_name: &'static str,
pub severity: Severity,
pub message: CompactString,
pub start: u32,
pub end: u32,
pub help: Option<CompactString>,
pub labels: Vec<Label>,
pub fix: Option<Fix>,
}
#[derive(Debug, Clone)]
pub struct Label {
pub message: CompactString,
pub start: u32,
pub end: u32,
}
impl LintDiagnostic {
#[inline]
pub fn error(
rule_name: &'static str,
message: impl Into<CompactString>,
start: u32,
end: u32,
) -> Self {
Self {
rule_name,
severity: Severity::Error,
message: message.into(),
start,
end,
help: None,
labels: Vec::new(),
fix: None,
}
}
#[inline]
pub fn warn(
rule_name: &'static str,
message: impl Into<CompactString>,
start: u32,
end: u32,
) -> Self {
Self {
rule_name,
severity: Severity::Warning,
message: message.into(),
start,
end,
help: None,
labels: Vec::new(),
fix: None,
}
}
#[inline]
pub fn with_help(mut self, help: impl Into<CompactString>) -> Self {
self.help = Some(help.into());
self
}
#[inline]
pub fn with_label(mut self, message: impl Into<CompactString>, start: u32, end: u32) -> Self {
self.labels.push(Label {
message: message.into(),
start,
end,
});
self
}
#[inline]
pub fn with_fix(mut self, fix: Fix) -> Self {
self.fix = Some(fix);
self
}
#[inline]
pub fn has_fix(&self) -> bool {
self.fix.is_some()
}
#[inline]
pub fn formatted_message(&self) -> String {
format!("[vize:{}] {}", self.rule_name, self.message)
}
#[inline]
pub fn into_oxc_diagnostic(self) -> OxcDiagnostic {
let formatted_msg = format!("[vize:{}] {}", self.rule_name, self.message);
let mut diag = match self.severity {
Severity::Error => OxcDiagnostic::error(formatted_msg),
Severity::Warning => OxcDiagnostic::warn(formatted_msg),
};
diag = diag.with_label(Span::new(self.start, self.end));
if let Some(help) = self.help {
diag = diag.with_help(render_help(&help, HelpRenderTarget::PlainText));
}
for label in self.labels {
diag =
diag.and_label(Span::new(label.start, label.end).label(label.message.to_string()));
}
diag
}
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct LintSummary {
pub error_count: usize,
pub warning_count: usize,
pub file_count: usize,
}
impl LintSummary {
#[inline]
pub fn add(&mut self, diagnostic: &LintDiagnostic) {
match diagnostic.severity {
Severity::Error => self.error_count += 1,
Severity::Warning => self.warning_count += 1,
}
}
#[inline]
pub fn has_errors(&self) -> bool {
self.error_count > 0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_help_level_full() {
let level = HelpLevel::Full;
let help = "**Why:** Use `:key` for tracking.\n\n```vue\n<li :key=\"id\">\n```";
let result = level.process(help);
assert_eq!(result, Some(help.to_string()));
}
#[test]
fn test_help_level_none() {
let level = HelpLevel::None;
let result = level.process("Any help text");
assert_eq!(result, None);
}
#[test]
fn test_help_level_short_strips_markdown() {
let level = HelpLevel::Short;
let help = "**Why:** The `:key` attribute helps Vue track items.\n\n**Fix:**\n```vue\n<li :key=\"id\">\n```";
let result = level.process(help);
assert_eq!(
result,
Some("Why: The :key attribute helps Vue track items.".to_string())
);
}
#[test]
fn test_help_level_short_skips_code_blocks() {
let level = HelpLevel::Short;
let help = "```vue\n<li :key=\"id\">\n```\nUse unique keys";
let result = level.process(help);
assert_eq!(result, Some("Use unique keys".to_string()));
}
#[test]
fn test_help_level_short_simple_text() {
let level = HelpLevel::Short;
let help = "Add a key attribute to the element";
let result = level.process(help);
assert_eq!(
result,
Some("Add a key attribute to the element".to_string())
);
}
#[test]
fn test_strip_markdown_first_line_with_backticks() {
let result = strip_markdown_first_line("Use `v-model` instead of `{{ }}`");
assert_eq!(result, "Use v-model instead of {{ }}");
}
#[test]
fn test_render_markdown_bold() {
let result = render_markdown_to_ansi("**bold** text");
assert_eq!(result, format!("{ANSI_BOLD}bold{ANSI_BOLD_OFF} text"));
}
#[test]
fn test_render_markdown_inline_code() {
let result = render_markdown_to_ansi("Use `v-model` directive");
assert_eq!(
result,
format!("Use {ANSI_CYAN}v-model{ANSI_CYAN_OFF} directive")
);
}
#[test]
fn test_render_markdown_header() {
let result = render_markdown_to_ansi("# Why");
assert_eq!(
result,
format!("{ANSI_BOLD}{ANSI_UNDERLINE}Why{ANSI_UNDERLINE_OFF}{ANSI_BOLD_OFF}")
);
}
#[test]
fn test_render_markdown_code_block() {
let result = render_markdown_to_ansi("```vue\n<li :key=\"id\">\n```");
assert_eq!(
result,
format!("{ANSI_DIM} <li :key=\"id\">{ANSI_DIM_OFF}")
);
}
#[test]
fn test_render_markdown_plain_text() {
let result = render_markdown_to_ansi("plain text");
assert_eq!(result, "plain text");
}
#[test]
fn test_render_markdown_underscore_bold() {
let result = render_markdown_to_ansi("__bold__ text");
assert_eq!(result, format!("{ANSI_BOLD}bold{ANSI_BOLD_OFF} text"));
}
#[test]
fn test_render_help_ansi() {
let md = "**bold** and `code`";
let result = render_help(md, HelpRenderTarget::Ansi);
assert_eq!(
result,
format!("{ANSI_BOLD}bold{ANSI_BOLD_OFF} and {ANSI_CYAN}code{ANSI_CYAN_OFF}")
);
}
#[test]
fn test_render_help_plain_text() {
let md = "**Why:** Use `:key` for tracking.\n\n```vue\n<li :key=\"id\">\n```";
let result = render_help(md, HelpRenderTarget::PlainText);
assert_eq!(result, "Why: Use :key for tracking.\n\n <li :key=\"id\">");
}
#[test]
fn test_render_help_markdown_passthrough() {
let md = "**bold** and `code`";
let result = render_help(md, HelpRenderTarget::Markdown);
assert_eq!(result, md);
}
#[test]
fn test_strip_markdown_bold_and_code() {
let result = strip_markdown("**bold** and `code`");
assert_eq!(result, "bold and code");
}
#[test]
fn test_strip_markdown_headers() {
let result = strip_markdown("# Title\n## Subtitle\nBody text");
assert_eq!(result, "Title\nSubtitle\nBody text");
}
#[test]
fn test_strip_markdown_code_block() {
let result = strip_markdown("Before\n```vue\n<div>code</div>\n```\nAfter");
assert_eq!(result, "Before\n <div>code</div>\nAfter");
}
#[test]
fn test_strip_markdown_plain_text() {
let result = strip_markdown("plain text");
assert_eq!(result, "plain text");
}
}