pre-commit-hooks/pre_commit_hooks/check_fluent.py
Sylvestre Ledru 9f9fdd6663 implement fluent (ftl) check
Fluent is file format used by Firefox and other programs for translation strings.
2025-06-21 17:31:17 +02:00

137 lines
4.3 KiB
Python

from __future__ import annotations
import argparse
from collections.abc import Sequence
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser()
parser.add_argument('filenames', nargs='*', help='Filenames to check.')
args = parser.parse_args(argv)
retval = 0
for filename in args.filenames:
try:
with open(filename, encoding='UTF-8') as f:
content = f.read()
if not _validate_fluent_syntax(content, filename):
retval = 1
except (OSError, UnicodeDecodeError) as exc:
print(f"{filename}: Failed to read file ({exc})")
retval = 1
return retval
def _validate_fluent_syntax(content: str, filename: str) -> bool:
"""Validate Fluent FTL file syntax."""
lines = content.splitlines()
errors = []
# Track current message context
current_message = None
has_default_variant = False
in_select_expression = False
for line_num, line in enumerate(lines, 1):
# Skip empty lines and comments
if not line.strip() or line.strip().startswith('#'):
continue
# Check for message definitions (identifier = value)
if (
'=' in line and
not line.startswith(' ') and
not line.startswith('\t')
):
current_message = line.split('=')[0].strip()
in_select_expression = False
has_default_variant = False
# Validate message identifier
if not _is_valid_identifier(current_message):
errors.append(
f"Line {line_num}: Invalid message identifier "
f'"{current_message}"',
)
# Check for select expressions (contains -> or other select syntax)
if '{' in line and '$' in line and '->' in line:
in_select_expression = True
# Handle indented content (attributes, variants, multiline values)
elif line.startswith(' ') or line.startswith('\t'):
if current_message is None:
errors.append(
f"Line {line_num}: Indented content without "
f"message context",
)
continue
stripped = line.strip()
# Check for attribute definitions
if stripped.startswith('.') and '=' in stripped:
# Remove leading dot
attr_name = stripped.split('=')[0].strip()[1:]
if not _is_valid_identifier(attr_name):
errors.append(
f"Line {line_num}: Invalid attribute identifier "
f'"{attr_name}"',
)
# Check for variants in select expressions
elif stripped.startswith('*') or (
stripped.startswith('[') and stripped.endswith(']')
):
if not in_select_expression:
errors.append(
f"Line {line_num}: Variant definition outside "
f"select expression",
)
elif stripped.startswith('*'):
has_default_variant = True
else:
# Non-* variants don't set has_default_variant
pass
# Check for unterminated select expressions
if in_select_expression and current_message:
if '}' in line:
in_select_expression = False
if not has_default_variant:
errors.append(
f"Line {line_num}: Select expression missing "
f"default variant (marked with *)",
)
# Report errors
if errors:
for error in errors:
print(f"{filename}: {error}")
return False
return True
def _is_valid_identifier(identifier: str) -> bool:
"""Check if identifier follows Fluent naming conventions."""
if not identifier:
return False
# Must start with letter
if not identifier[0].isalpha():
return False
# Can contain letters, numbers, underscores, and hyphens
for char in identifier:
if not (char.isalnum() or char in '_-'):
return False
return True
if __name__ == '__main__':
raise SystemExit(main())