Skip to main content

serializer/
biome_config.rs

1//! DX-native Biome configuration parsed from the extensionless root `dx` file.
2
3use std::collections::BTreeSet;
4use std::fs;
5use std::path::Path;
6
7use thiserror::Error;
8
9use crate::llm::ConvertError;
10use crate::{DxDocument, DxLlmValue, llm_to_document};
11
12const BIOME_SECTION: &str = "biome";
13const TARGET_COLUMN: &str = "target";
14const PATH_COLUMN: &str = "path";
15const ENABLED_COLUMN: &str = "enabled";
16const DEFAULT_PATH: &str = ".";
17
18/// Biome target class declared in DX configuration.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum DxBiomeTarget {
21    /// Applies to both lint and format adapters.
22    All,
23    /// Applies only to Biome lint.
24    Lint,
25    /// Applies only to Biome format.
26    Format,
27}
28
29impl DxBiomeTarget {
30    /// Returns true when this declaration applies to the requested target.
31    #[must_use]
32    pub fn includes(self, requested: Self) -> bool {
33        self == Self::All || self == requested
34    }
35}
36
37/// Parsed Biome configuration from a DX document.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct DxBiomeConfig {
40    entries: Vec<DxBiomeConfigEntry>,
41}
42
43impl DxBiomeConfig {
44    /// Returns all enabled Biome entries in source order.
45    #[must_use]
46    pub fn entries(&self) -> &[DxBiomeConfigEntry] {
47        &self.entries
48    }
49
50    /// Returns true when the requested Biome target has at least one enabled path.
51    #[must_use]
52    pub fn is_enabled_for(&self, target: DxBiomeTarget) -> bool {
53        self.entries
54            .iter()
55            .any(|entry| entry.target.includes(target))
56    }
57
58    /// Returns deduplicated paths for the requested target in source order.
59    #[must_use]
60    pub fn paths_for(&self, target: DxBiomeTarget) -> Vec<String> {
61        let mut seen = BTreeSet::new();
62        let mut paths = Vec::new();
63        for entry in self
64            .entries
65            .iter()
66            .filter(|entry| entry.target.includes(target))
67        {
68            if seen.insert(entry.path.clone()) {
69                paths.push(entry.path.clone());
70            }
71        }
72        paths
73    }
74}
75
76/// One enabled Biome target/path row from DX configuration.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct DxBiomeConfigEntry {
79    /// Target class for this row.
80    pub target: DxBiomeTarget,
81    /// Path passed to the Biome CLI for this row.
82    pub path: String,
83}
84
85/// Errors produced while reading or validating DX Biome configuration.
86#[derive(Debug, Error)]
87pub enum DxBiomeConfigError {
88    /// The extensionless `dx` file could not be read.
89    #[error("failed to read Biome dx config at {path}: {source}")]
90    Read {
91        /// Display path for the failed read.
92        path: String,
93        /// Underlying filesystem error.
94        #[source]
95        source: std::io::Error,
96    },
97    /// The extensionless `dx` file could not be parsed as a DX document.
98    #[error("failed to parse Biome dx config: {0}")]
99    Parse(#[from] ConvertError),
100    /// The `biome[...]` table is missing a required column.
101    #[error("biome table must include a {column} column")]
102    MissingColumn {
103        /// Missing column name.
104        column: &'static str,
105    },
106    /// A row declared an unsupported Biome target.
107    #[error("invalid Biome target at row {row}: {value}")]
108    InvalidTarget {
109        /// One-based table row number.
110        row: usize,
111        /// Unsupported target value.
112        value: String,
113    },
114    /// A row declared an unsupported `enabled` value.
115    #[error("invalid Biome enabled value at row {row}: {value}")]
116    InvalidEnabled {
117        /// One-based table row number.
118        row: usize,
119        /// Unsupported enabled value.
120        value: String,
121    },
122    /// A row declared an unsafe or invalid Biome path.
123    #[error("invalid Biome path at row {row}: {value} ({reason})")]
124    InvalidPath {
125        /// One-based table row number.
126        row: usize,
127        /// Invalid path value.
128        value: String,
129        /// Validation reason.
130        reason: &'static str,
131    },
132}
133
134/// Loads DX Biome configuration from an extensionless `dx` file path.
135///
136/// # Errors
137///
138/// Returns an error when the file cannot be read, the DX document cannot be
139/// parsed, or the `biome[...]` table contains invalid values.
140pub fn load_biome_config(
141    path: impl AsRef<Path>,
142) -> Result<Option<DxBiomeConfig>, DxBiomeConfigError> {
143    let path = path.as_ref();
144    if !path.is_file() {
145        return Ok(None);
146    }
147
148    let source = fs::read_to_string(path).map_err(|source| DxBiomeConfigError::Read {
149        path: path.display().to_string(),
150        source,
151    })?;
152    biome_config_from_source(&source)
153}
154
155/// Parses DX Biome configuration directly from extensionless `dx` source text.
156///
157/// # Errors
158///
159/// Returns an error when the source is not valid DX text or the `biome[...]`
160/// table contains invalid values.
161pub fn biome_config_from_source(source: &str) -> Result<Option<DxBiomeConfig>, DxBiomeConfigError> {
162    let document = llm_to_document(source)?;
163    biome_config_from_document(&document)
164}
165
166/// Extracts DX Biome configuration from a parsed DX document.
167///
168/// # Errors
169///
170/// Returns an error when the `biome[...]` table is present but malformed.
171pub fn biome_config_from_document(
172    document: &DxDocument,
173) -> Result<Option<DxBiomeConfig>, DxBiomeConfigError> {
174    let Some(section) = document.section_by_name(BIOME_SECTION) else {
175        return Ok(None);
176    };
177
178    let target_index =
179        section
180            .column_index(TARGET_COLUMN)
181            .ok_or(DxBiomeConfigError::MissingColumn {
182                column: TARGET_COLUMN,
183            })?;
184    let path_index = section.column_index(PATH_COLUMN);
185    let enabled_index = section.column_index(ENABLED_COLUMN);
186    let mut entries = Vec::new();
187
188    for (index, row) in section.rows.iter().enumerate() {
189        let row_number = index + 1;
190        if !enabled_value(row, enabled_index, row_number)? {
191            continue;
192        }
193
194        let target_value = row
195            .get(target_index)
196            .map(cell_text)
197            .filter(|value| !value.trim().is_empty())
198            .ok_or_else(|| DxBiomeConfigError::InvalidTarget {
199                row: row_number,
200                value: String::new(),
201            })?;
202        let target =
203            parse_target(&target_value).ok_or_else(|| DxBiomeConfigError::InvalidTarget {
204                row: row_number,
205                value: target_value.clone(),
206            })?;
207
208        let path = path_value(row, path_index, row_number)?;
209        validate_path(&path, row_number)?;
210        entries.push(DxBiomeConfigEntry {
211            target,
212            path: normalize_relative_path(&path),
213        });
214    }
215
216    Ok(Some(DxBiomeConfig { entries }))
217}
218
219fn enabled_value(
220    row: &[DxLlmValue],
221    enabled_index: Option<usize>,
222    row_number: usize,
223) -> Result<bool, DxBiomeConfigError> {
224    let Some(index) = enabled_index else {
225        return Ok(true);
226    };
227    let Some(value) = row.get(index) else {
228        return Ok(true);
229    };
230
231    match value {
232        DxLlmValue::Bool(value) => Ok(*value),
233        DxLlmValue::Num(value) if value.abs() < f64::EPSILON => Ok(false),
234        DxLlmValue::Num(value) if (*value - 1.0).abs() < f64::EPSILON => Ok(true),
235        DxLlmValue::Str(value) => match value.trim().to_ascii_lowercase().as_str() {
236            "true" | "yes" | "on" | "1" | "enabled" => Ok(true),
237            "false" | "no" | "off" | "0" | "disabled" => Ok(false),
238            _ => Err(DxBiomeConfigError::InvalidEnabled {
239                row: row_number,
240                value: value.clone(),
241            }),
242        },
243        DxLlmValue::Null => Ok(true),
244        _ => Err(DxBiomeConfigError::InvalidEnabled {
245            row: row_number,
246            value: value.to_string(),
247        }),
248    }
249}
250
251fn parse_target(value: &str) -> Option<DxBiomeTarget> {
252    match value.trim().to_ascii_lowercase().as_str() {
253        "all" | "check" | "lint-format" | "lint_format" | "lint+format" => Some(DxBiomeTarget::All),
254        "lint" => Some(DxBiomeTarget::Lint),
255        "format" | "formatter" => Some(DxBiomeTarget::Format),
256        _ => None,
257    }
258}
259
260fn cell_text(value: &DxLlmValue) -> String {
261    value.to_string()
262}
263
264fn path_value(
265    row: &[DxLlmValue],
266    path_index: Option<usize>,
267    row_number: usize,
268) -> Result<String, DxBiomeConfigError> {
269    let Some(index) = path_index else {
270        return Ok(DEFAULT_PATH.to_string());
271    };
272    let Some(value) = row.get(index) else {
273        return Ok(DEFAULT_PATH.to_string());
274    };
275    match value {
276        DxLlmValue::Str(value) => {
277            let trimmed = value.trim();
278            if trimmed.is_empty() {
279                return Err(DxBiomeConfigError::InvalidPath {
280                    row: row_number,
281                    value: value.clone(),
282                    reason: "path cannot be empty",
283                });
284            }
285            Ok(trimmed.to_string())
286        }
287        _ => Err(DxBiomeConfigError::InvalidPath {
288            row: row_number,
289            value: value.to_string(),
290            reason: "path must be a string",
291        }),
292    }
293}
294
295fn validate_path(path: &str, row_number: usize) -> Result<(), DxBiomeConfigError> {
296    if path.trim().is_empty() {
297        return Err(DxBiomeConfigError::InvalidPath {
298            row: row_number,
299            value: path.to_string(),
300            reason: "path cannot be empty",
301        });
302    }
303    if path.starts_with('-') {
304        return Err(DxBiomeConfigError::InvalidPath {
305            row: row_number,
306            value: path.to_string(),
307            reason: "path cannot look like a command-line option",
308        });
309    }
310    let normalized = path.replace('\\', "/");
311    if is_absolute_like(&normalized) {
312        return Err(DxBiomeConfigError::InvalidPath {
313            row: row_number,
314            value: path.to_string(),
315            reason: "path must be relative to the project root",
316        });
317    }
318    if normalized.split('/').any(|component| component == "..") {
319        return Err(DxBiomeConfigError::InvalidPath {
320            row: row_number,
321            value: path.to_string(),
322            reason: "path cannot escape the project root",
323        });
324    }
325    if path.chars().any(char::is_control) {
326        return Err(DxBiomeConfigError::InvalidPath {
327            row: row_number,
328            value: path.to_string(),
329            reason: "path cannot contain control characters",
330        });
331    }
332    Ok(())
333}
334
335fn is_absolute_like(path: &str) -> bool {
336    path.starts_with('/')
337        || path.starts_with("//")
338        || path
339            .as_bytes()
340            .get(1)
341            .is_some_and(|separator| *separator == b':')
342}
343
344fn normalize_relative_path(path: &str) -> String {
345    let normalized = path.replace('\\', "/");
346    let parts = normalized
347        .split('/')
348        .filter(|part| !part.is_empty() && *part != ".")
349        .collect::<Vec<_>>();
350    if parts.is_empty() {
351        DEFAULT_PATH.to_string()
352    } else {
353        parts.join("/")
354    }
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360
361    #[test]
362    fn parses_biome_config_from_extensionless_dx_source() {
363        let source = r#"
364project(name=demo kind=dx-project)
365
366biome[target path enabled](
367lint src true
368format tests true
369all packages/app true
370)
371"#;
372
373        let config = biome_config_from_source(source).unwrap().unwrap();
374
375        assert_eq!(
376            config.paths_for(DxBiomeTarget::Lint),
377            vec!["src".to_string(), "packages/app".to_string()]
378        );
379        assert_eq!(
380            config.paths_for(DxBiomeTarget::Format),
381            vec!["tests".to_string(), "packages/app".to_string()]
382        );
383    }
384
385    #[test]
386    fn missing_biome_table_returns_none() {
387        let config = biome_config_from_source("project(name=demo kind=dx-project)").unwrap();
388
389        assert!(config.is_none());
390    }
391
392    #[test]
393    fn rejects_paths_that_would_be_cli_options() {
394        let error = biome_config_from_source(
395            r#"
396biome[target path](
397lint "--write"
398)
399"#,
400        )
401        .unwrap_err();
402
403        assert!(error.to_string().contains("--write"));
404    }
405
406    #[test]
407    fn trims_and_deduplicates_biome_paths_in_source_order() {
408        let config = biome_config_from_source(
409            r#"
410biome[target path](
411lint " src "
412lint src
413lint ./src
414lint src/.
415lint tests
416)
417"#,
418        )
419        .unwrap()
420        .unwrap();
421
422        assert_eq!(
423            config.paths_for(DxBiomeTarget::Lint),
424            vec!["src".to_string(), "tests".to_string()]
425        );
426    }
427
428    #[test]
429    fn rejects_absolute_and_parent_traversal_biome_paths() {
430        for path in [
431            "/tmp/project",
432            "C:/repo/src",
433            "//server/share",
434            "../outside",
435        ] {
436            let error = biome_config_from_source(&format!(
437                r#"
438biome[target path](
439lint "{path}"
440)
441"#
442            ))
443            .unwrap_err();
444
445            assert!(
446                error.to_string().contains(path),
447                "error should mention rejected path {path}: {error}"
448            );
449        }
450
451        for path in ["..\\outside", "src\\..\\outside", "C:\\repo\\src"] {
452            assert!(
453                validate_path(path, 1).is_err(),
454                "path should be rejected independent of host OS: {path}"
455            );
456        }
457    }
458
459    #[test]
460    fn rejects_non_scalar_biome_paths() {
461        let mut document = DxDocument::new();
462        let mut section = crate::DxSection::new(vec!["target".to_string(), "path".to_string()]);
463        section
464            .add_row(vec![
465                DxLlmValue::Str("lint".to_string()),
466                DxLlmValue::Arr(vec![DxLlmValue::Str("src".to_string())]),
467            ])
468            .unwrap();
469        document.sections.insert('b', section);
470        document.section_names.insert('b', "biome".to_string());
471
472        let error = biome_config_from_document(&document).unwrap_err();
473
474        assert!(error.to_string().contains("path"));
475    }
476
477    #[test]
478    fn disabled_rows_do_not_validate_draft_target_or_path() {
479        let config = biome_config_from_source(
480            r#"
481biome[target path enabled](
482future "--write" false
483lint src true
484)
485"#,
486        )
487        .unwrap()
488        .unwrap();
489
490        assert_eq!(
491            config.paths_for(DxBiomeTarget::Lint),
492            vec!["src".to_string()]
493        );
494    }
495}