mirror of
https://github.com/home-assistant/iOS.git
synced 2026-02-04 02:46:35 -06:00
Add automated detection and cleanup of unused L10n strings (#4119)
## Summary Implements automated detection and removal of unused localization strings. Currently identifies 37 unused L10n properties across the codebase (onboarding flows, settings, thread credentials, widgets, etc.). **Components:** - **Detection script** (`Tools/detect_unused_strings.py`): Parses `Strings.swift`, searches all Swift source for L10n property usage and direct key references, reports unused strings grouped by category - **Removal script** (`Tools/remove_unused_strings.py`): Deletes unused keys from all `*.lproj/Localizable.strings` files, regenerates `Strings.swift` via SwiftGen - **CI check** (`check-unused-strings` job): Runs on PRs, posts sticky comment with unused string count and details - **Automated workflow** (`clean_unused_strings.yml`): Monthly scheduled run + manual trigger, creates PR with cleanup changes **Usage:** ```bash # Detect unused strings python3 Tools/detect_unused_strings.py # Remove unused strings and regenerate code python3 Tools/remove_unused_strings.py ``` ## Screenshots N/A - No UI changes ## Link to pull request in Documentation repository Documentation: home-assistant/companion.home-assistant# ## Any other notes The detection algorithm checks three patterns to minimize false positives: 1. Full L10n path usage (`L10n.About.title`) 2. Leaf property usage (`.title`) 3. Direct Localizable key usage (`"about.title"`) Modified `.gitignore` to allow Python scripts in Tools directory. All changes pass yamllint and CodeQL security checks. <!-- START COPILOT CODING AGENT SUFFIX --> <!-- START COPILOT ORIGINAL PROMPT --> <details> <summary>Original prompt</summary> > Tasks: 1 - Create a script that detects L10n (Strings.swift) unused strings in the codebase 2 - From this list, extract the Localizable string and double check if they are not used directly in the codebase 3 - If both are true, the script should delete these strings from all localizable languages and L10n (Strings.swift) > 4 - Add to the CI workflow a step which checks if that PR has unused strings, if so, comment on the PR 5 - Create a workflow dedicated to delete unused strings </details> <!-- START COPILOT CODING AGENT TIPS --> --- 💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more [Copilot coding agent tips](https://gh.io/copilot-coding-agent-tips) in the docs. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: bgoncal <5808343+bgoncal@users.noreply.github.com>
This commit is contained in:
parent
31030a1144
commit
d0e30e0c1a
53
.github/workflows/ci.yml
vendored
53
.github/workflows/ci.yml
vendored
@ -73,6 +73,59 @@ jobs:
|
||||
|
||||
The following added lines contain `// swiftlint:disable`. Please verify this is necessary.
|
||||
|
||||
check-unused-strings:
|
||||
needs: lint
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@f677139bbe7f9c59b41e40162b753c062f5d49a3 # v5.3.0
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Detect unused L10n strings
|
||||
id: detect
|
||||
continue-on-error: true
|
||||
run: |
|
||||
OUTPUT=$(python3 Tools/detect_unused_strings.py 2>&1 || true)
|
||||
echo "output<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$OUTPUT" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
# Check if any unused strings were found
|
||||
if echo "$OUTPUT" | grep -q "Total unused:"; then
|
||||
COUNT=$(echo "$OUTPUT" | grep "Total unused:" | grep -oE '[0-9]+')
|
||||
echo "has_unused=true" >> $GITHUB_OUTPUT
|
||||
echo "count=$COUNT" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "has_unused=false" >> $GITHUB_OUTPUT
|
||||
echo "count=0" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Comment on PR if unused strings are found
|
||||
if: steps.detect.outputs.has_unused == 'true'
|
||||
uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
|
||||
with:
|
||||
header: unused-strings-check
|
||||
message: |
|
||||
⚠️ **Unused L10n strings detected**
|
||||
|
||||
Found **${{ steps.detect.outputs.count }}** unused localization strings in the codebase.
|
||||
|
||||
<details>
|
||||
<summary>Click to see details</summary>
|
||||
|
||||
```
|
||||
${{ steps.detect.outputs.output }}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
Consider running `python3 Tools/remove_unused_strings.py` to clean up these strings,
|
||||
or use the automated cleanup workflow.
|
||||
|
||||
test:
|
||||
needs: check-swiftlint-disables
|
||||
runs-on: macos-15
|
||||
|
||||
117
.github/workflows/clean_unused_strings.yml
vendored
Normal file
117
.github/workflows/clean_unused_strings.yml
vendored
Normal file
@ -0,0 +1,117 @@
|
||||
---
|
||||
name: Clean Unused Strings
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# Run monthly on the first day of the month at 00:00 UTC
|
||||
- cron: '0 0 1 * *'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
clean-unused-strings:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@f677139bbe7f9c59b41e40162b753c062f5d49a3 # v5.3.0
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Detect unused strings
|
||||
id: detect
|
||||
run: |
|
||||
OUTPUT=$(python3 Tools/detect_unused_strings.py 2>&1 || true)
|
||||
echo "output<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$OUTPUT" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
# Check if any unused strings were found
|
||||
if echo "$OUTPUT" | grep -q "Total unused:"; then
|
||||
COUNT=$(echo "$OUTPUT" | grep "Total unused:" | grep -oE '[0-9]+')
|
||||
echo "has_unused=true" >> $GITHUB_OUTPUT
|
||||
echo "count=$COUNT" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "has_unused=false" >> $GITHUB_OUTPUT
|
||||
echo "count=0" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Skip if no unused strings
|
||||
if: steps.detect.outputs.has_unused != 'true'
|
||||
run: |
|
||||
echo "✅ No unused strings found - nothing to clean up!"
|
||||
exit 0
|
||||
|
||||
- name: Set up Ruby
|
||||
if: steps.detect.outputs.has_unused == 'true'
|
||||
uses: ruby/setup-ruby@ac793fdd38cc468a4dd57246fa9d0e868aba9085 # v1.270.0
|
||||
with:
|
||||
ruby-version: '3.1'
|
||||
bundler-cache: true
|
||||
|
||||
- name: Install Pods (for SwiftGen)
|
||||
if: steps.detect.outputs.has_unused == 'true'
|
||||
run: |
|
||||
bundle install
|
||||
bundle exec pod install --repo-update
|
||||
|
||||
- name: Remove unused strings
|
||||
if: steps.detect.outputs.has_unused == 'true'
|
||||
id: remove
|
||||
run: |
|
||||
python3 Tools/remove_unused_strings.py
|
||||
echo "removed=true" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create Pull Request
|
||||
if: steps.remove.outputs.removed == 'true'
|
||||
uses: peter-evans/create-pull-request@5e914681df9dc83aa4e4905692ca88beb2f9e91f # v7.0.5
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
commit-message: |
|
||||
Remove unused L10n strings
|
||||
|
||||
Automatically detected and removed ${{ steps.detect.outputs.count }}
|
||||
unused localization strings.
|
||||
branch: automated/cleanup-unused-strings
|
||||
delete-branch: true
|
||||
title: 'Remove unused L10n strings'
|
||||
body: |
|
||||
## Automated Cleanup: Unused L10n Strings
|
||||
|
||||
This PR was automatically created by the `Clean Unused Strings` workflow.
|
||||
|
||||
### Summary
|
||||
- **Removed**: ${{ steps.detect.outputs.count }} unused localization strings
|
||||
- **Modified files**: All `Localizable.strings` files across language directories
|
||||
- **Regenerated**: `Sources/Shared/Resources/Swiftgen/Strings.swift`
|
||||
|
||||
### Details
|
||||
|
||||
<details>
|
||||
<summary>Unused strings that were removed</summary>
|
||||
|
||||
```
|
||||
${{ steps.detect.outputs.output }}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Review Notes
|
||||
- Please review the removed strings to ensure they are truly unused
|
||||
- The script checks for both L10n property usage and direct key usage
|
||||
- Strings.swift has been regenerated automatically using SwiftGen
|
||||
|
||||
---
|
||||
|
||||
*This is an automated cleanup PR. If you believe any of these strings
|
||||
should be kept, please comment and close this PR.*
|
||||
labels: |
|
||||
automated
|
||||
localization
|
||||
cleanup
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -73,7 +73,6 @@ env.sh
|
||||
.env
|
||||
|
||||
push_certs/*
|
||||
Tools/*.py
|
||||
Tools/*.ttf
|
||||
Tools/*.json
|
||||
dSYMs/
|
||||
|
||||
59
Tools/README.md
Normal file
59
Tools/README.md
Normal file
@ -0,0 +1,59 @@
|
||||
# Tools Directory
|
||||
|
||||
This directory contains scripts and tools used for development and maintenance of the Home Assistant iOS app.
|
||||
|
||||
## Python Scripts
|
||||
|
||||
### detect_unused_strings.py
|
||||
|
||||
Detects unused localization strings in the codebase.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
python3 Tools/detect_unused_strings.py
|
||||
```
|
||||
|
||||
**What it does:**
|
||||
1. Parses `Sources/Shared/Resources/Swiftgen/Strings.swift` to extract all L10n properties and their corresponding Localizable keys
|
||||
2. Checks for usage of L10n properties in Swift source code
|
||||
3. Double-checks for direct usage of Localizable keys in the codebase
|
||||
4. Reports unused strings that can be safely removed
|
||||
|
||||
**Exit codes:**
|
||||
- `0`: No unused strings found
|
||||
- `1`: Unused strings detected (normal for reporting)
|
||||
|
||||
### remove_unused_strings.py
|
||||
|
||||
Removes unused localization strings from all language files and regenerates Strings.swift.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
python3 Tools/remove_unused_strings.py
|
||||
```
|
||||
|
||||
**What it does:**
|
||||
1. Uses `detect_unused_strings.py` to find unused strings
|
||||
2. Removes them from all `*.lproj/Localizable.strings` files
|
||||
3. Regenerates `Strings.swift` using SwiftGen
|
||||
|
||||
**Requirements:**
|
||||
- Python 3.x
|
||||
- SwiftGen (installed via CocoaPods)
|
||||
- Pods must be installed before running (`bundle exec pod install`)
|
||||
|
||||
**Exit codes:**
|
||||
- `0`: Successfully removed unused strings and regenerated Strings.swift
|
||||
- `1`: Error occurred during processing
|
||||
|
||||
## Shell Scripts
|
||||
|
||||
### BuildMaterialDesignIconsFont.sh
|
||||
|
||||
Builds the Material Design Icons font file from the icon definitions.
|
||||
|
||||
## Stencil Templates
|
||||
|
||||
### icons.stencil
|
||||
|
||||
SwiftGen template for generating Swift code from Material Design Icons JSON data.
|
||||
257
Tools/detect_unused_strings.py
Executable file
257
Tools/detect_unused_strings.py
Executable file
@ -0,0 +1,257 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Detect unused localization strings in the Home Assistant iOS app.
|
||||
|
||||
This script:
|
||||
1. Parses Strings.swift to extract all L10n properties and their corresponding Localizable keys
|
||||
2. Checks for usage of L10n properties in Swift source code
|
||||
3. Double-checks for direct usage of Localizable keys in the codebase
|
||||
4. Reports unused strings that can be safely removed
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Set, Tuple, NamedTuple
|
||||
|
||||
|
||||
class EnumContext(NamedTuple):
|
||||
"""Represents an enum in the stack with its name and indentation level."""
|
||||
name: str
|
||||
indent: int
|
||||
|
||||
|
||||
class L10nString:
|
||||
"""Represents a localized string with its L10n property path and Localizable key."""
|
||||
|
||||
def __init__(self, swift_property: str, localizable_key: str, line_number: int):
|
||||
self.swift_property = swift_property
|
||||
self.localizable_key = localizable_key
|
||||
self.line_number = line_number
|
||||
|
||||
def __repr__(self):
|
||||
return f"L10nString({self.swift_property} -> {self.localizable_key})"
|
||||
|
||||
|
||||
def parse_strings_swift(strings_swift_path: Path) -> List[L10nString]:
|
||||
"""
|
||||
Parse Strings.swift to extract all L10n properties and their Localizable keys.
|
||||
|
||||
Returns a list of L10nString objects containing the Swift property path and
|
||||
corresponding Localizable key.
|
||||
"""
|
||||
with open(strings_swift_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
lines = content.split('\n')
|
||||
l10n_strings = []
|
||||
|
||||
# Track the current enum path (e.g., ["About", "Beta"])
|
||||
enum_stack = []
|
||||
|
||||
for i, line in enumerate(lines, start=1):
|
||||
# Track enum declarations to build the property path
|
||||
enum_match = re.search(r'public enum (\w+)', line)
|
||||
if enum_match:
|
||||
enum_name = enum_match.group(1)
|
||||
# Skip the root L10n enum
|
||||
if enum_name == 'L10n' and not enum_stack:
|
||||
continue
|
||||
# Calculate indentation level
|
||||
indent = len(line) - len(line.lstrip())
|
||||
# Pop enums from stack if we're at the same or lower indentation
|
||||
while enum_stack and enum_stack[-1].indent >= indent:
|
||||
enum_stack.pop()
|
||||
enum_stack.append(EnumContext(enum_name, indent))
|
||||
continue
|
||||
|
||||
# Detect closing braces that end enum blocks
|
||||
if re.match(r'\s*}', line):
|
||||
# Pop the last enum if there's significant dedent
|
||||
indent = len(line) - len(line.lstrip())
|
||||
while enum_stack and enum_stack[-1].indent >= indent:
|
||||
enum_stack.pop()
|
||||
continue
|
||||
|
||||
# Match static var declarations with L10n.tr() calls
|
||||
# Pattern: public static var propertyName: String { return L10n.tr("Localizable", "key") }
|
||||
static_var_match = re.search(
|
||||
r'public static var (\w+):\s*String\s*\{\s*return L10n\.tr\("Localizable",\s*"([^"]+)"\)',
|
||||
line
|
||||
)
|
||||
if static_var_match:
|
||||
property_name = static_var_match.group(1)
|
||||
localizable_key = static_var_match.group(2)
|
||||
|
||||
# Build the full property path
|
||||
path_parts = [e.name for e in enum_stack] + [property_name]
|
||||
# SwiftGen creates nested enums but access is L10n.EnumName.property
|
||||
# So we just join with dots, no need to convert first to lowercase
|
||||
swift_property = '.'.join(path_parts)
|
||||
|
||||
l10n_strings.append(L10nString(swift_property, localizable_key, i))
|
||||
continue
|
||||
|
||||
# Match static func declarations with L10n.tr() calls (for parameterized strings)
|
||||
# Pattern: public static func funcName(_ p1: Any) -> String { return L10n.tr("Localizable", "key", ...) }
|
||||
static_func_match = re.search(
|
||||
r'public static func (\w+)\([^)]*\)\s*->\s*String\s*\{[^}]*L10n\.tr\("Localizable",\s*"([^"]+)"',
|
||||
line
|
||||
)
|
||||
if static_func_match:
|
||||
func_name = static_func_match.group(1)
|
||||
localizable_key = static_func_match.group(2)
|
||||
|
||||
# Build the full property path
|
||||
path_parts = [e.name for e in enum_stack] + [func_name]
|
||||
swift_property = '.'.join(path_parts)
|
||||
|
||||
l10n_strings.append(L10nString(swift_property, localizable_key, i))
|
||||
|
||||
return l10n_strings
|
||||
|
||||
|
||||
def get_all_swift_content(repo_root: Path) -> str:
|
||||
"""
|
||||
Get all Swift source code content (excluding generated Strings.swift).
|
||||
Uses git ls-files for efficiency.
|
||||
"""
|
||||
try:
|
||||
# Get all Swift files tracked by git
|
||||
result = subprocess.run(
|
||||
['git', 'ls-files', '*.swift'],
|
||||
cwd=repo_root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True
|
||||
)
|
||||
swift_files = result.stdout.strip().split('\n')
|
||||
|
||||
# Exclude the generated Strings.swift and related files
|
||||
swift_files = [
|
||||
f for f in swift_files
|
||||
if f and 'Swiftgen' not in f and 'SwiftGen' not in f
|
||||
]
|
||||
|
||||
# Read all content
|
||||
all_content = []
|
||||
for swift_file in swift_files:
|
||||
file_path = repo_root / swift_file
|
||||
if file_path.exists():
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
all_content.append(f.read())
|
||||
except Exception:
|
||||
pass # Skip files that can't be read
|
||||
|
||||
return '\n'.join(all_content)
|
||||
except Exception as e:
|
||||
print(f"Error reading Swift files: {e}", file=sys.stderr)
|
||||
return ""
|
||||
|
||||
|
||||
def find_unused_strings(repo_root: Path, strings_swift_path: Path) -> List[L10nString]:
|
||||
"""
|
||||
Find L10n strings that are not used anywhere in the codebase.
|
||||
|
||||
Returns a list of unused L10nString objects.
|
||||
"""
|
||||
print("Parsing Strings.swift...")
|
||||
l10n_strings = parse_strings_swift(strings_swift_path)
|
||||
print(f"Found {len(l10n_strings)} L10n strings")
|
||||
|
||||
print("\nReading all Swift source code...")
|
||||
all_swift_content = get_all_swift_content(repo_root)
|
||||
print(f"Read {len(all_swift_content)} characters of Swift code")
|
||||
|
||||
unused_strings = []
|
||||
|
||||
print("\nChecking for unused strings...")
|
||||
for i, l10n_str in enumerate(l10n_strings):
|
||||
if (i + 1) % 100 == 0:
|
||||
print(f"Checked {i + 1}/{len(l10n_strings)} strings...")
|
||||
|
||||
# Check if L10n property is used
|
||||
property_parts = l10n_str.swift_property.split('.')
|
||||
leaf_property = property_parts[-1] if property_parts else l10n_str.swift_property
|
||||
|
||||
swift_used = False
|
||||
|
||||
# Check full L10n path usage (case-insensitive)
|
||||
full_path = f"L10n.{l10n_str.swift_property}"
|
||||
if full_path.lower() in all_swift_content.lower():
|
||||
swift_used = True
|
||||
|
||||
# Check leaf property/function usage (more permissive check)
|
||||
if not swift_used:
|
||||
# For leaf property, we check with common patterns
|
||||
if f".{leaf_property}" in all_swift_content:
|
||||
swift_used = True
|
||||
|
||||
# If not used as L10n property, check if the Localizable key is used directly
|
||||
# (e.g., in NSLocalizedString calls or string literals)
|
||||
direct_key_used = False
|
||||
if not swift_used:
|
||||
# Check if the localizable key is referenced directly as a string
|
||||
if f'"{l10n_str.localizable_key}"' in all_swift_content:
|
||||
direct_key_used = True
|
||||
|
||||
if not swift_used and not direct_key_used:
|
||||
unused_strings.append(l10n_str)
|
||||
|
||||
return unused_strings
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for the script."""
|
||||
# Determine repository root
|
||||
repo_root = Path(__file__).parent.parent
|
||||
|
||||
# Path to Strings.swift
|
||||
strings_swift_path = repo_root / "Sources/Shared/Resources/Swiftgen/Strings.swift"
|
||||
|
||||
if not strings_swift_path.exists():
|
||||
print(f"Error: Strings.swift not found at {strings_swift_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Find unused strings
|
||||
unused_strings = find_unused_strings(repo_root, strings_swift_path)
|
||||
|
||||
# Report results
|
||||
print(f"\n{'='*80}")
|
||||
print(f"UNUSED STRINGS REPORT")
|
||||
print(f"{'='*80}\n")
|
||||
|
||||
if not unused_strings:
|
||||
print("✅ No unused strings found!")
|
||||
sys.exit(0)
|
||||
|
||||
print(f"Found {len(unused_strings)} unused strings:\n")
|
||||
|
||||
# Group by prefix for better readability
|
||||
grouped: Dict[str, List[L10nString]] = {}
|
||||
for unused in unused_strings:
|
||||
parts = unused.swift_property.split('.')
|
||||
prefix = parts[0] if len(parts) > 1 else "root"
|
||||
if prefix not in grouped:
|
||||
grouped[prefix] = []
|
||||
grouped[prefix].append(unused)
|
||||
|
||||
for prefix in sorted(grouped.keys()):
|
||||
print(f"\n{prefix.upper()}:")
|
||||
for unused in sorted(grouped[prefix], key=lambda x: x.swift_property):
|
||||
print(f" - L10n.{unused.swift_property}")
|
||||
print(f" Key: {unused.localizable_key}")
|
||||
print(f" Line: {unused.line_number}")
|
||||
|
||||
print(f"\n{'='*80}")
|
||||
print(f"Total unused: {len(unused_strings)}")
|
||||
print(f"{'='*80}\n")
|
||||
|
||||
# Exit with error code to indicate unused strings were found
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
150
Tools/remove_unused_strings.py
Executable file
150
Tools/remove_unused_strings.py
Executable file
@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Remove unused localization strings from the Home Assistant iOS app.
|
||||
|
||||
This script:
|
||||
1. Uses detect_unused_strings.py to find unused strings
|
||||
2. Removes them from all language Localizable.strings files
|
||||
3. Regenerates Strings.swift using SwiftGen
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Set
|
||||
|
||||
# Import the detection module
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from detect_unused_strings import find_unused_strings, L10nString
|
||||
|
||||
|
||||
def get_all_localizable_files(repo_root: Path) -> List[Path]:
|
||||
"""Find all Localizable.strings files across all language directories."""
|
||||
resources_dir = repo_root / "Sources/App/Resources"
|
||||
localizable_files = []
|
||||
|
||||
for lproj_dir in resources_dir.glob("*.lproj"):
|
||||
localizable_file = lproj_dir / "Localizable.strings"
|
||||
if localizable_file.exists():
|
||||
localizable_files.append(localizable_file)
|
||||
|
||||
return sorted(localizable_files)
|
||||
|
||||
|
||||
def remove_key_from_strings_file(strings_file: Path, key_to_remove: str) -> bool:
|
||||
"""
|
||||
Remove a specific key from a .strings file.
|
||||
Returns True if the key was found and removed, False otherwise.
|
||||
"""
|
||||
try:
|
||||
with open(strings_file, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Pattern to match the key-value pair
|
||||
# Example: "key.name" = "value";
|
||||
# The value is a quoted string that may contain escaped quotes
|
||||
pattern = rf'^"{re.escape(key_to_remove)}" = "(?:[^"\\]|\\.)*";$'
|
||||
|
||||
lines = content.split('\n')
|
||||
new_lines = []
|
||||
removed = False
|
||||
|
||||
for line in lines:
|
||||
if re.match(pattern, line.strip()):
|
||||
removed = True
|
||||
print(f" Removed from {strings_file.parent.name}: {key_to_remove}")
|
||||
else:
|
||||
new_lines.append(line)
|
||||
|
||||
if removed:
|
||||
# Write back the modified content
|
||||
new_content = '\n'.join(new_lines)
|
||||
with open(strings_file, 'w', encoding='utf-8') as f:
|
||||
f.write(new_content)
|
||||
|
||||
return removed
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing {strings_file}: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
|
||||
def regenerate_strings_swift(repo_root: Path) -> bool:
|
||||
"""Regenerate Strings.swift using SwiftGen."""
|
||||
try:
|
||||
print("\nRegenerating Strings.swift using SwiftGen...")
|
||||
result = subprocess.run(
|
||||
['./Pods/SwiftGen/bin/swiftgen'],
|
||||
cwd=repo_root,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
print(f"Error running SwiftGen: {result.stderr}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
print("✅ Strings.swift regenerated successfully")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error regenerating Strings.swift: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for the script."""
|
||||
# Determine repository root
|
||||
repo_root = Path(__file__).parent.parent
|
||||
|
||||
# Path to Strings.swift
|
||||
strings_swift_path = repo_root / "Sources/Shared/Resources/Swiftgen/Strings.swift"
|
||||
|
||||
if not strings_swift_path.exists():
|
||||
print(f"Error: Strings.swift not found at {strings_swift_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Find unused strings
|
||||
print("Detecting unused strings...")
|
||||
unused_strings = find_unused_strings(repo_root, strings_swift_path)
|
||||
|
||||
if not unused_strings:
|
||||
print("\n✅ No unused strings found - nothing to remove!")
|
||||
sys.exit(0)
|
||||
|
||||
print(f"\n{'='*80}")
|
||||
print(f"Found {len(unused_strings)} unused strings to remove")
|
||||
print(f"{'='*80}\n")
|
||||
|
||||
# Get all Localizable.strings files
|
||||
localizable_files = get_all_localizable_files(repo_root)
|
||||
print(f"Found {len(localizable_files)} Localizable.strings files\n")
|
||||
|
||||
# Extract the keys to remove
|
||||
keys_to_remove = {unused.localizable_key for unused in unused_strings}
|
||||
|
||||
# Remove keys from all Localizable.strings files
|
||||
total_removals = 0
|
||||
for key in sorted(keys_to_remove):
|
||||
print(f"\nRemoving key: {key}")
|
||||
for strings_file in localizable_files:
|
||||
if remove_key_from_strings_file(strings_file, key):
|
||||
total_removals += 1
|
||||
|
||||
print(f"\n{'='*80}")
|
||||
print(f"Removed {total_removals} key-value pairs across all language files")
|
||||
print(f"{'='*80}\n")
|
||||
|
||||
# Regenerate Strings.swift
|
||||
if not regenerate_strings_swift(repo_root):
|
||||
print("\n⚠️ Warning: Failed to regenerate Strings.swift", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print("\n✅ Successfully removed unused strings and regenerated Strings.swift")
|
||||
print("\nPlease review the changes and commit them.")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
199
UNUSED_STRINGS.md
Normal file
199
UNUSED_STRINGS.md
Normal file
@ -0,0 +1,199 @@
|
||||
# Unused L10n String Detection and Cleanup
|
||||
|
||||
This document describes the automated system for detecting and removing unused localization (L10n) strings in the Home Assistant iOS app.
|
||||
|
||||
## Overview
|
||||
|
||||
The system consists of:
|
||||
1. **Detection Script** - Identifies unused L10n strings
|
||||
2. **Removal Script** - Safely removes unused strings and regenerates code
|
||||
3. **CI Check** - Automatically detects unused strings in pull requests
|
||||
4. **Automated Cleanup Workflow** - Creates PRs to clean up unused strings monthly
|
||||
|
||||
## Components
|
||||
|
||||
### 1. Detection Script (`Tools/detect_unused_strings.py`)
|
||||
|
||||
**Purpose**: Identifies L10n strings that are not used anywhere in the codebase.
|
||||
|
||||
**How it works**:
|
||||
1. Parses `Sources/Shared/Resources/Swiftgen/Strings.swift` to extract all L10n properties
|
||||
2. Reads all Swift source files once for efficient searching
|
||||
3. Checks for:
|
||||
- Full L10n property path usage (e.g., `L10n.About.title`)
|
||||
- Leaf property usage (e.g., `.title`)
|
||||
- Direct Localizable key usage (e.g., `"about.title"`)
|
||||
4. Reports unused strings grouped by category
|
||||
|
||||
**Usage**:
|
||||
```bash
|
||||
python3 Tools/detect_unused_strings.py
|
||||
```
|
||||
|
||||
**Exit Codes**:
|
||||
- `0`: No unused strings found
|
||||
- `1`: Unused strings detected (exits with 1 to enable workflow detection)
|
||||
|
||||
### 2. Removal Script (`Tools/remove_unused_strings.py`)
|
||||
|
||||
**Purpose**: Removes unused strings from all language files and regenerates Strings.swift.
|
||||
|
||||
**How it works**:
|
||||
1. Uses the detection script to find unused strings
|
||||
2. Removes matching entries from all `*.lproj/Localizable.strings` files
|
||||
3. Runs SwiftGen to regenerate `Strings.swift`
|
||||
4. Reports detailed summary of changes
|
||||
|
||||
**Prerequisites**:
|
||||
- Python 3.x
|
||||
- CocoaPods dependencies installed (`bundle exec pod install`)
|
||||
|
||||
**Usage**:
|
||||
```bash
|
||||
python3 Tools/remove_unused_strings.py
|
||||
```
|
||||
|
||||
**Safety Features**:
|
||||
- Only removes strings that are truly unused
|
||||
- Regenerates code immediately to maintain consistency
|
||||
- Changes are visible in git for review before commit
|
||||
|
||||
### 3. CI Check (`.github/workflows/ci.yml`)
|
||||
|
||||
**Purpose**: Alert developers when unused strings are introduced or exist in PRs.
|
||||
|
||||
**Job**: `check-unused-strings`
|
||||
- Runs on every pull request
|
||||
- Executes the detection script
|
||||
- Posts a sticky comment on the PR with results
|
||||
- Non-blocking (informational only)
|
||||
|
||||
**Comment Format**:
|
||||
- Shows count of unused strings
|
||||
- Includes detailed list in collapsible section
|
||||
- Suggests using the removal script or automated workflow
|
||||
|
||||
### 4. Automated Cleanup Workflow (`.github/workflows/clean_unused_strings.yml`)
|
||||
|
||||
**Purpose**: Automatically create PRs to clean up unused strings.
|
||||
|
||||
**Triggers**:
|
||||
- Manual dispatch (workflow_dispatch)
|
||||
- Monthly schedule (1st of each month at 00:00 UTC)
|
||||
|
||||
**Process**:
|
||||
1. Checks out main branch
|
||||
2. Runs detection script
|
||||
3. If unused strings found:
|
||||
- Installs dependencies (Ruby, Pods)
|
||||
- Runs removal script
|
||||
- Creates pull request with changes
|
||||
4. If no unused strings, exits successfully
|
||||
|
||||
**PR Details**:
|
||||
- Title: "Remove unused L10n strings"
|
||||
- Labels: `automated`, `localization`, `cleanup`
|
||||
- Includes detailed summary and list of removed strings
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### String Detection Algorithm
|
||||
|
||||
The detection script uses a multi-step approach:
|
||||
|
||||
1. **Parse Strings.swift**: Extracts L10n enum structure using regex patterns
|
||||
- Tracks enum nesting using a stack
|
||||
- Identifies both properties and functions
|
||||
- Maps L10n properties to Localizable keys
|
||||
|
||||
2. **Efficient Code Search**: Reads all Swift files once into memory
|
||||
- Avoids multiple git grep calls
|
||||
- Case-insensitive matching for robustness
|
||||
- Checks multiple usage patterns
|
||||
|
||||
3. **Multi-Pattern Matching**:
|
||||
- Full path: `L10n.About.title`
|
||||
- Leaf property: `.title`
|
||||
- Direct key: `"about.title"`
|
||||
|
||||
### String Removal Process
|
||||
|
||||
The removal script:
|
||||
|
||||
1. Uses detection script to get unused keys
|
||||
2. For each unused key:
|
||||
- Iterates through all language directories
|
||||
- Matches the key using a precise regex pattern
|
||||
- Removes the line from the file
|
||||
3. Regenerates Strings.swift using SwiftGen
|
||||
4. Reports summary of changes
|
||||
|
||||
### Safety Considerations
|
||||
|
||||
- **Double-checking**: Both L10n usage and direct key usage are checked
|
||||
- **Regex precision**: Handles escaped characters and complex string values
|
||||
- **Git visibility**: All changes are visible for review
|
||||
- **Reversibility**: Changes can be reverted before merging
|
||||
- **CI notification**: Developers are informed about unused strings
|
||||
|
||||
## Maintenance
|
||||
|
||||
### Adding New Checks
|
||||
|
||||
To enhance the detection script:
|
||||
1. Edit `Tools/detect_unused_strings.py`
|
||||
2. Add new patterns to check in the `find_unused_strings` function
|
||||
3. Test with: `python3 Tools/detect_unused_strings.py`
|
||||
|
||||
### Modifying CI Behavior
|
||||
|
||||
To change when checks run:
|
||||
1. Edit `.github/workflows/ci.yml` (PR checks)
|
||||
2. Edit `.github/workflows/clean_unused_strings.yml` (automated cleanup)
|
||||
|
||||
### Updating Schedule
|
||||
|
||||
To change cleanup frequency:
|
||||
1. Edit `.github/workflows/clean_unused_strings.yml`
|
||||
2. Modify the `cron` expression under `schedule`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Detection Script Reports False Positives
|
||||
|
||||
- Check if the string is used in a way not covered by patterns
|
||||
- Consider if the string is used in non-Swift files (storyboards, etc.)
|
||||
- Verify the L10n property path is correctly parsed
|
||||
|
||||
### Removal Script Fails to Regenerate
|
||||
|
||||
- Ensure Pods are installed: `bundle exec pod install`
|
||||
- Check SwiftGen is available: `./Pods/SwiftGen/bin/swiftgen --version`
|
||||
- Verify swiftgen.yml configuration is correct
|
||||
|
||||
### CI Check Not Running
|
||||
|
||||
- Verify the workflow file has correct YAML syntax
|
||||
- Check GitHub Actions permissions
|
||||
- Ensure Python setup-python action is available
|
||||
|
||||
### Automated Workflow Not Creating PR
|
||||
|
||||
- Check workflow run logs in GitHub Actions
|
||||
- Verify `GITHUB_TOKEN` has correct permissions
|
||||
- Ensure no unused strings were found (workflow skips if nothing to clean)
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements:
|
||||
- Support for Core.strings and Frontend.strings
|
||||
- Integration with localization service (Lokalise)
|
||||
- Interactive mode for reviewing each string before removal
|
||||
- Support for detecting unused strings in other file types
|
||||
- Performance optimizations for very large codebases
|
||||
|
||||
## References
|
||||
|
||||
- SwiftGen: https://github.com/SwiftGen/SwiftGen
|
||||
- Lokalise: Used for translation management
|
||||
- GitHub Actions: https://docs.github.com/en/actions
|
||||
Loading…
x
Reference in New Issue
Block a user