use super::context::{Context, Item};
use super::text::{Body, occurrences, phrase_positions, shown_tokens, tokens};
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 check(context: &Context<'_>, config: &LintConfig, out: &mut Vec<Raw>) {
for item in &context.items {
let assertion = is_assertion(item.id.kind());
if !assertion && item.body().is_none() {
continue;
}
let texts = Texts::read(item, 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);
}
}
super::similarity::check(context, config, out);
untagged_terms(context, 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: Vec<String>,
body: Vec<String>,
lines: Vec<usize>,
sentences: Vec<(Vec<String>, usize)>,
}
impl Texts {
fn read(item: &Item<'_>, assertion: bool) -> Self {
let body = item.body().map(Body::read);
let (tokens_, lines) = body.as_ref().map(Body::tokens).unwrap_or_default();
let sentences = body.as_ref().filter(|_| assertion).map(Body::sentences);
Self {
key: item.id.key(),
name: if assertion {
shown_tokens(&item.name())
} else {
Vec::new()
},
body: tokens_,
lines,
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, phrase)
} else {
Vec::new()
};
let name = names
.into_iter()
.filter(|at| keep(&self.name, *at))
.map(|_| Spot::Name)
.collect::<Vec<_>>();
let body = phrase_positions(&self.body, phrase)
.into_iter()
.filter(move |at| keep(&self.body, *at))
.map(|at| Spot::Body(self.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) {
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.",
));
}
}
fn untagged_terms(context: &Context<'_>, out: &mut Vec<Raw>) {
let mut entries: Vec<_> = context.document.tags.iter().collect();
entries.sort_unstable_by_key(|entry| entry.id);
for item in context.items.iter().filter(|item| taggable(item.id.kind())) {
let name = shown_tokens(&item.name());
let body = item.body().map(|body| Body::read(body).tokens());
for entry in &entries {
let phrase = tokens(entry.name.as_str());
let covered = context
.lineage(item.id)
.any(|above| above.tags().any(|tag| tag == entry.name.as_str()));
if phrase.is_empty() || covered {
continue;
}
if let Some(spot) = first_spot(&phrase, &name, body.as_ref()) {
out.push(
Raw::new(
RuleId::Lq009,
vec![item.id.key()],
"The text uses a tag registry term the node does not list.",
)
.term(entry.name.as_str())
.line(spot.line()),
);
}
}
}
}
fn first_spot(
phrase: &[String],
name: &[String],
body: Option<&(Vec<String>, Vec<usize>)>,
) -> Option<Spot> {
if !phrase_positions(name, phrase).is_empty() {
return Some(Spot::Name);
}
let (tokens, lines) = body?;
let at = *phrase_positions(tokens, phrase).first()?;
Some(Spot::Body(lines[at]))
}
const fn taggable(kind: NodeKind) -> bool {
matches!(
kind,
NodeKind::Product
| NodeKind::FeatureGroup
| NodeKind::Feature
| NodeKind::Requirement
| NodeKind::Component
)
}