use std::collections::HashMap;
use std::fs::File;
use std::io::Read as IoRead;
use std::iter::Peekable;
use std::path::Path;
use std::str;
use std::sync::{Arc, LazyLock};
use std::time::Instant;
use memchr::memmem;
use crate::{EngineConfig, Language, LineClass, NestedLanguage, ScanSkip, Span, SpanKind, phase_timing};
use crate::domain::{CommentPair, FileStats, LineContinuation};
pub(crate) const MAX_RETAINED_FILE_BUFFER_BYTES: usize = 4_194_304;
const MINIFIED_AVERAGE_LINE_BYTES : usize = 1_000;
const SMALLEST_FILE_WORTH_TESTING : usize = 10_240;
const GENERATED_MARKER_BYTES : usize = 512;
const GENERATED_MARKERS : [&str; 4] = ["do not edit", "auto-generated", "autogenerated", "@generated"];
static GENERATED_FINDERS : LazyLock<[memmem::Finder<'static>; 4]> =
LazyLock::new(|| GENERATED_MARKERS.map(memmem::Finder::new));
const IDENTIFICATION_BYTES : usize = 8_192;
const NOT_CODE_MARKER_LINES : usize = 8;
const NO_SLOT : u16 = u16::MAX;
const STRINGS : u8 = 0;
const COMMENTS : u8 = 1;
const COM_STARTS : u8 = 2;
const COM_ENDS : u8 = 3;
const ROLE_EITHER : u8 = 0;
const ROLE_OPEN : u8 = 1;
const ROLE_CLOSE : u8 = 2;
const ROLE_LITERAL : u8 = 3;
const ROLE_RAW : u8 = 4;
const ROLE_RAW_ESCAPED : u8 = 5;
pub(crate) struct FileReport {
pub shell: FileStats,
pub sections: Vec<SectionReport>,
pub bytes: usize,
}
pub(crate) struct SectionReport {
pub language: String,
pub stats: FileStats,
pub bytes: usize,
}
impl FileReport {
pub(crate) fn total_lines(&self) -> usize {
self.shell.lines + self.sections.iter().map(|section| section.stats.lines).sum::<usize>()
}
pub(crate) fn into_whole(mut self) -> FileStats {
for section in &self.sections {
self.shell.lines += section.stats.lines;
self.shell.classes.add(§ion.stats.classes);
}
self.shell
}
}
pub(crate) enum FileOutcome {
Counted(FileReport, Option<Arc<str>>),
Skipped(ScanSkip)
}
pub(crate) fn parse_file(path: &Path, size: u64, lang_name: &str, buf: &mut Vec<u8>, buffers: &mut ParseBuffers,
lookup: &NestedLanguageLookup, matchers: &mut KeywordMatchers,
id_matchers: &mut IdentificationMatchers, config: &EngineConfig,
written_by_hand: bool, extension_rules: Option<&crate::engine::identity::ExtensionRules>,
shebang_map: &HashMap<String, Arc<str>>)
-> Result<FileOutcome,String>
{
let mut at = phase_timing::ENABLED.then(Instant::now);
let mut file = match File::open(path){
Ok(f) => f,
Err(x) => return Err(x.to_string())
};
if let Some(t) = at {
buffers.timing.open_nanos += phase_timing::nanos_since(t);
at = Some(Instant::now());
}
let filled = match read_file_into(&mut file, buf, size) {
Ok(filled) => filled,
Err(x) => return Err(x.to_string())
};
if let Some(t) = at {
buffers.timing.read_nanos += phase_timing::nanos_since(t);
buffers.timing.bytes += filled as u64;
buffers.timing.files += 1;
at = Some(Instant::now());
}
let Ok(contents) = str::from_utf8(&buf[..filled]) else {
return Err("stream did not contain valid UTF-8".to_owned());
};
if !written_by_hand && let Some(kind) = find_scan_skip(contents, extension_rules, config) {
return Ok(FileOutcome::Skipped(kind));
}
let resolved = extension_rules.and_then(|rules| rules.contenders.as_deref())
.and_then(|c| identify_language(contents, c, lookup.languages, shebang_map, id_matchers))
.map(|(name, _)| name);
let lang_name = resolved.as_deref().unwrap_or(lang_name);
let report = parse_lines::<false>(contents, lookup.languages.get(lang_name).unwrap(), lookup, matchers,
config, buffers, &mut ExplainLog::default());
if let Some(t) = at { buffers.timing.parse_nanos += phase_timing::nanos_since(t); }
Ok(FileOutcome::Counted(report, resolved))
}
pub(crate) fn explain_parsed_file(contents: String, lang_name: &str, lookup: &NestedLanguageLookup,
config: &EngineConfig) -> (String, FileReport, ExplainLog)
{
let config = EngineConfig { count_keywords: false, ..config.clone() };
let mut log = ExplainLog::default();
let report = parse_lines::<true>(&contents, lookup.languages.get(lang_name).unwrap(), lookup,
&mut KeywordMatchers::default(), &config, &mut ParseBuffers::default(), &mut log);
(contents, report, log)
}
fn read_file_into(file: &mut File, buf: &mut Vec<u8>, size: u64) -> std::io::Result<usize> {
const READ_WINDOW_BYTES : usize = 8_192;
let expected = usize::try_from(size).unwrap_or(0);
let mut filled = 0;
loop {
let end = if filled <= expected {expected + 1} else {filled + READ_WINDOW_BYTES};
if buf.len() < end {
buf.resize(end, 0);
}
match file.read(&mut buf[filled..end]) {
Ok(0) => return Ok(filled),
Ok(read) if filled + read < end && expected > 0 && filled + read >= expected
=> return Ok(filled + read),
Ok(read) => filled += read,
Err(x) if x.kind() == std::io::ErrorKind::Interrupted => (),
Err(x) => return Err(x)
}
}
}
#[derive(Debug, Clone, Copy)]
struct Slot {
symbol: u8,
kind: u8,
role: u8,
len: u8,
second: u8,
anchor: u8,
filler: u8,
suffix: u8,
cancelled_by: u8,
next: u16,
}
#[derive(Debug, Clone, Copy)]
struct Chunk {
bytes: [u8; 3],
len: u8,
}
struct PlanEntry {
kind: u8,
symbol: u8,
role: u8,
filler: u8,
suffix: u8,
cancelled_by: u8,
bytes: Box<[u8]>,
}
impl PlanEntry {
fn of(kind: u8, symbol: u8, role: u8, bytes: &[u8]) -> PlanEntry {
PlanEntry { kind, symbol, role, filler: 0, suffix: 0, cancelled_by: 0, bytes: bytes.into() }
}
fn leveled(kind: u8, symbol: u8, prefix: &[u8], suffix: u8) -> PlanEntry {
PlanEntry { kind, symbol, role: ROLE_EITHER, filler: b'=', suffix, cancelled_by: 0,
bytes: prefix.into() }
}
}
#[derive(Debug, Clone)]
pub(crate) struct ScanPlan {
chunks: Vec<Chunk>,
first: [u16; 256],
slots: Vec<Slot>,
symbols: Vec<Box<[u8]>>,
sorted_kinds: [bool; 4],
line_comment_ends_the_line: bool,
}
impl ScanPlan {
pub(crate) fn build(language: &Language) -> ScanPlan {
let mut entries : Vec<PlanEntry> = Vec::new();
let (symbols, literals) = (language.strings.get_symbols(), language.strings.get_char_literals());
for (i, symbol) in symbols.iter().enumerate() {
entries.push(PlanEntry::of(STRINGS, i as u8, ROLE_EITHER, symbol.as_bytes()));
}
for (i, symbol) in literals.iter().enumerate() {
let index = (symbols.len() + i) as u8;
entries.push(PlanEntry::of(STRINGS, index, ROLE_LITERAL, symbol.as_bytes()));
}
for (i, crossing) in language.strings.get_multiline_strings().iter().enumerate() {
let index = (symbols.len() + literals.len() + i) as u8;
let (open, close) = (&crossing.open, &crossing.close);
if open != close {
entries.push(PlanEntry::of(STRINGS, index, ROLE_OPEN, open.as_bytes()));
entries.push(PlanEntry::of(STRINGS, index, ROLE_CLOSE, close.as_bytes()));
} else {
let role = if crossing.escapes {ROLE_EITHER} else {ROLE_RAW};
entries.push(PlanEntry::of(STRINGS, index, role, open.as_bytes()));
}
}
for (i, symbol) in language.comment_symbols.iter().enumerate() {
entries.push(PlanEntry::of(COMMENTS, i as u8, ROLE_EITHER, symbol.as_bytes()));
}
for (i, pair) in language.comment_pairs().enumerate() {
let index = i as u8;
match pair {
CommentPair::Plain { start, end } | CommentPair::Nesting { start, end } => {
entries.push(PlanEntry::of(COM_STARTS, index, ROLE_EITHER, start.as_bytes()));
entries.push(PlanEntry::of(COM_ENDS, index, ROLE_EITHER, end.as_bytes()));
},
CommentPair::Leveled(pair) => {
entries.push(PlanEntry::leveled(COM_STARTS, index, pair.start_prefix.as_bytes(), pair.start_suffix));
entries.push(PlanEntry::leveled(COM_ENDS, index, pair.end_prefix.as_bytes(), pair.end_suffix));
}
}
}
for (symbol, cancelling) in &language.cancelled_symbols {
for entry in entries.iter_mut().filter(|entry| *entry.bytes == *symbol.as_bytes()) {
entry.cancelled_by = *cancelling;
}
}
entries.retain(|entry| !entry.bytes.is_empty());
let line_comment_ends_the_line = !entries.iter().filter(|entry| entry.kind == COM_STARTS)
.any(|start| entries.iter().filter(|entry| entry.kind == COMMENTS)
.any(|comment| start.bytes.starts_with(&comment.bytes)));
entries.sort_by_key(|entry| std::cmp::Reverse(entry.bytes.len()));
let anchors = anchors_of(&entries);
let mut first = [NO_SLOT; 256];
let (mut slots, mut symbols) = (Vec::with_capacity(entries.len()), Vec::with_capacity(entries.len()));
for (entry, anchor) in entries.iter().zip(&anchors) {
let index = slots.len() as u16;
let anchor = *anchor;
slots.push(Slot {
symbol: entry.symbol,
kind: entry.kind,
role: entry.role,
len: entry.bytes.len() as u8,
second: if entry.bytes.len() > 1 { entry.bytes[1] } else { 0 },
anchor,
filler: entry.filler,
suffix: entry.suffix,
cancelled_by: entry.cancelled_by,
next: NO_SLOT,
});
symbols.push(entry.bytes.clone());
let head = &mut first[entry.bytes[anchor as usize] as usize];
if *head == NO_SLOT {
*head = index;
} else {
let mut cursor = *head as usize;
while slots[cursor].next != NO_SLOT { cursor = slots[cursor].next as usize }
slots[cursor].next = index;
}
}
let (chunks, mut sorted_kinds) = pack_into_chunks(&entries, &anchors);
for (kind, sorted) in sorted_kinds.iter_mut().enumerate() {
let mut depths = entries.iter().zip(&anchors)
.filter(|(entry, _)| entry.kind as usize == kind).map(|(_, anchor)| *anchor);
let Some(first) = depths.next() else { continue };
if depths.any(|depth| depth != first) { *sorted = true }
}
ScanPlan { chunks, first, slots, symbols, sorted_kinds, line_comment_ends_the_line }
}
}
fn anchors_of(entries: &[PlanEntry]) -> Vec<u8> {
let mut searched : Vec<u8> = Vec::new();
for entry in entries {
let mut bytes = get_candidate_bytes_of(entry);
let Some(first) = bytes.next() else { continue };
if bytes.all(|byte| byte == first) && !searched.contains(&first) { searched.push(first) }
}
while let Some(byte) = find_the_byte_reaching_most_of(entries, &searched) {
searched.push(byte);
}
entries.iter().map(|entry| entry.bytes.iter().position(|byte| searched.contains(byte))
.unwrap_or(0) as u8).collect()
}
fn get_candidate_bytes_of(entry: &PlanEntry) -> impl Iterator<Item = u8> + '_ {
entry.bytes.iter().copied().filter(|byte| !byte.is_ascii_alphanumeric())
}
fn is_reached_by(entry: &PlanEntry, searched: &[u8]) -> bool {
get_candidate_bytes_of(entry).any(|byte| searched.contains(&byte))
}
fn find_the_byte_reaching_most_of(entries: &[PlanEntry], searched: &[u8]) -> Option<u8> {
let waiting = entries.iter().filter(|entry| get_candidate_bytes_of(entry).next().is_some()
&& !is_reached_by(entry, searched)).collect::<Vec<&PlanEntry>>();
let mut best : Option<(u8, usize)> = None;
for entry in &waiting {
for byte in get_candidate_bytes_of(entry) {
let reach = waiting.iter().filter(|other|
get_candidate_bytes_of(other).any(|other_byte| other_byte == byte)).count();
if best.is_none_or(|(_, most)| reach > most) { best = Some((byte, reach)) }
}
}
best.map(|(byte, _)| byte)
}
fn pack_into_chunks(entries: &[PlanEntry], anchors: &[u8]) -> (Vec<Chunk>, [bool; 4]) {
let mut bytes_of_kind : [Vec<u8>; 4] = Default::default();
for (entry, anchor) in entries.iter().zip(anchors) {
let searched = entry.bytes[*anchor as usize];
let set = &mut bytes_of_kind[entry.kind as usize];
if !set.contains(&searched) { set.push(searched) }
}
let mut group_of = [0usize, 1, 2, 3];
for a in 0..4 {
for b in (a + 1)..4 {
if bytes_of_kind[a].iter().any(|x| bytes_of_kind[b].contains(x)) {
let (from, to) = (group_of[b], group_of[a]);
for slot in group_of.iter_mut() { if *slot == from { *slot = to } }
}
}
}
let (mut chunks, mut sorted_kinds) : (Vec<Vec<u8>>, [bool; 4]) = (Vec::new(), [false; 4]);
for group in 0..4 {
let mut group_bytes : Vec<u8> = Vec::new();
for kind in 0..4 {
if group_of[kind] != group { continue }
for byte in &bytes_of_kind[kind] {
if !group_bytes.contains(byte) { group_bytes.push(*byte) }
}
}
if group_bytes.is_empty() { continue }
if group_bytes.len() > 3 {
for kind in 0..4 { if group_of[kind] == group { sorted_kinds[kind] = true } }
for piece in group_bytes.chunks(3) { chunks.push(piece.to_vec()) }
continue;
}
match chunks.iter_mut().find(|chunk| chunk.len() + group_bytes.len() <= 3) {
Some(chunk) => chunk.extend_from_slice(&group_bytes),
None => chunks.push(group_bytes)
}
}
let chunks = chunks.into_iter().map(|bytes| {
let mut padded = [0u8; 3];
padded[..bytes.len()].copy_from_slice(&bytes);
Chunk { bytes: padded, len: bytes.len() as u8 }
}).collect();
(chunks, sorted_kinds)
}
fn get_or_build_plan_of(language: &Language) -> &ScanPlan {
language.scan_plan.get_or_init(|| ScanPlan::build(language))
}
#[derive(Debug, Default)]
pub(crate) struct ScanBuffers {
raw_strings: Vec<(usize, u8, u8)>,
strings: Vec<usize>,
string_symbols: Vec<u8>,
comments: Vec<usize>,
com_starts: Vec<(usize, u8, u8)>,
com_ends: Vec<(usize, u8, u8)>,
consumed: Vec<usize>,
code_ranges: Vec<(usize, usize)>,
}
impl ScanBuffers {
fn reset(&mut self, slots: usize) {
self.raw_strings.clear();
self.strings.clear();
self.string_symbols.clear();
self.comments.clear();
self.com_starts.clear();
self.com_ends.clear();
self.consumed.clear();
self.consumed.resize(slots, 0);
self.code_ranges.clear();
}
}
#[derive(Debug, Default)]
pub(crate) struct ParseBuffers {
scan: ScanBuffers,
alias_indices: Vec<usize>,
code_spans: Vec<(u32, u32)>,
pub timing: phase_timing::Totals,
}
fn stands_as_its_own_word(line: &[u8], start: usize, width: usize) -> bool {
let is_word = |byte: u8| byte.is_ascii_alphanumeric() || byte == b'_';
let glued_before = is_word(line[start]) && start > 0 && is_word(line[start - 1]);
let glued_after = is_word(line[start + width - 1])
&& line.get(start + width).is_some_and(|byte| is_word(*byte));
!glued_before && !glued_after
}
fn is_not_escaped(pos: usize, bytes: &[u8], escape: Option<u8>) -> bool {
let Some(escape) = escape else { return true };
let mut escapes = 0;
let mut offset = 1;
while pos >= offset && bytes[pos - offset] == escape {
offset += 1;
escapes += 1;
}
escapes % 2 == 0
}
fn scan_line(line: &str, language: &Language, buffers: &mut ScanBuffers) {
let plan = get_or_build_plan_of(language);
let line_bytes = line.as_bytes();
let escape = language.strings.get_escape();
buffers.reset(plan.slots.len());
for chunk in &plan.chunks {
match chunk.len {
1 => for at in memchr::memchr_iter(chunk.bytes[0], line_bytes) {
take_symbols_at(at, line_bytes, plan, buffers, escape)
},
2 => for at in memchr::memchr2_iter(chunk.bytes[0], chunk.bytes[1], line_bytes) {
take_symbols_at(at, line_bytes, plan, buffers, escape)
},
_ => for at in memchr::memchr3_iter(chunk.bytes[0], chunk.bytes[1], chunk.bytes[2], line_bytes) {
take_symbols_at(at, line_bytes, plan, buffers, escape)
}
}
}
if plan.sorted_kinds[STRINGS as usize] {
let length_of = |symbol: u8, role: u8| {
let (open, close) = language.get_string_pair_of(symbol);
match role { ROLE_CLOSE => close.len(), _ => open.len() }
};
buffers.raw_strings.sort_unstable_by(|(a_at, a_symbol, a_role), (b_at, b_symbol, b_role)|
a_at.cmp(b_at).then_with(|| length_of(*b_symbol, *b_role).cmp(&length_of(*a_symbol, *a_role))));
}
if plan.sorted_kinds[COMMENTS as usize] { buffers.comments.sort_unstable() }
if plan.sorted_kinds[COM_STARTS as usize] {
buffers.com_starts.sort_unstable_by(|(a_at, a_symbol, a_level), (b_at, b_symbol, b_level)|
a_at.cmp(b_at).then_with(|| language.comment_start_len(*b_symbol, *b_level)
.cmp(&language.comment_start_len(*a_symbol, *a_level))));
}
if plan.sorted_kinds[COM_ENDS as usize] {
buffers.com_ends.sort_unstable_by(|(a_at, a_symbol, a_level), (b_at, b_symbol, b_level)|
a_at.cmp(b_at).then_with(|| language.comment_end_len(*b_symbol, *b_level)
.cmp(&language.comment_end_len(*a_symbol, *a_level))));
}
}
fn take_symbols_at(at: usize, line_bytes: &[u8], plan: &ScanPlan, buffers: &mut ScanBuffers,
escape: Option<u8>)
{
let mut cursor = plan.first[line_bytes[at] as usize];
while cursor != NO_SLOT {
let index = cursor as usize;
let slot = plan.slots[index];
cursor = slot.next;
let Some(start) = at.checked_sub(slot.anchor as usize) else { continue };
if slot.filler == 0 && start < buffers.consumed[index] { continue }
let matched = match (slot.anchor, slot.len) {
(0, 1) if slot.filler == 0 => true,
(0, 2) if slot.filler == 0 => line_bytes.get(at + 1) == Some(&slot.second),
_ => line_bytes[start..].starts_with(&plan.symbols[index])
};
if !matched { continue }
if slot.cancelled_by != 0 && start > 0 && line_bytes[start - 1] == slot.cancelled_by {
continue;
}
let mut level = 0u8;
let mut width = slot.len as usize;
if slot.filler != 0 {
let mut cursor = start + slot.len as usize;
while line_bytes.get(cursor) == Some(&slot.filler) && level < u8::MAX {
cursor += 1;
level += 1;
}
if line_bytes.get(cursor) != Some(&slot.suffix) { continue }
width = cursor + 1 - start;
}
let mut role = slot.role;
if slot.kind == STRINGS && start != 0 && !is_not_escaped(start, line_bytes, escape) {
match slot.role {
ROLE_EITHER | ROLE_LITERAL => continue,
ROLE_RAW => role = ROLE_RAW_ESCAPED,
_ => ()
}
}
if slot.role == ROLE_LITERAL {
let symbol_bytes = &plan.symbols[index];
let mut cursor = start + width;
let closed_at = loop {
let Some(offset) = memchr::memchr(symbol_bytes[0], &line_bytes[cursor..]) else { break None };
let candidate = cursor + offset;
if line_bytes[candidate..].starts_with(symbol_bytes)
&& is_not_escaped(candidate, line_bytes, escape)
&& holds_one_character(&line_bytes[start + width..candidate]) {
break Some(candidate);
}
cursor = candidate + 1;
};
let Some(closed_at) = closed_at else {
buffers.consumed[index] = start + width;
continue;
};
buffers.raw_strings.push((start, slot.symbol, ROLE_OPEN));
buffers.raw_strings.push((closed_at, slot.symbol, ROLE_CLOSE));
buffers.consumed[index] = closed_at + width;
continue;
}
buffers.consumed[index] = start + width;
match slot.kind {
STRINGS => buffers.raw_strings.push((start, slot.symbol, role)),
COMMENTS => if stands_as_its_own_word(line_bytes, start, width) {
buffers.comments.push(start);
},
COM_STARTS => buffers.com_starts.push((start, slot.symbol, level)),
_ => buffers.com_ends.push((start, slot.symbol, level))
}
}
}
#[derive(Debug)]
pub(crate) struct IdentificationMatcher {
rules: Vec<(memmem::Finder<'static>, String, bool)>,
}
impl IdentificationMatcher {
pub(crate) fn build(language: &Language) -> Option<IdentificationMatcher> {
Self::of(&language.identifying_line_starts, &language.identifying_line_contains)
}
pub(crate) fn of(line_starts: &[String], line_contains: &[String]) -> Option<IdentificationMatcher> {
let rule = |literal: &String, at_line_start: bool|
(memmem::Finder::new(literal.as_str()).into_owned(), literal.clone(), at_line_start);
let rules = line_starts.iter().filter(|x| !x.is_empty()).map(|x| rule(x, true))
.chain(line_contains.iter().filter(|x| !x.is_empty()).map(|x| rule(x, false)))
.collect::<Vec<_>>();
if rules.is_empty() {None} else {Some(IdentificationMatcher { rules })}
}
pub(crate) fn find_evidence(&self, head: &[u8]) -> Option<(usize, &str)> {
self.rules.iter().filter_map(|(finder, literal, at_line_start)| {
let mut from = 0;
while let Some(offset) = finder.find(&head[from..]) {
let at = from + offset;
from = at + 1;
if evidence_stands(head, at, literal.len(), *at_line_start, false) {
return Some((at, literal.as_str()));
}
}
None
}).min_by_key(|(at, _)| *at)
}
pub(crate) fn finds_a_marker(&self, buf: &str) -> bool {
let head = head_of(buf);
let lines = first_lines_of(buf, NOT_CODE_MARKER_LINES);
self.rules.iter().any(|(finder, literal, at_line_start)| {
let hay = if *at_line_start {head} else {lines};
let mut from = 0;
while let Some(offset) = finder.find(&hay[from..]) {
let at = from + offset;
from = at + 1;
if evidence_stands(hay, at, literal.len(), *at_line_start, *at_line_start) {
return true;
}
}
false
})
}
}
fn evidence_stands(head: &[u8], at: usize, len: usize, at_line_start: bool, allows_a_word_after: bool) -> bool {
let is_word = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
if len == 0 {
return false;
}
if !allows_a_word_after && is_word(head[at + len - 1]) && head.get(at + len).copied().is_some_and(is_word) {
return false;
}
if is_word(head[at]) && at > 0 && is_word(head[at - 1]) {
return false;
}
if !at_line_start {
return true;
}
head[..at].iter().rev().take_while(|b| **b != b'\n').all(|b| matches!(b, b' ' | b'\t' | b'\r'))
}
#[derive(Default)]
pub(crate) struct IdentificationMatchers {
by_language: HashMap<String, Option<IdentificationMatcher>>,
}
impl IdentificationMatchers {
fn for_language(&mut self, language: &Language) -> Option<&IdentificationMatcher> {
self.by_language.entry(language.name.clone())
.or_insert_with(|| IdentificationMatcher::build(language)).as_ref()
}
}
pub(crate) fn find_identified_language(buf: &str, contenders: &[Arc<str>],
languages: &HashMap<String, Language>, shebang_map: &HashMap<String, Arc<str>>)
-> Option<(Arc<str>, String, usize)>
{
let head = head_of(buf);
let (name, offset) = identify_language(buf, contenders, languages, shebang_map,
&mut IdentificationMatchers::default())?;
match offset {
None => Some((name, String::from_utf8_lossy(first_line_of(head)).trim_end().to_owned(), 1)),
Some(offset) => {
let literal = languages.get(name.as_ref()).and_then(IdentificationMatcher::build)
.and_then(|matcher| matcher.find_evidence(head).map(|(_, x)| x.to_owned()))?;
let line = head[..offset].iter().filter(|b| **b == b'\n').count() + 1;
Some((name, literal, line))
}
}
}
fn identify_language(buf: &str, contenders: &[Arc<str>], languages: &HashMap<String, Language>,
shebang_map: &HashMap<String, Arc<str>>, matchers: &mut IdentificationMatchers)
-> Option<(Arc<str>, Option<usize>)>
{
let head = head_of(buf);
if let Some(name) = find_shebang_language(head, shebang_map) {
return Some((name, None));
}
let mut best: Option<(usize, usize)> = None;
for (at, name) in contenders.iter().enumerate() {
if let Some(language) = languages.get(name.as_ref())
&& let Some(matcher) = matchers.for_language(language)
&& let Some((offset, _)) = matcher.find_evidence(head)
&& best.is_none_or(|(earliest, _)| offset < earliest) {
best = Some((offset, at));
}
}
best.map(|(offset, at)| (contenders[at].clone(), Some(offset)))
}
fn find_shebang_language(head: &[u8], shebang_map: &HashMap<String, Arc<str>>) -> Option<Arc<str>> {
let token = crate::engine::identity::find_interpreter(first_line_of(head))?;
crate::engine::identity::find_language_of_interpreter(shebang_map, str::from_utf8(token).ok()?)
}
fn head_of(buf: &str) -> &[u8] {
let bytes = strip_bom(buf.as_bytes());
&bytes[..bytes.len().min(IDENTIFICATION_BYTES)]
}
fn first_lines_of(buf: &str, lines: usize) -> &[u8] {
let bytes = strip_bom(buf.as_bytes());
let mut from = 0;
for _ in 0..lines {
match memchr::memchr(b'\n', &bytes[from..]) {
Some(at) => from += at + 1,
None => return bytes
}
}
&bytes[..from]
}
fn strip_bom(bytes: &[u8]) -> &[u8] {
bytes.strip_prefix(b"\xef\xbb\xbf".as_slice()).unwrap_or(bytes)
}
fn first_line_of(head: &[u8]) -> &[u8] {
&head[..memchr::memchr(b'\n', head).unwrap_or(head.len())]
}
pub(crate) struct KeywordMatcher {
aliases_with_indices: Vec<(memmem::Finder<'static>, usize, usize)>,
}
impl KeywordMatcher {
pub(crate) fn build(language: &Language) -> Option<KeywordMatcher> {
let mut aliases_with_indices = Vec::new();
for (keyword_index, keyword) in language.keywords.iter().enumerate() {
for alias in &keyword.aliases {
aliases_with_indices.push((memmem::Finder::new(alias.as_str()).into_owned(), alias.len(), keyword_index));
}
}
if aliases_with_indices.is_empty() {
None
} else {
Some(KeywordMatcher { aliases_with_indices })
}
}
}
pub(crate) struct NestedLanguageLookup<'a> {
pub languages: &'a HashMap<String, Language>,
pub extension_to_name: &'a HashMap<String, std::sync::Arc<str>>,
pub set_aside: &'a HashMap<String, Language>,
}
impl NestedLanguageLookup<'_> {
fn find_by_spelling(&self, spelling: &str) -> Option<&Language> {
let lowered = spelling.to_lowercase();
if let Some(name) = self.extension_to_name.get(&lowered) {
return self.find_by_name(name.as_ref());
}
self.languages.values().chain(self.set_aside.values())
.find(|language| language.name.to_lowercase() == lowered)
}
pub(crate) fn find_by_name(&self, name: &str) -> Option<&Language> {
self.languages.get(name).or_else(|| self.set_aside.get(name))
}
}
#[derive(Default)]
pub(crate) struct KeywordMatchers {
by_language: HashMap<String, Option<KeywordMatcher>>,
}
impl KeywordMatchers {
fn for_language(&mut self, language: &Language) -> Option<&KeywordMatcher> {
self.by_language.entry(language.name.clone())
.or_insert_with(|| KeywordMatcher::build(language)).as_ref()
}
}
pub(crate) fn find_scan_skip(contents: &str, rules: Option<&crate::engine::identity::ExtensionRules>,
config: &EngineConfig) -> Option<ScanSkip> {
if !config.count_not_code && rules.and_then(|x| x.not_code.as_ref())
.is_some_and(|matcher| matcher.finds_a_marker(contents)) {
return Some(ScanSkip::NotCode);
}
if !config.count_minified && is_minified(contents) {
return Some(ScanSkip::Minified);
}
if !config.count_generated && is_generated(contents) {
return Some(ScanSkip::Generated);
}
None
}
fn is_minified(contents: &str) -> bool {
if contents.len() < SMALLEST_FILE_WORTH_TESTING {
return false;
}
let most_lines = contents.len() / MINIFIED_AVERAGE_LINE_BYTES;
memchr::memchr_iter(b'\n', contents.as_bytes()).take(most_lines).count() < most_lines
}
fn is_generated(contents: &str) -> bool {
let head = &contents.as_bytes()[..contents.len().min(GENERATED_MARKER_BYTES)];
let mut lowercased = [0u8; GENERATED_MARKER_BYTES];
lowercased[..head.len()].copy_from_slice(head);
lowercased[..head.len()].make_ascii_lowercase();
GENERATED_FINDERS.iter().any(|finder| finder.find(&lowercased[..head.len()]).is_some())
}
struct LineIter<'a> {
contents: &'a str,
newlines: memchr::Memchr<'a>,
start: usize,
}
impl<'a> Iterator for LineIter<'a> {
type Item = (usize, &'a str);
fn next(&mut self) -> Option<(usize, &'a str)> {
match self.newlines.next() {
Some(at) => {
let mut end = at;
if end > self.start && self.contents.as_bytes()[end - 1] == b'\r' {
end -= 1;
}
let line = (self.start, &self.contents[self.start..end]);
self.start = at + 1;
Some(line)
},
None => {
if self.start >= self.contents.len() {
return None;
}
let line = (self.start, &self.contents[self.start..]);
self.start = self.contents.len();
Some(line)
}
}
}
}
fn get_lines_of(contents: &str) -> LineIter<'_> {
LineIter { contents, newlines: memchr::memchr_iter(b'\n', contents.as_bytes()), start: 0 }
}
#[derive(Default)]
struct WalkState {
open_comment: Option<(u8, u32)>,
open_str_symbol: Option<u8>,
continued_comment: bool,
opened_line: usize,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) enum CarriedRecord {
Nothing,
Comment { symbol: u8, depth: u32, since_line: usize, ends: bool },
Str { symbol: u8, since_line: usize, ends: bool },
Continuation { since_line: usize },
}
impl CarriedRecord {
fn of(state: &WalkState) -> CarriedRecord {
if let Some(symbol) = state.open_str_symbol {
CarriedRecord::Str { symbol, since_line: state.opened_line, ends: false }
} else if let Some((symbol, depth)) = state.open_comment {
CarriedRecord::Comment { symbol, depth, since_line: state.opened_line, ends: false }
} else if state.continued_comment {
CarriedRecord::Continuation { since_line: state.opened_line }
} else {
CarriedRecord::Nothing
}
}
fn with_its_end_marked(self, state: &WalkState, opened_here: OpenedHere) -> CarriedRecord {
match self {
CarriedRecord::Comment { symbol, depth, since_line, .. } => CarriedRecord::Comment {
symbol, depth, since_line,
ends: state.open_comment.is_none() || opened_here.comment },
CarriedRecord::Str { symbol, since_line, .. } => CarriedRecord::Str {
symbol, since_line,
ends: state.open_str_symbol.is_none() || opened_here.string },
other => other,
}
}
}
#[derive(Default)]
pub(crate) struct ExplainLog {
records: Vec<LineRecord>,
languages: Vec<String>,
}
pub(crate) struct LineRecord {
pub class: LineClass,
pub carried: CarriedRecord,
pub spans: Vec<Span>,
pub language: u16,
}
impl ExplainLog {
fn record(&mut self, class: LineClass, carried: CarriedRecord, language: &Language, spans: Vec<Span>) {
let language = match self.languages.iter().position(|name| name == &language.name) {
Some(at) => at as u16,
None => {
self.languages.push(language.name.clone());
(self.languages.len() - 1) as u16
}
};
self.records.push(LineRecord { class, carried, spans, language });
}
fn get_current_line_number(&self) -> usize {
self.records.len() + 1
}
#[cfg(test)]
pub(crate) fn get_language_name_of(&self, record: &LineRecord) -> &str {
&self.languages[record.language as usize]
}
#[cfg(test)]
pub(crate) fn records(&self) -> &[LineRecord] {
&self.records
}
pub(crate) fn into_parts(self) -> (Vec<LineRecord>, Vec<String>) {
(self.records, self.languages)
}
}
struct SectionBucket<'a> {
language: &'a Language,
stats: FileStats,
spans: Vec<(u32, u32)>,
collecting_spans: bool,
bytes: usize,
}
struct CandidateProbe<'a> {
passes: Vec<CandidatePass<'a>>,
}
enum CandidatePass<'a> {
One(Peekable<memchr::Memchr<'a>>),
Two(Peekable<memchr::Memchr2<'a>>),
Three(Peekable<memchr::Memchr3<'a>>)
}
impl<'a> CandidateProbe<'a> {
fn of(contents: &'a str, plan: &ScanPlan) -> CandidateProbe<'a> {
let bytes = contents.as_bytes();
let passes = plan.chunks.iter().map(|chunk| match chunk.len {
1 => CandidatePass::One(memchr::memchr_iter(chunk.bytes[0], bytes).peekable()),
2 => CandidatePass::Two(memchr::memchr2_iter(chunk.bytes[0], chunk.bytes[1], bytes).peekable()),
_ => CandidatePass::Three(memchr::memchr3_iter(chunk.bytes[0], chunk.bytes[1], chunk.bytes[2], bytes).peekable())
}).collect();
CandidateProbe { passes }
}
fn has_a_candidate_in(&mut self, from: usize, to: usize) -> bool {
self.passes.iter_mut().any(|pass| match pass {
CandidatePass::One(pass) => reaches_into(pass, from, to),
CandidatePass::Two(pass) => reaches_into(pass, from, to),
CandidatePass::Three(pass) => reaches_into(pass, from, to)
})
}
}
fn reaches_into(pass: &mut Peekable<impl Iterator<Item = usize>>, from: usize, to: usize) -> bool {
while pass.next_if(|at| *at < from).is_some() {}
pass.peek().is_some_and(|at| *at < to)
}
fn parse_lines<const EXPLAIN: bool>(contents: &str, language: &Language, lookup: &NestedLanguageLookup,
matchers: &mut KeywordMatchers, config: &EngineConfig, buffers: &mut ParseBuffers,
log: &mut ExplainLog) -> FileReport
{
let ParseBuffers { scan, alias_indices, code_spans, .. } = buffers;
let mut shell_stats = if config.count_keywords { FileStats::with_keywords(&language.keywords) }
else { FileStats::default() };
code_spans.clear();
let collecting_spans = config.count_keywords && matchers.for_language(language).is_some();
let mut shell = WalkState::default();
let mut buckets: Vec<SectionBucket> = Vec::new();
let mut probe = CandidateProbe::of(contents, get_or_build_plan_of(language));
let mut lines = get_lines_of(contents);
let mut handed_back = None;
while let Some((line_start, raw_line)) = handed_back.take().or_else(|| lines.next()) {
let has_candidates = probe.has_a_candidate_in(line_start, line_start + raw_line.len());
let had_code = walk_line::<EXPLAIN>(raw_line, line_start, language, collecting_spans,
has_candidates, scan, &mut shell, &mut shell_stats, code_spans, log);
if had_code && !language.nested_languages.is_empty()
&& let Some((region, inner)) = find_region_opening(raw_line.trim_ascii(), &scan.code_ranges, language, lookup) {
let section_from = end_of_line(contents, line_start, raw_line);
let Some(closer_at) = find_tag_ignoring_case(&contents.as_bytes()[section_from..],
region.end.as_bytes()) else { continue };
let closer_at = section_from + closer_at;
shell = WalkState::default();
let bucket_at = match buckets.iter().position(|bucket| bucket.language.name == inner.name) {
Some(at) => at,
None => {
buckets.push(SectionBucket { language: inner,
stats: if config.count_keywords { FileStats::with_keywords(&inner.keywords) }
else { FileStats::default() }, spans: Vec::new(),
collecting_spans: config.count_keywords && matchers.for_language(inner).is_some(),
bytes: 0 });
buckets.len() - 1
}
};
let bucket = &mut buckets[bucket_at];
let mut inner_state = WalkState::default();
let mut section_to = contents.len();
for (inner_start, inner_raw) in lines.by_ref() {
if inner_start + inner_raw.len() > closer_at {
section_to = inner_start;
handed_back = Some((inner_start, inner_raw));
break;
}
walk_line::<EXPLAIN>(inner_raw, inner_start, inner, bucket.collecting_spans,
true, scan, &mut inner_state, &mut bucket.stats, &mut bucket.spans, log);
}
bucket.bytes += section_to - section_from;
}
}
if config.count_keywords {
if let Some(matcher) = matchers.for_language(language) {
count_keywords(contents, code_spans, matcher, &mut shell_stats, alias_indices);
}
for bucket in &mut buckets {
if let Some(matcher) = matchers.for_language(bucket.language) {
count_keywords(contents, &bucket.spans, matcher, &mut bucket.stats, alias_indices);
}
}
}
FileReport {
shell: shell_stats,
sections: buckets.into_iter().map(|bucket| SectionReport {
language: bucket.language.name.clone(), stats: bucket.stats, bytes: bucket.bytes }).collect(),
bytes: contents.len()
}
}
fn walk_line<const EXPLAIN: bool>(raw_line: &str, line_start: usize, language: &Language, collecting_spans: bool,
has_candidates: bool, scan: &mut ScanBuffers, state: &mut WalkState, file_stats: &mut FileStats,
code_spans: &mut Vec<(u32, u32)>, log: &mut ExplainLog) -> bool
{
file_stats.lines += 1;
let carried = if EXPLAIN { CarriedRecord::of(state) } else { CarriedRecord::Nothing };
let from_start = raw_line.trim_ascii_start();
let line = from_start.trim_ascii_end();
if line.is_empty() {
let carried_by_a_continuation = state.continued_comment;
state.continued_comment = false;
let class = if state.open_str_symbol.is_some() { LineClass::BlankInString }
else if state.open_comment.is_some() || carried_by_a_continuation { LineClass::BlankInComment }
else { LineClass::Blank };
file_stats.classes.bump(class);
if EXPLAIN { log.record(class, carried, language, Vec::new()); }
if state.open_str_symbol.is_some_and(|symbol| !language.string_crosses_lines(symbol)) {
state.open_str_symbol = None;
}
return false;
}
let lead = raw_line.len() - from_start.len();
let base = line_start + lead;
if state.continued_comment {
let class = if has_word_byte(line.as_bytes()) { LineClass::WordsInComment }
else { LineClass::PunctuationInComment };
file_stats.classes.bump(class);
state.continued_comment = ends_with_continuation(line, language);
if EXPLAIN {
log.record(class, carried, language,
vec![Span { from: lead, to: lead + line.len(), kind: SpanKind::Comment }]);
}
return false;
}
let mut line_spans: Vec<Span> = Vec::new();
let (line_info, opened_here) = get_bounds::<EXPLAIN>(line, language, state.open_comment,
state.open_str_symbol, has_candidates, scan, &mut line_spans);
state.open_comment = line_info.open_comment_after;
state.open_str_symbol = line_info.open_str_symbol_after.filter(|symbol|
language.string_crosses_lines(*symbol)
|| (continues_in(language, |continuation| continuation.in_strings)
&& ends_with_continuation(line, language)));
let has_code = line_info.has_code;
let words_in_code = has_code && has_word_byte_in(&scan.code_ranges, line);
let counts_as_code = words_in_code || line_info.has_string_literal;
let counts_as_comment = !counts_as_code && has_word_byte(line.as_bytes());
state.continued_comment = state.open_str_symbol.is_none() && state.open_comment.is_none()
&& opened_here.ended_in_line_comment
&& continues_in(language, |continuation| continuation.in_comments)
&& ends_with_continuation(line, language);
let class = if words_in_code { LineClass::WordsInCode }
else if line_info.has_string_literal { LineClass::StringContent }
else if counts_as_comment {
if has_code { LineClass::CommentWordsBesideCode } else { LineClass::WordsInComment }
}
else if has_code { LineClass::PunctuationInCode }
else { LineClass::PunctuationInComment };
file_stats.classes.bump(class);
if EXPLAIN {
if (state.open_comment.is_some() && opened_here.comment)
|| (state.open_str_symbol.is_some() && opened_here.string)
|| state.continued_comment {
state.opened_line = log.get_current_line_number();
}
for span in &mut line_spans {
span.from += lead;
span.to += lead;
}
log.record(class, carried.with_its_end_marked(state, opened_here), language, line_spans);
}
if counts_as_code && collecting_spans && has_code {
push_trimmed_spans(code_spans, &scan.code_ranges, line, base);
}
has_code
}
fn end_of_line(contents: &str, line_start: usize, raw_line: &str) -> usize {
let mut end = line_start + raw_line.len();
if contents.as_bytes().get(end) == Some(&b'\r') { end += 1; }
if contents.as_bytes().get(end) == Some(&b'\n') { end += 1; }
end
}
fn find_region_opening<'a>(line: &str, code_ranges: &[(usize, usize)], language: &'a Language,
lookup: &'a NestedLanguageLookup) -> Option<(&'a NestedLanguage, &'a Language)>
{
let bytes = line.as_bytes();
for (from, to) in code_ranges {
let mut cursor = *from;
while let Some(offset) = memchr::memchr(b'<', &bytes[cursor..*to]) {
let at = cursor + offset;
cursor = at + 1;
for region in &language.nested_languages {
if !starts_with_ignoring_case(&bytes[at..], region.start.as_bytes()) {
continue;
}
let after_start = at + region.start.len();
match bytes.get(after_start) {
Some(byte) if byte.is_ascii_whitespace() || *byte == b'>' => (),
_ => continue
}
let Some(tag_close) = memchr::memchr(b'>', &bytes[after_start..]) else { continue };
if find_tag_ignoring_case(&bytes[after_start + tag_close..], region.end.as_bytes()).is_some() {
continue;
}
let tag_text = &line[after_start..after_start + tag_close];
let named = find_attribute_value(tag_text, "lang")
.or_else(|| find_attribute_value(tag_text, "type").map(strip_mime_family));
let inner = named.and_then(|value| lookup.find_by_spelling(value))
.or_else(|| lookup.find_by_spelling(®ion.default));
if let Some(inner) = inner {
return Some((region, inner));
}
}
}
}
None
}
fn find_attribute_value<'a>(tag_text: &'a str, name: &str) -> Option<&'a str> {
let bytes = tag_text.as_bytes();
let mut cursor = 0;
while let Some(offset) = find_case_insensitive(&bytes[cursor..], name.as_bytes()) {
let at = cursor + offset;
cursor = at + 1;
if at != 0 && !bytes[at - 1].is_ascii_whitespace() {
continue;
}
let rest = tag_text[at + name.len()..].trim_ascii_start();
let Some(value) = rest.strip_prefix('=') else { continue };
let value = value.trim_ascii_start();
return Some(match value.as_bytes().first() {
Some("e @ (b'"' | b'\'')) => value[1..].split(quote as char).next().unwrap_or(""),
_ => value.split_ascii_whitespace().next().unwrap_or("")
});
}
None
}
fn strip_mime_family(value: &str) -> &str {
value.rsplit('/').next().unwrap_or(value)
}
fn starts_with_ignoring_case(haystack: &[u8], needle: &[u8]) -> bool {
!needle.is_empty() && haystack.len() >= needle.len()
&& haystack[..needle.len()].eq_ignore_ascii_case(needle)
}
fn find_tag_ignoring_case(haystack: &[u8], needle: &[u8]) -> Option<usize> {
let name = needle.strip_suffix(b">").unwrap_or(needle);
let first = *name.first()?;
memchr::memchr2_iter(first.to_ascii_lowercase(), first.to_ascii_uppercase(), haystack)
.find(|at| closes_a_tag(&haystack[*at..], name))
}
fn closes_a_tag(rest: &[u8], name: &[u8]) -> bool {
if !starts_with_ignoring_case(rest, name) {
return false;
}
match rest.get(name.len()) {
Some(b'>') => true,
Some(byte) if byte.is_ascii_whitespace() => {
let line_end = memchr::memchr(b'\n', rest).unwrap_or(rest.len());
memchr::memchr(b'>', &rest[name.len()..line_end]).is_some()
},
_ => false
}
}
fn find_case_insensitive(haystack: &[u8], needle: &[u8]) -> Option<usize> {
if needle.is_empty() || haystack.len() < needle.len() {
return None;
}
(0..=haystack.len() - needle.len())
.find(|&at| haystack[at..at + needle.len()].eq_ignore_ascii_case(needle))
}
#[derive(Debug, PartialEq)]
struct LineInfo {
has_code: bool,
has_string_literal: bool,
open_comment_after: Option<(u8, u32)>,
open_str_symbol_after: Option<u8>
}
impl LineInfo {
fn of(has_code: bool, has_string_literal: bool) -> LineInfo {
LineInfo { has_code, has_string_literal, open_comment_after: None, open_str_symbol_after: None }
}
fn with_open_comment(has_code: bool, has_string_literal: bool, symbol: u8, depth: u32) -> LineInfo {
LineInfo { has_code, has_string_literal, open_comment_after: Some((symbol, depth)), open_str_symbol_after: None }
}
fn with_open_string(has_code: bool, symbol: Option<u8>) -> LineInfo {
LineInfo { has_code, has_string_literal: true, open_comment_after: None, open_str_symbol_after: symbol }
}
}
fn holds_one_character(between: &[u8]) -> bool {
match between.first() {
None => false,
Some(b'\\') => true,
Some(byte) if byte.is_ascii() => between.len() == 1,
Some(_) => std::str::from_utf8(between).is_ok_and(|text| text.chars().count() == 1)
}
}
fn continues_in(language: &Language, wanted: impl Fn(&LineContinuation) -> bool) -> bool {
language.line_continuation.as_ref().is_some_and(wanted)
}
fn ends_with_continuation(line: &str, language: &Language) -> bool {
let Some(continuation) = &language.line_continuation else { return false };
let bytes = line.as_bytes();
bytes.ends_with(continuation.symbol.as_bytes())
&& is_not_escaped(bytes.len() - continuation.symbol.len(), bytes, language.strings.get_escape())
}
fn push_code(ranges: &mut Vec<(usize, usize)>, line: &str, from: usize, to: usize) {
if to > from && !line[from..to].trim_ascii().is_empty() {
ranges.push((from, to));
}
}
fn has_word_byte(bytes: &[u8]) -> bool {
bytes.iter().any(|byte| byte.is_ascii_alphanumeric() || *byte >= 0x80)
}
fn has_word_byte_in(ranges: &[(usize, usize)], line: &str) -> bool {
let bytes = line.as_bytes();
ranges.iter().any(|(from, to)| has_word_byte(&bytes[*from..*to]))
}
#[derive(Debug, Default, Clone, Copy, PartialEq)]
struct OpenedHere {
comment: bool,
string: bool,
ended_in_line_comment: bool,
}
fn note_span<const EXPLAIN: bool>(spans: &mut Vec<Span>, from: usize, to: usize, kind: SpanKind) {
if EXPLAIN && to > from {
spans.push(Span { from, to, kind });
}
}
fn get_bounds<const EXPLAIN: bool>(line: &str, language: &Language, open_comment: Option<(u8, u32)>,
open_str_symbol: Option<u8>, has_candidates: bool, buffers: &mut ScanBuffers, spans: &mut Vec<Span>)
-> (LineInfo, OpenedHere)
{
if !has_candidates {
buffers.code_ranges.clear();
if let Some((symbol, depth)) = open_comment {
note_span::<EXPLAIN>(spans, 0, line.len(), SpanKind::Comment);
return (LineInfo::with_open_comment(false, false, symbol, depth), OpenedHere::default());
}
if open_str_symbol.is_some() {
note_span::<EXPLAIN>(spans, 0, line.len(), SpanKind::String);
return (LineInfo::with_open_string(false, open_str_symbol), OpenedHere::default());
}
push_code(&mut buffers.code_ranges, line, 0, line.len());
note_span::<EXPLAIN>(spans, 0, line.len(), SpanKind::Code);
return (LineInfo::of(true, false), OpenedHere::default());
}
if open_comment.is_none() && open_str_symbol.is_none()
&& get_or_build_plan_of(language).line_comment_ends_the_line
&& language.comment_symbols.iter().any(|symbol| line.as_bytes().starts_with(symbol.as_bytes())
&& stands_as_its_own_word(line.as_bytes(), 0, symbol.len())) {
note_span::<EXPLAIN>(spans, 0, line.len(), SpanKind::Comment);
return (LineInfo::of(false, false),
OpenedHere { ended_in_line_comment: true, ..OpenedHere::default() });
}
scan_line(line, language, buffers);
resolve_string_delimiters(language, open_str_symbol, buffers);
let ScanBuffers { strings: str_indices, string_symbols: str_symbols, comments: comment_indices,
com_starts: com_start_indices, com_ends: com_end_indices, code_ranges, .. } = buffers;
match open_comment {
None => if open_str_symbol.is_some() && str_indices.is_empty() {
note_span::<EXPLAIN>(spans, 0, line.len(), SpanKind::String);
return (LineInfo::with_open_string(false, open_str_symbol), OpenedHere::default());
},
Some((open_pair, carried)) => {
let leveled = language.comment_is_leveled(open_pair);
let has_end = com_end_indices.iter().any(|(_, symbol, level)|
*symbol == open_pair && (!leveled || *level as u32 == carried));
let deepens = language.comment_nests(open_pair)
&& com_start_indices.iter().any(|(_, symbol, _)| *symbol == open_pair);
if !has_end && !deepens {
note_span::<EXPLAIN>(spans, 0, line.len(), SpanKind::Comment);
return (LineInfo::with_open_comment(false, false, open_pair, carried), OpenedHere::default());
}
}
}
resolve_comment_and_multiline_end_overlap(line, language, comment_indices, com_end_indices);
resolve_comment_and_multiline_start_overlap(line, language, comment_indices, com_start_indices);
if !com_end_indices.is_empty() && !com_start_indices.is_empty() {
resolve_double_counting_of_adjacent_start_and_end_symbols(com_start_indices, com_end_indices,
open_comment.is_some(), language);
}
if str_indices.is_empty() && comment_indices.is_empty() && com_start_indices.is_empty() && com_end_indices.is_empty() {
push_code(code_ranges, line, 0, line.len());
note_span::<EXPLAIN>(spans, 0, line.len(), SpanKind::Code);
return (LineInfo::of(true, false), OpenedHere::default());
}
let (mut start_com_counter, mut end_com_counter, mut str_counter, mut comment_counter) = (0,0,0,0);
let (mut open_com_m, mut is_str_open_m) = (open_comment, open_str_symbol.is_some());
let mut opened = OpenedHere::default();
let mut region_from = 0;
let has_more_comments = |counter| counter < comment_indices.len();
let has_more_strs = |counter| counter < str_indices.len();
let has_more_ends = |counter| counter < com_end_indices.len();
let has_more_starts = |counter| counter < com_start_indices.len();
let next_symbol_is_comment = |comment_counter: usize, str_counter: usize,
start_counter: usize| {
if !has_more_comments(comment_counter) {return false; }
if has_more_strs(str_counter) && comment_indices[comment_counter] > str_indices[str_counter] {
return false;
}
if has_more_starts(start_counter) && comment_indices[comment_counter] > com_start_indices[start_counter].0 {
return false;
}
true
};
let next_symbol_is_string = |comment_counter: usize, str_counter: usize,
start_counter: usize| {
if !has_more_strs(str_counter) {return false;}
if has_more_comments(comment_counter) && str_indices[str_counter] > comment_indices[comment_counter] {
return false;
}
if has_more_starts(start_counter) && str_indices[str_counter] > com_start_indices[start_counter].0 {
return false;
}
true
};
let next_symbol_is_com_start = |comment_counter: usize, str_counter: usize,
start_counter: usize| {
if !has_more_starts(start_counter) {return false;}
if has_more_comments(comment_counter) && com_start_indices[start_counter].0 > comment_indices[comment_counter] {
return false;
}
if has_more_strs(str_counter) && com_start_indices[start_counter].0 > str_indices[str_counter] {
return false;
}
true
};
let progress_counters_after = |index, comment_counter: &mut usize, str_counter: &mut usize,
start_counter: &mut usize, end_counter: &mut usize| {
while *comment_counter < comment_indices.len() && comment_indices[*comment_counter] < index {
*comment_counter += 1;
}
while *str_counter < str_indices.len() && str_indices[*str_counter] < index {
*str_counter += 1;
}
while *start_counter < com_start_indices.len() && com_start_indices[*start_counter].0 < index {
*start_counter += 1;
}
while *end_counter < com_end_indices.len() && com_end_indices[*end_counter].0 < index {
*end_counter += 1;
}
};
let skipped_com_end_symbol = |last_symbol_index: usize, end_com_counter: usize, cur_index: usize| {
has_more_ends(end_com_counter) && com_end_indices[end_com_counter].0 < cur_index && com_end_indices[end_com_counter].0 >= last_symbol_index
};
let mut has_string_literal = false;
let mut slice_start_index = 0;
let mut last_symbol_index = 0;
loop {
if is_str_open_m {
last_symbol_index = str_indices[str_counter];
let index_after = last_symbol_index
+ language.get_string_pair_of(str_symbols[str_counter]).1.len();
if index_after >= line.len() {
note_span::<EXPLAIN>(spans, region_from, line.len(), SpanKind::String);
return (LineInfo::of(!code_ranges.is_empty(), true), OpenedHere::default());
}
note_span::<EXPLAIN>(spans, region_from, index_after, SpanKind::String);
region_from = index_after;
progress_counters_after(last_symbol_index, &mut comment_counter, &mut str_counter,
&mut start_com_counter, &mut end_com_counter);
is_str_open_m = false;
str_counter += 1;
has_string_literal = true;
slice_start_index = index_after;
} else if let Some((open_pair, carried)) = open_com_m {
let leveled = language.comment_is_leveled(open_pair);
let nests = language.comment_nests(open_pair);
let mut depth = if leveled { 1 } else { carried };
let closing = loop {
while end_com_counter < com_end_indices.len()
&& (com_end_indices[end_com_counter].1 != open_pair
|| (leveled && com_end_indices[end_com_counter].2 as u32 != carried)) {
end_com_counter += 1;
}
if end_com_counter == com_end_indices.len() { break None; }
let end_at = com_end_indices[end_com_counter].0;
if nests {
while start_com_counter < com_start_indices.len() && com_start_indices[start_com_counter].0 < end_at {
if com_start_indices[start_com_counter].1 == open_pair { depth = depth.saturating_add(1); }
start_com_counter += 1;
}
}
depth -= 1;
if depth == 0 { break Some(end_at); }
end_com_counter += 1;
};
let Some(closed_at) = closing else {
let mut carry = carried;
if nests {
while start_com_counter < com_start_indices.len() {
if com_start_indices[start_com_counter].1 == open_pair { depth = depth.saturating_add(1); }
start_com_counter += 1;
}
carry = depth;
}
note_span::<EXPLAIN>(spans, region_from, line.len(), SpanKind::Comment);
return (LineInfo::with_open_comment(has_string_literal || !code_ranges.is_empty(),
has_string_literal, open_pair, carry), opened);
};
last_symbol_index = closed_at;
let end_level = if leveled { carried as u8 } else { 0 };
let index_after = last_symbol_index + language.comment_end_len(open_pair, end_level);
if index_after >= line.len() {
note_span::<EXPLAIN>(spans, region_from, line.len(), SpanKind::Comment);
return (LineInfo::of(!code_ranges.is_empty(), has_string_literal), OpenedHere::default());
}
note_span::<EXPLAIN>(spans, region_from, index_after, SpanKind::Comment);
region_from = index_after;
open_com_m = None;
progress_counters_after(index_after, &mut comment_counter, &mut str_counter,
&mut start_com_counter, &mut end_com_counter);
slice_start_index = index_after;
} else {
if next_symbol_is_comment(comment_counter, str_counter, start_com_counter) {
let comment_at = comment_indices[comment_counter];
push_code(code_ranges, line, slice_start_index, comment_at);
note_span::<EXPLAIN>(spans, region_from, comment_at, SpanKind::Code);
note_span::<EXPLAIN>(spans, comment_at, line.len(), SpanKind::Comment);
let ends = OpenedHere { ended_in_line_comment: true, ..OpenedHere::default() };
return (LineInfo::of(!code_ranges.is_empty(), has_string_literal), ends);
} else if next_symbol_is_string(comment_counter, str_counter, start_com_counter) {
let this_index = str_indices[str_counter];
if skipped_com_end_symbol(last_symbol_index, end_com_counter, this_index) {
end_com_counter += 1;
}
push_code(code_ranges, line, slice_start_index, this_index);
note_span::<EXPLAIN>(spans, region_from, this_index, SpanKind::Code);
region_from = this_index;
str_counter += 1;
if !has_more_strs(str_counter) {
note_span::<EXPLAIN>(spans, region_from, line.len(), SpanKind::String);
return (LineInfo::with_open_string(!code_ranges.is_empty(), Some(str_symbols[str_counter-1])),
OpenedHere { string: true, ..OpenedHere::default() });
}
is_str_open_m = true;
has_string_literal = true;
last_symbol_index = this_index;
} else if next_symbol_is_com_start(comment_counter, str_counter, start_com_counter) {
let (this_index, this_symbol, this_level) = com_start_indices[start_com_counter];
if skipped_com_end_symbol(last_symbol_index, end_com_counter, this_index) {
end_com_counter += 1;
}
push_code(code_ranges, line, slice_start_index, this_index);
note_span::<EXPLAIN>(spans, region_from, this_index, SpanKind::Code);
region_from = this_index;
if !has_more_ends(end_com_counter) && !language.comment_nests(this_symbol)
&& !language.comment_is_leveled(this_symbol) {
note_span::<EXPLAIN>(spans, region_from, line.len(), SpanKind::Comment);
return (LineInfo::with_open_comment(has_string_literal || !code_ranges.is_empty(),
has_string_literal, this_symbol, 1), OpenedHere { comment: true, ..OpenedHere::default() });
}
open_com_m = Some((this_symbol,
if language.comment_is_leveled(this_symbol) { this_level as u32 } else { 1 }));
opened.comment = true;
start_com_counter += 1;
last_symbol_index = this_index;
} else {
push_code(code_ranges, line, slice_start_index, line.len());
note_span::<EXPLAIN>(spans, region_from, line.len(), SpanKind::Code);
return (LineInfo::of(true, has_string_literal), OpenedHere::default());
}
}
}
}
fn resolve_double_counting_of_adjacent_start_and_end_symbols(start_indices: &mut Vec<(usize, u8, u8)>,
end_indices: &mut Vec<(usize, u8, u8)>, is_comment_open: bool, language: &Language)
{
fn resolve_collision(start_indices: &mut Vec<(usize, u8, u8)>, end_indices: &mut Vec<(usize, u8, u8)>, start_counter: &mut usize,
end_counter: &mut usize, is_comment_open_m: &mut bool, language: &Language)
{
if *is_comment_open_m {
start_indices.remove(*start_counter);
if *start_counter < start_indices.len() && start_indices[*start_counter].0 <
end_indices[*end_counter].0 + language.comment_end_len(end_indices[*end_counter].1, end_indices[*end_counter].2) {
start_indices.remove(*start_counter);
}
*end_counter += 1;
} else {
end_indices.remove(*end_counter);
if *end_counter < end_indices.len() && end_indices[*end_counter].0 <
start_indices[*start_counter].0 + language.comment_start_len(start_indices[*start_counter].1, start_indices[*start_counter].2) {
end_indices.remove(*end_counter);
}
*start_counter += 1;
}
*is_comment_open_m = !*is_comment_open_m;
}
let mut is_comment_open_m = is_comment_open;
let (mut start_counter, mut end_counter) = (0,0);
loop {
if start_counter == start_indices.len() || end_counter == end_indices.len() {break;}
let (start_index, start_symbol, start_level) = start_indices[start_counter];
let (end_index, end_symbol, end_level) = end_indices[end_counter];
if end_index > start_index && end_index < start_index + language.comment_start_len(start_symbol, start_level) ||
start_index > end_index && start_index < end_index + language.comment_end_len(end_symbol, end_level) {
resolve_collision(start_indices, end_indices, &mut start_counter, &mut end_counter, &mut is_comment_open_m, language);
} else {
if start_index < end_index {
start_counter += 1;
if start_counter < start_indices.len() {
if start_indices[start_counter].0 > end_index {
is_comment_open_m = true;
}
} else {
break;
}
}
else {
end_counter += 1;
if end_counter < end_indices.len() {
if end_indices[end_counter].0 > start_counter {
is_comment_open_m = false;
}
} else {
break;
}
}
}
}
}
fn push_trimmed_spans(spans: &mut Vec<(u32, u32)>, ranges: &[(usize, usize)], line: &str, base: usize) {
let bytes = line.as_bytes();
let (mut head, mut tail) = (0usize, ranges.len());
let (mut head_from, mut tail_to) = (0usize, 0usize);
while head < tail {
let (from, to) = ranges[head];
let mut at = from;
while at < to && bytes[at].is_ascii_whitespace() { at += 1; }
if at < to { head_from = at; break; }
head += 1;
}
if head == tail { return; }
while tail > head {
let (from, to) = ranges[tail - 1];
let floor = if tail - 1 == head { head_from } else { from };
let mut at = to;
while at > floor && bytes[at - 1].is_ascii_whitespace() { at -= 1; }
if at > floor { tail_to = at; break; }
tail -= 1;
}
for (i, (from, to)) in ranges.iter().enumerate().take(tail).skip(head) {
let from = if i == head { head_from } else { *from };
let to = if i == tail - 1 { tail_to } else { *to };
spans.push(((base + from) as u32, (base + to) as u32));
}
}
fn count_keywords(contents: &str, spans: &[(u32, u32)], matcher: &KeywordMatcher,
file_stats: &mut FileStats, indices: &mut Vec<usize>)
{
fn is_acceptable_before(byte: Option<&u8>) -> bool {
match byte {
None => true,
Some(b) => *b == b' ' || *b == b'}' || *b == b'{' || *b == b','
}
}
fn is_acceptable_after(byte: Option<&u8>) -> bool {
matches!(byte, Some(b'(')) || is_acceptable_before(byte)
}
if spans.is_empty() { return; }
let bytes = contents.as_bytes();
for (alias_finder, alias_len, keyword_index) in &matcher.aliases_with_indices {
indices.clear();
indices.extend(alias_finder.find_iter(bytes));
if indices.is_empty() { continue; }
let mut span = 0;
for (found, at) in indices.iter().enumerate() {
if (found > 0 && indices[found - 1] + alias_len == *at)
|| indices.get(found + 1).is_some_and(|next| at + alias_len == *next) {
continue;
}
while span < spans.len() && (spans[span].1 as usize) <= *at { span += 1; }
if span == spans.len() { break; }
let (from, to) = (spans[span].0 as usize, spans[span].1 as usize);
if *at < from || at + alias_len > to { continue; }
let before = if *at > from { bytes.get(*at - 1) } else { None };
let after = if at + alias_len < to { bytes.get(at + alias_len) } else { None };
if is_acceptable_before(before) && is_acceptable_after(after) {
file_stats.keyword_occurences[*keyword_index] += 1;
}
}
}
}
fn resolve_string_delimiters(language: &Language, open_str_symbol: Option<u8>, buffers: &mut ScanBuffers) {
let ScanBuffers { raw_strings, strings, string_symbols, .. } = buffers;
let mut open = open_str_symbol;
let mut consumed_up_to = 0;
for &(at, symbol, role) in raw_strings.iter() {
if at < consumed_up_to {
continue;
}
let length = match open {
Some(open_symbol) => {
if open_symbol != symbol || role == ROLE_OPEN { continue; }
open = None;
language.get_string_pair_of(symbol).1.len()
}
None => {
if role == ROLE_CLOSE || role == ROLE_RAW_ESCAPED { continue; }
open = Some(symbol);
language.get_string_pair_of(symbol).0.len()
}
};
consumed_up_to = at + length;
strings.push(at);
string_symbols.push(symbol);
}
}
fn resolve_comment_and_multiline_start_overlap(line: &str, language: &Language,
comment_indices: &mut Vec<usize>, com_start_indices: &mut Vec<(usize, u8, u8)>)
{
if comment_indices.is_empty() || com_start_indices.is_empty() {
return;
}
let longest_comment_at = |at: usize| {
language.comment_symbols.iter()
.filter(|symbol| line.as_bytes()[at..].starts_with(symbol.as_bytes()))
.map(String::len)
.max()
.unwrap_or(0)
};
com_start_indices.retain(|(start, _, _)| !comment_indices.iter()
.any(|at| start > at && *start < at + longest_comment_at(*at)));
comment_indices.retain(|at| !com_start_indices.iter()
.any(|(start, symbol, level)| at > start && *at < start + language.comment_start_len(*symbol, *level)));
comment_indices.retain(|at| match com_start_indices.iter().find(|(start, _, _)| start == at) {
Some((_, symbol, level)) => longest_comment_at(*at) >= language.comment_start_len(*symbol, *level),
None => true
});
com_start_indices.retain(|(at, _, _)| !comment_indices.contains(at));
}
fn resolve_comment_and_multiline_end_overlap(line: &str, language: &Language,
comment_indices: &mut Vec<usize>, com_end_indices: &[(usize, u8, u8)])
{
if comment_indices.is_empty() || com_end_indices.is_empty() {
return;
}
let past_the_end_symbol_at = |at: usize| com_end_indices.iter().find_map(|(end, symbol, level)| {
let after = end + language.comment_end_len(*symbol, *level);
(at > *end && at < after).then_some(after)
});
let starts_a_comment = |at: usize| language.comment_symbols.iter()
.any(|symbol| line.as_bytes()[at..].starts_with(symbol.as_bytes())
&& stands_as_its_own_word(line.as_bytes(), at, symbol.len()));
for at in comment_indices.iter_mut() {
if let Some(after) = past_the_end_symbol_at(*at) {
*at = after;
}
}
comment_indices.retain(|at| *at < line.len() && starts_a_comment(*at));
comment_indices.dedup();
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, LazyLock};
use super::*;
use crate::{CountingModel, Keyword, LineClasses, Stats, StringRules};
use crate::test_paths::{FIXTURES_DIR, LANGUAGES_DIR};
use crate::engine::identity::{ClaimKind, LanguageLookup, build_language_map_by};
fn sample_file(name: &str) -> std::path::PathBuf {
Path::new(FIXTURES_DIR).join("parser").join(name)
}
#[derive(Debug, PartialEq)]
struct TextInfo {
cleansed_string: Option<String>,
has_string_literal: bool,
open_comment_after: Option<(u8, u32)>,
open_str_symbol_after: Option<u8>
}
impl TextInfo {
fn from_slice(slice: &str) -> TextInfo {
TextInfo { cleansed_string: Some(slice.to_owned()), has_string_literal: false, open_comment_after: None, open_str_symbol_after: None }
}
fn from_slice_w_literal(slice: &str) -> TextInfo {
TextInfo { cleansed_string: Some(slice.to_owned()), has_string_literal: true, open_comment_after: None, open_str_symbol_after: None }
}
fn with_open_comment(symbol: u8) -> TextInfo {
TextInfo { cleansed_string: None, has_string_literal: false, open_comment_after: Some((symbol, 1)), open_str_symbol_after: None }
}
fn with_open_comment_at(symbol: u8, depth: u32) -> TextInfo {
TextInfo { cleansed_string: None, has_string_literal: false, open_comment_after: Some((symbol, depth)), open_str_symbol_after: None }
}
fn with_open_symbol(symbol: u8) -> TextInfo {
TextInfo { cleansed_string: None, has_string_literal: true, open_comment_after: None, open_str_symbol_after: Some(symbol) }
}
fn none_all(has_string_literal: bool) -> TextInfo {
TextInfo { cleansed_string: None, has_string_literal, open_comment_after: None, open_str_symbol_after: None }
}
fn new(cleansed_string: Option<String>, has_string_literal: bool, open_comment_after: Option<(u8, u32)>, open_str_symbol_after: Option<u8>) -> TextInfo {
TextInfo { cleansed_string, has_string_literal, open_comment_after, open_str_symbol_after }
}
}
fn text_of(line: &str, info: LineInfo, buffers: &ScanBuffers) -> TextInfo {
TextInfo {
cleansed_string: info.has_code.then(||
buffers.code_ranges.iter().map(|(a, b)| &line[*a..*b]).collect::<String>()),
has_string_literal: info.has_string_literal,
open_comment_after: info.open_comment_after,
open_str_symbol_after: info.open_str_symbol_after
}
}
fn bounds_multi(line: &str, language: &Language, open_comment: Option<u8>, open_str_symbol: Option<u8>) -> TextInfo {
bounds_multi_deep(line, language, open_comment.map(|symbol| (symbol, 1)), open_str_symbol)
}
fn bounds_multi_deep(line: &str, language: &Language, open_comment: Option<(u8, u32)>, open_str_symbol: Option<u8>) -> TextInfo {
let mut buffers = ScanBuffers::default();
let (info, _) = get_bounds::<false>(line, language, open_comment, open_str_symbol, true,
&mut buffers, &mut Vec::new());
text_of(line, info, &buffers)
}
fn keywords_of(line: &str, matcher: &KeywordMatcher, file_stats: &mut FileStats) {
count_keywords(line, &[(0, line.len() as u32)], matcher, file_stats, &mut Vec::new());
}
fn str_delimiters(line: &str, language: &Language, open_str_symbol: Option<u8>) -> (Vec<usize>, Vec<u8>) {
let mut buffers = ScanBuffers::default();
scan_line(line, language, &mut buffers);
resolve_string_delimiters(language, open_str_symbol, &mut buffers);
(buffers.strings, buffers.string_symbols)
}
fn comment_delimiters(line: &str, language: &Language) -> Vec<usize> {
let mut buffers = ScanBuffers::default();
scan_line(line, language, &mut buffers);
buffers.comments
}
fn comment_delimiters_w_multiline(line: &str, language: &Language, com_end_indices: &[usize]) -> Vec<usize> {
let ends = com_end_indices.iter().map(|at| (*at, 0u8, 0u8)).collect::<Vec<_>>();
let mut buffers = ScanBuffers::default();
scan_line(line, language, &mut buffers);
resolve_comment_and_multiline_end_overlap(line, language, &mut buffers.comments, &ends);
buffers.comments
}
static CLASS : LazyLock<Keyword> = LazyLock::new(|| Keyword::new("classes", ["class"]));
static INTERFACE : LazyLock<Keyword> = LazyLock::new(|| Keyword::new("interfaces", ["interface"]));
static ENUM : LazyLock<Keyword> = LazyLock::new(|| Keyword::new("enums", ["enum"]));
static STRUCT : LazyLock<Keyword> = LazyLock::new(|| Keyword::new("structs", ["struct"]));
static TRAIT : LazyLock<Keyword> = LazyLock::new(|| Keyword::new("traits", ["trait"]));
fn build_backslashed_quotes() -> StringRules {
StringRules::escaping_with(b'\\').with_symbols(["\""])
}
static JAVA : LazyLock<Language> = LazyLock::new(|| Language::new("java", ["java"],
build_backslashed_quotes(), ["//"], &[("/*", "*/")], [CLASS.clone(), INTERFACE.clone()]));
static PHP : LazyLock<Language> = LazyLock::new(|| Language::new("PHP", ["php"],
StringRules::escaping_with(b'\\').with_symbols(["\"", "'"]), ["//", "#"],
&[("/*", "*/")], [CLASS.clone()]));
static PYTHON : LazyLock<Language> = LazyLock::new(|| Language::new("py", ["py"],
StringRules::escaping_with(b'\\').with_symbols(["\"", "'"]), ["#"], &[], [CLASS.clone()]));
static RUST : LazyLock<Language> = LazyLock::new(|| Language::new("rust", ["rs"],
build_backslashed_quotes(), ["//"], &[("/*", "*/")],
[STRUCT.clone(), ENUM.clone(), TRAIT.clone()]));
static PYTHON_FULL : LazyLock<Language> = LazyLock::new(|| Language::new("py", ["py"],
StringRules::escaping_with(b'\\').with_symbols(["\"", "'"])
.with_multiline_strings(["\"\"\"", "'''"]),
["#", "//", "--"], &[], [CLASS.clone()]));
static LANGUAGE_MAP_REF : LazyLock<Arc<HashMap<String,Language>>> = LazyLock::new(||
Arc::new(crate::languages::keyed_by_name(crate::language_file::parse_languages_in_dir(LANGUAGES_DIR).unwrap().0)));
static JAVA_MATCHER : LazyLock<KeywordMatcher> = LazyLock::new(|| KeywordMatcher::build(&JAVA).unwrap());
static NO_EXTENSIONS : LazyLock<HashMap<String, Arc<str>>> = LazyLock::new(HashMap::new);
static NO_SET_ASIDE : LazyLock<HashMap<String, Language>> = LazyLock::new(HashMap::new);
static SHIPPED_EXTENSIONS : LazyLock<HashMap<String, Arc<str>>> = LazyLock::new(||
build_language_map_by(ClaimKind::Extension, &LANGUAGE_MAP_REF, &HashMap::new(), &HashMap::new()).0);
fn shipped_lookup() -> NestedLanguageLookup<'static> {
NestedLanguageLookup { languages: &LANGUAGE_MAP_REF, extension_to_name: &SHIPPED_EXTENSIONS, set_aside: &NO_SET_ASIDE }
}
fn parse_file_whole(path: &Path, lang_name: &str, buf: &mut Vec<u8>, config: &EngineConfig) -> Result<FileStats, String> {
parse_file_report(path, lang_name, buf, config).map(FileReport::into_whole)
}
fn parse_file_report(path: &Path, lang_name: &str, buf: &mut Vec<u8>, config: &EngineConfig) -> Result<FileReport, String> {
match parse_file(path, get_size_of(path), lang_name, buf, &mut ParseBuffers::default(), &shipped_lookup(),
&mut KeywordMatchers::default(), &mut IdentificationMatchers::default(), config,
false, None, &HashMap::new())? {
FileOutcome::Counted(report, _) => Ok(report),
FileOutcome::Skipped(kind) => panic!("{} was skipped as {}", path.display(), kind.name())
}
}
fn get_size_of(path: &Path) -> u64 {
std::fs::metadata(path).map_or(0, |m| m.len())
}
fn parse_lines_whole(contents: &str, language: &Language) -> FileStats {
parse_lines::<false>(contents, language, &NestedLanguageLookup { languages: &NO_SET_ASIDE,
extension_to_name: &NO_EXTENSIONS, set_aside: &NO_SET_ASIDE },
&mut KeywordMatchers::default(), &EngineConfig::default(), &mut ParseBuffers::default(),
&mut ExplainLog::default()).into_whole()
}
fn c_like_with_a_splice() -> Language {
Language::new("c-like", ["c"], build_backslashed_quotes(), ["//"], &[("/*", "*/")], [])
.with_line_continuation("\\", true, true)
}
#[test]
fn a_spliced_line_comment_carries_even_off_a_line_that_also_holds_code() {
let language = c_like_with_a_splice();
let stats = parse_lines_whole("int a = 1; // comment \\\n joined to the comment\nint x = 1;\n", &language);
assert_eq!(2, stats.classes.words_in_code);
assert_eq!(1, stats.classes.words_in_comment);
let stats = parse_lines_whole("/* block */ \\\nint x = 1;\n", &language);
assert_eq!(1, stats.classes.words_in_code);
assert_eq!(1, stats.classes.comment_words_beside_code);
}
#[test]
fn a_blank_line_the_splice_joined_to_a_comment_belongs_to_that_comment() {
let language = c_like_with_a_splice();
let stats = parse_lines_whole("// a comment \\\n\nint x = 1;\n", &language);
assert_eq!(1, stats.classes.blank_in_comment);
assert_eq!(0, stats.classes.blank);
assert_eq!(1, stats.classes.words_in_comment);
assert_eq!(1, stats.classes.words_in_code);
let stats = parse_lines_whole("// a comment \\\n\n\nint x = 1;\n", &language);
assert_eq!(1, stats.classes.blank_in_comment);
assert_eq!(1, stats.classes.blank);
let plain = Language::new("c-like", ["c"], build_backslashed_quotes(), ["//"], &[("/*", "*/")], []);
let stats = parse_lines_whole("// a comment \\\n\nint x = 1;\n", &plain);
assert_eq!(0, stats.classes.blank_in_comment);
assert_eq!(1, stats.classes.blank);
}
#[test]
fn a_blank_line_ends_a_string_the_splice_was_carrying() {
let stats = parse_lines_whole("char *s = \"abc \\\n\n// a comment\";\nint x = 1;\n",
&c_like_with_a_splice());
assert_eq!(2, stats.classes.words_in_code);
assert_eq!(1, stats.classes.blank_in_string);
assert_eq!(1, stats.classes.words_in_comment);
let crossing = Language::new("py-like", ["py"],
build_backslashed_quotes().with_multiline_strings(["\"\"\""]), ["#"], &[], []);
let stats = parse_lines_whole("\"\"\" open\n\nstill inside \"\"\"\n", &crossing);
assert_eq!(1, stats.classes.blank_in_string);
assert_eq!(2, stats.classes.string_content);
}
fn content_info_of(file: FileStats, lang_name: &str) -> Stats {
let language = LANGUAGE_MAP_REF.get(lang_name).unwrap();
let mut stats = Stats::from(language);
stats.add_file(&file, 0, &language.keywords);
stats
}
fn content_counts(stats: &FileStats) -> (usize, usize, usize) {
(stats.lines, CountingModel::Content.calculate_code_lines(&stats.classes),
CountingModel::Content.calculate_comment_lines(&stats.classes))
}
#[test]
fn a_sample_file_is_counted_under_each_language_that_claims_it_with_that_languages_keywords() {
let mut buf = Vec::with_capacity(150);
let assert_sample = |file: &str, lang: &str, counts: (usize, usize, usize),
keywords: HashMap<String, usize>, config: &EngineConfig, buf: &mut Vec<u8>| {
let file_stats = parse_file_whole(&sample_file(file), lang, buf, config).unwrap();
assert_eq!(counts, content_counts(&file_stats), "{file} as {lang}");
assert_eq!(keywords, content_info_of(file_stats, lang).keyword_occurences, "{file} as {lang}");
buf.clear();
};
let mut config = EngineConfig::default();
assert_sample("a.txt", "Java", (44, 13, 8),
hashmap!("classes".to_owned()=>3,"interfaces".to_owned()=>0), &config, &mut buf);
config.count_keywords = false;
assert_sample("a.txt", "Java", (44, 13, 8),
hashmap!("classes".to_owned()=>0,"interfaces".to_owned()=>0), &config, &mut buf);
config.count_keywords = true;
assert_sample("a.txt", "C#", (44, 13, 8),
hashmap!("structs".to_owned()=>0,"classes".to_owned()=>3,"interfaces".to_owned()=>0), &config, &mut buf);
assert_sample("d.txt", "C#", (19, 7, 7),
hashmap!("structs".to_owned()=>0,"classes".to_owned()=>5,"interfaces".to_owned()=>0), &config, &mut buf);
assert_sample("d.txt", "Java", (19, 7, 7),
hashmap!("classes".to_owned()=>5,"interfaces".to_owned()=>0), &config, &mut buf);
assert_sample("b.txt", "Java", (19, 11, 4),
hashmap!("classes".to_owned()=>7,"interfaces".to_owned()=>0), &config, &mut buf);
assert_sample("c.txt", "Python", (11, 6, 1),
hashmap!("classes".to_owned()=>3), &config, &mut buf);
}
#[test]
fn one_parse_answers_both_models_through_the_classes() {
let mut buf = Vec::with_capacity(150);
let stats = parse_file_whole(&sample_file("a.txt"), "Java", &mut buf, &EngineConfig::default()).unwrap();
assert_eq!(LineClasses {
words_in_code: 13, string_content: 0, comment_words_beside_code: 0, words_in_comment: 8,
punctuation_in_code: 10, punctuation_in_comment: 7, blank: 6, blank_in_comment: 0,
blank_in_string: 0
}, stats.classes);
assert_eq!((44, 13, 8), content_counts(&stats));
assert_eq!(23, CountingModel::Region.calculate_code_lines(&stats.classes));
assert_eq!(15, CountingModel::Region.calculate_comment_lines(&stats.classes));
}
#[test]
fn a_line_with_no_symbol_byte_on_it_reads_the_same_with_the_scan_skipped() {
let line = "let total = width + height";
assert!(!line.contains(['/', '*', '"', '\'']), "the line carries a symbol byte");
let read = |has_candidates: bool, open_comment, open_str_symbol| {
let mut buffers = ScanBuffers::default();
get_bounds::<true>("let seeded = 1", &RUST, None, None, true, &mut buffers, &mut Vec::new());
assert!(!buffers.code_ranges.is_empty(), "the seeding line left no code range behind");
let mut spans = Vec::new();
let answer = get_bounds::<true>(line, &RUST, open_comment, open_str_symbol, has_candidates,
&mut buffers, &mut spans);
(answer, buffers.code_ranges.clone(), spans)
};
for (open_comment, open_str_symbol) in [(None, None), (Some((0u8, 1u32)), None), (None, Some(0u8))] {
assert_eq!(read(true, open_comment, open_str_symbol), read(false, open_comment, open_str_symbol),
"the shortcut disagreed with the scan for {open_comment:?} and {open_str_symbol:?}");
}
}
#[test]
fn a_line_counts_where_its_words_are_and_bare_delimiters_are_extra() {
let counts = |contents: &str| content_counts(&parse_lines_whole(contents, &JAVA));
assert_eq!((4, 0, 1), counts("/*\n* words here\n*\n*/\n"));
assert_eq!((2, 1, 0), counts("/*----------*/\nint x = 1;\n"));
assert_eq!((3, 1, 1), counts("//\n// words\nint x = 1;\n"));
assert_eq!((2, 1, 1), counts("int x = 1;\n} // end of main\n"));
assert_eq!((3, 0, 2), counts("/* words\n*/ }\n/* more words\n"));
}
#[test]
fn a_keyword_split_by_a_string_is_not_a_keyword() {
let line = "str\"X\"uct a;";
let mut file_stats = FileStats::with_keywords(&[STRUCT.clone(),ENUM.clone(),TRAIT.clone()]);
let matcher = KeywordMatcher::build(&RUST).unwrap();
let mut buffers = ScanBuffers::default();
let (info, _) = get_bounds::<false>(line, &RUST, None, None, true, &mut buffers, &mut Vec::new());
let mut spans = Vec::new();
assert!(info.has_code);
push_trimmed_spans(&mut spans, &buffers.code_ranges, line, 0);
count_keywords(line, &spans, &matcher, &mut file_stats, &mut Vec::new());
assert_eq!(0, file_stats.keyword_occurences[0]);
let line = "struct a;";
let mut file_stats = FileStats::with_keywords(&[STRUCT.clone(),ENUM.clone(),TRAIT.clone()]);
let mut buffers = ScanBuffers::default();
let (info, _) = get_bounds::<false>(line, &RUST, None, None, true, &mut buffers, &mut Vec::new());
let mut spans = Vec::new();
assert!(info.has_code);
push_trimmed_spans(&mut spans, &buffers.code_ranges, line, 0);
count_keywords(line, &spans, &matcher, &mut file_stats, &mut Vec::new());
assert_eq!(1, file_stats.keyword_occurences[0]);
}
#[test]
fn a_keyword_counts_only_where_it_stands_as_a_word_of_its_own() {
let line = String::from("Hello world!");
let mut file_stats = FileStats::with_keywords(&[CLASS.clone(),INTERFACE.clone()]);
keywords_of(&line, &JAVA_MATCHER, &mut file_stats);
assert_eq!(make_file_stats(0,0), file_stats);
let line = String::from("class");
let mut file_stats = FileStats::with_keywords(&[CLASS.clone(),INTERFACE.clone()]);
keywords_of(&line, &JAVA_MATCHER, &mut file_stats);
assert_eq!(make_file_stats(1,0), file_stats);
let line = String::from("1class");
let mut file_stats = FileStats::with_keywords(&[CLASS.clone(),INTERFACE.clone()]);
keywords_of(&line, &JAVA_MATCHER, &mut file_stats);
assert_eq!(make_file_stats(0,0), file_stats);
let line = String::from("hello class word!");
let mut file_stats = FileStats::with_keywords(&[CLASS.clone(),INTERFACE.clone()]);
keywords_of(&line, &JAVA_MATCHER, &mut file_stats);
assert_eq!(make_file_stats(1,0), file_stats);
let line = String::from("class class class");
let mut file_stats = FileStats::with_keywords(&[CLASS.clone(),INTERFACE.clone()]);
keywords_of(&line, &JAVA_MATCHER, &mut file_stats);
assert_eq!(make_file_stats(3,0), file_stats);
let line = String::from("classclass");
let mut file_stats = FileStats::with_keywords(&[CLASS.clone(),INTERFACE.clone()]);
keywords_of(&line, &JAVA_MATCHER, &mut file_stats);
assert_eq!(make_file_stats(0,0), file_stats);
let line = String::from("hello,class{word!");
let mut file_stats = FileStats::with_keywords(&[CLASS.clone(),INTERFACE.clone()]);
keywords_of(&line, &JAVA_MATCHER, &mut file_stats);
assert_eq!(make_file_stats(1,0), file_stats);
let line = String::from("classe,");
let mut file_stats = FileStats::with_keywords(&[CLASS.clone(),INTERFACE.clone()]);
keywords_of(&line, &JAVA_MATCHER, &mut file_stats);
assert_eq!(make_file_stats(0,0), file_stats);
let line = String::from("class interfaceclass classinterface interface");
let mut file_stats = FileStats::with_keywords(&[CLASS.clone(),INTERFACE.clone()]);
keywords_of(&line, &JAVA_MATCHER, &mut file_stats);
assert_eq!(make_file_stats(1,1), file_stats);
let line = String::from("{class,interface}");
let mut file_stats = FileStats::with_keywords(&[CLASS.clone(),INTERFACE.clone()]);
keywords_of(&line, &JAVA_MATCHER, &mut file_stats);
assert_eq!(make_file_stats(1,1), file_stats);
let line = String::from("{class.interface}");
let mut file_stats = FileStats::with_keywords(&[CLASS.clone(),INTERFACE.clone()]);
keywords_of(&line, &JAVA_MATCHER, &mut file_stats);
assert_eq!(make_file_stats(0,0), file_stats);
let line = String::from("TFoo = class(TObject)");
let mut file_stats = FileStats::with_keywords(&[CLASS.clone(),INTERFACE.clone()]);
keywords_of(&line, &JAVA_MATCHER, &mut file_stats);
assert_eq!(make_file_stats(1,0), file_stats);
let line = String::from("(class foo)");
let mut file_stats = FileStats::with_keywords(&[CLASS.clone(),INTERFACE.clone()]);
keywords_of(&line, &JAVA_MATCHER, &mut file_stats);
assert_eq!(make_file_stats(0,0), file_stats);
}
fn make_file_stats(class_occurances: usize, interface_occurances: usize) -> FileStats {
fn get_keyword_map(class_occurances: usize, interface_occurances: usize) -> Vec<usize> {
vec![class_occurances, interface_occurances]
}
FileStats {
lines: 0,
classes: LineClasses::default(),
keyword_occurences : get_keyword_map(class_occurances, interface_occurances)
}
}
#[test]
fn a_string_delimiter_is_found_wherever_it_is_not_escaped_or_inside_another_string() {
let single_str_opt = Some(1u8);
let double_str_opt = Some(0u8);
let line = String::from("Hello");
assert_eq!(Vec::<usize>::new(),str_delimiters(&line, &PYTHON, None).0);
let line = String::from("\"Hello\"");
assert_eq!((vec![0,6],vec![0u8,0u8]),str_delimiters(&line, &PYTHON, None));
let line = String::from("\"'\"Hello");
assert_eq!((vec![0,2],vec![0u8,0u8]),str_delimiters(&line, &PYTHON, None));
assert_eq!((vec![1,2],vec![1u8,0u8]),str_delimiters(&line, &PYTHON, single_str_opt));
assert_eq!((vec![0,1],vec![0u8,1u8]),str_delimiters(&line, &PYTHON, double_str_opt));
let line = String::from("''\"\"Hello");
assert_eq!(vec![0,1,2,3],str_delimiters(&line, &PYTHON, None).0);
assert_eq!(vec![0,1],str_delimiters(&line, &PYTHON, single_str_opt).0);
assert_eq!(vec![2,3],str_delimiters(&line, &PYTHON, double_str_opt).0);
let line = String::from("'\"'\"''\"He'l\"lo");
assert_eq!(vec![0,2,3,6,9],str_delimiters(&line, &PYTHON, None).0);
assert_eq!(vec![0,1,3,4,5,6,11],str_delimiters(&line, &PYTHON, single_str_opt).0);
assert_eq!(vec![1,2,4,5,9,11],str_delimiters(&line, &PYTHON, double_str_opt).0);
assert_eq!(vec![1,3,6,11],str_delimiters(&line, &JAVA, double_str_opt).0);
let line = String::from(r#"\'\\'\\'\\\''"#);
assert_eq!(vec![4,7,12], str_delimiters(&line, &PYTHON, None).0);
assert_eq!(vec![4,7,12], str_delimiters(&line, &PYTHON, single_str_opt).0);
let line = String::from(r#"["❌🔤","💭🔜","📗","📘",]"#);
assert!(str_delimiters(&line, &PYTHON, None).0.len() == 8);
assert!(str_delimiters(&line, &RUST, double_str_opt).0.len() == 8);
let line = String::from(r#"[\'⣾\', '⣷', '⣯', '⣟', '⡿']"#);
assert!(str_delimiters(&line, &PYTHON, None).0.len() == 8);
assert!(str_delimiters(&line, &RUST, None).0.is_empty());
let line = String::from(r#"['⣾", '⣷", '⣯"]"#);
assert_eq!(vec![1u8,1u8,0u8,0u8],
str_delimiters(&line, &PYTHON, None).1);
let line = String::from(r#"'\'\'\''"#);
assert_eq!(vec![0,7], str_delimiters(&line, &PYTHON, None).0);
let line = String::from(r#""\"\\"""#); assert_eq!(vec![0,5,6], str_delimiters(&line, &RUST, None).0);
assert_eq!(vec![0,5,6], str_delimiters(&line, &PYTHON, None).0);
let line = String::from(r#"\\\"\"\\""#);
assert_eq!(vec![8], str_delimiters(&line, &RUST, None).0);
assert_eq!(vec![8], str_delimiters(&line, &PYTHON, None).0);
}
#[test]
fn a_language_can_declare_more_than_two_string_symbols() {
let indices_of = |line: &str| str_delimiters(line, &PYTHON_FULL, None);
assert_eq!(vec![0, 4], indices_of(r#""abc""#).0);
assert_eq!(vec![0, 4], indices_of(r#"'abc'"#).0);
let (indices, symbols) = indices_of(r#""""a docstring""""#);
assert_eq!(vec![0, 14], indices);
assert_eq!(vec![2u8, 2u8], symbols);
assert_eq!(vec![0, 10], indices_of(r#""it's fine""#).0);
assert_eq!(vec![0, 8], indices_of(r#"'a """ b'"#).0);
let (indices, symbols) = indices_of(r#"x = """ open"#);
assert_eq!((vec![4], vec![2u8]), (indices, symbols));
let open = Some(2u8);
assert_eq!(vec![5], str_delimiters("still\"\"\"", &PYTHON_FULL, open).0);
}
#[test]
fn the_other_symbol_stays_text_when_the_one_that_could_close_the_string_is_escaped() {
let open_single = Some(1u8);
let open_double = Some(0u8);
assert_eq!((vec![], vec![]), str_delimiters("\"\\'", &PYTHON, open_single));
assert_eq!((vec![], vec![]), str_delimiters("'\\\"", &PYTHON, open_double));
assert_eq!((vec![], vec![]), str_delimiters("a\"b\\'c", &PYTHON, open_single));
assert_eq!(vec![3], str_delimiters("\"\\''", &PYTHON, open_single).0);
}
#[test]
fn a_comment_symbol_spelled_with_letters_is_a_word_and_not_a_prefix() {
let batch_like = Language::new("batch-like", ["bat"], StringRules::escaping_nothing(),
["rem", "REM", "::"], &[], []);
let counts = |text: &str| content_counts(&parse_lines_whole(text, &batch_like));
assert_eq!((1, 0, 1), counts("REM a comment\n"));
assert_eq!((1, 1, 0), counts("REMOVE /Q file.txt\n"));
assert_eq!((1, 1, 0), counts("prerem x\n"));
assert_eq!((1, 0, 1), counts("rem\n"));
assert_eq!((1, 1, 0), counts("REM_TEST /Q\n"));
assert_eq!((1, 0, 1), counts("::a comment\n"));
let c_like = Language::new("c-like", ["c"], build_backslashed_quotes(), ["//"], &[("/*", "*/")], []);
assert_eq!(vec![4], comment_delimiters("code// a comment", &c_like));
assert_eq!(vec![0], comment_delimiters("//x", &c_like));
}
#[test]
fn a_language_can_declare_more_than_two_comment_symbols() {
let indices_of = |line: &str| comment_delimiters(line, &PYTHON_FULL);
assert_eq!(vec![4], indices_of("code# a comment"));
assert_eq!(vec![4], indices_of("code// a comment"));
assert_eq!(vec![4], indices_of("code-- a comment"));
assert_eq!(vec![2, 6, 10], indices_of("a --b //c #d"));
assert_eq!(vec![0, 2, 3], indices_of("--#//"));
assert_eq!(vec![0, 2], indices_of("////"));
}
static LUA : LazyLock<Language> = LazyLock::new(|| Language::new("lua", ["lua"],
StringRules::escaping_with(b'\\').with_symbols(["\"", "'"]), ["--"],
&[("--[[", "]]")], []));
#[test]
fn the_longer_symbol_wins_when_a_comment_and_a_block_start_together() {
assert_eq!(TextInfo::with_open_comment(0), bounds_multi("--[[", &LUA, None, None));
assert_eq!(TextInfo::with_open_comment(0), bounds_multi("--[[ opening", &LUA, None, None));
assert_eq!(TextInfo::none_all(false), bounds_multi("-- just a comment", &LUA, None, None));
assert_eq!(TextInfo::new(Some("x = 1 ".to_owned()), false, Some((0, 1)), None),
bounds_multi("x = 1 --[[ opens here", &LUA, None, None));
assert_eq!(TextInfo::from_slice(" y = 2"), bounds_multi("]] y = 2", &LUA, Some(0), None));
}
static POWERSHELL : LazyLock<Language> = LazyLock::new(|| Language::new("powershell", ["ps1"],
StringRules::escaping_with(b'`').with_symbols(["\"", "'"]), ["#"], &[("<#", "#>")], []));
#[test]
fn a_comment_symbol_inside_the_block_opening_belongs_to_the_opening() {
assert_eq!(TextInfo::with_open_comment(0), bounds_multi("<#", &POWERSHELL, None, None));
assert_eq!(TextInfo::with_open_comment(0), bounds_multi("<# opening", &POWERSHELL, None, None));
assert_eq!(TextInfo::new(Some("$x = 1 ".to_owned()), false, Some((0, 1)), None),
bounds_multi("$x = 1 <# opens here", &POWERSHELL, None, None));
assert_eq!(TextInfo::none_all(false), bounds_multi("# just a comment", &POWERSHELL, None, None));
assert_eq!(TextInfo::from_slice(" $y = 2"), bounds_multi("#> $y = 2", &POWERSHELL, Some(0), None));
}
static PASCAL : LazyLock<Language> = LazyLock::new(|| Language::new("pascal", ["pas"],
StringRules::escaping_nothing().with_symbols(["'"]), ["//"],
&[("{", "}"), ("(*", "*)")], []));
static D_LANG : LazyLock<Language> = LazyLock::new(|| Language::new("d", ["d"],
build_backslashed_quotes(), ["//"], &[("/*", "*/")], [])
.with_nesting_comments(&[("/+", "+/")]));
static LUA_LEVELED : LazyLock<Language> = LazyLock::new(|| Language::new(
"lua-leveled", ["lua"], StringRules::escaping_with(b'\\').with_symbols(["\"", "'"]), ["--"], &[], [])
.with_leveled_comments(&[crate::LeveledPair::of("--[=*[", "]=*]").unwrap()]));
#[test]
fn a_leveled_pair_closes_only_at_an_end_carrying_the_same_count() {
assert_eq!(TextInfo::from_slice_w_literal("x = 1 y = "),
bounds_multi("x = 1 --[[ note ]] y = ''", &LUA_LEVELED, None, None));
assert_eq!(TextInfo::none_all(false), bounds_multi("--[==[ a ]] b ]==]", &LUA_LEVELED, None, None));
assert_eq!(TextInfo::with_open_comment_at(0, 1), bounds_multi("--[=[ open", &LUA_LEVELED, None, None));
assert_eq!(TextInfo::with_open_comment_at(0, 1),
bounds_multi_deep("]] not yet", &LUA_LEVELED, Some((0, 1)), None));
assert_eq!(TextInfo::from_slice(" done"),
bounds_multi_deep("]=] done", &LUA_LEVELED, Some((0, 1)), None));
assert_eq!(TextInfo::from_slice("x = 1 "), bounds_multi("x = 1 --[= not a block", &LUA_LEVELED, None, None));
assert_eq!(TextInfo::with_open_comment_at(0, 0), bounds_multi("--[[ open", &LUA_LEVELED, None, None));
assert_eq!(TextInfo::from_slice(" done"),
bounds_multi_deep("]] done", &LUA_LEVELED, Some((0, 0)), None));
assert!(LUA_LEVELED.supports_multiline_comments());
}
static OCAML : LazyLock<Language> = LazyLock::new(|| Language::new(
"ocaml-like", ["ml"], StringRules::escaping_with(b'\\').with_multiline_strings(["\""]),
[""; 0], &[], [])
.with_nesting_comments(&[("(*", "*)")]));
#[test]
fn a_nesting_pair_closes_only_when_as_many_ends_as_starts_have_passed() {
assert_eq!(TextInfo::from_slice(" d"),
bounds_multi("(* a (* b *) c *) d", &OCAML, None, None));
assert_eq!(TextInfo::with_open_comment_at(0, 2), bounds_multi("(* one (* two", &OCAML, None, None));
assert_eq!(TextInfo::with_open_comment_at(0, 1),
bounds_multi_deep("still *) inside", &OCAML, Some((0, 2)), None));
assert_eq!(TextInfo::from_slice(" x"), bounds_multi_deep("done *) x", &OCAML, Some((0, 1)), None));
assert_eq!(TextInfo::with_open_comment_at(0, 3),
bounds_multi_deep("more (* here", &OCAML, Some((0, 2)), None));
}
#[test]
fn the_plain_pair_of_a_language_does_not_nest_while_its_nesting_pair_does() {
assert_eq!(TextInfo::from_slice(" tail */"),
bounds_multi("/* a /* b */ tail */", &D_LANG, None, None));
assert_eq!(TextInfo::from_slice(" d"), bounds_multi("/+ a /+ b +/ c +/ d", &D_LANG, None, None));
assert_eq!(TextInfo::with_open_comment_at(1, 2), bounds_multi("/+ one /+ two", &D_LANG, None, None));
}
#[test]
fn a_second_comment_pair_opens_and_only_its_own_end_closes_it() {
assert_eq!(TextInfo::none_all(false), bounds_multi("{ comment }", &PASCAL, None, None));
assert_eq!(TextInfo::none_all(false), bounds_multi("(* comment *)", &PASCAL, None, None));
assert_eq!(TextInfo::from_slice_w_literal("x := 1; "),
bounds_multi("x := 1; { note } '4'", &PASCAL, None, None));
assert_eq!(TextInfo::none_all(false), bounds_multi("{ close with *) no, with }", &PASCAL, None, None));
assert_eq!(TextInfo::none_all(false), bounds_multi("(* a } inside *)", &PASCAL, None, None));
assert_eq!(TextInfo::with_open_comment(0), bounds_multi("{ open", &PASCAL, None, None));
assert_eq!(TextInfo::with_open_comment(1), bounds_multi("(* open", &PASCAL, None, None));
assert_eq!(TextInfo::with_open_comment(0), bounds_multi("still *) going", &PASCAL, Some(0), None));
assert_eq!(TextInfo::with_open_comment(1), bounds_multi("still } going", &PASCAL, Some(1), None));
assert_eq!(TextInfo::from_slice(" x := 2;"), bounds_multi("} x := 2;", &PASCAL, Some(0), None));
assert_eq!(TextInfo::from_slice(" x := 2;"), bounds_multi("*) x := 2;", &PASCAL, Some(1), None));
assert_eq!(TextInfo::from_slice_w_literal(" x "),
bounds_multi("{ a } x (* b *) ''", &PASCAL, None, None));
assert_eq!(TextInfo::none_all(true), bounds_multi("{ a } (* b *) ''", &PASCAL, None, None));
assert_eq!(TextInfo::from_slice(" c"), bounds_multi("{ a (* b } c", &PASCAL, None, None));
}
#[test]
fn the_d_shape_where_both_pairs_share_a_first_byte_still_matches_by_pair() {
assert_eq!(TextInfo::none_all(false), bounds_multi("/* comment */", &D_LANG, None, None));
assert_eq!(TextInfo::none_all(false), bounds_multi("/+ comment +/", &D_LANG, None, None));
assert_eq!(TextInfo::with_open_comment(1), bounds_multi("/+ a */ still open", &D_LANG, None, None));
assert_eq!(TextInfo::with_open_comment(0), bounds_multi("/* a +/ still open", &D_LANG, None, None));
assert_eq!(TextInfo::with_open_comment(1), bounds_multi("text */ text", &D_LANG, Some(1), None));
assert_eq!(TextInfo::from_slice(" code"), bounds_multi("+/ code", &D_LANG, Some(1), None));
assert_eq!(TextInfo::from_slice(" a b"), bounds_multi("/* x */ a /+ y +/ b", &D_LANG, None, None));
}
static RUST_RAW : LazyLock<Language> = LazyLock::new(|| Language::new(
"rust-raw", ["rs"], StringRules::escaping_with(b'\\').with_multiline_strings(["\""])
.with_string_pairs(&[("r#\"", "\"#")]),
["//"], &[("/*", "*/")], []));
static CSHARP_VERBATIM : LazyLock<Language> = LazyLock::new(|| Language::new(
"csharp-verbatim", ["cs"], build_backslashed_quotes().with_multiline_strings(["\"\"\""])
.with_string_pairs(&[("@\"", "\"")]),
["//"], &[("/*", "*/")], []));
static RUST_CHARS : LazyLock<Language> = LazyLock::new(|| Language::new(
"rust-chars", ["rs"], StringRules::escaping_with(b'\\').with_char_literals(["'"])
.with_multiline_strings(["\""]),
["//"], &[("/*", "*/")], []));
#[test]
fn a_character_literal_pairs_on_its_own_line_or_is_not_a_literal_at_all() {
assert_eq!(TextInfo::from_slice_w_literal("let c = ;"),
bounds_multi("let c = '\"';", &RUST_CHARS, None, None));
assert_eq!(TextInfo::from_slice("let x: &'a str = y;"),
bounds_multi("let x: &'a str = y;", &RUST_CHARS, None, None));
assert_eq!(TextInfo::from_slice("fn get<'a>(x: &'a str) -> &'a str {"),
bounds_multi("fn get<'a>(x: &'a str) -> &'a str {", &RUST_CHARS, None, None));
assert_eq!(TextInfo::from_slice_w_literal("let msg: &'static str = ;"),
bounds_multi("let msg: &'static str = \"don't panic\";", &RUST_CHARS, None, None));
assert_eq!(TextInfo::from_slice_w_literal("let u = ;"),
bounds_multi("let u = '\\u{1F600}';", &RUST_CHARS, None, None));
assert_eq!(TextInfo::from_slice_w_literal("let q = ;"),
bounds_multi("let q = '\\'';", &RUST_CHARS, None, None));
assert_eq!(TextInfo::from_slice_w_literal("let b = ;"),
bounds_multi("let b = '\\\\';", &RUST_CHARS, None, None));
assert_eq!(TextInfo::none_all(false), bounds_multi("// don't", &RUST_CHARS, None, None));
assert_eq!(TextInfo::from_slice_w_literal("let s = ;"),
bounds_multi("let s = \"don't\";", &RUST_CHARS, None, None));
assert_eq!(TextInfo::new(Some(" after".to_owned()), true, None, None),
bounds_multi("tick ' text\" after", &RUST_CHARS, None, Some(1)));
}
#[test]
fn a_symbol_led_by_a_letter_is_searched_by_a_byte_the_scan_wanted_anyway() {
let plan = ScanPlan::build(&RUST_RAW);
assert!(plan.chunks.iter().all(|c| !c.bytes[..c.len as usize].contains(&b'r')),
"the scan searches for 'r', which floods on ordinary code");
assert_eq!(1, plan.chunks.len());
let cpp_raw = Language::new("cpp-like", ["cpp"],
build_backslashed_quotes().with_string_pairs(&[("R\"(", ")\"")]),
["//"], &[("/*", "*/")], []);
let plan = ScanPlan::build(&cpp_raw);
assert!(plan.chunks.iter().all(|c| !c.bytes[..c.len as usize].contains(&b'(')),
"the opener is searched by '(' in a language made of brackets");
}
#[test]
fn a_symbol_is_searched_by_a_byte_another_symbol_needs_before_one_of_its_own() {
let plain = Language::new("cpp-like", ["cpp"], build_backslashed_quotes(), ["//"], &[("/*", "*/")], []);
let with_raw = Language::new("cpp-like", ["cpp"],
build_backslashed_quotes().with_string_pairs(&[("R\"(", ")\"")]),
["//"], &[("/*", "*/")], []);
let bytes_of = |language: &Language| {
let plan = ScanPlan::build(language);
let mut bytes = plan.chunks.iter()
.flat_map(|c| c.bytes[..c.len as usize].to_vec()).collect::<Vec<u8>>();
bytes.sort_unstable();
(bytes, plan.chunks.len())
};
assert_eq!(bytes_of(&plain), bytes_of(&with_raw));
assert_eq!((vec![b'"', b'/'], 1), bytes_of(&with_raw));
}
#[test]
fn what_cancels_a_symbol_is_read_from_the_language_and_not_assumed() {
let shell = Language::new("shell-like", ["sh"],
StringRules::escaping_with(b'\\').with_raw_multiline_strings(["'"]), ["#"], &[], []);
let powershell = Language::new("ps-like", ["ps1"],
StringRules::escaping_with(b'`').with_raw_multiline_strings(["'"]), ["#"], &[], []);
let pascal = Language::new("pascal-like", ["pas"],
StringRules::escaping_nothing().with_symbols(["'"]), ["//"], &[], []);
assert_eq!(TextInfo::from_slice(r"echo I\'m done"),
bounds_multi(r"echo I\'m done", &shell, None, None));
assert_eq!(TextInfo::from_slice_w_literal("echo done"),
bounds_multi(r"echo 'a\' done", &shell, None, None));
assert_eq!(TextInfo::from_slice_w_literal(r"cd C:\"),
bounds_multi(r"cd C:\'Program Files'", &powershell, None, None));
assert_eq!(TextInfo::from_slice_w_literal("s := ; "),
bounds_multi(r"s := 'C:\'; // a comment", &pascal, None, None));
}
#[test]
fn a_cpp_raw_string_keeps_the_quotes_and_brackets_inside_it() {
let cpp = Language::new("cpp-like", ["cpp"],
build_backslashed_quotes().with_string_pairs(&[("R\"(", ")\"")]),
["//"], &[("/*", "*/")], []);
assert_eq!(TextInfo::from_slice_w_literal("auto s = ;"),
bounds_multi(r#"auto s = R"(say "hi" // and (stay) code)";"#, &cpp, None, None));
assert_eq!(TextInfo::from_slice_w_literal("auto s = u8;"),
bounds_multi(r#"auto s = u8R"(text)";"#, &cpp, None, None));
assert_eq!(TextInfo::from_slice_w_literal("printf();"),
bounds_multi(r#"printf(")");"#, &cpp, None, None));
assert_eq!(TextInfo::new(Some("auto s = ".to_owned()), true, None, Some(1)),
bounds_multi(r#"auto s = R"(open"#, &cpp, None, None));
assert_eq!(TextInfo::new(None, true, None, Some(1)),
bounds_multi(r#"still text "quoted" // not a comment"#, &cpp, None, Some(1)));
assert_eq!(TextInfo::new(Some(";".to_owned()), true, None, None),
bounds_multi(r#"done)";"#, &cpp, None, Some(1)));
}
#[test]
fn a_raw_opener_that_appears_inside_an_ordinary_string_is_text() {
let rust = Language::new("rust-like", ["rs"],
StringRules::escaping_with(b'\\').with_multiline_strings(["\""])
.with_string_pairs(&[("r\"", "\""), ("r#\"", "\"#")]),
["//"], &[("/*", "*/")], []);
assert_eq!(TextInfo::from_slice_w_literal("let s = ;"),
bounds_multi(r#"let s = "abcr";"#, &rust, None, None));
assert_eq!(TextInfo::from_slice_w_literal("let p = ;"),
bounds_multi(r#"let p = r"C:\temp\";"#, &rust, None, None));
assert_eq!(TextInfo::from_slice_w_literal("let q = ab;"),
bounds_multi(r#"let q = r"a"ab"b";"#, &rust, None, None));
}
#[test]
fn a_string_that_opens_with_one_symbol_closes_only_with_its_other_half() {
assert_eq!(TextInfo::from_slice_w_literal("let a = ; done"),
bounds_multi(r##"let a = r#"say "hi"#; done"##, &RUST_RAW, None, None));
assert_eq!(TextInfo::new(Some("x = ".to_owned()), true, None, Some(1)),
bounds_multi(r##"x = r#"open"##, &RUST_RAW, None, None));
assert_eq!(TextInfo::new(None, true, None, Some(1)),
bounds_multi(r#"say "quoted" more"#, &RUST_RAW, None, Some(1)));
assert_eq!(TextInfo::none_all(true), bounds_multi(r##"done"#"##, &RUST_RAW, None, Some(1)));
assert_eq!(TextInfo::from_slice_w_literal("let s = ;"),
bounds_multi(r##"let s = "#";"##, &RUST_RAW, None, None));
}
#[test]
fn inside_a_two_sided_pair_the_backslash_does_not_escape() {
assert_eq!(TextInfo::from_slice_w_literal("let p = ;"),
bounds_multi(r##"let p = r#"C:\path\"#;"##, &RUST_RAW, None, None));
assert_eq!(TextInfo::new(Some("let q = ".to_owned()), true, None, Some(0)),
bounds_multi(r#"let q = "C:\path\";"#, &RUST_RAW, None, None));
assert_eq!(TextInfo::from_slice_w_literal("var s = + x;"),
bounds_multi(r#"var s = @"C:\temp\" + x;"#, &CSHARP_VERBATIM, None, None));
}
#[test]
fn a_one_sided_form_escapes_or_not_as_the_language_declares_and_not_as_its_shape_suggests() {
let go = Language::new("go-like", ["go"],
build_backslashed_quotes().with_raw_multiline_strings(["`"]), ["//"], &[("/*", "*/")], []);
let js = Language::new("js-like", ["js"],
build_backslashed_quotes().with_multiline_strings(["`"]), ["//"], &[("/*", "*/")], []);
assert_eq!(TextInfo::from_slice_w_literal("var sep = ;"),
bounds_multi(r"var sep = `C:\`;", &go, None, None));
assert_eq!(TextInfo::new(Some("var sep = ".to_owned()), true, None, Some(1)),
bounds_multi(r"var sep = `C:\`;", &js, None, None));
assert_eq!(TextInfo::new(Some("var s = ".to_owned()), true, None, Some(1)),
bounds_multi("var s = `open", &go, None, None));
assert_eq!(TextInfo::none_all(true), bounds_multi("still \" /* text `", &go, None, Some(1)));
}
fn section_fixture() -> (HashMap<String, Language>, HashMap<String, Arc<str>>) {
let js = Language::new("JS", ["js"], build_backslashed_quotes(), ["//"], &[("/*", "*/")],
[Keyword { descriptive_name: "functions".to_owned(), aliases: vec!["function".to_owned()] }]);
let css = Language::new("CSS", ["css"], StringRules::escaping_nothing(), [""; 0], &[("/*", "*/")], []);
let languages = crate::languages::keyed_by_name(vec![js, css]);
let extensions = HashMap::from([("js".to_owned(), Arc::from("JS")), ("css".to_owned(), Arc::from("CSS"))]);
(languages, extensions)
}
fn web_shell() -> Language {
Language::new("web", ["wbl"], StringRules::escaping_nothing(), [""; 0], &[("<!--", "-->")], [])
.with_nested_languages(&[NestedLanguage::of("<script", "</script>", "js"),
NestedLanguage::of("<style", "</style>", "css")])
}
fn parse_with_sections(contents: &str, shell: &Language,
languages: &HashMap<String, Language>, extensions: &HashMap<String, Arc<str>>) -> FileReport
{
let lookup = NestedLanguageLookup { languages, extension_to_name: extensions, set_aside: &NO_SET_ASIDE };
parse_lines::<false>(contents, shell, &lookup, &mut KeywordMatchers::default(),
&EngineConfig::default(), &mut ParseBuffers::default(), &mut ExplainLog::default())
}
#[test]
fn a_section_is_counted_with_its_own_language_and_the_tag_lines_stay_with_the_shell() {
let (languages, extensions) = section_fixture();
let contents = "<p>hello</p>\n<script>\n// a js comment\nvar s = \"x\"; function f() {}\n</script>\n\
<style>\n/* css comment */\n</style>\n<p>bye</p>\n";
let report = parse_with_sections(contents, &web_shell(), &languages, &extensions);
assert_eq!((6, 6, 0), content_counts(&report.shell),
"the tag lines and the html around them belong to the shell");
let js = &report.sections[0];
assert_eq!("JS", js.language.as_str());
assert_eq!((2, 1, 1), content_counts(&js.stats));
assert_eq!(vec![1], js.stats.keyword_occurences, "the js keywords count inside the js section");
let css = &report.sections[1];
assert_eq!("CSS", css.language.as_str());
assert_eq!((1, 0, 1), content_counts(&css.stats));
let js_bytes = contents.find("</script>").unwrap() - (contents.find("<script>").unwrap() + "<script>\n".len());
assert_eq!(js_bytes, js.bytes);
assert_eq!(contents.lines().count(), report.total_lines(), "a line of the file is counted exactly once");
}
#[test]
fn an_opener_inside_a_comment_or_a_string_of_the_shell_opens_nothing() {
let (languages, extensions) = section_fixture();
let report = parse_with_sections("<!-- <script> -->\n<p>x</p>\n", &web_shell(), &languages, &extensions);
assert!(report.sections.is_empty(), "a tag inside a comment opened a section");
assert_eq!((2, 1, 1), content_counts(&report.shell));
let stringy = Language::new("webstr", ["wbs"], build_backslashed_quotes(), [""; 0], &[], [])
.with_nested_languages(&[NestedLanguage::of("<script", "</script>", "js")]);
let report = parse_with_sections("x = \"<script>\"\n", &stringy, &languages, &extensions);
assert!(report.sections.is_empty(), "a tag inside a string opened a section");
}
#[test]
fn the_tag_names_its_language_and_falls_to_the_declared_default_when_it_does_not() {
let (languages, extensions) = section_fixture();
let shell = web_shell();
for tag in ["<script lang=\"css\">", "<script lang='css'>", "<script lang=css>"] {
let contents = format!("{tag}\n/* x */\n</script>\n");
let report = parse_with_sections(&contents, &shell, &languages, &extensions);
assert_eq!("CSS", report.sections[0].language, "{tag} did not resolve its language");
}
let report = parse_with_sections("<script type=\"text/js\">\nvar x = 1;\n</script>\n", &shell, &languages, &extensions);
assert_eq!("JS", report.sections[0].language);
let report = parse_with_sections("<style lang=\"CSS\">\n.a { color: red; }\n</style>\n", &shell, &languages, &extensions);
assert_eq!("CSS", report.sections[0].language, "a language's own name was not recognised");
let report = parse_with_sections("<script lang=\"zz\">\nvar x = 1;\n</script>\n", &shell, &languages, &extensions);
assert_eq!("JS", report.sections[0].language);
let report = parse_with_sections("<script slang=\"css\">\nvar x = 1;\n</script>\n", &shell, &languages, &extensions);
assert_eq!("JS", report.sections[0].language);
}
#[test]
fn tags_match_in_any_case() {
let (languages, extensions) = section_fixture();
let report = parse_with_sections("<SCRIPT>\n// x\n</SCRIPT>\n<p>y</p>\n", &web_shell(), &languages, &extensions);
assert_eq!(1, report.sections.len(), "an upper case tag was not read as a tag");
assert_eq!((1, 0, 1), content_counts(&report.sections[0].stats));
let contents = "<script>\n// x\n</SCRIPT>\n";
let report = parse_with_sections(contents, &web_shell(), &languages, &extensions);
assert_eq!(1, report.sections.len(), "a closer in another case did not end the section");
let section_from = contents.find("// x").unwrap();
assert_eq!(contents.find("</SCRIPT>").unwrap() - section_from, report.sections[0].bytes);
}
#[test]
fn a_section_that_never_closes_stays_with_the_shell() {
let (languages, extensions) = section_fixture();
let report = parse_with_sections("<p>x</p>\n<script>\n// one\n// two\n", &web_shell(), &languages, &extensions);
assert!(report.sections.is_empty(), "an unclosed opener took the rest of the file");
assert_eq!(4, report.shell.lines);
let report = parse_with_sections("<scriptures>\n// one\n</scriptures>\n", &web_shell(), &languages, &extensions);
assert!(report.sections.is_empty(), "a longer word beginning with the tag opened a section");
let report = parse_with_sections("<p>x</p>\n<script>\n<!-- a note -->\n", &web_shell(), &languages, &extensions);
assert_eq!((3, 2, 1), content_counts(&report.shell));
}
#[test]
fn a_closing_tag_ends_a_section_wherever_html_says_it_does() {
let (languages, extensions) = section_fixture();
let closed_by = |closer: &str| parse_with_sections(
&format!("<script>\nvar x = 1;\n{closer}\n<p>y</p>\n"), &web_shell(), &languages, &extensions);
for closer in ["</script>", "</script >", "</script >", "</SCRIPT >", "</script foo>"] {
let report = closed_by(closer);
assert_eq!(1, report.sections.len(), "'{closer}' closed no section");
assert_eq!((1, 1, 0), content_counts(&report.sections[0].stats), "'{closer}'");
assert_eq!(3, report.shell.lines, "'{closer}' left the wrong lines to the shell");
}
assert!(closed_by("</scriptfoo>").sections.is_empty());
assert!(closed_by("</script").sections.is_empty());
}
#[test]
fn what_cannot_be_a_section_counts_as_the_shell_it_always_was() {
let (languages, extensions) = section_fixture();
let report = parse_with_sections("<script\nlang=\"js\">\nvar x = 1;\n</script>\n", &web_shell(), &languages, &extensions);
assert!(report.sections.is_empty(), "a tag split over two lines opened a section");
let report = parse_with_sections("<script>var x = 1;</script>\n<p>y</p>\n", &web_shell(), &languages, &extensions);
assert!(report.sections.is_empty(), "a one line section left the line");
assert_eq!((2, 2, 0), content_counts(&report.shell));
let unknown = Language::new("web", ["wbl"], StringRules::escaping_nothing(), [""; 0], &[("<!--", "-->")], [])
.with_nested_languages(&[NestedLanguage::of("<script", "</script>", "nosuchthing")]);
let report = parse_with_sections("<script>\nvar x = 1;\n</script>\n", &unknown, &languages, &extensions);
assert!(report.sections.is_empty(), "a section resolved to a language nothing declares");
assert_eq!((3, 3, 0), content_counts(&report.shell));
}
#[test]
fn two_sections_of_the_same_language_are_one_entry_of_the_report() {
let (languages, extensions) = section_fixture();
let contents = "<script>\n// one\n</script>\n<script>\n// two\nvar x = 1;\n</script>\n";
let report = parse_with_sections(contents, &web_shell(), &languages, &extensions);
assert_eq!(1, report.sections.len());
assert_eq!((3, 1, 2), content_counts(&report.sections[0].stats));
}
#[test]
fn an_unbalanced_quote_costs_its_line_and_not_the_rest_of_the_file() {
let plain = Language::new("py-like", ["py"], StringRules::escaping_with(b'\\')
.with_symbols(["\"", "'"]).with_multiline_strings(["\"\"\""]), ["#"], &[], []);
let crossing = Language::new("py-like", ["py"], StringRules::escaping_with(b'\\')
.with_multiline_strings(["\"\"\"", "\"", "'"]), ["#"], &[], []);
let contents = "a = \"unbalanced\nb = 1\nc = 2\n# comment\n";
let stats = parse_lines_whole(contents, &plain);
assert_eq!((4, 3, 1), content_counts(&stats));
let stats = parse_lines_whole(contents, &crossing);
assert_eq!((4, 4, 0), content_counts(&stats));
let doc = "d = \"\"\"docstring\n# still string\n\"\"\"\ne = 1\n# comment\n";
let stats = parse_lines_whole(doc, &plain);
assert_eq!((5, 4, 1), content_counts(&stats));
}
#[test]
fn closing_a_string_advances_past_the_whole_closing_symbol() {
assert_eq!(TextInfo::from_slice_w_literal("var d = y"),
bounds_multi(r#"var d = """doc""" y"#, &CSHARP_VERBATIM, None, None));
assert_eq!(TextInfo::from_slice_w_literal("x = y"),
bounds_multi(r#"x = """doc""" y"#, &PYTHON_FULL, None, None));
}
static DEFN : LazyLock<Keyword> = LazyLock::new(|| Keyword::new("functions", ["(defn", "defn"]));
static CLOJURE : LazyLock<Language> = LazyLock::new(|| Language::new("clojure", ["clj"],
build_backslashed_quotes(), [";"], &[], [DEFN.clone()]));
#[test]
fn a_bracketed_alias_counts_once_and_never_twice() {
let matcher = KeywordMatcher::build(&CLOJURE).unwrap();
let count_of = |line: &str| {
let mut file_stats = FileStats::with_keywords(std::slice::from_ref(&DEFN));
keywords_of(line, &matcher, &mut file_stats);
file_stats.keyword_occurences[0]
};
assert_eq!(1, count_of("(defn foo [x] x)"));
assert_eq!(1, count_of(" (defn foo [x] x)"));
assert_eq!(1, count_of("(do (defn foo))"));
assert_eq!(1, count_of("defn"));
assert_eq!(1, count_of("defn foo"));
assert_eq!(0, count_of("(defnx foo)"));
assert_eq!(0, count_of("(mydefn foo)"));
}
#[test]
fn a_run_of_touching_aliases_counts_as_nothing() {
let clojure = KeywordMatcher::build(&CLOJURE).unwrap();
let defns = |line: &str| {
let mut file_stats = FileStats::with_keywords(std::slice::from_ref(&DEFN));
keywords_of(line, &clojure, &mut file_stats);
file_stats.keyword_occurences[0]
};
assert_eq!(0, defns("(defn(defn"));
assert_eq!(0, defns("(defn(defn(defn"));
assert_eq!(0, defns("(defn(defn(defn(defn"));
assert_eq!(0, defns("(defn(defn(defn(defn(defn"));
assert_eq!(2, defns("(defn (defn"));
assert_eq!(1, defns("(defn(defn (defn"));
let braced = Keyword::new("braced", ["{x{"]);
let language = Language::new("braced", ["bx"], build_backslashed_quotes(), [";"], &[],
[braced.clone()]);
let matcher = KeywordMatcher::build(&language).unwrap();
let braces = |line: &str| {
let mut file_stats = FileStats::with_keywords(std::slice::from_ref(&braced));
keywords_of(line, &matcher, &mut file_stats);
file_stats.keyword_occurences[0]
};
assert_eq!(1, braces("{x{"));
assert_eq!(2, braces("{x{ {x{"));
let chains = (2..=7).map(|n| braces(&"{x{".repeat(n))).collect::<Vec<usize>>();
assert_eq!(vec![0, 0, 0, 0, 0, 0], chains);
assert_eq!(1, braces("{x{{x{ {x{"));
}
#[test]
fn every_kind_is_searched_whole_in_a_single_pass() {
for language in [&*JAVA, &*RUST, &*PHP, &*PYTHON, &*PYTHON_FULL, &*PASCAL, &*D_LANG, &*LUA, &*POWERSHELL] {
let plan = ScanPlan::build(language);
let searched = |byte: u8| plan.chunks.iter().filter(|c| c.bytes[..c.len as usize].contains(&byte)).count();
let bytes_of = |kind: u8| plan.slots.iter().enumerate().filter(|(_, slot)| slot.kind == kind)
.map(|(at, slot)| plan.symbols[at][slot.anchor as usize]).collect::<Vec<u8>>();
for kind in [STRINGS, COMMENTS, COM_STARTS, COM_ENDS] {
let bytes = bytes_of(kind);
if bytes.is_empty() { continue; }
let holding = plan.chunks.iter()
.filter(|c| bytes.iter().any(|b| c.bytes[..c.len as usize].contains(b)))
.count();
assert_eq!(1, holding, "{} splits a kind across passes", language.name);
for byte in bytes {
assert_eq!(1, searched(byte), "{} searches a byte twice", language.name);
}
}
}
}
#[test]
fn a_language_is_scanned_in_as_few_passes_as_its_first_bytes_allow() {
assert_eq!(1, ScanPlan::build(&JAVA).chunks.len());
assert_eq!(1, ScanPlan::build(&PYTHON).chunks.len());
assert_eq!(2, ScanPlan::build(&PHP).chunks.len());
}
#[test]
fn a_symbol_does_not_overlap_itself() {
assert_eq!(vec![0], comment_delimiters("///", &JAVA));
assert_eq!(vec![0], comment_delimiters("//", &JAVA));
assert_eq!(vec![0, 2], comment_delimiters("////", &JAVA));
assert_eq!(vec![1], comment_delimiters("a///", &JAVA));
assert_eq!(vec![0, 3], str_delimiters(&"\"".repeat(6), &PYTHON_FULL, None).0);
assert_eq!(vec![0], str_delimiters(&"\"".repeat(5), &PYTHON_FULL, None).0);
}
#[test]
fn the_line_iterator_agrees_with_the_standard_library() {
let cases = ["", "\n", "\n\n", "a", "a\n", "a\nb", "a\nb\n", "a\r\nb", "a\r\n",
"a\r\r\nb", "a\rb", "\r\n", " \n\t\n", "one\ntwo\nthree",
"fn main() {\n println!(\"hi\");\n}\n", "αβ\nγ"];
for case in cases {
let expected = case.lines().collect::<Vec<&str>>();
let actual = get_lines_of(case).map(|(_, line)| line).collect::<Vec<&str>>();
assert_eq!(expected, actual, "disagreed on {case:?}");
}
}
fn resolved_double_counting(start_indices: Vec<usize>, end_indices: Vec<usize>, is_comment_open: bool)
-> (Vec<usize>, Vec<usize>) {
let language = Language::new("one-pair", ["x"], build_backslashed_quotes(), ["//"], &[("/*", "*/")], []);
let mut starts = start_indices.into_iter().map(|x| (x, 0u8, 0u8)).collect::<Vec<_>>();
let mut ends = end_indices.into_iter().map(|x| (x, 0u8, 0u8)).collect::<Vec<_>>();
resolve_double_counting_of_adjacent_start_and_end_symbols(&mut starts, &mut ends, is_comment_open, &language);
(starts.into_iter().map(|(x, _, _)| x).collect(), ends.into_iter().map(|(x, _, _)| x).collect())
}
#[test]
fn a_block_opener_and_closer_sharing_bytes_are_counted_once() {
assert_eq!((vec![0,9,19],vec![7,17]), resolved_double_counting(vec![0,9,19], vec![7,17], false));
assert_eq!((vec![0,4],vec![2,6]), resolved_double_counting(vec![0,4], vec![2,6], false));
assert_eq!((vec![0,2],vec![4,6]), resolved_double_counting(vec![0,2], vec![4,6], false));
assert_eq!((vec![0],vec![3]), resolved_double_counting(vec![0,4], vec![3], false));
assert_eq!((vec![1],vec![5]), resolved_double_counting(vec![1,4], vec![0,5], false));
assert_eq!((vec![4],vec![0]), resolved_double_counting(vec![1,4], vec![0,5], true));
assert_eq!((vec![0,7,11],vec![3,14]), resolved_double_counting(vec![0,2,7,11], vec![1,3,6,8,14], false));
assert_eq!((vec![7,11],vec![1,3,14]), resolved_double_counting(vec![0,2,7,11], vec![1,3,6,8,14], true));
assert_eq!((vec![0,7],vec![3]), resolved_double_counting(vec![0,2,7], vec![1,3,6,8], false));
assert_eq!((vec![7],vec![1,3]), resolved_double_counting(vec![0,2,7], vec![1,3,6,8], true));
assert_eq!((vec![4],vec![0]), resolved_double_counting(vec![4], vec![0,3], true));
assert_eq!((vec![0,6,9],vec![3]), resolved_double_counting(vec![0,4,6,9], vec![3,5,7], false));
assert_eq!((vec![0,6,9],vec![3]), resolved_double_counting(vec![0,4,6,9], vec![3,5,7], true));
}
#[test]
fn a_symbol_the_character_in_front_of_it_cancels_opens_nothing() {
let vectorish = Language::new("vectorish", ["vec"], build_backslashed_quotes(), ["//"],
&[("<*", "*>")], [])
.with_cancelled_symbols(&[("<*", b'['), ("*>", b'<')]);
let stats = parse_lines_whole("macro rotate(int[<*>] x)\nreturn x;\n", &vectorish);
assert_eq!((2, 0), (stats.classes.words_in_code, stats.classes.words_in_comment));
let plain = Language::new("vectorish", ["vec"], build_backslashed_quotes(), ["//"],
&[("<*", "*>")], []);
let stats = parse_lines_whole("macro rotate(int[<*>] x)\nreturn x;\n", &plain);
assert_eq!((1, 1), (stats.classes.words_in_code, stats.classes.words_in_comment));
let stats = parse_lines_whole("<*\n a comment\n*>\nreturn x;\n", &vectorish);
assert_eq!((1, 1), (stats.classes.words_in_code, stats.classes.words_in_comment));
let stats = parse_lines_whole("<*\n the type int[<*>] holds one lane\n a second line\n*>\nreturn x;\n",
&vectorish);
assert_eq!((1, 2), (stats.classes.words_in_code, stats.classes.words_in_comment));
}
#[test]
fn a_close_that_touches_a_reopen_is_not_a_collision_when_the_lengths_differ() {
let lua_like = Language::new("lua-like", ["x"], build_backslashed_quotes(), ["--"], &[("--[[", "]]")], []);
let (mut starts, mut ends) = (vec![(2usize, 0u8, 0u8)], vec![(0usize, 0u8, 0u8)]);
resolve_double_counting_of_adjacent_start_and_end_symbols(&mut starts, &mut ends, true, &lua_like);
assert_eq!((vec![(2, 0, 0)], vec![(0, 0, 0)]), (starts, ends));
assert_eq!(TextInfo::with_open_comment(0), bounds_multi("]]--[[", &LUA, Some(0), None));
assert_eq!(TextInfo::with_open_comment(0),
bounds_multi("]] --[[ reopened", &LUA, Some(0), None));
let html : Language = Language::new("html-like", ["html"], build_backslashed_quotes(), [""; 0],
&[("<!--", "-->")], []);
assert_eq!(TextInfo::with_open_comment(0), bounds_multi("--><!--", &html, Some(0), None));
assert_eq!(TextInfo::with_open_comment(0),
bounds_multi("--> <!-- reopened", &html, Some(0), None));
}
#[test]
fn a_block_comment_end_behind_a_line_comment_is_not_a_delimiter() {
let line = "Hello world!";
assert_eq!(Vec::<usize>::new(), comment_delimiters_w_multiline(line, &PHP, &[]));
let line = "//Hello*/ world!";
assert_eq!(vec![0], comment_delimiters_w_multiline(line, &PHP, &[7]));
let line = "///*Hello world!";
assert_eq!(vec![0], comment_delimiters_w_multiline(line, &PHP, &[]));
let line = "//*//Hello world!";
assert_eq!(vec![0], comment_delimiters_w_multiline(line, &PHP, &[2]));
let line = "//*/#Hello world!";
assert_eq!(vec![0,4], comment_delimiters_w_multiline(line, &PHP, &[2]));
}
#[test]
fn python_quotes_are_read_with_pythons_own_rules() {
let line = String::from("[\"\\\"\\\"\\\"\",\"'''\",\"\\\"\",\"'\",]");
assert_eq!(TextInfo::new(Some("[,,,,]".to_owned()),true,None,None),bounds_multi(&line, &PYTHON, None,None));
let line = String::from("\\''\''");
assert_eq!(TextInfo::new(Some("\\\'".to_owned()),true,None,Some(1u8)), bounds_multi(&line, &PYTHON, None,None));
assert_eq!(TextInfo::none_all(true), bounds_multi(&line, &PYTHON, None, Some(1u8)));
let line = String::from("\'\\'\\'\\\''");
assert_eq!(TextInfo::new(None,true,None,None), bounds_multi(&line, &PYTHON, None,None));
let single_str_opt = Some(1u8);
let double_str_opt = Some(0u8);
let single_str_li = TextInfo::with_open_symbol(1);
let double_str_li = TextInfo::with_open_symbol(0);
let line = String::from("Hello world!");
assert_eq!(TextInfo::from_slice("Hello world!"),bounds_multi(&line, &PYTHON, None,None));
assert_eq!(single_str_li,bounds_multi(&line, &PYTHON, None,single_str_opt));
let line = String::from("#Hello world!");
assert_eq!(single_str_li,bounds_multi(&line, &PYTHON, None,single_str_opt));
let line = String::from("Hello world!#");
assert_eq!(TextInfo::from_slice("Hello world!"),bounds_multi(&line, &PYTHON, None,None));
let line = String::from("Hello# world!");
assert_eq!(TextInfo::from_slice("Hello"),bounds_multi(&line, &PYTHON, None,None));
assert_eq!(single_str_li,bounds_multi(&line, &PYTHON, None,single_str_opt));
let line = String::from("Hello## world!");
assert_eq!(TextInfo::from_slice("Hello"),bounds_multi(&line, &PYTHON, None,None));
let line = String::from("#Hello# world!");
assert_eq!(single_str_li,bounds_multi(&line, &PYTHON, None,single_str_opt));
let line = String::from("\"Hello world!#");
assert_eq!(double_str_li,bounds_multi(&line, &PYTHON, None,None));
let line = String::from("\"Hello\" world!");
assert_eq!(TextInfo::from_slice_w_literal(" world!"),bounds_multi(&line, &PYTHON, None,None));
assert_eq!(TextInfo::new(Some("Hello".to_owned()), true, None, Some(0u8)),bounds_multi(&line, &PYTHON, None,double_str_opt));
let line = String::from("Hello world!\"");
assert_eq!(TextInfo::new(Some("Hello world!".to_owned()), true, None, Some(0u8)),bounds_multi(&line, &PYTHON, None,None));
let line = String::from("\"'Hello'\" world!");
assert_eq!(TextInfo::from_slice_w_literal(" world!"),bounds_multi(&line, &PYTHON, None,None));
let line = String::from("'Hello' world!");
assert_eq!(TextInfo::from_slice_w_literal(" world!"),bounds_multi(&line, &PYTHON, None,None));
let line = String::from("'\"He'llo'\" world!'");
assert_eq!(TextInfo::from_slice_w_literal("llo"),bounds_multi(&line, &PYTHON, None,None));
assert_eq!(TextInfo::new(Some("He".to_owned()), true, None, Some(0u8)),bounds_multi(&line, &PYTHON, None,double_str_opt));
let line = String::from(r#""""Hello""#);
assert_eq!(TextInfo::new(None, true, None, None), bounds_multi(&line, &PYTHON, None,None));
assert_eq!(TextInfo::new(Some("Hello".to_owned()), true, None, Some(0u8)), bounds_multi(&line, &PYTHON, None,double_str_opt));
let line = String::from(r#"['⣯', '⣟"#);
assert_eq!(TextInfo::new(Some("[, ".to_owned()),true,None,Some(1u8)), bounds_multi(&line, &PYTHON, None,None));
let line = String::from("'Hello#' world!'");
assert_eq!(TextInfo::new(Some(" world!".to_owned()), true, None, Some(1u8)),bounds_multi(&line, &PYTHON, None,None));
assert_eq!(TextInfo::from_slice_w_literal("Hello"),bounds_multi(&line, &PYTHON, None,single_str_opt));
let line = String::from("'Hello'# world!'");
assert_eq!(TextInfo::none_all(true),bounds_multi(&line, &PYTHON, None,None));
assert_eq!(TextInfo::from_slice_w_literal("Hello"),bounds_multi(&line, &PYTHON, None,single_str_opt));
let line = String::from("''#Hello");
assert_eq!(TextInfo::none_all(true),bounds_multi(&line, &PYTHON, None,None));
let line = String::from("'''#'''Hello world!'");
assert_eq!(TextInfo::new(Some("Hello world!".to_owned()), true, None, Some(1u8)),bounds_multi(&line, &PYTHON, None,None));
assert_eq!(TextInfo::none_all(true),bounds_multi(&line, &PYTHON, None,single_str_opt));
assert_eq!(TextInfo::with_open_symbol(0),bounds_multi(&line, &PYTHON, None,double_str_opt));
let line = String::from("Hello'###'\"world!\"");
assert_eq!(TextInfo::from_slice_w_literal("Hello"),bounds_multi(&line, &PYTHON, None,None));
assert_eq!(TextInfo::none_all(true),bounds_multi(&line, &PYTHON, None,single_str_opt));
assert_eq!(TextInfo::new(Some("world!".to_owned()), true, None, Some(0u8)),bounds_multi(&line, &PYTHON, None,double_str_opt));
let line = String::from("\"//'''\"Hello'\"world!");
assert_eq!(TextInfo::new(Some("Hello".to_owned()), true, None, Some(1u8)),bounds_multi(&line, &PYTHON, None,None));
assert_eq!(TextInfo::from_slice_w_literal("world!"),bounds_multi(&line, &PYTHON, None,single_str_opt));
assert_eq!(TextInfo::new(Some("//".to_owned()), true, None, Some(0u8)),bounds_multi(&line, &PYTHON, None,double_str_opt));
}
#[test]
fn java_strings_and_block_comments_are_read_with_javas_own_rules() {
let double_str_opt = Some(0u8);
let line = String::from("Hello world!");
assert_eq!(TextInfo::with_open_comment(0),bounds_multi(&line, &JAVA, Some(0), None));
assert_eq!(TextInfo::with_open_symbol(0),bounds_multi(&line, &JAVA, None, double_str_opt));
assert_eq!(TextInfo::from_slice("Hello world!"),bounds_multi(&line, &JAVA, None, None));
let line = String::from("*/Hello world!");
assert_eq!(TextInfo::from_slice("Hello world!"),bounds_multi(&line, &JAVA, Some(0), None));
assert_eq!(TextInfo::from_slice("*/Hello world!"),bounds_multi(&line, &JAVA, None, None));
let line = String::from("Hello/* ffd /**//*erer */ world!");
assert_eq!(TextInfo::from_slice(" world!"),bounds_multi(&line, &JAVA, Some(0), None));
assert_eq!(TextInfo::from_slice("Hello world!"),bounds_multi(&line, &JAVA, None, None));
let line = String::from("Hello*//**//**/ world!");
assert_eq!(TextInfo::from_slice(" world!"),bounds_multi(&line, &JAVA, Some(0), None));
assert_eq!(TextInfo::from_slice("Hello*/ world!"),bounds_multi(&line, &JAVA, None, None));
let line = String::from("*//*Hello/**/ world!");
assert_eq!(TextInfo::from_slice(" world!"),bounds_multi(&line, &JAVA, Some(0), None));
assert_eq!(TextInfo::from_slice("*/ world!"),bounds_multi(&line, &JAVA, None, None));
let line = String::from("Hello world*/");
assert_eq!(TextInfo::none_all(false), bounds_multi(&line, &JAVA, Some(0), None));
let line = String::from("*/Hello world!/**/");
assert_eq!(TextInfo::from_slice("Hello world!"), bounds_multi(&line, &JAVA, Some(0), None));
let line = String::from("Hello world*//**/");
assert_eq!(TextInfo::none_all(false), bounds_multi(&line, &JAVA, Some(0), None));
let line = String::from("*/He/**//*llo world*/!/**/");
assert_eq!(TextInfo::from_slice("He!"), bounds_multi(&line, &JAVA, Some(0), None));
let line = String::from("Hello world*/!");
assert_eq!(TextInfo::from_slice("!"), bounds_multi(&line, &JAVA, Some(0), None));
let line = String::from("/*H*/ello world/*!");
assert_eq!(TextInfo::new(Some("ello world".to_string()), false, Some((0, 1)), None), bounds_multi(&line, &JAVA, Some(0), None));
assert_eq!(TextInfo::new(Some("ello world".to_string()), false, Some((0, 1)), None), bounds_multi(&line, &JAVA, None, None));
let line = String::from("/*H*/e/*llo world!");
assert_eq!(TextInfo::new(Some("e".to_string()), false, Some((0, 1)), None), bounds_multi(&line, &JAVA, Some(0), None));
let line = String::from("\"");
assert_eq!(TextInfo::with_open_symbol(0), bounds_multi(&line, &JAVA, None, None));
let line = String::from("\"Hello\"");
assert_eq!(TextInfo::new(Some("Hello".to_string()), true, None, Some(0u8)), bounds_multi(&line, &JAVA, None, double_str_opt));
assert_eq!(TextInfo::none_all(true), bounds_multi(&line, &JAVA, None, None));
let line = String::from("\"\"Hello");
assert_eq!(TextInfo::with_open_symbol(0), bounds_multi(&line, &JAVA, None, double_str_opt));
assert_eq!(TextInfo::from_slice_w_literal("Hello"), bounds_multi(&line, &JAVA, None, None));
let line = String::from("\"\"");
assert_eq!(TextInfo::with_open_symbol(0), bounds_multi(&line, &JAVA, None, double_str_opt));
assert_eq!(TextInfo::none_all(true), bounds_multi(&line, &JAVA, None, None));
let line = String::from("\"\"Hello");
assert_eq!(TextInfo::from_slice_w_literal("Hello"), bounds_multi(&line, &JAVA, None, None));
let line = String::from("Hel\"\"lo");
assert_eq!(TextInfo::from_slice_w_literal("Hello"), bounds_multi(&line, &JAVA, None, None));
let line = String::from("\"\"He\"\"\"ll\"o");
assert_eq!(TextInfo::from_slice_w_literal("Heo"), bounds_multi(&line, &JAVA, None, None));
let line = String::from(r#""""Hello""#);
assert_eq!(TextInfo::new(None, true, None, None), bounds_multi(&line, &JAVA, None, None));
assert_eq!(TextInfo::new(Some("Hello".to_owned()), true, None, Some(0u8)), bounds_multi(&line, &JAVA, None, double_str_opt));
let line = String::from("//");
assert_eq!(TextInfo::none_all(false), bounds_multi(&line, &JAVA, None, None));
let line = String::from("Hello//");
assert_eq!(TextInfo::from_slice("Hello"), bounds_multi(&line, &JAVA, None, None));
assert_eq!(TextInfo::with_open_comment(0), bounds_multi(&line, &JAVA, Some(0), None));
assert_eq!(TextInfo::with_open_symbol(0), bounds_multi(&line, &JAVA, None, double_str_opt));
let line = String::from("//Hello");
assert_eq!(TextInfo::none_all(false), bounds_multi(&line, &JAVA, None, None));
let line = String::from("////Hello");
assert_eq!(TextInfo::none_all(false), bounds_multi(&line, &JAVA, None, None));
let line = String::from("He//llo//");
assert_eq!(TextInfo::from_slice("He"), bounds_multi(&line, &JAVA, None, None));
let line = String::from("\"\"\"//\"\"\"Hello world!");
assert_eq!(TextInfo::from_slice_w_literal("Hello world!"),bounds_multi(&line, &JAVA, None, None));
assert_eq!(TextInfo::none_all(true),bounds_multi(&line, &JAVA, None, double_str_opt));
let line = String::from("\"\"one\"//\"\"\"Hello world!");
assert_eq!(TextInfo::from_slice_w_literal("oneHello world!"),bounds_multi(&line, &JAVA, None, None));
let line = String::from("\"He\"/*l*/lo//fd");
assert_eq!(TextInfo::from_slice_w_literal("lo"), bounds_multi(&line, &JAVA, None, None));
assert_eq!(TextInfo::new(Some("He".to_string()), true, None, Some(0u8)), bounds_multi(&line, &JAVA, None, double_str_opt));
assert_eq!(TextInfo::from_slice("lo"), bounds_multi(&line, &JAVA, Some(0), None));
let line = String::from("//\"/**/dfd\"");
assert_eq!(TextInfo::none_all(false), bounds_multi(&line, &JAVA, None, None));
assert_eq!(TextInfo::new(Some("dfd".to_string()), true, None, Some(0u8)), bounds_multi(&line, &JAVA, Some(0), None));
assert_eq!(TextInfo::new(Some("dfd".to_string()), true, None, Some(0u8)), bounds_multi(&line, &JAVA, None, double_str_opt));
let line = String::from(
"Hello /* \
mefm \" */ \" \
//*/world!"
);
assert_eq!(TextInfo::new(Some("Hello ".to_string()), true, None, Some(0u8)), bounds_multi(&line, &JAVA, None, None));
assert_eq!(TextInfo::with_open_symbol(0), bounds_multi(&line, &JAVA, Some(0), None));
assert_eq!(TextInfo::new(Some(" */ ".to_string()), true, None, Some(0u8)), bounds_multi(&line, &JAVA, None, double_str_opt));
}
const MARKER: &str = "mezura-expect";
const LANGUAGE_FIELD: &str = "language=";
fn fixtures_dir() -> std::path::PathBuf {
Path::new(FIXTURES_DIR).join("lang")
}
fn parse_expectations(first_line: &str) -> Option<(Option<String>, HashMap<String, usize>)> {
let after_marker = first_line.split_once(MARKER)?.1;
let after_marker = ["-->", "*/", "*)", "-}", "]]", "}"].iter()
.fold(after_marker, |text, closer| text.split(closer).next().unwrap_or(text));
let (counts, language) = match after_marker.split_once(LANGUAGE_FIELD) {
Some((before, name)) => (before, Some(name.trim().to_owned())),
None => (after_marker, None)
};
let mut expectations = HashMap::new();
for entry in counts.split_whitespace() {
let (key, value) = entry.split_once('=')?;
expectations.insert(key.to_owned(), value.parse::<usize>().ok()?);
}
if expectations.is_empty() { None } else { Some((language, expectations)) }
}
fn fixture_paths(root: &Path) -> Vec<std::path::PathBuf> {
let mut paths = std::fs::read_dir(root)
.unwrap_or_else(|x| panic!("cannot read the fixture directory {}: {x}", root.display()))
.flatten()
.map(|entry| entry.path())
.filter(|path| path.is_file())
.collect::<Vec<_>>();
paths.sort();
paths
}
#[test]
fn language_fixtures_match_their_declared_counts() {
let root = fixtures_dir();
let lookup = fixture_lookup();
let config = EngineConfig::default();
let mut failures = Vec::new();
let mut checked = 0;
for path in fixture_paths(&root) {
let name = path.file_name().unwrap().to_string_lossy().into_owned();
let contents = std::fs::read_to_string(&path).unwrap();
let Some((declared, expected)) = parse_expectations(contents.lines().next().unwrap_or_default()) else {
failures.push(format!("{name}: the first line must contain a '{MARKER} lines=N code=N ...' header"));
continue;
};
let lang_name = match declared {
Some(declared) => std::sync::Arc::from(declared.as_str()),
None => match lookup.of_path_or_shebang(&path) {
Some(found) => found,
None => {
failures.push(format!("{name}: no supported language claims this name, its extension or its first line"));
continue;
}
}
};
if !LANGUAGE_MAP_REF.contains_key(lang_name.as_ref()) {
failures.push(format!("{name}: no language is called '{lang_name}'"));
continue;
}
let language = LANGUAGE_MAP_REF.get(lang_name.as_ref()).unwrap();
let mut buf = Vec::new();
let stats = match parse_file_whole(&path, lang_name.as_ref(), &mut buf, &config) {
Ok(stats) => stats,
Err(x) => {
failures.push(format!("{name}: could not be parsed: {x}"));
continue;
}
};
let (lines, code, comments) = content_counts(&stats);
let mut actual = HashMap::from([
("lines".to_owned(), lines),
("code".to_owned(), code),
("comments".to_owned(), comments),
("extra".to_owned(), lines - code - comments),
]);
for (index, keyword) in language.keywords.iter().enumerate() {
actual.insert(keyword.descriptive_name.clone(), stats.keyword_occurences[index]);
}
for (key, expected_value) in &expected {
match actual.get(key) {
Some(actual_value) if actual_value == expected_value => (),
Some(actual_value) => failures.push(format!("{name} ({lang_name}): {key} expected {expected_value}, got {actual_value}")),
None => {
let mut known = actual.keys().cloned().collect::<Vec<_>>();
known.sort();
failures.push(format!("{name} ({lang_name}): '{key}' is not a countable field. Available: {}", known.join(", ")));
}
}
}
for (index, keyword) in language.keywords.iter().enumerate() {
let occurrences = stats.keyword_occurences[index];
if occurrences > 0 && !expected.contains_key(&keyword.descriptive_name) {
failures.push(format!("{name} ({lang_name}): found {occurrences} '{}' but the header does not declare them",
keyword.descriptive_name));
}
}
checked += 1;
}
assert!(checked > 0, "no fixtures were checked, is {} populated?", root.display());
assert!(failures.is_empty(), "\n{} fixture check(s) failed:\n {}\n", failures.len(), failures.join("\n "));
}
#[test]
fn explaining_a_file_answers_exactly_what_counting_it_does() {
let lookup = fixture_lookup();
let config = EngineConfig::default();
let mut checked = 0;
for path in fixture_paths(&fixtures_dir()) {
let name = path.file_name().unwrap().to_string_lossy().into_owned();
if name.ends_with(".md") { continue; }
let Some(lang_name) = lookup.of_path_or_shebang(&path) else { continue };
let mut buf = Vec::new();
let counted = parse_file_report(&path, lang_name.as_ref(), &mut buf, &config)
.unwrap_or_else(|x| panic!("{name} could not be counted: {x}"));
let raw = std::fs::read_to_string(&path)
.unwrap_or_else(|x| panic!("{name} could not be read: {x}"));
let (contents, explained, log) = explain_parsed_file(raw, lang_name.as_ref(), &shipped_lookup(), &config);
for (at, (raw_line, record)) in contents.lines().zip(log.records()).enumerate() {
let trimmed = raw_line.trim_ascii();
if trimmed.is_empty() {
assert!(record.spans.is_empty(), "{name}:{}: a blank line got spans", at + 1);
continue;
}
let lead = raw_line.len() - raw_line.trim_ascii_start().len();
assert_eq!(lead, record.spans[0].from,
"{name}:{}: the first span starts past the text", at + 1);
assert_eq!(lead + trimmed.len(), record.spans.last().unwrap().to,
"{name}:{}: the last span stops short of the text", at + 1);
for pair in record.spans.windows(2) {
assert_eq!(pair[0].to, pair[1].from,
"{name}:{}: spans leave a gap or overlap", at + 1);
}
for span in &record.spans {
assert!(span.from < span.to, "{name}:{}: an empty span", at + 1);
}
}
let mut lines_per_language = HashMap::<String, usize>::new();
for record in log.records() {
*lines_per_language.entry(log.get_language_name_of(record).to_owned()).or_default() += 1;
}
let mut expected = HashMap::<String, usize>::new();
*expected.entry(lang_name.to_string()).or_default() += counted.shell.lines;
for section in &counted.sections {
*expected.entry(section.language.clone()).or_default() += section.stats.lines;
}
expected.retain(|_, lines| *lines > 0);
assert_eq!(expected, lines_per_language, "{name}: lines per language");
let whole_counted = counted.into_whole();
let whole_explained = explained.into_whole();
assert_eq!(whole_counted.classes, whole_explained.classes, "{name}");
assert_eq!(whole_counted.lines, log.records().len(),
"{name}: {} lines got {} records", whole_counted.lines, log.records().len());
let mut from_records = crate::LineClasses::default();
for record in log.records() {
from_records.bump(record.class);
}
assert_eq!(whole_counted.classes, from_records,
"{name}: the records disagree with the counted classes");
checked += 1;
}
assert!(checked > 30, "only {checked} files were swept");
}
#[test]
fn a_file_is_read_to_its_end_whatever_length_the_listing_gave() {
let path = std::env::temp_dir().join("a_file_is_read_to_its_end_whatever_length_the_listing_gave.rs");
std::fs::write(&path, "x".repeat(40)).unwrap();
let read_with = |size: u64| {
let mut file = File::open(&path).unwrap();
read_file_into(&mut file, &mut Vec::new(), size).unwrap()
};
assert_eq!(40, read_with(4), "a file longer than the listing said was cut short");
assert_eq!(40, read_with(0), "a file the listing could not size was cut short");
assert_eq!(40, read_with(400), "a file shorter than the listing said was read past its end");
std::fs::remove_file(&path).unwrap();
}
#[test]
fn a_bundle_is_left_out_of_the_counts_and_counted_when_the_flag_asks() {
let root = std::env::temp_dir().join("mezura_minified_test");
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
let file = |name: &str, contents: String| {
let path = root.join(name);
std::fs::write(&path, contents).unwrap();
path
};
let skipped = |path: &std::path::Path, config: &EngineConfig| {
let mut buf = Vec::new();
matches!(parse_file(path, get_size_of(path), "JavaScript", &mut buf, &mut ParseBuffers::default(),
&shipped_lookup(), &mut KeywordMatchers::default(),
&mut IdentificationMatchers::default(), config, false, None, &HashMap::new()),
Ok(FileOutcome::Skipped(ScanSkip::Minified)))
};
let payload = format!("var a{};\n", "x".repeat(4000));
let bundle = file("bundle.js", format!("/*! licence */\n{}", payload.repeat(20)));
let hand_written = file("app.js", "var a = 1;\nfunction f() { return 2; }\n".repeat(1000));
let tiny = file("tiny.js", format!("var a{};\n", "x".repeat(5000)));
let counting_everything = EngineConfig { count_minified: true, ..Default::default() };
assert!(skipped(&bundle, &EngineConfig::default()), "the bundle was counted");
assert!(!skipped(&bundle, &counting_everything), "'--count-minified' did not count it");
assert!(!skipped(&hand_written, &EngineConfig::default()),
"{} lines of ordinary source were taken for a bundle",
std::fs::read_to_string(&hand_written).unwrap().lines().count());
assert!(!skipped(&tiny, &EngineConfig::default()), "a file too small to matter was tested");
std::fs::remove_dir_all(&root).unwrap();
}
#[test]
fn evidence_matches_at_a_line_start_behind_blanks_and_never_inside_a_word() {
let language = Language::new("Evid", ["ev"], crate::StringRules::escaping_nothing(), ["//"], &[], [])
.with_identification(["@property", "class"], ["std::"]);
let matcher = IdentificationMatcher::build(&language).unwrap();
let finds = |text: &str| matcher.find_evidence(text.as_bytes()).map(|(_, literal)| literal.to_owned());
assert_eq!(Some("@property".to_owned()), finds("int x;\n\t @property int y;\n"));
assert_eq!(None, finds("int x; @property int y;\n"), "mid-line matched a line-start literal");
assert_eq!(Some("class".to_owned()), finds("class Foo {\n"));
assert_eq!(None, finds("classic_t x;\n"), "a literal kept running into the word after it");
assert_eq!(Some("std::".to_owned()), finds("int a = std::max(1, 2);\n"));
assert_eq!(None, finds("plain text\n"));
assert!(IdentificationMatcher::build(&Language::new("Bare", ["b"],
crate::StringRules::escaping_nothing(), ["//"], &[], [])).is_none());
}
#[test]
fn evidence_past_the_identification_cap_is_not_read() {
let language = Language::new("Evid", ["ev"], crate::StringRules::escaping_nothing(), ["//"], &[], [])
.with_identification(["zeddoc"], [""; 0]);
let languages = crate::languages::keyed_by_name([language]);
let contenders = [Arc::<str>::from("Evid")];
let no_shebangs = HashMap::new();
let mut matchers = IdentificationMatchers::default();
let mut identify = |buf: &str| identify_language(buf, &contenders, &languages, &no_shebangs,
&mut matchers).map(|(name, _)| name.to_string());
let padding = "aaaaaaaa\n".repeat(IDENTIFICATION_BYTES / 9 + 1);
assert_eq!(None, identify(&format!("{padding}zeddoc\n")));
assert_eq!(Some("Evid".to_owned()),
identify(&format!("{}\nzeddoc\n{padding}", &padding[..IDENTIFICATION_BYTES / 2])));
}
#[test]
fn a_marker_matches_as_a_prefix_on_the_first_two_lines_where_identification_would_not() {
let matcher = IdentificationMatcher::of(&["-keep".to_owned()], &[".o:".to_owned()]).unwrap();
assert!(matcher.finds_a_marker("-keepnames class * { *; }\n"));
assert!(matcher.find_evidence("-keepnames class * { *; }\n".as_bytes()).is_none(),
"identification loosened into prefix matching");
assert!(matcher.finds_a_marker(&format!("{} main.o: src\nrest\n", "x".repeat(3 * IDENTIFICATION_BYTES))),
"a marker at the end of one long first line was not read");
assert!(matcher.finds_a_marker("main.d: src\n\nlibmain.o: src\n"),
"a marker on the third line, where cargo writes its artifact rule, was not read");
assert!(!matcher.finds_a_marker(&format!("{}main.o: y\n", "code\n".repeat(NOT_CODE_MARKER_LINES))),
"a contains marker past the top lines was believed");
assert!(matcher.finds_a_marker("\u{feff}-keep class x\n"),
"a byte order mark defeated a line-start marker");
let contains_word = IdentificationMatcher::of(&[], &["bundle".to_owned()]).unwrap();
assert!(!contains_word.finds_a_marker("a bundled thing\n"),
"a contains marker matched a prefix of a longer word");
assert!(contains_word.finds_a_marker("a bundle of things\n"));
}
#[test]
fn a_file_whose_head_says_a_tool_wrote_it_is_left_out() {
let root = std::env::temp_dir().join("mezura_generated_test");
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
let body = "var a = 1;\nvar b = 2;\n".repeat(40);
let skipped = |name: &str, head: &str, config: &EngineConfig| {
let path = root.join(name);
std::fs::write(&path, format!("{head}{body}")).unwrap();
let mut buf = Vec::new();
matches!(parse_file(&path, get_size_of(&path), "JavaScript", &mut buf, &mut ParseBuffers::default(),
&shipped_lookup(), &mut KeywordMatchers::default(),
&mut IdentificationMatchers::default(), config, false, None, &HashMap::new()),
Ok(FileOutcome::Skipped(ScanSkip::Generated)))
};
let default = EngineConfig::default();
let counting_everything = EngineConfig { count_generated: true, ..Default::default() };
for (name, head) in [("go.js", "// Code generated by protoc-gen-go. DO NOT EDIT.\n"),
("csharp.js", "// <auto-generated>\n"),
("cml.js", "/* This file is autogenerated by cml-utils 2024-10-04 */\n"),
("meta.js", "/* @generated */\n"),
("shouty.js", "/* THIS FILE WAS AUTOGENERATED BY jevents.py */\n"),
("licenced.js", &format!("/*\n{}\n*/\n// <auto-generated>\n", " * SPDX-License-Identifier: GPL-2.0\n".repeat(6)))] {
assert!(skipped(name, head, &default), "'{}' was counted", head.trim());
assert!(!skipped(name, head, &counting_everything), "'--count-generated' did not count {name}");
}
assert!(!skipped("plain.js", "// a hand written file\n", &default));
assert!(!skipped("prose.js", "// The table below was generated by a reference implementation\n", &default));
let deep = format!("{}\nconsole.log('/* do not edit */');\n", "// padding padding padding\n".repeat(30));
assert!(!skipped("generator.js", &deep, &default), "the marker was found past the window");
std::fs::remove_dir_all(&root).unwrap();
}
fn fixture_lookup() -> LanguageLookup {
let conflicts = crate::languages::parse_shipped_conflict_rules();
LanguageLookup {
by_extension: build_language_map_by(ClaimKind::Extension, &LANGUAGE_MAP_REF,
&conflicts.by_extension, &HashMap::new()).0,
by_filename: build_language_map_by(ClaimKind::Filename, &LANGUAGE_MAP_REF,
&conflicts.by_filename, &HashMap::new()).0,
by_shebang: build_language_map_by(ClaimKind::Shebang, &LANGUAGE_MAP_REF,
&HashMap::new(), &HashMap::new()).0,
extension_rules: HashMap::new()
}
}
#[test]
fn every_fixture_extension_resolves_to_exactly_one_language() {
use crate::engine::identity::interpreter_spellings;
let mut claimants_of = HashMap::<String, Vec<String>>::new();
for language in LANGUAGE_MAP_REF.values() {
let mut claim = |identity: String| {
let claiming = claimants_of.entry(identity).or_default();
if !claiming.contains(&language.name) {
claiming.push(language.name.clone());
}
};
language.extensions.iter().for_each(|x| claim(ClaimKind::Extension.key_of(x)));
language.filenames.iter().for_each(|x| claim(ClaimKind::Filename.key_of(x)));
language.shebangs.iter().for_each(|x| claim(ClaimKind::Shebang.key_of(x)));
}
for path in fixture_paths(&fixtures_dir()) {
let contents = std::fs::read_to_string(&path).unwrap_or_default();
if parse_expectations(contents.lines().next().unwrap_or_default())
.is_some_and(|(declared, _)| declared.is_some()) {
continue;
}
let name = path.file_name().and_then(|x| x.to_str()).unwrap_or_default();
let as_filename = ClaimKind::Filename.key_of(name);
let identity = if claimants_of.contains_key(&as_filename) {
as_filename
} else if let Some(extension) = path.extension().and_then(|x| x.to_str()) {
ClaimKind::Extension.key_of(extension)
} else {
let token = crate::engine::identity::find_interpreter(contents.as_bytes())
.and_then(|x| std::str::from_utf8(x).ok()).unwrap_or_default();
interpreter_spellings(token).into_iter()
.find(|spelling| claimants_of.contains_key(spelling))
.unwrap_or_else(|| token.to_ascii_lowercase())
};
let claimants = claimants_of.get(&identity).cloned().unwrap_or_default();
assert!(claimants.len() == 1, "the fixture identity '{identity}' is claimed by {} languages ({}), so its counts depend on the tie-break rule",
claimants.len(), claimants.join(", "));
}
}
}