use crate::error::{LoaderError, Result};
use crate::lock;
use crate::registry::{PluginRegistry, WithInject};
use cordis::{Config, Context, EffectHandle, EventOptions, Fiber, FiberState, PluginHandle, Value};
use cordis_include::{Entry, EntryOptions, EntryTree, LoaderFile, Node, PluginResolver, TreeDiff};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, Weak};
use std::time::Duration;
#[derive(Clone, Default)]
pub struct LoaderConfig {
pub filename: PathBuf,
pub initial: Option<cordis_include::Document>,
pub registry: Option<PluginRegistry>,
pub write_debounce: Option<Duration>,
}
impl LoaderConfig {
pub fn new(filename: impl Into<PathBuf>) -> Self {
Self {
filename: filename.into(),
initial: None,
registry: None,
write_debounce: None,
}
}
pub fn with_initial(mut self, initial: cordis_include::Document) -> Self {
self.initial = Some(initial);
self
}
pub fn with_registry(mut self, registry: PluginRegistry) -> Self {
self.registry = Some(registry);
self
}
pub fn with_write_debounce(mut self, delay: Duration) -> Self {
self.write_debounce = Some(delay);
self
}
}
struct LoaderState {
entries: HashMap<u64, Entry>,
operating: u16,
last_error: Option<String>,
_keep_alive: Vec<EffectHandle>,
}
#[derive(Clone)]
pub struct Loader {
pub(crate) inner: Arc<LoaderInner>,
}
pub(crate) struct LoaderInner {
root: Context,
file: LoaderFile,
tree: EntryTree,
registry: Mutex<PluginRegistry>,
state: Mutex<LoaderState>,
imports: Mutex<HashMap<PathBuf, LoaderFile>>,
#[cfg(feature = "watch")]
watched: Mutex<HashSet<PathBuf>>,
#[cfg(feature = "watch")]
watchers: Mutex<Vec<cordis_include::FileWatcher>>,
write_debounce: Mutex<Option<Duration>>,
}
pub struct LoaderHandle {
inner: Weak<LoaderInner>,
}
impl LoaderHandle {
pub fn upgrade(&self) -> Option<Loader> {
self.inner.upgrade().map(|inner| Loader { inner })
}
}
impl Loader {
pub fn open(root: &Context, config: LoaderConfig) -> Result<Loader> {
let file = LoaderFile::open(&config.filename)?;
if !file.path().exists() {
if let Some(initial) = &config.initial {
file.write(initial)?;
}
}
let inner = Arc::new(LoaderInner {
root: root.clone(),
file,
tree: EntryTree::new(),
registry: Mutex::new(config.registry.unwrap_or_default()),
state: Mutex::new(LoaderState {
entries: HashMap::new(),
operating: 0,
last_error: None,
_keep_alive: Vec::new(),
}),
imports: Mutex::new(HashMap::new()),
#[cfg(feature = "watch")]
watched: Mutex::new(HashSet::new()),
#[cfg(feature = "watch")]
watchers: Mutex::new(Vec::new()),
write_debounce: Mutex::new(config.write_debounce),
});
let mut errors = Vec::new();
let (composed, _dirty) = compose(
&inner.file,
&mut lock(&inner.imports),
&mut HashSet::new(),
&mut errors,
);
for error in errors {
lock(&inner.state).last_error = Some(error);
}
inner.tree.update(composed)?;
let weak = Arc::downgrade(&inner);
let status = root.events().on(
"internal/status",
move |event| {
if let Some(inner) = weak.upgrade() {
handle_status(&inner, &event)?;
}
Ok(None)
},
EventOptions {
global: true,
..EventOptions::default()
},
)?;
let service = root.provide_arc(
"loader",
Arc::new(LoaderHandle {
inner: Arc::downgrade(&inner),
}),
)?;
lock(&inner.state)._keep_alive = vec![status, service];
let loader = Loader { inner };
loader.start_all();
Ok(loader)
}
pub fn context(&self) -> &Context {
&self.inner.root
}
pub fn tree(&self) -> &EntryTree {
&self.inner.tree
}
pub fn file(&self) -> &LoaderFile {
&self.inner.file
}
pub fn registry(&self) -> PluginRegistry {
lock(&self.inner.registry).clone()
}
pub fn register_plugin<P: cordis::Plugin>(&self, plugin: P) {
lock(&self.inner.registry).register_plugin(plugin);
}
pub fn register<F>(&self, name: impl Into<String>, factory: F)
where
F: Fn() -> PluginHandle + Send + Sync + 'static,
{
lock(&self.inner.registry).register(name, factory);
}
pub fn last_error(&self) -> Option<String> {
lock(&self.inner.state).last_error.clone()
}
pub fn set_write_debounce(&self, delay: Option<Duration>) {
*lock(&self.inner.write_debounce) = delay;
}
pub fn locate(&self, fiber: &Fiber) -> Option<Entry> {
let state = lock(&self.inner.state);
if let Some(uid) = fiber.uid() {
return state.entries.get(&uid).cloned();
}
state
.entries
.values()
.find(|entry| entry.fiber().is_some_and(|started| started.ptr_eq(fiber)))
.cloned()
}
fn start_all(&self) {
for entry in self.inner.tree.entries() {
if let Err(error) = start_entry(&self.inner, &entry) {
self.record_error(error);
}
}
}
pub fn reload(&self) -> Result<TreeDiff> {
let inner = &self.inner;
let mut imports = HashMap::new();
let mut errors = Vec::new();
let (composed, dirty) =
compose(&inner.file, &mut imports, &mut HashSet::new(), &mut errors);
for error in errors {
self.record_error(LoaderError::Include(
cordis_include::IncludeError::Message { message: error },
));
}
let diff = inner.tree.update(composed)?;
*lock(&inner.imports) = imports;
for removed in &diff.removed {
if let Err(error) = stop_entry(inner, &removed.entry) {
self.record_error(error);
}
}
for entry in &diff.moved {
if let Err(error) = stop_entry(inner, entry) {
self.record_error(error);
}
}
for entry in &diff.updated {
if let Err(error) = patch_entry(inner, entry) {
self.record_error(error);
}
}
for entry in &diff.created {
if let Err(error) = start_entry(inner, entry) {
self.record_error(error);
}
}
if dirty {
write_back(inner)?;
}
#[cfg(feature = "watch")]
self.arm_import_watchers();
Ok(diff)
}
pub fn update_config(&self, id: &str, config: Node) -> Result<()> {
let inner = &self.inner;
let entry = inner.tree.resolve(id).ok_or_else(|| {
LoaderError::Include(cordis_include::IncludeError::EntryNotFound { id: id.to_owned() })
})?;
if let Some(fiber) = entry.fiber() {
fiber.update_value(Config::new(config.clone()))?;
}
let mut options = entry_options_with_children(&entry);
options.config = Some(config.clone());
inner
.tree
.update_entry(&entry.path(), options, None, None)?;
write_back(inner)?;
emit(
inner,
crate::events::CONFIG_UPDATE,
vec![Value::new(entry), Value::new(config)],
);
Ok(())
}
pub fn dispose(&self) -> Result<()> {
let inner = &self.inner;
for entry in inner.tree.top_level() {
if let Err(error) = stop_entry(inner, &entry) {
self.record_error(error);
}
}
Ok(())
}
#[cfg(feature = "watch")]
pub fn watch(&self) -> Result<cordis_include::FileWatcher> {
let loader = self.clone();
let watcher = self
.inner
.file
.watch(move || {
if let Err(error) = loader.reload() {
loader.record_error(error);
}
})
.map_err(LoaderError::Include)?;
let main_path = std::fs::canonicalize(self.inner.file.path())
.unwrap_or_else(|_| self.inner.file.path().to_path_buf());
lock(&self.inner.watched).insert(main_path);
self.arm_import_watchers();
Ok(watcher)
}
#[cfg(feature = "watch")]
fn arm_import_watchers(&self) {
for (path, file) in lock(&self.inner.imports).clone() {
if lock(&self.inner.watched).contains(&path) {
continue;
}
let loader = self.clone();
match file.watch(move || {
if let Err(error) = loader.reload() {
loader.record_error(error);
}
}) {
Ok(watcher) => {
lock(&self.inner.watched).insert(path);
lock(&self.inner.watchers).push(watcher);
}
Err(error) => self.record_error(LoaderError::Include(error)),
}
}
}
fn record_error(&self, error: LoaderError) {
lock(&self.inner.state).last_error = Some(error.to_string());
}
}
struct OperatingGuard<'a> {
state: &'a Mutex<LoaderState>,
}
impl<'a> OperatingGuard<'a> {
fn new(state: &'a Mutex<LoaderState>) -> Self {
lock(state).operating += 1;
Self { state }
}
}
impl Drop for OperatingGuard<'_> {
fn drop(&mut self) {
let mut state = lock(self.state);
state.operating = state.operating.saturating_sub(1);
}
}
fn emit(inner: &LoaderInner, name: &str, args: Vec<Value>) {
if let Err(error) = inner.root.events().emit(name, args) {
lock(&inner.state).last_error = Some(format!("{name} listener failed: {error}"));
}
}
fn start_entry(inner: &LoaderInner, entry: &Entry) -> Result<()> {
if !entry.enabled() || entry.fiber().is_some() {
return Ok(());
}
let name = entry.name();
let handle: PluginHandle = lock(&inner.registry)
.resolve(&name)
.map_err(LoaderError::Cordis)?;
let inject = entry.options().inject;
let handle = WithInject::wrap(handle, inject);
let config = entry.resolved_config()?.unwrap_or(Node::Null);
let parent_ctx = entry
.parent()
.and_then(|parent| parent.fiber())
.and_then(|fiber| fiber.context())
.unwrap_or_else(|| inner.root.clone());
let fiber = parent_ctx.plugin(handle, config);
entry.set_fiber(Some(fiber.clone()));
if let Some(uid) = fiber.uid() {
lock(&inner.state).entries.insert(uid, entry.clone());
}
emit(
inner,
crate::events::ENTRY_INIT,
vec![Value::new(entry.clone())],
);
Ok(())
}
fn stop_entry(inner: &LoaderInner, entry: &Entry) -> Result<()> {
for child in entry.children() {
stop_entry(inner, &child)?;
}
let Some(fiber) = entry.fiber() else {
return Ok(());
};
entry.set_fiber(None);
if let Some(uid) = fiber.uid() {
lock(&inner.state).entries.remove(&uid);
}
let _guard = OperatingGuard::new(&inner.state);
fiber.dispose().map_err(LoaderError::Cordis)
}
fn patch_entry(inner: &LoaderInner, entry: &Entry) -> Result<()> {
if !entry.enabled() {
return stop_entry(inner, entry);
}
let Some(fiber) = entry.fiber() else {
return start_entry(inner, entry);
};
let options = entry.options();
let inject_changed =
fiber.inject().names().collect::<Vec<_>>() != options.inject.iter().collect::<Vec<_>>();
if fiber.name() != options.name || inject_changed {
stop_entry(inner, entry)?;
return start_entry(inner, entry);
}
let new_config = entry.resolved_config()?.unwrap_or(Node::Null);
let current = fiber
.config()
.downcast::<Node>()
.ok()
.map(|node| (*node).clone());
if current.as_ref() != Some(&new_config) {
emit(
inner,
crate::events::BEFORE_PATCH,
vec![Value::new(entry.clone())],
);
fiber.update_value(Config::new(new_config))?;
emit(
inner,
crate::events::AFTER_PATCH,
vec![Value::new(entry.clone())],
);
}
Ok(())
}
fn entry_options_with_children(entry: &Entry) -> EntryOptions {
let mut options = entry.options();
options.group = entry
.children()
.iter()
.map(entry_options_with_children)
.collect();
options
}
fn write_back(inner: &LoaderInner) -> Result<()> {
let mut jobs: Vec<(LoaderFile, Vec<EntryOptions>)> = vec![(
inner.file.clone(),
inner
.tree
.top_level()
.iter()
.map(to_stripped_options)
.collect(),
)];
for entry in inner.tree.entries() {
if entry.options().import_url().is_some() {
if let Some(file) = lock(&inner.imports).get(&import_canonical(inner, &entry)) {
let children = entry.children().iter().map(to_stripped_options).collect();
jobs.push((file.clone(), children));
}
}
}
let debounce = *lock(&inner.write_debounce);
for (file, entries) in jobs {
let mut document = file.read()?;
document.entries = entries;
match debounce {
Some(delay) => file.write_deferred(document, delay),
None => file.write(&document)?,
}
}
Ok(())
}
fn to_stripped_options(entry: &Entry) -> EntryOptions {
fn strip(options: &mut EntryOptions) {
if options.import_url().is_some() {
options.group.clear();
return;
}
options.group.retain(|child| child.import_url().is_none());
for child in &mut options.group {
strip(child);
}
}
let mut options = entry_options_with_children(entry);
strip(&mut options);
options
}
fn import_path(base_file: &LoaderFile, url: &str) -> PathBuf {
let direct = Path::new(url);
if direct.is_absolute() {
return direct.to_path_buf();
}
match base_file.path().parent() {
Some(parent) => parent.join(url),
None => direct.to_path_buf(),
}
}
fn import_canonical(inner: &LoaderInner, entry: &Entry) -> PathBuf {
let url = entry.options().import_url().unwrap_or_default().to_owned();
let path = import_path(&inner.file, &url);
std::fs::canonicalize(&path).unwrap_or(path)
}
fn compose(
file: &LoaderFile,
imports: &mut HashMap<PathBuf, LoaderFile>,
active: &mut HashSet<PathBuf>,
errors: &mut Vec<String>,
) -> (Vec<EntryOptions>, bool) {
let document = match file.read() {
Ok(document) => document,
Err(error) => {
errors.push(format!("cannot read {}: {error}", file.path().display()));
return (Vec::new(), false);
}
};
let mut entries = Vec::with_capacity(document.entries.len());
let mut dirty = document.entries.iter().any(|options| options.id.is_none());
for mut options in document.entries {
if let Some(url) = options.import_url().map(str::to_owned) {
let path = import_path(file, &url);
let canonical = std::fs::canonicalize(&path).unwrap_or_else(|_| path.clone());
if !active.insert(canonical.clone()) {
errors.push(format!("import cycle detected at {}", path.display()));
continue;
}
match LoaderFile::open(&path) {
Ok(sub_file) => {
let (sub_entries, sub_dirty) = compose(&sub_file, imports, active, errors);
dirty |= sub_dirty;
options.group = sub_entries;
imports.insert(canonical, sub_file);
}
Err(error) => errors.push(format!(
"cannot open import {} ({}: {error})",
path.display(),
file.path().display()
)),
}
}
entries.push(options);
}
(entries, dirty)
}
fn handle_status(inner: &LoaderInner, event: &cordis::Event) -> cordis::EventResult {
let Some(fiber) = event.arg::<Fiber>(0).ok().flatten() else {
return Ok(None);
};
if fiber.state() != FiberState::Disposed {
return Ok(None);
}
if lock(&inner.state).operating > 0 {
return Ok(None);
}
let Some(entry) = lock(&inner.state)
.entries
.values()
.find(|entry| entry.fiber().is_some_and(|started| started.ptr_eq(&fiber)))
.cloned()
else {
return Ok(None);
};
if let Err(error) = persist_self_dispose(inner, &entry) {
lock(&inner.state).last_error = Some(error.to_string());
}
Ok(None)
}
fn persist_self_dispose(inner: &LoaderInner, entry: &Entry) -> Result<()> {
{
let mut state = lock(&inner.state);
let key = state
.entries
.iter()
.find(|(_, mapped)| Entry::ptr_eq(mapped, entry))
.map(|(uid, _)| *uid);
if let Some(uid) = key {
state.entries.remove(&uid);
}
}
entry.set_fiber(None);
let mut options = entry_options_with_children(entry);
options.disabled = true;
inner
.tree
.update_entry(&entry.path(), options, None, None)?;
write_back(inner)?;
emit(
inner,
crate::events::PARTIAL_DISPOSE,
vec![Value::new(entry.clone())],
);
Ok(())
}