Skip to content

Commit 8934a2f

Browse files
Add term validation and reference checking infrastructure
Adds validation for Fluent terms and message references: - TermInfo: Struct to track term attributes - collect_terms_from_resource: Extract all terms from a resource - check_references: Validate message/term references in a resource - check_pattern_references: Check refs within patterns - check_expression_references: Validate individual expression references This infrastructure enables detecting: - Unknown message/term references - Missing attributes on messages/terms - Positional arguments to terms (which are ignored per Fluent spec)
1 parent 866ae0a commit 8934a2f

File tree

1 file changed

+208
-0
lines changed

1 file changed

+208
-0
lines changed

src/lib.rs

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,214 @@ fn collect_expression_references(
316316
}
317317
}
318318

319+
/// Helper struct to hold term information for validation
320+
#[derive(Debug, Clone)]
321+
struct TermInfo {
322+
attributes: HashSet<String>,
323+
}
324+
325+
/// Collect all term definitions from a resource
326+
fn collect_terms_from_resource(resource: &FluentResource) -> HashMap<String, TermInfo> {
327+
use fluent_syntax::ast;
328+
let mut terms = HashMap::new();
329+
330+
for entry in resource.entries() {
331+
if let ast::Entry::Term(term) = entry {
332+
let term_id = format!("-{}", term.id.name);
333+
let attributes: HashSet<String> = term
334+
.attributes
335+
.iter()
336+
.map(|attr| attr.id.name.to_string())
337+
.collect();
338+
terms.insert(term_id, TermInfo { attributes });
339+
}
340+
}
341+
342+
terms
343+
}
344+
345+
/// Helper function to check all references in a resource against the bundle and available terms
346+
fn check_references(
347+
resource: &FluentResource,
348+
bundle: &FluentBundle<FluentResource>,
349+
available_terms: &HashMap<String, TermInfo>,
350+
) -> Vec<ValidationError> {
351+
use fluent_syntax::ast;
352+
let mut errors = Vec::new();
353+
354+
for entry in resource.entries() {
355+
match entry {
356+
ast::Entry::Message(msg) => {
357+
let msg_id = msg.id.name.to_string();
358+
check_pattern_references(bundle, &msg.value, &msg_id, available_terms, &mut errors);
359+
for attr in &msg.attributes {
360+
check_pattern_references(
361+
bundle,
362+
&Some(attr.value.clone()),
363+
&msg_id,
364+
available_terms,
365+
&mut errors,
366+
);
367+
}
368+
}
369+
ast::Entry::Term(term) => {
370+
let term_id = format!("-{}", term.id.name);
371+
check_pattern_references(
372+
bundle,
373+
&Some(term.value.clone()),
374+
&term_id,
375+
available_terms,
376+
&mut errors,
377+
);
378+
for attr in &term.attributes {
379+
check_pattern_references(
380+
bundle,
381+
&Some(attr.value.clone()),
382+
&term_id,
383+
available_terms,
384+
&mut errors,
385+
);
386+
}
387+
}
388+
_ => {}
389+
}
390+
}
391+
392+
errors
393+
}
394+
395+
fn check_pattern_references(
396+
bundle: &FluentBundle<FluentResource>,
397+
pattern: &Option<fluent_syntax::ast::Pattern<&str>>,
398+
current_msg_id: &str,
399+
available_terms: &HashMap<String, TermInfo>,
400+
errors: &mut Vec<ValidationError>,
401+
) {
402+
use fluent_syntax::ast;
403+
404+
if let Some(pattern) = pattern {
405+
for element in &pattern.elements {
406+
if let ast::PatternElement::Placeable { expression } = element {
407+
check_expression_references(
408+
bundle,
409+
expression,
410+
current_msg_id,
411+
available_terms,
412+
errors,
413+
);
414+
}
415+
}
416+
}
417+
}
418+
419+
fn check_expression_references(
420+
bundle: &FluentBundle<FluentResource>,
421+
expression: &fluent_syntax::ast::Expression<&str>,
422+
current_msg_id: &str,
423+
available_terms: &HashMap<String, TermInfo>,
424+
errors: &mut Vec<ValidationError>,
425+
) {
426+
use fluent_syntax::ast;
427+
428+
match expression {
429+
ast::Expression::Inline(inline) => {
430+
match inline {
431+
ast::InlineExpression::MessageReference { id, attribute } => {
432+
if !bundle.has_message(id.name) {
433+
errors.push(ValidationError {
434+
error_type: "UnknownMessage".to_string(),
435+
message: format!("Unknown message: {}", id.name),
436+
message_id: Some(current_msg_id.to_string()),
437+
reference: Some(id.name.to_string()),
438+
});
439+
} else if let Some(attr) = attribute {
440+
if let Some(msg) = bundle.get_message(id.name) {
441+
if msg.get_attribute(attr.name).is_none() {
442+
errors.push(ValidationError {
443+
error_type: "UnknownAttribute".to_string(),
444+
message: format!(
445+
"Unknown attribute: {}.{}",
446+
id.name, attr.name
447+
),
448+
message_id: Some(current_msg_id.to_string()),
449+
reference: Some(format!("{}.{}", id.name, attr.name)),
450+
});
451+
}
452+
}
453+
}
454+
}
455+
ast::InlineExpression::TermReference {
456+
id,
457+
attribute,
458+
arguments,
459+
} => {
460+
let term_id = format!("-{}", id.name);
461+
462+
// Validate that terms don't receive positional arguments
463+
// Per Fluent spec, positional arguments to terms are ignored, so we warn about them
464+
if let Some(args) = arguments {
465+
if !args.positional.is_empty() {
466+
errors.push(ValidationError {
467+
error_type: "IgnoredPositionalArgument".to_string(),
468+
message: format!(
469+
"Positional arguments passed to term -{} are ignored. Use named arguments instead.",
470+
id.name
471+
),
472+
message_id: Some(current_msg_id.to_string()),
473+
reference: Some(term_id.clone()),
474+
});
475+
}
476+
}
477+
478+
// Check against available_terms instead of bundle.has_message
479+
if !available_terms.contains_key(&term_id) {
480+
errors.push(ValidationError {
481+
error_type: "UnknownTerm".to_string(),
482+
message: format!("Unknown term: -{}", id.name),
483+
message_id: Some(current_msg_id.to_string()),
484+
reference: Some(term_id),
485+
});
486+
} else if let Some(attr) = attribute {
487+
// Check term attributes from available_terms instead of bundle.get_message
488+
if let Some(term_info) = available_terms.get(&term_id) {
489+
if !term_info.attributes.contains(attr.name) {
490+
errors.push(ValidationError {
491+
error_type: "UnknownAttribute".to_string(),
492+
message: format!(
493+
"Unknown attribute on term: -{}.{}",
494+
id.name, attr.name
495+
),
496+
message_id: Some(current_msg_id.to_string()),
497+
reference: Some(format!("-{}.{}", id.name, attr.name)),
498+
});
499+
}
500+
}
501+
}
502+
}
503+
_ => {}
504+
}
505+
}
506+
ast::Expression::Select { selector, variants } => {
507+
check_expression_references(
508+
bundle,
509+
&ast::Expression::Inline((*selector).clone()),
510+
current_msg_id,
511+
available_terms,
512+
errors,
513+
);
514+
for variant in variants {
515+
check_pattern_references(
516+
bundle,
517+
&Some(variant.value.clone()),
518+
current_msg_id,
519+
available_terms,
520+
errors,
521+
);
522+
}
523+
}
524+
}
525+
}
526+
319527
#[pymodule]
320528
mod rustfluent {
321529
use super::*;

0 commit comments

Comments
 (0)