diff --git a/crates/flavor-cli/src/naming/mod.rs b/crates/flavor-cli/src/naming/mod.rs index ff181ab..796ec50 100644 --- a/crates/flavor-cli/src/naming/mod.rs +++ b/crates/flavor-cli/src/naming/mod.rs @@ -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, rule: &RuleSettings, @@ -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, + 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 { +pub(crate) fn split_name_words(name: &str) -> Vec { let mut words = Vec::new(); let normalized = name .strip_prefix("r#") @@ -53,6 +101,71 @@ fn split_name_words(name: &str) -> Vec { 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, +} + +fn affix_buckets(names: &[NameFact]) -> Vec { + 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::>() + .join(", ") +} + fn split_camel_part(part: &str, words: &mut Vec) { let chars: Vec = part.chars().collect(); let mut current = String::new(); diff --git a/crates/flavor-cli/src/plugins/language.rs b/crates/flavor-cli/src/plugins/language.rs index 0dfd38b..9b10a8f 100644 --- a/crates/flavor-cli/src/plugins/language.rs +++ b/crates/flavor-cli/src/plugins/language.rs @@ -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}; @@ -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, ); @@ -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( diff --git a/crates/flavor-cli/src/plugins/language/name.rs b/crates/flavor-cli/src/plugins/language/name.rs index dc05866..5a0e984 100644 --- a/crates/flavor-cli/src/plugins/language/name.rs +++ b/crates/flavor-cli/src/plugins/language/name.rs @@ -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", @@ -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, ) { + 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 { @@ -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); } diff --git a/crates/flavor-cli/src/plugins/language/python.rs b/crates/flavor-cli/src/plugins/language/python.rs index 3fdb362..d454a0b 100644 --- a/crates/flavor-cli/src/plugins/language/python.rs +++ b/crates/flavor-cli/src/plugins/language/python.rs @@ -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, }, }; @@ -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, ); diff --git a/crates/flavor-cli/src/rules.rs b/crates/flavor-cli/src/rules.rs index 20396a5..302341a 100644 --- a/crates/flavor-cli/src/rules.rs +++ b/crates/flavor-cli/src/rules.rs @@ -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"; @@ -81,6 +82,15 @@ pub(crate) fn descriptor(rule_id: &str) -> Option { 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, @@ -299,6 +309,7 @@ pub(crate) fn descriptor(rule_id: &str) -> Option { 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, diff --git a/crates/flavor-cli/tests/unit/naming.rs b/crates/flavor-cli/tests/unit/naming.rs index a86700f..af30ba1 100644 --- a/crates/flavor-cli/tests/unit/naming.rs +++ b/crates/flavor-cli/tests/unit/naming.rs @@ -1,5 +1,7 @@ use std::{ + fs, path::{Path, PathBuf}, + sync::atomic::{AtomicUsize, Ordering}, sync::LazyLock, }; @@ -9,10 +11,11 @@ use crate::{ naming::count_name_words, path_match::path_string, plugins::{PluginHost, Scope}, - rules::{DISPATCH_BRANCH_TOO_LONG, VUE_PARSE_ERROR}, + rules::{DISPATCH_BRANCH_TOO_LONG, NAMING_AFFIX_PRESSURE, VUE_PARSE_ERROR}, }; static CONFIG: LazyLock = LazyLock::new(|| GuardConfig::core(PathBuf::from("."))); +static TEMP_SEQ: AtomicUsize = AtomicUsize::new(0); #[test] fn counts_name_words() { @@ -77,6 +80,51 @@ fn ts_detects_long_names() { .contains("controllerRuntimeResultValueText")); } +#[test] +fn ignores_small_affix_buckets() { + let relative = Path::new("sample.rs"); + let issues = source_issues( + relative, + r#" +fn name_fact() {} +fn line_count_fact() {} +fn descriptor_block_fact() {} +fn embedded_script_fact() {} +fn named_span_fact() {} +"#, + ); + + assert!(!issues + .iter() + .any(|issue| issue.rule == NAMING_AFFIX_PRESSURE)); +} + +#[test] +fn reports_affix_pressure() { + let relative = Path::new("sample.rs"); + let config = config_with_affix_threshold(3); + let issues = source_issues_with_config( + &config, + relative, + r#" +fn parse_token() {} +fn parse_statement() {} +fn parse_expression() {} +"#, + ); + + let issue = issues + .iter() + .find(|issue| issue.rule == NAMING_AFFIX_PRESSURE) + .expect("affix pressure issue"); + assert_eq!(issue.line, Some(2)); + assert!(issue + .message + .contains("3 function names share the prefix `parse`")); + assert!(issue.message.contains("move from the name into")); + assert!(issue.message.contains("too broad")); +} + #[test] fn vue_offsets_lines() { let relative = Path::new("Sample.vue"); @@ -168,14 +216,51 @@ fn flags_switch_case() { } fn source_issues(relative: &Path, source: &str) -> Vec { + source_issues_with_config(&CONFIG, relative, source) +} + +fn source_issues_with_config(config: &GuardConfig, relative: &Path, source: &str) -> Vec { let host = PluginHost::bundled(); let path = path_string(relative); let kind = source_file_kind(relative).expect("test source should have supported extension"); let mut issues = Vec::new(); host.run_scope( - &CONFIG, + config, Scope::source_file(relative, &path, source, kind), &mut issues, ); issues } + +fn config_with_affix_threshold(min_occurrences: usize) -> GuardConfig { + let root = temp_root("naming-affix"); + fs::create_dir_all(&root).unwrap(); + let path = root.join("flavor.toml"); + fs::write( + &path, + format!( + r#"[scan] +include = ["**/*.rs"] + +[[overrides]] +match = "**/*.rs" +kind = "file" + +[overrides.rules."core/naming/affix-pressure".payload] +min_occurrences = {min_occurrences} +"# + ), + ) + .unwrap(); + let config = GuardConfig::from_file(root.clone(), &path).unwrap(); + fs::remove_dir_all(root).unwrap(); + config +} + +fn temp_root(slug: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "flavor-{slug}-{}-{}", + std::process::id(), + TEMP_SEQ.fetch_add(1, Ordering::Relaxed) + )) +} diff --git a/crates/flavor-grammar/src/lib.rs b/crates/flavor-grammar/src/lib.rs index 0b5d243..65be422 100644 --- a/crates/flavor-grammar/src/lib.rs +++ b/crates/flavor-grammar/src/lib.rs @@ -9,6 +9,7 @@ mod source; #[cfg(feature = "tree-sitter-backend")] mod tree_sitter_raw; mod view; +mod vue; pub use markup::{ find_balanced_brace_close, find_html_comment_close, is_html_void_element, is_markup_name_char, @@ -29,6 +30,7 @@ pub use tree_sitter_raw::{ parse_tree_sitter, tree_sitter_error_span, TreeSitterParseConfig, TreeSitterRawAstAdapter, }; pub use view::{GrammarContext, GrammarNode, GrammarToken, GrammarTree, TokenTextRun}; +pub use vue::{parse_vue_sfc, parse_vue_template, VueSfcBlock, VueSfcDescriptor, VueSfcError}; #[derive(Debug, Clone, Eq, PartialEq)] pub struct GrammarMetadata { diff --git a/crates/flavor-grammar/src/vue/mod.rs b/crates/flavor-grammar/src/vue/mod.rs new file mode 100644 index 0000000..1696d8c --- /dev/null +++ b/crates/flavor-grammar/src/vue/mod.rs @@ -0,0 +1,5 @@ +mod sfc; +mod template; + +pub use sfc::{parse_vue_sfc, VueSfcBlock, VueSfcDescriptor, VueSfcError}; +pub use template::parse_vue_template; diff --git a/crates/flavor-plugin-vue/src/sfc/parser.rs b/crates/flavor-grammar/src/vue/sfc.rs similarity index 99% rename from crates/flavor-plugin-vue/src/sfc/parser.rs rename to crates/flavor-grammar/src/vue/sfc.rs index 7e9966d..92e710e 100644 --- a/crates/flavor-plugin-vue/src/sfc/parser.rs +++ b/crates/flavor-grammar/src/vue/sfc.rs @@ -38,7 +38,7 @@ struct CloseTag { end: usize, } -pub fn parse_sfc(source: &str) -> VueSfcDescriptor { +pub fn parse_vue_sfc(source: &str) -> VueSfcDescriptor { let mut descriptor = VueSfcDescriptor::default(); let mut cursor = 0; diff --git a/crates/flavor-plugin-vue/src/template/parser.rs b/crates/flavor-grammar/src/vue/template.rs similarity index 61% rename from crates/flavor-plugin-vue/src/template/parser.rs rename to crates/flavor-grammar/src/vue/template.rs index 8028b04..e8ec68e 100644 --- a/crates/flavor-plugin-vue/src/template/parser.rs +++ b/crates/flavor-grammar/src/vue/template.rs @@ -1,98 +1,122 @@ -use flavor_core::{Diagnostic, Span}; -use flavor_grammar::{ +use flavor_core::{Diagnostic, SourceText, Span}; + +use crate::{ find_html_comment_close, is_html_void_element, is_markup_name_char, markup_char_at, - scan_markup_name, RawAstBuilder, + scan_markup_name, GrammarBundle, GrammarParseOutput, RawAstBuilder, }; -use super::{ - kind, - kind::Kind, - names::{ - directive_base_len, is_attribute_name_char, is_directive_name, is_shorthand_directive, - is_whitespace, - }, - TemplateAst, -}; +type Kind = &'static str; + +const ROOT: Kind = "root"; +const ELEMENT: Kind = "element"; +const START_TAG: Kind = "start_tag"; +const END_TAG: Kind = "end_tag"; +const INTERPOLATION: Kind = "interpolation"; +const DIRECTIVE: Kind = "directive"; +const DIRECTIVE_NAME: Kind = "directive_name"; +const DIRECTIVE_EXPRESSION: Kind = "directive_expression"; +const COMMENT: Kind = "comment"; +const ATTRIBUTE: Kind = "attribute"; +const COMMENT_TEXT: Kind = "COMMENT_TEXT"; +const INTERPOLATION_OPEN: Kind = "INTERPOLATION_OPEN"; +const INTERPOLATION_CLOSE: Kind = "INTERPOLATION_CLOSE"; +const LESS_THAN: Kind = "LESS_THAN"; +const GREATER_THAN: Kind = "GREATER_THAN"; +const SLASH: Kind = "SLASH"; +const EQUALS: Kind = "EQUALS"; +const DIRECTIVE_BASE: Kind = "DIRECTIVE_BASE"; +const DIRECTIVE_ARGUMENT: Kind = "DIRECTIVE_ARGUMENT"; +const DIRECTIVE_MODIFIER: Kind = "DIRECTIVE_MODIFIER"; +const TAG_NAME: Kind = "TAG_NAME"; +const ATTRIBUTE_NAME: Kind = "ATTRIBUTE_NAME"; +const ATTRIBUTE_VALUE: Kind = "ATTRIBUTE_VALUE"; +const EXPRESSION_TEXT: Kind = "EXPRESSION_TEXT"; +const TEXT: Kind = "TEXT"; +const WHITESPACE: Kind = "WHITESPACE"; +const ERROR: Kind = "ERROR"; -pub fn parse_template(source: &str) -> TemplateAst { - let parser = TemplateParser::new(source); - parser.parse() +pub fn parse_vue_template(bundle: &GrammarBundle, source: SourceText) -> GrammarParseOutput { + let (syntax, diagnostics) = { + let parser = VueTemplateParser::new(source.as_str(), bundle); + parser.parse() + }; + GrammarParseOutput::new(source, syntax, diagnostics) } -struct TemplateParser<'a> { - source: &'a str, +struct VueTemplateParser<'source, 'schema> { + source: &'source str, cursor: usize, - builder: RawAstBuilder<'static>, + builder: RawAstBuilder<'schema>, diagnostics: Vec, } -impl<'a> TemplateParser<'a> { - fn new(source: &'a str) -> Self { +impl<'source, 'schema> VueTemplateParser<'source, 'schema> { + fn new(source: &'source str, bundle: &'schema GrammarBundle) -> Self { Self { source, cursor: 0, - builder: RawAstBuilder::new(kind::schema()), + builder: RawAstBuilder::new(bundle.schema()), diagnostics: Vec::new(), } } - fn parse(mut self) -> TemplateAst { - self.builder.start_node(kind::ROOT); - self.parse_children(None); + fn parse(mut self) -> (flavor_core::SyntaxNode, Vec) { + self.builder.start_node(ROOT); + self.children(None); self.builder.finish_node(); - TemplateAst::new(self.builder.finish(), self.diagnostics) + (self.builder.finish(), self.diagnostics) } - fn parse_children(&mut self, parent_tag: Option<&str>) -> bool { + fn children(&mut self, parent_tag: Option<&str>) -> bool { while self.cursor < self.source.len() { if self.source[self.cursor..].starts_with(" expr_start { self.builder - .token(kind::EXPRESSION_TEXT, &self.source[expr_start..self.cursor]); + .token(EXPRESSION_TEXT, &self.source[expr_start..self.cursor]); } if self.source[self.cursor..].starts_with("}}") { - self.token_len(kind::INTERPOLATION_CLOSE, 2); + self.token_len(INTERPOLATION_CLOSE, 2); } else { self.error_at(start, "missing interpolation close delimiter"); } self.builder.finish_node(); } - fn parse_comment(&mut self) { + fn comment(&mut self) { let start = self.cursor; - self.builder.start_node(kind::COMMENT); + self.builder.start_node(COMMENT); match find_html_comment_close(self.source, self.cursor) { Some(end) => self.cursor = end, None => { @@ -101,24 +125,24 @@ impl<'a> TemplateParser<'a> { } } self.builder - .token(kind::COMMENT_TEXT, &self.source[start..self.cursor]); + .token(COMMENT_TEXT, &self.source[start..self.cursor]); self.builder.finish_node(); } - fn parse_element(&mut self) { + fn element(&mut self) { let start = self.cursor; - self.builder.start_node(kind::ELEMENT); - let Some(tag) = self.parse_start_tag() else { + self.builder.start_node(ELEMENT); + let Some(tag) = self.start_tag() else { self.builder.finish_node(); return; }; if tag.v_pre { - let matched = self.parse_raw_children(&tag.name); + let matched = self.raw_children(&tag.name); if !matched { self.error_at(start, format!("missing closing tag", tag.name)); } } else if !tag.self_closing && !is_html_void_element(&tag.name) { - let matched = self.parse_children(Some(&tag.name)); + let matched = self.children(Some(&tag.name)); if !matched { self.error_at(start, format!("missing closing tag", tag.name)); } @@ -126,21 +150,21 @@ impl<'a> TemplateParser<'a> { self.builder.finish_node(); } - fn parse_start_tag(&mut self) -> Option { - self.builder.start_node(kind::START_TAG); - self.token_len(kind::LESS_THAN, 1); + fn start_tag(&mut self) -> Option { + self.builder.start_node(START_TAG); + self.token_len(LESS_THAN, 1); let name_start = self.cursor; self.cursor = scan_markup_name(self.source, self.cursor, is_markup_name_char); if self.cursor == name_start { self.error_at(name_start.saturating_sub(1), "expected tag name"); - self.parse_bad_tag_tail(); + self.bad_tag_tail(); self.builder.finish_node(); return None; } let name = self.source[name_start..self.cursor].to_string(); self.builder - .token(kind::TAG_NAME, &self.source[name_start..self.cursor]); - let tail = self.parse_tag_tail(); + .token(TAG_NAME, &self.source[name_start..self.cursor]); + let tail = self.tag_tail(); self.builder.finish_node(); Some(ParsedTag { name, @@ -149,103 +173,98 @@ impl<'a> TemplateParser<'a> { }) } - fn parse_end_tag(&mut self) { - self.builder.start_node(kind::END_TAG); - self.token_len(kind::LESS_THAN, 1); - self.token_len(kind::SLASH, 1); + fn end_tag(&mut self) { + self.builder.start_node(END_TAG); + self.token_len(LESS_THAN, 1); + self.token_len(SLASH, 1); let name_start = self.cursor; self.cursor = scan_markup_name(self.source, self.cursor, is_markup_name_char); if self.cursor == name_start { self.error_at(name_start.saturating_sub(2), "expected closing tag name"); } else { self.builder - .token(kind::TAG_NAME, &self.source[name_start..self.cursor]); + .token(TAG_NAME, &self.source[name_start..self.cursor]); } - self.parse_tag_tail(); + self.tag_tail(); self.builder.finish_node(); } - fn parse_tag_tail(&mut self) -> ParsedTagTail { + fn tag_tail(&mut self) -> ParsedTagTail { let mut tail = ParsedTagTail::default(); while self.cursor < self.source.len() { if self.source[self.cursor..].starts_with("/>") { - self.token_len(kind::SLASH, 1); - self.token_len(kind::GREATER_THAN, 1); + self.token_len(SLASH, 1); + self.token_len(GREATER_THAN, 1); tail.self_closing = true; return tail; } if self.source[self.cursor..].starts_with('>') { - self.token_len(kind::GREATER_THAN, 1); + self.token_len(GREATER_THAN, 1); return tail; } if self.peek().is_some_and(is_whitespace) { - self.parse_whitespace(); + self.whitespace(); } else { - tail.v_pre |= self.parse_attribute(); + tail.v_pre |= self.attribute(); } } self.error_at(self.source.len(), "missing tag close delimiter"); tail } - fn parse_attribute(&mut self) -> bool { + fn attribute(&mut self) -> bool { let name_start = self.cursor; while self.peek().is_some_and(is_attribute_name_char) { self.bump(); } if self.cursor == name_start { - self.parse_error_char(); + self.error_char(); return false; } let name = &self.source[name_start..self.cursor]; let is_directive = is_directive_name(name); let is_v_pre = name == "v-pre"; - self.builder.start_node(if is_directive { - kind::DIRECTIVE - } else { - kind::ATTRIBUTE - }); + self.builder + .start_node(if is_directive { DIRECTIVE } else { ATTRIBUTE }); if is_directive { - self.parse_directive_name(name); + self.directive_name(name); } else { - self.builder.token(kind::ATTRIBUTE_NAME, name); + self.builder.token(ATTRIBUTE_NAME, name); } while self.peek().is_some_and(is_whitespace) { - self.parse_whitespace(); + self.whitespace(); } if self.source[self.cursor..].starts_with('=') { - self.token_len(kind::EQUALS, 1); + self.token_len(EQUALS, 1); while self.peek().is_some_and(is_whitespace) { - self.parse_whitespace(); + self.whitespace(); } if is_directive { - self.builder.start_node(kind::DIRECTIVE_EXPRESSION); - self.parse_attribute_value(); + self.builder.start_node(DIRECTIVE_EXPRESSION); + self.attribute_value(); self.builder.finish_node(); } else { - self.parse_attribute_value(); + self.attribute_value(); } } self.builder.finish_node(); is_v_pre } - fn parse_directive_name(&mut self, name: &str) { - self.builder.start_node(kind::DIRECTIVE_NAME); + fn directive_name(&mut self, name: &str) { + self.builder.start_node(DIRECTIVE_NAME); let shorthand = is_shorthand_directive(name); let mut offset = directive_base_len(name); - self.builder.token(kind::DIRECTIVE_BASE, &name[..offset]); + self.builder.token(DIRECTIVE_BASE, &name[..offset]); if shorthand && offset < name.len() { let start = offset; offset = scan_arg(name, offset); - self.builder - .token(kind::DIRECTIVE_ARGUMENT, &name[start..offset]); + self.builder.token(DIRECTIVE_ARGUMENT, &name[start..offset]); } else if offset < name.len() && name.as_bytes()[offset] == b':' { let start = offset; offset += 1; offset = scan_arg(name, offset); - self.builder - .token(kind::DIRECTIVE_ARGUMENT, &name[start..offset]); + self.builder.token(DIRECTIVE_ARGUMENT, &name[start..offset]); } while offset < name.len() && name.as_bytes()[offset] == b'.' { let start = offset; @@ -253,13 +272,12 @@ impl<'a> TemplateParser<'a> { while offset < name.len() && name.as_bytes()[offset] != b'.' { offset += 1; } - self.builder - .token(kind::DIRECTIVE_MODIFIER, &name[start..offset]); + self.builder.token(DIRECTIVE_MODIFIER, &name[start..offset]); } self.builder.finish_node(); } - fn parse_attribute_value(&mut self) { + fn attribute_value(&mut self) { let start = self.cursor; let Some(ch) = self.peek() else { return; @@ -286,20 +304,20 @@ impl<'a> TemplateParser<'a> { } if self.cursor > start { self.builder - .token(kind::ATTRIBUTE_VALUE, &self.source[start..self.cursor]); + .token(ATTRIBUTE_VALUE, &self.source[start..self.cursor]); } } - fn parse_whitespace(&mut self) { + fn whitespace(&mut self) { let start = self.cursor; while self.peek().is_some_and(is_whitespace) { self.bump(); } self.builder - .token(kind::WHITESPACE, &self.source[start..self.cursor]); + .token(WHITESPACE, &self.source[start..self.cursor]); } - fn parse_text(&mut self) { + fn text(&mut self) { let start = self.cursor; while self.cursor < self.source.len() && !self.source[self.cursor..].starts_with('<') @@ -308,53 +326,48 @@ impl<'a> TemplateParser<'a> { self.bump(); } if self.cursor > start { - self.builder - .token(kind::TEXT, &self.source[start..self.cursor]); + self.builder.token(TEXT, &self.source[start..self.cursor]); } } - fn parse_raw_children(&mut self, parent_tag: &str) -> bool { + fn raw_children(&mut self, parent_tag: &str) -> bool { let start = self.cursor; while self.cursor < self.source.len() { if self.source[self.cursor..].starts_with(" start { - self.builder - .token(kind::TEXT, &self.source[start..self.cursor]); + self.builder.token(TEXT, &self.source[start..self.cursor]); } - self.parse_end_tag(); + self.end_tag(); return true; } } self.bump(); } if self.cursor > start { - self.builder - .token(kind::TEXT, &self.source[start..self.cursor]); + self.builder.token(TEXT, &self.source[start..self.cursor]); } false } - fn parse_bad_tag_tail(&mut self) { + fn bad_tag_tail(&mut self) { let start = self.cursor; while self.cursor < self.source.len() && !self.source[self.cursor..].starts_with('>') { self.bump(); } if self.cursor > start { - self.builder - .token(kind::ERROR, &self.source[start..self.cursor]); + self.builder.token(ERROR, &self.source[start..self.cursor]); } if self.source[self.cursor..].starts_with('>') { - self.token_len(kind::GREATER_THAN, 1); + self.token_len(GREATER_THAN, 1); } } - fn parse_error_char(&mut self) { + fn error_char(&mut self) { let start = self.cursor; self.bump(); - self.builder - .token(kind::ERROR, &self.source[start..self.cursor]); + self.builder.token(ERROR, &self.source[start..self.cursor]); self.error_at(start, "unexpected token in tag"); } @@ -402,6 +415,32 @@ struct ParsedTagTail { v_pre: bool, } +fn is_attribute_name_char(ch: char) -> bool { + !is_whitespace(ch) && !matches!(ch, '=' | '>' | '/' | '"' | '\'') +} + +fn is_directive_name(name: &str) -> bool { + name.starts_with("v-") + || name.starts_with(':') + || name.starts_with('@') + || name.starts_with('#') +} + +fn directive_base_len(name: &str) -> usize { + if is_shorthand_directive(name) { + return 1; + } + name.find([':', '.']).unwrap_or(name.len()) +} + +fn is_shorthand_directive(name: &str) -> bool { + name.starts_with(':') || name.starts_with('@') || name.starts_with('#') +} + +fn is_whitespace(ch: char) -> bool { + matches!(ch, ' ' | '\n' | '\r' | '\t') +} + fn scan_arg(name: &str, mut offset: usize) -> usize { if name.as_bytes().get(offset) == Some(&b'[') { offset += 1; diff --git a/crates/flavor-grammar/tests/architecture.rs b/crates/flavor-grammar/tests/architecture.rs index 4a88f47..0f930e3 100644 --- a/crates/flavor-grammar/tests/architecture.rs +++ b/crates/flavor-grammar/tests/architecture.rs @@ -182,7 +182,6 @@ fn plugin_raw_builders_tracked() { vec![ "crates/flavor-plugin-svelte/src/markup/parser.rs".to_string(), "crates/flavor-plugin-typescript/src/parser/mod.rs".to_string(), - "crates/flavor-plugin-vue/src/template/parser.rs".to_string(), ], "remaining plugin-side raw CST builders must stay explicit until migrated into flavor-grammar" ); @@ -205,16 +204,17 @@ fn markup_atoms_in_grammar() { ); } - for path in [ - "crates/flavor-plugin-vue/src/template/names.rs", - "crates/flavor-plugin-svelte/src/markup/names.rs", - ] { - let source = read_repo(path); - assert!( - !source.contains("fn source_char_at") && !source.contains("fn is_void_tag"), - "{path} should not duplicate grammar-owned markup cursor or void-element atoms" - ); - } + let source = read_repo("crates/flavor-plugin-svelte/src/markup/names.rs"); + assert!( + !source.contains("fn source_char_at") && !source.contains("fn is_void_tag"), + "Svelte markup names should not duplicate grammar-owned markup cursor or void-element atoms" + ); + let vue_template = read_repo("crates/flavor-grammar/src/vue/template.rs"); + assert!( + vue_template.contains("fn is_directive_name") + && vue_template.contains("fn is_attribute_name_char"), + "Vue template parser atoms should live with the grammar-owned backend" + ); assert!( !repo_path("crates/flavor-plugin-svelte/src/markup/cursor.rs").exists(), "Svelte markup should use grammar-owned brace close scanning" @@ -258,8 +258,6 @@ fn parser_escapes_tracked() { "crates/flavor-plugin-typescript/src/parser/modules.rs".to_string(), "crates/flavor-plugin-typescript/src/parser/statements.rs".to_string(), "crates/flavor-plugin-typescript/src/parser/types.rs".to_string(), - "crates/flavor-plugin-vue/src/sfc/parser.rs".to_string(), - "crates/flavor-plugin-vue/src/template/parser.rs".to_string(), ], "remaining plugin-side parser execution files must stay explicit until migrated into flavor-grammar" ); @@ -305,7 +303,8 @@ fn fact_atoms_used() { fn builder_files() -> Vec { let mut files = vec![ "crates/flavor-grammar/src/tree_sitter_raw.rs".to_string(), - "crates/flavor-plugin-vue/src/template/parser.rs".to_string(), + "crates/flavor-grammar/src/vue/template.rs".to_string(), + "crates/flavor-grammar/src/vue/sfc.rs".to_string(), ]; collect_rs_files("crates/flavor-plugin-typescript/src/parser", &mut files); collect_rs_files("crates/flavor-plugin-svelte/src/markup", &mut files); diff --git a/crates/flavor-plugin-svelte/src/markup/parser.rs b/crates/flavor-plugin-svelte/src/markup/parser.rs index 9b3c982..3033150 100644 --- a/crates/flavor-plugin-svelte/src/markup/parser.rs +++ b/crates/flavor-plugin-svelte/src/markup/parser.rs @@ -40,77 +40,77 @@ impl<'a> MarkupParser<'a> { fn parse(mut self) -> SvelteMarkupAst { self.builder.start_node(kind::ROOT); - self.parse_children(None); + self.children(None); self.builder.finish_node(); SvelteMarkupAst::new(self.builder.finish(), self.diagnostics) } - fn parse_children(&mut self, parent_tag: Option<&str>) -> bool { + fn children(&mut self, parent_tag: Option<&str>) -> bool { while self.cursor < self.source.len() { if self.source[self.cursor..].starts_with(" bool { + fn block_children(&mut self, keyword: &str) -> bool { while self.cursor < self.source.len() { if self.source[self.cursor..].starts_with("{/") { let close_keyword = self.peek_block_keyword(2); if close_keyword.as_deref() == Some(keyword) { - self.parse_block_tag(kind::BLOCK_CLOSE, 2); + self.block_tag(kind::BLOCK_CLOSE, 2); return true; } return false; } if self.source[self.cursor..].starts_with("{:") { - self.parse_block_tag(kind::BLOCK_BRANCH, 2); + self.block_tag(kind::BLOCK_BRANCH, 2); } else { - self.parse_child(); + self.child(); } } false } - fn parse_child(&mut self) { + fn child(&mut self) { if self.source[self.cursor..].starts_with("\n\