use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use indexmap::IndexMap;
use regex::Regex;
use skif_config::JsCompiler;
use skif_core::LineIndex;
use skif_js_syntax::{Import, ImportKind, ModuleInfo};
#[derive(Debug)]
pub(crate) enum Compiler {
Reader(String),
Patterns(Vec<Regex>),
}
#[derive(Debug, Default)]
pub(crate) struct Compilers(BTreeMap<String, Compiler>);
impl Compilers {
pub(crate) fn new(declared: &IndexMap<String, JsCompiler>) -> Self {
let compilers = declared
.iter()
.filter_map(|(extension, compiler)| {
let read = match (compiler.reader.as_deref(), compiler.imports.as_deref()) {
(Some(reader), None) if JsCompiler::READERS.contains(&reader) => {
Compiler::Reader(reader.to_owned())
}
(None, Some(patterns)) if !patterns.is_empty() => Compiler::Patterns(
patterns
.iter()
.map(|pattern| Regex::new(pattern).ok().filter(|regex| regex.captures_len() == 2))
.collect::<Option<Vec<_>>>()?,
),
_ => return None,
};
let extension = extension.to_ascii_lowercase();
(!JsCompiler::READERS.contains(&extension.as_str())).then_some((extension, read))
})
.collect();
Self(compilers)
}
pub(crate) fn of(&self, path: &Path) -> Option<&Compiler> {
if self.0.is_empty() {
return None;
}
let extension = path.extension()?.to_str()?.to_ascii_lowercase();
self.0.get(&extension)
}
pub(crate) fn handles(&self, path: &Path) -> bool {
self.of(path).is_some()
}
pub(crate) fn extensions(&self) -> impl Iterator<Item = &str> {
self.0.keys().map(String::as_str)
}
}
pub(crate) fn reader_path(path: &Path, reader: &str) -> PathBuf {
path.with_extension(reader)
}
pub(crate) fn extract(patterns: &[Regex], source: &str) -> ModuleInfo {
let lines = LineIndex::new(source);
let mut found: Vec<(usize, usize)> = patterns
.iter()
.flat_map(|pattern| pattern.captures_iter(source))
.filter_map(|captures| captures.get(1))
.filter(|capture| !capture.as_str().trim().is_empty())
.map(|capture| (capture.start(), capture.end()))
.collect();
found.sort_unstable();
found.dedup();
let offset = |at: usize| u32::try_from(at).unwrap_or(u32::MAX);
ModuleInfo {
imports: found
.into_iter()
.map(|(start, end)| Import {
specifier: source[start..end].trim().to_owned(),
kind: ImportKind::SideEffect,
names: Vec::new(),
call: None,
span: lines.span(offset(start), offset(end)),
})
.collect(),
..ModuleInfo::default()
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::resolve::is_read_file;
fn declared(entries: &[(&str, JsCompiler)]) -> Compilers {
let map: IndexMap<String, JsCompiler> = entries
.iter()
.cloned()
.map(|(key, value)| (key.to_owned(), value))
.collect();
Compilers::new(&map)
}
#[test]
fn the_readers_a_compiler_can_name_are_the_extensions_skif_reads() {
for reader in JsCompiler::READERS {
assert!(is_read_file(Path::new(&format!("x.{reader}"))), "{reader}");
}
for extension in crate::resolve::CODE_EXTENSIONS
.iter()
.chain(crate::resolve::EXTRACTED_EXTENSIONS)
{
assert!(JsCompiler::READERS.contains(extension), "{extension}");
}
for other in ["html", "json", "md", "hbs"] {
assert!(!is_read_file(Path::new(&format!("x.{other}"))), "{other}");
}
}
#[test]
fn captures_of_every_pattern_are_side_effect_imports_in_file_order() {
let source = "<link href=\"./a.css\">\n<script src=\"./main.ts\"></script>\n<script src=\" \"></script>\n\
<script src=\"./main.ts\"></script>\n";
let patterns = [
Regex::new(r#"<script[^>]+src="([^"]*)""#).unwrap(),
Regex::new(r#"(?:src|href)="(\./[^"]+)""#).unwrap(),
];
let info = extract(&patterns, source);
let found: Vec<(&str, ImportKind, u32, u32)> = info
.imports
.iter()
.map(|import| {
(
import.specifier.as_str(),
import.kind,
import.span.start_line,
import.span.start_col,
)
})
.collect();
assert_eq!(
found,
[
("./a.css", ImportKind::SideEffect, 1, 13),
("./main.ts", ImportKind::SideEffect, 2, 14),
("./main.ts", ImportKind::SideEffect, 4, 14),
]
);
assert!(info.exports.is_empty() && !info.fatal);
}
#[test]
fn compilers_are_found_by_lowercase_extension_and_invalid_entries_are_left_out() {
let compilers = declared(&[
("html", JsCompiler::matching(["src=\"([^\"]+)\""])),
("doc", JsCompiler::reading("mdx")),
("vue", JsCompiler::reading("mdx")),
("two", JsCompiler::matching(["(a)(b)"])),
("bad", JsCompiler::reading("html")),
]);
assert!(matches!(compilers.of(Path::new("a/Index.HTML")), Some(Compiler::Patterns(p)) if p.len() == 1));
assert!(matches!(compilers.of(Path::new("page.doc")), Some(Compiler::Reader(r)) if r == "mdx"));
for path in ["a.vue", "a.two", "a.bad", "a.ts", "html"] {
assert!(!compilers.handles(Path::new(path)), "{path}");
}
assert_eq!(compilers.extensions().collect::<Vec<_>>(), ["doc", "html"]);
assert_eq!(
reader_path(Path::new("/r/docs/page.doc"), "mdx"),
Path::new("/r/docs/page.mdx")
);
}
}