Skip to content

Commit d9f5fd9

Browse files
committed
implement fluent (ftl) check
Fluent is file format used by Firefox and other programs for translation strings.
1 parent ec458dc commit d9f5fd9

File tree

4 files changed

+296
-0
lines changed

4 files changed

+296
-0
lines changed

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ Checks for a common error of placing code before the docstring.
5151
#### `check-executables-have-shebangs`
5252
Checks that non-binary executables have a proper shebang.
5353

54+
#### `check-fluent`
55+
Checks that fluent files are correctly formatted.
56+
5457
#### `check-illegal-windows-names`
5558
Check for files that cannot be created on Windows.
5659

pre_commit_hooks/check_fluent.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
from collections.abc import Sequence
5+
6+
7+
def main(argv: Sequence[str] | None = None) -> int:
8+
parser = argparse.ArgumentParser()
9+
parser.add_argument('filenames', nargs='*', help='Filenames to check.')
10+
args = parser.parse_args(argv)
11+
12+
retval = 0
13+
for filename in args.filenames:
14+
try:
15+
with open(filename, encoding='UTF-8') as f:
16+
content = f.read()
17+
18+
if not _validate_fluent_syntax(content, filename):
19+
retval = 1
20+
21+
except (OSError, UnicodeDecodeError) as exc:
22+
print(f"{filename}: Failed to read file ({exc})")
23+
retval = 1
24+
25+
return retval
26+
27+
28+
def _validate_fluent_syntax(content: str, filename: str) -> bool:
29+
"""Validate Fluent FTL file syntax."""
30+
lines = content.splitlines()
31+
errors = []
32+
33+
# Track current message context
34+
current_message = None
35+
has_default_variant = False
36+
in_select_expression = False
37+
38+
for line_num, line in enumerate(lines, 1):
39+
# Skip empty lines and comments
40+
if not line.strip() or line.strip().startswith('#'):
41+
continue
42+
43+
# Check for message definitions (identifier = value)
44+
if (
45+
'=' in line and
46+
not line.startswith(' ') and
47+
not line.startswith('\t')
48+
):
49+
current_message = line.split('=')[0].strip()
50+
in_select_expression = False
51+
has_default_variant = False
52+
53+
# Validate message identifier
54+
if not _is_valid_identifier(current_message):
55+
errors.append(
56+
f"Line {line_num}: Invalid message identifier "
57+
f'"{current_message}"',
58+
)
59+
60+
# Check for select expressions (contains -> or other select syntax)
61+
if '{' in line and '$' in line and '->' in line:
62+
in_select_expression = True
63+
64+
# Handle indented content (attributes, variants, multiline values)
65+
elif line.startswith(' ') or line.startswith('\t'):
66+
if current_message is None:
67+
errors.append(
68+
f"Line {line_num}: Indented content without "
69+
f"message context",
70+
)
71+
continue
72+
73+
stripped = line.strip()
74+
75+
# Check for attribute definitions
76+
if stripped.startswith('.') and '=' in stripped:
77+
# Remove leading dot
78+
attr_name = stripped.split('=')[0].strip()[1:]
79+
if not _is_valid_identifier(attr_name):
80+
errors.append(
81+
f"Line {line_num}: Invalid attribute identifier "
82+
f'"{attr_name}"',
83+
)
84+
85+
# Check for variants in select expressions
86+
elif stripped.startswith('*') or (
87+
stripped.startswith('[') and stripped.endswith(']')
88+
):
89+
if not in_select_expression:
90+
errors.append(
91+
f"Line {line_num}: Variant definition outside "
92+
f"select expression",
93+
)
94+
elif stripped.startswith('*'):
95+
has_default_variant = True
96+
97+
# Check for unterminated select expressions
98+
if in_select_expression and current_message:
99+
if '}' in line:
100+
in_select_expression = False
101+
if not has_default_variant:
102+
errors.append(
103+
f"Line {line_num}: Select expression missing "
104+
f"default variant (marked with *)",
105+
)
106+
107+
# Report errors
108+
if errors:
109+
for error in errors:
110+
print(f"{filename}: {error}")
111+
return False
112+
113+
return True
114+
115+
116+
def _is_valid_identifier(identifier: str) -> bool:
117+
"""Check if identifier follows Fluent naming conventions."""
118+
if not identifier:
119+
return False
120+
121+
# Must start with letter
122+
if not identifier[0].isalpha():
123+
return False
124+
125+
# Can contain letters, numbers, underscores, and hyphens
126+
for char in identifier:
127+
if not (char.isalnum() or char in '_-'):
128+
return False
129+
130+
return True
131+
132+
133+
if __name__ == '__main__':
134+
raise SystemExit(main())

setup.cfg

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ console_scripts =
3535
check-case-conflict = pre_commit_hooks.check_case_conflict:main
3636
check-docstring-first = pre_commit_hooks.check_docstring_first:main
3737
check-executables-have-shebangs = pre_commit_hooks.check_executables_have_shebangs:main
38+
check-fluent = pre_commit_hooks.check_fluent:main
3839
check-json = pre_commit_hooks.check_json:main
3940
check-merge-conflict = pre_commit_hooks.check_merge_conflict:main
4041
check-shebang-scripts-are-executable = pre_commit_hooks.check_shebang_scripts_are_executable:main

tests/check_fluent_test.py

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
from __future__ import annotations
2+
3+
import pytest
4+
5+
from pre_commit_hooks.check_fluent import main
6+
7+
8+
def test_valid_fluent_file(tmp_path):
9+
f = tmp_path / 'test.ftl'
10+
f.write_text(
11+
'hello = Hello, world!\n'
12+
'greeting = Hello, { $name }!\n'
13+
' .title = Greeting\n'
14+
'menu-item = Menu Item\n',
15+
)
16+
assert main([str(f)]) == 0
17+
18+
19+
def test_fluent_file_with_select_expression(tmp_path):
20+
f = tmp_path / 'test.ftl'
21+
f.write_text(
22+
'emails = { $unreadEmails ->\n'
23+
' [0] You have no unread emails.\n'
24+
' [one] You have one unread email.\n'
25+
' *[other] You have { $unreadEmails } unread emails.\n'
26+
'}\n',
27+
)
28+
assert main([str(f)]) == 0
29+
30+
31+
def test_fluent_file_with_comments(tmp_path):
32+
f = tmp_path / 'test.ftl'
33+
f.write_text(
34+
'# This is a comment\n'
35+
'hello = Hello, world!\n'
36+
'\n'
37+
'## Another comment\n'
38+
'goodbye = Goodbye!\n',
39+
)
40+
assert main([str(f)]) == 0
41+
42+
43+
def test_fluent_file_with_invalid_identifier(tmp_path):
44+
f = tmp_path / 'test.ftl'
45+
f.write_text('123invalid = Invalid identifier\n')
46+
assert main([str(f)]) == 1
47+
48+
49+
def test_fluent_file_with_invalid_attribute_identifier(tmp_path):
50+
f = tmp_path / 'test.ftl'
51+
f.write_text('hello = Hello\n' ' .123invalid = Invalid attribute\n')
52+
assert main([str(f)]) == 1
53+
54+
55+
def test_fluent_file_missing_default_variant(tmp_path):
56+
f = tmp_path / 'test.ftl'
57+
f.write_text(
58+
'emails = { $unreadEmails ->\n'
59+
' [0] You have no unread emails.\n'
60+
' [one] You have one unread email.\n'
61+
'}\n',
62+
)
63+
assert main([str(f)]) == 1
64+
65+
66+
def test_fluent_file_variant_outside_select(tmp_path):
67+
f = tmp_path / 'test.ftl'
68+
f.write_text('hello = Hello\n' ' *[default] This should not be here\n')
69+
assert main([str(f)]) == 1
70+
71+
72+
def test_fluent_file_missing_indentation(tmp_path):
73+
f = tmp_path / 'test.ftl'
74+
f.write_text('hello = Hello\n' '.title = This should be indented\n')
75+
assert main([str(f)]) == 1
76+
77+
78+
def test_fluent_file_indented_without_context(tmp_path):
79+
f = tmp_path / 'test.ftl'
80+
f.write_text(' orphaned = This line has no message context\n')
81+
assert main([str(f)]) == 1
82+
83+
84+
def test_non_utf8_file(tmp_path):
85+
f = tmp_path / 'test.ftl'
86+
f.write_bytes(b'\xa9\xfe\x12')
87+
assert main([str(f)]) == 1
88+
89+
90+
def test_nonexistent_file():
91+
assert main(['nonexistent.ftl']) == 1
92+
93+
94+
def test_empty_file(tmp_path):
95+
f = tmp_path / 'test.ftl'
96+
f.write_text('')
97+
assert main([str(f)]) == 0
98+
99+
100+
def test_multiple_files(tmp_path):
101+
f1 = tmp_path / 'valid.ftl'
102+
f1.write_text('hello = Hello, world!\n')
103+
104+
f2 = tmp_path / 'invalid.ftl'
105+
f2.write_text('123invalid = Invalid identifier\n')
106+
107+
assert main([str(f1), str(f2)]) == 1
108+
109+
110+
def test_multiple_valid_files(tmp_path):
111+
f1 = tmp_path / 'valid1.ftl'
112+
f1.write_text('hello = Hello, world!\n')
113+
114+
f2 = tmp_path / 'valid2.ftl'
115+
f2.write_text('goodbye = Goodbye!\n')
116+
117+
assert main([str(f1), str(f2)]) == 0
118+
119+
120+
@pytest.mark.parametrize(
121+
'identifier,expected',
122+
[
123+
('hello', True),
124+
('hello-world', True),
125+
('hello_world', True),
126+
('hello123', True),
127+
('123hello', False),
128+
('hello-', True),
129+
('-hello', False),
130+
('', False),
131+
('hello.world', False),
132+
('hello world', False),
133+
],
134+
)
135+
def test_identifier_validation(identifier, expected):
136+
from pre_commit_hooks.check_fluent import _is_valid_identifier
137+
138+
assert _is_valid_identifier(identifier) == expected
139+
140+
141+
def test_fluent_file_non_default_variant_with_closing_brace(tmp_path):
142+
f = tmp_path / 'test.ftl'
143+
f.write_text(
144+
'emails = { $unreadEmails ->\n'
145+
' [0] You have no unread emails. }\n',
146+
)
147+
assert main([str(f)]) == 1 # Should fail due to missing default variant
148+
149+
150+
def test_fluent_file_bracket_variant_coverage(tmp_path):
151+
f = tmp_path / 'test.ftl'
152+
f.write_text(
153+
'emails = { $unreadEmails ->\n'
154+
' [zero] No emails\n'
155+
' *[other] Has emails\n'
156+
'}\n',
157+
)
158+
assert main([str(f)]) == 0

0 commit comments

Comments
 (0)