#[cfg(test)]
use crate::core::position::Position;
use crate::core::{
diagnostic::Diagnostic,
physical_source::{PhysicalLine, PhysicalSource},
};
use unicode_segmentation::UnicodeSegmentation;
use crate::{
config::Config, document::Document, line_break::LineBreakPlanner,
spacing_checker::SpacingChecker,
};
pub(crate) fn check_one_file(
config: &Config,
document: &Document,
) -> Result<Vec<Diagnostic>, anyhow::Error> {
let mut diagnostics = Vec::new();
let breaker = LineBreakPlanner::builder()
.ambiguous_width(config.ambiguous_width)
.max_width(config.max_width)
.build()?;
let source = PhysicalSource::new(&document.content);
for line in source.lines() {
if let Some(diagnostic) = check_line_length(&breaker, document, &source, line) {
diagnostics.push(diagnostic);
}
}
if let Some(language) = document.language {
let spacing_checker = SpacingChecker::new(config, document, language);
diagnostics.extend(spacing_checker.check(&source)?);
}
Ok(diagnostics)
}
fn check_line_length(
breaker: &LineBreakPlanner,
document: &Document,
source: &PhysicalSource<'_>,
line: PhysicalLine<'_>,
) -> Option<Diagnostic> {
let line_content = line.content();
let overflow_pos = breaker.first_overflow(line_content)?;
let overflow_offset = line.start_offset() + overflow_pos;
let end_offset = overflow_offset
+ line_content[overflow_pos..]
.graphemes(true)
.next()
.map_or(0, str::len);
let start = source.position(overflow_offset);
let end = source.position(end_offset);
Some(Diagnostic::new(
document.filename.as_deref(),
start,
end,
"W001".to_string(),
format!("Line length exceeds {} characters", breaker.max_width()),
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
config::{LineWrapping, SpacingRule},
language::Language,
};
#[test]
fn check_one_file_checks_unparsed_markdown_documents() {
let mut config = Config::default();
config.spacing.alphabets = SpacingRule::Require;
let document = Document::new("漢A", Some(Language::Markdown), None);
let diagnostics = check_one_file(&config, &document).expect("failed to check document");
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].code, "W002");
}
#[test]
fn check_without_a_selected_language_reports_line_length_but_skips_spacing() {
let config = Config {
max_width: 2,
spacing: crate::config::SpacingConfig {
alphabets: SpacingRule::Require,
..crate::config::SpacingConfig::default()
},
..Config::default()
};
let document = Document::new("漢A", None, None);
let diagnostics = check_one_file(&config, &document).unwrap();
assert_eq!(
diagnostics
.iter()
.map(|item| item.code.as_str())
.collect::<Vec<_>>(),
["W001"]
);
}
#[test]
fn diagnostic_positions_are_consistent_across_physical_line_endings() {
let line_length_config = Config {
max_width: 2,
..Config::default()
};
for line_ending in ["\n", "\r\n", "\r"] {
let source = format!("ab{line_ending}abc");
let document = Document::new(&source, None, None);
let diagnostics = check_one_file(&line_length_config, &document).unwrap();
assert_eq!(
diagnostics.len(),
1,
"unexpected diagnostics for {source:?}"
);
assert_eq!(diagnostics[0].code, "W001");
assert_eq!(diagnostics[0].start, Position::new(1, 2));
assert_eq!(diagnostics[0].end, Position::new(1, 3));
}
let mut spacing_config = Config::default();
spacing_config.spacing.alphabets = SpacingRule::Require;
for line_ending in ["\n", "\r\n", "\r"] {
let source = format!("# intro{line_ending}漢A");
let document = Document::new(&source, Some(Language::Markdown), None);
let diagnostics = check_one_file(&spacing_config, &document).unwrap();
assert_eq!(
diagnostics.len(),
1,
"unexpected diagnostics for {source:?}"
);
assert_eq!(diagnostics[0].code, "W002");
assert_eq!(diagnostics[0].start, Position::new(1, 1));
assert_eq!(diagnostics[0].end, Position::new(1, 2));
}
}
#[test]
fn checking_and_formatting_share_physical_interpretation_for_mixed_endings() {
let config = Config {
max_width: 3,
spacing: crate::config::SpacingConfig {
alphabets: SpacingRule::Require,
..crate::config::SpacingConfig::default()
},
..Config::default()
};
let source = "漢A xx\r\n漢A yy\r漢A zz\n漢A qq";
let document = Document::new(source, Some(Language::Markdown), Some("shared.md".into()));
let diagnostics = check_one_file(&config, &document).unwrap();
let ranges = diagnostics
.iter()
.map(|diagnostic| {
(
diagnostic.code.as_str(),
diagnostic.start.clone(),
diagnostic.end.clone(),
)
})
.collect::<Vec<_>>();
assert_eq!(
ranges,
[
("W001", Position::new(0, 2), Position::new(0, 3)),
("W001", Position::new(1, 2), Position::new(1, 3)),
("W001", Position::new(2, 2), Position::new(2, 3)),
("W001", Position::new(3, 2), Position::new(3, 3)),
("W002", Position::new(0, 1), Position::new(0, 2)),
("W002", Position::new(1, 1), Position::new(1, 2)),
("W002", Position::new(2, 1), Position::new(2, 2)),
("W002", Position::new(3, 1), Position::new(3, 2)),
]
);
assert!(diagnostics.iter().all(|diagnostic| {
diagnostic.filename.as_deref() == Some("shared.md")
&& diagnostic.message
== if diagnostic.code == "W001" {
"Line length exceeds 3 characters"
} else {
"Possible spacing position found"
}
}));
let formatted = crate::format::Formatter::new(&config)
.unwrap()
.format(Some(Language::Markdown), source)
.unwrap();
assert_eq!(
formatted,
"漢\r\nA\r\nxx\r\n漢\rA\ryy\r漢\nA\nzz\n漢\nA\nqq"
);
}
#[test]
fn checking_and_formatting_match_the_shared_source_behavior_corpus() {
let config = Config {
max_width: 3,
spacing: crate::config::SpacingConfig {
alphabets: SpacingRule::Require,
..crate::config::SpacingConfig::default()
},
..Config::default()
};
type ExpectedRange = (&'static str, Position, Position);
type CorpusCase = (&'static str, Vec<ExpectedRange>, &'static str);
let cases: &[CorpusCase] = &[
("", vec![], ""),
("\r\n\n\r", vec![], "\r\n\n\r"),
(
"ab cd",
vec![("W001", Position::new(0, 3), Position::new(0, 4))],
"ab\ncd",
),
(
"😀Axx",
vec![("W001", Position::new(0, 3), Position::new(0, 4))],
"😀\nAxx",
),
(
"e\u{301}漢A",
vec![
("W001", Position::new(0, 3), Position::new(0, 4)),
("W002", Position::new(0, 2), Position::new(0, 3)),
("W002", Position::new(0, 3), Position::new(0, 4)),
],
"e\u{301}\n漢\nA",
),
(
"👩👩 bbbb",
vec![("W001", Position::new(0, 6), Position::new(0, 7))],
"👩👩\nbbbb",
),
];
for (source, expected_ranges, expected_output) in cases {
let document = Document::new(*source, Some(Language::Markdown), None);
let diagnostics = check_one_file(&config, &document).unwrap();
let actual_ranges = diagnostics
.iter()
.map(|diagnostic| {
(
diagnostic.code.as_str(),
diagnostic.start.clone(),
diagnostic.end.clone(),
)
})
.collect::<Vec<_>>();
assert_eq!(
actual_ranges, *expected_ranges,
"wrong diagnostics for {source:?}"
);
let formatted = crate::format::Formatter::new(&config)
.unwrap()
.format(Some(Language::Markdown), source)
.unwrap();
assert_eq!(formatted, *expected_output, "wrong output for {source:?}");
}
}
#[test]
fn check_one_file_reports_spacing_columns_from_line_start() {
let mut config = Config::default();
config.spacing.digits = SpacingRule::Require;
let document = Document::new("# 漢1\n", Some(Language::Markdown), Some("t.md".to_owned()));
let diagnostics = check_one_file(&config, &document).expect("failed to check document");
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].code, "W002");
assert_eq!(diagnostics[0].start, Position::new(0, 3));
assert_eq!(diagnostics[0].end, Position::new(0, 4));
}
#[test]
fn check_one_file_reports_spacing_columns_for_a_later_inline_node() {
let mut config = Config::default();
config.spacing.digits = SpacingRule::Require;
let document = Document::new(
"# 見出し\n\n# 漢1\n",
Some(Language::Markdown),
Some("t.md".to_owned()),
);
let diagnostics = check_one_file(&config, &document).expect("failed to check document");
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].code, "W002");
assert_eq!(diagnostics[0].start, Position::new(2, 3));
assert_eq!(diagnostics[0].end, Position::new(2, 4));
}
#[test]
fn check_one_file_reports_prohibited_spacing_without_panicking() {
let mut config = Config::default();
config.spacing.alphabets = SpacingRule::Prohibit;
let document = Document::new(
"# 漢 A\n",
Some(Language::Markdown),
Some("t.md".to_owned()),
);
let diagnostics = check_one_file(&config, &document).expect("failed to check document");
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].code, "W002");
assert_eq!(diagnostics[0].start, Position::new(0, 3));
assert_eq!(diagnostics[0].end, Position::new(0, 4));
}
#[test]
fn check_one_file_reports_spacing_in_each_kind_of_visible_inline_prose() {
let mut config = Config::default();
config.spacing.alphabets = SpacingRule::Require;
for (source, start) in [
("*漢A*", 2),
("~~A漢~~", 3),
("[漢A](destination)", 2),
("", 3),
] {
let document = Document::new(source, Some(Language::Markdown), Some("t.md".to_owned()));
let diagnostics = check_one_file(&config, &document).expect("failed to check document");
assert_eq!(
diagnostics.len(),
1,
"unexpected diagnostics for {source:?}"
);
assert_eq!(diagnostics[0].start, Position::new(0, start));
assert_eq!(diagnostics[0].end, Position::new(0, start + 1));
}
}
#[test]
fn check_one_file_reports_deletion_span_for_the_entire_ascii_space_run() {
let mut config = Config::default();
config.spacing.alphabets = SpacingRule::Prohibit;
let document = Document::new("漢 A", Some(Language::Markdown), Some("t.md".to_owned()));
let diagnostics = check_one_file(&config, &document).expect("failed to check document");
assert_eq!(diagnostics[0].start, Position::new(0, 1));
assert_eq!(diagnostics[0].end, Position::new(0, 3));
}
#[test]
fn check_one_file_excludes_non_prose_and_unsafe_inline_constructs() {
let mut config = Config::default();
config.spacing.alphabets = SpacingRule::Require;
let sources = [
"`漢A`",
"```\n漢A\n```",
"[text](漢A \"漢A\")",
"[text][漢A]",
"<https://example.test/漢A>",
"<foo@example.test>",
"<a href=\"漢A\">",
"&漢",
"\\*漢",
"[漢A](broken",
"`漢A",
];
for source in sources {
let source_with_prose = format!("{source}\n\n漢A");
let document = Document::new(
&source_with_prose,
Some(Language::Markdown),
Some("t.md".to_owned()),
);
let diagnostics = check_one_file(&config, &document)
.expect("failed to check document")
.into_iter()
.filter(|diagnostic| diagnostic.code == "W002")
.collect::<Vec<_>>();
assert_eq!(
diagnostics.len(),
1,
"unexpected diagnostics for {source:?}"
);
let prose_line = source.matches('\n').count() as u32 + 2;
assert_eq!(diagnostics[0].start, Position::new(prose_line, 1));
assert_eq!(diagnostics[0].end, Position::new(prose_line, 2));
}
}
#[test]
fn check_one_file_does_not_report_spacing_for_json_documents() {
let mut config = Config::default();
config.spacing.alphabets = SpacingRule::Require;
let document = Document::new(
"{\"value\":\"漢A\"}",
Some(Language::Json),
Some("t.json".to_owned()),
);
let diagnostics = check_one_file(&config, &document).expect("failed to check document");
assert!(
diagnostics
.iter()
.all(|diagnostic| diagnostic.code != "W002")
);
}
#[test]
fn w002_reports_missing_spacing_in_markdown_prose() {
let mut config = Config::default();
config.spacing.alphabets = SpacingRule::Require;
let source = "漢A and `漢A`";
let document = Document::new(source, Some(Language::Markdown), Some("t.md".to_owned()));
let diagnostics = check_one_file(&config, &document).unwrap();
let formatted = crate::format::Formatter::new(&config)
.unwrap()
.format(Some(crate::language::Language::Markdown), source)
.unwrap();
assert_eq!(formatted, "漢 A and `漢A`");
assert_eq!(
diagnostics
.iter()
.filter(|item| item.code == "W002")
.count(),
1
);
}
#[test]
fn w002_does_not_report_spacing_in_json_strings() {
let mut config = Config::default();
config.spacing.alphabets = SpacingRule::Require;
let source = r#"{"value":"漢A"}"#;
let document = Document::new(source, Some(Language::Json), Some("t.json".to_owned()));
let diagnostics = check_one_file(&config, &document).unwrap();
let formatted = crate::format::Formatter::new(&config)
.unwrap()
.format(Some(crate::language::Language::Json), source)
.unwrap();
assert_eq!(formatted, source);
assert!(diagnostics.iter().all(|item| item.code != "W002"));
}
#[test]
fn w002_does_not_report_spacing_in_malformed_json() {
let mut config = Config::default();
config.spacing.alphabets = SpacingRule::Require;
let source = r#"{"value":"漢A""#;
let document = Document::new(source, Some(Language::Json), Some("t.json".to_owned()));
let diagnostics = check_one_file(&config, &document).unwrap();
let formatted = crate::format::Formatter::new(&config)
.unwrap()
.format(Some(crate::language::Language::Json), source)
.unwrap();
assert_eq!(formatted, source);
assert!(diagnostics.iter().all(|item| item.code != "W002"));
}
#[test]
fn check_diagnostics_are_identical_in_preserve_and_split_modes() {
let source = "漢A `very-long-code-span`";
let document = Document::new(
source,
Some(Language::Markdown),
Some("protected.md".to_owned()),
);
let expected = [
Diagnostic::new(
Some("protected.md"),
Position::new(0, 4),
Position::new(0, 5),
"W001".to_owned(),
"Line length exceeds 5 characters".to_owned(),
),
Diagnostic::new(
Some("protected.md"),
Position::new(0, 1),
Position::new(0, 2),
"W002".to_owned(),
"Possible spacing position found".to_owned(),
),
];
for line_wrapping in [LineWrapping::Preserve, LineWrapping::Split] {
let config = Config {
max_width: 5,
line_wrapping,
spacing: crate::config::SpacingConfig {
alphabets: SpacingRule::Require,
..crate::config::SpacingConfig::default()
},
..Config::default()
};
assert_eq!(check_one_file(&config, &document).unwrap(), expected);
}
}
#[test]
fn check_reports_w001_for_protected_markdown_and_json_in_both_modes() {
let cases = [
(
"`very-long-code-span`",
Language::Markdown,
"protected.md",
Position::new(0, 5),
Position::new(0, 6),
),
(
"\"very long JSON string\"",
Language::Json,
"protected.json",
Position::new(0, 5),
Position::new(0, 6),
),
];
for (source, language, filename, start, end) in cases {
let document = Document::new(source, Some(language), Some(filename.to_owned()));
let expected = [Diagnostic::new(
Some(filename),
start,
end,
"W001".to_owned(),
"Line length exceeds 5 characters".to_owned(),
)];
for line_wrapping in [LineWrapping::Preserve, LineWrapping::Split] {
let config = Config {
max_width: 5,
line_wrapping,
..Config::default()
};
assert_eq!(check_one_file(&config, &document).unwrap(), expected);
}
}
}
#[test]
fn preserve_spacing_can_create_w001_without_causing_format_failure() {
let config = Config {
max_width: 3,
line_wrapping: LineWrapping::Preserve,
spacing: crate::config::SpacingConfig {
alphabets: SpacingRule::Require,
..crate::config::SpacingConfig::default()
},
..Config::default()
};
let document = Document::new("漢A", Some(Language::Markdown), None);
let original_diagnostics = check_one_file(&config, &document).unwrap();
let formatted = crate::format::Formatter::new(&config)
.unwrap()
.format(Some(Language::Markdown), &document.content)
.unwrap();
let formatted_diagnostics = check_one_file(
&config,
&Document::new(&formatted, Some(Language::Markdown), None),
)
.unwrap();
assert_eq!(formatted, "漢 A");
assert_eq!(
original_diagnostics,
[Diagnostic::new(
None::<String>,
Position::new(0, 1),
Position::new(0, 2),
"W002".to_owned(),
"Possible spacing position found".to_owned(),
)]
);
assert_eq!(
formatted_diagnostics,
[Diagnostic::new(
None::<String>,
Position::new(0, 2),
Position::new(0, 3),
"W001".to_owned(),
"Line length exceeds 3 characters".to_owned(),
)]
);
}
#[test]
fn w001_reports_overlong_indivisible_source_regardless_of_language_policy() {
let config = Config {
max_width: 5,
..Config::default()
};
for (source, grammar, filename) in [
("`very-long-code-span`", Some(Language::Markdown), "code.md"),
(
"<https://example.test/very-long-url>",
Some(Language::Markdown),
"url.md",
),
(
r#""very long JSON string""#,
Some(Language::Json),
"value.json",
),
] {
let document = Document::new(source, grammar, Some(filename.to_owned()));
let diagnostics = check_one_file(&config, &document).unwrap();
assert_eq!(
diagnostics
.iter()
.filter(|item| item.code == "W001")
.count(),
1,
"did not report physical overflow for {source:?}"
);
}
}
}