ilib-lint 2.16.0 → 2.16.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ilib-lint",
3
- "version": "2.16.0",
3
+ "version": "2.16.2",
4
4
  "type": "module",
5
5
  "main": "./src/index.js",
6
6
  "module": "./src/index.js",
@@ -60,7 +60,8 @@
60
60
  "jest": "^29.7.0",
61
61
  "jsdoc": "^4.0.3",
62
62
  "jsdoc-to-markdown": "^8.0.3",
63
- "typescript": "^5.5.4"
63
+ "typescript": "^5.5.4",
64
+ "@ilib-mono/e2e-test": "^0.0.0"
64
65
  },
65
66
  "dependencies": {
66
67
  "@formatjs/intl": "^2.10.4",
@@ -72,15 +73,17 @@
72
73
  "options-parser": "^0.4.0",
73
74
  "xml-js": "^1.6.11",
74
75
  "ilib-common": "^1.1.6",
75
- "ilib-lint-common": "^3.4.0",
76
+ "ilib-ctype": "^1.2.2",
76
77
  "ilib-locale": "^1.2.4",
77
- "ilib-tools-common": "^1.18.0"
78
+ "ilib-lint-common": "^3.4.0",
79
+ "ilib-tools-common": "^1.19.0"
78
80
  },
79
81
  "scripts": {
80
82
  "coverage": "pnpm test -- --coverage",
81
83
  "test": "pnpm test:jest",
82
84
  "test:jest": "LANG=en_US.UTF8 node --trace-warnings --experimental-vm-modules node_modules/jest/bin/jest.js",
83
85
  "test:watch": "pnpm test:jest --watch",
86
+ "test:e2e": "LANG=en_US.UTF8 node --trace-warnings --experimental-vm-modules node_modules/jest/bin/jest.js --config test-e2e/jest.config.cjs",
84
87
  "debug": "LANG=en_US.UTF8 node --experimental-vm-modules --inspect-brk node_modules/jest/bin/jest.js -i",
85
88
  "lint": "node src/index.js",
86
89
  "clean": "git clean -f -d src test",
@@ -56,8 +56,8 @@ class AnsiConsoleFormatter extends Formatter {
56
56
  output += ` Source: ${result.source}\n`;
57
57
  }
58
58
  output += ` ${result.highlight}
59
- Auto-fix: ${result.fix === undefined ? "unavailable" : result.fix.applied ? "\u001B[92mapplied\u001B[0m" : "\u001B[91mnot applied\u001B[0m"}
60
- Rule (${result.rule.getName()}): ${result.rule.getDescription()}
59
+ Auto-fix: ${!result.fix ? "unavailable" : result.fix.applied ? "\u001B[92mapplied\u001B[0m" : "\u001B[91mnot applied\u001B[0m"}
60
+ Rule (${result?.rule?.getName()}): ${result?.rule?.getDescription()}
61
61
  `;
62
62
  if (result.locale) {
63
63
  output += ` Locale: ${result.locale}\n`;
@@ -68,7 +68,7 @@ class AnsiConsoleFormatter extends Formatter {
68
68
  output = output.replace(/<e\d\/>/g, "\u001B[91m␣\u001B[0m");
69
69
  output = output.replace(/<e\d>/g, "\u001B[91m");
70
70
  output = output.replace(/<\/e\d>/g, "\u001B[0m");
71
- if (typeof(result.rule.getLink) === 'function' && result.rule.getLink()) {
71
+ if (typeof(result?.rule?.getLink) === 'function' && result?.rule?.getLink()) {
72
72
  output += ` More info: ${result.rule.getLink()}\n`;
73
73
  }
74
74
  return output;
@@ -34,7 +34,9 @@
34
34
  import { Result } from 'ilib-lint-common';
35
35
  import ResourceRule from './ResourceRule.js';
36
36
  import Locale from 'ilib-locale';
37
+ import LocaleInfo from 'ilib-localeinfo';
37
38
  import ResourceFixer from '../plugins/resource/ResourceFixer.js';
39
+ import { isPunct, isSpace } from 'ilib-ctype';
38
40
 
39
41
  /** @ignore @typedef {import("ilib-tools-common").Resource} Resource */
40
42
 
@@ -62,57 +64,77 @@ class ResourceSentenceEnding extends ResourceRule {
62
64
 
63
65
  // Initialize custom punctuation mappings from configuration
64
66
  this.customPunctuationMap = {};
65
-
66
- if (options && options.param) {
67
- if (typeof options.param === 'object' && !Array.isArray(options.param)) {
68
- // param is an object with locale codes as keys and punctuation mappings as values
69
- // Merge the default punctuation with the custom punctuation so that the custom
70
- // punctuation overrides the default and we don't have to specify all punctuation types.
71
- this.customPunctuationMap = {
72
- ...defaults,
73
- ...options.param
74
- };
75
- }
67
+ if (options && typeof options === 'object' && !Array.isArray(options)) {
68
+ // options is an object with locale codes as keys and punctuation mappings as values
69
+ // Merge the default punctuation with the custom punctuation so that the custom
70
+ // punctuation overrides the default and we don't have to specify all punctuation types.
71
+ // Custom maps are stored by language, not locale, so that they apply to all locales of
72
+ // that language.
73
+ for (const locale in options) {
74
+ const localeObj = new Locale(locale);
75
+
76
+ // only process config for valid locales
77
+ if (localeObj.isValid()) {
78
+ const language = localeObj.getLanguage();
79
+ // locale must have a language code
80
+ if (!language) continue;
81
+ // Apply locale-specific defaults for any locale that usesthis language
82
+ const localeDefaults = this.getLocaleDefaults(language);
83
+ this.customPunctuationMap[language] = {
84
+ ...localeDefaults,
85
+ ...options[locale]
86
+ };
87
+ }
88
+ };
76
89
  }
77
90
  }
78
91
 
79
92
  /**
80
- * Check if the given string ends with any of the specified punctuation patterns
93
+ * Check if the given string ends with any of the configured punctuation patterns
81
94
  * @param {string} str - The string to check
95
+ * @param {Locale} locale - The locale code of the string
82
96
  * @returns {Object|null} - Object with type and original punctuation, or null if no match
83
97
  */
84
- getEndingPunctuation(str) {
98
+ getEndingPunctuation(str, locale) {
85
99
  if (!str || typeof str !== 'string') return null;
86
100
 
87
101
  const trimmed = str.trim();
88
102
  if (!trimmed) return null;
89
103
 
90
- // Patterns to match, in order of specificity (longer patterns first)
91
- const patterns = [
92
- // Ellipsis patterns (three dots or Unicode ellipsis)
93
- { regex: /\.{3}$/, type: 'ellipsis', original: '...' },
94
- { regex: /…$/, type: 'ellipsis', original: '…' },
95
-
96
- // Punctuation followed by quotes
97
- { regex: /\.["']$/, type: 'period', original: trimmed.slice(-2) },
98
- { regex: /\?["']$/, type: 'question', original: trimmed.slice(-2) },
99
- { regex: /!["']$/, type: 'exclamation', original: trimmed.slice(-2) },
100
- { regex: /:["']$/, type: 'colon', original: trimmed.slice(-2) },
101
-
102
- // Single punctuation marks
103
- { regex: /\.$/, type: 'period', original: '.' },
104
- { regex: /\?$/, type: 'question', original: '?' },
105
- { regex: /!$/, type: 'exclamation', original: '!' },
106
- { regex: /:$/, type: 'colon', original: ':' }
107
- ];
108
-
109
- for (const pattern of patterns) {
110
- if (pattern.regex.test(trimmed)) {
111
- return pattern;
112
- }
104
+ // Strip trailing quotes and whitespace to find the actual ending punctuation
105
+ const stripped = ResourceSentenceEnding.stripTrailingQuotesAndWhitespace(trimmed);
106
+ if (!stripped) return null;
107
+
108
+ // Check for ellipsis first (three dots or Unicode ellipsis)
109
+ if (stripped.endsWith('...')) {
110
+ return { type: 'ellipsis', original: '...' };
111
+ }
112
+ if (stripped.endsWith('')) {
113
+ return { type: 'ellipsis', original: '…' };
113
114
  }
114
115
 
115
- return null;
116
+ // Check if the last character is punctuation using isPunct
117
+ const lastChar = stripped.charAt(stripped.length - 1);
118
+ if (!isPunct(lastChar)) return null;
119
+
120
+ // Determine the punctuation type based on the character
121
+ let type = 'period'; // default
122
+ let original = lastChar;
123
+
124
+ // Check for specific punctuation types
125
+ if (lastChar === this.getExpectedPunctuation(locale, 'question')) {
126
+ type = 'question';
127
+ } else if (lastChar === this.getExpectedPunctuation(locale, 'exclamation')) {
128
+ type = 'exclamation';
129
+ } else if (lastChar === this.getExpectedPunctuation(locale, 'colon')) {
130
+ type = 'colon';
131
+ } else if (lastChar === this.getExpectedPunctuation(locale, 'period')) {
132
+ type = 'period';
133
+ } else {
134
+ type = 'unknown';
135
+ }
136
+
137
+ return { type, original };
116
138
  }
117
139
 
118
140
  /**
@@ -132,6 +154,18 @@ class ResourceSentenceEnding extends ResourceRule {
132
154
  if (language === 'en' && type === 'ellipsis') {
133
155
  return defaults['ellipsis'];
134
156
  }
157
+ // Get locale-specific defaults for this language
158
+ const localeDefaults = this.getLocaleDefaults(language);
159
+ const result = localeDefaults[type] || this.getDefaultPunctuation(type);
160
+ return result;
161
+ }
162
+
163
+ /**
164
+ * Get locale-specific defaults for a given language
165
+ * @param {string} language - The language code
166
+ * @returns {Object} - The locale-specific defaults for the language
167
+ */
168
+ getLocaleDefaults(language) {
135
169
  const punctuationMap = {
136
170
  'ja': { 'period': '。', 'question': '?', 'exclamation': '!', 'ellipsis': '…', 'colon': ':' },
137
171
  'zh': { 'period': '。', 'question': '?', 'exclamation': '!', 'ellipsis': '…', 'colon': ':' },
@@ -147,8 +181,26 @@ class ResourceSentenceEnding extends ResourceRule {
147
181
  'kn': { 'period': '।', 'question': '?', 'exclamation': '!', 'ellipsis': '…', 'colon': ':' },
148
182
  'km': { 'period': '។', 'question': '?', 'exclamation': '!', 'ellipsis': '…', 'colon': ':' }
149
183
  };
150
- const result = punctuationMap[language]?.[type] || this.getDefaultPunctuation(type);
151
- return result;
184
+ return punctuationMap[language] || defaults;
185
+ }
186
+
187
+ /**
188
+ * Get a regex that matches all expected punctuation for a given locale
189
+ * @param {Locale} localeObj locale of the punctuation
190
+ * @returns {string} regex string that matches all expected punctuation for the locale
191
+ */
192
+ getExpectedPunctuationRegex(localeObj) {
193
+ const language = localeObj.getLanguage();
194
+ let config;
195
+ if (language) {
196
+ config = this.customPunctuationMap[language];
197
+ if (!config) {
198
+ config = this.getLocaleDefaults(language);
199
+ }
200
+ } else {
201
+ config = defaults;
202
+ }
203
+ return Object.values(config).join('').replace(/\./g, '\\.').replace(/\?/g, '\\?');
152
204
  }
153
205
 
154
206
  /**
@@ -203,46 +255,71 @@ class ResourceSentenceEnding extends ResourceRule {
203
255
  }
204
256
 
205
257
  /**
206
- * Get the last sentence from a string, using period, question, exclamation, or colon as delimiters.
207
- * For quoted content, extract the actual sentence within the quotes.
258
+ * Get the last quoted string in the input, or null if none found.
259
+ * Handles all quote types in allQuoteChars.
208
260
  * @param {string} str
209
- * @returns {string}
261
+ * @returns {string|null}
210
262
  */
211
- static getLastSentence(str) {
212
- if (!str) return str;
213
-
214
- // If there's a trailing quote, search backwards for the matching start quote
263
+ static getLastQuotedString(str) {
264
+ if (!str) return null;
215
265
  const quoteChars = ResourceSentenceEnding.allQuoteChars;
216
- const trimmedStr = str.trim();
217
- const lastChar = trimmedStr.charAt(trimmedStr.length - 1);
218
-
219
- if (quoteChars.includes(lastChar)) {
220
- // Find the last matching opening quote
221
- for (let i = trimmedStr.length - 2; i >= 0; i--) {
222
- if (quoteChars.includes(trimmedStr.charAt(i))) {
223
- // Extract content inside the last pair of quotes
224
- const quotedContent = trimmedStr.substring(i + 1, trimmedStr.length - 1);
225
- // Now find the last sentence within the quoted content
226
- return ResourceSentenceEnding.getLastSentenceFromContent(quotedContent);
266
+ let lastOpen = -1, lastClose = -1;
267
+ for (let i = str.length - 1; i >= 0; i--) {
268
+ if (quoteChars.includes(str[i])) {
269
+ lastClose = i;
270
+ // Find the matching opening quote
271
+ for (let j = i - 1; j >= 0; j--) {
272
+ if (quoteChars.includes(str[j])) {
273
+ lastOpen = j;
274
+ break;
275
+ }
227
276
  }
277
+ break;
228
278
  }
279
+
229
280
  }
281
+ if (lastOpen !== -1 && lastClose !== -1 && lastOpen < lastClose) {
282
+ return str.substring(lastOpen + 1, lastClose);
283
+ }
284
+ return null;
285
+ }
286
+
287
+ /**
288
+ * Get the last sentence from a string, handling quoted content.
289
+ * For source strings: only return quoted content if the string ends with a quote; otherwise return the full string.
290
+ * For target strings: return the last quoted content anywhere in the string, or the full string if no quotes.
291
+ * @param {string} str
292
+ * @param {boolean} isSource - true if this is a source string, false if target string
293
+ * @param {Locale} targetLocaleObj - locale of the punctuation
294
+ * @returns {string}
295
+ */
296
+ getLastSentence(str, isSource, targetLocaleObj) {
297
+ if (!str) return str;
298
+ const quoteChars = ResourceSentenceEnding.allQuoteChars;
299
+ const trimmedStr = str.trim();
230
300
 
231
- // If no quotes, use the current algorithm directly
232
- return ResourceSentenceEnding.getLastSentenceFromContent(trimmedStr);
301
+ const lastQuotedString = ResourceSentenceEnding.getLastQuotedString(trimmedStr);
302
+ if (lastQuotedString !== null) {
303
+ return lastQuotedString;
304
+ } else {
305
+ return this.getLastSentenceFromContent(trimmedStr, targetLocaleObj);
306
+ }
233
307
  }
234
308
 
235
309
  /**
236
310
  * Get the last sentence from content without considering outer quotes
237
311
  * @param {string} content
312
+ * @param {Locale} targetLocaleObj
238
313
  * @returns {string}
239
314
  */
240
- static getLastSentenceFromContent(content) {
315
+ getLastSentenceFromContent(content, targetLocaleObj) {
241
316
  if (!content) return content;
242
317
  // Only treat .!?。?! as sentence-ending punctuation, not ¿ or ¡
243
- const sentences = content.match(/[^.!?。?!]+[.!?。?!]+(?:\s+|$)/gu);
244
- if (sentences && sentences.length > 0) {
245
- let lastSentence = sentences[sentences.length - 1].trim();
318
+ const allSentenceEnding = this.getExpectedPunctuationRegex(targetLocaleObj);
319
+ const sentenceEndingRegex = new RegExp(`[^${allSentenceEnding}]+\\p{P}?\\w*$`, 'gu');
320
+ const match = sentenceEndingRegex.exec(content);
321
+ if (match !== null && match.length > 0) {
322
+ let lastSentence = match[0].trim();
246
323
  const quoteChars = ResourceSentenceEnding.allQuoteChars;
247
324
  const lastChar = lastSentence.charAt(lastSentence.length - 1);
248
325
  // If the last sentence ends with a quote, try to extract the last quoted segment
@@ -266,15 +343,35 @@ class ResourceSentenceEnding extends ResourceRule {
266
343
  return content.trim();
267
344
  }
268
345
 
346
+ /**
347
+ * Get Unicode code for a character
348
+ * @param {string} char - The character to get the Unicode code for
349
+ * @returns {string} - The Unicode code in format "U+XXXX"
350
+ */
351
+ static getUnicodeCode(char) {
352
+ if (!char || char.length === 0) return '';
353
+ const code = char.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0');
354
+ return `U+${code}`;
355
+ }
356
+
357
+ /**
358
+ * Get Unicode codes for a string
359
+ * @param {string} str - The string to get Unicode codes for
360
+ * @returns {string} - The Unicode codes in format "U+XXXX U+YYYY U+ZZZZ"
361
+ */
362
+ static getUnicodeCodes(str) {
363
+ if (!str) return '';
364
+ return str.split('').map(char => ResourceSentenceEnding.getUnicodeCode(char)).join(' ');
365
+ }
366
+
269
367
  /**
270
368
  * Check if Spanish target has the correct inverted punctuation at the beginning of the last sentence
271
- * @param {string} source - The source string
272
369
  * @param {string} lastSentence - The last sentence of the target string (already stripped of quotes)
273
370
  * @param {string} sourceEndingType - The type of ending punctuation in source
274
371
  * @returns {boolean} - True if Spanish target has correct inverted punctuation at start of last sentence
275
372
  */
276
- hasCorrectSpanishInvertedPunctuation(source, lastSentence, sourceEndingType) {
277
- if (!source || !lastSentence || typeof lastSentence !== 'string') return false;
373
+ hasCorrectSpanishInvertedPunctuation(lastSentence, sourceEndingType) {
374
+ if (!lastSentence || typeof lastSentence !== 'string') return false;
278
375
  // Only check for questions and exclamations
279
376
  if (sourceEndingType !== 'question' && sourceEndingType !== 'exclamation') {
280
377
  return true; // Not applicable for other punctuation types
@@ -282,7 +379,7 @@ class ResourceSentenceEnding extends ResourceRule {
282
379
  // Strip any leading quote characters before checking for inverted punctuation
283
380
  const quoteChars = ResourceSentenceEnding.allQuoteChars;
284
381
  let strippedSentence = lastSentence;
285
- while (strippedSentence.length > 0 && quoteChars.includes(strippedSentence.charAt(0))) {
382
+ while (strippedSentence.length > 0 && (quoteChars.includes(strippedSentence.charAt(0)) || isSpace(strippedSentence.charAt(0)))) {
286
383
  strippedSentence = strippedSentence.slice(1);
287
384
  }
288
385
  // Check for inverted punctuation at the beginning of the stripped last sentence
@@ -293,26 +390,26 @@ class ResourceSentenceEnding extends ResourceRule {
293
390
  /**
294
391
  * Find the position of the incorrect punctuation in the target string
295
392
  * @param {string} target - The target string
393
+ * @param {string} lastSentence - The last sentence from the target
296
394
  * @param {string} incorrectPunctuation - The incorrect punctuation to find
297
395
  * @returns {Object|null} - Object with position and length, or null if not found
298
396
  */
299
- findIncorrectPunctuationPosition(target, incorrectPunctuation) {
300
- if (!target || !incorrectPunctuation) return null;
397
+ findIncorrectPunctuationPosition(target, lastSentence, incorrectPunctuation) {
398
+ if (!target || !lastSentence || !incorrectPunctuation) return null;
301
399
 
302
- const stripped = ResourceSentenceEnding.stripTrailingQuotesAndWhitespace(target.trim());
303
- if (!stripped) return null;
304
-
305
- // Find the position of the incorrect punctuation at the end
400
+ // Find the position of the incorrect punctuation in the last sentence
306
401
  const punctuationLength = incorrectPunctuation.length;
307
- const endPosition = stripped.length - punctuationLength;
308
-
309
- if (endPosition >= 0 && stripped.substring(endPosition) === incorrectPunctuation) {
310
- // Calculate the position in the original target string
311
- const originalEndPosition = target.length - (target.trim().length - stripped.length) - punctuationLength;
312
- return {
313
- position: originalEndPosition,
314
- length: punctuationLength
315
- };
402
+ const endPosition = lastSentence.trimEnd().length - punctuationLength;
403
+
404
+ if (endPosition >= 0 && lastSentence.trimEnd().substring(endPosition) === incorrectPunctuation) {
405
+ // Find the position of the last sentence within the target string
406
+ const lastSentenceStart = target.lastIndexOf(lastSentence);
407
+ if (lastSentenceStart !== -1) {
408
+ return {
409
+ position: lastSentenceStart + endPosition,
410
+ length: punctuationLength
411
+ };
412
+ }
316
413
  }
317
414
 
318
415
  return null;
@@ -324,14 +421,55 @@ class ResourceSentenceEnding extends ResourceRule {
324
421
  * @param {string} target - The target string
325
422
  * @param {string} incorrectPunctuation - The incorrect punctuation
326
423
  * @param {string} correctPunctuation - The correct punctuation
327
- * @returns {Object|null} - The fix object or null if no fix can be created
424
+ * @returns {Object|undefined} - The fix object or undefined if no fix can be created
328
425
  */
329
- createPunctuationFix(resource, target, incorrectPunctuation, correctPunctuation) {
330
- const positionInfo = this.findIncorrectPunctuationPosition(target, incorrectPunctuation);
331
- if (!positionInfo) return null;
426
+ createPunctuationFix(resource, target, incorrectPunctuation, correctPunctuation, index, category, targetLocaleObj) {
427
+ // Get the last sentence to find the position
428
+ const lastSentence = this.getLastSentence(target, false, targetLocaleObj);
429
+
430
+ // If we're adding punctuation (incorrectPunctuation is empty), add it at the end
431
+ if (!incorrectPunctuation && correctPunctuation) {
432
+ return ResourceFixer.createFix({
433
+ resource,
434
+ index,
435
+ category,
436
+ commands: [
437
+ ResourceFixer.createStringCommand(
438
+ target.length,
439
+ 0,
440
+ correctPunctuation
441
+ )
442
+ ]
443
+ });
444
+ }
445
+
446
+ // If we're removing punctuation (correctPunctuation is empty), remove it
447
+ if (incorrectPunctuation && !correctPunctuation) {
448
+ const positionInfo = this.findIncorrectPunctuationPosition(target, lastSentence, incorrectPunctuation);
449
+ if (!positionInfo) return undefined;
450
+
451
+ return ResourceFixer.createFix({
452
+ resource,
453
+ index,
454
+ category,
455
+ commands: [
456
+ ResourceFixer.createStringCommand(
457
+ positionInfo.position,
458
+ positionInfo.length,
459
+ correctPunctuation
460
+ )
461
+ ]
462
+ });
463
+ }
464
+
465
+ // Normal case: replacing punctuation
466
+ const positionInfo = this.findIncorrectPunctuationPosition(target, lastSentence, incorrectPunctuation);
467
+ if (!positionInfo) return undefined;
332
468
 
333
469
  return ResourceFixer.createFix({
334
470
  resource,
471
+ index,
472
+ category,
335
473
  commands: [
336
474
  ResourceFixer.createStringCommand(
337
475
  positionInfo.position,
@@ -342,6 +480,34 @@ class ResourceSentenceEnding extends ResourceRule {
342
480
  });
343
481
  }
344
482
 
483
+ /**
484
+ * Create a fix to insert the correct starting punctuation for a Spanish question or exclamation
485
+ * at the beginning of the last sentence.
486
+ *
487
+ * @param {Resource} resource - The resource object
488
+ * @param {string} target - The target string
489
+ * @param {string} lastSentence - The last sentence from the target
490
+ * @param {string} correctPunctuation - The correct punctuation
491
+ * @returns {Object|undefined} - The fix object or undefined if no fix can be created
492
+ */
493
+ createFixForSpanishInvertedPunctuation(resource, target, lastSentence, correctPunctuation, index, category, targetLocaleObj) {
494
+ const lastSentenceStart = target.lastIndexOf(lastSentence);
495
+
496
+ return ResourceFixer.createFix({
497
+ resource,
498
+ index,
499
+ category,
500
+ commands: [
501
+ // insert the correct punctuation at the beginning of the last sentence
502
+ ResourceFixer.createStringCommand(
503
+ lastSentenceStart,
504
+ 0,
505
+ correctPunctuation
506
+ )
507
+ ]
508
+ });
509
+ }
510
+
345
511
  /**
346
512
  * Match the source and target strings for sentence ending punctuation issues
347
513
  * @param {Object} params - Parameters object
@@ -349,81 +515,194 @@ class ResourceSentenceEnding extends ResourceRule {
349
515
  * @param {string} params.target - The target string
350
516
  * @param {Resource} params.resource - The resource object
351
517
  * @param {string} params.file - The file path
352
- * @returns {Result|undefined} - A Result object if there's an issue, undefined otherwise
518
+ * @param {number} [params.index] - Index for array/plural resources
519
+ * @param {string} [params.category] - Category for plural resources
520
+ * @returns {Result|undefined} - Result object if there's an issue, undefined otherwise
353
521
  */
354
- matchString({ source, target, resource, file }) {
355
- if (!source || !target || !resource) return undefined;
356
- const sourceEnding = this.getEndingPunctuation(source);
357
- if (!sourceEnding) return undefined;
522
+ matchString({ source, target, resource, file, index, category }) {
523
+ if (!source || !target) return undefined;
524
+
358
525
  const targetLocale = resource.getTargetLocale();
359
526
  if (!targetLocale) return undefined;
360
- const localeObj = new Locale(targetLocale);
361
- const language = localeObj.getLanguage();
362
- if (!language) return undefined;
363
- const optionalPunctuationLanguages = ['th', 'lo', 'my'];
364
- if (optionalPunctuationLanguages.includes(language)) return undefined;
365
- // Pass sourceEnding.original to getExpectedPunctuation for ellipsis
366
- const expectedPunctuation = this.getExpectedPunctuation(localeObj, sourceEnding.type);
367
- if (!expectedPunctuation) return undefined;
368
-
369
- const strippedTarget = ResourceSentenceEnding.stripTrailingQuotesAndWhitespace(target.trim());
370
- if (this.hasExpectedEnding(strippedTarget, expectedPunctuation, sourceEnding.original)) {
371
- if (language !== 'es') {
372
- return undefined;
527
+ const targetLocaleObj = new Locale(targetLocale);
528
+ const targetLanguage = targetLocaleObj.getLanguage();
529
+ if (!targetLanguage) return undefined;
530
+
531
+ const sourceLocale = resource.getSourceLocale();
532
+ if (!sourceLocale) return undefined;
533
+ const sourceLocaleObj = new Locale(sourceLocale);
534
+ const sourceLanguage = sourceLocaleObj.getLanguage();
535
+ if (!sourceLanguage) return undefined;
536
+
537
+ const optionalPunctuationLanguages = ['th', 'lo', 'my', 'km', 'vi', 'id', 'ms', 'tl', 'jv', 'su'];
538
+ const isOptionalPunctuationLanguage = optionalPunctuationLanguages.includes(targetLanguage);
539
+
540
+ // Get the ending punctuation from source and target
541
+ const sourceEnding = this.getEndingPunctuation(source, sourceLocaleObj);
542
+
543
+ // Determine if the source ends with a quote
544
+ const sourceTrimmed = source.trim();
545
+ const quoteChars = ResourceSentenceEnding.allQuoteChars;
546
+ const sourceEndsWithQuote = quoteChars.includes(sourceTrimmed.charAt(sourceTrimmed.length - 1));
547
+
548
+ let lastSentence;
549
+ if (sourceEndsWithQuote) {
550
+ // Use the last quoted string in the target
551
+ lastSentence = ResourceSentenceEnding.getLastQuotedString(target) || target.trim();
552
+ } else {
553
+ // Use the full target string
554
+ lastSentence = this.getLastSentenceFromContent(target, targetLocaleObj);
555
+ }
556
+
557
+ const targetEnding = this.getEndingPunctuation(lastSentence, targetLocaleObj);
558
+
559
+ // Case 1: Source has no punctuation but target does
560
+ if (!sourceEnding && targetEnding) {
561
+ const unicodeCode = ResourceSentenceEnding.getUnicodeCodes(targetEnding.original);
562
+ const positionInfo = this.findIncorrectPunctuationPosition(target, lastSentence, targetEnding.original);
563
+ let highlight = '';
564
+ if (positionInfo) {
565
+ const beforePunctuation = target.substring(0, positionInfo.position);
566
+ const afterPunctuation = target.substring(positionInfo.position + positionInfo.length);
567
+ highlight = `${beforePunctuation}<e0>${targetEnding.original} (${unicodeCode})</e0>${afterPunctuation}`;
373
568
  }
374
569
 
375
- // For Spanish, also check for inverted punctuation at the beginning of the last sentence
376
- // Call getLastSentence with the original target to properly handle quoted content
377
- const lastSentence = ResourceSentenceEnding.getLastSentence(target);
378
- if (this.hasCorrectSpanishInvertedPunctuation(source, lastSentence, sourceEnding.type)) {
379
- return undefined;
570
+ // Add prefix for array/plural resources
571
+ if (index !== undefined) {
572
+ highlight = `Target[${index}]: ${highlight}`;
573
+ } else if (category) {
574
+ highlight = `Target(${category}): ${highlight}`;
380
575
  }
381
576
 
382
- // Spanish target is missing inverted punctuation at the beginning
383
577
  return new Result({
384
578
  rule: this,
385
579
  severity: "warning",
386
580
  id: "sentence-ending-punctuation",
387
- description: `Spanish ${sourceEnding.type} should start with "${sourceEnding.type === 'question' ? '¿' : '¡'}" for ${targetLocale} locale`,
581
+ description: `Extra sentence ending punctuation "${targetEnding.original}" (${unicodeCode}) for ${targetLocale} locale`,
388
582
  source: source,
389
- highlight: `Spanish ${sourceEnding.type} should start with "${sourceEnding.type === 'question' ? '¿' : '¡'}"`,
583
+ highlight: highlight,
390
584
  pathName: file,
585
+ fix: this.createPunctuationFix(resource, target, targetEnding.original, '', index, category, targetLocaleObj),
391
586
  lineNumber: typeof(resource['lineNumber']) !== 'undefined' ? resource['lineNumber'] : undefined
392
587
  });
393
588
  }
394
- let actualPunctuation = null;
395
- const patterns = [
396
- { regex: /\.{3}$/, punctuation: '...' },
397
- { regex: /…$/, punctuation: '…' },
398
- { regex: /\.$/, punctuation: '.' },
399
- { regex: /\?$/, punctuation: '?' },
400
- { regex: /!$/, punctuation: '!' },
401
- { regex: /:$/, punctuation: ':' }
402
- ];
403
- for (const pattern of patterns) {
404
- if (pattern.regex.test((target || '').trim())) {
405
- actualPunctuation = pattern.punctuation;
406
- break;
589
+
590
+ // Case 2: Source has punctuation but target doesn't
591
+ if (sourceEnding && !targetEnding && !isOptionalPunctuationLanguage && sourceEnding.type !== 'unknown') {
592
+ const expectedPunctuation = this.getExpectedPunctuation(targetLocaleObj, sourceEnding.type);
593
+ if (!expectedPunctuation) return undefined;
594
+
595
+ const unicodeCode = ResourceSentenceEnding.getUnicodeCodes(expectedPunctuation);
596
+ let highlight = `${lastSentence}<e0></e0>`;
597
+
598
+ // Add prefix for array/plural resources
599
+ if (index !== undefined) {
600
+ highlight = `Target[${index}]: ${highlight}`;
601
+ } else if (category) {
602
+ highlight = `Target(${category}): ${highlight}`;
407
603
  }
604
+
605
+ return new Result({
606
+ rule: this,
607
+ severity: "warning",
608
+ id: "sentence-ending-punctuation",
609
+ description: `Missing sentence ending punctuation for ${targetLocale} locale. It should be "${expectedPunctuation}" (${unicodeCode})`,
610
+ source: source,
611
+ highlight: highlight,
612
+ pathName: file,
613
+ fix: this.createPunctuationFix(resource, target, '', expectedPunctuation, index, category, targetLocaleObj),
614
+ lineNumber: typeof(resource['lineNumber']) !== 'undefined' ? resource['lineNumber'] : undefined
615
+ });
408
616
  }
409
- // For English ellipsis, prefer the form used in the source for the fix
410
- let fix = null;
411
- if (actualPunctuation && actualPunctuation !== expectedPunctuation) {
412
- fix = this.createPunctuationFix(resource, target, actualPunctuation, expectedPunctuation);
617
+
618
+ // Case 3: Both source and target have punctuation, but they don't match
619
+ if (sourceEnding && targetEnding && sourceEnding.type !== 'unknown') {
620
+ const expectedPunctuation = this.getExpectedPunctuation(targetLocaleObj, sourceEnding.type);
621
+ if (!expectedPunctuation) return undefined;
622
+
623
+ // For Spanish, check for inverted punctuation at the beginning
624
+ if (targetLanguage === 'es' && (sourceEnding.type === 'question' || sourceEnding.type === 'exclamation')) {
625
+ if (!this.hasCorrectSpanishInvertedPunctuation(lastSentence, sourceEnding.type)) {
626
+ // Spanish target is missing inverted punctuation at the beginning
627
+ const quoteChars = ResourceSentenceEnding.allQuoteChars;
628
+ let quotedContentStart = -1;
629
+ for (let i = 0; i < target.length; i++) {
630
+ if (quoteChars.includes(target.charAt(i))) {
631
+ quotedContentStart = i + 1;
632
+ break;
633
+ }
634
+ }
635
+
636
+ let highlight;
637
+ if (quotedContentStart !== -1) {
638
+ const beforeQuote = target.substring(0, quotedContentStart);
639
+ const afterQuote = target.substring(quotedContentStart);
640
+ highlight = `${beforeQuote}<e0/>${afterQuote}`;
641
+ } else {
642
+ highlight = `<e0/>${target}`;
643
+ }
644
+
645
+ // Add prefix for array/plural resources
646
+ if (index !== undefined) {
647
+ highlight = `Target[${index}]: ${highlight}`;
648
+ } else if (category) {
649
+ highlight = `Target(${category}): ${highlight}`;
650
+ }
651
+
652
+ const invertedChar = sourceEnding.type === 'question' ? '¿' : '¡';
653
+ const unicodeCode = ResourceSentenceEnding.getUnicodeCode(invertedChar);
654
+ return new Result({
655
+ rule: this,
656
+ severity: "warning",
657
+ id: "sentence-ending-punctuation",
658
+ description: `Spanish ${sourceEnding.type} should start with "${invertedChar}" (${unicodeCode}) for ${targetLocale} locale`,
659
+ source: source,
660
+ highlight: highlight,
661
+ pathName: file,
662
+ fix: this.createFixForSpanishInvertedPunctuation(resource, target, lastSentence, invertedChar, index, category, targetLocaleObj),
663
+ lineNumber: typeof(resource['lineNumber']) !== 'undefined' ? resource['lineNumber'] : undefined
664
+ });
665
+ }
666
+ }
667
+
668
+ // Check if the target punctuation matches the expected punctuation for the locale
669
+ if (targetEnding.type === sourceEnding.type && targetEnding.original === expectedPunctuation) {
670
+ return undefined; // Punctuation matches, no issue
671
+ }
672
+
673
+ // Target has different punctuation than expected
674
+ const unicodeCode = ResourceSentenceEnding.getUnicodeCodes(targetEnding.original);
675
+ const expectedUnicode = ResourceSentenceEnding.getUnicodeCodes(expectedPunctuation);
676
+ const positionInfo = this.findIncorrectPunctuationPosition(target, lastSentence, targetEnding.original);
677
+ let highlight = '';
678
+ if (positionInfo) {
679
+ const beforePunctuation = target.substring(0, positionInfo.position);
680
+ const afterPunctuation = target.substring(positionInfo.position + positionInfo.length);
681
+ highlight = `${beforePunctuation}<e0>${targetEnding.original} (${unicodeCode})</e0>${afterPunctuation}`;
682
+ }
683
+
684
+ // Add prefix for array/plural resources
685
+ if (index !== undefined) {
686
+ highlight = `Target[${index}]: ${highlight}`;
687
+ } else if (category) {
688
+ highlight = `Target(${category}): ${highlight}`;
689
+ }
690
+
691
+ return new Result({
692
+ rule: this,
693
+ severity: "warning",
694
+ id: "sentence-ending-punctuation",
695
+ description: `Sentence ending punctuation should be "${expectedPunctuation}" (${expectedUnicode}) for ${targetLocale} locale, not "${targetEnding.original}" (${unicodeCode})`,
696
+ source: source,
697
+ highlight: highlight,
698
+ pathName: file,
699
+ fix: this.createPunctuationFix(resource, target, targetEnding.original, expectedPunctuation, index, category, targetLocaleObj),
700
+ lineNumber: typeof(resource['lineNumber']) !== 'undefined' ? resource['lineNumber'] : undefined
701
+ });
413
702
  }
414
703
 
415
- return new Result({
416
- rule: this,
417
- severity: "warning",
418
- id: "sentence-ending-punctuation",
419
- description: `Sentence ending punctuation should be "${expectedPunctuation}" for ${targetLocale} locale, not "${(actualPunctuation ?? sourceEnding.original)}"`,
420
- source: source,
421
- highlight: '',
422
- pathName: file,
423
- fix,
424
- lineNumber: typeof(resource['lineNumber']) !== 'undefined' ? resource['lineNumber'] : undefined
425
- });
704
+ return undefined;
426
705
  }
427
706
  }
428
707
 
429
- export default ResourceSentenceEnding;
708
+ export default ResourceSentenceEnding;