use std::path::{Path, PathBuf};
use fallow_config::{ScopedUsedClassMemberRule, UsedClassMemberRule};
use serde_json::Value;
use super::{Plugin, manifest::has_matching_manifest_json};
const ENABLERS: &[&str] = &["obsidian"];
const ENTRY_PATTERNS: &[&str] = &["src/main.{ts,js}", "main.{ts,js}", "cdp.js"];
const CONFIG_PATTERNS: &[&str] = &["manifest.json"];
const ALWAYS_USED: &[&str] = &["manifest.json", "styles.css"];
const PLUGIN_MEMBERS: &[&str] = &["onload", "onunload"];
const MODAL_MEMBERS: &[&str] = &["onOpen", "onClose"];
const VIEW_MEMBERS: &[&str] = &[
"getViewType",
"getDisplayText",
"getIcon",
"onOpen",
"onClose",
"onPaneMenu",
];
pub struct ObsidianPlugin;
impl Plugin for ObsidianPlugin {
fn name(&self) -> &'static str {
"obsidian"
}
fn enablers(&self) -> &'static [&'static str] {
ENABLERS
}
fn is_enabled_with_files(
&self,
deps: &[String],
root: &Path,
discovered_files: &[PathBuf],
candidate_index: Option<&super::registry::ConfigCandidateIndex>,
) -> bool {
if self.is_enabled_with_deps(deps, root) {
return true;
}
has_matching_manifest_json(
root,
discovered_files,
candidate_index,
is_obsidian_manifest,
)
}
fn entry_patterns(&self) -> &'static [&'static str] {
ENTRY_PATTERNS
}
fn config_patterns(&self) -> &'static [&'static str] {
CONFIG_PATTERNS
}
fn always_used(&self) -> &'static [&'static str] {
ALWAYS_USED
}
fn used_class_member_rules(&self) -> Vec<UsedClassMemberRule> {
vec![
scoped_rule("Plugin", PLUGIN_MEMBERS),
scoped_rule("Modal", MODAL_MEMBERS),
scoped_rule("ItemView", VIEW_MEMBERS),
scoped_rule("View", VIEW_MEMBERS),
]
}
}
fn scoped_rule(extends: &str, members: &[&str]) -> UsedClassMemberRule {
UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
extends: Some(extends.to_string()),
implements: None,
members: members.iter().map(|member| (*member).to_string()).collect(),
})
}
fn is_obsidian_manifest(manifest: &Value) -> bool {
let Some(object) = manifest.as_object() else {
return false;
};
if object.contains_key("manifest_version") {
return false;
}
["id", "name", "version", "minAppVersion"]
.iter()
.all(|key| object.get(*key).and_then(Value::as_str).is_some())
}
#[cfg(test)]
mod tests {
use fallow_config::EntryPointRole;
use super::*;
fn rule_for<'a>(
rules: &'a [UsedClassMemberRule],
extends: &str,
) -> &'a ScopedUsedClassMemberRule {
rules
.iter()
.find_map(|rule| match rule {
UsedClassMemberRule::Scoped(scoped)
if scoped.extends.as_deref() == Some(extends) =>
{
Some(scoped)
}
_ => None,
})
.unwrap_or_else(|| panic!("{extends}-scoped rule missing"))
}
#[test]
fn exposes_static_patterns_and_runtime_role() {
let plugin = ObsidianPlugin;
assert_eq!(plugin.enablers(), ENABLERS);
assert_eq!(plugin.entry_patterns(), ENTRY_PATTERNS);
assert_eq!(plugin.config_patterns(), CONFIG_PATTERNS);
assert_eq!(plugin.always_used(), ALWAYS_USED);
assert_eq!(plugin.entry_point_role(), EntryPointRole::Runtime);
}
#[test]
fn lifecycle_rules_are_scoped_to_obsidian_base_classes() {
let rules = ObsidianPlugin.used_class_member_rules();
for member in ["onload", "onunload"] {
assert!(
rule_for(&rules, "Plugin")
.members
.iter()
.any(|m| m == member)
);
}
for member in ["onOpen", "onClose"] {
assert!(
rule_for(&rules, "Modal")
.members
.iter()
.any(|m| m == member)
);
}
for base in ["ItemView", "View"] {
for member in [
"getViewType",
"getDisplayText",
"getIcon",
"onOpen",
"onClose",
] {
assert!(rule_for(&rules, base).members.iter().any(|m| m == member));
}
}
}
#[test]
fn lifecycle_rules_match_only_direct_base_names() {
let rules = ObsidianPlugin.used_class_member_rules();
let plugin_rule = rule_for(&rules, "Plugin");
assert!(plugin_rule.matches_heritage(Some("Plugin"), &[]));
assert!(!plugin_rule.matches_heritage(Some("ObsidianPlugin"), &[]));
assert!(!plugin_rule.matches_heritage(Some("LocalPluginBase"), &[]));
assert!(!plugin_rule.matches_heritage(None, &[]));
}
#[test]
fn activates_from_obsidian_manifest_without_dependency() {
let plugin = ObsidianPlugin;
let tmp = tempfile::tempdir().expect("temp dir");
std::fs::write(
tmp.path().join("manifest.json"),
r#"{"id":"work-terminal","name":"Work Terminal","version":"1.0.0","minAppVersion":"1.5.0"}"#,
)
.expect("manifest");
assert!(plugin.is_enabled_with_files(
&[],
tmp.path(),
&[tmp.path().join("src/main.ts")],
None
));
}
#[test]
fn index_activation_matches_filesystem_when_manifest_is_captured() {
let plugin = ObsidianPlugin;
let tmp = tempfile::tempdir().expect("temp dir");
let manifest = tmp.path().join("manifest.json");
std::fs::write(
&manifest,
r#"{"id":"work-terminal","name":"Work Terminal","version":"1.0.0","minAppVersion":"1.5.0"}"#,
)
.expect("manifest");
let discovered = [tmp.path().join("src/main.ts")];
let index_with = crate::plugins::registry::ConfigCandidateIndex::build(std::iter::once(
manifest.as_path(),
));
let index_without =
crate::plugins::registry::ConfigCandidateIndex::build(std::iter::empty());
assert!(plugin.is_enabled_with_files(&[], tmp.path(), &discovered, None));
assert!(plugin.is_enabled_with_files(&[], tmp.path(), &discovered, Some(&index_with)));
assert!(!plugin.is_enabled_with_files(&[], tmp.path(), &discovered, Some(&index_without)));
}
#[test]
fn rejects_browser_extension_pwa_and_generic_manifests() {
let browser_extension = serde_json::json!({
"manifest_version": 3,
"name": "Extension",
"version": "1.0.0",
"background": { "service_worker": "background.js" }
});
let pwa = serde_json::json!({
"name": "PWA",
"start_url": "/",
"display": "standalone",
"icons": []
});
let generic_package_style = serde_json::json!({
"name": "app",
"version": "1.0.0",
"description": "not an Obsidian plugin"
});
assert!(!is_obsidian_manifest(&browser_extension));
assert!(!is_obsidian_manifest(&pwa));
assert!(!is_obsidian_manifest(&generic_package_style));
}
}