use std::collections::{HashMap, HashSet};
use crate::cst::{CabalCst, CstNodeKind};
use crate::diagnostic::Diagnostic;
use crate::span::{NodeId, Span};
pub fn validate(cst: &CabalCst) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
let ctx = ValidationContext::collect(cst);
check_required_fields(cst, &ctx, &mut diagnostics);
check_cabal_version_first(cst, &ctx, &mut diagnostics);
check_cabal_version_value(cst, &ctx, &mut diagnostics);
check_duplicate_top_level_fields(cst, &ctx, &mut diagnostics);
check_duplicate_section_fields(cst, &ctx, &mut diagnostics);
check_duplicate_sections(cst, &ctx, &mut diagnostics);
check_import_references(cst, &ctx, &mut diagnostics);
check_build_type(cst, &ctx, &mut diagnostics);
check_library_exposed_modules(cst, &ctx, &mut diagnostics);
diagnostics
}
fn canonicalize_field_name(name: &str) -> String {
name.to_ascii_lowercase().replace('_', "-")
}
fn get_field_value(cst: &CabalCst, node_id: NodeId) -> Option<&str> {
let node = cst.node(node_id);
node.field_value.map(|span| span.slice(&cst.source).trim())
}
#[derive(Debug)]
struct FieldInfo {
canonical_name: String,
name_span: Span,
node_id: NodeId,
}
#[derive(Debug)]
struct SectionInfo {
keyword: String,
arg: Option<String>,
keyword_span: Span,
node_id: NodeId,
}
#[derive(Debug)]
struct ValidationContext {
top_level_fields: Vec<FieldInfo>,
sections: Vec<SectionInfo>,
common_stanza_names: HashSet<String>,
imports: Vec<(String, Span, NodeId)>,
section_fields: HashMap<usize, Vec<FieldInfo>>,
}
impl ValidationContext {
fn collect(cst: &CabalCst) -> Self {
let mut ctx = ValidationContext {
top_level_fields: Vec::new(),
sections: Vec::new(),
common_stanza_names: HashSet::new(),
imports: Vec::new(),
section_fields: HashMap::new(),
};
let root = cst.node(cst.root);
for &child_id in &root.children {
let child = cst.node(child_id);
match child.kind {
CstNodeKind::Field => {
if let Some(name_span) = child.field_name {
ctx.top_level_fields.push(FieldInfo {
canonical_name: canonicalize_field_name(name_span.slice(&cst.source)),
name_span,
node_id: child_id,
});
}
}
CstNodeKind::Section => {
let keyword = child
.section_keyword
.map(|s| s.slice(&cst.source).to_ascii_lowercase())
.unwrap_or_default();
let arg = child.section_arg.map(|s| s.slice(&cst.source).to_string());
let keyword_span = child.section_keyword.unwrap_or(child.content_span);
if keyword == "common" {
if let Some(ref name) = arg {
ctx.common_stanza_names.insert(name.clone());
}
}
ctx.sections.push(SectionInfo {
keyword: keyword.clone(),
arg,
keyword_span,
node_id: child_id,
});
ctx.collect_section_children(cst, child_id);
}
_ => {}
}
}
ctx
}
fn collect_section_children(&mut self, cst: &CabalCst, section_id: NodeId) {
let section = cst.node(section_id);
for &child_id in §ion.children {
let child = cst.node(child_id);
match child.kind {
CstNodeKind::Field => {
if let Some(name_span) = child.field_name {
self.section_fields
.entry(section_id.0)
.or_default()
.push(FieldInfo {
canonical_name: canonicalize_field_name(
name_span.slice(&cst.source),
),
name_span,
node_id: child_id,
});
}
}
CstNodeKind::Import => {
if let Some(val_span) = child.field_value {
let value = val_span.slice(&cst.source).trim().to_string();
self.imports.push((value, val_span, child_id));
}
}
CstNodeKind::Section => {
let keyword = child
.section_keyword
.map(|s| s.slice(&cst.source).to_ascii_lowercase())
.unwrap_or_default();
let arg = child.section_arg.map(|s| s.slice(&cst.source).to_string());
let keyword_span = child.section_keyword.unwrap_or(child.content_span);
if keyword == "common" {
if let Some(ref name) = arg {
self.common_stanza_names.insert(name.clone());
}
}
self.sections.push(SectionInfo {
keyword,
arg,
keyword_span,
node_id: child_id,
});
self.collect_section_children(cst, child_id);
}
CstNodeKind::Conditional => {
self.collect_conditional_children(cst, child_id);
}
_ => {}
}
}
}
fn collect_conditional_children(&mut self, cst: &CabalCst, cond_id: NodeId) {
let cond = cst.node(cond_id);
for &child_id in &cond.children {
let child = cst.node(child_id);
match child.kind {
CstNodeKind::Field => {
}
CstNodeKind::Import => {
if let Some(val_span) = child.field_value {
let value = val_span.slice(&cst.source).trim().to_string();
self.imports.push((value, val_span, child_id));
}
}
CstNodeKind::ElseBlock => {
let else_node = cst.node(child_id);
for &else_child_id in &else_node.children {
let else_child = cst.node(else_child_id);
if else_child.kind == CstNodeKind::Import {
if let Some(val_span) = else_child.field_value {
let value = val_span.slice(&cst.source).trim().to_string();
self.imports.push((value, val_span, else_child_id));
}
}
}
}
CstNodeKind::Conditional => {
self.collect_conditional_children(cst, child_id);
}
_ => {}
}
}
}
}
fn check_required_fields(
_cst: &CabalCst,
ctx: &ValidationContext,
diagnostics: &mut Vec<Diagnostic>,
) {
let required = ["cabal-version", "name", "version"];
for &field_name in &required {
let found = ctx
.top_level_fields
.iter()
.any(|f| f.canonical_name == field_name);
if !found {
diagnostics.push(Diagnostic::error(
Span::new(0, 0),
format!("missing required field: `{field_name}`"),
));
}
}
}
fn check_cabal_version_first(
cst: &CabalCst,
ctx: &ValidationContext,
diagnostics: &mut Vec<Diagnostic>,
) {
let root = cst.node(cst.root);
let first_field_id = root.children.iter().find(|&&child_id| {
let child = cst.node(child_id);
child.kind == CstNodeKind::Field
});
let Some(&first_id) = first_field_id else {
return; };
let first_node = cst.node(first_id);
let Some(name_span) = first_node.field_name else {
return;
};
let name = canonicalize_field_name(name_span.slice(&cst.source));
if name != "cabal-version" {
if let Some(cv) = ctx
.top_level_fields
.iter()
.find(|f| f.canonical_name == "cabal-version")
{
diagnostics.push(Diagnostic::warning(
cv.name_span,
"`cabal-version` should be the first field in the file",
));
}
}
}
fn check_cabal_version_value(
cst: &CabalCst,
ctx: &ValidationContext,
diagnostics: &mut Vec<Diagnostic>,
) {
let Some(cv_field) = ctx
.top_level_fields
.iter()
.find(|f| f.canonical_name == "cabal-version")
else {
return; };
let Some(raw_value) = get_field_value(cst, cv_field.node_id) else {
return;
};
let version_str = raw_value.strip_prefix(">=").unwrap_or(raw_value).trim();
let is_valid_version =
!version_str.is_empty() && version_str.chars().all(|c| c.is_ascii_digit() || c == '.');
if !is_valid_version {
let val_span = cst
.node(cv_field.node_id)
.field_value
.unwrap_or(cv_field.name_span);
diagnostics.push(Diagnostic::warning(
val_span,
format!("unrecognized `cabal-version` value: `{raw_value}`"),
));
}
}
fn check_duplicate_top_level_fields(
_cst: &CabalCst,
ctx: &ValidationContext,
diagnostics: &mut Vec<Diagnostic>,
) {
check_duplicates_in_field_list(&ctx.top_level_fields, diagnostics);
}
fn check_duplicate_section_fields(
_cst: &CabalCst,
ctx: &ValidationContext,
diagnostics: &mut Vec<Diagnostic>,
) {
for fields in ctx.section_fields.values() {
check_duplicates_in_field_list(fields, diagnostics);
}
}
const REPEATABLE_FIELDS: &[&str] = &[
"build-depends",
"exposed-modules",
"other-modules",
"default-extensions",
"other-extensions",
"ghc-options",
"ghc-prof-options",
"ghc-shared-options",
"pkgconfig-depends",
"extra-libraries",
"extra-lib-dirs",
"extra-framework-dirs",
"frameworks",
"build-tool-depends",
"build-tools",
"mixins",
"hs-source-dirs",
"includes",
"include-dirs",
"c-sources",
"cxx-sources",
"js-sources",
"extra-ghci-libraries",
"extra-bundled-libraries",
"autogen-modules",
"virtual-modules",
"reexported-modules",
"signatures",
];
fn check_duplicates_in_field_list(fields: &[FieldInfo], diagnostics: &mut Vec<Diagnostic>) {
let mut seen: HashMap<&str, Span> = HashMap::new();
for field in fields {
if REPEATABLE_FIELDS.contains(&field.canonical_name.as_str()) {
continue;
}
if let Some(&first_span) = seen.get(field.canonical_name.as_str()) {
let _ = first_span; diagnostics.push(Diagnostic::warning(
field.name_span,
format!("duplicate field: `{}`", field.canonical_name),
));
} else {
seen.insert(&field.canonical_name, field.name_span);
}
}
}
fn check_duplicate_sections(
_cst: &CabalCst,
ctx: &ValidationContext,
diagnostics: &mut Vec<Diagnostic>,
) {
let mut seen: HashMap<(String, Option<String>), Span> = HashMap::new();
for section in &ctx.sections {
let key = (section.keyword.clone(), section.arg.clone());
if let Some(&_first_span) = seen.get(&key) {
let label = match §ion.arg {
Some(arg) => format!("`{} {}`", section.keyword, arg),
None => format!("`{}`", section.keyword),
};
diagnostics.push(Diagnostic::error(
section.keyword_span,
format!("duplicate section: {label}"),
));
} else {
seen.insert(key, section.keyword_span);
}
}
}
fn check_import_references(
_cst: &CabalCst,
ctx: &ValidationContext,
diagnostics: &mut Vec<Diagnostic>,
) {
for (value, val_span, _node_id) in &ctx.imports {
for stanza_name in value.split(',') {
let stanza_name = stanza_name.trim();
if !stanza_name.is_empty() && !ctx.common_stanza_names.contains(stanza_name) {
diagnostics.push(Diagnostic::error(
*val_span,
format!("import references undefined common stanza: `{stanza_name}`"),
));
}
}
}
}
fn check_build_type(cst: &CabalCst, ctx: &ValidationContext, diagnostics: &mut Vec<Diagnostic>) {
let Some(bt_field) = ctx
.top_level_fields
.iter()
.find(|f| f.canonical_name == "build-type")
else {
return;
};
let Some(value) = get_field_value(cst, bt_field.node_id) else {
return;
};
const VALID_BUILD_TYPES: &[&str] = &["Simple", "Configure", "Make", "Custom"];
if !VALID_BUILD_TYPES.contains(&value) {
let val_span = cst
.node(bt_field.node_id)
.field_value
.unwrap_or(bt_field.name_span);
diagnostics.push(Diagnostic::error(
val_span,
format!(
"invalid `build-type` value: `{value}` \
(expected one of: Simple, Configure, Make, Custom)"
),
));
}
}
fn check_library_exposed_modules(
cst: &CabalCst,
ctx: &ValidationContext,
diagnostics: &mut Vec<Diagnostic>,
) {
for section in &ctx.sections {
if section.keyword != "library" {
continue;
}
let fields = ctx.section_fields.get(§ion.node_id.0);
let has_exposed_modules = fields
.map(|fs| {
fs.iter().any(|f| {
if f.canonical_name != "exposed-modules" {
return false;
}
let node = cst.node(f.node_id);
let has_inline_value = node
.field_value
.map(|s| !s.slice(&cst.source).trim().is_empty())
.unwrap_or(false);
let has_continuation = !node.children.is_empty();
has_inline_value || has_continuation
})
})
.unwrap_or(false);
if !has_exposed_modules {
diagnostics.push(Diagnostic::warning(
section.keyword_span,
"library section has no `exposed-modules` \
(or it is empty)",
));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parse::parse;
fn validate_source(source: &str) -> Vec<Diagnostic> {
let result = parse(source);
validate(&result.cst)
}
fn has_diagnostic(diagnostics: &[Diagnostic], substring: &str) -> bool {
diagnostics.iter().any(|d| d.message.contains(substring))
}
#[test]
fn missing_name_field() {
let diags = validate_source("cabal-version: 3.0\nversion: 0.1.0.0\n");
assert!(has_diagnostic(&diags, "missing required field: `name`"));
}
#[test]
fn missing_version_field() {
let diags = validate_source("cabal-version: 3.0\nname: foo\n");
assert!(has_diagnostic(&diags, "missing required field: `version`"));
}
#[test]
fn missing_cabal_version_field() {
let diags = validate_source("name: foo\nversion: 0.1.0.0\n");
assert!(has_diagnostic(
&diags,
"missing required field: `cabal-version`"
));
}
#[test]
fn all_required_fields_present() {
let diags = validate_source("cabal-version: 3.0\nname: foo\nversion: 0.1.0.0\n");
assert!(
!has_diagnostic(&diags, "missing required field"),
"unexpected: {diags:?}"
);
}
#[test]
fn cabal_version_not_first() {
let diags = validate_source("name: foo\ncabal-version: 3.0\nversion: 0.1.0.0\n");
assert!(has_diagnostic(
&diags,
"`cabal-version` should be the first field"
));
}
#[test]
fn cabal_version_is_first() {
let diags = validate_source("cabal-version: 3.0\nname: foo\nversion: 0.1.0.0\n");
assert!(
!has_diagnostic(&diags, "should be the first field"),
"unexpected: {diags:?}"
);
}
#[test]
fn cabal_version_first_after_comments() {
let diags =
validate_source("-- A top comment\ncabal-version: 3.0\nname: foo\nversion: 0.1.0.0\n");
assert!(
!has_diagnostic(&diags, "should be the first field"),
"unexpected: {diags:?}"
);
}
#[test]
fn duplicate_top_level_field() {
let diags = validate_source("cabal-version: 3.0\nname: foo\nname: bar\nversion: 0.1.0.0\n");
assert!(has_diagnostic(&diags, "duplicate field: `name`"));
}
#[test]
fn duplicate_field_in_section() {
let src = "\
cabal-version: 3.0
name: foo
version: 0.1.0.0
library
exposed-modules: Foo
default-language: Haskell2010
default-language: GHC2021
";
let diags = validate_source(src);
assert!(has_diagnostic(
&diags,
"duplicate field: `default-language`"
));
}
#[test]
fn repeatable_field_not_flagged_as_duplicate() {
let src = "\
cabal-version: 3.0
name: foo
version: 0.1.0.0
library
exposed-modules: Foo
build-depends: base
build-depends: text
";
let diags = validate_source(src);
assert!(
!has_diagnostic(&diags, "duplicate field"),
"build-depends should be allowed multiple times: {diags:?}"
);
}
#[test]
fn same_field_different_sections_is_ok() {
let src = "\
cabal-version: 3.0
name: foo
version: 0.1.0.0
library
exposed-modules: Foo
build-depends: base
executable bar
main-is: Main.hs
build-depends: base
";
let diags = validate_source(src);
assert!(
!has_diagnostic(&diags, "duplicate field"),
"unexpected: {diags:?}"
);
}
#[test]
fn duplicate_executable_sections() {
let src = "\
cabal-version: 3.0
name: foo
version: 0.1.0.0
executable bar
main-is: Main.hs
executable bar
main-is: Other.hs
";
let diags = validate_source(src);
assert!(has_diagnostic(
&diags,
"duplicate section: `executable bar`"
));
}
#[test]
fn duplicate_unnamed_library() {
let src = "\
cabal-version: 3.0
name: foo
version: 0.1.0.0
library
exposed-modules: Foo
library
exposed-modules: Bar
";
let diags = validate_source(src);
assert!(has_diagnostic(&diags, "duplicate section: `library`"));
}
#[test]
fn different_executable_names_is_ok() {
let src = "\
cabal-version: 3.0
name: foo
version: 0.1.0.0
executable bar
main-is: Main.hs
executable baz
main-is: Other.hs
";
let diags = validate_source(src);
assert!(
!has_diagnostic(&diags, "duplicate section"),
"unexpected: {diags:?}"
);
}
#[test]
fn import_missing_common_stanza() {
let src = "\
cabal-version: 3.0
name: foo
version: 0.1.0.0
library
import: warnings
exposed-modules: Foo
";
let diags = validate_source(src);
assert!(has_diagnostic(
&diags,
"import references undefined common stanza: `warnings`"
));
}
#[test]
fn import_with_existing_common_stanza() {
let src = "\
cabal-version: 3.0
name: foo
version: 0.1.0.0
common warnings
ghc-options: -Wall
library
import: warnings
exposed-modules: Foo
";
let diags = validate_source(src);
assert!(
!has_diagnostic(&diags, "import references undefined"),
"unexpected: {diags:?}"
);
}
#[test]
fn valid_build_type_simple() {
let diags = validate_source(
"cabal-version: 3.0\nname: foo\nversion: 0.1.0.0\nbuild-type: Simple\n",
);
assert!(
!has_diagnostic(&diags, "invalid `build-type`"),
"unexpected: {diags:?}"
);
}
#[test]
fn invalid_build_type() {
let diags =
validate_source("cabal-version: 3.0\nname: foo\nversion: 0.1.0.0\nbuild-type: Foo\n");
assert!(has_diagnostic(&diags, "invalid `build-type` value: `Foo`"));
}
#[test]
fn library_without_exposed_modules() {
let src = "\
cabal-version: 3.0
name: foo
version: 0.1.0.0
library
build-depends: base
";
let diags = validate_source(src);
assert!(has_diagnostic(
&diags,
"library section has no `exposed-modules`"
));
}
#[test]
fn library_with_exposed_modules() {
let src = "\
cabal-version: 3.0
name: foo
version: 0.1.0.0
library
exposed-modules: Foo
build-depends: base
";
let diags = validate_source(src);
assert!(
!has_diagnostic(&diags, "exposed-modules"),
"unexpected: {diags:?}"
);
}
#[test]
fn library_with_multiline_exposed_modules() {
let src = "\
cabal-version: 3.0
name: foo
version: 0.1.0.0
library
exposed-modules:
Foo
Bar
build-depends: base
";
let diags = validate_source(src);
assert!(
!has_diagnostic(&diags, "exposed-modules"),
"unexpected: {diags:?}"
);
}
#[test]
fn cabal_version_valid_value() {
let diags = validate_source("cabal-version: 3.0\nname: foo\nversion: 0.1.0.0\n");
assert!(
!has_diagnostic(&diags, "unrecognized `cabal-version`"),
"unexpected: {diags:?}"
);
}
#[test]
fn cabal_version_deprecated_prefix() {
let diags = validate_source("cabal-version: >=1.10\nname: foo\nversion: 0.1.0.0\n");
assert!(
!has_diagnostic(&diags, "unrecognized `cabal-version`"),
"unexpected: {diags:?}"
);
}
#[test]
fn cabal_version_invalid_value() {
let diags = validate_source("cabal-version: foobar\nname: foo\nversion: 0.1.0.0\n");
assert!(has_diagnostic(&diags, "unrecognized `cabal-version` value"));
}
#[test]
fn full_valid_file_no_diagnostics() {
let src = "\
cabal-version: 3.0
name: foo
version: 0.1.0.0
synopsis: A test package
build-type: Simple
common warnings
ghc-options: -Wall
library
import: warnings
exposed-modules: Foo
build-depends: base >=4.14
executable my-exe
import: warnings
main-is: Main.hs
build-depends: base, foo
test-suite tests
import: warnings
type: exitcode-stdio-1.0
main-is: Main.hs
build-depends: base, foo, tasty
";
let diags = validate_source(src);
assert!(diags.is_empty(), "expected no diagnostics, got: {diags:?}");
}
#[test]
fn duplicate_field_case_insensitive() {
let diags = validate_source("cabal-version: 3.0\nName: foo\nname: bar\nversion: 0.1.0.0\n");
assert!(has_diagnostic(&diags, "duplicate field: `name`"));
}
#[test]
fn duplicate_field_underscore_hyphen() {
let src = "\
cabal-version: 3.0
name: foo
version: 0.1.0.0
library
exposed-modules: Foo
default-language: Haskell2010
default_language: GHC2021
";
let diags = validate_source(src);
assert!(has_diagnostic(
&diags,
"duplicate field: `default-language`"
));
}
#[test]
fn empty_file() {
let diags = validate_source("");
assert!(has_diagnostic(
&diags,
"missing required field: `cabal-version`"
));
assert!(has_diagnostic(&diags, "missing required field: `name`"));
assert!(has_diagnostic(&diags, "missing required field: `version`"));
}
#[test]
fn comments_only_file() {
let diags = validate_source("-- just a comment\n");
assert!(has_diagnostic(
&diags,
"missing required field: `cabal-version`"
));
assert!(has_diagnostic(&diags, "missing required field: `name`"));
assert!(has_diagnostic(&diags, "missing required field: `version`"));
}
}