1pub mod error;
13pub(crate) mod follows;
14mod syntax;
15
16pub use error::{DuplicateAttr, Location, Severity, ValidationError, ValidationResult};
17
18pub(crate) use syntax::ParsedSource;
19
20use crate::edit::InputMap;
21use crate::follows::{DEFAULT_MAX_DEPTH, FollowsGraph};
22use crate::lock::{FlakeLock, NestedInput};
23
24pub fn validate(source: &str) -> ValidationResult {
27 let parsed = ParsedSource::new(source);
28 validate_parsed(&parsed)
29}
30
31pub(crate) fn validate_parsed(parsed: &ParsedSource) -> ValidationResult {
34 let mut errors: Vec<ValidationError> = Vec::new();
35 syntax::collect_with_parsed(parsed, &mut errors);
36 if errors.is_empty() {
37 let mut walker = crate::walk::Walker::from_root(parsed.syntax.clone());
38 if walker.walk(&crate::change::Change::None).is_ok() {
39 let graph = crate::follows::FollowsGraph::from_declared(&walker.inputs);
40 let offset_to_location = |offset: usize| parsed.line_map.offset_to_location(offset);
41 errors.extend(follows::lint_follows_cycle(&graph, &offset_to_location));
42 }
43 }
44 ValidationResult {
45 errors,
46 warnings: Vec::new(),
47 }
48}
49
50pub fn validate_full(
56 source: &str,
57 inputs: &InputMap,
58 lock: Option<&FlakeLock>,
59) -> ValidationResult {
60 let parsed = ParsedSource::new(source);
61 let nested_inputs = lock.map(FlakeLock::nested_inputs);
62 let lock_graph = nested_inputs
63 .as_deref()
64 .map(FollowsGraph::from_nested_inputs);
65 validate_full_with_lock_graph(
66 &parsed,
67 inputs,
68 lock_graph.as_ref(),
69 nested_inputs.as_deref().unwrap_or(&[]),
70 )
71}
72
73pub fn validate_speculative(
82 source: &str,
83 inputs: &InputMap,
84 lock: Option<&FlakeLock>,
85) -> ValidationResult {
86 let parsed = ParsedSource::new(source);
87 let lock_graph = lock.map(FollowsGraph::from_lock);
88 validate_speculative_parsed(&parsed, inputs, lock_graph.as_ref())
89}
90
91pub(crate) fn validate_speculative_parsed(
102 parsed: &ParsedSource,
103 inputs: &InputMap,
104 lock_graph: Option<&FollowsGraph>,
105) -> ValidationResult {
106 let mut errors: Vec<ValidationError> = parsed.parse_errors.to_vec();
107 let mut warnings: Vec<ValidationError> = Vec::new();
108 let graph = follows::build_graph_with_lock_graph(inputs, lock_graph, DEFAULT_MAX_DEPTH);
109 run_follows_lints(parsed, inputs, &graph, None, &mut errors, &mut warnings);
110 ValidationResult { errors, warnings }
111}
112
113pub(crate) fn validate_full_with_lock_graph(
122 parsed: &ParsedSource,
123 inputs: &InputMap,
124 lock_graph: Option<&FollowsGraph>,
125 nested_inputs: &[NestedInput],
126) -> ValidationResult {
127 let mut errors: Vec<ValidationError> = Vec::new();
128 let mut warnings: Vec<ValidationError> = Vec::new();
129 syntax::collect_with_parsed(parsed, &mut errors);
130 let graph = follows::build_graph_with_lock_graph(inputs, lock_graph, DEFAULT_MAX_DEPTH);
131 let nested = lock_graph.is_some().then_some(nested_inputs);
132 run_follows_lints(parsed, inputs, &graph, nested, &mut errors, &mut warnings);
133 ValidationResult { errors, warnings }
134}
135
136fn run_follows_lints(
140 parsed: &ParsedSource,
141 inputs: &InputMap,
142 graph: &FollowsGraph,
143 nested_inputs: Option<&[NestedInput]>,
144 errors: &mut Vec<ValidationError>,
145 warnings: &mut Vec<ValidationError>,
146) {
147 let offset_to_location = |offset: usize| parsed.line_map.offset_to_location(offset);
148
149 let mut candidates: Vec<ValidationError> = Vec::new();
150 candidates.extend(follows::lint_follows_cycle(graph, &offset_to_location));
151 if let Some(nested) = nested_inputs {
152 candidates.extend(follows::lint_follows_stale(graph, &offset_to_location));
153 candidates.extend(follows::lint_follows_stale_lock(
154 graph,
155 nested,
156 &offset_to_location,
157 ));
158 }
159 let top_level = follows::top_level_names(inputs);
160 candidates.extend(follows::lint_follows_target_not_toplevel(
161 graph,
162 &top_level,
163 &offset_to_location,
164 ));
165 candidates.extend(follows::lint_follows_contradiction(
166 graph,
167 &offset_to_location,
168 ));
169 candidates.extend(follows::lint_follows_depth_exceeded(
170 graph,
171 DEFAULT_MAX_DEPTH,
172 &offset_to_location,
173 ));
174
175 for err in candidates {
176 match err.severity() {
177 Severity::Warning => warnings.push(err),
178 Severity::Error => errors.push(err),
179 }
180 }
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186 use crate::edit::InputMap;
187 use crate::follows::{AttrPath, Segment};
188 use crate::input::{Follows, Input, Range};
189 use crate::validate::error::DuplicateAttr;
190
191 fn expect_duplicate(err: &ValidationError) -> &DuplicateAttr {
192 match err {
193 ValidationError::DuplicateAttribute(dup) => dup,
194 other => panic!("expected DuplicateAttribute, got {other:?}"),
195 }
196 }
197
198 #[test]
199 fn simple_duplicate() {
200 let source = "{ a = 1; a = 2; }";
201 let result = validate(source);
202 assert!(result.has_errors());
203 assert_eq!(result.errors.len(), 1);
204
205 let dup = expect_duplicate(&result.errors[0]);
206 assert_eq!(dup.path, "a");
207 assert_eq!(dup.first.line, 1);
208 assert_eq!(dup.first.column, 3);
209 assert_eq!(dup.duplicate.line, 1);
210 assert_eq!(dup.duplicate.column, 10);
211 }
212
213 #[test]
214 fn nested_path_duplicate() {
215 let source = "{ a.b.c = 1; a.b.c = 2; }";
216 let result = validate(source);
217 assert!(result.has_errors());
218 assert_eq!(result.errors.len(), 1);
219
220 let dup = expect_duplicate(&result.errors[0]);
221 assert_eq!(dup.path, "a.b.c");
222 }
223
224 #[test]
225 fn different_paths_valid() {
226 let source = "{ a.b = 1; a.c = 2; }";
227 let result = validate(source);
228 assert!(result.is_ok());
229 }
230
231 #[test]
232 fn flake_style_duplicate() {
233 let source = r#"{ inputs.nixpkgs.url = "github:nixos/nixpkgs"; inputs.nixpkgs.url = "github:nixos/nixpkgs/unstable"; }"#;
234 let result = validate(source);
235 assert!(result.has_errors());
236 assert_eq!(result.errors.len(), 1);
237
238 let dup = expect_duplicate(&result.errors[0]);
239 assert_eq!(dup.path, "inputs.nixpkgs.url");
240 }
241
242 #[test]
243 fn quoted_attribute_duplicate() {
244 let source = r#"{ "a" = 1; a = 2; }"#;
245 let result = validate(source);
246 assert!(result.has_errors());
247 assert_eq!(result.errors.len(), 1);
248
249 let dup = expect_duplicate(&result.errors[0]);
250 assert_eq!(dup.path, "a");
251 }
252
253 #[test]
254 fn nested_attr_set_duplicate() {
255 let source = "{ outer = { inner = 1; inner = 2; }; }";
256 let result = validate(source);
257 assert!(result.has_errors());
258 assert_eq!(result.errors.len(), 1);
259
260 let dup = expect_duplicate(&result.errors[0]);
261 assert_eq!(dup.path, "inner");
262 }
263
264 #[test]
265 fn multiple_duplicates() {
266 let source = "{ a = 1; a = 2; b = 3; b = 4; }";
267 let result = validate(source);
268 assert!(result.has_errors());
269 assert_eq!(result.errors.len(), 2);
270 }
271
272 #[test]
273 fn multiline_flake() {
274 let source = r#"{
275 inputs.nixpkgs.url = "github:nixos/nixpkgs";
276 inputs.nixpkgs.url = "github:nixos/nixpkgs/unstable";
277 outputs = { ... }: { };
278}"#;
279 let result = validate(source);
280 assert!(result.has_errors());
281 assert_eq!(result.errors.len(), 1);
282
283 let dup = expect_duplicate(&result.errors[0]);
284 assert_eq!(dup.path, "inputs.nixpkgs.url");
285 assert_eq!(dup.first.line, 2);
286 assert_eq!(dup.duplicate.line, 3);
287 }
288
289 #[test]
290 fn valid_flake() {
291 let source = r#"{
292 inputs.nixpkgs.url = "github:nixos/nixpkgs";
293 inputs.flake-utils.url = "github:numtide/flake-utils";
294 outputs = { self, nixpkgs, flake-utils }: { };
295}"#;
296 let result = validate(source);
297 assert!(result.is_ok());
298 }
299
300 #[test]
301 fn empty_attr_set() {
302 let source = "{ }";
303 let result = validate(source);
304 assert!(result.is_ok());
305 }
306
307 #[test]
308 fn single_attribute() {
309 let source = "{ a = 1; }";
310 let result = validate(source);
311 assert!(result.is_ok());
312 }
313
314 #[test]
315 fn parse_error_missing_semicolon() {
316 let source = "{ a = 1 }";
317 let result = validate(source);
318 assert!(result.has_errors());
319 assert!(matches!(
320 &result.errors[0],
321 ValidationError::ParseError { .. }
322 ));
323 }
324
325 #[test]
326 fn parse_error_unclosed_brace() {
327 let source = "{ a = 1;";
328 let result = validate(source);
329 assert!(result.has_errors());
330 assert!(matches!(
331 &result.errors[0],
332 ValidationError::ParseError { .. }
333 ));
334 }
335
336 #[test]
337 fn mergeable_attrsets_valid() {
338 let source = r#"{
339 inputs = {
340 nixpkgs.url = "github:NixOS/nixpkgs";
341 };
342 inputs = {
343 flake-utils.url = "github:numtide/flake-utils";
344 };
345}"#;
346 let result = validate(source);
347 assert!(
348 result.is_ok(),
349 "expected no errors, got: {:?}",
350 result.errors
351 );
352 }
353
354 #[test]
355 fn mergeable_attrsets_with_comments() {
356 let source = r#"{
358 # Common inputs
359 inputs = {
360 nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
361 home-manager.url = "github:nix-community/home-manager";
362 };
363
364 # Autofirma sources
365 inputs = {
366 jmulticard-src = {
367 url = "github:ctt-gob-es/jmulticard/v2.0";
368 flake = false;
369 };
370 };
371
372 outputs = { self, nixpkgs, ... }: { };
373}"#;
374 let result = validate(source);
375 assert!(
376 result.is_ok(),
377 "expected no errors, got: {:?}",
378 result.errors
379 );
380 }
381
382 #[test]
383 fn mergeable_attrsets_cross_duplicate() {
384 let source = r#"{
385 inputs = {
386 nixpkgs.url = "github:NixOS/nixpkgs";
387 };
388 inputs = {
389 nixpkgs.url = "github:NixOS/nixpkgs/unstable";
390 };
391}"#;
392 let result = validate(source);
393 assert!(result.has_errors());
394 assert_eq!(result.errors.len(), 1);
395
396 let dup = expect_duplicate(&result.errors[0]);
397 assert_eq!(dup.path, "nixpkgs.url");
398 }
399
400 #[test]
401 fn non_attrset_duplicate_still_errors() {
402 let source = r#"{ a = { x = 1; }; a = 2; }"#;
403 let result = validate(source);
404 assert!(result.has_errors());
405 assert_eq!(result.errors.len(), 1);
406
407 let dup = expect_duplicate(&result.errors[0]);
408 assert_eq!(dup.path, "a");
409 }
410
411 #[test]
412 fn follows_cycle_self_edge_lints() {
413 let source = r#"{
414 inputs.foo = {
415 url = "github:owner/foo";
416 inputs.foo.follows = "foo/foo";
417 };
418 outputs = { ... }: { };
419}"#;
420 let result = validate(source);
421 assert!(
422 result
423 .errors
424 .iter()
425 .any(|e| matches!(e, ValidationError::FollowsCycle { .. })),
426 "expected FollowsCycle, got: {:?}",
427 result.errors,
428 );
429 }
430
431 #[test]
432 fn three_mergeable_attrsets() {
433 let source = r#"{
434 inputs = { a.url = "a"; };
435 inputs = { b.url = "b"; };
436 inputs = { c.url = "c"; };
437}"#;
438 let result = validate(source);
439 assert!(
440 result.is_ok(),
441 "expected no errors, got: {:?}",
442 result.errors
443 );
444 }
445
446 fn seg(s: &str) -> Segment {
447 Segment::from_unquoted(s).unwrap()
448 }
449
450 fn path(s: &str) -> AttrPath {
451 AttrPath::parse(s).unwrap()
452 }
453
454 fn declared_input(id: &str, follows: &[(&str, &str)]) -> Input {
455 let mut input = Input::new(seg(id));
456 for (parent, target) in follows {
457 input.follows.push(Follows::Indirect {
458 path: AttrPath::new(seg(parent)),
459 target: Some(path(target)),
460 });
461 }
462 input.range = Range { start: 1, end: 2 };
463 input
464 }
465
466 fn make_inputs(items: Vec<Input>) -> InputMap {
467 let mut map = InputMap::new();
468 for input in items {
469 map.insert(input.id().as_str().to_string(), input);
470 }
471 map
472 }
473
474 #[test]
475 fn validate_full_emits_target_not_toplevel_by_default() {
476 let inputs = make_inputs(vec![declared_input("a", &[("b", "missing")])]);
477 let result = validate_full("{}", &inputs, None);
478 assert!(
479 result
480 .errors
481 .iter()
482 .any(|e| matches!(e, ValidationError::FollowsTargetNotToplevel { .. })),
483 "expected target-not-toplevel error, got: {:?}",
484 result.errors,
485 );
486 }
487
488 #[test]
489 fn validate_full_separates_warnings_from_errors() {
490 let inputs = make_inputs(vec![declared_input("a", &[("b", "missing")])]);
493 let lock_text = r#"{
494 "nodes": {
495 "a": {
496 "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "abc", "type": "github" },
497 "original": { "owner": "o", "repo": "r", "type": "github" }
498 },
499 "root": { "inputs": { "a": "a" } }
500 },
501 "root": "root",
502 "version": 7
503}"#;
504 let lock = FlakeLock::read_from_str(lock_text).unwrap();
505 let result = validate_full("{}", &inputs, Some(&lock));
506 assert!(
507 result
508 .errors
509 .iter()
510 .any(|e| matches!(e, ValidationError::FollowsTargetNotToplevel { .. })),
511 );
512 assert!(
513 result
514 .warnings
515 .iter()
516 .any(|e| matches!(e, ValidationError::FollowsStale { .. })),
517 "expected at least one stale warning, got warnings: {:?}",
518 result.warnings,
519 );
520 }
521}