1use std::path::{Path, PathBuf};
8
9use super::FallowConfig;
10use super::parsing::{ConfigFormat, parse_config_to_value};
11
12#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct ConfigEntrySpan {
15 pub path: PathBuf,
18 pub start: usize,
20 pub end: usize,
22}
23
24impl FallowConfig {
25 #[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
59fn 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}