@@ -192,23 +192,58 @@ def test_raises_parser_error_on_file_that_contains_errors_in_strict_mode():
192192 message = "\n " .join (lines )
193193 # End recombination
194194
195- expected = f"""\
196- × Error when parsing { filename }
197- ╭─[1:16]
198- 1 │ invalid-message
199- · ┬
200- · ╰── Expected a token starting with "="
201- 2 │
202- 3 │ valid-message = I'm valid.
203- ╰────
204- """
195+ # Note: Line 2 has a trailing space after the pipe
196+ expected = ' × Found 1 parse error(s) and 0 validation error(s)\n ╭─[1:16]\n 1 │ invalid-message\n · ┬\n · ╰── Expected a token starting with "="\n 2 │ \n 3 │ valid-message = I\' m valid.\n ╰────\n '
205197 assert message == expected
206198
207199
208200def test_parser_error_str ():
209201 assert str (fluent .ParserError ) == "<class 'rustfluent.ParserError'>"
210202
211203
204+ def test_parser_error_has_structured_error_details ():
205+ """Test that ParserError exposes structured error information."""
206+ filename = data_dir / "errors.ftl"
207+
208+ with pytest .raises (fluent .ParserError ) as exc_info :
209+ fluent .Bundle ("fr" , [filename ], strict = True )
210+
211+ error = exc_info .value
212+
213+ # Verify the parse_errors attribute exists
214+ assert hasattr (error , "parse_errors" ), "ParserError should have 'parse_errors' attribute"
215+ assert len (error .parse_errors ) == 1 , "Should have exactly one error"
216+
217+ # Verify the structure of the error detail
218+ error_detail = error .parse_errors [0 ]
219+ assert type (error_detail ).__name__ == "ParseErrorDetail"
220+
221+ # Verify all expected attributes exist and have correct values
222+ assert hasattr (error_detail , "message" )
223+ assert hasattr (error_detail , "line" )
224+ assert hasattr (error_detail , "column" )
225+ assert hasattr (error_detail , "byte_start" )
226+ assert hasattr (error_detail , "byte_end" )
227+ assert hasattr (error_detail , "filename" )
228+
229+ # Verify the error is at the expected location
230+ assert error_detail .line == 1
231+ assert error_detail .column == 16
232+ assert error_detail .byte_start == 15
233+ assert error_detail .byte_end == 16
234+
235+ # Verify the message contains expected content
236+ assert 'Expected a token starting with "="' in error_detail .message
237+
238+ # Verify filename is included
239+ assert str (filename ) in error_detail .filename
240+
241+ # Verify string representation works
242+ error_str = str (error_detail )
243+ assert "1:16" in error_str # Line:column should be in string representation
244+ assert "Expected a token" in error_str
245+
246+
212247# Attribute access tests
213248
214249
@@ -288,3 +323,170 @@ def test_missing_attribute_raises_error():
288323def test_attribute_and_message_access_parameterized (identifier , expected ):
289324 bundle = fluent .Bundle ("en" , [data_dir / "attributes.ftl" ])
290325 assert bundle .get_translation (identifier ) == expected
326+
327+
328+ # ==============================================================================
329+ # Term Tests
330+ # ==============================================================================
331+ # Tests for Fluent terms - reusable vocabulary items that start with "-"
332+ # and can only be referenced within messages (not retrieved directly)
333+
334+
335+ def test_basic_term_reference ():
336+ """Test that messages can reference terms using { -term-name } syntax."""
337+ bundle = fluent .Bundle ("en" , [data_dir / "terms.ftl" ])
338+ result = bundle .get_translation ("welcome" )
339+ assert result == "Welcome to Acme Corporation!"
340+
341+
342+ def test_term_attribute_as_selector ():
343+ """Test that term attributes can be used as selectors in select expressions.
344+
345+ Note: Per Fluent spec, term attributes are private and can only be used
346+ as selectors, not as direct placeables like { -term.attribute }.
347+ """
348+ bundle = fluent .Bundle ("en" , [data_dir / "terms.ftl" ])
349+ result = bundle .get_translation ("product-category" )
350+ assert result == "This is a gadget"
351+
352+
353+ def test_multiple_term_references ():
354+ """Test that multiple messages can reference the same term."""
355+ bundle = fluent .Bundle ("en" , [data_dir / "terms.ftl" ])
356+ assert bundle .get_translation ("welcome" ) == "Welcome to Acme Corporation!"
357+ assert bundle .get_translation ("product-info" ) == "Learn about Super Widget"
358+
359+
360+ def test_direct_term_access_fails ():
361+ """Test that terms cannot be retrieved directly per Fluent spec."""
362+ bundle = fluent .Bundle ("en" , [data_dir / "terms.ftl" ])
363+ with pytest .raises (ValueError , match = "-brand-name not found" ):
364+ bundle .get_translation ("-brand-name" )
365+
366+
367+ def test_unknown_term_validation_error ():
368+ """Test that referencing a non-existent term generates a validation error."""
369+ bundle = fluent .Bundle ("en" , [data_dir / "broken_terms.ftl" ])
370+
371+ # Should have validation errors
372+ validation_errors = bundle .get_validation_errors ()
373+ assert len (validation_errors ) == 2 # One for unknown term, one for unknown attribute
374+
375+ # Find the unknown term error
376+ unknown_term_errors = [e for e in validation_errors if e .error_type == "UnknownTerm" ]
377+ assert len (unknown_term_errors ) == 1
378+
379+ error = unknown_term_errors [0 ]
380+ assert error .error_type == "UnknownTerm"
381+ assert "-nonexistent-term" in error .message or "nonexistent-term" in error .message
382+ assert error .message_id == "broken-reference"
383+
384+
385+ def test_unknown_term_attribute_validation_error ():
386+ """Test that referencing a non-existent term attribute generates a validation error."""
387+ bundle = fluent .Bundle ("en" , [data_dir / "broken_terms.ftl" ])
388+
389+ # Should have validation errors
390+ validation_errors = bundle .get_validation_errors ()
391+ assert len (validation_errors ) == 2 # One for unknown term, one for unknown attribute
392+
393+ # Find the unknown attribute error
394+ unknown_attr_errors = [e for e in validation_errors if e .error_type == "UnknownAttribute" ]
395+ assert len (unknown_attr_errors ) == 1
396+
397+ error = unknown_attr_errors [0 ]
398+ assert error .error_type == "UnknownAttribute"
399+ assert "nonexistent" in error .message
400+ assert error .message_id == "broken-attribute"
401+
402+
403+ def test_terms_across_multiple_files ():
404+ """Test that terms defined in one file can be referenced in messages from another file."""
405+ # Load terms file first, then messages file
406+ bundle = fluent .Bundle (
407+ "en" , [data_dir / "terms_definitions.ftl" , data_dir / "terms_messages.ftl" ]
408+ )
409+
410+ # Should have no validation errors
411+ assert len (bundle .get_validation_errors ()) == 0
412+
413+ # Verify messages correctly reference terms from the other file
414+ assert bundle .get_translation ("about-app" ) == "About SuperApp"
415+ assert bundle .get_translation ("app-title" ) == "SuperApp - The Best App"
416+ assert bundle .get_translation ("app-description" ) == "This is a Web App"
417+ assert bundle .get_translation ("company-info" ) == "Brought to you by Acme Corporation"
418+
419+
420+ def test_strict_mode_rejects_unknown_terms ():
421+ """Test that strict mode raises an error when unknown terms are referenced.
422+
423+ Note: When there are only validation errors (no parse errors), a ValueError
424+ is raised. When there are parse errors, a ParserError is raised.
425+ """
426+ with pytest .raises (ValueError ) as exc_info :
427+ fluent .Bundle ("en" , [data_dir / "broken_terms.ftl" ], strict = True )
428+
429+ error = exc_info .value
430+
431+ # Should have validation errors
432+ assert hasattr (error , "validation_errors" )
433+ assert len (error .validation_errors ) == 2
434+
435+ # Check that the errors mention the unknown term and attribute
436+ error_messages = [e .message for e in error .validation_errors ]
437+ assert any ("nonexistent-term" in msg for msg in error_messages )
438+ assert any ("nonexistent" in msg and "attribute" in msg .lower () for msg in error_messages )
439+
440+
441+ def test_term_positional_arguments_generate_validation_error ():
442+ """Test that positional arguments to terms generate a validation error.
443+
444+ Per Fluent spec, positional arguments to terms are syntactically valid but
445+ semantically ignored at runtime. We warn about them to prevent confusion.
446+ """
447+ bundle = fluent .Bundle ("en" , [data_dir / "term_positional_args.ftl" ])
448+
449+ # Should have validation errors for the two messages with positional args
450+ validation_errors = bundle .get_validation_errors ()
451+ assert len (validation_errors ) == 2
452+
453+ # Both errors should be about ignored positional arguments
454+ for error in validation_errors :
455+ assert error .error_type == "IgnoredPositionalArgument"
456+ assert "positional" in error .message .lower () or "ignored" in error .message .lower ()
457+ assert "-brand-name" in error .message
458+ assert error .message_id in ["bad-reference" , "bad-reference-mixed" ]
459+
460+ # The good reference should still work correctly
461+ assert bundle .get_translation ("good-reference" ) == "About Firefoxie."
462+
463+
464+ def test_strict_mode_rejects_term_positional_arguments ():
465+ """Test that strict mode raises an error when terms are given positional arguments."""
466+ with pytest .raises (ValueError ) as exc_info :
467+ fluent .Bundle ("en" , [data_dir / "term_positional_args.ftl" ], strict = True )
468+
469+ error = exc_info .value
470+
471+ # Should have validation errors
472+ assert hasattr (error , "validation_errors" )
473+ assert len (error .validation_errors ) == 2
474+
475+ # Check that the errors mention positional arguments
476+ error_messages = [e .message for e in error .validation_errors ]
477+ assert all ("positional" in msg .lower () or "ignored" in msg .lower () for msg in error_messages )
478+
479+
480+ def test_term_positional_arguments_validation_has_correct_context ():
481+ """Test that positional argument validation errors have correct context."""
482+ bundle = fluent .Bundle ("en" , [data_dir / "term_positional_args.ftl" ])
483+
484+ validation_errors = bundle .get_validation_errors ()
485+
486+ # Find the error for bad-reference
487+ bad_ref_error = next (e for e in validation_errors if e .message_id == "bad-reference" )
488+
489+ assert bad_ref_error .error_type == "IgnoredPositionalArgument"
490+ assert bad_ref_error .message_id == "bad-reference"
491+ assert bad_ref_error .reference == "-brand-name"
492+ assert "named arguments" in bad_ref_error .message
0 commit comments