use std::{collections::HashMap, sync::OnceLock};
#[derive(Debug, Clone)]
pub struct Language {
pub name: String,
pub extensions : Vec<String>,
pub filenames : Vec<String>,
pub shebangs : Vec<String>,
pub strings : StringRules,
pub comment_symbols : Vec<String>,
pub multiline_comments : Vec<(String, String)>,
pub nesting_comments : Vec<(String, String)>,
pub leveled_comments : Vec<LeveledPair>,
pub cancelled_symbols : Vec<(String, u8)>,
pub line_continuation : Option<LineContinuation>,
pub nested_languages : Vec<NestedLanguage>,
pub keywords : Vec<Keyword>,
pub identifying_line_starts : Vec<String>,
pub identifying_line_contains : Vec<String>,
pub(crate) scan_plan : OnceLock<crate::engine::file_parser::ScanPlan>
}
impl Language {
pub fn new(name: impl AsRef<str>,
extensions: impl IntoIterator<Item = impl AsRef<str>>,
strings: StringRules,
comment_symbols: impl IntoIterator<Item = impl AsRef<str>>,
multiline_comments: &[(&str, &str)],
keywords: impl IntoIterator<Item = Keyword>) -> Self
{
Language {
name : name.as_ref().to_owned(),
extensions : owned_strings(extensions),
filenames : Vec::new(),
shebangs : Vec::new(),
strings,
comment_symbols : owned_strings(comment_symbols),
multiline_comments : multiline_comments.iter()
.map(|(start, end)| ((*start).to_owned(), (*end).to_owned())).collect(),
nesting_comments : Vec::new(),
leveled_comments : Vec::new(),
cancelled_symbols : Vec::new(),
line_continuation : None,
nested_languages : Vec::new(),
keywords : keywords.into_iter().collect(),
identifying_line_starts : Vec::new(),
identifying_line_contains : Vec::new(),
scan_plan : OnceLock::new()
}
}
pub fn with_identification(mut self, line_starts: impl IntoIterator<Item = impl AsRef<str>>,
line_contains: impl IntoIterator<Item = impl AsRef<str>>) -> Self
{
let kept = |x: Vec<String>| x.into_iter().filter(|literal| !literal.is_empty()).collect();
self.identifying_line_starts = kept(owned_strings(line_starts));
self.identifying_line_contains = kept(owned_strings(line_contains));
self
}
pub fn with_leveled_comments(mut self, pairs: &[LeveledPair]) -> Self {
self.leveled_comments.extend(pairs.iter().cloned());
self
}
pub fn with_filenames(mut self, names: impl IntoIterator<Item = impl AsRef<str>>) -> Self {
self.filenames.extend(owned_strings(names));
self
}
pub fn with_shebangs(mut self, interpreters: impl IntoIterator<Item = impl AsRef<str>>) -> Self {
self.shebangs.extend(owned_strings(interpreters));
self
}
pub fn with_line_continuation(mut self, symbol: &str, in_strings: bool, in_comments: bool) -> Self {
if symbol.is_empty() {
return self;
}
self.line_continuation = Some(LineContinuation {
symbol: symbol.to_owned(), in_strings, in_comments });
self
}
pub fn with_nested_languages(mut self, regions: &[NestedLanguage]) -> Self {
self.nested_languages.extend(regions.iter().cloned());
self
}
pub fn with_nesting_comments(mut self, pairs: &[(impl AsRef<str>, impl AsRef<str>)]) -> Self {
self.nesting_comments.extend(pairs.iter()
.map(|(start, end)| (start.as_ref().to_owned(), end.as_ref().to_owned())));
self
}
pub fn with_cancelled_symbols(mut self, symbols: &[(impl AsRef<str>, u8)]) -> Self {
self.cancelled_symbols.extend(symbols.iter()
.map(|(symbol, after)| (symbol.as_ref().to_owned(), *after)));
self
}
pub fn supports_multiline_comments(&self) -> bool {
!self.multiline_comments.is_empty() || !self.nesting_comments.is_empty()
|| !self.leveled_comments.is_empty()
}
pub(crate) fn declares_identification(&self) -> bool {
!self.identifying_line_starts.is_empty() || !self.identifying_line_contains.is_empty()
}
pub(crate) fn get_string_pair_of(&self, symbol: u8) -> (&str, &str) {
let (symbols, literals) = (self.strings.get_symbols(), self.strings.get_char_literals());
let symbol = symbol as usize;
if let Some(single) = symbols.get(symbol) {
return (single, single);
}
match literals.get(symbol - symbols.len()) {
Some(literal) => (literal, literal),
None => {
let crossing = &self.strings.get_multiline_strings()[symbol - symbols.len() - literals.len()];
(&crossing.open, &crossing.close)
}
}
}
pub(crate) fn string_crosses_lines(&self, symbol: u8) -> bool {
symbol as usize >= self.strings.get_symbols().len() + self.strings.get_char_literals().len()
}
pub(crate) fn get_comment_pair_of(&self, symbol: u8) -> CommentPair<'_> {
let symbol = symbol as usize;
if let Some((start, end)) = self.multiline_comments.get(symbol) {
return CommentPair::Plain { start, end };
}
match self.nesting_comments.get(symbol - self.multiline_comments.len()) {
Some((start, end)) => CommentPair::Nesting { start, end },
None => CommentPair::Leveled(
&self.leveled_comments[symbol - self.multiline_comments.len() - self.nesting_comments.len()])
}
}
pub(crate) fn comment_pairs(&self) -> impl Iterator<Item = CommentPair<'_>> {
self.multiline_comments.iter().map(|(start, end)| CommentPair::Plain { start, end })
.chain(self.nesting_comments.iter().map(|(start, end)| CommentPair::Nesting { start, end }))
.chain(self.leveled_comments.iter().map(CommentPair::Leveled))
}
pub(crate) fn comment_nests(&self, symbol: u8) -> bool {
matches!(self.get_comment_pair_of(symbol), CommentPair::Nesting { .. })
}
pub(crate) fn comment_is_leveled(&self, symbol: u8) -> bool {
matches!(self.get_comment_pair_of(symbol), CommentPair::Leveled(_))
}
pub(crate) fn comment_start_len(&self, symbol: u8, level: u8) -> usize {
match self.get_comment_pair_of(symbol) {
CommentPair::Leveled(pair) => pair.start_prefix.len() + level as usize + 1,
CommentPair::Plain { start, .. } | CommentPair::Nesting { start, .. } => start.len()
}
}
pub(crate) fn comment_end_len(&self, symbol: u8, level: u8) -> usize {
match self.get_comment_pair_of(symbol) {
CommentPair::Leveled(pair) => pair.end_prefix.len() + level as usize + 1,
CommentPair::Plain { end, .. } | CommentPair::Nesting { end, .. } => end.len()
}
}
}
impl PartialEq for Language {
fn eq(&self, other: &Self) -> bool {
self.name == other.name
&& self.extensions == other.extensions
&& self.filenames == other.filenames
&& self.shebangs == other.shebangs
&& self.strings == other.strings
&& self.comment_symbols == other.comment_symbols
&& self.multiline_comments == other.multiline_comments
&& self.nesting_comments == other.nesting_comments
&& self.leveled_comments == other.leveled_comments
&& self.line_continuation == other.line_continuation
&& self.nested_languages == other.nested_languages
&& self.keywords == other.keywords
}
}
pub(crate) enum CommentPair<'a> {
Plain { start: &'a str, end: &'a str },
Nesting { start: &'a str, end: &'a str },
Leveled(&'a LeveledPair)
}
#[derive(Debug, Clone, PartialEq)]
pub struct StringRules {
escape : Option<u8>,
symbols : Vec<String>,
char_literals : Vec<String>,
multiline : Vec<MultilineString>
}
impl StringRules {
pub fn escaping_with(escape: u8) -> StringRules {
StringRules { escape: Some(escape), symbols: Vec::new(), char_literals: Vec::new(),
multiline: Vec::new() }
}
pub fn escaping_nothing() -> StringRules {
StringRules { escape: None, symbols: Vec::new(), char_literals: Vec::new(), multiline: Vec::new() }
}
pub fn with_symbols(mut self, symbols: impl IntoIterator<Item = impl AsRef<str>>) -> Self {
self.symbols.extend(owned_strings(symbols));
self
}
pub fn with_char_literals(mut self, symbols: impl IntoIterator<Item = impl AsRef<str>>) -> Self {
self.char_literals.extend(owned_strings(symbols));
self
}
pub fn with_multiline_strings(mut self, symbols: impl IntoIterator<Item = impl AsRef<str>>) -> Self {
self.multiline.extend(symbols.into_iter().map(|x| MultilineString::escaping(x.as_ref())));
self
}
pub fn with_raw_multiline_strings(mut self, symbols: impl IntoIterator<Item = impl AsRef<str>>) -> Self {
self.multiline.extend(symbols.into_iter().map(|x| MultilineString::raw(x.as_ref())));
self
}
pub fn with_string_pairs(mut self, pairs: &[(impl AsRef<str>, impl AsRef<str>)]) -> Self {
self.multiline.extend(pairs.iter().map(|(open, close)| MultilineString::of(open.as_ref(), close.as_ref())));
self
}
pub fn get_escape(&self) -> Option<u8> {
self.escape
}
pub fn get_symbols(&self) -> &[String] {
&self.symbols
}
pub fn get_char_literals(&self) -> &[String] {
&self.char_literals
}
pub fn get_multiline_strings(&self) -> &[MultilineString] {
&self.multiline
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct MultilineString {
pub open : String,
pub close : String,
pub escapes : bool
}
impl MultilineString {
pub fn escaping(symbol: &str) -> MultilineString {
MultilineString { open: symbol.to_owned(), close: symbol.to_owned(), escapes: true }
}
pub fn raw(symbol: &str) -> MultilineString {
MultilineString { open: symbol.to_owned(), close: symbol.to_owned(), escapes: false }
}
pub fn of(open: &str, close: &str) -> MultilineString {
MultilineString { open: open.to_owned(), close: close.to_owned(), escapes: false }
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct NestedLanguage {
pub start : String,
pub end : String,
pub default : String
}
impl NestedLanguage {
pub fn of(start: &str, end: &str, default: &str) -> NestedLanguage {
NestedLanguage { start: start.to_owned(), end: end.to_owned(), default: default.to_owned() }
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct LineContinuation {
pub symbol : String,
pub in_strings : bool,
pub in_comments : bool
}
#[derive(Debug, Clone, PartialEq)]
pub struct LeveledPair {
pub start_prefix : String,
pub start_suffix : u8,
pub end_prefix : String,
pub end_suffix : u8,
}
impl LeveledPair {
pub fn of(start: &str, end: &str) -> Option<LeveledPair> {
let (start_prefix, start_suffix) = split_leveled_half(start)?;
let (end_prefix, end_suffix) = split_leveled_half(end)?;
Some(LeveledPair { start_prefix, start_suffix, end_prefix, end_suffix })
}
}
#[derive(Debug,PartialEq,Eq,Clone)]
pub struct Keyword {
pub descriptive_name : String,
pub aliases : Vec<String>
}
impl Keyword {
pub fn new(descriptive_name: impl AsRef<str>, aliases: impl IntoIterator<Item = impl AsRef<str>>) -> Self {
Keyword {
descriptive_name : descriptive_name.as_ref().to_owned(),
aliases : owned_strings(aliases)
}
}
}
#[derive(Debug,PartialEq,Default,Clone)]
pub struct LineClasses {
pub words_in_code : usize,
pub string_content : usize,
pub comment_words_beside_code : usize,
pub words_in_comment : usize,
pub punctuation_in_code : usize,
pub punctuation_in_comment : usize,
pub blank : usize,
pub blank_in_comment : usize,
pub blank_in_string : usize
}
impl LineClasses {
pub const NAMES : [&'static str; 9] = ["words_in_code", "string_content",
"comment_words_beside_code", "words_in_comment", "punctuation_in_code",
"punctuation_in_comment", "blank", "blank_in_comment", "blank_in_string"];
pub fn of_array(counts: [usize; 9]) -> Self {
LineClasses {
words_in_code: counts[0],
string_content: counts[1],
comment_words_beside_code: counts[2],
words_in_comment: counts[3],
punctuation_in_code: counts[4],
punctuation_in_comment: counts[5],
blank: counts[6],
blank_in_comment: counts[7],
blank_in_string: counts[8]
}
}
pub fn to_array(&self) -> [usize; 9] {
[self.words_in_code, self.string_content, self.comment_words_beside_code,
self.words_in_comment, self.punctuation_in_code, self.punctuation_in_comment,
self.blank, self.blank_in_comment, self.blank_in_string]
}
pub fn calculate_lines(&self) -> usize {
self.to_array().iter().sum()
}
pub fn bump(&mut self, class: LineClass) {
match class {
LineClass::WordsInCode => self.words_in_code += 1,
LineClass::StringContent => self.string_content += 1,
LineClass::CommentWordsBesideCode => self.comment_words_beside_code += 1,
LineClass::WordsInComment => self.words_in_comment += 1,
LineClass::PunctuationInCode => self.punctuation_in_code += 1,
LineClass::PunctuationInComment => self.punctuation_in_comment += 1,
LineClass::Blank => self.blank += 1,
LineClass::BlankInComment => self.blank_in_comment += 1,
LineClass::BlankInString => self.blank_in_string += 1
}
}
pub(crate) fn add(&mut self, other: &LineClasses) {
*self = combine_classes(self, other, |mine, theirs| mine + theirs);
}
pub fn subtract(&mut self, other: &LineClasses) {
*self = combine_classes(self, other, usize::saturating_sub);
}
}
#[allow(missing_docs)]
#[derive(Debug,PartialEq,Eq,Clone,Copy)]
pub enum LineClass {
WordsInCode,
StringContent,
CommentWordsBesideCode,
WordsInComment,
PunctuationInCode,
PunctuationInComment,
Blank,
BlankInComment,
BlankInString
}
impl LineClass {
pub const ALL : [LineClass; 9] = [LineClass::WordsInCode, LineClass::StringContent,
LineClass::CommentWordsBesideCode, LineClass::WordsInComment, LineClass::PunctuationInCode,
LineClass::PunctuationInComment, LineClass::Blank, LineClass::BlankInComment,
LineClass::BlankInString];
pub fn name(self) -> &'static str {
LineClasses::NAMES[self as usize]
}
}
#[derive(Debug,PartialEq,Eq,Clone,Copy)]
pub enum Bucket {
Code,
Comments,
Third
}
#[derive(Debug,PartialEq,Eq,Clone,Copy)]
pub struct Span {
pub from: usize,
pub to: usize,
pub kind: SpanKind
}
#[derive(Debug,PartialEq,Eq,Clone,Copy)]
pub enum SpanKind {
Code,
String,
Comment
}
impl SpanKind {
pub fn name(self) -> &'static str {
match self {
Self::Code => "code",
Self::String => "string",
Self::Comment => "comment"
}
}
}
#[derive(Debug,PartialEq,Eq,Clone,Copy,Default)]
pub enum CountingModel {
#[default]
Content,
Region
}
impl CountingModel {
pub fn parse(value: &str) -> Option<CountingModel> {
match value.trim().to_lowercase().as_str() {
"content" => Some(Self::Content),
"region" => Some(Self::Region),
_ => None
}
}
pub fn name(self) -> &'static str {
match self {
Self::Content => "content",
Self::Region => "region"
}
}
pub fn get_other(self) -> CountingModel {
match self {
Self::Content => Self::Region,
Self::Region => Self::Content
}
}
pub fn get_third_quantity_name(self) -> &'static str {
match self {
Self::Content => "extra",
Self::Region => "blanks"
}
}
pub fn get_bucket_name(self, bucket: Bucket) -> &'static str {
match bucket {
Bucket::Code => "code",
Bucket::Comments => "comments",
Bucket::Third => self.get_third_quantity_name()
}
}
pub fn fold(self, class: LineClass) -> Bucket {
match self {
Self::Content => match class {
LineClass::WordsInCode | LineClass::StringContent => Bucket::Code,
LineClass::CommentWordsBesideCode | LineClass::WordsInComment => Bucket::Comments,
LineClass::PunctuationInCode | LineClass::PunctuationInComment | LineClass::Blank
| LineClass::BlankInComment | LineClass::BlankInString => Bucket::Third
},
Self::Region => match class {
LineClass::WordsInCode | LineClass::StringContent | LineClass::CommentWordsBesideCode
| LineClass::PunctuationInCode | LineClass::BlankInString => Bucket::Code,
LineClass::WordsInComment | LineClass::PunctuationInComment
| LineClass::BlankInComment => Bucket::Comments,
LineClass::Blank => Bucket::Third
}
}
}
pub fn calculate_code_lines(self, classes: &LineClasses) -> usize {
self.sum_the_classes_folding_to(Bucket::Code, classes)
}
pub fn calculate_comment_lines(self, classes: &LineClasses) -> usize {
self.sum_the_classes_folding_to(Bucket::Comments, classes)
}
fn sum_the_classes_folding_to(self, bucket: Bucket, classes: &LineClasses) -> usize {
LineClass::ALL.iter().zip(classes.to_array())
.filter(|(class, _)| self.fold(**class) == bucket)
.map(|(_, count)| count).sum()
}
}
#[derive(Debug,PartialEq,Default,Clone)]
pub struct Stats {
pub files : usize,
pub bytes : usize,
pub lines : usize,
pub classes : LineClasses,
pub keyword_occurences : HashMap<String,usize>
}
impl Stats {
pub fn new(files: usize, bytes: usize, lines: usize, classes: LineClasses,
keyword_occurences: HashMap<String,usize>) -> Self
{
Stats { files, bytes, lines, classes, keyword_occurences }
}
pub fn calculate_code_lines(&self, model: CountingModel) -> usize {
model.calculate_code_lines(&self.classes)
}
pub fn calculate_comment_lines(&self, model: CountingModel) -> usize {
model.calculate_comment_lines(&self.classes)
}
pub fn calculate_extra_lines(&self, model: CountingModel) -> usize {
self.lines.saturating_sub(self.calculate_code_lines(model))
.saturating_sub(self.calculate_comment_lines(model))
}
pub(crate) fn add_file(&mut self, stats: &FileStats, bytes: usize, keywords: &[Keyword]) {
debug_assert_eq!(stats.lines, stats.classes.calculate_lines(),
"a counted file has {} lines and {} of them landed in a class",
stats.lines, stats.classes.calculate_lines());
self.files += 1;
self.bytes += bytes;
self.lines += stats.lines;
self.classes.add(&stats.classes);
for (keyword_index, occurrences) in stats.keyword_occurences.iter().enumerate() {
if *occurrences > 0 {
*self.keyword_occurences.entry(keywords[keyword_index].descriptive_name.clone())
.or_default() += *occurrences;
}
}
}
pub fn add(&mut self, other: &Stats) {
self.files += other.files;
self.bytes += other.bytes;
self.lines += other.lines;
self.classes.add(&other.classes);
for (keyword, occurrences) in other.keyword_occurences.iter() {
*self.keyword_occurences.entry(keyword.clone()).or_default() += *occurrences;
}
}
pub fn total_of(languages: &HashMap<String, Stats>) -> Self {
let mut total = Stats::default();
for stats in languages.values() {
total.add(stats);
}
total
}
}
impl From<&Language> for Stats {
fn from(language: &Language) -> Self {
Stats { keyword_occurences: create_keyword_slots(language), ..Default::default() }
}
}
#[derive(Debug,PartialEq,Default)]
pub(crate) struct FileStats {
pub lines : usize,
pub classes : LineClasses,
pub keyword_occurences : Vec<usize>
}
impl FileStats {
pub(crate) fn with_keywords(keywords: &[Keyword]) -> Self {
FileStats {
lines : 0,
classes : LineClasses::default(),
keyword_occurences : vec![0; keywords.len()]
}
}
}
pub(crate) fn owned_strings(items: impl IntoIterator<Item = impl AsRef<str>>) -> Vec<String> {
items.into_iter().map(|x| x.as_ref().to_owned()).collect()
}
fn split_leveled_half(pattern: &str) -> Option<(String, u8)> {
let (prefix, rest) = pattern.split_once("=*")?;
if prefix.is_empty() || rest.len() != 1 || rest.contains("=*") {
return None;
}
Some((prefix.to_owned(), rest.as_bytes()[0]))
}
fn combine_classes(one: &LineClasses, other: &LineClasses,
of_each: impl Fn(usize, usize) -> usize) -> LineClasses
{
let (mine, theirs) = (one.to_array(), other.to_array());
LineClasses::of_array(std::array::from_fn(|at| of_each(mine[at], theirs[at])))
}
fn create_keyword_slots(language: &Language) -> HashMap<String,usize> {
language.keywords.iter().map(|keyword| (keyword.descriptive_name.clone(), 0)).collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn stats_of(lines: usize, code: usize, comments: usize) -> Stats {
let classes = LineClasses { words_in_code: code, words_in_comment: comments, ..Default::default() };
Stats::new(1, 0, lines, classes, HashMap::new())
}
#[test]
fn counts_that_do_not_add_up_give_no_extra_lines_rather_than_a_panic() {
assert_eq!(0, stats_of(0, 0, 900).calculate_extra_lines(CountingModel::Content));
assert_eq!(0, stats_of(40, 900, 50).calculate_extra_lines(CountingModel::Content));
assert_eq!(0, stats_of(100, 60, 40).calculate_extra_lines(CountingModel::Content));
assert_eq!(10, stats_of(100, 60, 30).calculate_extra_lines(CountingModel::Content));
}
#[test]
fn each_model_folds_the_classes_into_its_own_columns() {
let classes = LineClasses {
words_in_code: 10, string_content: 5, comment_words_beside_code: 4, words_in_comment: 8,
punctuation_in_code: 3, punctuation_in_comment: 2, blank: 6, blank_in_comment: 1,
blank_in_string: 7
};
let stats = Stats::new(1, 0, 46, classes, HashMap::new());
assert_eq!(15, stats.calculate_code_lines(CountingModel::Content));
assert_eq!(12, stats.calculate_comment_lines(CountingModel::Content));
assert_eq!(19, stats.calculate_extra_lines(CountingModel::Content));
assert_eq!(29, stats.calculate_code_lines(CountingModel::Region));
assert_eq!(11, stats.calculate_comment_lines(CountingModel::Region));
assert_eq!(6, stats.calculate_extra_lines(CountingModel::Region));
}
#[test]
fn every_class_is_listed_in_the_order_its_names_are_written_in() {
for (i, class) in LineClass::ALL.iter().enumerate() {
assert_eq!(LineClasses::NAMES[i], class.name());
}
}
#[test]
fn a_model_names_its_buckets() {
assert_eq!("code", CountingModel::Content.get_bucket_name(Bucket::Code));
assert_eq!("comments", CountingModel::Region.get_bucket_name(Bucket::Comments));
assert_eq!("extra", CountingModel::Content.get_bucket_name(Bucket::Third));
assert_eq!("blanks", CountingModel::Region.get_bucket_name(Bucket::Third));
}
#[test]
fn an_empty_continuation_symbol_leaves_the_language_without_one() {
let language = |symbol| Language::new("L", ["l"], StringRules::escaping_nothing(), [""], &[], [])
.with_line_continuation(symbol, true, true);
assert_eq!(None, language("").line_continuation);
assert_eq!(Some("\\".to_owned()), language("\\").line_continuation.map(|x| x.symbol));
}
}