use super::context::{Context, Item};
use super::text::{Body, occurrences, phrase_positions, tokens, without_code_spans, words};
use super::{LintConfig, Raw, RuleId};
use crate::identity::Key;
use crate::kind::NodeKind;
use crate::node::Node;
use crate::statement::Statement;
#[derive(Clone, Copy, PartialEq, Eq)]
enum Scope {
Assertion,
Body,
}
const LEXICAL: [(RuleId, Scope, &str); 5] = [
(
RuleId::Lq001,
Scope::Assertion,
"The text contains a weak term.",
),
(
RuleId::Lq002,
Scope::Assertion,
"The text contains a vague quantifier.",
),
(
RuleId::Lq003,
Scope::Assertion,
"The text contains an escape clause.",
),
(
RuleId::Lq004,
Scope::Assertion,
"The text contains absolute language.",
),
(
RuleId::V2007,
Scope::Body,
"The body holds a normative word; state the obligation in a statement or a criterion.",
),
];
pub(super) fn body_rule(
context: &Context<'_>,
config: &LintConfig,
rule: RuleId,
out: &mut Vec<Raw>,
) {
let Some((_, scope, message)) = LEXICAL
.into_iter()
.find(|(lexical, scope, _)| *lexical == rule && *scope == Scope::Body)
else {
return;
};
for item in context.items.iter().filter(|item| item.body().is_some()) {
lexical(
&Texts::read(item, false, false),
rule,
scope,
message,
config,
out,
);
}
}
pub(super) fn check(context: &Context<'_>, config: &LintConfig, out: &mut Vec<Raw>) {
let terms = terms(context);
for item in &context.items {
let assertion = is_assertion(item.id.kind());
let tagged = taggable(item.id.kind());
if !assertion && !tagged && item.body().is_none() {
continue;
}
let texts = Texts::read(item, assertion || tagged, assertion);
for (rule, scope, message) in LEXICAL {
if assertion || scope == Scope::Body {
lexical(&texts, rule, scope, message, config, out);
}
}
if assertion {
compound(&texts, config, out);
outside_the_forms(item, out);
}
if tagged {
untagged_terms(context, item, &texts, &terms, out);
}
}
super::similarity::check(context, config, out);
}
const fn is_assertion(kind: NodeKind) -> bool {
matches!(
kind,
NodeKind::Requirement
| NodeKind::Criterion
| NodeKind::Constraint
| NodeKind::QualityAttribute
)
}
#[derive(Clone, Copy)]
enum Spot {
Name,
Body(usize),
}
impl Spot {
const fn line(self) -> Option<usize> {
match self {
Self::Name => None,
Self::Body(line) => Some(line),
}
}
}
struct Texts {
key: Key,
name: Words,
body: Words,
sentences: Vec<(Vec<String>, usize)>,
}
impl Texts {
fn read(item: &Item<'_>, named: bool, assertion: bool) -> Self {
let body = item.body().map(Body::read);
let sentences = body.as_ref().filter(|_| assertion).map(Body::sentences);
Self {
key: item.id.key(),
name: if named {
Words::name(&item.name())
} else {
Words::default()
},
body: body.as_ref().map(Words::body).unwrap_or_default(),
sentences: sentences.unwrap_or_default(),
}
}
fn spots<'a>(
&'a self,
scope: Scope,
phrase: &'a [String],
keep: impl Fn(&[String], usize) -> bool + 'a,
) -> impl Iterator<Item = Spot> + 'a {
let names = if scope == Scope::Assertion {
phrase_positions(&self.name.tokens, phrase)
} else {
Vec::new()
};
let name = names
.into_iter()
.filter(|at| keep(&self.name.tokens, *at))
.map(|_| Spot::Name)
.collect::<Vec<_>>();
let body = phrase_positions(&self.body.tokens, phrase)
.into_iter()
.filter(move |at| keep(&self.body.tokens, *at))
.map(|at| Spot::Body(self.body.lines[at]));
name.into_iter().chain(body)
}
}
fn lexical(
texts: &Texts,
rule: RuleId,
scope: Scope,
message: &str,
config: &LintConfig,
out: &mut Vec<Raw>,
) {
for phrase in config.lexicon(rule) {
let term = phrase.join(" ");
let keep = |tokens: &[String], at| !exact_bound(rule, phrase, tokens, at);
for spot in texts.spots(scope, phrase, keep) {
out.push(
Raw::new(rule, vec![texts.key], message)
.term(&term)
.line(spot.line()),
);
}
}
}
fn exact_bound(rule: RuleId, phrase: &[String], tokens: &[String], at: usize) -> bool {
rule == RuleId::Lq002
&& phrase == ["most"]
&& at > 0
&& tokens[at - 1] == "at"
&& tokens.get(at + 1).is_some_and(|token| is_quantity(token))
}
const NUMBER_WORDS: [&str; 28] = [
"zero",
"one",
"two",
"three",
"four",
"five",
"six",
"seven",
"eight",
"nine",
"ten",
"eleven",
"twelve",
"thirteen",
"fourteen",
"fifteen",
"sixteen",
"seventeen",
"eighteen",
"nineteen",
"twenty",
"thirty",
"forty",
"fifty",
"sixty",
"seventy",
"eighty",
"ninety",
];
fn is_quantity(token: &str) -> bool {
let digits = |text: &str| !text.is_empty() && text.chars().all(|c| c.is_ascii_digit());
let grouped = token.split_once(',').is_some_and(|(lead, groups)| {
(1..=3).contains(&lead.len())
&& digits(lead)
&& groups
.split(',')
.all(|group| group.len() == 3 && digits(group))
});
digits(token) || grouped || NUMBER_WORDS.contains(&token)
}
fn compound(texts: &Texts, config: &LintConfig, out: &mut Vec<Raw>) {
let compound = |tokens: &[String]| {
occurrences(tokens, config.modals()) > 1
|| occurrences(tokens, config.conjunctions()) >= config.conjunction_threshold()
};
let spot = if compound(&texts.name.tokens) {
Some(Spot::Name)
} else {
texts
.sentences
.iter()
.find(|(tokens, _)| compound(tokens))
.map(|(_, line)| Spot::Body(*line))
};
if let Some(spot) = spot {
let message = "The text may hold more than one verifiable statement.";
out.push(Raw::new(RuleId::Lq005, vec![texts.key], message).line(spot.line()));
}
}
fn outside_the_forms(item: &Item<'_>, out: &mut Vec<Raw>) {
let Node::Requirement(requirement) = item.node else {
return;
};
let Statement::Text(text) = &requirement.statement else {
return;
};
if crate::ears::parse(text, &crate::text::token_ranges(text)).is_none() {
out.push(Raw::new(
RuleId::Lq007,
vec![item.id.key()],
"The statement matches none of the five EARS forms.",
));
}
}
#[derive(Default)]
struct Words {
tokens: Vec<String>,
lines: Vec<usize>,
joins: Vec<bool>,
}
impl Words {
fn name(name: &str) -> Self {
let (tokens, joins) = words(&without_code_spans(name));
Self {
tokens,
lines: Vec::new(),
joins,
}
}
fn body(body: &Body) -> Self {
let (tokens, lines, joins) = body.words();
Self {
tokens,
lines,
joins,
}
}
fn first(&self, phrase: &[String], hyphenated: bool) -> Option<usize> {
phrase_positions(&self.tokens, phrase)
.into_iter()
.find(|at| !hyphenated || self.joins[at + 1..at + phrase.len()].iter().all(|j| *j))
}
}
type Term<'a> = (&'a str, Vec<String>, bool);
fn terms<'a>(context: &Context<'a>) -> Vec<Term<'a>> {
let mut entries: Vec<_> = context.document.tags.iter().collect();
entries.sort_unstable_by_key(|entry| entry.id);
entries
.into_iter()
.map(|entry| {
let name = entry.name.as_str();
(name, tokens(name), name.contains('-'))
})
.filter(|(_, phrase, _)| !phrase.is_empty())
.collect()
}
fn untagged_terms(
context: &Context<'_>,
item: &Item<'_>,
texts: &Texts,
terms: &[Term<'_>],
out: &mut Vec<Raw>,
) {
let listed: Vec<&str> = context.lineage(item.id).flat_map(Item::tags).collect();
for (term, phrase, hyphenated) in terms {
if listed.contains(term) {
continue;
}
if let Some(spot) = first_spot(phrase, *hyphenated, &texts.name, &texts.body) {
out.push(
Raw::new(
RuleId::Lq009,
vec![item.id.key()],
"The text uses a tag registry term the node does not list.",
)
.term(*term)
.line(spot.line()),
);
}
}
}
fn first_spot(phrase: &[String], hyphenated: bool, name: &Words, body: &Words) -> Option<Spot> {
if name.first(phrase, hyphenated).is_some() {
return Some(Spot::Name);
}
let at = body.first(phrase, hyphenated)?;
Some(Spot::Body(body.lines[at]))
}
const fn taggable(kind: NodeKind) -> bool {
matches!(
kind,
NodeKind::Product
| NodeKind::FeatureGroup
| NodeKind::Feature
| NodeKind::Requirement
| NodeKind::Component
)
}