Source code
Revision control
Copy as Markdown
Other Tools
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
import re
import xml.etree.ElementTree as ET
from pathlib import Path
from mozlint import result
MOZ_NS = "http://mozac.org/tools"
MOBILE_ANDROID = Path("mobile", "android")
SOURCE_EXTENSIONS = {".kt", ".java", ".xml"}
SKIP_DIRS = {"build", ".gradle", ".idea", "node_modules", ".git"}
PROJECTS = {
"fenix": "fenix",
"focus-android": "focus",
"android-components": "ac",
}
def _is_en_us_strings_xml(filepath):
parts = Path(filepath).parts
return (
len(parts) >= 3
and parts[-1] == "strings.xml"
and parts[-2] == "values"
and parts[-3] == "res"
)
def _is_source_file(filepath, root):
try:
rel = Path(filepath).relative_to(Path(root) / MOBILE_ANDROID)
except ValueError:
return False
return "src" in rel.parts
def _get_project(filepath, root):
try:
rel = Path(filepath).relative_to(Path(root) / MOBILE_ANDROID)
except ValueError:
return None
return PROJECTS.get(rel.parts[0])
def _parse_strings_xml(root):
"""Return (deprecated, active) dicts keyed by project, parsed from
en-US values/strings.xml files under mobile/android."""
deprecated = {}
active = {}
mobile_android = Path(root) / MOBILE_ANDROID
for strings_xml in mobile_android.rglob("values/strings.xml"):
rel = strings_xml.relative_to(mobile_android)
if any(p in SKIP_DIRS for p in rel.parts):
continue
project = _get_project(strings_xml, root)
if project is None:
continue
try:
tree = ET.parse(strings_xml)
except ET.ParseError:
continue
for elem in tree.getroot():
if elem.tag != "string":
continue
name = elem.get("name")
if not name:
continue
removed_in = elem.get(f"{{{MOZ_NS}}}removedIn")
if removed_in:
deprecated.setdefault(project, {})[name] = removed_in
else:
active.setdefault(project, set()).add(name)
return deprecated, active
def _visible_deprecated(deprecated, active, project):
"""Deprecated strings from the project itself, plus AC strings for Fenix/Focus
(their own active strings shadow same-named AC ones)."""
visible = dict(deprecated.get(project, {}))
if project in ("fenix", "focus"):
own_active = active.get(project, set())
for name, version in deprecated.get("ac", {}).items():
if name not in own_active:
visible.setdefault(name, version)
return visible
def _find_all_source_files(root):
mobile_android = Path(root) / MOBILE_ANDROID
for path in mobile_android.rglob("*"):
if not path.is_file():
continue
rel = path.relative_to(mobile_android)
if any(p in SKIP_DIRS for p in rel.parts):
continue
if path.suffix not in SOURCE_EXTENSIONS:
continue
if path.name == "strings.xml":
continue
if any(p.startswith("values-") for p in rel.parts):
continue
if not _is_source_file(path, root):
continue
yield str(path)
def _compile_patterns(deprecated):
"""Pre-compile one regex per deprecated string name."""
return {
name: re.compile(
r"(?:R\.string\.|@string/)" + re.escape(name) + r"(?![a-zA-Z0-9_])"
)
for name in deprecated
}
def _check_file_for_references(filepath, deprecated, patterns, config, results):
try:
with open(filepath, encoding="utf-8") as f:
content = f.read()
except (OSError, UnicodeDecodeError):
return
lines = content.splitlines()
for name, version in deprecated.items():
if name not in content:
continue
pattern = patterns[name]
for lineno, line in enumerate(lines, start=1):
if pattern.search(line):
res = {
"path": filepath,
"lineno": lineno,
"message": (
f'Reference to string "{name}" which is marked '
f"for removal (moz:removedIn={version})"
),
"level": "error",
"rule": "expired-string-reference",
}
results.append(result.from_config(config, **res))
def lint(paths, config, fix=None, **lintargs):
root = lintargs["root"]
results = []
deprecated, active = _parse_strings_xml(root)
if not deprecated:
return results
# When a strings.xml is among the modified files, do a full scan so we
# catch references in files the developer did not touch.
if any(_is_en_us_strings_xml(p) for p in paths):
files_to_check = _find_all_source_files(root)
else:
files_to_check = [
p
for p in paths
if Path(p).name != "strings.xml" and _is_source_file(p, root)
]
visible_cache = {}
pattern_cache = {}
for filepath in files_to_check:
project = _get_project(filepath, root)
if project is None:
continue
if project not in visible_cache:
visible_cache[project] = _visible_deprecated(deprecated, active, project)
if visible_cache[project]:
pattern_cache[project] = _compile_patterns(visible_cache[project])
visible = visible_cache[project]
if not visible:
continue
_check_file_for_references(
filepath, visible, pattern_cache[project], config, results
)
return results