Skip to content

New sniff to enforce using the Lite textdomain for translations#2877

Merged
Crabcyborg merged 2 commits into
masterfrom
new_sniff_to_enforce_using_lite_textdomain
Jan 26, 2026
Merged

New sniff to enforce using the Lite textdomain for translations#2877
Crabcyborg merged 2 commits into
masterfrom
new_sniff_to_enforce_using_lite_textdomain

Conversation

@Crabcyborg

@Crabcyborg Crabcyborg commented Jan 26, 2026

Copy link
Copy Markdown
Contributor

This shouldn't do anything really in this repo, but in other repositories it can help us remove string translations that are already handled in Lite.

Summary by CodeRabbit

Release Notes

  • New Features
    • Introduced a new code quality check to enforce consistent translation text domain usage, automatically aligning domains with shared standards and offering automatic fixes.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai

coderabbitai Bot commented Jan 26, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

A new PHP_CodeSniffer sniff (UseSharedTranslationDomainSniff) is introduced to enforce consistent translation text domains. The sniff detects translation function calls, cross-references strings against a configurable Lite plugin .pot file, and provides automatic fixes to align domains with either a shared or plugin-specific target domain.

Changes

Cohort / File(s) Summary
Translation Domain Validation Sniff
phpcs-sniffs/Formidable/Sniffs/Translations/UseSharedTranslationDomainSniff.php
New UseSharedTranslationDomainSniff class with three public configurable properties: potFilePath, targetDomain (default 'formidable'), and pluginDomain. Implements token registration for T_STRING, pot file loading and caching with string unescaping, translation function detection and argument extraction, domain validation logic (fixes mismatched domains or unused target domains), and automatic text domain replacement.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 A sniff hops through translations so fine,
Checking domains and keeping them in line,
With pot files parsed and fixes applied,
Text domains dance, perfectly aligned!
✨📝

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: introducing a new PHP_CodeSniffer sniff to enforce using the Lite textdomain for translations.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@Crabcyborg
Crabcyborg merged commit 87c752b into master Jan 26, 2026
14 of 16 checks passed
@Crabcyborg
Crabcyborg deleted the new_sniff_to_enforce_using_lite_textdomain branch January 26, 2026 15:03

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In
`@phpcs-sniffs/Formidable/Sniffs/Translations/UseSharedTranslationDomainSniff.php`:
- Around line 87-94: The domain index mapping for the plural translation
functions is off: in the UseSharedTranslationDomainSniff mapping for '_n' and
'_nx' update the 'domain' values to the correct 0-based positions (set '_n' =>
['domain' => 3] and '_nx' => ['domain' => 4]) so the sniff checks the correct
argument for the domain when analyzing _n() and _nx() calls.
- Around line 365-407: In getStringValue, ensure the argument is a pure literal
string and unescape its contents: scan from $arg['start'] to $arg['end'] to find
a single T_CONSTANT_ENCAPSED_STRING token (the $stringToken) but do not return
early on the first non-string token — instead verify that all tokens before and
after the found string are only T_WHITESPACE (reject if any other token exists,
which prevents allowing concatenation like 'foo' . $bar); once you have the
string content and the quote character, remove the surrounding quotes and
unescape properly: for single-quoted strings use str_replace to convert \' and
\\ to their characters, and for double-quoted strings use stripcslashes to
interpret C-style escapes, then return the unescaped content.
- Around line 270-302: The current loadPotFile() uses
preg_match_all('/^msgid\s+"(.*)"/m', ...) which fails to capture multi-line
msgid entries; update loadPotFile() to handle msgid "" followed by one or more
continuation lines by either: (a) using a regex that captures the full msgid
block including following quoted continuation lines (match msgid "..." OR msgid
"" followed by subsequent lines starting with quote marks) and then join the
captured pieces, or (b) parse $content line-by-line inside loadPotFile() to
detect a msgid start (msgid "..." or msgid "") and, if empty, accumulate
subsequent lines that begin with a quoted string until a non-quoted line
appears; after assembling the full message, call unescapePotString() on the
concatenated string and add it to $strings. Ensure the code still skips the
header empty msgid and that preg_match_all or the parser targets only msgid (not
msgstr) occurrences.

Comment on lines +87 to +94
'_n' => array(
'string' => 0,
'domain' => 4,
),
'_nx' => array(
'string' => 0,
'domain' => 5,
),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

WordPress _n() function signature parameter order domain

💡 Result:

In WordPress, the _n() signature (and parameter order) is:

_n( string $single, string $plural, int $number, string $domain = 'default' ): string

So the text domain is the 4th parameter ($domain), optional, defaulting to 'default'. [1]


🌐 Web query:

WordPress _nx() function signature parameter order domain

💡 Result:

In WordPress, the _nx() function signature (and parameter order, including the text domain last) is:

_nx( string $single, string $plural, int $number, string $context, string $domain = 'default' ): string [1]

So the domain parameter is 5th (last), optional, defaulting to 'default'. [1]


Fix domain argument indexes for _n and _nx.

Both functions use 0‑based positions. In the WordPress _n() function, the domain is the 4th parameter (0-based index 3). In _nx(), the domain is the 5th parameter (0-based index 4). The current mapping (4 and 5) will cause the sniff to fail when checking these translation calls.

🛠️ Proposed fix
-		'_n'                    => array(
-			'string' => 0,
-			'domain' => 4,
-		),
-		'_nx'                   => array(
-			'string' => 0,
-			'domain' => 5,
-		),
+		'_n'                    => array(
+			'string' => 0,
+			'domain' => 3,
+		),
+		'_nx'                   => array(
+			'string' => 0,
+			'domain' => 4,
+		),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
'_n' => array(
'string' => 0,
'domain' => 4,
),
'_nx' => array(
'string' => 0,
'domain' => 5,
),
'_n' => array(
'string' => 0,
'domain' => 3,
),
'_nx' => array(
'string' => 0,
'domain' => 4,
),
🤖 Prompt for AI Agents
In
`@phpcs-sniffs/Formidable/Sniffs/Translations/UseSharedTranslationDomainSniff.php`
around lines 87 - 94, The domain index mapping for the plural translation
functions is off: in the UseSharedTranslationDomainSniff mapping for '_n' and
'_nx' update the 'domain' values to the correct 0-based positions (set '_n' =>
['domain' => 3] and '_nx' => ['domain' => 4]) so the sniff checks the correct
argument for the domain when analyzing _n() and _nx() calls.

Comment on lines +270 to +302
private function loadPotFile() {
if ( empty( $this->potFilePath ) || ! file_exists( $this->potFilePath ) ) {
return array();
}

$content = file_get_contents( $this->potFilePath );

if ( false === $content ) {
return array();
}

$strings = array();

// Parse msgid entries from the .pot file.
// Match msgid "string" or msgid "" followed by continuation strings.
preg_match_all( '/^msgid\s+"(.*)"/m', $content, $matches );

if ( ! empty( $matches[1] ) ) {
foreach ( $matches[1] as $string ) {
// Skip empty strings (header).
if ( '' === $string ) {
continue;
}

// Unescape the string.
$string = $this->unescapePotString( $string );

$strings[ $string ] = true;
}
}

return $strings;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

GNU gettext .pot file format multiline msgid continuation lines

💡 Result:

In GNU gettext .pot (same syntax as .po), multi-line msgid/msgstr values are written as multiple adjacent quoted C-strings, which gettext concatenates—they are not continued with backslashes or “escaped newlines” in the file itself. The canonical style is:

msgid ""
"First line\n"
"Second line\n"
"Third line"

Key points:

  • msgid (and msgstr) must be followed by a quoted string; using msgid "" is a common way to keep subsequent lines neatly aligned, and the empty string just concatenates to nothing. [1]
  • Each quoted line uses C string escaping (e.g., \n, \", \\), and the PO file’s physical newlines outside quotes do not become part of the string—only \n inside quotes does. [1]
  • This same concatenation rule applies to other string fields like msgid_plural and msgstr[0], msgstr[1], etc. (they can also be split across multiple quoted lines). [2]

Sources: GNU gettext manual on multi-line strings and PO file entry structure. [1] [2]


🏁 Script executed:

# First, locate and examine the actual file mentioned in the review
fd "UseSharedTranslationDomainSniff.php" --type f

Repository: Strategy11/formidable-forms

Length of output: 150


🏁 Script executed:

sed -n '260,310p' phpcs-sniffs/Formidable/Sniffs/Translations/UseSharedTranslationDomainSniff.php

Repository: Strategy11/formidable-forms

Length of output: 1131


🏁 Script executed:

# Also check if there's an unescapePotString method
grep -n "unescapePotString" phpcs-sniffs/Formidable/Sniffs/Translations/UseSharedTranslationDomainSniff.php

Repository: Strategy11/formidable-forms

Length of output: 178


🏁 Script executed:

# Check the full implementation of unescapePotString
sed -n '311,350p' phpcs-sniffs/Formidable/Sniffs/Translations/UseSharedTranslationDomainSniff.php

Repository: Strategy11/formidable-forms

Length of output: 1422


🏁 Script executed:

# Also check how the pot file content is typically structured by looking for test files
fd -e pot -e po --type f | head -20

Repository: Strategy11/formidable-forms

Length of output: 245


🏁 Script executed:

# Examine the actual .pot file to see if it contains multi-line msgid entries
head -100 languages/formidable.pot

Repository: Strategy11/formidable-forms

Length of output: 3124


🏁 Script executed:

# Search for msgid "" pattern which would indicate multi-line entries
grep -A 3 'msgid ""' languages/formidable.pot | head -40

Repository: Strategy11/formidable-forms

Length of output: 209


🏁 Script executed:

# Search for msgid entries with continuation lines (multi-line format)
# This would show msgid "" or msgid "something" followed by "..." on next lines
grep -E '(^msgid |^msgstr )' languages/formidable.pot | head -50

Repository: Strategy11/formidable-forms

Length of output: 1151


🏁 Script executed:

# More targeted search: look for lines with just quotes after msgid/msgstr
awk '/^msgid ""/,/^[a-z]/ {print NR": "$0}' languages/formidable.pot | head -100

Repository: Strategy11/formidable-forms

Length of output: 82


🏁 Script executed:

# Check if there are any actual multi-line msgid entries in the .pot file
# (where msgid is followed by continuation lines like "...")
grep -B1 -A5 'msgid "' languages/formidable.pot | grep -A3 'msgid ""' | tail -20

Repository: Strategy11/formidable-forms

Length of output: 209


🏁 Script executed:

# Look for the pattern of msgid "" (empty) followed by quoted continuation strings
awk '/^msgid ""$/ {found=1; print NR": "$0; next} found {print NR": "$0; if (/^msgid/ || /^msgstr/ || /^#/) {if (!/^msgid ""/) found=0}}' languages/formidable.pot | head -50

Repository: Strategy11/formidable-forms

Length of output: 95


🏁 Script executed:

# Let me check more carefully for any multi-line string entries
python3 << 'EOF'
with open('languages/formidable.pot', 'r') as f:
    lines = f.readlines()

in_msgid = False
msgid_start_line = 0
continuation_count = 0

for i, line in enumerate(lines[:200], 1):
    if line.startswith('msgid '):
        if continuation_count > 0:
            print(f"Lines {msgid_start_line}-{i-1}: Found multi-line msgid with {continuation_count} continuations")
        in_msgid = True
        msgid_start_line = i
        continuation_count = 0
        print(f"{i}: {line.rstrip()}")
    elif in_msgid and line.startswith('"') and not line.startswith('msgid'):
        continuation_count += 1
        print(f"{i}: {line.rstrip()} [continuation {continuation_count}]")
    elif in_msgid and not line.startswith('"'):
        in_msgid = False
EOF

Repository: Strategy11/formidable-forms

Length of output: 157


🏁 Script executed:

# Let me search more carefully for msgid entries with continuation lines
# by looking for the specific pattern of msgid "" followed by quoted strings
grep -n 'msgid ""' languages/formidable.pot

Repository: Strategy11/formidable-forms

Length of output: 81


🏁 Script executed:

# Check if any msgid (not msgstr) entries span multiple lines
# by looking for msgid followed by a closing quote and then continuation lines
sed -n '1,500p' languages/formidable.pot | grep -E '(^msgid |^msgstr )' | grep -A1 'msgid ""'

Repository: Strategy11/formidable-forms

Length of output: 89


🏁 Script executed:

# Let me sample different parts of the file to find if any msgid entries have continuations
tail -500 languages/formidable.pot | head -100

Repository: Strategy11/formidable-forms

Length of output: 1974


🏁 Script executed:

# Check the entire .pot file to see if ANY msgid entries have multi-line format
# (where a msgid is split across multiple lines with continuation strings)
wc -l languages/formidable.pot

Repository: Strategy11/formidable-forms

Length of output: 100


🏁 Script executed:

# Sample more of the file to look for the pattern of msgid with continuations
# Look for msgid followed by a line that starts with a quote
awk '/^msgid / {msgid_line=NR; msgid=$0} /^"/ && NR == msgid_line+1 {print "Found continuation at line " NR ": " msgid " -> " $0}' languages/formidable.pot | head -20

Repository: Strategy11/formidable-forms

Length of output: 53


🏁 Script executed:

# More robust check: look for msgid entries where the line doesn't end with a closing quote
grep -n '^msgid ' languages/formidable.pot | grep -v '"$' | head -20

Repository: Strategy11/formidable-forms

Length of output: 53


🏁 Script executed:

# Final verification: check for msgid "" pattern (the standard for multi-line entries)
# and see if there are any continuation lines after it (other than msgstr)
grep -n '^msgid ""$' languages/formidable.pot

Repository: Strategy11/formidable-forms

Length of output: 81


🏁 Script executed:

# Also check the actual .po files to see if they have multi-line entries
head -50 languages/formidable-hu_HU.po

Repository: Strategy11/formidable-forms

Length of output: 1783


🏁 Script executed:

# Search specifically for the pattern where msgid is followed by a continuation line
# (a line starting with " that's not msgstr)
awk '/^msgid ".*"$/ {next} /^msgid ""$/ {multiline=1; next} multiline && /^"/ && !/^msgstr/ {print "Found continuation line"; multiline=0} multiline && !/^"/ {multiline=0}' languages/formidable.pot

Repository: Strategy11/formidable-forms

Length of output: 53


Multi-line msgid support is limited in current implementation, though not triggered by existing .pot files.

The regex /^msgid\s+"(.*)"/m does not capture multi-line msgid entries per the GNU gettext .pot format (where msgid "" can be followed by continuation lines like "part1\n" "part2\n"). However, inspection of languages/formidable.pot and languages/formidable-hu_HU.po reveals that the codebase uses only single-line msgid entries throughout, meaning the limitation does not cause false negatives in practice.

Nevertheless, the code should be hardened to properly handle the documented .pot format for future compatibility and robustness.

🤖 Prompt for AI Agents
In
`@phpcs-sniffs/Formidable/Sniffs/Translations/UseSharedTranslationDomainSniff.php`
around lines 270 - 302, The current loadPotFile() uses
preg_match_all('/^msgid\s+"(.*)"/m', ...) which fails to capture multi-line
msgid entries; update loadPotFile() to handle msgid "" followed by one or more
continuation lines by either: (a) using a regex that captures the full msgid
block including following quoted continuation lines (match msgid "..." OR msgid
"" followed by subsequent lines starting with quote marks) and then join the
captured pieces, or (b) parse $content line-by-line inside loadPotFile() to
detect a msgid start (msgid "..." or msgid "") and, if empty, accumulate
subsequent lines that begin with a quoted string until a non-quoted line
appears; after assembling the full message, call unescapePotString() on the
concatenated string and add it to $strings. Ensure the code still skips the
header empty msgid and that preg_match_all or the parser targets only msgid (not
msgstr) occurrences.

Comment on lines +365 to +407
/**
* Get the string value from an argument.
*
* @param File $phpcsFile The file being scanned.
* @param array{start: int, end: int} $arg The argument info.
*
* @return string|null The string value or null if not a simple string.
*/
private function getStringValue( File $phpcsFile, $arg ) {
$tokens = $phpcsFile->getTokens();

// Find the first non-whitespace token in the argument.
$stringToken = null;

for ( $i = $arg['start']; $i <= $arg['end']; $i++ ) {
if ( $tokens[ $i ]['code'] === T_WHITESPACE ) {
continue;
}

if ( $tokens[ $i ]['code'] === T_CONSTANT_ENCAPSED_STRING ) {
$stringToken = $i;
break;
}

// Not a simple string literal.
return null;
}

if ( null === $stringToken ) {
return null;
}

$content = $tokens[ $stringToken ]['content'];

// Remove quotes.
$quote = substr( $content, 0, 1 );

if ( $quote === '"' || $quote === "'" ) {
$content = substr( $content, 1, -1 );
}

return $content;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "UseSharedTranslationDomainSniff.php" | head -5

Repository: Strategy11/formidable-forms

Length of output: 152


🌐 Web query:

PHP string literal escaping rules single-quoted double-quoted strings stripcslashes

💡 Result:

PHP string literal escaping rules

Single-quoted strings ('...')

  • Only two escape sequences are recognized:
    • \' → a literal single quote
    • \\ → a literal backslash
  • Any other backslash sequence is not interpreted (e.g. '\n' is two characters: backslash + n), and variables are not expanded. [1]

Double-quoted strings ("...")

  • PHP interprets common C-style escapes such as \n, \r, \t, \\, \", \$, plus octal (\123), hex (\x7F), and Unicode codepoint escapes (\u{...}), and it also performs variable interpolation. [1]

stripcslashes()

  • stripcslashes($s) un-escapes C-style backslash sequences (the kind produced by addcslashes()), converting sequences like \n into an actual newline character, etc. [2]
  • Example from the manual: stripcslashes('I\'d have a coffee.\nNot a problem.') becomes a string containing a real newline between the sentences. [2]

Key practical point: if you write '\n' (single-quoted), PHP does not turn it into a newline at parse time—but stripcslashes('\n') will turn it into an actual newline at runtime. [1][2]

Sources: PHP Strings manual [1]; stripcslashes() manual [2].


🏁 Script executed:

cat -n ./phpcs-sniffs/Formidable/Sniffs/Translations/UseSharedTranslationDomainSniff.php | sed -n '365,407p'

Repository: Strategy11/formidable-forms

Length of output: 1388


🏁 Script executed:

grep -n "getStringValue\|T_CONSTANT_ENCAPSED_STRING" ./phpcs-sniffs/Formidable/Sniffs/Translations/UseSharedTranslationDomainSniff.php | head -20

Repository: Strategy11/formidable-forms

Length of output: 412


Ensure the argument is only a literal string and unescape escape sequences for reliable matching.

The current implementation has two issues:

  1. Concatenation not rejected: The loop breaks immediately after finding the first string token, without validating that only whitespace follows. This allows expressions like 'foo' . $bar to be treated as literal strings.

  2. Escape sequences not unescaped: The code removes quotes but does not unescape escape sequences. For double-quoted strings, sequences like \n remain literal; for single-quoted strings, \' and \\ are not converted to their actual characters. This causes mismatches against .pot files.

The proposed fix adds validation to reject any non-whitespace tokens after the string literal, and properly unescapes the content: using str_replace() for single-quoted strings (which only recognize \' and \\), and stripcslashes() for double-quoted strings (which support C-style escape sequences).

🛠️ Proposed fix
-		// Find the first non-whitespace token in the argument.
+		// Find the first non-whitespace token in the argument.
 		$stringToken = null;
 
 		for ( $i = $arg['start']; $i <= $arg['end']; $i++ ) {
 			if ( $tokens[ $i ]['code'] === T_WHITESPACE ) {
 				continue;
 			}
 
 			if ( $tokens[ $i ]['code'] === T_CONSTANT_ENCAPSED_STRING ) {
 				$stringToken = $i;
 				break;
 			}
 
 			// Not a simple string literal.
 			return null;
 		}
 
 		if ( null === $stringToken ) {
 			return null;
 		}
+
+		// Ensure the argument is only a single string literal (no concatenation, etc.).
+		for ( $i = $stringToken + 1; $i <= $arg['end']; $i++ ) {
+			if ( $tokens[ $i ]['code'] === T_WHITESPACE ) {
+				continue;
+			}
+			return null;
+		}
 
 		$content = $tokens[ $stringToken ]['content'];
 
 		// Remove quotes.
 		$quote = substr( $content, 0, 1 );
 
 		if ( $quote === '"' || $quote === "'" ) {
 			$content = substr( $content, 1, -1 );
 		}
 
-		return $content;
+		if ( "'" === $quote ) {
+			return str_replace( array( '\\\\', "\\'" ), array( '\\', "'" ), $content );
+		}
+
+		return stripcslashes( $content );
🤖 Prompt for AI Agents
In
`@phpcs-sniffs/Formidable/Sniffs/Translations/UseSharedTranslationDomainSniff.php`
around lines 365 - 407, In getStringValue, ensure the argument is a pure literal
string and unescape its contents: scan from $arg['start'] to $arg['end'] to find
a single T_CONSTANT_ENCAPSED_STRING token (the $stringToken) but do not return
early on the first non-string token — instead verify that all tokens before and
after the found string are only T_WHITESPACE (reject if any other token exists,
which prevents allowing concatenation like 'foo' . $bar); once you have the
string content and the quote character, remove the surrounding quotes and
unescape properly: for single-quoted strings use str_replace to convert \' and
\\ to their characters, and for double-quoted strings use stripcslashes to
interpret C-style escapes, then return the unescaped content.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant