pub(crate) mod query;
pub(crate) mod store;
use crate::hash::DftHashMap;
use crate::options::DiffOptions;
use crate::parse::{guess_language::Language, tree_sitter_parser};
use crate::plugin::config::PluginsConfig;
use crate::plugin::queries::{self, Queries};
use query::AnnotationQuery;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::{Arc, OnceLock};
use strum::IntoEnumIterator;
pub(crate) const DEFAULT_CONFIG: &str = include_str!("config/default.toml");
const CONFIG_VERSION: u32 = 1;
fn config_version() -> u32 {
CONFIG_VERSION
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct Config {
#[serde(default = "config_version")]
#[schemars(extend("x-settings" = false))]
pub(crate) version: u32,
#[schemars(skip)]
#[serde(default)]
pub(crate) plugins: PluginsConfig,
#[serde(default)]
pub(crate) theme: ThemeConfig,
#[serde(default)]
pub(crate) diff: DiffConfig,
}
impl Default for Config {
fn default() -> Self {
Self::from_toml_in(DEFAULT_CONFIG, Path::new(""))
.expect("the embedded default config is valid")
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct DiffConfig {
#[schemars(title = "Largest file to diff structurally (bytes)", extend("x-group" = "Diff limits"))]
pub(crate) byte_limit: usize,
#[schemars(title = "Largest matching graph", extend("x-group" = "Diff limits"))]
pub(crate) graph_limit: usize,
#[schemars(title = "Parse errors allowed", extend("x-group" = "Diff limits"))]
pub(crate) parse_error_limit: usize,
}
impl Default for DiffConfig {
fn default() -> Self {
Self {
byte_limit: crate::options::DEFAULT_BYTE_LIMIT,
graph_limit: crate::options::DEFAULT_GRAPH_LIMIT,
parse_error_limit: crate::options::DEFAULT_PARSE_ERROR_LIMIT,
}
}
}
impl DiffConfig {
pub(crate) fn options(&self, ignore_comments: bool) -> DiffOptions {
DiffOptions {
byte_limit: self.byte_limit,
graph_limit: self.graph_limit,
parse_error_limit: self.parse_error_limit,
ignore_comments,
..DiffOptions::default()
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct ThemeConfig {
#[schemars(title = "Theme", extend("x-group" = "Appearance"))]
pub(crate) name: String,
#[schemars(title = "Theme file", extend("x-group" = "Appearance"))]
pub(crate) path: Option<PathBuf>,
}
impl Default for ThemeConfig {
fn default() -> Self {
Self {
name: "default-dark".to_owned(),
path: None,
}
}
}
#[derive(Debug)]
pub(crate) struct ConfigError(pub(crate) String);
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for ConfigError {}
pub(crate) struct Params {
languages: DftHashMap<Language, OnceLock<Arc<LanguageParams>>>,
pub(crate) diff: DiffConfig,
}
pub(crate) struct LanguageParams {
pub(crate) parser: &'static tree_sitter_parser::TreeSitterConfig,
pub(crate) query: AnnotationQuery,
sub_languages: OnceLock<
Vec<(
&'static tree_sitter_parser::TreeSitterSubLanguage,
Arc<LanguageParams>,
)>,
>,
}
impl LanguageParams {
pub(crate) fn sub_languages(
&self,
) -> &[(
&'static tree_sitter_parser::TreeSitterSubLanguage,
Arc<LanguageParams>,
)] {
self.sub_languages.get().expect("resolved sub-languages")
}
}
pub(crate) fn global_path() -> Result<PathBuf, ConfigError> {
let dir = match std::env::var_os("XDG_CONFIG_HOME") {
Some(dir) if !dir.is_empty() => PathBuf::from(dir),
_ => dirs::home_dir()
.ok_or_else(|| ConfigError("no home directory for this user".into()))?
.join(".config"),
};
Ok(dir.join("diffr").join("config.toml"))
}
pub(crate) fn directory_of(file: &Path) -> &Path {
file.parent().unwrap_or(Path::new(""))
}
impl Config {
pub(crate) fn load(explicit: Option<&Path>) -> Result<Self, ConfigError> {
let path = match explicit {
Some(path) => path.to_path_buf(),
None => global_path()?,
};
let source = match std::fs::read_to_string(&path) {
Ok(source) => source,
Err(error) if explicit.is_none() && error.kind() == std::io::ErrorKind::NotFound => {
return Ok(Self::default());
}
Err(error) => return Err(ConfigError(format!("{}: {error}", path.display()))),
};
Self::from_toml_in(&source, directory_of(&path))
.map_err(|error| ConfigError(format!("{}: {error}", path.display())))
}
#[cfg(test)]
pub(crate) fn from_toml(source: &str) -> Result<Self, ConfigError> {
Self::from_toml_in(source, Path::new(""))
}
pub(crate) fn from_toml_in(source: &str, directory: &Path) -> Result<Self, ConfigError> {
let mut config: Self = serde_path_to_error::deserialize(toml::Deserializer::new(source))
.map_err(|error| {
let path = error.path().to_string();
let message = error.inner().to_string();
ConfigError(match path.as_str() {
"." => message,
_ => format!("{path}: {message}"),
})
})?;
if config.version != CONFIG_VERSION {
return Err(ConfigError(format!(
"unsupported config version {}; expected {CONFIG_VERSION}",
config.version
)));
}
config.plugins.resolve(directory)?;
Ok(config)
}
pub(crate) fn schema() -> serde_json::Value {
let mut schema =
serde_json::to_value(schemars::schema_for!(Config)).expect("schema serializes");
let rest = std::mem::take(
schema["properties"]
.as_object_mut()
.expect("the schema has properties"),
);
let properties = schema["properties"]
.as_object_mut()
.expect("the schema has properties");
properties.insert("plugins".to_owned(), PluginsConfig::schema());
properties.extend(rest);
schema
}
pub(crate) fn compile_with(
self,
pipeline: &crate::plugin::Pipeline,
) -> Result<Params, ConfigError> {
let queries = pipeline
.queries()
.map_err(|error| ConfigError(format!("{error:#}")))?;
self.compile_queries(queries)
}
#[cfg(test)]
pub(crate) fn compile(self) -> Result<Params, ConfigError> {
let pipeline = crate::plugin::Pipeline::from_config(&self.plugins, Path::new("."))
.map_err(|error| ConfigError(format!("{error:#}")))?;
self.compile_with(&pipeline)
}
pub(crate) fn compile_queries(
self,
queries: Vec<(String, Queries)>,
) -> Result<Params, ConfigError> {
let mut languages: DftHashMap<_, _> = Language::iter()
.map(|language| (language, OnceLock::new()))
.collect();
for (name, sources) in queries::assemble(&queries)? {
let language = Language::iter()
.find(|language| format!("{language:?}").to_lowercase() == name)
.ok_or_else(|| ConfigError(format!("unknown language: {name}")))?;
let parser = tree_sitter_parser::from_language(language);
let query = AnnotationQuery::compile(&parser.language, &sources)?;
check_tags(&query, &self.plugins.order)?;
languages.insert(
language,
OnceLock::from(Arc::new(LanguageParams {
parser,
query,
sub_languages: OnceLock::new(),
})),
);
}
Ok(Params {
languages,
diff: self.diff,
})
}
}
fn check_tags(query: &AnnotationQuery, order: &[String]) -> Result<(), ConfigError> {
for pattern in &query.patterns {
for tag in &pattern.tags {
let owned = tag.split_once(':').is_some_and(|(plugin, name)| {
!name.is_empty()
&& (crate::plugin::builtin::manifest(plugin).is_some()
|| order.iter().any(|own| {
own.split_once('.').map_or(own.as_str(), |(_, name)| name) == plugin
}))
});
if !owned {
return Err(ConfigError(format!(
"{}: tag {tag:?} must be written <plugin>:<name> with a bundled or configured plugin",
query.sources[pattern.source]
)));
}
}
}
Ok(())
}
impl Params {
pub(crate) fn language(&self, language: Language) -> &Arc<LanguageParams> {
let config = self.languages[&language].get_or_init(|| {
let parser = tree_sitter_parser::from_language(language);
Arc::new(LanguageParams {
parser,
query: AnnotationQuery::compile(&parser.language, &[]).expect("an empty query"),
sub_languages: OnceLock::new(),
})
});
config.sub_languages.get_or_init(|| {
config
.parser
.sub_languages
.iter()
.map(|sub| (sub, Arc::clone(self.language(sub.parse_as))))
.collect()
});
config
}
}
impl Default for Params {
fn default() -> Self {
let config = Config::default();
let pipeline = crate::plugin::Pipeline::from_config(&config.plugins, Path::new("."))
.expect("invalid bundled plugin configuration");
config
.compile_with(&pipeline)
.expect("invalid bundled annotation configuration")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::summary::DiffResult;
#[test]
fn diff_limits_default_and_layer_from_the_file() {
let defaults = Config::default().diff;
assert_eq!(defaults.graph_limit, crate::options::DEFAULT_GRAPH_LIMIT);
assert_eq!(defaults.byte_limit, crate::options::DEFAULT_BYTE_LIMIT);
let custom = Config::from_toml("[diff]\ngraph_limit = 5").unwrap();
assert_eq!(custom.diff.graph_limit, 5);
assert_eq!(custom.diff.byte_limit, defaults.byte_limit);
let options = custom.diff.options(true);
assert_eq!(options.graph_limit, 5);
assert!(options.ignore_comments);
let compiled = custom.compile().unwrap();
assert_eq!(compiled.diff.graph_limit, 5);
let schema = Config::schema();
assert!(
schema["$defs"]["DiffConfig"]["properties"]["graph_limit"]["description"]
.as_str()
.is_some_and(|text| !text.is_empty())
);
}
#[test]
fn language_without_annotation_rules_keeps_structural_diffing() {
let params = Params::default();
let result = DiffResult::from_sources_with_params(
"a.c",
"int run() { return 1; }",
"int run() { return 2; }",
¶ms,
);
assert!(matches!(
result.file_format,
crate::summary::FileFormat::SupportedLanguage(Language::C)
));
assert!(result
.rhs_positions
.iter()
.any(|position| position.kind.is_novel()));
assert!(result.rhs_folds.is_empty());
}
#[test]
fn shared_grammars_keep_language_configuration_independent() {
let params = with_queries(&[
(
"javascript",
r#"((statement_block) @fold (#set! tag "removed-runs:plain-js"))"#,
),
(
"javascriptjsx",
r#"((statement_block) @fold (#set! tag "removed-runs:jsx"))"#,
),
]);
for (path, tag) in [
("file.js", "removed-runs:plain-js"),
("file.jsx", "removed-runs:jsx"),
] {
let result = DiffResult::from_sources_with_params(
path,
"",
"function run() { work(); }",
¶ms,
);
assert_eq!(result.rhs_folds.len(), 1);
assert_eq!(result.rhs_folds[0].tags, [tag]);
}
}
#[test]
fn embedded_languages_use_the_configured_queries() {
let params = with_queries(&[(
"javascript",
r#"((statement_block) @fold (#set! tag "removed-runs:embedded"))"#,
)]);
let result = DiffResult::from_sources_with_params(
"page.html",
"",
"<script>function run() { work(); }</script>",
¶ms,
);
assert_eq!(result.rhs_folds.len(), 1);
assert_eq!(result.rhs_folds[0].tags, ["removed-runs:embedded"]);
}
#[test]
fn rejects_unknown_settings_languages_and_invalid_queries() {
assert!(Config::from_toml("typo = true").is_err());
assert!(Config::from_toml("[languages.rust]\ncontext = '(block) @context'").is_err());
let error = Config::default()
.compile_queries(vec![(
"removed-runs".to_owned(),
vec![diffr_plugin_sdk::QuerySource {
language: "klingon".into(),
name: "unknown.scm".into(),
text: "".into(),
}],
)])
.err()
.expect("an unknown language")
.to_string();
assert!(error.contains("unknown language: klingon"), "{error}");
for (query, message) in [
("(not_a_rust_node) @fold", "NodeType error"),
("(block) @typo", "unsupported capture @typo"),
(
r#"((block) @fold (#set! tag "body"))"#,
"must be written <plugin>:<name>",
),
(
r#"((block) @fold (#set! tag "nobody:body"))"#,
"must be written <plugin>:<name>",
),
] {
let error = try_with_queries(&[("rust", query)])
.err()
.expect(query)
.to_string();
assert!(error.contains("removed-runs.scm"), "{error}");
assert!(error.contains(message), "{error}");
}
}
}
#[cfg(test)]
pub(crate) fn try_with_queries(queries: &[(&str, &str)]) -> Result<Params, ConfigError> {
let files = queries
.iter()
.map(|(language, text)| diffr_plugin_sdk::QuerySource {
language: (*language).into(),
name: format!("{language}-removed-runs.scm"),
text: (*text).into(),
})
.collect();
Config::default().compile_queries(vec![("removed-runs".to_owned(), files)])
}
#[cfg(test)]
fn with_queries(queries: &[(&str, &str)]) -> Params {
try_with_queries(queries).expect("the test queries compile")
}
#[cfg(test)]
pub(crate) fn body_params() -> Params {
Config::from_toml("[plugins.bundled.context]\nenabled = false\n[plugins.bundled.summarize]\nenabled = true\napi_key = 'test'\n")
.expect("a valid configuration")
.compile()
.expect("the bundled queries compile")
}
#[cfg(test)]
mod query_tests {
use super::*;
use crate::summary::DiffResult;
#[test]
fn arbitrary_tags_and_delimiter_captures_reach_the_domain() {
let params = with_queries(&[(
"rust",
r#"((block "{" @fold.open "}" @fold.close) @fold (#set! tag "removed-runs:user.validation"))"#,
)]);
let source = "fn f() { println!(\"☕\"); }";
let diff = DiffResult::from_sources_with_params("a.rs", "", source, ¶ms);
let fold = &diff.rhs_folds[0];
assert_eq!(fold.tags, ["removed-runs:user.validation"]);
assert_eq!(
&source[fold.range.start.byte_column..fold.range.end.byte_column],
" println!(\"☕\"); "
);
}
#[test]
fn rejects_unsupported_or_malformed_directives_at_compile_time() {
for query in [
"((block) @fold (#offset! @fold 0 1 0 -1))",
"((block) @fold (#unknown! @fold))",
"((block) @fold (#make-range! \"fold\" @fold @fold))",
"((block) @fold (#set! typo value))",
"((block) @fold (#set! tag))",
] {
let error = match try_with_queries(&[("rust", query)]) {
Ok(_) => panic!("accepted {query}"),
Err(error) => error.to_string(),
};
assert!(error.contains("rust-removed-runs.scm: "), "{error}");
}
}
#[test]
fn rust_labeled_blocks_fold_between_actual_braces() {
let params = Params::default();
for source in [
"fn f() { 'outer: { work(); } }",
"fn f() { 'outer: /* prefix */ { work(); } }",
"fn f() { { work(); } }",
] {
let result = DiffResult::from_sources_with_params("a.rs", "", source, ¶ms);
let start = source.find("{ work(); }").unwrap() + 1;
let end = start + " work(); ".len();
assert!(
result.rhs_folds.iter().any(|fold| {
fold.range.start.line.as_usize() == 0
&& fold.range.start.byte_column == start
&& fold.range.end.byte_column == end
}),
"missing inner body in {source}"
);
}
}
#[test]
fn unicode_string_fold_uses_the_complete_node_range() {
let params = with_queries(&[("rust", "(string_literal) @fold")]);
let source = "fn f() { let x = \"☕\"; }";
let diff = DiffResult::from_sources_with_params("a.rs", "", source, ¶ms);
assert_eq!(diff.rhs_folds.len(), 1);
let range = diff.rhs_folds[0].range;
assert_eq!(
&source[range.start.byte_column..range.end.byte_column],
"\"☕\""
);
}
}
#[cfg(test)]
mod tag_tests {
use super::*;
use crate::parse::folds::FoldMatch;
use crate::summary::DiffResult;
#[test]
fn repeated_rules_accumulate_sorted_tags_without_duplicate_folds() {
let params = with_queries(&[(
"rust",
r#"
((block) @fold (#set! tag "removed-runs:user.check"))
((block) @fold (#set! tag "removed-runs:body"))
((block) @fold (#set! tag "removed-runs:user.check"))
"#,
)]);
let result =
DiffResult::from_sources_with_params("a.rs", "", "fn f() { work(); }", ¶ms);
assert_eq!(result.rhs_folds.len(), 1);
assert_eq!(
result.rhs_folds[0].tags,
["removed-runs:body", "removed-runs:user.check"]
);
}
#[test]
fn an_opening_capture_alone_folds_to_the_end_of_the_fold_node() {
let query = r#"((function_definition ":" @fold.open body: (block) @fold) (#set! tag "removed-runs:body"))"#;
let params = with_queries(&[("python", query)]);
let rhs = "def f(a):\n x = a\n return x\n";
let result = DiffResult::from_sources_with_params("a.py", "", rhs, ¶ms);
assert_eq!(result.rhs_folds.len(), 1);
let range = &result.rhs_folds[0].range;
assert_eq!(
(range.start.line.as_usize(), range.start.byte_column),
(0, 9)
);
assert_eq!((range.end.line.as_usize(), range.end.byte_column), (2, 12));
}
#[test]
fn a_node_captured_with_two_ranges_is_a_query_conflict_naming_both_files() {
let whole = "((block) @fold (#set! tag \"removed-runs:whole\"))";
let interior =
"((block \"{\" @fold.open \"}\" @fold.close) @fold (#set! tag \"summarize:inside\"))";
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("queries/rust")).unwrap();
let removed_runs = dir.path().join("queries/rust/removed-runs.scm");
let summarize = dir.path().join("queries/rust/summarize.scm");
std::fs::write(&removed_runs, whole).unwrap();
std::fs::write(&summarize, interior).unwrap();
let queries = |plugin: &str, path: &std::path::Path| {
(
plugin.to_owned(),
vec![diffr_plugin_sdk::QuerySource {
language: "rust".into(),
name: path.display().to_string(),
text: std::fs::read_to_string(path).unwrap(),
}],
)
};
for order in [
[
queries("removed-runs", &removed_runs),
queries("summarize", &summarize),
],
[
queries("summarize", &summarize),
queries("removed-runs", &removed_runs),
],
] {
let params = Config::default().compile_queries(order.to_vec()).unwrap();
let conflict = DiffResult::try_from_sources_with_params(
"src/lib.rs",
"",
"fn f() {\n work();\n}\n",
¶ms,
)
.expect_err("a conflict");
let message = conflict.to_string().replace('\\', "/");
assert!(message.starts_with("src/lib.rs:1: "), "{message}");
assert!(
message.contains("queries/rust/removed-runs.scm and ")
&& message.contains(
"queries/rust/summarize.scm capture the same block with different fold ranges"
),
"{message}"
);
assert!(
DiffResult::try_from_sources_with_params("a.py", "", "x = 1\n", ¶ms).is_ok()
);
}
}
#[test]
fn test_bodies_keep_every_owner_tag_and_remain_paired() {
let params = body_params();
let result = DiffResult::from_sources_with_params(
"a.rs",
"#[test]\nfn example() { old(); }",
"#[test]\nfn example() { old(); new(); }",
¶ms,
);
assert_eq!(result.lhs_folds.len(), 1);
assert_eq!(result.rhs_folds.len(), 1);
let tags = [
"deleted-bodies:function",
"removed-runs:function",
"summarize:function",
"summarize:test",
"test-bodies:test",
];
assert_eq!(result.lhs_folds[0].tags, tags);
assert_eq!(result.rhs_folds[0].tags, tags);
assert!(matches!(
result.lhs_folds[0].match_kind,
FoldMatch::Matched { .. }
));
}
}
#[cfg(test)]
mod load_tests {
use super::*;
#[test]
fn the_file_overrides_defaults_key_by_key() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
"[diff]\ngraph_limit = 5\nbyte_limit = 6\n[theme]\nname = 'mine'\n",
)
.unwrap();
let config = Config::load(Some(&path)).unwrap();
assert_eq!(config.diff.graph_limit, 5);
assert_eq!(config.diff.byte_limit, 6);
assert_eq!(
config.diff.parse_error_limit,
crate::options::DEFAULT_PARSE_ERROR_LIMIT
);
assert_eq!(config.theme.name, "mine");
assert_eq!(config.theme.path, None);
}
#[test]
fn unknown_keys_name_their_path_and_the_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "[diff]\ngraph_limit = 5\ntypo = 1\n").unwrap();
let error = Config::load(Some(&path)).unwrap_err().to_string();
assert!(
error.starts_with(&format!("{}: diff.typo: ", path.display())),
"{error}"
);
std::fs::write(&path, "[diff]\ngraph_limit = 'many'\n").unwrap();
let error = Config::load(Some(&path)).unwrap_err().to_string();
assert!(error.contains("diff.graph_limit: "), "{error}");
}
#[test]
fn a_missing_explicit_file_is_an_error() {
let dir = tempfile::tempdir().unwrap();
assert!(Config::load(Some(&dir.path().join("absent.toml"))).is_err());
}
#[test]
fn schema_describes_every_setting_with_its_default() {
let schema = Config::schema();
let diff = &schema["properties"]["diff"];
let diff = match diff.get("$ref") {
Some(reference) => {
let name = reference.as_str().unwrap().rsplit('/').next().unwrap();
&schema["$defs"][name]
}
None => diff,
};
let graph_limit = &diff["properties"]["graph_limit"];
assert_eq!(graph_limit["default"], crate::options::DEFAULT_GRAPH_LIMIT);
assert!(graph_limit["description"]
.as_str()
.unwrap()
.contains("matching graph"));
assert!(schema["properties"].get("languages").is_none());
}
}
#[cfg(test)]
mod format_tests {
use super::*;
#[test]
fn embedded_defaults_round_trip_with_an_explicit_version_and_order() {
let defaults = Config::default();
let text = toml::to_string_pretty(&defaults).unwrap();
let restored = Config::from_toml(&text).unwrap();
assert_eq!(defaults.version, 1);
assert_eq!(
serde_json::to_value(defaults).unwrap(),
serde_json::to_value(restored).unwrap()
);
}
#[test]
fn unsupported_versions_are_rejected_and_explicit_lists_stay_small() {
assert!(Config::from_toml("version = 2")
.err()
.unwrap()
.to_string()
.contains("unsupported config version 2"));
let config =
Config::from_toml("version = 1\n[plugins]\norder = ['bundled.group']\n").unwrap();
assert_eq!(config.plugins.entries.len(), 1);
assert!(config.plugins.entries.contains_key("bundled.group"));
}
}