cspell-lib 10.2.0 → 10.2.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.
@@ -1,5 +1,6 @@
1
1
  import type { MappedText } from '@cspell/cspell-types';
2
2
  import type { TextTransformer } from './Transformer.js';
3
+ export declare const softHyphen = "\u00AD";
3
4
  export declare class IntlSegmentTextTransformer implements TextTransformer {
4
5
  #private;
5
6
  constructor(locale: string | string[]);
@@ -1,6 +1,7 @@
1
1
  import { normalizeLocaleIntl } from '../Settings/LanguageSettings.js';
2
2
  import { applyEditsToMappedText } from './TextMapEdit.js';
3
3
  import { toMappedText } from './Transformer.js';
4
+ export const softHyphen = '\u00AD';
4
5
  const regExSplitWordCharacters = /(?<=[\p{L}\p{M}])[\p{L}\p{M}]/uy;
5
6
  export class IntlSegmentTextTransformer {
6
7
  #locale;
@@ -23,11 +24,11 @@ function segmentText(mText, segmenter) {
23
24
  let lastIndex = -1;
24
25
  const reg = new RegExp(regExSplitWordCharacters);
25
26
  const text = mText.text;
26
- for (const { segment, index } of segmenter.segment(mText.text)) {
27
+ for (const { segment, index, isWordLike } of segmenter.segment(mText.text)) {
27
28
  const endIndex = index + segment.length;
28
29
  reg.lastIndex = index;
29
- if (index === lastIndex && reg.test(text)) {
30
- const edit = { text: ' ' + segment, range: [index, endIndex] };
30
+ if (isWordLike && index === lastIndex && reg.test(text)) {
31
+ const edit = { text: softHyphen + segment, range: [index, endIndex] };
31
32
  edits.push(edit);
32
33
  }
33
34
  lastIndex = endIndex;
@@ -1,3 +1,4 @@
1
+ import { softHyphen } from '../util/wordSplitter/index.js';
1
2
  const maxRadio = 0.5;
2
3
  /**
3
4
  * Try to detect if a string is a random string of characters or is it camel case / snake case words.
@@ -5,6 +6,9 @@ const maxRadio = 0.5;
5
6
  * @returns true if the string is considered random;
6
7
  */
7
8
  export function isRandomString(s, maxNoiseToLengthRatio = maxRadio) {
9
+ // Soft hyphen indicates a word break, so it's likely not a random string.
10
+ if (s.includes(softHyphen))
11
+ return false;
8
12
  return scoreRandomString(s) >= maxNoiseToLengthRatio;
9
13
  }
10
14
  /**
@@ -28,9 +32,6 @@ function sumWordLengths(s) {
28
32
  }
29
33
  return total;
30
34
  }
31
- function _nonLetterRatio(s) {
32
- return s.replaceAll(/\p{L}/gu, '').length / s.length;
33
- }
34
35
  export function categorizeString(s) {
35
36
  const n = s
36
37
  .replaceAll(/\d+/g, '0')
@@ -7,7 +7,7 @@ import { mapRangeBackToOriginalPos } from '../Transform/index.js';
7
7
  import { autoResolve } from '../util/AutoResolve.js';
8
8
  import { extractPossibleWordsFromTextOffset, extractText, extractWordsFromTextOffset, splitWordWithOffset, } from '../util/text.js';
9
9
  import { regExpCamelCaseWordBreaksWithEnglishSuffix } from '../util/textRegex.js';
10
- import { split } from '../util/wordSplitter.js';
10
+ import { split } from '../util/wordSplitter/index.js';
11
11
  import { defaultMinWordLength } from './defaultConstants.js';
12
12
  import { extractHexSequences, isRandomString } from './isRandomString.js';
13
13
  import { isWordValidWithEscapeRetry } from './isWordValid.js';
@@ -327,7 +327,12 @@ export function lineValidatorFactory(sDict, options) {
327
327
  const v = checkWord({ ...w, text: m[1], line: lineSegment.line });
328
328
  return v.isFlagged || !v.isFound;
329
329
  });
330
- const filtered = filterExcludedTextOffsets(nonMatching.map((w) => ({ ...w, line: lineSegment.line })).map(annotateIsFlagged), hexSequences);
330
+ // Ignore 1 or 2 letter segments.
331
+ const minWordLen = Math.min(3, minWordLength);
332
+ const filtered = filterExcludedTextOffsets(nonMatching
333
+ .map((w) => ({ ...w, line: lineSegment.line }))
334
+ .map(annotateIsFlagged)
335
+ .filter((w) => w.isFlagged || w.text.length >= minWordLen), hexSequences);
331
336
  if (filtered.length < mismatches.length) {
332
337
  return filtered;
333
338
  }
@@ -2,7 +2,7 @@ import { getSources } from '../Settings/index.js';
2
2
  import { createCollection, getInlineConfigDictionaries, mapSpecialDictionaryNamesToSettings, } from '../SpellingDictionary/index.js';
3
3
  import { toFileUrl } from '../util/url.js';
4
4
  import { uniqueFn } from '../util/util.js';
5
- import { split } from '../util/wordSplitter.js';
5
+ import { split } from '../util/wordSplitter/index.js';
6
6
  export function traceWord(word, dictCollection, config) {
7
7
  const opts = {
8
8
  ignoreCase: config.ignoreCase ?? true,
@@ -7,7 +7,7 @@ export const regExpCamelCaseWordBreaks_NOT_USED = /(?<=\p{Ll}\p{M}?)(?=\p{Lu})|(
7
7
  export const regExpAllPossibleWordBreaks_NOT_USED = /(?<=\p{Ll}\p{M}?)(?=\p{Lu})|(?<=\p{Lu}\p{M}?)(?=\p{Lu}\p{M}?\p{Ll})|(?<=\p{Lu}\p{M}?\p{Lu}\p{M}?)(?=\p{Ll})|(?<=\p{L}\p{M}?)(?=\P{L})|(?<=\P{L})(?=\p{L})/gu;
8
8
  export const regExWords = /\p{L}\p{M}?(?:(?:\\?['’])?\p{L}\p{M}?)*/gu;
9
9
  // Words can be made of letters, numbers, period, underscore, dash, plus, and single quote
10
- export const regExWordsAndDigits = /[\p{L}\w'’`.+-](?:(?:\\(?=[']))?[\p{L}\p{M}\w'’`.+-])*/gu;
10
+ export const regExWordsAndDigits = /[\p{L}\w'’`.+\u00AD-](?:(?:\\(?=[']))?[\p{L}\p{M}\w'’`.+\u00AD-])*/gu;
11
11
  export const regExIgnoreCharacters = /[\p{sc=Hiragana}\p{sc=Han}\p{sc=Katakana}\u30A0-\u30FF\p{sc=Hangul}]/gu;
12
12
  export const regExFirstUpper = /^\p{Lu}\p{M}?\p{Ll}+$/u;
13
13
  export const regExAllUpper = /^(?:\p{Lu}\p{M}?)+$/u;
@@ -0,0 +1,32 @@
1
+ import type { TextOffset } from '@cspell/cspell-types';
2
+ export declare const softHyphen = "\u00AD";
3
+ export interface LineSegment {
4
+ line: TextOffset;
5
+ /**
6
+ * The starting offset within line.text.
7
+ * The absolute offset can be obtained by adding line.offset.
8
+ */
9
+ relStart: number;
10
+ /**
11
+ * The ending offset within line.text.
12
+ * The absolute offset can be obtained by adding line.offset.
13
+ */
14
+ relEnd: number;
15
+ }
16
+ export type BreakPairs = readonly [number, number];
17
+ export interface PossibleWordBreak {
18
+ /** offset from the start of the string */
19
+ offset: number;
20
+ /**
21
+ * break pairs (start, end)
22
+ * (the characters between the start and end are removed)
23
+ * With a pure break, start === end.
24
+ */
25
+ breaks: BreakPairs[];
26
+ }
27
+ export type SortedBreaks = PossibleWordBreak[];
28
+ export interface WordBreakOptions {
29
+ optionalWordBreakCharacters?: string;
30
+ }
31
+ export declare function generateWordBreaks(line: LineSegment, options: WordBreakOptions): SortedBreaks;
32
+ //# sourceMappingURL=generateWordBreaks.d.ts.map
@@ -0,0 +1,120 @@
1
+ import { escapeRegEx } from '../regexHelper.js';
2
+ import { regExDanglingQuote, regExEscapeCharacters, regExPossibleWordBreaks, regExSplitWords, regExSplitWords2, regExTrailingEndings, } from '../textRegex.js';
3
+ export const softHyphen = '\u00AD';
4
+ const ignoreBreak = Object.freeze([]);
5
+ export function generateWordBreaks(line, options) {
6
+ const camelBreaks = genWordBreakCamel(line);
7
+ const symbolBreaks = genSymbolBreaks(line);
8
+ const optionalBreaks = genOptionalWordBreaks(line, options.optionalWordBreakCharacters);
9
+ return mergeSortedBreaks(...camelBreaks, ...symbolBreaks, ...optionalBreaks);
10
+ }
11
+ function offsetRegEx(reg, offset) {
12
+ const r = new RegExp(reg);
13
+ r.lastIndex = offset;
14
+ return r;
15
+ }
16
+ function genWordBreakCamel(line) {
17
+ const breaksCamel1 = [];
18
+ const text = line.line.text.slice(0, line.relEnd);
19
+ // lower,Upper: camelCase -> camel|Case
20
+ for (const m of text.matchAll(offsetRegEx(regExSplitWords, line.relStart))) {
21
+ if (m.index === undefined)
22
+ break;
23
+ const i = m.index + m[1].length;
24
+ breaksCamel1.push({
25
+ offset: m.index,
26
+ breaks: [[i, i], ignoreBreak],
27
+ });
28
+ }
29
+ const breaksCamel2 = [];
30
+ // cspell:ignore ERRORC
31
+ // Upper,Upper,lower: ERRORCodes -> ERROR|Codes, ERRORC|odes
32
+ for (const m of text.matchAll(offsetRegEx(regExSplitWords2, line.relStart))) {
33
+ if (m.index === undefined)
34
+ break;
35
+ const i = m.index + m[1].length;
36
+ const j = i + m[3].length;
37
+ breaksCamel2.push({
38
+ offset: m.index,
39
+ breaks: [[i, i], [j, j], ignoreBreak],
40
+ });
41
+ }
42
+ return [breaksCamel1, breaksCamel2];
43
+ }
44
+ function calcBreaksForSubString(line, subStr, calcBreak) {
45
+ const sb = [];
46
+ const text = line.line.text;
47
+ const inc = subStr.length;
48
+ for (let pos = text.indexOf(subStr, line.relStart); pos >= 0 && pos < line.relEnd; pos = text.indexOf(subStr, pos + inc)) {
49
+ const b = calcBreak(pos, pos + inc);
50
+ if (b) {
51
+ sb.push(b);
52
+ }
53
+ }
54
+ return sb;
55
+ }
56
+ function calcBreaksForRegEx(line, reg, calcBreak) {
57
+ const sb = [];
58
+ const text = line.line.text.slice(0, line.relEnd);
59
+ for (const m of text.matchAll(offsetRegEx(reg, line.relStart))) {
60
+ const b = calcBreak(m);
61
+ if (b) {
62
+ sb.push(b);
63
+ }
64
+ }
65
+ return sb;
66
+ }
67
+ function calcOptionalWordBreak(i, j) {
68
+ return {
69
+ offset: i,
70
+ breaks: [
71
+ [i, j], // Remove the characters
72
+ ignoreBreak,
73
+ ],
74
+ };
75
+ }
76
+ function genOptionalWordBreaks(line, optionalBreakCharacters) {
77
+ function calcBreaks(m) {
78
+ const i = m.index;
79
+ if (i === undefined)
80
+ return;
81
+ const j = i + m[0].length;
82
+ return calcOptionalWordBreak(i, j);
83
+ }
84
+ const breaks = [
85
+ calcBreaksForSubString(line, softHyphen, calcOptionalWordBreak),
86
+ calcBreaksForRegEx(line, regExDanglingQuote, calcBreaks),
87
+ calcBreaksForRegEx(line, regExTrailingEndings, calcBreaks),
88
+ ];
89
+ if (optionalBreakCharacters) {
90
+ const regex = new RegExp(`[${escapeRegEx(optionalBreakCharacters)}]`, 'gu');
91
+ breaks.push(calcBreaksForRegEx(line, regex, calcBreaks));
92
+ }
93
+ return breaks;
94
+ }
95
+ function genSymbolBreaks(line) {
96
+ function calcBreaks(m) {
97
+ const i = m.index;
98
+ if (i === undefined)
99
+ return;
100
+ const j = i + m[0].length;
101
+ return {
102
+ offset: i,
103
+ breaks: [
104
+ [i, j], // Remove the characters
105
+ [i, i], // keep characters with word to right
106
+ [j, j], // keep characters with word to left
107
+ ignoreBreak,
108
+ ],
109
+ };
110
+ }
111
+ return [
112
+ calcBreaksForRegEx(line, regExPossibleWordBreaks, calcBreaks),
113
+ calcBreaksForRegEx(line, /\d+/g, calcBreaks),
114
+ calcBreaksForRegEx(line, regExEscapeCharacters, calcBreaks),
115
+ ];
116
+ }
117
+ function mergeSortedBreaks(...maps) {
118
+ return maps.flat().sort((a, b) => a.offset - b.offset);
119
+ }
120
+ //# sourceMappingURL=generateWordBreaks.js.map
@@ -0,0 +1,4 @@
1
+ export { softHyphen } from './generateWordBreaks.js';
2
+ export type { SplitOptions, SplitResult } from './wordSplitter.js';
3
+ export { split } from './wordSplitter.js';
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,3 @@
1
+ export { softHyphen } from './generateWordBreaks.js';
2
+ export { split } from './wordSplitter.js';
3
+ //# sourceMappingURL=index.js.map
@@ -1,4 +1,5 @@
1
1
  import type { TextOffset } from '@cspell/cspell-types';
2
+ import type { WordBreakOptions } from './generateWordBreaks.js';
2
3
  export type IsValidWordFn = (word: TextOffset) => boolean;
3
4
  export interface SplitResult {
4
5
  /** Original line passed to the split function */
@@ -12,36 +13,26 @@ export interface SplitResult {
12
13
  /** the offset at which the split stopped */
13
14
  endOffset: number;
14
15
  }
15
- export interface LineSegment {
16
- line: TextOffset;
17
- relStart: number;
18
- relEnd: number;
16
+ export interface TextOffsetWithOptionalValid extends TextOffset {
17
+ length: number;
18
+ isFound?: boolean;
19
19
  }
20
- export interface TextOffsetWithValid extends TextOffset {
20
+ export interface TextOffsetWithValid extends TextOffsetWithOptionalValid {
21
21
  isFound: boolean;
22
22
  }
23
23
  export interface SplitOptions extends WordBreakOptions {
24
24
  }
25
+ /**
26
+ *
27
+ * @param line - the line of text
28
+ * @param offset - absolute offset, comparable to line.offset.
29
+ * @param isValidWord - predicate function to test if a word is valid.
30
+ * @param options - SplitOptions
31
+ * @returns SplitResult
32
+ */
25
33
  export declare function split(line: TextOffset, offset: number, isValidWord: IsValidWordFn, options?: SplitOptions): SplitResult;
26
34
  declare function findNextWordText({ text, offset }: TextOffset): TextOffset;
27
- type BreakPairs = readonly [number, number];
28
- interface PossibleWordBreak {
29
- /** offset from the start of the string */
30
- offset: number;
31
- /**
32
- * break pairs (start, end)
33
- * (the characters between the start and end are removed)
34
- * With a pure break, start === end.
35
- */
36
- breaks: BreakPairs[];
37
- }
38
- export type SortedBreaks = PossibleWordBreak[];
39
- interface WordBreakOptions {
40
- optionalWordBreakCharacters?: string;
41
- }
42
- declare function generateWordBreaks(line: LineSegment, options: WordBreakOptions): SortedBreaks;
43
35
  export declare const __testing__: {
44
- generateWordBreaks: typeof generateWordBreaks;
45
36
  findNextWordText: typeof findNextWordText;
46
37
  };
47
38
  export {};
@@ -0,0 +1,338 @@
1
+ import { PairingHeap } from '../PairingHeap.js';
2
+ import { regExFirstUpper, regExNumericLiteral, regExWordsAndDigits } from '../textRegex.js';
3
+ import { generateWordBreaks, softHyphen } from './generateWordBreaks.js';
4
+ const ignoreBreak = Object.freeze([]);
5
+ const maxSkippedBreaks = 16; // Maximum number of breaks that can be skipped during word splitting.
6
+ const maxAttempts = 64_000; // Maximum number of attempts to split words.
7
+ // Quote/backtick characters that `regExWordsAndDigits` treats as word characters, so a quoted
8
+ // or templated string literal in source code gets scanned with its delimiters attached.
9
+ const regExLeadingTrimCharacters = /^['’`\\-]+/;
10
+ const regExTrailingTrimCharacters = /['’`\\-]+$/;
11
+ /**
12
+ *
13
+ * @param line - the line of text
14
+ * @param offset - absolute offset, comparable to line.offset.
15
+ * @param isValidWord - predicate function to test if a word is valid.
16
+ * @param options - SplitOptions
17
+ * @returns SplitResult
18
+ */
19
+ export function split(line, offset, isValidWord, options = {}) {
20
+ const relWordToSplit = findNextWordText({ text: line.text, offset: offset - line.offset });
21
+ const hasSoftHyphen = relWordToSplit.text.includes(softHyphen);
22
+ const lineOffset = line.offset;
23
+ const requested = new Map();
24
+ const regExpIgnoreSegment = /^[-.+\d_eE'`\\\s\u00AD]+$/;
25
+ if (!relWordToSplit.text) {
26
+ const text = rebaseTextOffsetToAbsolute(relWordToSplit);
27
+ return {
28
+ line,
29
+ offset,
30
+ text: text,
31
+ words: [],
32
+ endOffset: text.offset + (text.length ?? text.text.length),
33
+ };
34
+ }
35
+ const lineSegment = {
36
+ line,
37
+ relStart: relWordToSplit.offset,
38
+ relEnd: relWordToSplit.offset + relWordToSplit.text.length,
39
+ };
40
+ const possibleBreaks = generateWordBreaks(lineSegment, options);
41
+ if (!possibleBreaks.length) {
42
+ const text = rebaseTextOffsetToAbsolute(relWordToSplit);
43
+ return {
44
+ line,
45
+ offset,
46
+ text: text,
47
+ words: [{ ...text, length: text.length || text.text.length, isFound: isValidWord(text) }],
48
+ endOffset: text.offset + (text.length ?? text.text.length),
49
+ };
50
+ }
51
+ function rebaseTextOffsetToAbsolute(relText) {
52
+ relText.offset += lineOffset;
53
+ return relText;
54
+ }
55
+ function rebaseTextOffsetToRelative(relText) {
56
+ relText.offset -= lineOffset;
57
+ return relText;
58
+ }
59
+ function createTextOffsetWithValid(start, end, isFound) {
60
+ const word = {
61
+ offset: start,
62
+ length: end - start,
63
+ text: line.text.slice(start, end),
64
+ isFound,
65
+ };
66
+ return word;
67
+ }
68
+ function calcKey(start, end) {
69
+ const i = start;
70
+ const j = end - start;
71
+ const v = i + (j << 20);
72
+ return i < 1 << 20 && j < 1 << 11 ? v : -1;
73
+ }
74
+ function checkTextRange(start, end) {
75
+ const v = calcKey(start, end);
76
+ const f = v >= 0 ? requested.get(v) : undefined;
77
+ if (f)
78
+ return f;
79
+ const validated = createTextOffsetWithValid(start, end, false);
80
+ if (regExpIgnoreSegment.test(validated.text)) {
81
+ validated.isFound = true;
82
+ if (v >= 0) {
83
+ requested.set(v, validated);
84
+ }
85
+ return validated;
86
+ }
87
+ rebaseTextOffsetToAbsolute(validated);
88
+ validated.isFound = isValidWord(validated);
89
+ if (hasSoftHyphen && !validated.isFound && validated.text.includes(softHyphen)) {
90
+ removeSoftHyphen(validated);
91
+ validated.isFound = isValidWord(validated);
92
+ }
93
+ rebaseTextOffsetToRelative(validated);
94
+ if (v >= 0) {
95
+ requested.set(v, validated);
96
+ }
97
+ return validated;
98
+ }
99
+ // Add a dummy break at the end to avoid needing to check for last break.
100
+ possibleBreaks.push({
101
+ offset: lineSegment.relEnd,
102
+ breaks: [ignoreBreak],
103
+ });
104
+ const result = {
105
+ line,
106
+ offset,
107
+ text: rebaseTextOffsetToAbsolute(relWordToSplit),
108
+ words: splitIntoWords(lineSegment, possibleBreaks, checkTextRange).map(rebaseTextOffsetToAbsolute),
109
+ endOffset: lineOffset + lineSegment.relEnd,
110
+ };
111
+ return result;
112
+ }
113
+ function removeSoftHyphen(word) {
114
+ word.text = word.text.replaceAll(softHyphen, '');
115
+ return word;
116
+ }
117
+ function findNextWordText({ text, offset }) {
118
+ const reg = new RegExp(regExWordsAndDigits);
119
+ reg.lastIndex = offset;
120
+ const m = reg.exec(text);
121
+ if (!m) {
122
+ // No more words found, return an empty text with offset at the end of the string within the document.
123
+ return { text: '', offset: offset + text.length };
124
+ }
125
+ // Skip numeric literals.
126
+ if (regExNumericLiteral.test(m[0])) {
127
+ return findNextWordText({ text, offset: offset + m[0].length });
128
+ }
129
+ return {
130
+ text: m[0],
131
+ offset: m.index,
132
+ };
133
+ }
134
+ function splitIntoWords(lineSeg, breaks, checkTextRange) {
135
+ const maxIndex = lineSeg.relEnd;
136
+ /**
137
+ * Map of known paths by their starting index in the text.
138
+ * This helps in quickly finding previously computed paths to avoid redundant calculations.
139
+ */
140
+ const knownPathsByIndex = new Map();
141
+ const terminalNode = { n: undefined, i: maxIndex, j: maxIndex, c: 0, nc: 0, text: undefined };
142
+ knownPathsByIndex.set(maxIndex, terminalNode);
143
+ // Seed the search with the whole word treated as a single candidate. This guarantees a
144
+ // correct fallback result (e.g. a whole word matched via ignoreWords/the dictionary, or
145
+ // reporting the word as a single misspelling) even when the segmented search below never
146
+ // finds a strictly cheaper split.
147
+ let wholeWord = checkTextRange(lineSeg.relStart, maxIndex);
148
+ if (!wholeWord.isFound) {
149
+ // `regExWordsAndDigits` treats quote/backtick characters as word characters so that
150
+ // things like `Tom's` or `n'cpp` are scanned as a single word; but it also means a
151
+ // quoted/templated string literal in source code (e.g. `'ignoredWord'`) is scanned
152
+ // including its delimiters. Retry against the span with that framing peeled off so a
153
+ // whole word that is otherwise valid/ignored is still recognized as such.
154
+ const raw = lineSeg.line.text.slice(lineSeg.relStart, maxIndex);
155
+ const lead = raw.match(regExLeadingTrimCharacters)?.[0].length ?? 0;
156
+ const tail = raw.slice(lead).match(regExTrailingTrimCharacters)?.[0].length ?? 0;
157
+ if ((lead || tail) && lead + tail < raw.length) {
158
+ wholeWord = checkTextRange(lineSeg.relStart + lead, maxIndex - tail);
159
+ }
160
+ }
161
+ const wholeWordCost = wholeWord.isFound ? 0 : wholeWord.text.length;
162
+ const wholeWordNode = {
163
+ n: terminalNode,
164
+ i: lineSeg.relStart,
165
+ j: maxIndex,
166
+ c: wholeWordCost,
167
+ nc: wholeWordCost,
168
+ text: wholeWord,
169
+ };
170
+ knownPathsByIndex.set(lineSeg.relStart, wholeWordNode);
171
+ function findNearestBreakIndex(offset, bi) {
172
+ while (bi < breaks.length && breaks[bi].offset < offset) {
173
+ bi += 1;
174
+ }
175
+ return bi < breaks.length ? bi : undefined;
176
+ }
177
+ /**
178
+ * Create a set of possible candidate to consider
179
+ * @param p - prev candidate that lead to this one
180
+ * @param i - offset within the string
181
+ * @param bi - current index into the set of breaks
182
+ * @param currentCost - current cost accrued
183
+ */
184
+ function makeCandidates(p, i, bi, bs, currentCost) {
185
+ const len = maxIndex;
186
+ const nBi = findNearestBreakIndex(i, bi);
187
+ const nBs = findNearestBreakIndex(i, bs);
188
+ if (nBi === undefined || nBs === undefined) {
189
+ return [];
190
+ }
191
+ bi = nBi;
192
+ bs = nBs;
193
+ if (bi - bs > maxSkippedBreaks) {
194
+ return [];
195
+ }
196
+ const br = breaks[bi];
197
+ function calcBreakCost(bp) {
198
+ if (bp.length < 2) {
199
+ // We are skipping this break pair.
200
+ return { p, i, j: len, bi, bs, bp, c: currentCost, ec: currentCost + len - i, text: undefined };
201
+ }
202
+ const startOfBreak = bp[0];
203
+ const endOfBreak = bp[1];
204
+ // cost from the end of the segment to the end of the text
205
+ // const costToEnd = knownPathsByIndex.get(endOfBreak)?.c ?? len - endOfBreak;
206
+ const costToEnd = len - endOfBreak;
207
+ // Guess the cost of the word.
208
+ const costOfWord = (startOfBreak - i) * 0.5;
209
+ const d = costOfWord + costToEnd;
210
+ const ec = currentCost + d;
211
+ return { p, i, j: endOfBreak, bi, bs, bp, c: currentCost, ec, text: undefined };
212
+ }
213
+ return br.breaks.map(calcBreakCost);
214
+ }
215
+ function compare(a, b) {
216
+ return a.bi - a.bs - (b.bi - b.bs) || a.ec - b.ec || b.i - a.i || a.j - b.j;
217
+ }
218
+ function pathToWords(node) {
219
+ const results = [];
220
+ for (let p = node; p; p = p.n) {
221
+ if (p.text) {
222
+ results.push(p.text);
223
+ }
224
+ }
225
+ return results;
226
+ }
227
+ function addToKnownPaths(candidate) {
228
+ let path = knownPathsByIndex.get(candidate.j);
229
+ for (let can = candidate; can !== undefined; can = can.p) {
230
+ const t = can.text;
231
+ const i = can.i;
232
+ const j = can.j;
233
+ const currentNode = knownPathsByIndex.get(i);
234
+ const exitingPath = knownPathsByIndex.get(j);
235
+ // If a better-or-equal path is already known, reuse it and keep propagating
236
+ // the (possibly cheaper) suffix back toward the start instead of abandoning it.
237
+ if (exitingPath && path && exitingPath.c <= path.c) {
238
+ path = exitingPath;
239
+ }
240
+ const nc = !t || t.isFound ? 0 : t.length;
241
+ const cost = nc + (path?.c || 0);
242
+ const node = { n: path, i, j, c: cost, nc, text: t };
243
+ if (!currentNode || currentNode.c > cost) {
244
+ knownPathsByIndex.set(i, node);
245
+ }
246
+ path = node;
247
+ }
248
+ return path;
249
+ }
250
+ // A candidate that only ties the whole-word fallback is still preferred over it, since it
251
+ // represents a more granular split; ties between two genuine split candidates keep whichever
252
+ // was found first. Allow the search to explore up to (and including) a tying cost by giving
253
+ // maxCost one extra unit of slack above the seeded whole-word cost.
254
+ function preferCandidate(candidate, current) {
255
+ if (candidate.c < current.c)
256
+ return true;
257
+ if (candidate.c > current.c)
258
+ return false;
259
+ return current === wholeWordNode && candidate !== wholeWordNode;
260
+ }
261
+ let maxCost = wholeWordCost + 1;
262
+ const candidates = new PairingHeap(compare);
263
+ candidates.append(makeCandidates(undefined, lineSeg.relStart, 0, 0, 0));
264
+ let attempts = 0;
265
+ let bestPath = wholeWordNode;
266
+ while (maxCost && candidates.length && attempts++ < maxAttempts) {
267
+ /** Best Candidate Index */
268
+ const best = candidates.dequeue();
269
+ if (!best || best.c >= maxCost) {
270
+ continue;
271
+ }
272
+ // Does it have a split?
273
+ if (best.bp.length) {
274
+ // yes
275
+ const i = best.bp[0];
276
+ const j = best.bp[1];
277
+ const parentPath = knownPathsByIndex.get(j);
278
+ const parentCost = parentPath?.c || 0;
279
+ if (parentCost + best.c >= maxCost) {
280
+ continue;
281
+ }
282
+ const t = i > best.i ? checkTextRange(best.i, i) : undefined;
283
+ const cost = !t || t.isFound ? 0 : t.length;
284
+ const mc = maxIndex - j;
285
+ best.c += cost;
286
+ best.ec = best.c + mc;
287
+ best.text = t;
288
+ if (best.c >= maxCost) {
289
+ continue;
290
+ }
291
+ if (parentPath) {
292
+ // We found a known apply to candidate
293
+ const f = addToKnownPaths(best);
294
+ bestPath = f && preferCandidate(f, bestPath) ? f : bestPath;
295
+ }
296
+ const c = makeCandidates(t ? best : best.p, j, best.bi + 1, best.bs, best.c);
297
+ candidates.append(c);
298
+ }
299
+ else {
300
+ // It is a pass through
301
+ const c = makeCandidates(best.p, best.i, best.bi + 1, best.bs, best.c);
302
+ candidates.append(c);
303
+ if (!c.length) {
304
+ const j = maxIndex;
305
+ const t = j > best.i ? checkTextRange(best.i, j) : undefined;
306
+ const cost = !t || t.isFound ? 0 : t.length;
307
+ best.c += cost;
308
+ best.ec = best.c;
309
+ best.text = t;
310
+ if (best.c >= maxCost) {
311
+ continue;
312
+ }
313
+ const segText = t || best.p?.text || checkTextRange(best.i, best.i);
314
+ const can = t ? { ...best, text: segText } : { ...best, ...best.p, text: segText };
315
+ can.j = j;
316
+ const f = addToKnownPaths(can);
317
+ bestPath = f && preferCandidate(f, bestPath) ? f : bestPath;
318
+ }
319
+ }
320
+ // While the whole-word fallback is still the best known answer, keep one unit of
321
+ // slack above its cost so a genuine split that only ties it can still be explored
322
+ // to completion and preferred by preferCandidate. Once a real split has taken over,
323
+ // tighten exactly like before.
324
+ const slack = bestPath === wholeWordNode ? 1 : 0;
325
+ if (bestPath.c + slack < maxCost) {
326
+ maxCost = bestPath.c + slack;
327
+ }
328
+ }
329
+ const result = pathToWords(bestPath);
330
+ if (result.length === 2 && result.every((r) => !r.isFound) && regExFirstUpper.test(wholeWord.text)) {
331
+ return [wholeWord];
332
+ }
333
+ return result;
334
+ }
335
+ export const __testing__ = {
336
+ findNextWordText,
337
+ };
338
+ //# sourceMappingURL=wordSplitter.js.map
@@ -1,8 +1,8 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { extname } from 'node:path/posix';
3
3
  import { suite } from 'perf-insight';
4
- import { extractPossibleWordsFromTextOffset, extractWordsFromCode, extractWordsFromText, matchStringToTextOffset, splitCamelCaseWordWithOffset, textOffset, } from './text.js';
5
- import { regExWordsAndDigits } from './textRegex.js';
4
+ import { extractPossibleWordsFromTextOffset, extractWordsFromCode, extractWordsFromText, matchStringToTextOffset, splitCamelCaseWordWithOffset, textOffset, } from '../text.js';
5
+ import { regExWordsAndDigits } from '../textRegex.js';
6
6
  const regExpWord = /\b[\w\p{L}\p{M}]+\b/gu;
7
7
  suite('wordSplitter', async (test) => {
8
8
  const lines = await sampleLines();
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "access": "public",
5
5
  "provenance": true
6
6
  },
7
- "version": "10.2.0",
7
+ "version": "10.2.2",
8
8
  "description": "A library of useful functions used across various cspell tools.",
9
9
  "type": "module",
10
10
  "sideEffects": false,
@@ -75,22 +75,22 @@
75
75
  },
76
76
  "homepage": "https://github.com/streetsidesoftware/cspell/tree/main/packages/cspell-lib#readme",
77
77
  "dependencies": {
78
- "@cspell/cspell-bundled-dicts": "10.2.0",
79
- "@cspell/cspell-performance-monitor": "10.2.0",
80
- "@cspell/cspell-pipe": "10.2.0",
81
- "@cspell/cspell-resolver": "10.2.0",
82
- "@cspell/cspell-types": "10.2.0",
83
- "@cspell/dynamic-import": "10.2.0",
84
- "@cspell/filetypes": "10.2.0",
85
- "@cspell/rpc": "10.2.0",
86
- "@cspell/strong-weak-map": "10.2.0",
87
- "@cspell/url": "10.2.0",
88
- "cspell-config-lib": "10.2.0",
89
- "cspell-dictionary": "10.2.0",
90
- "cspell-glob": "10.2.0",
91
- "cspell-grammar": "10.2.0",
92
- "cspell-io": "10.2.0",
93
- "cspell-trie-lib": "10.2.0",
78
+ "@cspell/cspell-bundled-dicts": "10.2.2",
79
+ "@cspell/cspell-performance-monitor": "10.2.2",
80
+ "@cspell/cspell-pipe": "10.2.2",
81
+ "@cspell/cspell-resolver": "10.2.2",
82
+ "@cspell/cspell-types": "10.2.2",
83
+ "@cspell/dynamic-import": "10.2.2",
84
+ "@cspell/filetypes": "10.2.2",
85
+ "@cspell/rpc": "10.2.2",
86
+ "@cspell/strong-weak-map": "10.2.2",
87
+ "@cspell/url": "10.2.2",
88
+ "cspell-config-lib": "10.2.2",
89
+ "cspell-dictionary": "10.2.2",
90
+ "cspell-glob": "10.2.2",
91
+ "cspell-grammar": "10.2.2",
92
+ "cspell-io": "10.2.2",
93
+ "cspell-trie-lib": "10.2.2",
94
94
  "env-paths": "^4.0.0",
95
95
  "gensequence": "^8.0.8",
96
96
  "import-fresh": "^4.0.0",
@@ -103,7 +103,7 @@
103
103
  "node": ">=22.18.0"
104
104
  },
105
105
  "devDependencies": {
106
- "@cspell/cspell-tools": "10.2.0",
106
+ "@cspell/cspell-tools": "10.2.2",
107
107
  "@cspell/dict-cpp": "^7.1.0",
108
108
  "@cspell/dict-csharp": "^4.0.8",
109
109
  "@cspell/dict-css": "^4.1.2",
@@ -113,6 +113,7 @@
113
113
  "@cspell/dict-html": "^4.0.16",
114
114
  "@cspell/dict-nl-nl": "^2.4.2",
115
115
  "@cspell/dict-python": "^4.5.0",
116
+ "@cspell/dict-th-th": "^1.0.0",
116
117
  "@cspell/dictionary-bundler-plugin": "",
117
118
  "@types/configstore": "^6.0.2",
118
119
  "comment-json": "^5.0.0",
@@ -122,5 +123,5 @@
122
123
  "lorem-ipsum": "^3.0.0",
123
124
  "perf-insight": "^2.0.1"
124
125
  },
125
- "gitHead": "2d310afc22fa500b1be07e3c2e2d3897ec00daad"
126
+ "gitHead": "22fae7ac63d4330c52fe97eebee743e3541f2e36"
126
127
  }
@@ -1,331 +0,0 @@
1
- import { PairingHeap } from './PairingHeap.js';
2
- import { escapeRegEx } from './regexHelper.js';
3
- import { regExDanglingQuote, regExEscapeCharacters, regExNumericLiteral, regExPossibleWordBreaks, regExSplitWords, regExSplitWords2, regExTrailingEndings, regExWordsAndDigits, } from './textRegex.js';
4
- const ignoreBreak = Object.freeze([]);
5
- export function split(line, offset, isValidWord, options = {}) {
6
- const relWordToSplit = findNextWordText({ text: line.text, offset: offset - line.offset });
7
- const lineOffset = line.offset;
8
- const requested = new Map();
9
- const regExpIgnoreSegment = /^[-.+\d_eE'`\\\s]+$/;
10
- if (!relWordToSplit.text) {
11
- const text = rebaseTextOffset(relWordToSplit);
12
- return {
13
- line,
14
- offset,
15
- text: text,
16
- words: [],
17
- endOffset: text.offset + text.text.length,
18
- };
19
- }
20
- const lineSegment = {
21
- line,
22
- relStart: relWordToSplit.offset,
23
- relEnd: relWordToSplit.offset + relWordToSplit.text.length,
24
- };
25
- const possibleBreaks = generateWordBreaks(lineSegment, options);
26
- if (!possibleBreaks.length) {
27
- const text = rebaseTextOffset(relWordToSplit);
28
- return {
29
- line,
30
- offset,
31
- text: text,
32
- words: [{ ...text, isFound: isValidWord(text) }],
33
- endOffset: text.offset + text.text.length,
34
- };
35
- }
36
- function rebaseTextOffset(relText) {
37
- return {
38
- ...relText,
39
- offset: relText.offset + lineOffset,
40
- };
41
- }
42
- function has(word) {
43
- if (regExpIgnoreSegment.test(word.text)) {
44
- return true;
45
- }
46
- const i = word.offset;
47
- const j = word.text.length;
48
- let v = i + (j << 20);
49
- if (i < 1 << 20 && j < 1 << 11) {
50
- const b = requested.get(v);
51
- if (b !== undefined)
52
- return b;
53
- }
54
- else {
55
- v = -1;
56
- }
57
- const r = isValidWord(rebaseTextOffset(word));
58
- if (v >= 0) {
59
- requested.set(v, r);
60
- }
61
- return r;
62
- }
63
- // Add a dummy break at the end to avoid needing to check for last break.
64
- possibleBreaks.push({
65
- offset: lineSegment.relEnd,
66
- breaks: [ignoreBreak],
67
- });
68
- const result = {
69
- line,
70
- offset,
71
- text: rebaseTextOffset(relWordToSplit),
72
- words: splitIntoWords(lineSegment, possibleBreaks, has).map(rebaseTextOffset),
73
- endOffset: lineOffset + lineSegment.relEnd,
74
- };
75
- return result;
76
- }
77
- function findNextWordText({ text, offset }) {
78
- const reg = new RegExp(regExWordsAndDigits);
79
- reg.lastIndex = offset;
80
- const m = reg.exec(text);
81
- if (!m) {
82
- return {
83
- text: '',
84
- offset: offset + text.length,
85
- };
86
- }
87
- // Skip numeric literals.
88
- if (regExNumericLiteral.test(m[0])) {
89
- return findNextWordText({ text, offset: offset + m[0].length });
90
- }
91
- return {
92
- text: m[0],
93
- offset: m.index,
94
- };
95
- }
96
- function generateWordBreaks(line, options) {
97
- const camelBreaks = genWordBreakCamel(line);
98
- const symbolBreaks = genSymbolBreaks(line);
99
- const optionalBreaks = genOptionalWordBreaks(line, options.optionalWordBreakCharacters);
100
- return mergeSortedBreaks(...camelBreaks, ...symbolBreaks, ...optionalBreaks);
101
- }
102
- function offsetRegEx(reg, offset) {
103
- const r = new RegExp(reg);
104
- r.lastIndex = offset;
105
- return r;
106
- }
107
- function genWordBreakCamel(line) {
108
- const breaksCamel1 = [];
109
- const text = line.line.text.slice(0, line.relEnd);
110
- // lower,Upper: camelCase -> camel|Case
111
- for (const m of text.matchAll(offsetRegEx(regExSplitWords, line.relStart))) {
112
- if (m.index === undefined)
113
- break;
114
- const i = m.index + m[1].length;
115
- breaksCamel1.push({
116
- offset: m.index,
117
- breaks: [[i, i], ignoreBreak],
118
- });
119
- }
120
- const breaksCamel2 = [];
121
- // cspell:ignore ERRORC
122
- // Upper,Upper,lower: ERRORCodes -> ERROR|Codes, ERRORC|odes
123
- for (const m of text.matchAll(offsetRegEx(regExSplitWords2, line.relStart))) {
124
- if (m.index === undefined)
125
- break;
126
- const i = m.index + m[1].length;
127
- const j = i + m[3].length;
128
- breaksCamel2.push({
129
- offset: m.index,
130
- breaks: [[i, i], [j, j], ignoreBreak],
131
- });
132
- }
133
- return [breaksCamel1, breaksCamel2];
134
- }
135
- function calcBreaksForRegEx(line, reg, calcBreak) {
136
- const sb = [];
137
- const text = line.line.text.slice(0, line.relEnd);
138
- for (const m of text.matchAll(offsetRegEx(reg, line.relStart))) {
139
- const b = calcBreak(m);
140
- if (b) {
141
- sb.push(b);
142
- }
143
- }
144
- return sb;
145
- }
146
- function genOptionalWordBreaks(line, optionalBreakCharacters) {
147
- function calcBreaks(m) {
148
- const i = m.index;
149
- if (i === undefined)
150
- return;
151
- const j = i + m[0].length;
152
- return {
153
- offset: i,
154
- breaks: [
155
- [i, j], // Remove the characters
156
- ignoreBreak,
157
- ],
158
- };
159
- }
160
- const breaks = [
161
- calcBreaksForRegEx(line, regExDanglingQuote, calcBreaks),
162
- calcBreaksForRegEx(line, regExTrailingEndings, calcBreaks),
163
- ];
164
- if (optionalBreakCharacters) {
165
- const regex = new RegExp(`[${escapeRegEx(optionalBreakCharacters)}]`, 'gu');
166
- breaks.push(calcBreaksForRegEx(line, regex, calcBreaks));
167
- }
168
- return breaks;
169
- }
170
- function genSymbolBreaks(line) {
171
- function calcBreaks(m) {
172
- const i = m.index;
173
- if (i === undefined)
174
- return;
175
- const j = i + m[0].length;
176
- return {
177
- offset: i,
178
- breaks: [
179
- [i, j], // Remove the characters
180
- [i, i], // keep characters with word to right
181
- [j, j], // keep characters with word to left
182
- ignoreBreak,
183
- ],
184
- };
185
- }
186
- return [
187
- calcBreaksForRegEx(line, regExPossibleWordBreaks, calcBreaks),
188
- calcBreaksForRegEx(line, /\d+/g, calcBreaks),
189
- calcBreaksForRegEx(line, regExEscapeCharacters, calcBreaks),
190
- ];
191
- }
192
- function splitIntoWords(lineSeg, breaks, has) {
193
- const maxIndex = lineSeg.relEnd;
194
- const maxAttempts = 1000;
195
- const knownPathsByIndex = new Map();
196
- /**
197
- * Create a set of possible candidate to consider
198
- * @param p - prev candidate that lead to this one
199
- * @param i - offset within the string
200
- * @param bi - current index into the set of breaks
201
- * @param currentCost - current cost accrued
202
- */
203
- function makeCandidates(p, i, bi, currentCost) {
204
- const len = maxIndex;
205
- while (bi < breaks.length && breaks[bi].offset < i) {
206
- bi += 1;
207
- }
208
- if (bi >= breaks.length) {
209
- return [];
210
- }
211
- const br = breaks[bi];
212
- function c(bp) {
213
- const d = bp.length < 2 ? len - i : (bp[0] - i) * 0.5 + len - bp[1];
214
- const ec = currentCost + d;
215
- return {
216
- p,
217
- i,
218
- bi,
219
- bp,
220
- c: currentCost,
221
- ec,
222
- text: undefined,
223
- };
224
- }
225
- return br.breaks.map(c);
226
- }
227
- function checkTextOffset(text, offset) {
228
- const valid = has({ text, offset });
229
- return {
230
- text,
231
- offset,
232
- isFound: valid,
233
- };
234
- }
235
- function compare(a, b) {
236
- return a.ec - b.ec || b.i - a.i;
237
- }
238
- function pathToWords(node) {
239
- const results = [];
240
- for (let p = node; p; p = p.n) {
241
- if (p.text) {
242
- results.push(p.text);
243
- }
244
- }
245
- return results;
246
- }
247
- function addToKnownPaths(candidate, path) {
248
- for (let can = candidate; can !== undefined; can = can.p) {
249
- const t = can.text;
250
- const i = can.i;
251
- const cost = (!t || t.isFound ? 0 : t.text.length) + (path?.c ?? 0);
252
- const exitingPath = knownPathsByIndex.get(i);
253
- // Keep going only if this is a better candidate than the existing path
254
- if (exitingPath && exitingPath.c <= cost) {
255
- return undefined;
256
- }
257
- const node = {
258
- n: path,
259
- i,
260
- c: cost,
261
- text: t,
262
- };
263
- knownPathsByIndex.set(i, node);
264
- path = node;
265
- }
266
- return path;
267
- }
268
- let maxCost = lineSeg.relEnd - lineSeg.relStart;
269
- const candidates = new PairingHeap(compare);
270
- const text = lineSeg.line.text;
271
- candidates.append(makeCandidates(undefined, lineSeg.relStart, 0, 0));
272
- let attempts = 0;
273
- let bestPath;
274
- while (maxCost && candidates.length && attempts++ < maxAttempts) {
275
- /** Best Candidate Index */
276
- const best = candidates.dequeue();
277
- if (!best || best.c >= maxCost) {
278
- continue;
279
- }
280
- // Does it have a split?
281
- if (best.bp.length) {
282
- // yes
283
- const i = best.bp[0];
284
- const j = best.bp[1];
285
- const t = i > best.i ? checkTextOffset(text.slice(best.i, i), best.i) : undefined;
286
- const cost = !t || t.isFound ? 0 : t.text.length;
287
- const mc = maxIndex - j;
288
- best.c += cost;
289
- best.ec = best.c + mc;
290
- best.text = t;
291
- const possiblePath = knownPathsByIndex.get(j);
292
- if (possiblePath) {
293
- // We found a known apply to candidate
294
- const f = addToKnownPaths(best, possiblePath);
295
- bestPath = !bestPath || (f && f.c < bestPath.c) ? f : bestPath;
296
- }
297
- else if (best.c < maxCost) {
298
- const c = makeCandidates(t ? best : best.p, j, best.bi + 1, best.c);
299
- candidates.append(c);
300
- }
301
- }
302
- else {
303
- // It is a pass through
304
- const c = makeCandidates(best.p, best.i, best.bi + 1, best.c);
305
- candidates.append(c);
306
- if (!c.length) {
307
- const t = maxIndex > best.i ? checkTextOffset(text.slice(best.i, maxIndex), best.i) : undefined;
308
- const cost = !t || t.isFound ? 0 : t.text.length;
309
- best.c += cost;
310
- best.ec = best.c;
311
- best.text = t;
312
- const segText = t || best.p?.text || checkTextOffset('', best.i);
313
- const can = t ? { ...best, text: segText } : { ...best, ...best.p, text: segText };
314
- const f = addToKnownPaths(can, undefined);
315
- bestPath = !bestPath || (f && f.c < bestPath.c) ? f : bestPath;
316
- }
317
- }
318
- if (bestPath && bestPath.c < maxCost) {
319
- maxCost = bestPath.c;
320
- }
321
- }
322
- return pathToWords(bestPath);
323
- }
324
- function mergeSortedBreaks(...maps) {
325
- return maps.flat().sort((a, b) => a.offset - b.offset);
326
- }
327
- export const __testing__ = {
328
- generateWordBreaks,
329
- findNextWordText,
330
- };
331
- //# sourceMappingURL=wordSplitter.js.map