# 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