HEX
Server: Apache
System: Linux pdx1-shared-a1-38 6.6.104-grsec-jammy+ #3 SMP Tue Sep 16 00:28:11 UTC 2025 x86_64
User: mmickelson (3396398)
PHP: 8.1.31
Disabled: NONE
Upload Files
File: //usr/local/wp/vendor/wp-cli/i18n-command/src/MakePotCommand.php
<?php

namespace WP_CLI\I18n;

use Gettext\Extractors\Po;
use Gettext\Merge;
use Gettext\Translation;
use Gettext\Translations;
use Gettext\Utils\ParsedComment;
use WP_CLI;
use WP_CLI_Command;
use WP_CLI\Utils;
use DirectoryIterator;
use IteratorIterator;

class MakePotCommand extends WP_CLI_Command {
	/**
	 * @var string
	 */
	protected $source;

	/**
	 * @var string
	 */
	protected $destination;

	/**
	 * @var array
	 */
	protected $merge = [];

	/**
	 * @var Translations[]
	 */
	protected $exceptions = [];

	/**
	 * @var bool
	 */
	protected $subtract_and_merge;

	/**
	 * @var array
	 */
	protected $include = [];

	/**
	 * @var array
	 */
	protected $exclude = [ 'node_modules', '.*', 'vendor', 'Gruntfile.js', 'webpack.config.js', '*.min.js', 'test', 'tests' ];

	/**
	 * @var string
	 */
	protected $slug;

	/**
	 * @var array
	 */
	protected $main_file_data = [];

	/**
	 * @var string
	 */
	protected $main_file_path;

	/**
	 * @var bool
	 */
	protected $skip_js = false;

	/**
	 * @var bool
	 */
	protected $skip_php = false;

	/**
	 * @var bool
	 */
	protected $skip_blade = false;

	/**
	 * @var bool
	 */
	protected $skip_block_json = false;

	/**
	 * @var bool
	 */
	protected $skip_theme_json = false;

	/**
	 * @var bool
	 */
	protected $skip_audit = false;

	/**
	 * @var bool
	 */
	protected $location = true;

	/**
	 * @var array
	 */
	protected $headers = [];

	/**
	 * @var string
	 */
	protected $domain;

	/**
	 * @var string
	 */
	protected $package_name;

	/**
	 * @var string
	 */
	protected $file_comment;

	/**
	 * @var string
	 */
	protected $project_type = 'generic';

	/**
	 * These Regexes copied from http://php.net/manual/en/function.sprintf.php#93552
	 * and adjusted for better precision and updated specs.
	 */
	const SPRINTF_PLACEHOLDER_REGEX = '/(?:
		(?<!%)                     # Don\'t match a literal % (%%).
		(
			%                          # Start of placeholder.
			(?:[0-9]+\$)?              # Optional ordering of the placeholders.
			[+-]?                      # Optional sign specifier.
			(?:
				(?:0|\'.)?                 # Optional padding specifier - excluding the space.
				-?                         # Optional alignment specifier.
				[0-9]*                     # Optional width specifier.
				(?:\.(?:[ 0]|\'.)?[0-9]+)? # Optional precision specifier with optional padding character.
				|                      # Only recognize the space as padding in combination with a width specifier.
				(?:[ ])?                   # Optional space padding specifier.
				-?                         # Optional alignment specifier.
				[0-9]+                     # Width specifier.
				(?:\.(?:[ 0]|\'.)?[0-9]+)? # Optional precision specifier with optional padding character.
			)
			[bcdeEfFgGosuxX]           # Type specifier.
		)
	)/x';

	/**
	 * "Unordered" means there's no position specifier: '%s', not '%2$s'.
	 */
	const UNORDERED_SPRINTF_PLACEHOLDER_REGEX = '/(?:
		(?<!%)                     # Don\'t match a literal % (%%).
		%                          # Start of placeholder.
		[+-]?                      # Optional sign specifier.
		(?:
			(?:0|\'.)?                 # Optional padding specifier - excluding the space.
			-?                         # Optional alignment specifier.
			[0-9]*                     # Optional width specifier.
			(?:\.(?:[ 0]|\'.)?[0-9]+)? # Optional precision specifier with optional padding character.
			|                      # Only recognize the space as padding in combination with a width specifier.
			(?:[ ])?                   # Optional space padding specifier.
			-?                         # Optional alignment specifier.
			[0-9]+                     # Width specifier.
			(?:\.(?:[ 0]|\'.)?[0-9]+)? # Optional precision specifier with optional padding character.
		)
		[bcdeEfFgGosuxX]           # Type specifier.
	)/x';

	/**
	 * Create a POT file for a WordPress project.
	 *
	 * Scans PHP, Blade-PHP and JavaScript files for translatable strings, as well as theme stylesheets and plugin files
	 * if the source directory is detected as either a plugin or theme.
	 *
	 * ## OPTIONS
	 *
	 * <source>
	 * : Directory to scan for string extraction.
	 *
	 * [<destination>]
	 * : Name of the resulting POT file.
	 *
	 * [--slug=<slug>]
	 * : Plugin or theme slug. Defaults to the source directory's basename.
	 *
	 * [--domain=<domain>]
	 * : Text domain to look for in the source code, unless the `--ignore-domain` option is used.
	 * By default, the "Text Domain" header of the plugin or theme is used.
	 * If none is provided, it falls back to the project slug.
	 *
	 * [--ignore-domain]
	 * : Ignore the text domain completely and extract strings with any text domain.
	 *
	 * [--merge[=<paths>]]
	 * : Comma-separated list of POT files whose contents should be merged with the extracted strings.
	 * If left empty, defaults to the destination POT file. POT file headers will be ignored.
	 *
	 * [--subtract=<paths>]
	 * : Comma-separated list of POT files whose contents should act as some sort of denylist for string extraction.
	 * Any string which is found on that denylist will not be extracted.
	 * This can be useful when you want to create multiple POT files from the same source directory with slightly
	 * different content and no duplicate strings between them.
	 *
	 * [--subtract-and-merge]
	 * : Whether source code references and comments from the generated POT file should be instead added to the POT file
	 * used for subtraction. Warning: this modifies the files passed to `--subtract`!
	 *
	 * [--include=<paths>]
	 * : Comma-separated list of files and paths that should be used for string extraction.
	 * If provided, only these files and folders will be taken into account for string extraction.
	 * For example, `--include="src,my-file.php` will ignore anything besides `my-file.php` and files in the `src`
	 * directory. Simple glob patterns can be used, i.e. `--include=foo-*.php` includes any PHP file with the `foo-`
	 * prefix. Leading and trailing slashes are ignored, i.e. `/my/directory/` is the same as `my/directory`.
	 *
	 * [--exclude=<paths>]
	 * : Comma-separated list of files and paths that should be skipped for string extraction.
	 * For example, `--exclude=.github,myfile.php` would ignore any strings found within `myfile.php` or the `.github`
	 * folder. Simple glob patterns can be used, i.e. `--exclude=foo-*.php` excludes any PHP file with the `foo-`
	 * prefix. Leading and trailing slashes are ignored, i.e. `/my/directory/` is the same as `my/directory`. The
	 * following files and folders are always excluded: node_modules, .git, .svn, .CVS, .hg, vendor, *.min.js.
	 *
	 * [--headers=<headers>]
	 * : Array in JSON format of custom headers which will be added to the POT file. Defaults to empty array.
	 *
	 * [--location]
	 * : Whether to write `#: filename:line` lines.
	 * Defaults to true, use `--no-location` to skip the removal.
	 * Note that disabling this option makes it harder for technically skilled translators to understand each message’s context.
	 *
	 * [--skip-js]
	 * : Skips JavaScript string extraction. Useful when this is done in another build step, e.g. through Babel.
	 *
	 * [--skip-php]
	 * : Skips PHP string extraction.
	 *
	 * [--skip-blade]
	 * : Skips Blade-PHP string extraction.
	 *
	 * [--skip-block-json]
	 * : Skips string extraction from block.json files.
	 *
	 * [--skip-theme-json]
	 * : Skips string extraction from theme.json files.
	 *
	 * [--skip-audit]
	 * : Skips string audit where it tries to find possible mistakes in translatable strings. Useful when running in an
	 * automated environment.
	 *
	 * [--file-comment=<file-comment>]
	 * : String that should be added as a comment to the top of the resulting POT file.
	 * By default, a copyright comment is added for WordPress plugins and themes in the following manner:
	 *
	 *      ```
	 *      Copyright (C) 2018 Example Plugin Author
	 *      This file is distributed under the same license as the Example Plugin package.
	 *      ```
	 *
	 *      If a plugin or theme specifies a license in their main plugin file or stylesheet, the comment looks like
	 *      this:
	 *
	 *      ```
	 *      Copyright (C) 2018 Example Plugin Author
	 *      This file is distributed under the GPLv2.
	 *      ```
	 *
	 * [--package-name=<name>]
	 * : Name to use for package name in the resulting POT file's `Project-Id-Version` header.
	 * Overrides plugin or theme name, if applicable.
	 *
	 * ## EXAMPLES
	 *
	 *     # Create a POT file for the WordPress plugin/theme in the current directory.
	 *     $ wp i18n make-pot . languages/my-plugin.pot
	 *
	 *     # Create a POT file for the continents and cities list in WordPress core.
	 *     $ wp i18n make-pot . continents-and-cities.pot --include="wp-admin/includes/continents-cities.php" --ignore-domain
	 *
	 *     # Create a POT file for the WordPress theme in the current directory with custom headers.
	 *     $ wp i18n make-pot . languages/my-theme.pot --headers='{"Report-Msgid-Bugs-To":"https://github.com/theme-author/my-theme/","POT-Creation-Date":""}'
	 *
	 * @when before_wp_load
	 *
	 * @throws WP_CLI\ExitException
	 */
	public function __invoke( $args, $assoc_args ) {
		$this->handle_arguments( $args, $assoc_args );

		$translations = $this->extract_strings();

		if ( ! $translations ) {
			WP_CLI::warning( 'No strings found' );
		}

		$translations_count = count( $translations );

		if ( 1 === $translations_count ) {
			WP_CLI::debug( sprintf( 'Extracted %d string', $translations_count ), 'make-pot' );
		} else {
			WP_CLI::debug( sprintf( 'Extracted %d strings', $translations_count ), 'make-pot' );
		}

		if ( ! PotGenerator::toFile( $translations, $this->destination ) ) {
			WP_CLI::error( 'Could not generate a POT file.' );
		}

		WP_CLI::success( 'POT file successfully generated.' );
	}

	/**
	 * Process arguments from command-line in a reusable way.
	 *
	 * @throws WP_CLI\ExitException
	 *
	 * @param array $args       Command arguments.
	 * @param array $assoc_args Associative arguments.
	 */
	public function handle_arguments( $args, $assoc_args ) {
		$array_arguments = array( 'headers' );
		$assoc_args      = Utils\parse_shell_arrays( $assoc_args, $array_arguments );

		$this->source          = realpath( $args[0] );
		$this->slug            = Utils\get_flag_value( $assoc_args, 'slug', Utils\basename( $this->source ) );
		$this->skip_js         = Utils\get_flag_value( $assoc_args, 'skip-js', $this->skip_js );
		$this->skip_php        = Utils\get_flag_value( $assoc_args, 'skip-php', $this->skip_php );
		$this->skip_blade      = Utils\get_flag_value( $assoc_args, 'skip-blade', $this->skip_blade );
		$this->skip_block_json = Utils\get_flag_value( $assoc_args, 'skip-block-json', $this->skip_block_json );
		$this->skip_theme_json = Utils\get_flag_value( $assoc_args, 'skip-theme-json', $this->skip_theme_json );
		$this->skip_audit      = Utils\get_flag_value( $assoc_args, 'skip-audit', $this->skip_audit );
		$this->headers         = Utils\get_flag_value( $assoc_args, 'headers', $this->headers );
		$this->file_comment    = Utils\get_flag_value( $assoc_args, 'file-comment' );
		$this->package_name    = Utils\get_flag_value( $assoc_args, 'package-name' );
		$this->location        = Utils\get_flag_value( $assoc_args, 'location', true );

		$ignore_domain = Utils\get_flag_value( $assoc_args, 'ignore-domain', false );

		if ( ! $this->source || ! is_dir( $this->source ) ) {
			WP_CLI::error( 'Not a valid source directory.' );
		}

		$this->main_file_data = $this->get_main_file_data();

		if ( $ignore_domain ) {
			WP_CLI::debug( 'Extracting all strings regardless of text domain', 'make-pot' );
		}

		if ( ! $ignore_domain ) {
			$this->domain = $this->slug;

			if ( ! empty( $this->main_file_data['Text Domain'] ) ) {
				$this->domain = $this->main_file_data['Text Domain'];
			}

			$this->domain = Utils\get_flag_value( $assoc_args, 'domain', $this->domain );

			WP_CLI::debug( sprintf( 'Extracting all strings with text domain "%s"', $this->domain ), 'make-pot' );
		}

		// Determine destination.
		$this->destination = "{$this->source}/{$this->slug}.pot";

		if ( ! empty( $this->main_file_data['Domain Path'] ) ) {
			// Domain Path inside source folder.
			$this->destination = sprintf(
				'%s/%s/%s.pot',
				$this->source,
				$this->unslashit( $this->main_file_data['Domain Path'] ),
				$this->slug
			);
		}

		if ( isset( $args[1] ) ) {
			$this->destination = $args[1];
		}

		WP_CLI::debug( sprintf( 'Destination: %s', $this->destination ), 'make-pot' );

		if ( ! is_dir( dirname( $this->destination ) ) && ! mkdir( dirname( $this->destination ), 0777, true ) ) {
			WP_CLI::error( 'Could not create destination directory.' );
		}

		if ( isset( $assoc_args['merge'] ) ) {
			if ( true === $assoc_args['merge'] ) {
				$this->merge = [ $this->destination ];
			} elseif ( ! empty( $assoc_args['merge'] ) ) {
				$this->merge = explode( ',', $assoc_args['merge'] );
			}

			$this->merge = array_filter(
				$this->merge,
				function ( $file ) {
					if ( ! file_exists( $file ) ) {
						WP_CLI::warning( sprintf( 'Invalid file provided to --merge: %s', $file ) );

						return false;
					}

					return true;
				}
			);

			if ( ! empty( $this->merge ) ) {
				WP_CLI::debug(
					sprintf(
						'Merging with existing POT %s: %s',
						WP_CLI\Utils\pluralize( 'file', count( $this->merge ) ),
						implode( ',', $this->merge )
					),
					'make-pot'
				);
			}
		}

		if ( isset( $assoc_args['subtract'] ) ) {
			$this->subtract_and_merge = Utils\get_flag_value( $assoc_args, 'subtract-and-merge', false );

			$files = explode( ',', $assoc_args['subtract'] );

			foreach ( $files as $file ) {
				if ( ! file_exists( $file ) ) {
					WP_CLI::warning( sprintf( 'Invalid file provided to --subtract: %s', $file ) );
					continue;
				}

				WP_CLI::debug( sprintf( 'Ignoring any string already existing in: %s', $file ), 'make-pot' );

				$this->exceptions[ $file ] = new Translations();
				Po::fromFile( $file, $this->exceptions[ $file ] );
			}
		}

		if ( isset( $assoc_args['include'] ) ) {
			$this->include = array_filter( explode( ',', $assoc_args['include'] ) );
			$this->include = array_map( [ $this, 'unslashit' ], $this->include );
			$this->include = array_unique( $this->include );

			WP_CLI::debug( sprintf( 'Only including the following files: %s', implode( ',', $this->include ) ), 'make-pot' );
		}

		if ( isset( $assoc_args['exclude'] ) ) {
			$this->exclude = array_filter( array_merge( $this->exclude, explode( ',', $assoc_args['exclude'] ) ) );
			$this->exclude = array_map( [ $this, 'unslashit' ], $this->exclude );
			$this->exclude = array_unique( $this->exclude );
		}

		WP_CLI::debug( sprintf( 'Excluding the following files: %s', implode( ',', $this->exclude ) ), 'make-pot' );
	}

	/**
	 * Removes leading and trailing slashes of a string.
	 *
	 * @param string $text What to add and remove slashes from.
	 * @return string String without leading and trailing slashes.
	 */
	protected function unslashit( $text ) {
		return ltrim( rtrim( trim( $text ), '/\\' ), '/\\' );
	}

	/**
	 * Retrieves the main file data of the plugin or theme.
	 *
	 * @return array
	 */
	protected function get_main_file_data() {
		$files = new IteratorIterator( new DirectoryIterator( $this->source ) );

		/** @var DirectoryIterator $file */
		foreach ( $files as $file ) {
			// wp-content/themes/my-theme/style.css
			if ( $file->isFile() && 'style' === $file->getBasename( '.css' ) && $file->isReadable() ) {
				$theme_data = FileDataExtractor::get_file_data(
					$file->getRealPath(),
					array_combine(
						$this->get_file_headers( 'theme' ),
						$this->get_file_headers( 'theme' )
					)
				);

				// Stop when it contains a valid Theme Name header.
				if ( ! empty( $theme_data['Theme Name'] ) ) {
					WP_CLI::log( 'Theme stylesheet detected.' );
					WP_CLI::debug( sprintf( 'Theme stylesheet: %s', $file->getRealPath() ), 'make-pot' );

					$this->project_type   = 'theme';
					$this->main_file_path = $file->getRealPath();

					return $theme_data;
				}
			}

			// wp-content/themes/my-themes/theme-a/style.css
			if ( $file->isDir() && ! $file->isDot() && is_readable( $file->getRealPath() . '/style.css' ) ) {
				$theme_data = FileDataExtractor::get_file_data(
					$file->getRealPath() . '/style.css',
					array_combine(
						$this->get_file_headers( 'theme' ),
						$this->get_file_headers( 'theme' )
					)
				);

				// Stop when it contains a valid Theme Name header.
				if ( ! empty( $theme_data['Theme Name'] ) ) {
					WP_CLI::log( 'Theme stylesheet detected.' );
					WP_CLI::debug( sprintf( 'Theme stylesheet: %s', $file->getRealPath() . '/style.css' ), 'make-pot' );

					$this->project_type   = 'theme';
					$this->main_file_path = $file->getRealPath();

					return $theme_data;
				}
			}

			// wp-content/plugins/my-plugin/my-plugin.php
			if ( $file->isFile() && $file->isReadable() && 'php' === $file->getExtension() ) {
				$plugin_data = FileDataExtractor::get_file_data(
					$file->getRealPath(),
					array_combine(
						$this->get_file_headers( 'plugin' ),
						$this->get_file_headers( 'plugin' )
					)
				);

				// Stop when we find a file with a valid Plugin Name header.
				if ( ! empty( $plugin_data['Plugin Name'] ) ) {
					WP_CLI::log( 'Plugin file detected.' );
					WP_CLI::debug( sprintf( 'Plugin file: %s', $file->getRealPath() ), 'make-pot' );

					$this->project_type   = 'plugin';
					$this->main_file_path = $file->getRealPath();

					return $plugin_data;
				}
			}
		}

		WP_CLI::debug( 'No valid theme stylesheet or plugin file found, treating as a regular project.', 'make-pot' );

		return [];
	}

	/**
	 * Returns the file headers for themes and plugins.
	 *
	 * @param string $type Source type, either theme or plugin.
	 *
	 * @return array List of file headers.
	 */
	protected function get_file_headers( $type ) {
		switch ( $type ) {
			case 'plugin':
				return [
					'Plugin Name',
					'Plugin URI',
					'Description',
					'Author',
					'Author URI',
					'Version',
					'License',
					'Domain Path',
					'Text Domain',
				];
			case 'theme':
				return [
					'Theme Name',
					'Theme URI',
					'Description',
					'Author',
					'Author URI',
					'Version',
					'License',
					'Domain Path',
					'Text Domain',
				];
			default:
				return [];
		}
	}

	/**
	 * Creates a POT file and stores it on disk.
	 *
	 * @throws WP_CLI\ExitException
	 *
	 * @return Translations A Translation set.
	 */
	protected function extract_strings() {
		$translations = new Translations();

		// Add existing strings first but don't keep headers.
		if ( ! empty( $this->merge ) ) {
			$existing_translations = new Translations();
			Po::fromFile( $this->merge, $existing_translations );
			$translations->mergeWith( $existing_translations, Merge::ADD | Merge::REMOVE );
		}

		PotGenerator::setCommentBeforeHeaders( $this->get_file_comment() );

		$this->set_default_headers( $translations );

		// POT files have no Language header.
		$translations->deleteHeader( Translations::HEADER_LANGUAGE );

		// Only relevant for PO files, not POT files.
		$translations->setHeader( 'PO-Revision-Date', 'YEAR-MO-DA HO:MI+ZONE' );

		if ( $this->domain ) {
			$translations->setDomain( $this->domain );
		}

		unset( $this->main_file_data['Version'], $this->main_file_data['License'], $this->main_file_data['Domain Path'], $this->main_file_data['Text Domain'] );

		$is_theme = isset( $this->main_file_data['Theme Name'] );

		// Set entries from main file data.
		foreach ( $this->main_file_data as $header => $data ) {
			if ( empty( $data ) ) {
				continue;
			}

			$translation = new Translation( '', $data );

			if ( $is_theme ) {
				$translation->addExtractedComment( sprintf( '%s of the theme', $header ) );
			} else {
				$translation->addExtractedComment( sprintf( '%s of the plugin', $header ) );
			}

			if ( $this->main_file_path && $this->location ) {
				$translation->addReference(
					ltrim( str_replace( Utils\normalize_path( "$this->source/" ), '', Utils\normalize_path( $this->main_file_path ) ), '/' )
				);
			}

			$translations[] = $translation;
		}

		try {
			if ( ! $this->skip_php ) {
				$options = [
					// Extract 'Template Name' headers in theme files.
					'wpExtractTemplates' => $is_theme,
					// Extract 'Title' and 'Description' headers from pattern files.
					'wpExtractPatterns'  => $is_theme,
					'include'            => $this->include,
					'exclude'            => $this->exclude,
					'extensions'         => [ 'php' ],
					'addReferences'      => $this->location,
				];
				PhpCodeExtractor::fromDirectory( $this->source, $translations, $options );
			}

			if ( ! $this->skip_blade ) {
				$options = [
					'include'       => $this->include,
					'exclude'       => $this->exclude,
					'extensions'    => [ 'blade.php' ],
					'addReferences' => $this->location,
				];
				BladeCodeExtractor::fromDirectory( $this->source, $translations, $options );
			}

			if ( ! $this->skip_js ) {
				JsCodeExtractor::fromDirectory(
					$this->source,
					$translations,
					[
						'include'       => $this->include,
						'exclude'       => $this->exclude,
						'extensions'    => [ 'js', 'jsx' ],
						'addReferences' => $this->location,
					]
				);

				MapCodeExtractor::fromDirectory(
					$this->source,
					$translations,
					[
						'include'       => $this->include,
						'exclude'       => $this->exclude,
						'extensions'    => [ 'map' ],
						'addReferences' => $this->location,
					]
				);
			}

			if ( ! $this->skip_block_json ) {
				BlockExtractor::fromDirectory(
					$this->source,
					$translations,
					[
						'schema'            => JsonSchemaExtractor::BLOCK_JSON_SOURCE,
						'schemaFallback'    => JsonSchemaExtractor::BLOCK_JSON_FALLBACK,
						// Only look for block.json files in any folder, nothing else.
						'restrictFileNames' => [ 'block.json' ],
						'include'           => $this->include,
						'exclude'           => $this->exclude,
						'extensions'        => [ 'json' ],
						'addReferences'     => $this->location,
					]
				);
			}

			if ( ! $this->skip_theme_json ) {
				JsonSchemaExtractor::fromDirectory(
					$this->source,
					$translations,
					[
						// Only look for theme.json files in any folder, nothing else.
						'restrictFileNames' => [ 'theme.json' ],
						'schema'            => JsonSchemaExtractor::THEME_JSON_SOURCE,
						'schemaFallback'    => JsonSchemaExtractor::THEME_JSON_FALLBACK,
						'include'           => $this->include,
						'exclude'           => $this->exclude,
						'extensions'        => [ 'json' ],
						'addReferences'     => $this->location,
					]
				);

				// Themes can have style variations in the top-level "styles" folder.
				// They're like theme.json but can have any name.
				if ( $is_theme ) {
					JsonSchemaExtractor::fromDirectory(
						$this->source,
						$translations,
						[
							'restrictDirectories' => [ 'styles' ],
							'schema'              => JsonSchemaExtractor::THEME_JSON_SOURCE,
							'schemaFallback'      => JsonSchemaExtractor::THEME_JSON_FALLBACK,
							'include'             => $this->include,
							'exclude'             => $this->exclude,
							'extensions'          => [ 'json' ],
							'addReferences'       => $this->location,
						]
					);
				}
			}
		} catch ( \Exception $e ) {
			WP_CLI::error( $e->getMessage() );
		}

		foreach ( $this->exceptions as $file => $exception_translations ) {
			/** @var Translation $exception_translation */
			foreach ( $exception_translations as $exception_translation ) {
				if ( ! $translations->find( $exception_translation ) ) {
					continue;
				}

				if ( $this->subtract_and_merge ) {
					$translation = $translations[ $exception_translation->getId() ];
					$exception_translation->mergeWith( $translation );
				}

				unset( $translations[ $exception_translation->getId() ] );
			}

			if ( $this->subtract_and_merge ) {
				PotGenerator::toFile( $exception_translations, $file );
			}
		}

		if ( ! $this->skip_audit ) {
			$this->audit_strings( $translations );
		}

		return $translations;
	}

	/**
	 * Audits strings.
	 *
	 * Goes through all extracted strings to find possible mistakes.
	 *
	 * @param Translations $translations Translations object.
	 */
	protected function audit_strings( $translations ) {
		foreach ( $translations as $translation ) {
			/** @var Translation $translation */

			$references = $translation->getReferences();

			// File headers don't have any file references.
			$location = $translation->hasReferences() ? '(' . implode( ':', $references[0] ) . ')' : '';

			// Check 1: Flag strings with placeholders that should have translator comments.
			if (
				! $translation->hasExtractedComments() &&
				preg_match( self::SPRINTF_PLACEHOLDER_REGEX, $translation->getOriginal(), $placeholders ) >= 1
			) {
				$message = sprintf(
					'The string "%1$s" contains placeholders but has no "translators:" comment to clarify their meaning. %2$s',
					$translation->getOriginal(),
					$location
				);
				WP_CLI::warning( $message );
			}

			// Check 2: Flag strings with different translator comments.
			if ( $translation->hasExtractedComments() ) {
				$comments = $translation->getExtractedComments();

				// Remove plugin header information from comments.
				$comments = array_filter(
					$comments,
					function ( $comment ) {
						/** @var ParsedComment|string $comment */
						/** @var string $file_header */
						foreach ( $this->get_file_headers( $this->project_type ) as $file_header ) {
							if ( 0 === strpos( ( $comment instanceof ParsedComment ? $comment->getComment() : $comment ), $file_header ) ) {
								return null;
							}
						}

						return $comment;
					}
				);

				$unique_comments = array();

				// Remove duplicate comments.
				$comments = array_filter(
					$comments,
					function ( $comment ) use ( &$unique_comments ) {
						/** @var ParsedComment|string $comment */
						if ( in_array( ( $comment instanceof ParsedComment ? $comment->getComment() : $comment ), $unique_comments, true ) ) {
							return null;
						}

						$unique_comments[] = ( $comment instanceof ParsedComment ? $comment->getComment() : $comment );

						return $comment;
					}
				);

				$comments_count = count( $comments );

				if ( $comments_count > 1 ) {
					$message = sprintf(
						"The string \"%1\$s\" has %2\$d different translator comments. %3\$s\n%4\$s",
						$translation->getOriginal(),
						$comments_count,
						$location,
						implode( "\n", $unique_comments )
					);
					WP_CLI::warning( $message );
				}
			}

			$non_placeholder_content = trim( preg_replace( '`^([\'"])(.*)\1$`Ds', '$2', $translation->getOriginal() ) );
			$non_placeholder_content = preg_replace( self::SPRINTF_PLACEHOLDER_REGEX, '', $non_placeholder_content );

			// Check 3: Flag empty strings without any translatable content.
			if ( '' === $non_placeholder_content ) {
				$message = sprintf(
					'Found string without translatable content. %s',
					$location
				);
				WP_CLI::warning( $message );
			}

			// Check 4: Flag strings with multiple unordered placeholders (%s %s %s vs. %1$s %2$s %3$s).
			$unordered_matches_count = preg_match_all( self::UNORDERED_SPRINTF_PLACEHOLDER_REGEX, $translation->getOriginal(), $unordered_matches );
			$unordered_matches       = $unordered_matches[0];

			if ( $unordered_matches_count >= 2 ) {
				$message = sprintf(
					'Multiple placeholders should be ordered. %s',
					$location
				);
				WP_CLI::warning( $message );
			}

			if ( $translation->hasPlural() ) {
				preg_match_all( self::SPRINTF_PLACEHOLDER_REGEX, $translation->getOriginal(), $single_placeholders );
				$single_placeholders = $single_placeholders[0];

				preg_match_all( self::SPRINTF_PLACEHOLDER_REGEX, $translation->getPlural(), $plural_placeholders );
				$plural_placeholders = $plural_placeholders[0];

				// see https://developer.wordpress.org/plugins/internationalization/how-to-internationalize-your-plugin/#plurals
				if ( count( $single_placeholders ) < count( $plural_placeholders ) ) {
					// Check 5: Flag things like _n( 'One comment', '%s Comments' )
					$message = sprintf(
						'Missing singular placeholder, needed for some languages. See https://developer.wordpress.org/plugins/internationalization/how-to-internationalize-your-plugin/#plurals %s',
						$location
					);
					WP_CLI::warning( $message );
				} else {
					// Reordering is fine, but mismatched placeholders is probably wrong.
					sort( $single_placeholders );
					sort( $plural_placeholders );

					// Check 6: Flag things like _n( '%s Comment (%d)', '%s Comments (%s)' )
					if ( $single_placeholders !== $plural_placeholders ) {
						$message = sprintf(
							'Mismatched placeholders for singular and plural string. %s',
							$location
						);
						WP_CLI::warning( $message );
					}
				}
			}
		}
	}

	/**
	 * Returns the copyright comment for the given package.
	 *
	 * @return string File comment.
	 */
	protected function get_file_comment() {
		if ( '' === $this->file_comment ) {
			return '';
		}

		if ( isset( $this->file_comment ) ) {
			return implode( "\n", explode( '\n', $this->file_comment ) );
		}

		if ( isset( $this->main_file_data['Theme Name'] ) ) {
			if ( ! empty( $this->main_file_data['License'] ) ) {
				return sprintf(
					"Copyright (C) %1\$s %2\$s\nThis file is distributed under the %3\$s.",
					date( 'Y' ), // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
					$this->main_file_data['Author'],
					$this->main_file_data['License']
				);
			}

			return sprintf(
				"Copyright (C) %1\$s %2\$s\nThis file is distributed under the same license as the %3\$s theme.",
				date( 'Y' ), // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
				$this->main_file_data['Author'],
				$this->main_file_data['Theme Name']
			);
		}

		if ( isset( $this->main_file_data['Plugin Name'] ) ) {
			if ( ! empty( $this->main_file_data['License'] ) ) {
				return sprintf(
					"Copyright (C) %1\$s %2\$s\nThis file is distributed under the %3\$s.",
					date( 'Y' ), // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
					$this->main_file_data['Author'],
					$this->main_file_data['License']
				);
			}

			return sprintf(
				"Copyright (C) %1\$s %2\$s\nThis file is distributed under the same license as the %3\$s plugin.",
				date( 'Y' ), // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
				$this->main_file_data['Author'],
				$this->main_file_data['Plugin Name']
			);
		}

		return '';
	}

	/**
	 * Sets default POT file headers for the project.
	 *
	 * @param Translations $translations Translations object.
	 */
	protected function set_default_headers( $translations ) {
		$name         = null;
		$version      = $this->get_wp_version();
		$bugs_address = null;

		if ( ! $version && isset( $this->main_file_data['Version'] ) ) {
			$version = $this->main_file_data['Version'];
		}

		if ( isset( $this->main_file_data['Theme Name'] ) ) {
			$name         = $this->main_file_data['Theme Name'];
			$bugs_address = sprintf( 'https://wordpress.org/support/theme/%s', $this->slug );
		} elseif ( isset( $this->main_file_data['Plugin Name'] ) ) {
			$name         = $this->main_file_data['Plugin Name'];
			$bugs_address = sprintf( 'https://wordpress.org/support/plugin/%s', $this->slug );
		}

		if ( null !== $this->package_name ) {
			$name = $this->package_name;
		}

		if ( null !== $name ) {
			$translations->setHeader( 'Project-Id-Version', $name . ( $version ? ' ' . $version : '' ) );
		}

		if ( null !== $bugs_address ) {
			$translations->setHeader( 'Report-Msgid-Bugs-To', $bugs_address );
		}

		$translations->setHeader( 'Last-Translator', 'FULL NAME <EMAIL@ADDRESS>' );
		$translations->setHeader( 'Language-Team', 'LANGUAGE <LL@li.org>' );
		$translations->setHeader( 'X-Generator', 'WP-CLI ' . WP_CLI_VERSION );

		foreach ( $this->headers as $key => $value ) {
			$translations->setHeader( $key, $value );
		}
	}

	/**
	 * Extracts the WordPress version number from wp-includes/version.php.
	 *
	 * @return string|false Version number on success, false otherwise.
	 */
	protected function get_wp_version() {
		$version_php = $this->source . '/wp-includes/version.php';
		if ( ! file_exists( $version_php ) || ! is_readable( $version_php ) ) {
			return false;
		}

		return preg_match( '/\$wp_version\s*=\s*\'(.*?)\';/', file_get_contents( $version_php ), $matches ) ? $matches[1] : false;
	}
}