puzlink 0.1.0

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.
Files changed (233) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +35 -0
  3. package/dist/data/answerLengths.d.ts +10 -0
  4. package/dist/data/answerLengths.d.ts.map +1 -0
  5. package/dist/data/answerLengths.js +63 -0
  6. package/dist/data/answerLengths.js.map +1 -0
  7. package/dist/data/categories/compass.d.ts +3 -0
  8. package/dist/data/categories/compass.d.ts.map +1 -0
  9. package/dist/data/categories/compass.js +11 -0
  10. package/dist/data/categories/compass.js.map +1 -0
  11. package/dist/data/categories/countryAlpha2.d.ts +3 -0
  12. package/dist/data/categories/countryAlpha2.d.ts.map +1 -0
  13. package/dist/data/categories/countryAlpha2.js +252 -0
  14. package/dist/data/categories/countryAlpha2.js.map +1 -0
  15. package/dist/data/categories/countryAlpha3.d.ts +3 -0
  16. package/dist/data/categories/countryAlpha3.d.ts.map +1 -0
  17. package/dist/data/categories/countryAlpha3.js +252 -0
  18. package/dist/data/categories/countryAlpha3.js.map +1 -0
  19. package/dist/data/categories/daysOfTheWeek.d.ts +3 -0
  20. package/dist/data/categories/daysOfTheWeek.d.ts.map +1 -0
  21. package/dist/data/categories/daysOfTheWeek.js +10 -0
  22. package/dist/data/categories/daysOfTheWeek.js.map +1 -0
  23. package/dist/data/categories/elementSymbols.d.ts +3 -0
  24. package/dist/data/categories/elementSymbols.d.ts.map +1 -0
  25. package/dist/data/categories/elementSymbols.js +121 -0
  26. package/dist/data/categories/elementSymbols.js.map +1 -0
  27. package/dist/data/categories/greekLetters.d.ts +3 -0
  28. package/dist/data/categories/greekLetters.d.ts.map +1 -0
  29. package/dist/data/categories/greekLetters.js +27 -0
  30. package/dist/data/categories/greekLetters.js.map +1 -0
  31. package/dist/data/categories/months.d.ts +3 -0
  32. package/dist/data/categories/months.d.ts.map +1 -0
  33. package/dist/data/categories/months.js +15 -0
  34. package/dist/data/categories/months.js.map +1 -0
  35. package/dist/data/categories/natoAlphabet.d.ts +3 -0
  36. package/dist/data/categories/natoAlphabet.d.ts.map +1 -0
  37. package/dist/data/categories/natoAlphabet.js +29 -0
  38. package/dist/data/categories/natoAlphabet.js.map +1 -0
  39. package/dist/data/categories/numbers.d.ts +3 -0
  40. package/dist/data/categories/numbers.d.ts.map +1 -0
  41. package/dist/data/categories/numbers.js +16 -0
  42. package/dist/data/categories/numbers.js.map +1 -0
  43. package/dist/data/categories/romanNumerals.d.ts +3 -0
  44. package/dist/data/categories/romanNumerals.d.ts.map +1 -0
  45. package/dist/data/categories/romanNumerals.js +134 -0
  46. package/dist/data/categories/romanNumerals.js.map +1 -0
  47. package/dist/data/categories/solfege.d.ts +3 -0
  48. package/dist/data/categories/solfege.d.ts.map +1 -0
  49. package/dist/data/categories/solfege.js +11 -0
  50. package/dist/data/categories/solfege.js.map +1 -0
  51. package/dist/data/categories/usStateAbbreviations.d.ts +3 -0
  52. package/dist/data/categories/usStateAbbreviations.d.ts.map +1 -0
  53. package/dist/data/categories/usStateAbbreviations.js +53 -0
  54. package/dist/data/categories/usStateAbbreviations.js.map +1 -0
  55. package/dist/data/categories.d.ts +10 -0
  56. package/dist/data/categories.d.ts.map +1 -0
  57. package/dist/data/categories.js +31 -0
  58. package/dist/data/categories.js.map +1 -0
  59. package/dist/data/knownLogProbs.d.ts +6 -0
  60. package/dist/data/knownLogProbs.d.ts.map +1 -0
  61. package/dist/data/knownLogProbs.js +2975 -0
  62. package/dist/data/knownLogProbs.js.map +1 -0
  63. package/dist/data/morse.d.ts +2 -0
  64. package/dist/data/morse.d.ts.map +1 -0
  65. package/dist/data/morse.js +29 -0
  66. package/dist/data/morse.js.map +1 -0
  67. package/dist/data/scrabble.d.ts +2 -0
  68. package/dist/data/scrabble.d.ts.map +1 -0
  69. package/dist/data/scrabble.js +29 -0
  70. package/dist/data/scrabble.js.map +1 -0
  71. package/dist/features/index.d.ts +32 -0
  72. package/dist/features/index.d.ts.map +1 -0
  73. package/dist/features/index.js +79 -0
  74. package/dist/features/index.js.map +1 -0
  75. package/dist/features/letterCount.d.ts +7 -0
  76. package/dist/features/letterCount.d.ts.map +1 -0
  77. package/dist/features/letterCount.js +121 -0
  78. package/dist/features/letterCount.js.map +1 -0
  79. package/dist/features/letterSequence.d.ts +7 -0
  80. package/dist/features/letterSequence.d.ts.map +1 -0
  81. package/dist/features/letterSequence.js +155 -0
  82. package/dist/features/letterSequence.js.map +1 -0
  83. package/dist/features/logProbCache.d.ts +16 -0
  84. package/dist/features/logProbCache.d.ts.map +1 -0
  85. package/dist/features/logProbCache.js +36 -0
  86. package/dist/features/logProbCache.js.map +1 -0
  87. package/dist/features/other.d.ts +4 -0
  88. package/dist/features/other.d.ts.map +1 -0
  89. package/dist/features/other.js +190 -0
  90. package/dist/features/other.js.map +1 -0
  91. package/dist/features/substring.d.ts +3 -0
  92. package/dist/features/substring.d.ts.map +1 -0
  93. package/dist/features/substring.js +146 -0
  94. package/dist/features/substring.js.map +1 -0
  95. package/dist/features/wordplay.d.ts +7 -0
  96. package/dist/features/wordplay.d.ts.map +1 -0
  97. package/dist/features/wordplay.js +387 -0
  98. package/dist/features/wordplay.js.map +1 -0
  99. package/dist/index.d.ts +4 -0
  100. package/dist/index.d.ts.map +1 -0
  101. package/dist/index.js +3 -0
  102. package/dist/index.js.map +1 -0
  103. package/dist/lib/affixDistribution.d.ts +26 -0
  104. package/dist/lib/affixDistribution.d.ts.map +1 -0
  105. package/dist/lib/affixDistribution.js +105 -0
  106. package/dist/lib/affixDistribution.js.map +1 -0
  107. package/dist/lib/counter.d.ts +23 -0
  108. package/dist/lib/counter.d.ts.map +1 -0
  109. package/dist/lib/counter.js +55 -0
  110. package/dist/lib/counter.js.map +1 -0
  111. package/dist/lib/distribution.d.ts +40 -0
  112. package/dist/lib/distribution.d.ts.map +1 -0
  113. package/dist/lib/distribution.js +176 -0
  114. package/dist/lib/distribution.js.map +1 -0
  115. package/dist/lib/lengthDistribution.d.ts +30 -0
  116. package/dist/lib/lengthDistribution.d.ts.map +1 -0
  117. package/dist/lib/lengthDistribution.js +137 -0
  118. package/dist/lib/lengthDistribution.js.map +1 -0
  119. package/dist/lib/letterBitset.d.ts +49 -0
  120. package/dist/lib/letterBitset.d.ts.map +1 -0
  121. package/dist/lib/letterBitset.js +101 -0
  122. package/dist/lib/letterBitset.js.map +1 -0
  123. package/dist/lib/letterDistribution.d.ts +60 -0
  124. package/dist/lib/letterDistribution.d.ts.map +1 -0
  125. package/dist/lib/letterDistribution.js +230 -0
  126. package/dist/lib/letterDistribution.js.map +1 -0
  127. package/dist/lib/letterIndices.d.ts +13 -0
  128. package/dist/lib/letterIndices.d.ts.map +1 -0
  129. package/dist/lib/letterIndices.js +41 -0
  130. package/dist/lib/letterIndices.js.map +1 -0
  131. package/dist/lib/logCounter.d.ts +23 -0
  132. package/dist/lib/logCounter.d.ts.map +1 -0
  133. package/dist/lib/logCounter.js +49 -0
  134. package/dist/lib/logCounter.js.map +1 -0
  135. package/dist/lib/logNum.d.ts +36 -0
  136. package/dist/lib/logNum.d.ts.map +1 -0
  137. package/dist/lib/logNum.js +193 -0
  138. package/dist/lib/logNum.js.map +1 -0
  139. package/dist/lib/memoize.d.ts +5 -0
  140. package/dist/lib/memoize.d.ts.map +1 -0
  141. package/dist/lib/memoize.js +104 -0
  142. package/dist/lib/memoize.js.map +1 -0
  143. package/dist/lib/util.d.ts +30 -0
  144. package/dist/lib/util.d.ts.map +1 -0
  145. package/dist/lib/util.js +111 -0
  146. package/dist/lib/util.js.map +1 -0
  147. package/dist/lib/wordlist.d.ts +66 -0
  148. package/dist/lib/wordlist.d.ts.map +1 -0
  149. package/dist/lib/wordlist.js +166 -0
  150. package/dist/lib/wordlist.js.map +1 -0
  151. package/dist/linkers/index.d.ts +34 -0
  152. package/dist/linkers/index.d.ts.map +1 -0
  153. package/dist/linkers/index.js +25 -0
  154. package/dist/linkers/index.js.map +1 -0
  155. package/dist/linkers/indexing.d.ts +5 -0
  156. package/dist/linkers/indexing.d.ts.map +1 -0
  157. package/dist/linkers/indexing.js +152 -0
  158. package/dist/linkers/indexing.js.map +1 -0
  159. package/dist/linkers/length.d.ts +5 -0
  160. package/dist/linkers/length.d.ts.map +1 -0
  161. package/dist/linkers/length.js +101 -0
  162. package/dist/linkers/length.js.map +1 -0
  163. package/dist/linkers/letterDistribution.d.ts +4 -0
  164. package/dist/linkers/letterDistribution.d.ts.map +1 -0
  165. package/dist/linkers/letterDistribution.js +46 -0
  166. package/dist/linkers/letterDistribution.js.map +1 -0
  167. package/dist/linkers/other.d.ts +5 -0
  168. package/dist/linkers/other.d.ts.map +1 -0
  169. package/dist/linkers/other.js +90 -0
  170. package/dist/linkers/other.js.map +1 -0
  171. package/dist/parse.d.ts +8 -0
  172. package/dist/parse.d.ts.map +1 -0
  173. package/dist/parse.js +23 -0
  174. package/dist/parse.js.map +1 -0
  175. package/dist/puzlink.d.ts +84 -0
  176. package/dist/puzlink.d.ts.map +1 -0
  177. package/dist/puzlink.js +59 -0
  178. package/dist/puzlink.js.map +1 -0
  179. package/package.json +57 -0
  180. package/src/data/answerLengths.ts +63 -0
  181. package/src/data/categories/README.md +3 -0
  182. package/src/data/categories/compass.ts +1 -0
  183. package/src/data/categories/countryAlpha2.ts +251 -0
  184. package/src/data/categories/countryAlpha3.ts +251 -0
  185. package/src/data/categories/daysOfTheWeek.ts +1 -0
  186. package/src/data/categories/elementSymbols.ts +120 -0
  187. package/src/data/categories/greekLetters.ts +26 -0
  188. package/src/data/categories/months.ts +14 -0
  189. package/src/data/categories/natoAlphabet.ts +28 -0
  190. package/src/data/categories/numbers.ts +15 -0
  191. package/src/data/categories/romanNumerals.ts +133 -0
  192. package/src/data/categories/solfege.ts +1 -0
  193. package/src/data/categories/txt/compass.txt +8 -0
  194. package/src/data/categories/txt/daysOfTheWeek.txt +7 -0
  195. package/src/data/categories/txt/elementSymbols.txt +118 -0
  196. package/src/data/categories/txt/greekLetters.txt +24 -0
  197. package/src/data/categories/txt/months.txt +12 -0
  198. package/src/data/categories/txt/natoAlphabet.txt +26 -0
  199. package/src/data/categories/txt/numbers.txt +13 -0
  200. package/src/data/categories/txt/solfege.txt +8 -0
  201. package/src/data/categories/txt/usStateAbbreviations.txt +50 -0
  202. package/src/data/categories/usStateAbbreviations.ts +52 -0
  203. package/src/data/categories.ts +42 -0
  204. package/src/data/knownLogProbs.ts +2992 -0
  205. package/src/data/morse.ts +28 -0
  206. package/src/data/scrabble.ts +28 -0
  207. package/src/features/index.ts +120 -0
  208. package/src/features/letterCount.ts +174 -0
  209. package/src/features/letterSequence.ts +222 -0
  210. package/src/features/logProbCache.ts +48 -0
  211. package/src/features/other.ts +214 -0
  212. package/src/features/substring.ts +173 -0
  213. package/src/features/wordplay.ts +428 -0
  214. package/src/index.ts +3 -0
  215. package/src/lib/affixDistribution.ts +70 -0
  216. package/src/lib/counter.ts +71 -0
  217. package/src/lib/distribution.ts +162 -0
  218. package/src/lib/lengthDistribution.ts +108 -0
  219. package/src/lib/letterBitset.ts +123 -0
  220. package/src/lib/letterDistribution.ts +236 -0
  221. package/src/lib/letterIndices.ts +51 -0
  222. package/src/lib/logCounter.ts +74 -0
  223. package/src/lib/logNum.ts +193 -0
  224. package/src/lib/memoize.ts +136 -0
  225. package/src/lib/testUtils.ts +1 -0
  226. package/src/lib/util.ts +150 -0
  227. package/src/lib/wordlist.ts +162 -0
  228. package/src/linkers/index.ts +56 -0
  229. package/src/linkers/indexing.ts +194 -0
  230. package/src/linkers/length.ts +122 -0
  231. package/src/linkers/other.ts +117 -0
  232. package/src/parse.ts +20 -0
  233. package/src/puzlink.ts +141 -0
@@ -0,0 +1,214 @@
1
+ import { morseLetter } from "../data/morse.js";
2
+ import { scrabbleLetterScore } from "../data/scrabble.js";
3
+ import { VOWELS } from "../lib/letterDistribution.js";
4
+ import {
5
+ enumerate,
6
+ interval,
7
+ mapProduct,
8
+ printIndexSlug,
9
+ windows,
10
+ } from "../lib/util.js";
11
+ import type { Feature } from "./index.js";
12
+
13
+ /**
14
+ * Returns the number of diffs between the first half and the last half
15
+ * reversed. Palindromes have a mirrorDiff of 0.
16
+ */
17
+ function mirrorDiff(slug: string): number {
18
+ let mismatches = 0;
19
+ for (let i = 0, j = slug.length - 1; i < j; i++, j--) {
20
+ if (slug[i] !== slug[j]) {
21
+ mismatches++;
22
+ }
23
+ }
24
+ return mismatches;
25
+ }
26
+
27
+ function palindrome(): Feature {
28
+ return {
29
+ name: "is palindrome",
30
+ property: (slug) => {
31
+ if (mirrorDiff(slug) !== 0) return null;
32
+ const letters = Array.from(slug);
33
+ if (letters.length % 2 === 0) {
34
+ letters.splice(letters.length / 2, 0, "|");
35
+ } else {
36
+ letters.splice(Math.floor(letters.length / 2) + 1, 0, "|");
37
+ letters.splice(Math.floor(letters.length / 2) - 1, 0, "|");
38
+ }
39
+ return letters.join("");
40
+ },
41
+ };
42
+ }
43
+
44
+ function changeToPalindrome(): Feature {
45
+ return {
46
+ name: "is one change to a palindrome",
47
+ property: (slug) => {
48
+ if (mirrorDiff(slug) !== 1) {
49
+ return null;
50
+ }
51
+ const letters = Array.from(slug);
52
+ if (letters.length % 2 === 0) {
53
+ letters.splice(letters.length / 2, 0, "|");
54
+ } else {
55
+ letters.splice(Math.floor(letters.length / 2) + 1, 0, "|");
56
+ letters.splice(Math.floor(letters.length / 2) - 1, 0, "|");
57
+ }
58
+ return letters.join("");
59
+ },
60
+ };
61
+ }
62
+
63
+ function deleteToPalindrome(): Feature {
64
+ return {
65
+ name: "is one deletion to a palindrome",
66
+ property: (slug) => {
67
+ for (const i of interval(0, slug.length - 1)) {
68
+ const candidate = `${slug.slice(0, i)}${slug.slice(i + 1)}`;
69
+ if (mirrorDiff(candidate) === 0) {
70
+ return `${slug} delete ${slug[i]!} = ${candidate}`;
71
+ }
72
+ }
73
+ return null;
74
+ },
75
+ };
76
+ }
77
+
78
+ function hill(): Feature {
79
+ return {
80
+ name: "is a hill",
81
+ property: (slug) => {
82
+ const codes = Array.from(slug, (letter) => letter.charCodeAt(0));
83
+ const max = Math.max(...codes);
84
+ const peak = codes.findIndex((code) => code === max);
85
+ for (const [a, b] of windows(codes.slice(0, peak), 2)) {
86
+ if (a > b) return null;
87
+ }
88
+ for (const [a, b] of windows(codes.slice(peak), 2)) {
89
+ if (a < b) return null;
90
+ }
91
+
92
+ const letters = Array.from(slug);
93
+ const peakEnd = codes.findLastIndex((code) => code === max);
94
+ letters.splice(peak, 0, "<");
95
+ letters.splice(peakEnd + 2, 0, ">");
96
+
97
+ return letters.join("");
98
+ },
99
+ };
100
+ }
101
+
102
+ function valley(): Feature {
103
+ return {
104
+ name: "is a valley",
105
+ property: (slug) => {
106
+ const codes = Array.from(slug, (letter) => letter.charCodeAt(0));
107
+ const min = Math.min(...codes);
108
+ const trough = codes.findIndex((code) => code === min);
109
+ for (const [a, b] of windows(codes.slice(0, trough), 2)) {
110
+ if (a < b) return null;
111
+ }
112
+ for (const [a, b] of windows(codes.slice(trough), 2)) {
113
+ if (a > b) return null;
114
+ }
115
+
116
+ const letters = Array.from(slug);
117
+ const troughEnd = codes.findLastIndex((code) => code === min);
118
+ letters.splice(trough, 0, ">");
119
+ letters.splice(troughEnd + 2, 0, "<");
120
+
121
+ return letters.join("");
122
+ },
123
+ };
124
+ }
125
+
126
+ function alternatingVowels(): Feature {
127
+ return {
128
+ name: "alternates vowels and consonants",
129
+ property: (slug) => {
130
+ let wasVowel = VOWELS.includes(slug[0]!);
131
+ const vowelIndices = wasVowel ? [0] : [];
132
+ for (const [i, letter] of enumerate(slug.slice(1))) {
133
+ const isVowel = VOWELS.includes(letter);
134
+ if (isVowel === wasVowel) {
135
+ return null;
136
+ }
137
+ if (isVowel) {
138
+ vowelIndices.push(i + 1);
139
+ }
140
+ wasVowel = isVowel;
141
+ }
142
+ return printIndexSlug(slug, vowelIndices);
143
+ },
144
+ };
145
+ }
146
+
147
+ function scrabbleScore(n: number): Feature {
148
+ return {
149
+ name: `has scrabble score ${n.toString()}`,
150
+ property: (slug) => {
151
+ const points = Array.from(slug, (letter) => scrabbleLetterScore[letter]!);
152
+ const score = points.reduce((a, b) => a + b, 0);
153
+ return score === n
154
+ ? `${slug}: ${points.join("+")} = ${n.toString()}`
155
+ : null;
156
+ },
157
+ };
158
+ }
159
+
160
+ function morseEqual(): Feature {
161
+ return {
162
+ name: "has morse code with equal dot/dash count",
163
+ property: (slug) => {
164
+ const morse = Array.from(slug, (letter) => morseLetter[letter]!).join(
165
+ " ",
166
+ );
167
+ const dotCount = Array.from(morse).filter((c) => c === ".").length;
168
+ const dashCount = Array.from(morse).filter((c) => c === "-").length;
169
+ return dotCount === dashCount
170
+ ? `${slug} has ${dotCount.toString()} dots/dashes: ${morse}`
171
+ : null;
172
+ },
173
+ };
174
+ }
175
+
176
+ function morseCount(kind: { name: string; chars: string }, n: number): Feature {
177
+ return {
178
+ name: `has morse code with ${n.toString()} ${kind.name}`,
179
+ property: (slug) => {
180
+ const morse = Array.from(slug, (letter) => morseLetter[letter]!).join(
181
+ " ",
182
+ );
183
+ const count = Array.from(morse).filter((c) =>
184
+ kind.chars.includes(c),
185
+ ).length;
186
+ return count === n
187
+ ? `${slug} has ${count.toString()} ${kind.name}: ${morse}`
188
+ : null;
189
+ },
190
+ };
191
+ }
192
+
193
+ /** Features that don't fit elsewhere. */
194
+ export function otherFeatures(): Feature[] {
195
+ return [
196
+ palindrome(),
197
+ changeToPalindrome(),
198
+ deleteToPalindrome(),
199
+ hill(),
200
+ valley(),
201
+ alternatingVowels(),
202
+ ...mapProduct(scrabbleScore, interval(1, 40)),
203
+ morseEqual(),
204
+ ...mapProduct(
205
+ morseCount,
206
+ [
207
+ { name: "dots", chars: "." },
208
+ { name: "dashes", chars: "-" },
209
+ { name: "dots and dashes", chars: ".-" },
210
+ ],
211
+ interval(1, 40),
212
+ ),
213
+ ];
214
+ }
@@ -0,0 +1,173 @@
1
+ import {
2
+ categories,
3
+ longCategories,
4
+ shortCategories,
5
+ type Category,
6
+ } from "../data/categories.js";
7
+ import { LetterBitsets } from "../lib/letterBitset.js";
8
+ import { capitalizeAt, interval, mapProduct } from "../lib/util.js";
9
+ import type { Feature } from "./index.js";
10
+
11
+ // TODO(maybe): looking for long substrings that are words; we need a good heuristic for these, because we e.g. don't want to report that UNDERSCORE is a substring of UNDERSCORES, but we do want to report STRANGE is a substring of FOREST RANGER
12
+
13
+ function containsOne(category: Category): Feature {
14
+ const regex = new RegExp(category.items.join("|"));
15
+ return {
16
+ name: `has ${category.name} substring`,
17
+ property: (slug) => {
18
+ const match = regex.exec(slug);
19
+ if (!match) {
20
+ return null;
21
+ }
22
+ const indices = interval(match.index, match.index + match[0].length - 1);
23
+ return `${slug} contains ${match[0]}: ${capitalizeAt(slug, indices)}`;
24
+ },
25
+ };
26
+ }
27
+
28
+ function containsTimes(
29
+ category: Category,
30
+ times: number,
31
+ strict: boolean,
32
+ ): Feature {
33
+ const regex = new RegExp(category.items.join("|"), "g");
34
+ return {
35
+ name: strict
36
+ ? `has ${category.name} substring, ${times.toString()} times`
37
+ : `has ${category.name} substring, at least ${times.toString()} times`,
38
+ property: (slug) => {
39
+ const matches = [];
40
+
41
+ for (const match of slug.matchAll(regex)) {
42
+ if (strict && matches.length >= times) {
43
+ return null;
44
+ }
45
+ matches.push(match);
46
+ }
47
+ if (strict ? matches.length !== times : matches.length < times) {
48
+ return null;
49
+ }
50
+
51
+ const indices = matches.flatMap((m) =>
52
+ interval(m.index, m.index + m[0].length - 1),
53
+ );
54
+ return `${slug} contains ${matches.map((m) => m[0]).join(", ")}: ${capitalizeAt(slug, indices)}`;
55
+ },
56
+ };
57
+ }
58
+
59
+ function startsWithOne(category: Category): Feature {
60
+ const regex = new RegExp(`^(${category.items.join("|")})`);
61
+ return {
62
+ name: `starts with ${category.name}`,
63
+ property: (slug) => {
64
+ const match = regex.exec(slug);
65
+ if (!match) {
66
+ return null;
67
+ }
68
+ return `${slug} starts with ${match[0]}`;
69
+ },
70
+ };
71
+ }
72
+
73
+ function endsWithOne(category: Category): Feature {
74
+ const regex = new RegExp(`(${category.items.join("|")})$`);
75
+ return {
76
+ name: `ends with ${category.name}`,
77
+ property: (slug) => {
78
+ const match = regex.exec(slug);
79
+ if (!match) {
80
+ return null;
81
+ }
82
+ return `${slug} ends with ${match[0]}`;
83
+ },
84
+ };
85
+ }
86
+
87
+ function canBeBrokenInto(category: Category): Feature {
88
+ const regex = new RegExp(`^(${category.items.join("|")})+$`);
89
+ return {
90
+ name: `can be broken into ${category.name}`,
91
+ property: (slug) => {
92
+ let match = regex.exec(slug);
93
+ if (!match) {
94
+ return null;
95
+ }
96
+ const parts = [];
97
+ let remaining = slug;
98
+ while (match !== null) {
99
+ const suffix = match[1]!;
100
+ parts.push(suffix);
101
+ remaining = remaining.slice(0, -suffix.length);
102
+ match = regex.exec(remaining);
103
+ }
104
+ return `${slug} = ${parts.reverse().join(" ")}`;
105
+ },
106
+ };
107
+ }
108
+
109
+ function hasAnagram(category: Category): Feature {
110
+ const bitsets = new LetterBitsets(category.items);
111
+ return {
112
+ name: `has ${category.name} anagram substring`,
113
+ property: (slug) => {
114
+ const match = Array.from(bitsets.matchSubstring(slug));
115
+ if (match.length !== 1) {
116
+ return null;
117
+ }
118
+ const {
119
+ start,
120
+ words: [word],
121
+ } = match[0]!;
122
+ return `${slug} has anagram ${word!}: ${capitalizeAt(slug, interval(start, start + word!.length - 1))}`;
123
+ },
124
+ };
125
+ }
126
+
127
+ function hasChangeAny(category: Category): Feature {
128
+ const changes = category.items.map((item) => ({
129
+ item,
130
+ regex: new RegExp(
131
+ interval(0, item.length)
132
+ .map((i) => `${item.slice(0, i)}.${item.slice(i + 1)}`)
133
+ .join("|"),
134
+ ),
135
+ }));
136
+ const regex = new RegExp(`(${changes.map((c) => c.regex.source).join("|")})`);
137
+ return {
138
+ name: `has ${category.name} change 1 substring`,
139
+ property: (slug) => {
140
+ const match = regex.exec(slug);
141
+ if (!match) {
142
+ return null;
143
+ }
144
+ // Check there's *exactly* one:
145
+ let matched: string | null = null;
146
+ for (const { item, regex } of changes) {
147
+ if (regex.exec(slug)) {
148
+ if (matched !== null) {
149
+ return null;
150
+ }
151
+ matched = item;
152
+ }
153
+ }
154
+ const { index } = match;
155
+ return `${slug} has change 1 of ${matched!}: ${capitalizeAt(slug, interval(index, index + matched!.length - 1))}`;
156
+ },
157
+ };
158
+ }
159
+
160
+ export function substringFeatures(): Feature[] {
161
+ return [
162
+ ...mapProduct(containsOne, categories),
163
+ ...mapProduct(containsTimes, shortCategories, interval(2, 5), [
164
+ true,
165
+ false,
166
+ ]),
167
+ ...mapProduct(startsWithOne, categories),
168
+ ...mapProduct(endsWithOne, categories),
169
+ ...mapProduct(canBeBrokenInto, shortCategories),
170
+ ...mapProduct(hasAnagram, longCategories),
171
+ ...mapProduct(hasChangeAny, longCategories),
172
+ ];
173
+ }