|
1 | 1 | # frozen_string_literal: true |
2 | 2 |
|
| 3 | +require 'json' |
| 4 | +require 'tmpdir' |
| 5 | +require 'fileutils' |
| 6 | +require 'open3' |
| 7 | + |
3 | 8 | ################################################# |
4 | 9 | # Constants |
5 | 10 | ################################################# |
@@ -589,4 +594,108 @@ def download_localized_app_store_metadata(glotpress_project_url:, locales:, meta |
589 | 594 | skip_confirm: skip_confirm |
590 | 595 | ) |
591 | 596 | end |
| 597 | + |
| 598 | + ################################################# |
| 599 | + # SwiftUI preview-string lint |
| 600 | + # |
| 601 | + # Fails if a localizable SwiftUI `Text("…")` literal lives inside a `#Preview` or `PreviewProvider`. |
| 602 | + # Preview code never ships to users, but Apple's extractor (`xcstringstool extract --SwiftUI-Text`) |
| 603 | + # still pulls those literals into the String Catalog and feeds them to translators as garbage. The |
| 604 | + # only correct opt-out is `Text(verbatim:)` (Apple has no preview-specific exclusion). |
| 605 | + # |
| 606 | + # This reuses the extractor as its own oracle: `xcstringstool` tags every extracted string's |
| 607 | + # `visibility`, and preview literals come out as `visibility: "preview"` — so no source parsing or |
| 608 | + # brace-tracking is required, and the lint is tautologically consistent with what extraction does. |
| 609 | + # |
| 610 | + # Scope (validated adversarially — zero false positives, so it never blocks an innocent PR): |
| 611 | + # • Catches `Text("…")` written *lexically inside* a `#Preview`/`PreviewProvider` (any nesting depth, |
| 612 | + # plus `#if`-gated previews via `preview_visibility?`). |
| 613 | + # • Known gaps (by design, not bugs): a preview-only string hoisted into a free helper view/func is |
| 614 | + # indistinguishable from shipping code, so it isn't tagged `preview`; and modern localizable APIs |
| 615 | + # (`String(localized:)`, `LocalizedStringResource`) aren't on the `--SwiftUI-Text` surface. Both are |
| 616 | + # latent today (the cleanup is `Text("…")`-only) — revisit if/when those patterns appear. |
| 617 | + ################################################# |
| 618 | + |
| 619 | + desc 'Fails if a localizable SwiftUI Text("…") literal lives in a #Preview (use Text(verbatim:) instead)' |
| 620 | + lane :lint_swiftui_preview_strings do |
| 621 | + roots = [ |
| 622 | + File.join(PROJECT_ROOT_FOLDER, 'WordPress'), |
| 623 | + File.join(PROJECT_ROOT_FOLDER, 'Modules', 'Sources') |
| 624 | + ] |
| 625 | + files = swiftui_lint_source_files(roots) |
| 626 | + UI.user_error!('No Swift source files found to lint') if files.empty? |
| 627 | + UI.message("Scanning #{files.count} Swift files for localizable Text(\"…\") literals in previews…") |
| 628 | + |
| 629 | + offenders = preview_localizable_text_literals(files) |
| 630 | + |
| 631 | + if offenders.empty? |
| 632 | + UI.success('No localizable Text("…") literals found in SwiftUI previews. ✅') |
| 633 | + else |
| 634 | + offenders.each { |offender| UI.error(" #{offender}") } |
| 635 | + UI.user_error!( |
| 636 | + "#{offenders.count} localizable SwiftUI `Text(\"…\")` literal(s) found in `#Preview` / `PreviewProvider`. " \ |
| 637 | + 'Preview strings must not be extracted for translation — wrap each in `Text(verbatim:)`.' |
| 638 | + ) |
| 639 | + end |
| 640 | + end |
| 641 | + |
| 642 | + # Enumerate .swift files under the given roots, applying the same exclusions as the string-extraction flow |
| 643 | + # (vendored code, the unit-test harness, and AppLocalizedString.swift's own definition). |
| 644 | + def swiftui_lint_source_files(roots) |
| 645 | + roots.flat_map { |root| Dir.glob(File.join(root, '**', '*.swift')) } |
| 646 | + .reject { |path| path.include?('Vendor/') || path.include?('/WordPressTest/') || File.basename(path) == 'AppLocalizedString.swift' } |
| 647 | + .uniq |
| 648 | + .sort |
| 649 | + end |
| 650 | + |
| 651 | + # Run `xcstringstool extract --SwiftUI-Text` over the files and return a sorted "file:line \"key\"" entry |
| 652 | + # for every extracted string tagged `visibility: preview` (i.e. originating from #Preview / PreviewProvider). |
| 653 | + def preview_localizable_text_literals(files) |
| 654 | + Dir.mktmpdir do |dir| |
| 655 | + extract_swiftui_text_stringsdata(files, dir) |
| 656 | + Dir.glob(File.join(dir, '**', '*.stringsdata')).flat_map { |path| preview_offenders_in(path) }.sort |
| 657 | + end |
| 658 | + end |
| 659 | + |
| 660 | + # Extract `--SwiftUI-Text` .stringsdata for all files into `dir`, chunked to stay under ARG_MAX. |
| 661 | + # `--omit-empty-stringsdata` skips writing a file per source with no strings. |
| 662 | + def extract_swiftui_text_stringsdata(files, dir) |
| 663 | + files.each_slice(400).with_index do |chunk, index| |
| 664 | + chunk_dir = File.join(dir, "chunk-#{index}") |
| 665 | + FileUtils.mkdir_p(chunk_dir) |
| 666 | + _out, err, status = Open3.capture3('xcrun', 'xcstringstool', 'extract', *chunk, '--SwiftUI-Text', '--omit-empty-stringsdata', '--output-directory', chunk_dir) |
| 667 | + UI.user_error!("xcstringstool extract failed:\n#{err}") unless status.success? |
| 668 | + end |
| 669 | + end |
| 670 | + |
| 671 | + # Parse one .stringsdata (a property list) into "file:line \"key\"" entries for its preview-visibility strings. |
| 672 | + # Scans every table (not just Localizable) so a preview using `Text("…", tableName:)` is still caught. |
| 673 | + def preview_offenders_in(stringsdata_path) |
| 674 | + json, status = Open3.capture2('plutil', '-convert', 'json', '-o', '-', stringsdata_path) |
| 675 | + return [] unless status.success? |
| 676 | + |
| 677 | + data = JSON.parse(json) |
| 678 | + source = data['source'].to_s.sub("#{PROJECT_ROOT_FOLDER}/", '') |
| 679 | + (data['tables'] || {}).values.flatten |
| 680 | + .select { |entry| preview_visibility?(entry['visibility']) } |
| 681 | + .map { |entry| offender_label(source, entry) } |
| 682 | + end |
| 683 | + |
| 684 | + # "file:line \"key\"" label for a single offending stringsdata entry. |
| 685 | + def offender_label(source, entry) |
| 686 | + "#{source}:#{entry.dig('location', 'startingLine')} #{entry['key'].inspect}" |
| 687 | + end |
| 688 | + |
| 689 | + # True if a string's `visibility` denotes a SwiftUI preview. `xcstringstool` emits a bare "preview" for a |
| 690 | + # plain `#Preview`/`PreviewProvider`, but wraps it when the preview sits under conditional compilation — |
| 691 | + # e.g. `#if DEBUG` → `#Preview` yields {"type":"conjunction","values":[{"type":"defined","value":"DEBUG"}, |
| 692 | + # "preview"]}. Recurse the values so a `#if DEBUG`-gated preview is caught too. A bare `#if FLAG` (no |
| 693 | + # preview) is {"type":"defined","value":"FLAG"} with no "preview" leaf, so it stays correctly unflagged. |
| 694 | + def preview_visibility?(visibility) |
| 695 | + case visibility |
| 696 | + when 'preview' then true |
| 697 | + when Hash then Array(visibility['values']).any? { |value| preview_visibility?(value) } |
| 698 | + else false |
| 699 | + end |
| 700 | + end |
592 | 701 | end |
0 commit comments