Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ description = "Structural diffs with a streaming API and interactive terminal fr
repository = "https://github.com/devdotfast/diffr"
homepage = "https://github.com/devdotfast/diffr"
license = "MIT"
version = "0.1.12"
version = "0.1.13"
authors = ["dev.fast"]
keywords = ["diff", "syntax"]
categories = ["development-tools", "command-line-utilities", "parser-implementations"]
Expand All @@ -26,7 +26,7 @@ include = [
]

[dependencies]
diffr-core = { path = "crates/diffr-core", version = "0.1.12" }
diffr-core = { path = "crates/diffr-core", version = "0.1.13" }
gix = { version = "0.88", default-features = false, features = ["parallel", "revision", "status", "blob-diff", "attributes", "sha1"] }
clap = { version = "4.0.0", features = ["cargo", "derive", "env", "wrap_help", "string"] }
typed-arena = "2.0.2"
Expand Down
4 changes: 2 additions & 2 deletions crates/diffr-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ description = "The diffr engine: parsing, structural diffing, configuration and
repository = "https://github.com/devdotfast/diffr"
homepage = "https://github.com/devdotfast/diffr"
license = "MIT"
version = "0.1.12"
version = "0.1.13"
authors = ["dev.fast"]
keywords = ["diff", "syntax"]
categories = ["development-tools", "parser-implementations"]
Expand All @@ -29,7 +29,7 @@ include = [
]

[dependencies]
diffr-grammars = { path = "../diffr-grammars", version = "0.1.12" }
diffr-grammars = { path = "../diffr-grammars", version = "0.1.13" }
regex = "1.10.4"
typed-arena = "2.0.2"
rustc-hash = "2.0.0"
Expand Down
96 changes: 46 additions & 50 deletions crates/diffr-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@
//! `title` and an `x-group` that settings screens show in place of the
//! dotted key. Lists are in the schema marked `"x-settings": false`: a
//! settings screen edits scalars and leaves those to the file and `diffr
//! config set`. The `plugins` part of the schema comes from each plugin's
//! config set`. The `plugins.shape` part of the schema comes from each plugin's
//! `plugin.toml`, with lists and tables marked the same way. `config set`
//! keeps only what differs from the defaults (see [`prune`]).
//!
//! `[plugins]` configures the plugins that shape regions after diffing (see
//! `[plugins.shape]` configures the plugins that shape regions after diffing (see
//! [`crate::plugin`]). Compiling assembles, per language, one query from the
//! query files of every enabled plugin (see [`crate::plugin::queries`]): its
//! `@fold` captures decide which folds exist, and its tags what they are.
Expand All @@ -23,7 +23,7 @@ pub mod store;
use crate::hash::DftHashMap;
use crate::options::DiffOptions;
use crate::parse::{guess_language::Language, tree_sitter_parser};
use crate::plugin::config::{ClassifierConfig, PluginsConfig};
use crate::plugin::config::PluginsConfig;
use crate::plugin::queries::{self, Queries};
use query::AnnotationQuery;
use schemars::JsonSchema;
Expand All @@ -33,29 +33,22 @@ use std::sync::{Arc, OnceLock};
use strum::IntoEnumIterator;

pub(crate) const DEFAULT_CONFIG: &str = include_str!("config/default.toml");
const CONFIG_VERSION: u32 = 1;
const CONFIG_VERSION: u32 = 2;
fn config_version() -> u32 {
CONFIG_VERSION
}

#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Config {
/// Configuration format version. Unknown versions require a newer diffr.
/// Configuration format version.
#[serde(default = "config_version")]
#[schemars(extend("x-settings" = false))]
#[schemars(extend("x-settings" = false, "const" = CONFIG_VERSION))]
pub(crate) version: u32,
/// The plugins that decide what starts collapsed, hidden, linked or
/// grouped, and the fold queries they own. Its schema comes from each
/// plugin's `plugin.toml`; see [`PluginsConfig::schema`].
/// The shape and classifier plugins, with settings from their manifests.
#[schemars(skip)]
#[serde(default)]
pub plugins: PluginsConfig,
/// The one plugin that tags files before diffing; see
/// [`ClassifierConfig::schema`].
#[schemars(skip)]
#[serde(default)]
pub classifier: ClassifierConfig,
/// Colors for the terminal frontend.
#[serde(default)]
pub(crate) theme: ThemeConfig,
Expand Down Expand Up @@ -217,24 +210,30 @@ impl Config {
/// Parse the text of a file in `directory`. Errors lead with the dotted
/// path of the key they concern, such as `diff.typo`.
pub fn from_toml_in(source: &str, directory: &Path) -> Result<Self, ConfigError> {
let source = prune::forget_legacy(source);
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 {
#[derive(Deserialize)]
struct Version {
#[serde(default = "config_version")]
version: u32,
}
let version: Version =
toml::from_str(source).map_err(|error| ConfigError(error.to_string()))?;
if version.version != CONFIG_VERSION {
return Err(ConfigError(format!(
"unsupported config version {}; expected {CONFIG_VERSION}",
config.version
version.version
)));
}
config.plugins.resolve(directory)?;
config.classifier.resolve(directory)?;
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}"),
})
})?;
config.plugins.shape.resolve(directory)?;
config.plugins.classify.resolve(directory)?;
Ok(config)
}

Expand All @@ -253,19 +252,18 @@ impl Config {
.as_object_mut()
.expect("the schema has properties");
properties.insert("plugins".to_owned(), PluginsConfig::schema());
properties.insert("classifier".to_owned(), ClassifierConfig::schema());
properties.extend(rest);
schema
}

/// Compile the query files the enabled plugins' manifests declare. No
/// plugin runs to supply them.
pub fn compile(&self) -> Result<Params, ConfigError> {
self.compile_queries(self.plugins.queries()?)
self.compile_queries(self.plugins.shape.queries()?)
}

/// Compile with `queries`, the enabled plugins' query files in
/// `plugins.order`.
/// `plugins.shape.order`.
pub fn compile_queries(&self, queries: Vec<(String, Queries)>) -> Result<Params, ConfigError> {
let mut languages: DftHashMap<_, _> = Language::iter()
.map(|language| (language, OnceLock::new()))
Expand All @@ -276,7 +274,7 @@ impl Config {
.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)?;
check_tags(&query, &self.plugins.shape.order)?;
languages.insert(
language,
OnceLock::from(Arc::new(LanguageParams {
Expand All @@ -301,9 +299,7 @@ fn check_tags(query: &AnnotationQuery, order: &[String]) -> Result<(), ConfigErr
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
}))
|| order.iter().any(|own| own == plugin))
});
if !owned {
return Err(ConfigError(format!(
Expand Down Expand Up @@ -512,10 +508,12 @@ fn with_queries(queries: &[(&str, &str)]) -> Params {
/// over them.
#[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")
Config::from_toml(
"[plugins.shape.bundled.context]\nenabled = false\n[plugins.shape.bundled.summarize]\nenabled = true\napi_key = 'test'\n",
)
.expect("a valid configuration")
.compile()
.expect("the bundled queries compile")
}

#[cfg(test)]
Expand Down Expand Up @@ -792,23 +790,21 @@ mod format_tests {
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!(defaults.version, 2);
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.context']\n").unwrap();
assert_eq!(config.plugins.entries.len(), 1);
assert!(config.plugins.entries.contains_key("bundled.context"));
fn unsupported_versions_are_rejected_before_their_keys() {
assert!(
Config::from_toml("version = 1\n[plugins.bundled.context]\nenabled = false")
.err()
.unwrap()
.to_string()
.contains("unsupported config version 1; expected 2")
);
}
}
14 changes: 7 additions & 7 deletions crates/diffr-core/src/config/default.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Bundled defaults. The user config keeps only what differs from these.
version = 1
version = 2

[plugins]
[plugins.shape]
order = [
"bundled.deleted-bodies",
"bundled.summarize",
Expand All @@ -10,19 +10,19 @@ order = [
"bundled.context",
]

[plugins.bundled.context]
[plugins.shape.bundled.context]
enabled = true
lines = 3

[plugins.bundled.deleted-bodies]
[plugins.shape.bundled.deleted-bodies]
enabled = true
min_lines = 12

[plugins.bundled.removed-runs]
[plugins.shape.bundled.removed-runs]
enabled = true
min_lines = 5

[plugins.bundled.summarize]
[plugins.shape.bundled.summarize]
enabled = false
provider = "gemini"
min_lines = 20
Expand All @@ -31,7 +31,7 @@ test_min_lines = 20
request_timeout_ms = 60000
retries = 3

[plugins.bundled.test-bodies]
[plugins.shape.bundled.test-bodies]
enabled = true
min_lines = 3

Expand Down
48 changes: 2 additions & 46 deletions crates/diffr-core/src/config/prune.rs
Original file line number Diff line number Diff line change
@@ -1,43 +1,7 @@
//! Keep the config file sparse, so that later default changes reach it.
//! Both passes remove keys by dotted path: [`prune`] each key whose removal
//! leaves the resolved configuration unchanged, and [`forget_legacy`] each
//! key that holds a default an earlier version wrote into files.
use std::borrow::Cow;
//! Remove each key whose removal leaves the resolved configuration unchanged.
use toml_edit::{DocumentMut, Item, TableLike};

/// Defaults that versions before 0.1.8 wrote into config files on the first
/// edit, read as unset. Temporary: remove after 2026-12-31.
const LEGACY_DEFAULTS: &[(&str, &[&str])] = &[(
"plugins.bundled.summarize.system_prompt",
&[
"For each listed fold, rewrite that function body as short python-flavored pseudocode. Keep the names. No prose, no comments, no code fences. Use as few lines as possible: about one pseudocode line per five source lines, and never more than a third of the body's lines. When a fold lists a doc, also set \"summary\" to one sentence copied verbatim from that doc; otherwise leave it empty. Answer with a JSON array of {\"id\", \"summary\", \"pseudocode\"} objects, one per fold.",
"For each listed fold, rewrite that function body as short pseudocode. Keep the names. No prose, no comments, no code fences. Use as few lines as possible: about one pseudocode line per five source lines, and never more than a third of the body's lines. When a fold lists a doc, also set \"summary\" to one sentence copied verbatim from that doc; otherwise leave it empty. Answer with a JSON array of {\"id\", \"summary\", \"pseudocode\"} objects, one per fold.",
],
)];

/// `source` without the keys that hold a legacy default. Text that does not
/// parse is returned as it is, for the caller to report.
pub(crate) fn forget_legacy(source: &str) -> Cow<'_, str> {
let Ok(mut document) = source.parse::<DocumentMut>() else {
return Cow::Borrowed(source);
};
let mut changed = false;
for (key, values) in LEGACY_DEFAULTS {
let path: Vec<&str> = key.split('.').collect();
let legacy = get(document.as_table(), &path)
.and_then(Item::as_str)
.is_some_and(|value| values.contains(&value));
if legacy {
remove(document.as_table_mut(), &path);
changed = true;
}
}
match changed {
true => Cow::Owned(document.to_string()),
false => Cow::Borrowed(source),
}
}

/// Remove each value, then each table, whose removal leaves `resolve` of
/// the document unchanged: an object option is only valid whole. `version` stays, and so does
/// anything with a comment on it: a note marks intent.
Expand All @@ -62,7 +26,7 @@ pub(crate) fn prune(document: &mut DocumentMut, resolve: impl Fn(&str) -> Option
}

/// Leave out the uncommented header of a table that only holds tables, such
/// as `[plugins]` above `[plugins.bundled.context]`.
/// as `[plugins.shape]` above `[plugins.shape.bundled.context]`.
fn hide_headers(table: &mut toml_edit::Table) {
let only_tables = table.iter().all(|(_, item)| item.is_table());
if only_tables && !table.is_empty() && !commented(table.decor()) {
Expand Down Expand Up @@ -146,14 +110,6 @@ fn commented(decor: &toml_edit::Decor) -> bool {
.any(|raw| raw.as_str().is_some_and(|text| text.contains('#')))
}

fn get<'a>(table: &'a dyn TableLike, path: &[&str]) -> Option<&'a Item> {
match path {
[key] => table.get(key),
[key, rest @ ..] => get(table.get(key)?.as_table_like()?, rest),
[] => None,
}
}

pub(crate) fn remove(table: &mut dyn TableLike, path: &[&str]) {
match path {
[key] => {
Expand Down
Loading
Loading