Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
308 changes: 0 additions & 308 deletions BitwardenResources/Localizations/en.lproj/Localizable.strings

Large diffs are not rendered by default.

27 changes: 27 additions & 0 deletions Scripts/fix-localizable-strings.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,36 @@ STRINGS_FILES=(
"TestHarnessShared/UI/Platform/Application/Support/Localizations/en.lproj/Localizable.strings"
)

# The main Localizable.strings file, used with delete-unused.
MAIN_STRINGS="BitwardenResources/Localizations/en.lproj/Localizable.strings"

# Swift source directories that reference the SwiftGen-generated Localizations enum.
SWIFT_SOURCE_DIRS=(
"AuthenticatorShared"
"Bitwarden"
"BitwardenKit"
"BitwardenResources"
"BitwardenShared"
"TestHarnessShared"
)

# Build the --swift-source arguments once for use inside the loop.
swift_source_args=()
for dir in "${SWIFT_SOURCE_DIRS[@]}"; do
swift_source_args+=(--swift-source "${REPO_ROOT}/${dir}")
done

# Run each fix-localizable-strings command against every strings file.
# Any extra arguments passed to this script (e.g. --dry-run) are forwarded as-is.
for strings_file in "${STRINGS_FILES[@]}"; do
echo "${strings_file}"
python3 "${PYTHON}" delete-duplicates --strings "${REPO_ROOT}/${strings_file}" "$@"
# delete-unused only applies to the main Localizable.strings because it works
# by scanning for Localizations.X references, which maps exclusively to the
# SwiftGen-generated Localizations enum produced from that file. The other
# strings files (AppShortcuts, Watch, TestHarness) use different access
# mechanisms and are not covered by this detection strategy.
if [[ "${strings_file}" == "${MAIN_STRINGS}" ]]; then
python3 "${PYTHON}" delete-unused --strings "${REPO_ROOT}/${strings_file}" "${swift_source_args[@]}" "$@"
fi
done
76 changes: 9 additions & 67 deletions Scripts/fix-localizable-strings/delete_duplicate_strings.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,7 @@
removed.
"""

import re

# Matches a complete key/value entry line.
_ENTRY_RE = re.compile(
r'^\s*"(?P<key>(?:[^"\\]|\\.)*)"\s*=\s*"(?:[^"\\]|\\.)*"\s*;\s*$'
)
from strings_file_utils import filter_entries


def deduplicate(content: str) -> tuple[str, list[str]]:
Expand All @@ -31,68 +26,15 @@ def deduplicate(content: str) -> tuple[str, list[str]]:
deduplicated file text and ``removed_keys`` is a list of keys that were
removed, in the order they were encountered.
"""
lines = content.splitlines(keepends=True)
output: list[str] = []
# Lines buffered since the last blank line; these are candidate comments
# for the next entry. Flushed to output on a blank line or non-entry line.
pending: list[str] = []
seen: set[str] = set()
removed: list[str] = []
in_block_comment = False

for line in lines:
stripped = line.strip()

# --- Multi-line block comment continuation ---
if in_block_comment:
pending.append(line)
if "*/" in line:
in_block_comment = False
continue

# --- Blank line: break comment-entry association ---
if not stripped:
output.extend(pending)
pending = []
output.append(line)
continue

# --- Block comment start (does not end on the same line) ---
if stripped.startswith("/*") and "*/" not in stripped:
in_block_comment = True
pending.append(line)
continue

# --- Single-line comment (// or /* ... */ on one line) ---
if stripped.startswith("//") or (
stripped.startswith("/*") and stripped.endswith("*/")
):
pending.append(line)
continue

# --- Key/value entry ---
m = _ENTRY_RE.match(line)
if m:
key = m.group("key")
if key not in seen:
seen.add(key)
output.extend(pending)
output.append(line)
else:
removed.append(key)
# pending (the preceding comment) is discarded
pending = []
continue

# --- Anything else (should be rare in a well-formed .strings file) ---
output.extend(pending)
pending = []
output.append(line)

# Flush any trailing pending content (e.g. trailing comment with no entry after it)
output.extend(pending)

return "".join(output), removed

def should_keep(key: str) -> bool:
if key in seen:
return False
seen.add(key)
return True

return filter_entries(content, should_keep)


def delete_duplicates(strings_path: str) -> list[str]:
Expand Down
129 changes: 129 additions & 0 deletions Scripts/fix-localizable-strings/delete_unused_strings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
"""
delete_unused_strings

Finds and removes string entries from a Localizable.strings file whose keys are
never referenced in Swift source code. Keys are assumed to be accessed via
``Localizations.X``, where ``X`` is the SwiftGen-generated identifier for the
key (first character lowercased). Any comment block immediately preceding a
removed entry (with no blank lines between them) is also removed.
"""

import os
import re

from strings_file_utils import filter_entries

# Matches any `Localizations.identifier` reference in Swift source, including
# cases where the identifier is on the next line (e.g. `Localizations\n .foo`).
_LOCALIZATIONS_RE = re.compile(r'Localizations\s*\.([a-zA-Z_][a-zA-Z0-9_]*)')

Check warning on line 18 in Scripts/fix-localizable-strings/delete_unused_strings.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use concise character class syntax '\w' instead of '[a-zA-Z0-9_]'.

See more on https://sonarcloud.io/project/issues?id=bitwarden_ios&issues=AZ0Ckz9HPm9DgMgZDawg&open=AZ0Ckz9HPm9DgMgZDawg&pullRequest=2464

# Matches any character that is not valid in a Swift identifier.
_NON_IDENTIFIER_RE = re.compile(r'[^a-zA-Z0-9_]')

Check warning on line 21 in Scripts/fix-localizable-strings/delete_unused_strings.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use concise character class syntax '\W' instead of '[^a-zA-Z0-9_]'.

See more on https://sonarcloud.io/project/issues?id=bitwarden_ios&issues=AZ0Ckz9HPm9DgMgZDawh&open=AZ0Ckz9HPm9DgMgZDawh&pullRequest=2464


def _normalize_key(key: str) -> str:
"""Normalize a ``.strings`` key for comparison against a SwiftGen identifier.

SwiftGen strips characters that are not valid in Swift identifiers when
generating property names (e.g. ``"NeedSomeInspiration?"`` becomes
``needSomeInspiration``). This function applies the same stripping and then
lowercases the result, matching the treatment applied to identifiers found
in Swift source via ``find_used_keys``.

Args:
key: A raw ``.strings`` key, possibly containing trailing punctuation.

Returns:
The normalized, lowercased key suitable for comparison.
"""
return _NON_IDENTIFIER_RE.sub('', key).lower()


def find_used_keys(swift_sources: list[str]) -> set[str]:
"""Scan Swift file contents for ``Localizations.X`` references.

Returns a set of identifiers found in the sources, converted to lowercase
for comparison with the keys from the strings file. While this does mean
that keys differing only in case (e.g. ``"OK"`` vs. ``"Ok"``) will be
treated as the same key, in practice we're not likely to have keys that
only differ by case.

The internal helper ``Localizations.tr(...)`` is excluded.

Args:
swift_sources: A list of strings, each being the full text of a Swift
source file.

Returns:
A set of lowercased identifiers referenced in the given sources,
e.g. ``{"about", "ok", "valuehasbeencopied"}``.
"""
result: set[str] = set()
for content in swift_sources:
for identifier in _LOCALIZATIONS_RE.findall(content):
if identifier == "tr":
continue
result.add(identifier.lower())
return result


def delete_unused_content(
strings_content: str, used_keys: set[str]
) -> tuple[str, list[str]]:
"""Remove unused key entries from Localizable.strings content.

Processes content line by line. Any key not present in ``used_keys`` is
removed. Any comment block (``/* */`` or ``//``) immediately preceding a
removed entry β€” with no intervening blank lines β€” is also removed.

Args:
strings_content: The full text of the ``.strings`` file.
used_keys: The set of lowercased identifiers (as returned by
``find_used_keys``) that are considered in-use. Each key from the
strings file is lowercased before lookup to match.

Returns:
A tuple of ``(new_content, removed_keys)`` where ``new_content`` is the
filtered file text and ``removed_keys`` is a list of keys that were
removed, in file order.
"""
return filter_entries(strings_content, lambda key: _normalize_key(key) in used_keys)


def delete_unused(strings_path: str, swift_dirs: list[str]) -> list[str]:
"""Remove unused entries from a Localizable.strings file in place.

Walks each directory in ``swift_dirs`` recursively for ``.swift`` files,
reads them, determines which keys are referenced, then removes any
unreferenced keys from the strings file.

Args:
strings_path: Path to the ``.strings`` file to process.
swift_dirs: List of directory paths to search recursively for Swift
source files.

Returns:
A list of keys that were removed, in file order. Returns an empty list
if no unused keys were found.
"""
swift_sources: list[str] = []
for swift_dir in swift_dirs:
for dirpath, _, filenames in os.walk(swift_dir):
for filename in filenames:
if filename.endswith(".swift"):
filepath = os.path.join(dirpath, filename)
with open(filepath, encoding="utf-8") as f:
swift_sources.append(f.read())

used_keys = find_used_keys(swift_sources)

with open(strings_path, encoding="utf-8") as f:
content = f.read()

new_content, removed = delete_unused_content(content, used_keys)

if removed:
with open(strings_path, "w", encoding="utf-8") as f:
f.write(new_content)

return removed
66 changes: 66 additions & 0 deletions Scripts/fix-localizable-strings/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,18 @@
python Scripts/fix-localizable-strings/main.py delete-duplicates \\
--strings <path/to/Localizable.strings> \\
[--dry-run]

python Scripts/fix-localizable-strings/main.py delete-unused \\
--strings <path/to/Localizable.strings> \\
--swift-source <dir> [--swift-source <dir> ...] \\
[--dry-run]
"""

import argparse
import sys

from delete_duplicate_strings import delete_duplicates, deduplicate
from delete_unused_strings import delete_unused, delete_unused_content, find_used_keys


def _pluralize(count: int, singular: str, plural: str) -> str:
Expand Down Expand Up @@ -45,6 +51,40 @@
print(f" {key}")


def cmd_delete_unused(args: argparse.Namespace) -> None:

Check failure on line 54 in Scripts/fix-localizable-strings/main.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 21 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=bitwarden_ios&issues=AZ0Ckz5jPm9DgMgZDawe&open=AZ0Ckz5jPm9DgMgZDawe&pullRequest=2464
if args.dry_run:
with open(args.strings, encoding="utf-8") as f:
content = f.read()
import os
swift_sources = []
for swift_dir in args.swift_sources:
for dirpath, _, filenames in os.walk(swift_dir):
for filename in filenames:
if filename.endswith(".swift"):
with open(os.path.join(dirpath, filename), encoding="utf-8") as f:
swift_sources.append(f.read())
used_keys = find_used_keys(swift_sources)
_, removed = delete_unused_content(content, used_keys)
if not removed:
print(" No unused strings found.")
return
noun = _pluralize(len(removed), "key", "keys")
print(f" Found {len(removed)} unused {noun}:")
for key in removed:
print(f" {key}")
print("\n Dry run β€” no changes written.")
return

removed = delete_unused(args.strings, args.swift_sources)
if not removed:
print(" No unused strings found.")
return
noun = _pluralize(len(removed), "key", "keys")
print(f" Removed {len(removed)} unused {noun}:")
for key in removed:
print(f" {key}")


def build_parser():
parser = argparse.ArgumentParser(
description="Tools for maintaining Localizable.strings files."
Expand All @@ -67,6 +107,30 @@
help="Report duplicates without modifying the strings file.",
)

unused_parser = subparsers.add_parser(
"delete-unused",
help="Remove string keys that are never referenced in Swift source code.",
)
unused_parser.add_argument(
"--strings",
required=True,
metavar="PATH",
help="Path to the Localizable.strings file to process.",
)
unused_parser.add_argument(
"--swift-source",
required=True,
action="append",
dest="swift_sources",
metavar="DIR",
help="Directory to search recursively for Swift source files. May be repeated.",
)
unused_parser.add_argument(
"--dry-run",
action="store_true",
help="Report unused keys without modifying the strings file.",
)

return parser


Expand All @@ -76,6 +140,8 @@

if args.command == "delete-duplicates":
cmd_delete_duplicates(args)
elif args.command == "delete-unused":
cmd_delete_unused(args)
else:
parser.print_help()
sys.exit(1)
Expand Down
Loading
Loading