Skip to main content

dprint_config/
lib.rs

1#![doc = include_str!("../README.md")]
2#![no_std]
3
4extern crate alloc;
5
6pub mod json;
7pub mod markdown;
8pub mod toml;
9pub mod typescript;
10
11use alloc::collections::BTreeMap;
12use alloc::string::String;
13use alloc::vec;
14use alloc::vec::Vec;
15
16use schemars::{JsonSchema, schema_for};
17use serde::{Deserialize, Serialize};
18use serde_json::Value;
19
20// ---------------------------------------------------------------------------
21// Example helpers (for schemars annotations)
22// ---------------------------------------------------------------------------
23
24fn example_plugin_urls() -> Vec<String> {
25    vec![
26        "https://plugins.dprint.dev/typescript-0.93.3.wasm".into(),
27        "https://plugins.dprint.dev/json-0.19.4.wasm".into(),
28    ]
29}
30
31fn example_includes() -> Vec<String> {
32    vec!["src/**/*.{ts,tsx,json}".into()]
33}
34
35fn example_excludes() -> Vec<String> {
36    vec!["**/*-lock.json".into(), "**/node_modules".into()]
37}
38
39fn example_associations() -> Vec<String> {
40    vec!["**/*.myconfig".into(), ".myconfigrc".into()]
41}
42
43// ---------------------------------------------------------------------------
44// Extends
45// ---------------------------------------------------------------------------
46
47/// One or more configuration files to extend.
48///
49/// Can be a single path/URL string or an array of paths/URLs. Properties
50/// from extended configs are merged, with the current file taking
51/// precedence. Earlier entries in an array take priority over later ones.
52///
53/// Supports the `${configDir}` variable (resolves to the directory
54/// containing the current config file) and `${originConfigDir}` (resolves
55/// to the directory of the original/root config file).
56///
57/// When extending remote configs (URLs), `includes` and non-Wasm `plugins`
58/// entries are ignored for security.
59#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
60#[serde(untagged)]
61pub enum Extends {
62    /// A single file path or URL to a configuration file to extend.
63    Single(String),
64    /// A collection of file paths and/or URLs to configuration files to
65    /// extend.
66    Multiple(Vec<String>),
67}
68
69// ---------------------------------------------------------------------------
70// NewLineKind
71// ---------------------------------------------------------------------------
72
73/// The newline character style to use when formatting files.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
75#[serde(rename_all = "lowercase")]
76#[schemars(title = "New Line Kind")]
77pub enum NewLineKind {
78    /// For each file, uses the newline kind found at the end of the last
79    /// line.
80    Auto,
81    /// Uses carriage return followed by line feed (`\r\n`).
82    Crlf,
83    /// Uses line feed (`\n`).
84    Lf,
85    /// Uses the system standard (e.g., CRLF on Windows, LF on
86    /// macOS/Linux).
87    System,
88}
89
90// ---------------------------------------------------------------------------
91// PluginConfig (generic, for unknown plugins)
92// ---------------------------------------------------------------------------
93
94/// Configuration for a dprint plugin not covered by the typed plugin
95/// structs.
96///
97/// Known plugins (`typescript`, `json`, `toml`, `markdown`) have dedicated
98/// typed structs. This catch-all is used for any other plugin name.
99#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)]
100#[schemars(title = "Plugin Configuration")]
101pub struct PluginConfig {
102    /// Prevent properties in this plugin section from being overridden by
103    /// extended configurations.
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    #[schemars(title = "Locked")]
106    pub locked: Option<bool>,
107
108    /// File patterns to associate with this plugin.
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    #[schemars(title = "File Associations", example = example_associations())]
111    pub associations: Option<Vec<String>>,
112
113    /// Plugin-specific configuration settings.
114    #[serde(flatten)]
115    pub settings: BTreeMap<String, Value>,
116}
117
118// ---------------------------------------------------------------------------
119// DprintConfig
120// ---------------------------------------------------------------------------
121
122/// dprint configuration file (`dprint.json` / `.dprint.json`).
123///
124/// Configuration for the [dprint](https://dprint.dev) code formatter.
125/// The config file is typically named `dprint.json`, `.dprint.json`,
126/// `dprint.jsonc`, or `.dprint.jsonc` and placed at the root of your
127/// project.
128///
129/// Global formatting options ([`lineWidth`](DprintConfig::line_width),
130/// [`indentWidth`](DprintConfig::indent_width),
131/// [`useTabs`](DprintConfig::use_tabs),
132/// [`newLineKind`](DprintConfig::new_line_kind)) apply as defaults to all
133/// plugins but can be overridden in per-plugin configuration sections.
134///
135/// Plugin-specific configuration is placed at the top level, keyed by the
136/// plugin name:
137///
138/// ```json
139/// {
140///   "lineWidth": 80,
141///   "typescript": {
142///     "quoteStyle": "preferSingle"
143///   },
144///   "json": {
145///     "indentWidth": 2
146///   },
147///   "plugins": [
148///     "https://plugins.dprint.dev/typescript-0.93.3.wasm",
149///     "https://plugins.dprint.dev/json-0.19.4.wasm"
150///   ]
151/// }
152/// ```
153#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)]
154#[schemars(
155    title = "dprint configuration file",
156    description = "Schema for a dprint configuration file."
157)]
158pub struct DprintConfig {
159    /// The JSON schema URL for editor validation and autocompletion.
160    ///
161    /// The dprint VS Code extension automatically constructs a composite
162    /// schema based on the installed plugins, so you usually don't need
163    /// to set this manually.
164    #[serde(default, rename = "$schema", skip_serializing_if = "Option::is_none")]
165    #[schemars(title = "JSON Schema")]
166    pub schema: Option<String>,
167
168    /// Only format files that have changed since the last formatting run.
169    ///
170    /// When `true` (the default), dprint tracks which files have been
171    /// formatted and skips unchanged files on subsequent runs. Set to
172    /// `false` to always reformat all matched files.
173    ///
174    /// Can also be toggled via the `--incremental` / `--incremental=false`
175    /// CLI flag.
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    #[schemars(title = "Incremental")]
178    pub incremental: Option<bool>,
179
180    /// One or more configuration files to extend.
181    ///
182    /// Can be a single path/URL or an array of paths/URLs. Properties from
183    /// extended configs are merged, with the current file taking
184    /// precedence. Earlier entries in an array take priority over later
185    /// ones.
186    ///
187    /// Supports the `${configDir}` variable (resolves to the directory of
188    /// the current config file) and `${originConfigDir}` (resolves to the
189    /// directory of the original/root config file).
190    ///
191    /// When extending remote configs (URLs), `includes` and non-Wasm
192    /// `plugins` entries are ignored for security.
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    #[schemars(title = "Extends")]
195    pub extends: Option<Extends>,
196
197    /// The maximum line width the formatter tries to stay under.
198    ///
199    /// The formatter may exceed this width in certain cases where breaking
200    /// the line would produce worse output. This is a global default that
201    /// individual plugins can override in their configuration sections.
202    #[serde(
203        default,
204        rename = "lineWidth",
205        alias = "line-width",
206        skip_serializing_if = "Option::is_none"
207    )]
208    #[schemars(title = "Line Width")]
209    pub line_width: Option<u32>,
210
211    /// The number of characters for each indent level.
212    ///
213    /// When `useTabs` is `true`, this controls the visual width of each
214    /// tab character. When `useTabs` is `false`, this is the number of
215    /// spaces inserted per indent level. This is a global default that
216    /// individual plugins can override in their configuration sections.
217    #[serde(
218        default,
219        rename = "indentWidth",
220        alias = "indent-width",
221        skip_serializing_if = "Option::is_none"
222    )]
223    #[schemars(title = "Indent Width")]
224    pub indent_width: Option<u32>,
225
226    /// Whether to use tabs (`true`) or spaces (`false`) for indentation.
227    ///
228    /// This is a global default that individual plugins can override in
229    /// their configuration sections.
230    #[serde(
231        default,
232        rename = "useTabs",
233        alias = "use-tabs",
234        skip_serializing_if = "Option::is_none"
235    )]
236    #[schemars(title = "Use Tabs")]
237    pub use_tabs: Option<bool>,
238
239    /// The newline character style to use when formatting.
240    ///
241    /// This is a global default that individual plugins can override in
242    /// their configuration sections.
243    #[serde(
244        default,
245        rename = "newLineKind",
246        alias = "new-line-kind",
247        skip_serializing_if = "Option::is_none"
248    )]
249    #[schemars(title = "New Line Kind")]
250    pub new_line_kind: Option<NewLineKind>,
251
252    /// Glob patterns for files to include in formatting.
253    ///
254    /// When specified, only files matching at least one of these patterns
255    /// are formatted. When omitted, all files matched by installed plugins
256    /// are formatted (respecting `excludes`).
257    ///
258    /// Uses gitignore-style glob syntax.
259    #[serde(default, skip_serializing_if = "Option::is_none")]
260    #[schemars(title = "Includes", example = example_includes())]
261    pub includes: Option<Vec<String>>,
262
263    /// Glob patterns for files or directories to exclude from formatting.
264    ///
265    /// Uses gitignore-style glob syntax. Files already ignored by
266    /// `.gitignore` are excluded automatically. Prefix a pattern with `!`
267    /// to un-exclude files that were previously excluded.
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    #[schemars(title = "Excludes", example = example_excludes())]
270    pub excludes: Option<Vec<String>>,
271
272    /// Plugin URLs or local file paths.
273    ///
274    /// Each entry is a URL to a WebAssembly plugin (`.wasm`) or a local
275    /// file path. The order determines precedence when multiple plugins
276    /// can handle the same file extension — the first matching plugin
277    /// wins.
278    ///
279    /// Can also be specified via the `--plugins` CLI flag.
280    #[serde(default, skip_serializing_if = "Option::is_none")]
281    #[schemars(title = "Plugins", example = example_plugin_urls())]
282    pub plugins: Option<Vec<String>>,
283
284    // ----- Known plugin configurations -----
285    /// TypeScript / JavaScript plugin configuration.
286    ///
287    /// See: <https://dprint.dev/plugins/typescript/config/>
288    #[serde(default, skip_serializing_if = "Option::is_none")]
289    pub typescript: Option<typescript::TypeScriptConfig>,
290
291    /// JSON plugin configuration.
292    ///
293    /// See: <https://dprint.dev/plugins/json/config/>
294    #[serde(default, skip_serializing_if = "Option::is_none")]
295    pub json: Option<json::JsonConfig>,
296
297    /// TOML plugin configuration.
298    ///
299    /// See: <https://dprint.dev/plugins/toml/config/>
300    #[serde(default, skip_serializing_if = "Option::is_none")]
301    pub toml: Option<toml::TomlConfig>,
302
303    /// Markdown plugin configuration.
304    ///
305    /// See: <https://dprint.dev/plugins/markdown/config/>
306    #[serde(default, skip_serializing_if = "Option::is_none")]
307    pub markdown: Option<markdown::MarkdownConfig>,
308
309    // ----- Unknown plugins (catch-all) -----
310    /// Configuration for plugins not covered by the typed fields above.
311    #[serde(flatten)]
312    pub plugin_configs: BTreeMap<String, PluginConfig>,
313}
314
315/// Generate the JSON Schema for [`DprintConfig`] as a
316/// [`serde_json::Value`].
317///
318/// # Panics
319///
320/// Panics if the schema cannot be serialized to JSON (should never happen).
321pub fn schema() -> Value {
322    serde_json::to_value(schema_for!(DprintConfig)).expect("schema serialization cannot fail")
323}
324
325#[cfg(test)]
326mod tests {
327    use alloc::string::ToString;
328
329    use super::*;
330
331    #[test]
332    fn deserialize_minimal_config() {
333        let json = r#"{"plugins":["https://plugins.dprint.dev/typescript-0.93.3.wasm"]}"#;
334        let config: DprintConfig = serde_json::from_str(json).expect("parse");
335        assert_eq!(config.plugins.as_ref().expect("plugins").len(), 1);
336    }
337
338    #[test]
339    fn deserialize_typed_typescript_config() {
340        let json = r#"{
341            "typescript": {
342                "quoteStyle": "preferSingle",
343                "semiColons": "asi",
344                "lineWidth": 100,
345                "locked": true,
346                "associations": ["!**/*.js"]
347            }
348        }"#;
349        let config: DprintConfig = serde_json::from_str(json).expect("parse");
350        let ts = config.typescript.as_ref().expect("typescript config");
351        assert_eq!(ts.locked, Some(true));
352        assert_eq!(ts.quote_style, Some(typescript::QuoteStyle::PreferSingle));
353        assert_eq!(ts.semi_colons, Some(typescript::SemiColons::Asi));
354        assert_eq!(ts.line_width, Some(100));
355    }
356
357    #[test]
358    fn deserialize_typed_json_config() {
359        let json = r#"{
360            "json": {
361                "indentWidth": 4,
362                "trailingCommas": "never"
363            }
364        }"#;
365        let config: DprintConfig = serde_json::from_str(json).expect("parse");
366        let j = config.json.as_ref().expect("json config");
367        assert_eq!(j.indent_width, Some(4));
368        assert_eq!(j.trailing_commas, Some(json::TrailingCommas::Never));
369    }
370
371    #[test]
372    fn deserialize_typed_toml_config() {
373        let json = r#"{
374            "toml": {
375                "cargo.applyConventions": false
376            }
377        }"#;
378        let config: DprintConfig = serde_json::from_str(json).expect("parse");
379        let t = config.toml.as_ref().expect("toml config");
380        assert_eq!(t.cargo_apply_conventions, Some(false));
381    }
382
383    #[test]
384    fn deserialize_typed_markdown_config() {
385        let json = r#"{
386            "markdown": {
387                "textWrap": "always",
388                "emphasisKind": "asterisks"
389            }
390        }"#;
391        let config: DprintConfig = serde_json::from_str(json).expect("parse");
392        let md = config.markdown.as_ref().expect("markdown config");
393        assert_eq!(md.text_wrap, Some(markdown::TextWrap::Always));
394        assert_eq!(md.emphasis_kind, Some(markdown::StrongKind::Asterisks));
395    }
396
397    #[test]
398    fn unknown_plugin_falls_through() {
399        let json = r#"{
400            "prettier": {
401                "tabWidth": 4
402            }
403        }"#;
404        let config: DprintConfig = serde_json::from_str(json).expect("parse");
405        assert!(config.typescript.is_none());
406        let prettier = config.plugin_configs.get("prettier").expect("prettier");
407        assert_eq!(
408            prettier.settings.get("tabWidth"),
409            Some(&serde_json::json!(4))
410        );
411    }
412
413    #[test]
414    fn extends_single_string() {
415        let json = r#"{"extends": "base.json"}"#;
416        let config: DprintConfig = serde_json::from_str(json).expect("parse");
417        assert!(matches!(config.extends, Some(Extends::Single(ref s)) if s == "base.json"));
418    }
419
420    #[test]
421    fn extends_multiple_strings() {
422        let json = r#"{"extends": ["a.json", "b.json"]}"#;
423        let config: DprintConfig = serde_json::from_str(json).expect("parse");
424        assert!(matches!(config.extends, Some(Extends::Multiple(ref v)) if v.len() == 2));
425    }
426
427    #[test]
428    fn new_line_kind_values() {
429        for (input, expected) in [
430            ("\"auto\"", NewLineKind::Auto),
431            ("\"crlf\"", NewLineKind::Crlf),
432            ("\"lf\"", NewLineKind::Lf),
433            ("\"system\"", NewLineKind::System),
434        ] {
435            let parsed: NewLineKind = serde_json::from_str(input).expect(input);
436            assert_eq!(parsed, expected);
437        }
438    }
439
440    #[test]
441    fn round_trip_config() {
442        let config = DprintConfig {
443            schema: None,
444            incremental: Some(true),
445            extends: Some(Extends::Single("base.json".to_string())),
446            line_width: Some(80),
447            indent_width: Some(2),
448            use_tabs: Some(false),
449            new_line_kind: Some(NewLineKind::Lf),
450            includes: Some(vec!["src/**/*.ts".to_string()]),
451            excludes: Some(vec!["**/*-lock.json".to_string()]),
452            plugins: Some(vec![
453                "https://plugins.dprint.dev/typescript-0.93.3.wasm".to_string(),
454            ]),
455            typescript: None,
456            json: None,
457            toml: None,
458            markdown: None,
459            plugin_configs: BTreeMap::new(),
460        };
461        let json = serde_json::to_string(&config).expect("serialize");
462        let parsed: DprintConfig = serde_json::from_str(&json).expect("deserialize");
463        assert_eq!(config, parsed);
464    }
465
466    #[test]
467    fn schema_has_expected_properties() {
468        let s = schema();
469        let text = serde_json::to_string(&s).expect("serialize");
470        for prop in [
471            "lineWidth",
472            "indentWidth",
473            "useTabs",
474            "newLineKind",
475            "plugins",
476            "includes",
477            "excludes",
478            "incremental",
479            "extends",
480            "$schema",
481            "typescript",
482            "json",
483            "toml",
484            "markdown",
485        ] {
486            assert!(text.contains(prop), "schema should contain {prop}");
487        }
488    }
489
490    #[test]
491    fn empty_config_deserializes() {
492        let config: DprintConfig = serde_json::from_str("{}").expect("parse");
493        assert!(config.plugins.is_none());
494        assert!(config.line_width.is_none());
495        assert!(config.typescript.is_none());
496        assert!(config.plugin_configs.is_empty());
497    }
498}