use crate::parser::{parse, Document};
use crate::render::{render, RenderOptions};
use moka::sync::Cache;
use std::collections::HashMap;
use std::fs;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use std::sync::{Arc, OnceLock, RwLock};
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone)]
pub struct CachedFile {
pub content: Arc<String>,
pub modification_time: u64,
pub last_accessed: SystemTime,
}
impl CachedFile {
pub fn new(content: String, modification_time: u64) -> Self {
Self {
content: Arc::new(content),
modification_time,
last_accessed: SystemTime::now(),
}
}
pub fn is_valid_for(&self, path: &Path) -> bool {
match fs::metadata(path) {
Ok(metadata) => {
if let Ok(modified) = metadata.modified() {
if let Ok(duration) = modified.duration_since(UNIX_EPOCH) {
return duration.as_secs() == self.modification_time;
}
}
}
Err(_) => return false,
}
false
}
}
pub struct SimpleFileCache {
content_cache: Arc<RwLock<HashMap<PathBuf, CachedFile>>>,
}
impl Default for SimpleFileCache {
fn default() -> Self {
Self::new()
}
}
impl SimpleFileCache {
pub fn new() -> Self {
Self {
content_cache: Arc::new(RwLock::new(HashMap::new())),
}
}
pub fn load_file_fast<P: AsRef<Path>>(
&self,
path: P,
) -> Result<String, Box<dyn std::error::Error>> {
let shared_content = self.load_file_fast_shared(path)?;
Ok((*shared_content).clone())
}
pub fn load_file_fast_shared<P: AsRef<Path>>(
&self,
path: P,
) -> Result<Arc<String>, Box<dyn std::error::Error>> {
let path = path.as_ref().to_path_buf();
{
if let Ok(cache) = self.content_cache.read() {
if let Some(entry) = cache.get(&path) {
if entry.is_valid_for(&path) {
return Ok(Arc::clone(&entry.content));
}
}
}
}
self.load_and_cache_file_shared(path)
}
fn load_and_cache_file_shared(
&self,
path: PathBuf,
) -> Result<Arc<String>, Box<dyn std::error::Error>> {
let raw_bytes = fs::read(&path)
.map_err(|e| format!("Failed to read file {}: {}", path.display(), e))?;
let (content, stats) = crate::logic::utf8::sanitize_input_with_stats(
&raw_bytes,
crate::logic::utf8::InputSource::File,
);
if stats.had_issues() {
log::warn!(
"File '{}' had UTF-8 issues: {}",
path.display(),
stats.summary()
);
}
let metadata = fs::metadata(&path)
.map_err(|e| format!("Failed to get metadata for {}: {}", path.display(), e))?;
let modification_time = metadata
.modified()
.map_err(|e| format!("Failed to get modification time: {}", e))?
.duration_since(UNIX_EPOCH)
.map_err(|e| format!("Invalid system time: {}", e))?
.as_secs();
let cached_file = CachedFile::new(content, modification_time);
let shared_content = Arc::clone(&cached_file.content);
if let Ok(mut cache) = self.content_cache.write() {
cache.insert(path, cached_file);
}
Ok(shared_content)
}
pub fn invalidate_file<P: AsRef<Path>>(&self, path: P) {
let path = path.as_ref();
if let Ok(mut cache) = self.content_cache.write() {
cache.remove(path);
}
}
pub fn clear(&self) {
log::info!("Clearing file cache");
let mut cleared_files = 0;
if let Ok(mut cache) = self.content_cache.write() {
cleared_files = cache.len();
cache.clear();
}
log::info!("File cache cleared: {} file entries", cleared_files);
}
}
const AST_CACHE_MAX_CAPACITY: u64 = 1000; const HTML_CACHE_MAX_CAPACITY: u64 = 2000;
static GLOBAL_PARSER_CACHE: OnceLock<ParserCache> = OnceLock::new();
#[derive(Clone)]
pub struct ParserCache {
ast_cache: Cache<u64, Document>,
html_cache: Cache<(u64, u64), String>,
}
impl ParserCache {
pub fn new() -> Self {
Self {
ast_cache: Cache::new(AST_CACHE_MAX_CAPACITY),
html_cache: Cache::new(HTML_CACHE_MAX_CAPACITY),
}
}
pub fn parse_with_cache(&self, content: &str) -> Result<Document, Box<dyn std::error::Error>> {
let content_hash = hash_content(content);
if let Some(doc) = self.ast_cache.get(&content_hash) {
return Ok(doc);
}
let doc = parse(content)?;
self.ast_cache.insert(content_hash, doc.clone());
Ok(doc)
}
pub fn render_with_cache(
&self,
content: &str,
options: RenderOptions,
) -> Result<String, Box<dyn std::error::Error>> {
let content_hash = hash_content(content);
let options_hash = hash_options(&options);
let cache_key = (content_hash, options_hash);
if let Some(html) = self.html_cache.get(&cache_key) {
return Ok(html);
}
let doc = self.parse_with_cache(content)?;
let html = render(&doc, &options)?;
self.html_cache.insert(cache_key, html.clone());
Ok(html)
}
pub fn clear(&self) {
self.ast_cache.invalidate_all();
self.html_cache.invalidate_all();
}
pub fn stats(&self) -> CacheStats {
CacheStats {
ast_entries: self.ast_cache.entry_count(),
html_entries: self.html_cache.entry_count(),
ast_capacity: AST_CACHE_MAX_CAPACITY,
html_capacity: HTML_CACHE_MAX_CAPACITY,
}
}
}
impl Default for ParserCache {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Copy)]
pub struct CacheStats {
pub ast_entries: u64,
pub html_entries: u64,
pub ast_capacity: u64,
pub html_capacity: u64,
}
pub fn global_parser_cache() -> &'static ParserCache {
GLOBAL_PARSER_CACHE.get_or_init(ParserCache::new)
}
pub fn shutdown_global_parser_cache() {
if let Some(cache) = GLOBAL_PARSER_CACHE.get() {
cache.clear();
}
}
fn hash_content(content: &str) -> u64 {
use std::collections::hash_map::DefaultHasher;
let mut hasher = DefaultHasher::new();
content.hash(&mut hasher);
hasher.finish()
}
fn hash_options(options: &RenderOptions) -> u64 {
use std::collections::hash_map::DefaultHasher;
let mut hasher = DefaultHasher::new();
options.syntax_highlighting.hash(&mut hasher);
options.line_numbers.hash(&mut hasher);
options.theme.hash(&mut hasher);
hasher.finish()
}
pub fn parse_to_html(
content: &str,
options: RenderOptions,
) -> Result<String, Box<dyn std::error::Error>> {
let doc = parse(content)?;
render(&doc, &options)
}
pub fn parse_to_html_cached(
content: &str,
options: RenderOptions,
) -> Result<String, Box<dyn std::error::Error>> {
global_parser_cache().render_with_cache(content, options)
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use std::io::Write;
use tempfile::{tempdir, NamedTempFile};
#[test]
fn smoke_test_file_cache() {
let cache = SimpleFileCache::new();
let mut temp_file = NamedTempFile::new().expect("Failed to create temp file");
writeln!(temp_file, "Test content for file cache").expect("Failed to write temp file");
let temp_path = temp_file.path();
let content1 = cache
.load_file_fast(temp_path)
.expect("Failed to load file");
assert!(content1.contains("Test content for file cache"));
let content2 = cache
.load_file_fast(temp_path)
.expect("Failed to load file");
assert_eq!(content1, content2);
}
#[test]
fn smoke_test_file_cache_cleanup() {
let cache = SimpleFileCache::new();
let temp_dir = tempdir().expect("Failed to create temp dir");
let file_path = temp_dir.path().join("test_file.txt");
std::fs::write(&file_path, "Content for cleanup test").expect("Failed to write test file");
let _content = cache
.load_file_fast(&file_path)
.expect("Failed to load file");
cache.clear();
let content_after_clear = cache
.load_file_fast(&file_path)
.expect("Cache should work after clear");
assert!(content_after_clear.contains("Content for cleanup test"));
}
#[test]
#[serial(file_cache)]
fn smoke_test_global_cache_cleanup() {
let cache = global_cache();
let temp_dir = tempdir().expect("Failed to create temp dir");
let file_path = temp_dir.path().join("global_test.txt");
std::fs::write(&file_path, "Global cache test content").expect("Failed to write test file");
let _content = cache
.load_file_fast(&file_path)
.expect("Failed to load file");
shutdown_global_cache();
let content_after_shutdown = cache
.load_file_fast(&file_path)
.expect("Global cache should work after shutdown");
assert!(content_after_shutdown.contains("Global cache test content"));
}
#[test]
fn smoke_test_parser_cache() {
let cache = ParserCache::new();
let content = "# Hello World\n\nThis is **bold** text.";
let doc1 = cache.parse_with_cache(content).expect("Parse failed");
assert!(format!("{:?}", doc1).contains("Heading"));
cache.ast_cache.run_pending_tasks();
let doc2 = cache.parse_with_cache(content).expect("Parse failed");
assert!(format!("{:?}", doc2).contains("Heading"));
let stats = cache.stats();
assert_eq!(stats.ast_entries, 1); }
#[test]
fn smoke_test_render_cache() {
let cache = ParserCache::new();
let content = "# Test\n\nSome content.";
let options = RenderOptions::default();
let html1 = cache
.render_with_cache(content, options.clone())
.expect("Render failed");
assert!(html1.contains("<h1"));
cache.ast_cache.run_pending_tasks();
cache.html_cache.run_pending_tasks();
let html2 = cache
.render_with_cache(content, options)
.expect("Render failed");
assert_eq!(html1, html2);
let stats = cache.stats();
assert_eq!(stats.ast_entries, 1);
assert_eq!(stats.html_entries, 1);
}
#[test]
fn smoke_test_global_parser_cache() {
let content = "## Global Cache Test";
let cache1 = global_parser_cache();
let cache2 = global_parser_cache();
assert_eq!(cache1 as *const _, cache2 as *const _);
let doc = cache1.parse_with_cache(content).expect("Parse failed");
assert!(format!("{:?}", doc).contains("Heading"));
}
#[test]
fn smoke_test_convenience_functions() {
let content = "Test content with **emphasis**.";
let options = RenderOptions::default();
let html1 = parse_to_html(content, options.clone()).expect("Parse failed");
assert!(html1.contains("<strong>"));
let html2 = parse_to_html_cached(content, options).expect("Parse failed");
assert_eq!(html1, html2);
}
}
static GLOBAL_CACHE: OnceLock<SimpleFileCache> = OnceLock::new();
pub fn global_cache() -> &'static SimpleFileCache {
GLOBAL_CACHE.get_or_init(SimpleFileCache::new)
}
pub fn shutdown_global_cache() {
if let Some(cache) = GLOBAL_CACHE.get() {
cache.clear();
} else {
log::info!("File cache was never initialized, no cleanup needed");
}
}
pub mod cached {
use super::*;
pub fn read_to_string<P: AsRef<Path>>(path: P) -> Result<String, Box<dyn std::error::Error>> {
global_cache().load_file_fast(path)
}
}