use rpm_spec::ast::{
BoolDep, CondExpr, DepAtom, DepExpr, FilesContent, PackageName, PreambleContent, PreambleItem,
Section, Span, SpecFile, SpecItem, Tag, TagValue, Text,
};
use rpm_spec_profile::{Family, Profile};
use crate::diagnostic::Edit;
pub(crate) fn is_fedora_or_rhel(profile: &Profile) -> bool {
match profile.identity.family {
Some(Family::Fedora | Family::Rhel) => true,
Some(Family::Alt | Family::Opensuse | Family::Mageia | Family::Generic) => false,
None => false,
Some(_) => false,
}
}
pub fn collect_top_level_preamble(spec: &SpecFile<Span>) -> Vec<&PreambleItem<Span>> {
fn walk<'a>(items: &'a [SpecItem<Span>], out: &mut Vec<&'a PreambleItem<Span>>) {
for item in items {
match item {
SpecItem::Preamble(p) => out.push(p),
SpecItem::Conditional(c) => {
for branch in &c.branches {
walk(&branch.body, out);
}
if let Some(els) = &c.otherwise {
walk(els, out);
}
}
_ => {}
}
}
}
let mut out = Vec::new();
walk(&spec.items, &mut out);
out
}
pub fn has_top_level_tag<F>(spec: &SpecFile<Span>, mut predicate: F) -> bool
where
F: FnMut(&Tag) -> bool,
{
collect_top_level_preamble(spec)
.iter()
.any(|p| predicate(&p.tag))
}
pub fn spec_span(spec: &SpecFile<Span>) -> Span {
spec.data
}
pub fn drop_span(span: Span) -> Edit {
Edit::new(span, "")
}
pub(crate) const FALLBACK_PATH_TABLE: &[(&str, &str)] = &[
("/usr/lib64", "%{_libdir}"),
("/usr/libexec", "%{_libexecdir}"),
("/usr/include", "%{_includedir}"),
("/usr/share", "%{_datadir}"),
("/usr/bin", "%{_bindir}"),
("/usr/sbin", "%{_sbindir}"),
("/usr/lib", "%{_libdir}"),
("/var/log", "%{_localstatedir}/log"),
("/var/lib", "%{_sharedstatedir}"),
("/etc", "%{_sysconfdir}"),
];
pub(crate) fn split_spdx_atoms(expr: &str) -> Vec<&str> {
expr.split(|c: char| c.is_whitespace() || c == '(' || c == ')' || c == ',')
.map(str::trim)
.filter(|t| !t.is_empty() && !is_spdx_operator(t))
.collect()
}
pub(crate) fn is_spdx_operator(tok: &str) -> bool {
tok.eq_ignore_ascii_case("OR")
|| tok.eq_ignore_ascii_case("AND")
|| tok.eq_ignore_ascii_case("WITH")
}
#[inline]
pub(crate) fn is_name_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.')
}
#[inline]
pub(crate) fn is_path_boundary(rest: &str) -> bool {
match rest.as_bytes().first() {
None => true,
Some(b'/') => true,
Some(&b) => !is_name_byte(b),
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct PatchDecl {
pub number: u32,
pub span: Span,
#[allow(dead_code)]
pub explicit: bool,
}
pub(crate) fn collect_declared_patches(spec: &SpecFile<Span>) -> Vec<PatchDecl> {
let mut out = Vec::new();
for item in collect_top_level_preamble(spec) {
if let Tag::Patch(n) = item.tag {
out.push(PatchDecl {
number: n.unwrap_or(0),
span: item.data,
explicit: n.is_some(),
});
}
}
out
}
pub(crate) const MACRO_SETUP: &str = "setup";
pub(crate) const MACRO_AUTOSETUP: &str = "autosetup";
pub(crate) const MACRO_AUTOPATCH: &str = "autopatch";
pub(crate) const MACRO_PATCH_PREFIX: &str = "patch";
pub(crate) fn is_constant_true_condition<T>(expr: &CondExpr<T>) -> bool {
match expr {
CondExpr::Parsed(ast) => match ast.as_ref().peel_parens() {
rpm_spec::ast::ExprAst::Integer { value, .. } => *value != 0,
rpm_spec::ast::ExprAst::String { value, .. } => !value.is_empty(),
rpm_spec::ast::ExprAst::Identifier { name, .. } => name == "true",
_ => false,
},
CondExpr::Raw(text) => {
let Some(lit) = text.literal_str() else {
return false;
};
matches!(lit.trim(), "1" | "true")
}
_ => false,
}
}
pub(crate) fn is_constant_false_condition<T>(expr: &CondExpr<T>) -> bool {
match expr {
CondExpr::Parsed(ast) => match ast.as_ref().peel_parens() {
rpm_spec::ast::ExprAst::Integer { value, .. } => *value == 0,
rpm_spec::ast::ExprAst::String { value, .. } => value.is_empty(),
rpm_spec::ast::ExprAst::Identifier { name, .. } => name == "false",
_ => false,
},
CondExpr::Raw(text) => {
let Some(lit) = text.literal_str() else {
return false;
};
matches!(lit.trim(), "0" | "false" | "")
}
_ => false,
}
}
pub(crate) fn cond_expr_resolvably_eq<T, U>(a: &CondExpr<T>, b: &CondExpr<U>) -> bool {
match (a, b) {
(CondExpr::Raw(t1), CondExpr::Raw(t2)) => match (t1.literal_str(), t2.literal_str()) {
(Some(s1), Some(s2)) => {
let trimmed1 = s1.trim();
let trimmed2 = s2.trim();
if trimmed1.contains('%') || trimmed2.contains('%') {
return false;
}
trimmed1 == trimmed2
}
_ => false,
},
(CondExpr::ArchList(a1), CondExpr::ArchList(a2)) => {
if a1.len() != a2.len() {
return false;
}
let lit_set = |items: &[Text]| -> Option<Vec<String>> {
let mut out = Vec::with_capacity(items.len());
for t in items {
let s = t.literal_str()?.trim();
if s.contains('%') {
return None;
}
out.push(s.to_owned());
}
Some(out)
};
let (Some(mut s1), Some(mut s2)) = (lit_set(a1), lit_set(a2)) else {
return false;
};
s1.sort();
s2.sort();
s1 == s2
}
(CondExpr::Parsed(a1), CondExpr::Parsed(b1)) => {
!contains_macro_ast(a1) && !contains_macro_ast(b1) && exprs_equiv(a1, b1)
}
_ => false,
}
}
pub(crate) fn exprs_equiv<T, U>(
a: &rpm_spec::ast::ExprAst<T>,
b: &rpm_spec::ast::ExprAst<U>,
) -> bool {
use rpm_spec::ast::ExprAst;
match (a.peel_parens(), b.peel_parens()) {
(ExprAst::Integer { value: v1, .. }, ExprAst::Integer { value: v2, .. }) => v1 == v2,
(ExprAst::String { value: v1, .. }, ExprAst::String { value: v2, .. }) => v1 == v2,
(ExprAst::Identifier { name: n1, .. }, ExprAst::Identifier { name: n2, .. }) => n1 == n2,
(ExprAst::Macro { text: m1, .. }, ExprAst::Macro { text: m2, .. }) => m1 == m2,
(ExprAst::Not { inner: i1, .. }, ExprAst::Not { inner: i2, .. }) => exprs_equiv(i1, i2),
(
ExprAst::Binary {
kind: k1,
lhs: l1,
rhs: r1,
..
},
ExprAst::Binary {
kind: k2,
lhs: l2,
rhs: r2,
..
},
) => k1 == k2 && exprs_equiv(l1, l2) && exprs_equiv(r1, r2),
_ => false,
}
}
pub(crate) fn contains_macro_ast<T>(ast: &rpm_spec::ast::ExprAst<T>) -> bool {
use rpm_spec::ast::ExprAst;
match ast {
ExprAst::Integer { .. } | ExprAst::String { .. } | ExprAst::Identifier { .. } => false,
ExprAst::Macro { .. } => true,
ExprAst::Paren { inner, .. } | ExprAst::Not { inner, .. } => contains_macro_ast(inner),
ExprAst::Binary { lhs, rhs, .. } => contains_macro_ast(lhs) || contains_macro_ast(rhs),
_ => true,
}
}
pub(crate) fn is_empty_top_body(body: &[SpecItem<Span>]) -> bool {
!body.is_empty()
&& body
.iter()
.all(|i| matches!(i, SpecItem::Blank | SpecItem::Comment(_)))
}
pub(crate) fn is_empty_preamble_body(body: &[PreambleContent<Span>]) -> bool {
!body.is_empty()
&& body
.iter()
.all(|i| matches!(i, PreambleContent::Blank | PreambleContent::Comment(_)))
}
pub(crate) fn is_empty_files_body(body: &[FilesContent<Span>]) -> bool {
!body.is_empty()
&& body
.iter()
.all(|i| matches!(i, FilesContent::Blank | FilesContent::Comment(_)))
}
pub fn package_name(spec: &SpecFile<Span>) -> Option<&str> {
for item in collect_top_level_preamble(spec) {
if let Tag::Name = item.tag
&& let TagValue::Text(t) = &item.value
{
return t.literal_str();
}
}
None
}
#[derive(Debug)]
pub struct PackageView<'a> {
pub(crate) name: Option<String>,
pub(crate) items: Vec<&'a PreambleItem<Span>>,
pub(crate) header_span: Span,
}
impl<'a> PackageView<'a> {
pub fn name(&self) -> Option<&str> {
self.name.as_deref()
}
pub fn items(&self) -> &[&'a PreambleItem<Span>] {
&self.items
}
pub fn header_span(&self) -> Span {
self.header_span
}
}
pub fn iter_packages(spec: &SpecFile<Span>) -> Vec<PackageView<'_>> {
let main_name = package_name(spec).map(str::to_owned);
let mut views = Vec::new();
views.push(PackageView {
name: main_name.clone(),
items: collect_top_level_preamble(spec),
header_span: spec.data,
});
for item in &spec.items {
if let SpecItem::Section(boxed) = item
&& let Section::Package {
name_arg,
content,
data,
} = boxed.as_ref()
{
views.push(PackageView {
name: resolve_subpackage_name(main_name.as_deref(), name_arg),
items: collect_preamble_items_in_content(content),
header_span: *data,
});
}
}
views
}
fn resolve_subpackage_name(main: Option<&str>, arg: &PackageName) -> Option<String> {
match arg {
PackageName::Relative(t) => match (main, t.literal_str()) {
(Some(m), Some(suffix)) => Some(format!("{m}-{suffix}")),
_ => None,
},
PackageName::Absolute(t) => t.literal_str().map(str::to_owned),
_ => None,
}
}
fn collect_preamble_items_in_content(
content: &[PreambleContent<Span>],
) -> Vec<&PreambleItem<Span>> {
fn walk<'a>(items: &'a [PreambleContent<Span>], out: &mut Vec<&'a PreambleItem<Span>>) {
for c in items {
match c {
PreambleContent::Item(p) => out.push(p),
PreambleContent::Conditional(cond) => {
for branch in &cond.branches {
walk(&branch.body, out);
}
if let Some(els) = &cond.otherwise {
walk(els, out);
}
}
_ => {}
}
}
}
let mut out = Vec::new();
walk(content, &mut out);
out
}
pub fn collect_dep_atoms_in_items<'a, F>(
items: &[&'a PreambleItem<Span>],
tag_matcher: F,
) -> Vec<&'a DepAtom>
where
F: Fn(&Tag) -> bool,
{
let mut out = Vec::new();
for item in items {
if !tag_matcher(&item.tag) {
continue;
}
if let TagValue::Dep(expr) = &item.value {
collect_atoms(expr, &mut out);
}
}
out
}
fn collect_atoms<'a>(expr: &'a DepExpr, out: &mut Vec<&'a DepAtom>) {
match expr {
DepExpr::Atom(a) => out.push(a),
DepExpr::Rich(b) => collect_atoms_bool(b, out),
_ => {}
}
}
fn collect_atoms_bool<'a>(b: &'a BoolDep, out: &mut Vec<&'a DepAtom>) {
match b {
BoolDep::And(xs) | BoolDep::Or(xs) | BoolDep::With(xs) => {
for x in xs {
collect_atoms(x, out);
}
}
BoolDep::If {
cond,
then,
otherwise,
}
| BoolDep::Unless {
cond,
then,
otherwise,
} => {
collect_atoms(cond, out);
collect_atoms(then, out);
if let Some(o) = otherwise {
collect_atoms(o, out);
}
}
BoolDep::Without { left, right } => {
collect_atoms(left, out);
collect_atoms(right, out);
}
_ => {}
}
}
pub(crate) fn collect_top_level_dep_names<F>(
spec: &SpecFile<Span>,
tag_matcher: F,
) -> std::collections::BTreeSet<String>
where
F: Fn(&Tag) -> bool,
{
let items = collect_top_level_preamble(spec);
collect_dep_atoms_in_items(&items, tag_matcher)
.into_iter()
.filter_map(|a| a.name.literal_str().map(|s| s.trim().to_owned()))
.collect()
}
#[macro_export]
macro_rules! declare_missing_tag_lint {
(
mod $mod_id:ident,
struct $struct:ident,
id: $id:literal,
name: $name:literal,
description: $desc:literal,
severity: $sev:expr,
tag: $tag:pat,
message: $msg:literal,
good_fixture: $good:literal,
bad_fixture: $bad:literal $(,)?
) => {
pub mod $mod_id {
use rpm_spec::ast::{Span, SpecFile, Tag};
use $crate::diagnostic::{Diagnostic, LintCategory, Severity};
use $crate::lint::{Lint, LintMetadata};
use $crate::rules::util::{has_top_level_tag, spec_span};
use $crate::visit::Visit;
pub static METADATA: LintMetadata = LintMetadata {
id: $id,
name: $name,
description: $desc,
default_severity: $sev,
category: LintCategory::Packaging,
};
#[derive(Debug, Default)]
pub struct $struct {
diagnostics: Vec<Diagnostic>,
}
impl $struct {
pub fn new() -> Self {
Self::default()
}
}
impl<'ast> Visit<'ast> for $struct {
fn visit_spec(&mut self, spec: &'ast SpecFile<Span>) {
if !has_top_level_tag(spec, |t: &Tag| matches!(t, $tag)) {
self.diagnostics.push(Diagnostic::new(
&METADATA,
$sev,
$msg,
spec_span(spec),
));
}
}
}
impl Lint for $struct {
fn metadata(&self) -> &'static LintMetadata {
&METADATA
}
fn take_diagnostics(&mut self) -> Vec<Diagnostic> {
::std::mem::take(&mut self.diagnostics)
}
}
#[cfg(test)]
mod tests {
use super::*;
use $crate::session::parse;
fn run(src: &str) -> Vec<Diagnostic> {
let outcome = parse(src);
let mut lint = $struct::new();
lint.visit_spec(&outcome.spec);
lint.take_diagnostics()
}
#[test]
fn flags_when_tag_missing() {
let diags = run($bad);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].lint_id, $id);
}
#[test]
fn silent_when_tag_present() {
assert!(run($good).is_empty());
}
}
}
};
}
#[macro_export]
macro_rules! declare_missing_section_lint {
(
mod $mod_id:ident,
struct $struct:ident,
id: $id:literal,
name: $name:literal,
description: $desc:literal,
severity: $sev:expr,
kind: $kind:expr,
message: $msg:literal,
good_fixture: $good:literal,
bad_fixture: $bad:literal $(,)?
) => {
pub mod $mod_id {
use rpm_spec::ast::{BuildScriptKind, Section, Span, SpecFile, SpecItem};
use $crate::diagnostic::{Diagnostic, LintCategory, Severity};
use $crate::lint::{Lint, LintMetadata};
use $crate::rules::util::spec_span;
use $crate::visit::Visit;
pub static METADATA: LintMetadata = LintMetadata {
id: $id,
name: $name,
description: $desc,
default_severity: $sev,
category: LintCategory::Packaging,
};
#[derive(Debug, Default)]
pub struct $struct {
diagnostics: Vec<Diagnostic>,
}
impl $struct {
pub fn new() -> Self {
Self::default()
}
}
impl<'ast> Visit<'ast> for $struct {
fn visit_spec(&mut self, spec: &'ast SpecFile<Span>) {
let target_kind: BuildScriptKind = $kind;
let found = spec.items.iter().any(|item| {
matches!(
item,
SpecItem::Section(s)
if matches!(
s.as_ref(),
Section::BuildScript { kind, .. } if *kind == target_kind
)
)
});
if !found {
self.diagnostics.push(Diagnostic::new(
&METADATA,
$sev,
$msg,
spec_span(spec),
));
}
}
}
impl Lint for $struct {
fn metadata(&self) -> &'static LintMetadata {
&METADATA
}
fn take_diagnostics(&mut self) -> Vec<Diagnostic> {
::std::mem::take(&mut self.diagnostics)
}
}
#[cfg(test)]
mod tests {
use super::*;
use $crate::session::parse;
fn run(src: &str) -> Vec<Diagnostic> {
let outcome = parse(src);
let mut lint = $struct::new();
lint.visit_spec(&outcome.spec);
lint.take_diagnostics()
}
#[test]
fn flags_when_section_missing() {
let diags = run($bad);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].lint_id, $id);
}
#[test]
fn silent_when_section_present() {
assert!(run($good).is_empty());
}
}
}
};
}
#[cfg(test)]
#[allow(dead_code)] pub fn make_test_profile(
family: Option<rpm_spec_profile::Family>,
dist_tag: Option<&str>,
macros: &[(&str, &str)],
rpmlib: &[(&str, &str)],
) -> rpm_spec_profile::Profile {
use rpm_spec_profile::{MacroEntry, Profile, Provenance};
let mut p = Profile::default();
p.identity.family = family;
p.identity.dist_tag = dist_tag.map(str::to_owned);
for (name, body) in macros {
p.macros
.insert(*name, MacroEntry::literal(*body, Provenance::Override));
}
for (name, ver) in rpmlib {
p.rpmlib.features.insert((*name).into(), (*ver).into());
}
p
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::parse;
fn p(src: &str) -> rpm_spec::ast::SpecFile<Span> {
parse(src).spec
}
#[test]
fn collects_simple_top_level() {
let spec = p("Name: hello\nVersion: 1\n");
let items = collect_top_level_preamble(&spec);
assert_eq!(items.len(), 2);
assert!(matches!(items[0].tag, Tag::Name));
assert!(matches!(items[1].tag, Tag::Version));
}
#[test]
fn skips_subpackage_preamble() {
let spec = p("Name: main\n\
%package -n foo\n\
Summary: subpackage\n\
%description -n foo\n\
sub body\n");
let items = collect_top_level_preamble(&spec);
assert_eq!(items.len(), 1, "got {items:?}");
assert!(matches!(items[0].tag, Tag::Name));
}
#[test]
fn dives_into_conditional_branches() {
let spec = p("%if 0%{?rhel}\n\
Name: rhel-pkg\n\
%else\n\
Name: fedora-pkg\n\
%endif\n");
let items = collect_top_level_preamble(&spec);
assert_eq!(items.len(), 2);
assert!(items.iter().all(|p| matches!(p.tag, Tag::Name)));
}
#[test]
fn empty_spec_returns_empty_vec() {
let spec = p("");
assert!(collect_top_level_preamble(&spec).is_empty());
}
#[test]
fn has_top_level_tag_finds_match() {
let spec = p("Name: x\nLicense: MIT\n");
assert!(has_top_level_tag(&spec, |t| matches!(t, Tag::License)));
assert!(!has_top_level_tag(&spec, |t| matches!(t, Tag::URL)));
}
#[test]
fn drop_span_emits_empty_replacement() {
let span = Span::from_bytes(5, 12);
let edit = drop_span(span);
assert_eq!(edit.span, span);
assert!(edit.replacement.is_empty());
}
#[test]
fn package_name_extracts_main_literal() {
let spec = p("Name: hello\nVersion: 1\n");
assert_eq!(package_name(&spec), Some("hello"));
}
#[test]
fn package_name_none_when_macro() {
let spec = p("Name: %{base_name}\n");
assert_eq!(package_name(&spec), None);
}
#[test]
fn iter_packages_main_only() {
let spec = p("Name: hello\nVersion: 1\n");
let pkgs = iter_packages(&spec);
assert_eq!(pkgs.len(), 1);
assert_eq!(pkgs[0].name.as_deref(), Some("hello"));
assert_eq!(pkgs[0].items.len(), 2);
}
#[test]
fn iter_packages_relative_subpackage() {
let spec = p("Name: hello\n\
%package devel\n\
Summary: dev files\n\
%description devel\nbody\n");
let pkgs = iter_packages(&spec);
assert_eq!(pkgs.len(), 2);
assert_eq!(pkgs[1].name.as_deref(), Some("hello-devel"));
assert!(pkgs[1].items.iter().any(|p| matches!(p.tag, Tag::Summary)));
}
#[test]
fn iter_packages_absolute_subpackage() {
let spec = p("Name: hello\n\
%package -n bar\n\
Summary: standalone\n\
%description -n bar\nbody\n");
let pkgs = iter_packages(&spec);
assert_eq!(pkgs.len(), 2);
assert_eq!(pkgs[1].name.as_deref(), Some("bar"));
}
#[test]
fn collect_dep_atoms_finds_plain_and_rich() {
let spec = p("Name: x\nRequires: a, (b and c)\n");
let pkgs = iter_packages(&spec);
let atoms = collect_dep_atoms_in_items(&pkgs[0].items, |t| matches!(t, Tag::Requires));
let names: Vec<&str> = atoms.iter().filter_map(|a| a.name.literal_str()).collect();
assert!(names.contains(&"a"));
assert!(names.contains(&"b"));
assert!(names.contains(&"c"));
}
#[test]
fn is_path_boundary_at_end_of_string() {
assert!(is_path_boundary(""));
}
#[test]
fn is_path_boundary_on_slash_continues_path() {
assert!(is_path_boundary("/foo"));
}
#[test]
fn is_path_boundary_rejects_name_continuation() {
assert!(!is_path_boundary("foo"));
assert!(!is_path_boundary("1"));
assert!(!is_path_boundary("_x"));
assert!(!is_path_boundary("-x"));
assert!(!is_path_boundary(".x"));
}
#[test]
fn is_path_boundary_terminator_chars() {
assert!(is_path_boundary(" "));
assert!(is_path_boundary("\t"));
assert!(is_path_boundary("\""));
assert!(is_path_boundary("'"));
assert!(is_path_boundary(":"));
}
#[test]
fn split_spdx_atoms_empty_input() {
let v = split_spdx_atoms("");
assert!(v.is_empty(), "empty input → no atoms; got {v:?}");
}
#[test]
fn split_spdx_atoms_single_identifier() {
assert_eq!(split_spdx_atoms("MIT"), vec!["MIT"]);
}
#[test]
fn split_spdx_atoms_or_and_with() {
assert_eq!(
split_spdx_atoms("MIT OR GPL-2.0-or-later WITH Classpath-exception-2.0"),
vec!["MIT", "GPL-2.0-or-later", "Classpath-exception-2.0"]
);
}
#[test]
fn split_spdx_atoms_handles_parens_and_commas() {
let v = split_spdx_atoms("(MIT OR Apache-2.0), GPL-3.0-or-later");
assert_eq!(v, vec!["MIT", "Apache-2.0", "GPL-3.0-or-later"]);
}
#[test]
fn split_spdx_atoms_only_operators_yields_empty() {
assert!(split_spdx_atoms("OR AND WITH").is_empty());
}
#[test]
fn is_spdx_operator_case_insensitive() {
for op in ["OR", "or", "Or", "AND", "and", "WITH", "with", "WitH"] {
assert!(is_spdx_operator(op), "{op} should match");
}
}
#[test]
fn is_spdx_operator_rejects_non_operators() {
for tok in ["MIT", "GPL", "ORIGINAL", "ANDX", "WITHOUT", "", "or-"] {
assert!(!is_spdx_operator(tok), "{tok} should not match");
}
}
#[test]
fn is_empty_top_body_returns_false_on_empty_slice() {
assert!(!is_empty_top_body(&[]));
}
#[test]
fn is_empty_preamble_body_returns_false_on_empty_slice() {
assert!(!is_empty_preamble_body(&[]));
}
#[test]
fn is_empty_files_body_returns_false_on_empty_slice() {
assert!(!is_empty_files_body(&[]));
}
#[test]
fn is_empty_top_body_true_on_single_blank() {
let body: Vec<SpecItem<Span>> = vec![SpecItem::Blank];
assert!(is_empty_top_body(&body));
}
#[test]
fn is_empty_preamble_body_true_on_single_blank() {
let body: Vec<PreambleContent<Span>> = vec![PreambleContent::Blank];
assert!(is_empty_preamble_body(&body));
}
#[test]
fn is_empty_files_body_true_on_single_blank() {
let body: Vec<FilesContent<Span>> = vec![FilesContent::Blank];
assert!(is_empty_files_body(&body));
}
}