Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 115 additions & 2 deletions crates/flavor-cli/src/naming/mod.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
use crate::{
config::RuleSettings,
model::{issue, Issue},
rules::PAYLOAD_MAX_WORDS,
rules::{PAYLOAD_MAX_WORDS, PAYLOAD_MIN_OCCURRENCES},
};

const DEFAULT_AFFIX_MIN_OCCURRENCES: usize = 15;
const MAX_EXAMPLES: usize = 5;
const IGNORED_AFFIXES: &[&str] = &[
"new", "default", "test", "tests", "ok", "run", "runs", "main", "mod",
];

pub(crate) fn check_name(
issues: &mut Vec<Issue>,
rule: &RuleSettings,
Expand Down Expand Up @@ -31,11 +37,53 @@ pub(crate) fn check_name(
));
}

#[derive(Debug, Clone, Eq, PartialEq)]
pub(crate) struct NameFact {
pub(crate) kind: String,
pub(crate) name: String,
pub(crate) line: usize,
}

pub(crate) fn check_affix_pressure(
issues: &mut Vec<Issue>,
rule: &RuleSettings,
path: &str,
names: &[NameFact],
) {
if !rule.enabled {
return;
}
let min_occurrences = rule
.usize(PAYLOAD_MIN_OCCURRENCES)
.unwrap_or(DEFAULT_AFFIX_MIN_OCCURRENCES);
for bucket in affix_buckets(names) {
if bucket.names.len() < min_occurrences {
continue;
}
issues.push(issue(
rule.severity,
rule.id,
path,
Some(bucket.line),
format!(
"{} {} names share the {} `{}`; consider whether `{}` wants to move from the name into a module, namespace, type, or factory; if the remaining names do not form a clear family, `{}` may be too broad and should be named more sharply; examples: {}",
bucket.names.len(),
bucket.kind,
bucket.side.label(),
bucket.affix,
bucket.affix,
bucket.affix,
examples(&bucket.names),
),
));
}
}

pub(crate) fn count_name_words(name: &str) -> usize {
split_name_words(name).len()
}

fn split_name_words(name: &str) -> Vec<String> {
pub(crate) fn split_name_words(name: &str) -> Vec<String> {
let mut words = Vec::new();
let normalized = name
.strip_prefix("r#")
Expand All @@ -53,6 +101,71 @@ fn split_name_words(name: &str) -> Vec<String> {
words
}

#[derive(Debug, Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
enum AffixSide {
Prefix,
Suffix,
}

impl AffixSide {
fn label(self) -> &'static str {
match self {
AffixSide::Prefix => "prefix",
AffixSide::Suffix => "suffix",
}
}
}

#[derive(Debug, Clone, Eq, PartialEq)]
struct AffixBucket {
kind: String,
side: AffixSide,
affix: String,
line: usize,
names: Vec<String>,
}

fn affix_buckets(names: &[NameFact]) -> Vec<AffixBucket> {
use std::collections::BTreeMap;

let mut buckets = BTreeMap::<(String, AffixSide, String), AffixBucket>::new();
for fact in names {
let words = split_name_words(&fact.name);
if words.len() < 2 {
continue;
}
for (side, affix) in [
(AffixSide::Prefix, words.first().unwrap()),
(AffixSide::Suffix, words.last().unwrap()),
] {
let affix = affix.to_ascii_lowercase();
if IGNORED_AFFIXES.contains(&affix.as_str()) {
continue;
}
let key = (fact.kind.clone(), side, affix.clone());
let bucket = buckets.entry(key).or_insert_with(|| AffixBucket {
kind: fact.kind.clone(),
side,
affix,
line: fact.line,
names: Vec::new(),
});
bucket.line = bucket.line.min(fact.line);
bucket.names.push(fact.name.clone());
}
}
buckets.into_values().collect()
}

fn examples(names: &[String]) -> String {
names
.iter()
.take(MAX_EXAMPLES)
.map(|name| format!("`{name}`"))
.collect::<Vec<_>>()
.join(", ")
}

fn split_camel_part(part: &str, words: &mut Vec<String>) {
let chars: Vec<char> = part.chars().collect();
let mut current = String::new();
Expand Down
26 changes: 19 additions & 7 deletions crates/flavor-cli/src/plugins/language.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,12 @@ use crate::{
model::{issue, Issue},
plugins::{AnalysisContext, PluginOutput, ProductSet, SourceFileScope},
rules::{
DISPATCH_BRANCH_TOO_LONG, NAMING_TOO_MANY_WORDS, PAYLOAD_ALLOWED_INTRINSICS,
PAYLOAD_MAX_BLOCKS, PAYLOAD_MAX_LINES, PAYLOAD_PRIMITIVE_SOURCES, RUST_PARSE_ERROR,
RUST_TESTS_IN_SOURCE, SHAPE_REPEATED_TOKEN_PATTERN, SVELTE_COMPONENT_TOO_LONG,
SVELTE_PARSE_ERROR, SVELTE_SCRIPT_TOO_LONG, SVELTE_STYLE_TOO_LONG,
SVELTE_TEMPLATE_TOO_COMPLEX, TSX_NO_INTRINSICS, TSX_REQUIRES_PRIMITIVE, TS_PARSE_ERROR,
VUE_PARSE_ERROR,
DISPATCH_BRANCH_TOO_LONG, NAMING_AFFIX_PRESSURE, NAMING_TOO_MANY_WORDS,
PAYLOAD_ALLOWED_INTRINSICS, PAYLOAD_MAX_BLOCKS, PAYLOAD_MAX_LINES,
PAYLOAD_PRIMITIVE_SOURCES, RUST_PARSE_ERROR, RUST_TESTS_IN_SOURCE,
SHAPE_REPEATED_TOKEN_PATTERN, SVELTE_COMPONENT_TOO_LONG, SVELTE_PARSE_ERROR,
SVELTE_SCRIPT_TOO_LONG, SVELTE_STYLE_TOO_LONG, SVELTE_TEMPLATE_TOO_COMPLEX,
TSX_NO_INTRINSICS, TSX_REQUIRES_PRIMITIVE, TS_PARSE_ERROR, VUE_PARSE_ERROR,
},
};
use flavor_core::{Fact, ProductDiagnostic};
Expand All @@ -52,10 +52,14 @@ pub(crate) fn analyze_rust_source<'a>(context: &AnalysisContext<'a>) -> PluginOu
let name_rule = context
.config
.rule(scope.relative, NodeKind::File, NAMING_TOO_MANY_WORDS);
let affix_rule = context
.config
.rule(scope.relative, NodeKind::File, NAMING_AFFIX_PRESSURE);
check_name_facts(
&context.products,
"rust",
&name_rule,
&affix_rule,
scope.path,
&mut issues,
);
Expand Down Expand Up @@ -164,7 +168,15 @@ fn analyze_typescript_products(
check_tsx_rules(config, scope, products, issues);

let name_rule = config.rule(scope.relative, NodeKind::File, NAMING_TOO_MANY_WORDS);
check_name_facts(products, "typescript", &name_rule, scope.path, issues);
let affix_rule = config.rule(scope.relative, NodeKind::File, NAMING_AFFIX_PRESSURE);
check_name_facts(
products,
"typescript",
&name_rule,
&affix_rule,
scope.path,
issues,
);

let dispatch_rule = config.rule(scope.relative, NodeKind::File, DISPATCH_BRANCH_TOO_LONG);
check_dispatch_branches(
Expand Down
17 changes: 16 additions & 1 deletion crates/flavor-cli/src/plugins/language/name.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
use crate::{config::RuleSettings, model::Issue, naming::check_name, plugins::ProductSet};
use crate::{
config::RuleSettings,
model::Issue,
naming::{check_affix_pressure, check_name, NameFact},
plugins::ProductSet,
};

const NAME_FACT_KEYS: &[&str] = &[
"name.function",
Expand All @@ -11,9 +16,11 @@ pub(super) fn check_name_facts(
products: &ProductSet,
grammar_id: &'static str,
rule: &RuleSettings,
affix_rule: &RuleSettings,
path: &str,
issues: &mut Vec<Issue>,
) {
let mut affix_names = Vec::new();
for key in NAME_FACT_KEYS {
for fact in products.facts(grammar_id, key) {
let Some(name) = fact.text("name") else {
Expand All @@ -27,6 +34,14 @@ pub(super) fn check_name_facts(
.or_else(|| fact.text("kind"))
.unwrap_or("name");
check_name(issues, rule, path, line, kind, name);
if matches!(key, &"name.function" | &"name.method") {
affix_names.push(NameFact {
kind: kind.to_string(),
name: name.to_string(),
line,
});
}
}
}
check_affix_pressure(issues, affix_rule, path, &affix_names);
}
7 changes: 6 additions & 1 deletion crates/flavor-cli/src/plugins/language/python.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ use crate::{
config::NodeKind,
plugins::{AnalysisContext, PluginOutput},
rules::{
DISPATCH_BRANCH_TOO_LONG, FUNCTION_TOO_LONG, NAMING_TOO_MANY_WORDS, PYTHON_PARSE_ERROR,
DISPATCH_BRANCH_TOO_LONG, FUNCTION_TOO_LONG, NAMING_AFFIX_PRESSURE, NAMING_TOO_MANY_WORDS,
PYTHON_PARSE_ERROR,
},
};

Expand All @@ -28,10 +29,14 @@ pub(crate) fn analyze_python_source<'a>(context: &AnalysisContext<'a>) -> Plugin
let name_rule = context
.config
.rule(scope.relative, NodeKind::File, NAMING_TOO_MANY_WORDS);
let affix_rule = context
.config
.rule(scope.relative, NodeKind::File, NAMING_AFFIX_PRESSURE);
check_name_facts(
&context.products,
"python",
&name_rule,
&affix_rule,
scope.path,
&mut issues,
);
Expand Down
11 changes: 11 additions & 0 deletions crates/flavor-cli/src/rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use serde_json::Value;
use crate::model::Severity;

pub(crate) const NAMING_TOO_MANY_WORDS: &str = "core/naming/too-many-words";
pub(crate) const NAMING_AFFIX_PRESSURE: &str = "core/naming/affix-pressure";
pub(crate) const DISPATCH_BRANCH_TOO_LONG: &str = "core/dispatch/branch-too-long";
pub(crate) const FUNCTION_TOO_LONG: &str = "core/function/too-long";
pub(crate) const FS_CHILDREN_SHAPE: &str = "core/fs/children-shape";
Expand Down Expand Up @@ -81,6 +82,15 @@ pub(crate) fn descriptor(rule_id: &str) -> Option<RuleDescriptor> {
bad_flavor: "Names may be carrying scenario, path, or assertion context that belongs near an owner boundary.",
action_hint: "Consider lifting repeated context into a namespace, object, class, module, impl block, or test module before shortening names.",
}),
NAMING_AFFIX_PRESSURE => Some(RuleDescriptor {
id: NAMING_AFFIX_PRESSURE,
target: RuleTarget::File,
default_enabled: true,
default_severity: Severity::Warning,
default_payload: payload([(PAYLOAD_MIN_OCCURRENCES, 15)]),
bad_flavor: "A repeated naming prefix or suffix may be acting like structure while still living inside identifiers.",
action_hint: "Consider moving the affix into a module, namespace, type, or factory; if the remaining names do not form a clear family, sharpen the repeated word instead.",
}),
DISPATCH_BRANCH_TOO_LONG => Some(RuleDescriptor {
id: DISPATCH_BRANCH_TOO_LONG,
target: RuleTarget::File,
Expand Down Expand Up @@ -299,6 +309,7 @@ pub(crate) fn descriptor(rule_id: &str) -> Option<RuleDescriptor> {
pub(crate) fn known_rule_ids() -> Vec<&'static str> {
vec![
NAMING_TOO_MANY_WORDS,
NAMING_AFFIX_PRESSURE,
DISPATCH_BRANCH_TOO_LONG,
FUNCTION_TOO_LONG,
FS_CHILDREN_SHAPE,
Expand Down
Loading