Skip to main content

cabalist_parser/
validate.rs

1//! Validation of a parsed `.cabal` file against the Cabal specification.
2//!
3//! These are **objective spec violations**, not opinionated lints. Opinionated
4//! checks belong in the separate `cabalist-opinions` crate.
5
6use std::collections::{HashMap, HashSet};
7
8use crate::cst::{CabalCst, CstNodeKind};
9use crate::diagnostic::Diagnostic;
10use crate::span::{NodeId, Span};
11
12/// Validate a parsed CST against the `.cabal` specification.
13/// Returns diagnostics for any spec violations found.
14pub fn validate(cst: &CabalCst) -> Vec<Diagnostic> {
15    let mut diagnostics = Vec::new();
16
17    let ctx = ValidationContext::collect(cst);
18
19    check_required_fields(cst, &ctx, &mut diagnostics);
20    check_cabal_version_first(cst, &ctx, &mut diagnostics);
21    check_cabal_version_value(cst, &ctx, &mut diagnostics);
22    check_duplicate_top_level_fields(cst, &ctx, &mut diagnostics);
23    check_duplicate_section_fields(cst, &ctx, &mut diagnostics);
24    check_duplicate_sections(cst, &ctx, &mut diagnostics);
25    check_import_references(cst, &ctx, &mut diagnostics);
26    check_build_type(cst, &ctx, &mut diagnostics);
27    check_library_exposed_modules(cst, &ctx, &mut diagnostics);
28
29    diagnostics
30}
31
32// ---------------------------------------------------------------------------
33// Field name canonicalization
34// ---------------------------------------------------------------------------
35
36/// Canonicalize a field name: lowercase, replace underscores with hyphens.
37fn canonicalize_field_name(name: &str) -> String {
38    name.to_ascii_lowercase().replace('_', "-")
39}
40
41// ---------------------------------------------------------------------------
42// Helper: extract field value text from a CST node
43// ---------------------------------------------------------------------------
44
45/// Get the trimmed text of a field's value from the CST.
46fn get_field_value(cst: &CabalCst, node_id: NodeId) -> Option<&str> {
47    let node = cst.node(node_id);
48    node.field_value.map(|span| span.slice(&cst.source).trim())
49}
50
51// ---------------------------------------------------------------------------
52// Collected information about the CST for validation
53// ---------------------------------------------------------------------------
54
55/// A field occurrence: its canonical name, the span of the field name, and the
56/// node id.
57#[derive(Debug)]
58struct FieldInfo {
59    canonical_name: String,
60    name_span: Span,
61    node_id: NodeId,
62}
63
64/// A section occurrence: its keyword, optional argument (name), and the node id.
65#[derive(Debug)]
66struct SectionInfo {
67    keyword: String,
68    arg: Option<String>,
69    keyword_span: Span,
70    node_id: NodeId,
71}
72
73/// Pre-collected information from a single CST walk, used by all validation
74/// checks.
75#[derive(Debug)]
76struct ValidationContext {
77    /// Top-level fields (direct children of Root that are Field nodes).
78    top_level_fields: Vec<FieldInfo>,
79    /// Top-level sections (direct children of Root that are Section nodes).
80    sections: Vec<SectionInfo>,
81    /// Names of all `common` stanzas.
82    common_stanza_names: HashSet<String>,
83    /// All `import:` directives: (value text, span of the value, node id).
84    imports: Vec<(String, Span, NodeId)>,
85    /// Per-section field lists: section NodeId -> Vec<FieldInfo>.
86    section_fields: HashMap<usize, Vec<FieldInfo>>,
87}
88
89impl ValidationContext {
90    /// Walk the CST once and collect all information needed for validation.
91    fn collect(cst: &CabalCst) -> Self {
92        let mut ctx = ValidationContext {
93            top_level_fields: Vec::new(),
94            sections: Vec::new(),
95            common_stanza_names: HashSet::new(),
96            imports: Vec::new(),
97            section_fields: HashMap::new(),
98        };
99
100        let root = cst.node(cst.root);
101        for &child_id in &root.children {
102            let child = cst.node(child_id);
103            match child.kind {
104                CstNodeKind::Field => {
105                    if let Some(name_span) = child.field_name {
106                        ctx.top_level_fields.push(FieldInfo {
107                            canonical_name: canonicalize_field_name(name_span.slice(&cst.source)),
108                            name_span,
109                            node_id: child_id,
110                        });
111                    }
112                }
113                CstNodeKind::Section => {
114                    let keyword = child
115                        .section_keyword
116                        .map(|s| s.slice(&cst.source).to_ascii_lowercase())
117                        .unwrap_or_default();
118                    let arg = child.section_arg.map(|s| s.slice(&cst.source).to_string());
119
120                    let keyword_span = child.section_keyword.unwrap_or(child.content_span);
121
122                    if keyword == "common" {
123                        if let Some(ref name) = arg {
124                            ctx.common_stanza_names.insert(name.clone());
125                        }
126                    }
127
128                    ctx.sections.push(SectionInfo {
129                        keyword: keyword.clone(),
130                        arg,
131                        keyword_span,
132                        node_id: child_id,
133                    });
134
135                    // Collect fields and imports within this section.
136                    ctx.collect_section_children(cst, child_id);
137                }
138                _ => {}
139            }
140        }
141
142        ctx
143    }
144
145    /// Recursively collect fields and imports from a section (and its
146    /// conditionals).
147    fn collect_section_children(&mut self, cst: &CabalCst, section_id: NodeId) {
148        let section = cst.node(section_id);
149        for &child_id in &section.children {
150            let child = cst.node(child_id);
151            match child.kind {
152                CstNodeKind::Field => {
153                    if let Some(name_span) = child.field_name {
154                        self.section_fields
155                            .entry(section_id.0)
156                            .or_default()
157                            .push(FieldInfo {
158                                canonical_name: canonicalize_field_name(
159                                    name_span.slice(&cst.source),
160                                ),
161                                name_span,
162                                node_id: child_id,
163                            });
164                    }
165                }
166                CstNodeKind::Import => {
167                    if let Some(val_span) = child.field_value {
168                        let value = val_span.slice(&cst.source).trim().to_string();
169                        self.imports.push((value, val_span, child_id));
170                    }
171                }
172                CstNodeKind::Section => {
173                    // The parser may nest top-level sections inside each other
174                    // when they're at indent 0 (a known parser quirk). Treat
175                    // nested Section nodes as additional top-level sections.
176                    let keyword = child
177                        .section_keyword
178                        .map(|s| s.slice(&cst.source).to_ascii_lowercase())
179                        .unwrap_or_default();
180                    let arg = child.section_arg.map(|s| s.slice(&cst.source).to_string());
181                    let keyword_span = child.section_keyword.unwrap_or(child.content_span);
182
183                    if keyword == "common" {
184                        if let Some(ref name) = arg {
185                            self.common_stanza_names.insert(name.clone());
186                        }
187                    }
188
189                    self.sections.push(SectionInfo {
190                        keyword,
191                        arg,
192                        keyword_span,
193                        node_id: child_id,
194                    });
195
196                    // Recurse into this nested section's children.
197                    self.collect_section_children(cst, child_id);
198                }
199                CstNodeKind::Conditional => {
200                    // Recurse into the then-block children.
201                    self.collect_conditional_children(cst, child_id);
202                }
203                _ => {}
204            }
205        }
206    }
207
208    /// Collect fields and imports from conditional blocks (then + else).
209    fn collect_conditional_children(&mut self, cst: &CabalCst, cond_id: NodeId) {
210        let cond = cst.node(cond_id);
211        for &child_id in &cond.children {
212            let child = cst.node(child_id);
213            match child.kind {
214                CstNodeKind::Field => {
215                    // Fields in conditionals don't count for duplicate-field
216                    // checks at the section level (they're conditional), but
217                    // we still need to track imports.
218                }
219                CstNodeKind::Import => {
220                    if let Some(val_span) = child.field_value {
221                        let value = val_span.slice(&cst.source).trim().to_string();
222                        self.imports.push((value, val_span, child_id));
223                    }
224                }
225                CstNodeKind::ElseBlock => {
226                    // Recurse into else block children.
227                    let else_node = cst.node(child_id);
228                    for &else_child_id in &else_node.children {
229                        let else_child = cst.node(else_child_id);
230                        if else_child.kind == CstNodeKind::Import {
231                            if let Some(val_span) = else_child.field_value {
232                                let value = val_span.slice(&cst.source).trim().to_string();
233                                self.imports.push((value, val_span, else_child_id));
234                            }
235                        }
236                    }
237                }
238                CstNodeKind::Conditional => {
239                    self.collect_conditional_children(cst, child_id);
240                }
241                _ => {}
242            }
243        }
244    }
245}
246
247// ---------------------------------------------------------------------------
248// Validation checks
249// ---------------------------------------------------------------------------
250
251/// Check that `name`, `version`, and `cabal-version` fields exist at the top
252/// level.
253fn check_required_fields(
254    _cst: &CabalCst,
255    ctx: &ValidationContext,
256    diagnostics: &mut Vec<Diagnostic>,
257) {
258    let required = ["cabal-version", "name", "version"];
259    for &field_name in &required {
260        let found = ctx
261            .top_level_fields
262            .iter()
263            .any(|f| f.canonical_name == field_name);
264        if !found {
265            // Use a zero-span at the start of the file since the field is missing.
266            diagnostics.push(Diagnostic::error(
267                Span::new(0, 0),
268                format!("missing required field: `{field_name}`"),
269            ));
270        }
271    }
272}
273
274/// Check that `cabal-version` is the first non-comment, non-blank field.
275fn check_cabal_version_first(
276    cst: &CabalCst,
277    ctx: &ValidationContext,
278    diagnostics: &mut Vec<Diagnostic>,
279) {
280    // Find the first top-level Field node in the CST (skipping comments and
281    // blank lines).
282    let root = cst.node(cst.root);
283    let first_field_id = root.children.iter().find(|&&child_id| {
284        let child = cst.node(child_id);
285        child.kind == CstNodeKind::Field
286    });
287
288    let Some(&first_id) = first_field_id else {
289        return; // No fields at all; check_required_fields handles that.
290    };
291
292    let first_node = cst.node(first_id);
293    let Some(name_span) = first_node.field_name else {
294        return;
295    };
296
297    let name = canonicalize_field_name(name_span.slice(&cst.source));
298    if name != "cabal-version" {
299        // Find the cabal-version field to point the diagnostic at it.
300        if let Some(cv) = ctx
301            .top_level_fields
302            .iter()
303            .find(|f| f.canonical_name == "cabal-version")
304        {
305            diagnostics.push(Diagnostic::warning(
306                cv.name_span,
307                "`cabal-version` should be the first field in the file",
308            ));
309        }
310    }
311}
312
313/// Check that the `cabal-version` value is a recognized format.
314fn check_cabal_version_value(
315    cst: &CabalCst,
316    ctx: &ValidationContext,
317    diagnostics: &mut Vec<Diagnostic>,
318) {
319    let Some(cv_field) = ctx
320        .top_level_fields
321        .iter()
322        .find(|f| f.canonical_name == "cabal-version")
323    else {
324        return; // Missing field is handled by check_required_fields.
325    };
326
327    let Some(raw_value) = get_field_value(cst, cv_field.node_id) else {
328        return;
329    };
330
331    // Strip the deprecated `>=` prefix if present.
332    let version_str = raw_value.strip_prefix(">=").unwrap_or(raw_value).trim();
333
334    // A cabal-version value should look like a version number: digits and dots.
335    let is_valid_version =
336        !version_str.is_empty() && version_str.chars().all(|c| c.is_ascii_digit() || c == '.');
337
338    if !is_valid_version {
339        let val_span = cst
340            .node(cv_field.node_id)
341            .field_value
342            .unwrap_or(cv_field.name_span);
343        diagnostics.push(Diagnostic::warning(
344            val_span,
345            format!("unrecognized `cabal-version` value: `{raw_value}`"),
346        ));
347    }
348}
349
350/// Check for duplicate field names at the top level.
351fn check_duplicate_top_level_fields(
352    _cst: &CabalCst,
353    ctx: &ValidationContext,
354    diagnostics: &mut Vec<Diagnostic>,
355) {
356    check_duplicates_in_field_list(&ctx.top_level_fields, diagnostics);
357}
358
359/// Check for duplicate field names within each section.
360fn check_duplicate_section_fields(
361    _cst: &CabalCst,
362    ctx: &ValidationContext,
363    diagnostics: &mut Vec<Diagnostic>,
364) {
365    for fields in ctx.section_fields.values() {
366        check_duplicates_in_field_list(fields, diagnostics);
367    }
368}
369
370/// Fields that can legitimately appear multiple times in a section.
371/// These are list-valued fields where cabal merges all occurrences.
372const REPEATABLE_FIELDS: &[&str] = &[
373    "build-depends",
374    "exposed-modules",
375    "other-modules",
376    "default-extensions",
377    "other-extensions",
378    "ghc-options",
379    "ghc-prof-options",
380    "ghc-shared-options",
381    "pkgconfig-depends",
382    "extra-libraries",
383    "extra-lib-dirs",
384    "extra-framework-dirs",
385    "frameworks",
386    "build-tool-depends",
387    "build-tools",
388    "mixins",
389    "hs-source-dirs",
390    "includes",
391    "include-dirs",
392    "c-sources",
393    "cxx-sources",
394    "js-sources",
395    "extra-ghci-libraries",
396    "extra-bundled-libraries",
397    "autogen-modules",
398    "virtual-modules",
399    "reexported-modules",
400    "signatures",
401];
402
403/// Helper: find duplicates in a list of fields and emit warnings.
404fn check_duplicates_in_field_list(fields: &[FieldInfo], diagnostics: &mut Vec<Diagnostic>) {
405    let mut seen: HashMap<&str, Span> = HashMap::new();
406    for field in fields {
407        if REPEATABLE_FIELDS.contains(&field.canonical_name.as_str()) {
408            continue;
409        }
410        if let Some(&first_span) = seen.get(field.canonical_name.as_str()) {
411            let _ = first_span; // We point at the duplicate, not the first.
412            diagnostics.push(Diagnostic::warning(
413                field.name_span,
414                format!("duplicate field: `{}`", field.canonical_name),
415            ));
416        } else {
417            seen.insert(&field.canonical_name, field.name_span);
418        }
419    }
420}
421
422/// Check for duplicate section names (e.g. two `executable foo`).
423fn check_duplicate_sections(
424    _cst: &CabalCst,
425    ctx: &ValidationContext,
426    diagnostics: &mut Vec<Diagnostic>,
427) {
428    // Key: (keyword, optional arg). For unnamed sections like `library`, the
429    // arg is None.
430    let mut seen: HashMap<(String, Option<String>), Span> = HashMap::new();
431    for section in &ctx.sections {
432        let key = (section.keyword.clone(), section.arg.clone());
433        if let Some(&_first_span) = seen.get(&key) {
434            let label = match &section.arg {
435                Some(arg) => format!("`{} {}`", section.keyword, arg),
436                None => format!("`{}`", section.keyword),
437            };
438            diagnostics.push(Diagnostic::error(
439                section.keyword_span,
440                format!("duplicate section: {label}"),
441            ));
442        } else {
443            seen.insert(key, section.keyword_span);
444        }
445    }
446}
447
448/// Check that all `import:` directives reference an existing `common` stanza.
449fn check_import_references(
450    _cst: &CabalCst,
451    ctx: &ValidationContext,
452    diagnostics: &mut Vec<Diagnostic>,
453) {
454    for (value, val_span, _node_id) in &ctx.imports {
455        // Imports can be comma-separated (e.g., `import: foo, bar`).
456        for stanza_name in value.split(',') {
457            let stanza_name = stanza_name.trim();
458            if !stanza_name.is_empty() && !ctx.common_stanza_names.contains(stanza_name) {
459                diagnostics.push(Diagnostic::error(
460                    *val_span,
461                    format!("import references undefined common stanza: `{stanza_name}`"),
462                ));
463            }
464        }
465    }
466}
467
468/// Check that `build-type` (if present) is one of the valid values.
469fn check_build_type(cst: &CabalCst, ctx: &ValidationContext, diagnostics: &mut Vec<Diagnostic>) {
470    let Some(bt_field) = ctx
471        .top_level_fields
472        .iter()
473        .find(|f| f.canonical_name == "build-type")
474    else {
475        return;
476    };
477
478    let Some(value) = get_field_value(cst, bt_field.node_id) else {
479        return;
480    };
481
482    const VALID_BUILD_TYPES: &[&str] = &["Simple", "Configure", "Make", "Custom"];
483    if !VALID_BUILD_TYPES.contains(&value) {
484        let val_span = cst
485            .node(bt_field.node_id)
486            .field_value
487            .unwrap_or(bt_field.name_span);
488        diagnostics.push(Diagnostic::error(
489            val_span,
490            format!(
491                "invalid `build-type` value: `{value}` \
492                 (expected one of: Simple, Configure, Make, Custom)"
493            ),
494        ));
495    }
496}
497
498/// Check that library sections have `exposed-modules` with at least one
499/// module.
500fn check_library_exposed_modules(
501    cst: &CabalCst,
502    ctx: &ValidationContext,
503    diagnostics: &mut Vec<Diagnostic>,
504) {
505    for section in &ctx.sections {
506        if section.keyword != "library" {
507            continue;
508        }
509
510        let fields = ctx.section_fields.get(&section.node_id.0);
511
512        let has_exposed_modules = fields
513            .map(|fs| {
514                fs.iter().any(|f| {
515                    if f.canonical_name != "exposed-modules" {
516                        return false;
517                    }
518                    // Check if the field has a non-empty value (inline or
519                    // via continuation lines).
520                    let node = cst.node(f.node_id);
521                    let has_inline_value = node
522                        .field_value
523                        .map(|s| !s.slice(&cst.source).trim().is_empty())
524                        .unwrap_or(false);
525                    let has_continuation = !node.children.is_empty();
526                    has_inline_value || has_continuation
527                })
528            })
529            .unwrap_or(false);
530
531        if !has_exposed_modules {
532            diagnostics.push(Diagnostic::warning(
533                section.keyword_span,
534                "library section has no `exposed-modules` \
535                 (or it is empty)",
536            ));
537        }
538    }
539}
540
541// ---------------------------------------------------------------------------
542// Tests
543// ---------------------------------------------------------------------------
544
545#[cfg(test)]
546mod tests {
547    use super::*;
548    use crate::parse::parse;
549
550    /// Helper: parse source and validate, returning diagnostics.
551    fn validate_source(source: &str) -> Vec<Diagnostic> {
552        let result = parse(source);
553        validate(&result.cst)
554    }
555
556    /// Helper: check that a specific message substring appears among
557    /// diagnostics.
558    fn has_diagnostic(diagnostics: &[Diagnostic], substring: &str) -> bool {
559        diagnostics.iter().any(|d| d.message.contains(substring))
560    }
561
562    // -- Required fields ---------------------------------------------------
563
564    #[test]
565    fn missing_name_field() {
566        let diags = validate_source("cabal-version: 3.0\nversion: 0.1.0.0\n");
567        assert!(has_diagnostic(&diags, "missing required field: `name`"));
568    }
569
570    #[test]
571    fn missing_version_field() {
572        let diags = validate_source("cabal-version: 3.0\nname: foo\n");
573        assert!(has_diagnostic(&diags, "missing required field: `version`"));
574    }
575
576    #[test]
577    fn missing_cabal_version_field() {
578        let diags = validate_source("name: foo\nversion: 0.1.0.0\n");
579        assert!(has_diagnostic(
580            &diags,
581            "missing required field: `cabal-version`"
582        ));
583    }
584
585    #[test]
586    fn all_required_fields_present() {
587        let diags = validate_source("cabal-version: 3.0\nname: foo\nversion: 0.1.0.0\n");
588        assert!(
589            !has_diagnostic(&diags, "missing required field"),
590            "unexpected: {diags:?}"
591        );
592    }
593
594    // -- cabal-version first ------------------------------------------------
595
596    #[test]
597    fn cabal_version_not_first() {
598        let diags = validate_source("name: foo\ncabal-version: 3.0\nversion: 0.1.0.0\n");
599        assert!(has_diagnostic(
600            &diags,
601            "`cabal-version` should be the first field"
602        ));
603    }
604
605    #[test]
606    fn cabal_version_is_first() {
607        let diags = validate_source("cabal-version: 3.0\nname: foo\nversion: 0.1.0.0\n");
608        assert!(
609            !has_diagnostic(&diags, "should be the first field"),
610            "unexpected: {diags:?}"
611        );
612    }
613
614    #[test]
615    fn cabal_version_first_after_comments() {
616        // Comments before cabal-version are fine.
617        let diags =
618            validate_source("-- A top comment\ncabal-version: 3.0\nname: foo\nversion: 0.1.0.0\n");
619        assert!(
620            !has_diagnostic(&diags, "should be the first field"),
621            "unexpected: {diags:?}"
622        );
623    }
624
625    // -- Duplicate fields ---------------------------------------------------
626
627    #[test]
628    fn duplicate_top_level_field() {
629        let diags = validate_source("cabal-version: 3.0\nname: foo\nname: bar\nversion: 0.1.0.0\n");
630        assert!(has_diagnostic(&diags, "duplicate field: `name`"));
631    }
632
633    #[test]
634    fn duplicate_field_in_section() {
635        let src = "\
636cabal-version: 3.0
637name: foo
638version: 0.1.0.0
639
640library
641  exposed-modules: Foo
642  default-language: Haskell2010
643  default-language: GHC2021
644";
645        let diags = validate_source(src);
646        assert!(has_diagnostic(
647            &diags,
648            "duplicate field: `default-language`"
649        ));
650    }
651
652    #[test]
653    fn repeatable_field_not_flagged_as_duplicate() {
654        let src = "\
655cabal-version: 3.0
656name: foo
657version: 0.1.0.0
658
659library
660  exposed-modules: Foo
661  build-depends: base
662  build-depends: text
663";
664        let diags = validate_source(src);
665        assert!(
666            !has_diagnostic(&diags, "duplicate field"),
667            "build-depends should be allowed multiple times: {diags:?}"
668        );
669    }
670
671    #[test]
672    fn same_field_different_sections_is_ok() {
673        let src = "\
674cabal-version: 3.0
675name: foo
676version: 0.1.0.0
677
678library
679  exposed-modules: Foo
680  build-depends: base
681
682executable bar
683  main-is: Main.hs
684  build-depends: base
685";
686        let diags = validate_source(src);
687        assert!(
688            !has_diagnostic(&diags, "duplicate field"),
689            "unexpected: {diags:?}"
690        );
691    }
692
693    // -- Duplicate sections -------------------------------------------------
694
695    #[test]
696    fn duplicate_executable_sections() {
697        let src = "\
698cabal-version: 3.0
699name: foo
700version: 0.1.0.0
701
702executable bar
703  main-is: Main.hs
704
705executable bar
706  main-is: Other.hs
707";
708        let diags = validate_source(src);
709        assert!(has_diagnostic(
710            &diags,
711            "duplicate section: `executable bar`"
712        ));
713    }
714
715    #[test]
716    fn duplicate_unnamed_library() {
717        let src = "\
718cabal-version: 3.0
719name: foo
720version: 0.1.0.0
721
722library
723  exposed-modules: Foo
724
725library
726  exposed-modules: Bar
727";
728        let diags = validate_source(src);
729        assert!(has_diagnostic(&diags, "duplicate section: `library`"));
730    }
731
732    #[test]
733    fn different_executable_names_is_ok() {
734        let src = "\
735cabal-version: 3.0
736name: foo
737version: 0.1.0.0
738
739executable bar
740  main-is: Main.hs
741
742executable baz
743  main-is: Other.hs
744";
745        let diags = validate_source(src);
746        assert!(
747            !has_diagnostic(&diags, "duplicate section"),
748            "unexpected: {diags:?}"
749        );
750    }
751
752    // -- Import references --------------------------------------------------
753
754    #[test]
755    fn import_missing_common_stanza() {
756        let src = "\
757cabal-version: 3.0
758name: foo
759version: 0.1.0.0
760
761library
762  import: warnings
763  exposed-modules: Foo
764";
765        let diags = validate_source(src);
766        assert!(has_diagnostic(
767            &diags,
768            "import references undefined common stanza: `warnings`"
769        ));
770    }
771
772    #[test]
773    fn import_with_existing_common_stanza() {
774        let src = "\
775cabal-version: 3.0
776name: foo
777version: 0.1.0.0
778
779common warnings
780  ghc-options: -Wall
781
782library
783  import: warnings
784  exposed-modules: Foo
785";
786        let diags = validate_source(src);
787        assert!(
788            !has_diagnostic(&diags, "import references undefined"),
789            "unexpected: {diags:?}"
790        );
791    }
792
793    // -- build-type ---------------------------------------------------------
794
795    #[test]
796    fn valid_build_type_simple() {
797        let diags = validate_source(
798            "cabal-version: 3.0\nname: foo\nversion: 0.1.0.0\nbuild-type: Simple\n",
799        );
800        assert!(
801            !has_diagnostic(&diags, "invalid `build-type`"),
802            "unexpected: {diags:?}"
803        );
804    }
805
806    #[test]
807    fn invalid_build_type() {
808        let diags =
809            validate_source("cabal-version: 3.0\nname: foo\nversion: 0.1.0.0\nbuild-type: Foo\n");
810        assert!(has_diagnostic(&diags, "invalid `build-type` value: `Foo`"));
811    }
812
813    // -- Library exposed-modules --------------------------------------------
814
815    #[test]
816    fn library_without_exposed_modules() {
817        let src = "\
818cabal-version: 3.0
819name: foo
820version: 0.1.0.0
821
822library
823  build-depends: base
824";
825        let diags = validate_source(src);
826        assert!(has_diagnostic(
827            &diags,
828            "library section has no `exposed-modules`"
829        ));
830    }
831
832    #[test]
833    fn library_with_exposed_modules() {
834        let src = "\
835cabal-version: 3.0
836name: foo
837version: 0.1.0.0
838
839library
840  exposed-modules: Foo
841  build-depends: base
842";
843        let diags = validate_source(src);
844        assert!(
845            !has_diagnostic(&diags, "exposed-modules"),
846            "unexpected: {diags:?}"
847        );
848    }
849
850    #[test]
851    fn library_with_multiline_exposed_modules() {
852        let src = "\
853cabal-version: 3.0
854name: foo
855version: 0.1.0.0
856
857library
858  exposed-modules:
859    Foo
860    Bar
861  build-depends: base
862";
863        let diags = validate_source(src);
864        assert!(
865            !has_diagnostic(&diags, "exposed-modules"),
866            "unexpected: {diags:?}"
867        );
868    }
869
870    // -- cabal-version value ------------------------------------------------
871
872    #[test]
873    fn cabal_version_valid_value() {
874        let diags = validate_source("cabal-version: 3.0\nname: foo\nversion: 0.1.0.0\n");
875        assert!(
876            !has_diagnostic(&diags, "unrecognized `cabal-version`"),
877            "unexpected: {diags:?}"
878        );
879    }
880
881    #[test]
882    fn cabal_version_deprecated_prefix() {
883        // The `>=` prefix is deprecated but the version itself is valid; no warning.
884        let diags = validate_source("cabal-version: >=1.10\nname: foo\nversion: 0.1.0.0\n");
885        assert!(
886            !has_diagnostic(&diags, "unrecognized `cabal-version`"),
887            "unexpected: {diags:?}"
888        );
889    }
890
891    #[test]
892    fn cabal_version_invalid_value() {
893        let diags = validate_source("cabal-version: foobar\nname: foo\nversion: 0.1.0.0\n");
894        assert!(has_diagnostic(&diags, "unrecognized `cabal-version` value"));
895    }
896
897    // -- Full valid file (zero diagnostics) ---------------------------------
898
899    #[test]
900    fn full_valid_file_no_diagnostics() {
901        let src = "\
902cabal-version: 3.0
903name: foo
904version: 0.1.0.0
905synopsis: A test package
906build-type: Simple
907
908common warnings
909  ghc-options: -Wall
910
911library
912  import: warnings
913  exposed-modules: Foo
914  build-depends: base >=4.14
915
916executable my-exe
917  import: warnings
918  main-is: Main.hs
919  build-depends: base, foo
920
921test-suite tests
922  import: warnings
923  type: exitcode-stdio-1.0
924  main-is: Main.hs
925  build-depends: base, foo, tasty
926";
927        let diags = validate_source(src);
928        assert!(diags.is_empty(), "expected no diagnostics, got: {diags:?}");
929    }
930
931    // -- Case/underscore insensitivity for duplicate detection ---------------
932
933    #[test]
934    fn duplicate_field_case_insensitive() {
935        let diags = validate_source("cabal-version: 3.0\nName: foo\nname: bar\nversion: 0.1.0.0\n");
936        assert!(has_diagnostic(&diags, "duplicate field: `name`"));
937    }
938
939    #[test]
940    fn duplicate_field_underscore_hyphen() {
941        // Use a non-repeatable field to test underscore/hyphen normalization.
942        let src = "\
943cabal-version: 3.0
944name: foo
945version: 0.1.0.0
946
947library
948  exposed-modules: Foo
949  default-language: Haskell2010
950  default_language: GHC2021
951";
952        let diags = validate_source(src);
953        assert!(has_diagnostic(
954            &diags,
955            "duplicate field: `default-language`"
956        ));
957    }
958
959    // -- Edge cases ---------------------------------------------------------
960
961    #[test]
962    fn empty_file() {
963        let diags = validate_source("");
964        // Should report all three missing required fields.
965        assert!(has_diagnostic(
966            &diags,
967            "missing required field: `cabal-version`"
968        ));
969        assert!(has_diagnostic(&diags, "missing required field: `name`"));
970        assert!(has_diagnostic(&diags, "missing required field: `version`"));
971    }
972
973    #[test]
974    fn comments_only_file() {
975        let diags = validate_source("-- just a comment\n");
976        assert!(has_diagnostic(
977            &diags,
978            "missing required field: `cabal-version`"
979        ));
980        assert!(has_diagnostic(&diags, "missing required field: `name`"));
981        assert!(has_diagnostic(&diags, "missing required field: `version`"));
982    }
983}