Skip to main content

fallow_config/config/
entry_span.rs

1//! Source positions of config list entries.
2//!
3//! The loaded config keeps no source positions. An editor that marks one
4//! entry of a list setting (for example an `ignoreDependencies` glob that
5//! matched nothing) finds the entry again with [`FallowConfig::locate_list_entry`].
6
7use std::path::{Path, PathBuf};
8
9use super::FallowConfig;
10use super::parsing::{ConfigFormat, parse_config_to_value};
11
12/// The place where a config file declares one entry of a list setting.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct ConfigEntrySpan {
15    /// The canonical path of the config file that declares the entry. For an
16    /// `extends` chain, this is the file whose list is in effect.
17    pub path: PathBuf,
18    /// The byte offset where the entry starts, at its opening quote.
19    pub start: usize,
20    /// The byte offset where the entry ends, after its closing quote.
21    pub end: usize,
22}
23
24impl FallowConfig {
25    /// Find where the config at `config_path` declares `entry` in the
26    /// top-level list setting `setting` (for example `ignoreFindings`).
27    ///
28    /// A list in a file replaces the list of the configs it extends, so the
29    /// entry belongs to the last source in merge order that sets `setting`.
30    /// Returns `None` when the extends chain does not resolve, when a remote
31    /// `https://` config can set the list, or when the declaring file does not
32    /// hold `entry` as a string in the list.
33    #[must_use]
34    pub fn locate_list_entry(
35        config_path: &Path,
36        setting: &str,
37        entry: &str,
38    ) -> Option<ConfigEntrySpan> {
39        let merge_order = Self::merge_order(config_path)?;
40        for source in merge_order.iter().rev() {
41            let path = source.as_deref()?;
42            let sets_list =
43                parse_config_to_value(path).is_ok_and(|value| value.get(setting).is_some());
44            if !sets_list {
45                continue;
46            }
47            let content = std::fs::read_to_string(path).ok()?;
48            let (start, end) = entry_range(path, &content, setting, entry)?;
49            return Some(ConfigEntrySpan {
50                path: path.to_path_buf(),
51                start,
52                end,
53            });
54        }
55        None
56    }
57}
58
59/// The byte range of `entry` in the `setting` list of one config file. The
60/// loader skips a leading byte order mark, so the range counts it back in.
61fn entry_range(path: &Path, content: &str, setting: &str, entry: &str) -> Option<(usize, usize)> {
62    let text = content.trim_start_matches('\u{FEFF}');
63    let offset = content.len() - text.len();
64    let (start, end) = match ConfigFormat::from_path(path) {
65        ConfigFormat::Json => json_entry_range(text, setting, entry),
66        ConfigFormat::Toml => toml_entry_range(text, setting, entry),
67    }?;
68    Some((start + offset, end + offset))
69}
70
71fn json_entry_range(content: &str, setting: &str, entry: &str) -> Option<(usize, usize)> {
72    let parsed = jsonc_parser::parse_to_ast(
73        content,
74        &jsonc_parser::CollectOptions::default(),
75        &crate::jsonc::parse_options(),
76    )
77    .ok()?;
78    let jsonc_parser::ast::Value::Object(root) = parsed.value? else {
79        return None;
80    };
81    root.get_array(setting)?
82        .elements
83        .iter()
84        .find_map(|element| match element {
85            jsonc_parser::ast::Value::StringLit(literal) if literal.value == entry => {
86                Some((literal.range.start, literal.range.end))
87            }
88            _ => None,
89        })
90}
91
92fn toml_entry_range(content: &str, setting: &str, entry: &str) -> Option<(usize, usize)> {
93    let document = toml_edit::Document::parse(content).ok()?;
94    document
95        .as_table()
96        .get(setting)?
97        .as_array()?
98        .iter()
99        .find(|value| value.as_str() == Some(entry))?
100        .span()
101        .map(|span| (span.start, span.end))
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    fn located_text(span: &ConfigEntrySpan) -> String {
109        let content = std::fs::read_to_string(&span.path).expect("read config");
110        content[span.start..span.end].to_string()
111    }
112
113    fn canonical(path: &Path) -> PathBuf {
114        dunce::canonicalize(path).expect("canonical path")
115    }
116
117    #[test]
118    fn finds_an_entry_in_a_jsonc_config() {
119        let dir = tempfile::tempdir().expect("tempdir");
120        let path = dir.path().join(".fallowrc.jsonc");
121        std::fs::write(
122            &path,
123            "{\n  // a comment\n  \"ignoreDependencies\": [\"react\", \"@acme/*\",],\n}\n",
124        )
125        .expect("write config");
126
127        let span = FallowConfig::locate_list_entry(&path, "ignoreDependencies", "@acme/*")
128            .expect("entry is found");
129
130        assert_eq!(span.path, canonical(&path));
131        assert_eq!(located_text(&span), "\"@acme/*\"");
132    }
133
134    #[test]
135    fn finds_an_entry_in_a_toml_config() {
136        let dir = tempfile::tempdir().expect("tempdir");
137        let path = dir.path().join("fallow.toml");
138        std::fs::write(
139            &path,
140            "ignoreFindings = [\n  \"src/generated/**\",\n  'legacy/**',\n]\n",
141        )
142        .expect("write config");
143
144        let span = FallowConfig::locate_list_entry(&path, "ignoreFindings", "legacy/**")
145            .expect("entry is found");
146
147        assert_eq!(located_text(&span), "'legacy/**'");
148    }
149
150    #[test]
151    fn finds_an_entry_in_the_extended_file_that_declares_the_list() {
152        let dir = tempfile::tempdir().expect("tempdir");
153        let base = dir.path().join("base.json");
154        std::fs::write(&base, r#"{"ignoreFindings": ["src/old/**"]}"#).expect("write base");
155        let path = dir.path().join(".fallowrc.json");
156        std::fs::write(
157            &path,
158            r#"{"extends": ["./base.json"], "entry": ["src/main.ts"]}"#,
159        )
160        .expect("write config");
161
162        let span = FallowConfig::locate_list_entry(&path, "ignoreFindings", "src/old/**")
163            .expect("entry is found");
164
165        assert_eq!(span.path, canonical(&base));
166        assert_eq!(located_text(&span), "\"src/old/**\"");
167    }
168
169    #[test]
170    fn a_list_in_the_extending_file_replaces_the_base_list() {
171        let dir = tempfile::tempdir().expect("tempdir");
172        std::fs::write(
173            dir.path().join("base.json"),
174            r#"{"ignoreFindings": ["src/old/**"]}"#,
175        )
176        .expect("write base");
177        let path = dir.path().join(".fallowrc.json");
178        std::fs::write(
179            &path,
180            r#"{"extends": ["./base.json"], "ignoreFindings": ["src/old/**"]}"#,
181        )
182        .expect("write config");
183
184        let span = FallowConfig::locate_list_entry(&path, "ignoreFindings", "src/old/**")
185            .expect("entry is found");
186
187        assert_eq!(span.path, canonical(&path));
188    }
189
190    #[test]
191    fn a_later_extends_target_overrides_an_earlier_one() {
192        let dir = tempfile::tempdir().expect("tempdir");
193        std::fs::write(dir.path().join("a.json"), r#"{"ignoreFindings": ["x/**"]}"#)
194            .expect("write a");
195        let second = dir.path().join("b.json");
196        std::fs::write(&second, r#"{"ignoreFindings": ["x/**"]}"#).expect("write b");
197        let path = dir.path().join(".fallowrc.json");
198        std::fs::write(&path, r#"{"extends": ["./a.json", "./b.json"]}"#).expect("write config");
199
200        let span = FallowConfig::locate_list_entry(&path, "ignoreFindings", "x/**").expect("entry");
201
202        assert_eq!(span.path, canonical(&second));
203    }
204
205    #[test]
206    fn a_reused_extends_target_brings_its_own_extends_chain_again() {
207        let dir = tempfile::tempdir().expect("tempdir");
208        let deepest = dir.path().join("e.json");
209        std::fs::write(&deepest, r#"{"ignoreFindings": ["x/**"]}"#).expect("write e");
210        std::fs::write(dir.path().join("d.json"), r#"{"extends": ["./e.json"]}"#).expect("write d");
211        std::fs::write(dir.path().join("f.json"), r#"{"ignoreFindings": ["x/**"]}"#)
212            .expect("write f");
213        std::fs::write(dir.path().join("g.json"), r#"{"extends": ["./d.json"]}"#).expect("write g");
214        let path = dir.path().join(".fallowrc.json");
215        std::fs::write(
216            &path,
217            r#"{"extends": ["./d.json", "./f.json", "./g.json"]}"#,
218        )
219        .expect("write config");
220
221        let span = FallowConfig::locate_list_entry(&path, "ignoreFindings", "x/**").expect("entry");
222
223        assert_eq!(span.path, canonical(&deepest));
224    }
225
226    #[test]
227    fn a_remote_source_after_the_local_ones_gives_no_location() {
228        let dir = tempfile::tempdir().expect("tempdir");
229        let path = dir.path().join(".fallowrc.json");
230        std::fs::write(&path, r#"{"extends": ["https://example.com/fallow.json"]}"#)
231            .expect("write config");
232
233        assert_eq!(
234            FallowConfig::locate_list_entry(&path, "ignoreFindings", "x/**"),
235            None
236        );
237    }
238
239    #[test]
240    fn a_byte_order_mark_does_not_shift_the_range() {
241        let dir = tempfile::tempdir().expect("tempdir");
242        let path = dir.path().join(".fallowrc.json");
243        std::fs::write(&path, "\u{FEFF}{\"ignoreFindings\": [\"a/**\"]}").expect("write config");
244
245        let span = FallowConfig::locate_list_entry(&path, "ignoreFindings", "a/**").expect("entry");
246
247        assert_eq!(located_text(&span), "\"a/**\"");
248    }
249
250    #[test]
251    fn an_entry_that_is_not_in_the_list_gives_no_location() {
252        let dir = tempfile::tempdir().expect("tempdir");
253        let path = dir.path().join(".fallowrc.json");
254        std::fs::write(&path, r#"{"ignoreFindings": ["a/**"]}"#).expect("write config");
255
256        assert_eq!(
257            FallowConfig::locate_list_entry(&path, "ignoreFindings", "b/**"),
258            None
259        );
260        assert_eq!(
261            FallowConfig::locate_list_entry(&path, "ignoreDependencies", "a/**"),
262            None
263        );
264    }
265}