use super::format::FileType;
use super::js;
use super::position::PositionIndex;
use super::scanner::{
Url, UrlMatch, collect_strings, locate_parsed_values, scan_urls, to_unpositioned_urls, to_urls,
};
pub(crate) fn extract_by_file_type(content: &str, file_type: FileType) -> Vec<Url> {
match file_type {
FileType::Markdown => markdown(content),
FileType::Html => html(content),
FileType::Json => json(content),
FileType::Properties => properties(content),
FileType::Toml => toml(content),
FileType::Ini => ini(content),
FileType::Css
| FileType::Javascript
| FileType::Typescript
| FileType::Yaml
| FileType::Xml
| FileType::Unknown => to_urls(content, &scan_urls(content, 0)),
}
}
fn markdown(content: &str) -> Vec<Url> {
let lines: Vec<&str> = content.split('\n').collect();
let fenced = fenced_lines(&lines);
let index = PositionIndex::new(content);
let matches: Vec<UrlMatch> = scan_urls(content, 0)
.into_iter()
.filter(|found| {
let position = index.at(found.start);
if fenced.contains(&position.line) {
return false;
}
let line = lines.get(position.line - 1).copied().unwrap_or_default();
!is_in_inline_code(line, position.column - 1)
})
.collect();
to_urls(content, &matches)
}
fn fenced_lines(lines: &[&str]) -> Vec<usize> {
let mut fenced = Vec::new();
let mut in_block = false;
for (index, line) in lines.iter().enumerate() {
let is_fence = js::trim_start(line).starts_with("```");
if is_fence || in_block {
fenced.push(index + 1);
}
if is_fence {
in_block = !in_block;
}
}
fenced
}
fn is_in_inline_code(line: &str, column: usize) -> bool {
line.chars().take(column).filter(|c| *c == '`').count() % 2 == 1
}
fn html(content: &str) -> Vec<Url> {
let spans = comment_spans(content);
let matches: Vec<UrlMatch> = scan_urls(content, 0)
.into_iter()
.filter(|found| {
!spans
.iter()
.any(|(start, end)| found.start >= *start && found.start < *end)
})
.collect();
to_urls(content, &matches)
}
fn comment_spans(content: &str) -> Vec<(usize, usize)> {
let mut spans = Vec::new();
let mut cursor = 0;
while let Some(offset) = content[cursor..].find("<!--") {
let start = cursor + offset;
let end = content[start..]
.find("-->")
.map_or(content.len(), |close| start + close + 3);
spans.push((start, end));
cursor = end;
if cursor >= content.len() {
break;
}
}
spans
}
fn json(content: &str) -> Vec<Url> {
let mut matches = Vec::new();
for range in json_string_ranges(content) {
let raw = &content[range.0..range.1];
matches.extend(scan_urls(raw, range.0));
}
matches.sort_by_key(|found| found.start);
to_urls(content, &matches)
}
fn json_string_ranges(content: &str) -> Vec<(usize, usize)> {
let mut ranges = Vec::new();
let bytes = content.as_bytes();
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'/' && bytes.get(index + 1) == Some(&b'/') {
index = find_from(bytes, index + 2, b"\n").map_or(bytes.len(), |end| end + 1);
continue;
}
if bytes[index] == b'/' && bytes.get(index + 1) == Some(&b'*') {
index = find_from(bytes, index + 2, b"*/").map_or(bytes.len(), |end| end + 2);
continue;
}
if bytes[index] != b'"' {
index += 1;
continue;
}
let start = index;
index += 1;
while index < bytes.len() {
match bytes[index] {
b'\\' => index += 2,
b'"' => {
index += 1;
break;
}
_ => index += 1,
}
}
ranges.push((start, index.min(bytes.len())));
}
ranges
}
fn find_from(haystack: &[u8], from: usize, needle: &[u8]) -> Option<usize> {
if from >= haystack.len() {
return None;
}
haystack[from..]
.windows(needle.len())
.position(|window| window == needle)
.map(|offset| from + offset)
}
fn properties(content: &str) -> Vec<Url> {
let lines: Vec<&str> = content.split('\n').collect();
let index = PositionIndex::new(content);
let matches: Vec<UrlMatch> = scan_urls(content, 0)
.into_iter()
.filter(|found| {
let line = lines
.get(index.at(found.start).line - 1)
.map(|line| js::trim(line))
.unwrap_or_default();
!line.starts_with('#') && !line.starts_with('!')
})
.collect();
to_urls(content, &matches)
}
fn toml(content: &str) -> Vec<Url> {
match content.parse::<::toml::Table>() {
Ok(parsed) => {
let value = serde_json::to_value(&parsed).unwrap_or(serde_json::Value::Null);
positioned_from_parsed(content, &collect_strings(&value))
}
Err(_) => to_urls(content, &scan_urls(content, 0)),
}
}
fn ini(content: &str) -> Vec<Url> {
let lines: Vec<&str> = content.split('\n').collect();
let index = PositionIndex::new(content);
let matches: Vec<UrlMatch> = scan_urls(content, 0)
.into_iter()
.filter(|found| {
let line = lines
.get(index.at(found.start).line - 1)
.map(|line| js::trim(line))
.unwrap_or_default();
!line.starts_with(';') && !line.starts_with('#')
})
.collect();
to_urls(content, &matches)
}
fn positioned_from_parsed(content: &str, strings: &[String]) -> Vec<Url> {
let (located, unlocated) = locate_parsed_values(content, strings);
let mut urls = to_urls(content, &located);
urls.extend(to_unpositioned_urls(&unlocated));
urls
}
#[cfg(test)]
mod tests {
use super::*;
fn values(content: &str, file_type: FileType) -> Vec<String> {
extract_by_file_type(content, file_type)
.into_iter()
.map(|url| url.value)
.collect()
}
#[test]
fn a_fenced_block_is_excluded_from_markdown() {
let content = "see https://a.example\n\n```\nhttps://b.example\n```\n";
assert_eq!(values(content, FileType::Markdown), ["https://a.example"]);
}
#[test]
fn an_inline_code_span_is_excluded_from_markdown() {
let content = "a `https://b.example` and https://a.example\n";
assert_eq!(values(content, FileType::Markdown), ["https://a.example"]);
}
#[test]
fn an_html_comment_is_excluded() {
let content = "<a href=\"https://a.example\">x</a><!-- https://b.example -->";
assert_eq!(values(content, FileType::Html), ["https://a.example"]);
}
#[test]
fn an_unterminated_html_comment_swallows_the_rest() {
let content = "<a href=\"https://a.example\">x</a><!-- https://b.example";
assert_eq!(values(content, FileType::Html), ["https://a.example"]);
}
#[test]
fn json_reads_string_literals_only() {
let content = "{\n \"a\": \"https://a.example\"\n}\n// https://b.example\n";
assert_eq!(values(content, FileType::Json), ["https://a.example"]);
}
#[test]
fn a_quoted_url_inside_a_json_comment_is_trivia() {
let content = concat!(
"{\n",
" // \"https://in-a-line-comment.example\"\n",
" /* \"https://in-a-block-comment.example\" */\n",
" \"a\": \"https://a.example\"\n",
"}\n",
);
assert_eq!(values(content, FileType::Json), ["https://a.example"]);
}
#[test]
fn an_unterminated_json_block_comment_swallows_the_rest() {
let content = "{ \"a\": \"https://a.example\" } /* \"https://b.example\"";
assert_eq!(values(content, FileType::Json), ["https://a.example"]);
}
#[test]
fn a_marker_behind_a_byte_order_mark_is_still_a_marker() {
let fenced = "\u{feff}```\nhttps://b.example\n```\n";
assert!(values(fenced, FileType::Markdown).is_empty());
assert!(values("\u{feff}# https://b.example\n", FileType::Properties).is_empty());
assert!(values("\u{feff}! https://b.example\n", FileType::Properties).is_empty());
assert!(values("\u{feff}; https://b.example\n", FileType::Ini).is_empty());
assert_eq!(
values("\u{85}# https://b.example\n", FileType::Properties),
["https://b.example"]
);
}
#[test]
fn an_ini_comment_is_excluded() {
let content = "; https://b.example\n# https://c.example\nkey=https://a.example\n";
assert_eq!(values(content, FileType::Ini), ["https://a.example"]);
}
#[test]
fn a_document_that_is_not_ini_is_still_read() {
let content = "bare https://a.example with no equals sign\n";
assert_eq!(values(content, FileType::Ini), ["https://a.example"]);
}
#[test]
fn a_properties_comment_is_excluded() {
let content = "# https://b.example\n! https://c.example\na=https://a.example\n";
assert_eq!(values(content, FileType::Properties), ["https://a.example"]);
}
#[test]
fn a_toml_document_that_does_not_parse_falls_back_to_a_scan() {
let content = "[package\nhomepage = \"https://a.example\"\n";
assert_eq!(values(content, FileType::Toml), ["https://a.example"]);
}
#[test]
fn the_whole_content_formats_need_no_exclusions() {
for file_type in [
FileType::Css,
FileType::Javascript,
FileType::Typescript,
FileType::Yaml,
FileType::Xml,
FileType::Unknown,
] {
assert_eq!(
values("x https://a.example y", file_type),
["https://a.example"],
"{file_type:?}"
);
}
}
}