2 MIT License http://www.opensource.org/licenses/mit-license.php
3 Author Tobias Koppers @sokra
8 // Simulations show these probabilities for a single change
9 // 93.1% that one group is invalidated
10 // 4.8% that two groups are invalidated
11 // 1.1% that 3 groups are invalidated
12 // 0.1% that 4 or more groups are invalidated
14 // And these for removing/adding 10 lexically adjacent files
15 // 64.5% that one group is invalidated
16 // 24.8% that two groups are invalidated
17 // 7.8% that 3 groups are invalidated
18 // 2.7% that 4 or more groups are invalidated
20 // And these for removing/adding 3 random files
21 // 0% that one group is invalidated
22 // 3.7% that two groups are invalidated
23 // 80.8% that 3 groups are invalidated
24 // 12.3% that 4 groups are invalidated
25 // 3.2% that 5 or more groups are invalidated
29 * @param {string} a key
30 * @param {string} b key
31 * @returns {number} the similarity as number
33 const similarity = (a, b) => {
34 const l = Math.min(a.length, b.length);
36 for (let i = 0; i < l; i++) {
37 const ca = a.charCodeAt(i);
38 const cb = b.charCodeAt(i);
39 dist += Math.max(0, 10 - Math.abs(ca - cb));
45 * @param {string} a key
46 * @param {string} b key
47 * @param {Set<string>} usedNames set of already used names
48 * @returns {string} the common part and a single char for the difference
50 const getName = (a, b, usedNames) => {
51 const l = Math.min(a.length, b.length);
54 if (a.charCodeAt(i) !== b.charCodeAt(i)) {
61 const name = a.slice(0, i);
62 const lowerName = name.toLowerCase();
63 if (!usedNames.has(lowerName)) {
64 usedNames.add(lowerName);
69 // names always contain a hash, so this is always unique
70 // we don't need to check usedNames nor add it
75 * @param {Record<string, number>} total total size
76 * @param {Record<string, number>} size single size
79 const addSizeTo = (total, size) => {
80 for (const key of Object.keys(size)) {
81 total[key] = (total[key] || 0) + size[key];
86 * @param {Record<string, number>} total total size
87 * @param {Record<string, number>} size single size
90 const subtractSizeFrom = (total, size) => {
91 for (const key of Object.keys(size)) {
92 total[key] -= size[key];
97 * @param {Iterable<Node>} nodes some nodes
98 * @returns {Record<string, number>} total size
100 const sumSize = nodes => {
101 const sum = Object.create(null);
102 for (const node of nodes) {
103 addSizeTo(sum, node.size);
108 const isTooBig = (size, maxSize) => {
109 for (const key of Object.keys(size)) {
111 if (s === 0) continue;
112 const maxSizeValue = maxSize[key];
113 if (typeof maxSizeValue === "number") {
114 if (s > maxSizeValue) return true;
120 const isTooSmall = (size, minSize) => {
121 for (const key of Object.keys(size)) {
123 if (s === 0) continue;
124 const minSizeValue = minSize[key];
125 if (typeof minSizeValue === "number") {
126 if (s < minSizeValue) return true;
132 const getTooSmallTypes = (size, minSize) => {
133 const types = new Set();
134 for (const key of Object.keys(size)) {
136 if (s === 0) continue;
137 const minSizeValue = minSize[key];
138 if (typeof minSizeValue === "number") {
139 if (s < minSizeValue) types.add(key);
145 const getNumberOfMatchingSizeTypes = (size, types) => {
147 for (const key of Object.keys(size)) {
148 if (size[key] !== 0 && types.has(key)) i++;
153 const selectiveSizeSum = (size, types) => {
155 for (const key of Object.keys(size)) {
156 if (size[key] !== 0 && types.has(key)) sum += size[key];
166 * @param {T} item item
167 * @param {string} key key
168 * @param {Record<string, number>} size size
170 constructor(item, key, size) {
182 * @param {Node<T>[]} nodes nodes
183 * @param {number[]} similarities similarities between the nodes (length = nodes.length - 1)
184 * @param {Record<string, number>=} size size of the group
186 constructor(nodes, similarities, size) {
188 this.similarities = similarities;
189 this.size = size || sumSize(nodes);
190 /** @type {string} */
191 this.key = undefined;
195 * @param {function(Node): boolean} filter filter function
196 * @returns {Node[]} removed nodes
200 const newSimilarities = [];
201 const resultNodes = [];
203 for (let i = 0; i < this.nodes.length; i++) {
204 const node = this.nodes[i];
206 resultNodes.push(node);
208 if (newNodes.length > 0) {
209 newSimilarities.push(
210 lastNode === this.nodes[i - 1]
211 ? this.similarities[i - 1]
212 : similarity(lastNode.key, node.key)
219 if (resultNodes.length === this.nodes.length) return undefined;
220 this.nodes = newNodes;
221 this.similarities = newSimilarities;
222 this.size = sumSize(newNodes);
228 * @param {Iterable<Node>} nodes nodes
229 * @returns {number[]} similarities
231 const getSimilarities = nodes => {
232 // calculate similarities between lexically adjacent nodes
233 /** @type {number[]} */
234 const similarities = [];
235 let last = undefined;
236 for (const node of nodes) {
237 if (last !== undefined) {
238 similarities.push(similarity(last.key, node.key));
247 * @typedef {Object} GroupedItems<T>
248 * @property {string} key
249 * @property {T[]} items
250 * @property {Record<string, number>} size
255 * @typedef {Object} Options
256 * @property {Record<string, number>} maxSize maximum size of a group
257 * @property {Record<string, number>} minSize minimum size of a group (preferred over maximum size)
258 * @property {Iterable<T>} items a list of items
259 * @property {function(T): Record<string, number>} getSize function to get size of an item
260 * @property {function(T): string} getKey function to get the key of an item
265 * @param {Options<T>} options options object
266 * @returns {GroupedItems<T>[]} grouped items
268 module.exports = ({ maxSize, minSize, items, getSize, getKey }) => {
269 /** @type {Group<T>[]} */
272 const nodes = Array.from(
274 item => new Node(item, getKey(item), getSize(item))
277 /** @type {Node<T>[]} */
278 const initialNodes = [];
280 // lexically ordering of keys
281 nodes.sort((a, b) => {
282 if (a.key < b.key) return -1;
283 if (a.key > b.key) return 1;
287 // return nodes bigger than maxSize directly as group
288 // But make sure that minSize is not violated
289 for (const node of nodes) {
290 if (isTooBig(node.size, maxSize) && !isTooSmall(node.size, minSize)) {
291 result.push(new Group([node], []));
293 initialNodes.push(node);
297 if (initialNodes.length > 0) {
298 const initialGroup = new Group(initialNodes, getSimilarities(initialNodes));
300 const removeProblematicNodes = (group, consideredSize = group.size) => {
301 const problemTypes = getTooSmallTypes(consideredSize, minSize);
302 if (problemTypes.size > 0) {
303 // We hit an edge case where the working set is already smaller than minSize
304 // We merge problematic nodes with the smallest result node to keep minSize intact
305 const problemNodes = group.popNodes(
306 n => getNumberOfMatchingSizeTypes(n.size, problemTypes) > 0
308 if (problemNodes === undefined) return false;
309 // Only merge it with result nodes that have the problematic size type
310 const possibleResultGroups = result.filter(
311 n => getNumberOfMatchingSizeTypes(n.size, problemTypes) > 0
313 if (possibleResultGroups.length > 0) {
314 const bestGroup = possibleResultGroups.reduce((min, group) => {
315 const minMatches = getNumberOfMatchingSizeTypes(min, problemTypes);
316 const groupMatches = getNumberOfMatchingSizeTypes(
320 if (minMatches !== groupMatches)
321 return minMatches < groupMatches ? group : min;
323 selectiveSizeSum(min.size, problemTypes) >
324 selectiveSizeSum(group.size, problemTypes)
329 for (const node of problemNodes) bestGroup.nodes.push(node);
330 bestGroup.nodes.sort((a, b) => {
331 if (a.key < b.key) return -1;
332 if (a.key > b.key) return 1;
336 // There are no other nodes with the same size types
337 // We create a new group and have to accept that it's smaller than minSize
338 result.push(new Group(problemNodes, null));
346 if (initialGroup.nodes.length > 0) {
347 const queue = [initialGroup];
349 while (queue.length) {
350 const group = queue.pop();
351 // only groups bigger than maxSize need to be splitted
352 if (!isTooBig(group.size, maxSize)) {
356 // If the group is already too small
357 // we try to work only with the unproblematic nodes
358 if (removeProblematicNodes(group)) {
359 // This changed something, so we try this group again
364 // find unsplittable area from left and right
365 // going minSize from left and right
366 // at least one node need to be included otherwise we get stuck
368 let leftSize = Object.create(null);
369 addSizeTo(leftSize, group.nodes[0].size);
370 while (left < group.nodes.length && isTooSmall(leftSize, minSize)) {
371 addSizeTo(leftSize, group.nodes[left].size);
374 let right = group.nodes.length - 2;
375 let rightSize = Object.create(null);
376 addSizeTo(rightSize, group.nodes[group.nodes.length - 1].size);
377 while (right >= 0 && isTooSmall(rightSize, minSize)) {
378 addSizeTo(rightSize, group.nodes[right].size);
383 // [ O O O ] O O O [ O O O ]
384 // ^^^^^^^^^ leftSize
385 // rightSize ^^^^^^^^^
386 // leftSize > minSize
387 // rightSize > minSize
389 // Perfect split: [ O O O ] [ O O O ]
390 // right === left - 1
392 if (left - 1 > right) {
393 // We try to remove some problematic nodes to "fix" that
395 if (right < group.nodes.length - left) {
396 subtractSizeFrom(rightSize, group.nodes[right + 1].size);
397 prevSize = rightSize;
399 subtractSizeFrom(leftSize, group.nodes[left - 1].size);
402 if (removeProblematicNodes(group, prevSize)) {
403 // This changed something, so we try this group again
407 // can't split group while holding minSize
408 // because minSize is preferred of maxSize we return
409 // the problematic nodes as result here even while it's too big
410 // To avoid this make sure maxSize > minSize * 3
415 // when there is a area between left and right
416 // we look for best split point
417 // we split at the minimum similarity
418 // here key space is separated the most
419 // But we also need to make sure to not create too small groups
421 let bestSimilarity = Infinity;
423 let rightSize = sumSize(group.nodes.slice(pos));
426 // [ O O O ] O O O [ O O O ]
427 // ^^^^^^^^^ leftSize
428 // rightSize ^^^^^^^^^^^^^^^
430 while (pos <= right + 1) {
431 const similarity = group.similarities[pos - 1];
433 similarity < bestSimilarity &&
434 !isTooSmall(leftSize, minSize) &&
435 !isTooSmall(rightSize, minSize)
438 bestSimilarity = similarity;
440 addSizeTo(leftSize, group.nodes[pos].size);
441 subtractSizeFrom(rightSize, group.nodes[pos].size);
446 // but if that assumption is wrong
447 // fallback to a big group
455 // create two new groups for left and right area
457 const rightNodes = [group.nodes[right + 1]];
458 /** @type {number[]} */
459 const rightSimilarities = [];
460 for (let i = right + 2; i < group.nodes.length; i++) {
461 rightSimilarities.push(group.similarities[i - 1]);
462 rightNodes.push(group.nodes[i]);
464 queue.push(new Group(rightNodes, rightSimilarities));
466 const leftNodes = [group.nodes[0]];
467 /** @type {number[]} */
468 const leftSimilarities = [];
469 for (let i = 1; i < left; i++) {
470 leftSimilarities.push(group.similarities[i - 1]);
471 leftNodes.push(group.nodes[i]);
473 queue.push(new Group(leftNodes, leftSimilarities));
478 // lexically ordering
479 result.sort((a, b) => {
480 if (a.nodes[0].key < b.nodes[0].key) return -1;
481 if (a.nodes[0].key > b.nodes[0].key) return 1;
485 // give every group a name
486 const usedNames = new Set();
487 for (let i = 0; i < result.length; i++) {
488 const group = result[i];
489 if (group.nodes.length === 1) {
490 group.key = group.nodes[0].key;
492 const first = group.nodes[0];
493 const last = group.nodes[group.nodes.length - 1];
494 const name = getName(first.key, last.key, usedNames);
499 // return the results
500 return result.map(group => {
501 /** @type {GroupedItems<T>} */
504 items: group.nodes.map(node => node.item),