├── .github └── workflows │ └── tests.yml ├── .gitignore ├── .npmignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── api-reference.md ├── example.js ├── jest.config.cjs ├── package-lock.json ├── package.json ├── scripts ├── docgen.js └── postbuild.js ├── src ├── MultiRange.test.ts ├── MultiRange.ts ├── fp.test.ts ├── fp.ts └── index.ts ├── tsconfig-cjs.json ├── tsconfig-esm.json └── tsconfig.json /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: [push] 3 | jobs: 4 | tests: 5 | runs-on: ubuntu-latest 6 | strategy: 7 | matrix: 8 | node: [10, 12, 14, 16] 9 | steps: 10 | - uses: actions/checkout@v2 11 | - uses: actions/setup-node@v1 12 | with: 13 | node-version: ${{ matrix.node }} 14 | - run: npm ci 15 | - run: npm run build 16 | - run: npx jest 17 | coverage: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v2 21 | - uses: actions/setup-node@v1 22 | with: 23 | node-version: 14 24 | - run: npm ci 25 | - run: npm run build 26 | - run: npx jest --coverage 27 | - uses: coverallsapp/github-action@master 28 | with: 29 | github-token: ${{ secrets.GITHUB_TOKEN }} 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /lib 3 | /coverage 4 | *.tgz -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | package-lock.json 2 | /coverage 3 | /src 4 | tsconfig*.json 5 | jest.config.cjs 6 | /scripts 7 | *.tgz 8 | .github -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v5.2.0 4 | 5 | - Added `individualThreshold` option to `stringify()`. 6 | 7 | ## v5.1.0 8 | 9 | - Introduced the `at()` function, which can be used to access the N-th smallest/largest integer. 10 | - `iterate()` now accepts an option to iterate in descending order. 11 | 12 | ## v5.0.0 13 | 14 | **❗️ Breaking Changes** 15 | 16 | The API has changed radically in version 5. The new API is slightly more verbose, but is simpler and tree-shakable. 17 | 18 | | | 4.x | 5.x | 19 | | ----------------- | ----------------------- | ----------------------------- | 20 | | API architecture | Class-based | Function-based | 21 | | Range data | Encapsulated in objects | Just an array of arrays | 22 | | ES version | Downpiled to ES5 | ES2015 | 23 | | Module system | CommonJS (CJS) | CJS/ESM hybrid | 24 | | Immutability | Mutable method chain | Pure functions only | 25 | | Supported runtime | Works even on IE | Modern browsers, Node ≥ 10 | 26 | 27 | - Dropped support for IE and old Node. 28 | - Migrated to pure function-style API and added an ESM build. This package is now fully tree-shakable. 29 | - In addition to the change from methods to simple functions, some functions have also been renamed or removed. 30 | 31 | - (constructor): Use `parse()` or `normalize()`. 32 | - `toArray`: Renamed to `flatten()`. 33 | - `toString`: Renamed to `stringify()`. 34 | - `getIterator`: Renamed to `iterate()`. 35 | - `segmentLength`: Removed. Use the standard `.length` property. 36 | - `pop`: Removed as this was mutable. Use `max()` in combination with `init()`. 37 | - `shift`: Removed as this was mutable. Use `min()` in combination with `tail()`. 38 | 39 | - Rewrote the `MultiRange` class to use the new function-style API under the hood. Use this only during migration because it defeats the benefits of version 5. Note that this is no longer exported as the default export, so you may need to change at least your `import` statements. 40 | - Improved the performance of heavily fragmented ranges by using binary search. 41 | 42 | ## v4.0.9 43 | 44 | - Removed unnecessary files from the package. 45 | - Updated all devDependencies to latest. 46 | - Migrated to GitHub Actions-based CI. 47 | 48 | ## v4.0.8 49 | 50 | - Improved tsdoc comments. 51 | - Updated dependencies. 52 | 53 | ## v4.0.7 54 | 55 | - Improved the quality of tsdoc comments. 56 | - Refactored test codes. 57 | - Updated dependencies. 58 | 59 | ## v4.0.6 60 | 61 | - Made the parser throw a `RangeError` if an integer in a string is too big or small (#10). 62 | 63 | ## v4.0.5 64 | 65 | - Included CHANGELOG in the repository. 66 | - Migrated test framework from Mocha to Jest. 67 | - Updated dependencies. 68 | 69 | ## v4.0.4 70 | 71 | - Only cosmetic changes and doc updates. 72 | 73 | ## v4.0.3 74 | 75 | - Fixed a bug where the copy constructor did not correctly copy the source's parse options (#9) 76 | 77 | ## v4.0.2 78 | 79 | - Fixed broken Runkit (tonic) example 80 | 81 | ## v4.0.1 82 | 83 | - Removed `package-lock.json` from the release package 84 | 85 | ## v4.0.0 86 | 87 | **❗️ Breaking Changes** 88 | 89 | - The string parser no longer accepts unbounded and negative ranges by default. To parse strings possibly containing unbound/negative ranges (eg `10-`, `(-5)-0`), you need to manually pass an option to enable them. 90 | 91 | ```js 92 | const userInput = '-5, 10-15, 30, 45-'; 93 | const pagesInMyDoc = [[1, 100]]; 94 | const mr = new MultiRange(userInput, { parseUnbounded: true }).intersect( 95 | pagesInMyDoc 96 | ); 97 | ``` 98 | 99 | Note that this affects only the string parser. Array/number initializers always accept unbounded/negative ranges, just as before. 100 | 101 | ``` 102 | const mr = new MultiRange([[-5, 3], [10, Inifinity]]); // This is always valid 103 | ``` 104 | 105 | **New** 106 | 107 | - The constructor now takes an optional `options` parameter, with which you can modify the parsing strategy. See above. 108 | - `MultiRange` is now exported also as the default export of the module. You can use `import MR from 'multi-integer-range'` instead of `import { MultiRange as MR } from 'multi-integer-range'`. 109 | - Iterator shimming: The type of `Symbol.iterator` is no longer strictly checked using `typeof`. This means polyfilled symbols (using core-js or such) will enable `MultiRange.prototype[Symbol.iterator]`, and `for ... of` loops will correctly transpile to ES5 using Babel or TypeScript (>=2.3 with `--downlevelIteration`). 110 | - Used ES2015 in the documentation. 111 | 112 | ## v3.0.0 113 | 114 | **❗️ Breaking Changes** 115 | 116 | - Removed the following methods which had been deprecated since v2.0.0. 117 | 118 | - `isContinuous()` (Gone for good. Use `segmentLength() === 1` instead) 119 | - `hasRange()` \* 120 | - `appendRange()` \* 121 | - `subtractRnage()` \* 122 | 123 | It's still possible to access some methods (marked with \*) unless you are using TypeScript (these methods were only turned to private methods). They will be there for the time being, although undocumented. 124 | 125 | - (TypeScript) `*.d.ts` file included in the package is now ready for `--strictNullChecks`. This means TypeScript users need to update their compiler to v2.0 or later to use the definition file. (You do not necessarily have to enable `--strictNullChecks` flag. See #7 for details.) 126 | 127 | **New** 128 | 129 | - Added four convenient methods: `min()`, `max()`, `shift()`, and `pop()` 130 | 131 | ## v2.1.0 132 | 133 | - Added support for unbounded (i.e., infinite) ranges. 134 | - Added support for ranges containing zero and negative integers. 135 | - Added isUnbounded() method. 136 | 137 | **❗️ Background Compatibility**: Most existing code should work just fine, but strings which used to be errors are now considered valid. For example, `new MultiRange('2-')` and `new MultiRange('(-10)')` raised a SyntaxError until v2.0.0, but now these are valid ways to denote unbound and negative ranges, respectively. Those who passes arbitrary user input to the string parser may have to perform additional error checking. Use `#isUnbounded()` to check if an instance is unbounded (infinite). Or limiting the range using `#intersection()` should be enough for most cases. 138 | 139 | ## v2.0.0 140 | 141 | - Dropped support for UMD. Now the module is compiled only as commonJS/node style module. 142 | - Added `segmentLength()` method. 143 | - Deprecated some methods, namely `addRange`, `subtractRange`, `hasRange`, `isContinuous`. These may be removed in future releases. 144 | 145 | Existing codes which do not depend on the AMD module system should work fine without problems. 146 | 147 | ## v1.4.3 148 | 149 | - Fixed compatibility issues for old browsers. 150 | 151 | ## v1.4.2 152 | 153 | - Small performance improvements and documentation improvements. 154 | 155 | ## v1.4.0 156 | 157 | - Add `intersect` method. 158 | - Many methods and the constructor now have consistent signatures. Notably, it's now possible to create a MultiRange object using a single integer (new MultiRange(5)). 159 | 160 | ## v1.3.1 161 | 162 | - `#has()` now accepts range strings. multirange('1-100').has('10-20') returns true. 163 | - Optimized `#append()` so that appending integers to the end of the existing ranges will be faster. 164 | 165 | ## v1.3.0 166 | 167 | - Exported a shorthand function `multirange`. 168 | 169 | ## v1.2.1 170 | 171 | - Removed the use of `Array.prototype.forEach`. The library should work fine for very old runtime. (except for native iterators) 172 | - Added `typings` field in `package.json`, so TypeScript compilers can locate the d.ts file (TS >= 1.6 required). Currently not working with iterators. 173 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2022 Soichiro Miki 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # multi-integer-range 2 | 3 | [![Build Status](https://github.com/smikitky/node-multi-integer-range/actions/workflows/tests.yml/badge.svg)](https://github.com/smikitky/node-multi-integer-range/actions) 4 | [![Coverage Status](https://coveralls.io/repos/github/smikitky/node-multi-integer-range/badge.svg?branch=dev)](https://coveralls.io/github/smikitky/node-multi-integer-range) 5 | [![npm version](https://badge.fury.io/js/multi-integer-range.svg)](https://badge.fury.io/js/multi-integer-range) 6 | 7 | A small library that parses comma-delimited integer ranges (such as `"1-3,8-10"`) and manipulates such range data. This type of data is commonly used to specify which lines to highlight or which pages to print. 8 | 9 | Key features: 10 | 11 | - Addition (aka union, e.g., `1-2,6` + `3-5` → `1-6`) 12 | - Subtraction (e.g., `1-10` − `5-9` → `1-4,10`) 13 | - Inclusion check (e.g., `3,7-9` ⊂ `1-10`) 14 | - Intersection (e.g., `1-5` ∩ `2-8` → `2-5`) 15 | - Unbounded ranges (aka infinite ranges, e.g., `5-`, meaning "all integers ≥ 5") 16 | - Ranges including negative integers or zero 17 | - ES6 iterator (`for ... of`, spread operator) 18 | - Array building ("flatten") 19 | 20 | The range data are always _sorted and normalized_ to the smallest possible representation. 21 | 22 | --- 23 | 24 | 🚨 **Note**: The following README is for the 5.x release, whose API has changed drastically. For the docs of the 4.x release, see [this](https://github.com/smikitky/node-multi-integer-range/tree/v4.0.9). 25 | 26 | ## Install 27 | 28 | Install via npm or yarn: 29 | 30 | ``` 31 | npm install multi-integer-range 32 | ``` 33 | 34 | Version 5 is a hybrid package; it provides both a CommonJS version and an ES Module version, built from the same TypeScript source. Bundlers such as Webpack can automatically pick the ESM version and perform tree-shaking. This package has no external dependencies nor does it use any Node-specific API. 35 | 36 | 🚨 The API style has changed drastically in version 5. The new API is slightly more verbose, but is simpler and tree-shakable 🌲. For example, if you don't use the default parser, your bundle will not include it. See the [CHANGELOG](./CHANGELOG.md) and the [docs for version 4](https://github.com/smikitky/node-multi-integer-range/tree/v4.0.9). 37 | 38 | ## Basic Example 39 | 40 | 41 | ```js 42 | import * as mr from 'multi-integer-range'; 43 | 44 | const ranges1 = mr.parse('1-6,9-12'); // [[1, 6], [9, 12]] 45 | const ranges2 = mr.parse('7-10, 100'); // [[7, 10], [100, 100]] 46 | const ranges3 = mr.normalize([1, 5, 6, [4, 2]]); // [[1, 6]] 47 | 48 | const sum = mr.append(ranges1, ranges2); // [[1, 12], [100, 100]] 49 | const diff = mr.subtract(ranges1, ranges2); // [[1, 6], [11, 12]] 50 | const commonValues = mr.intersect(ranges1, ranges2); // [[9, 10]] 51 | 52 | const str = mr.stringify(sum); // "1-12,100" 53 | const bool = mr.has(ranges1, ranges3); // true 54 | const isSame = mr.equals(ranges1, ranges2); // false 55 | const array = mr.flatten(diff); // [1, 2, 3, 4, 5, 6, 11, 12] 56 | const len = mr.length(ranges1); // 10 57 | ``` 58 | 59 | ## Creating a normalized MultiIntegerRange 60 | 61 | The fundamental data structure of this package is a **normalized** array of `[min, max]` tuples, as shown below. Here, 'normalized' means the range data is in the smallest possible representation and is sorted in ascending order. You can denote an unbounded (aka infinite) range using the JavaScript constant `Infinity`. 62 | 63 | 64 | ```ts 65 | type Range = readonly [min: number, max: number]; 66 | type MultiIntegerRange = readonly Range[]; 67 | 68 | // Examples of normalized MultiIntegerRanges 69 | [[1, 3], [5, 6], [9, 12]] // 1-3,5-6,9-12 70 | [[-Infinity, 4], [7, 7], [10, Infinity]] // -4,7,10- 71 | [[-Infinity, Infinity]] // all integers 72 | [] // empty 73 | 74 | // These are NOT normalized. Don't pass them to append() and such! 75 | [[3, 1]] // min is larger than max 76 | [[7, 9], [1, 4]] // not in the ascending order 77 | [[1, 5], [3, 7]] // there is an overlap of ranges 78 | [[1, 2], [3, 4]] // the two ranges can be combined to "1-4" 79 | [[Infinity, Infinity]] // makes no sense 80 | ``` 81 | 82 | Most functions take one or two **normalized** `MultiIntegerRange`s as shown above to work correctly. To produce a valid normalized `MultiIntegerRange`, you can use `normalize()`, `parse()` or `initialize()`. You can write a normalized `MultiIntgerRange` by hand as shown above, too. 83 | 84 | `normalize(data?: number | (number | Range)[])` creates a normalized `MultiIntegerRange` from a single integer or an unsorted array of integers/`Range`s. This and `initialize` are the only functions that can safely take an unsorted array. Do not pass unnormalized range data to other functions. 85 | 86 | 87 | ```ts 88 | console.log(mr.normalize(10)); // [[10, 10]] 89 | console.log(mr.normalize([3, 1, 2, 4, 5])); // [[1, 5]] 90 | console.log(mr.normalize([5, [2, 0], 6, 4])); // [[0, 2], [4, 6]] 91 | console.log(mr.normalize([7, 7, 10, 7, 7])); // [[7, 7], [10, 10]] 92 | console.log(mr.normalize()); // [] 93 | 94 | // Do not directly pass an unnormalized array 95 | // to functions other than normalize(). 96 | const unsorted = [[3, 1], [2, 8]]; 97 | const wrong = mr.length(unsorted); // This won't work! 98 | const correct = mr.length(mr.normalize(unsorted)); // 8 99 | ``` 100 | 101 | `parse(data: string, options?: Options)` creates a normalized `MultiIntegerRange` from a string. The string parser is permissive and accepts space characters before/after comma/hyphens. It calls `normalize()` under the hood, so the order is not important, and overlapped numbers are silently ignored. 102 | 103 | ```ts 104 | console.log(mr.parse('1-3,10')); // [[1, 3], [10, 10]] 105 | console.log(mr.parse('3,\t8-3,2,3,\n10, 9 - 7 ')); // [[2, 10]] 106 | ``` 107 | 108 | By default, the string parser does not try to parse unbounded ranges or negative integers. You need to pass an `options` object to modify the parsing behavior. To avoid ambiguity, all negative integers must always be enclosed in parentheses. If you don't like the default `parse()`, you can always create and use your custom parsing function instead, as long as it returns a normalized `MultiIntegerRange`. 109 | 110 | ```ts 111 | console.log(mr.parse('7-')); // throws a SyntaxError 112 | 113 | console.log(mr.parse('7-', { parseUnbounded: true })); // [[7, Infinity]] 114 | console.log(mr.parse('(-7)-(-1)', { parseNegative: true })); // [[-7, -1]] 115 | console.log( 116 | mr.parse('0-,(-6)-(-2),-(-100)', { 117 | parseUnbounded: true, 118 | parseNegative: true 119 | }) 120 | ); // [[-Infinity, -100], [-6, -2], [0, Infinity]] 121 | ``` 122 | 123 | ## API Reference 124 | 125 | See [api-reference.md](api-reference.md). 126 | 127 | ## Tips 128 | 129 | ### Iteration 130 | 131 | Since a `MultiIntegerRange` is just an array of `Range`s, if you naively iterate over it (e.g., in a for-of loop), you'll simply get each `Range` tuple one by one. To iterate each integer contained in the `MultiIntegerRange` instead, use `iterate()` like so: 132 | 133 | ```ts 134 | const ranges = mr.parse('2,5-7'); 135 | 136 | for (const page of ranges) { 137 | console.log(page); 138 | } // prints 2 items: [2, 2] and [5, 7] 139 | 140 | for (const page of mr.iterate(ranges)) { 141 | console.log(page); 142 | } // prints 4 items: 2, 5, 6 and 7 143 | 144 | // array spreading (alternative of flatten()) 145 | const arr1 = [...mr.iterate(ranges)]; //=> [2, 5, 6, 7] 146 | const arr2 = Array.from(mr.iterate(ranges)); //=> [2, 5, 6, 7] 147 | ``` 148 | 149 | ### Combine Intersection and Unbounded Ranges 150 | 151 | Intersection is especially useful to "trim" unbounded ranges. 152 | 153 | ```ts 154 | const userInput = '-5,15-'; 155 | const pagesInMyDoc = [[1, 20]]; // 1-20 156 | const pagesToPrint = mr.intersect( 157 | mr.parse(userInput, { parseUnbounded: true }), 158 | pagesInMyDoc 159 | ); // [[1, 5], [15, 20]] 160 | for (const page of mr.iterate(pagesToPrint)) await printPage(page); 161 | ``` 162 | 163 | ## Legacy Classe-based API 164 | 165 | For compatibility purposes, version 5 exports the `MultiRange` class and `multirange` function, which is mostly compatible with the 4.x API but has been rewritten to use the new functional API under the hood. See the [4.x documentation](https://github.com/smikitky/node-multi-integer-range/tree/v4.0.9) for the usage. The use of this compatibility layer is discouraged because it is not tree-shakable and has no performance merit. Use this only during migration. These may be removed in the future. 166 | 167 | ## Caveats 168 | 169 | **Performance Considerations**: This library works efficiently for large ranges as long as they're _mostly_ continuous (e.g., `1-10240000,20480000-50960000`). However, this library is not intended to be efficient with a heavily fragmented set of integers that are scarcely continuous (e.g., random 10000 integers between 1 to 1000000). 170 | 171 | **No Integer Type Checks**: Make sure you are not passing floating-point `number`s to this library. For example, don't do `normalize(3.14)`. For performance reasons, the library does not check if a passed number is an integer. Passing a float will result in unexpected and unrecoverable behavior. 172 | 173 | ## Comparison with Similar Libraries 174 | 175 | [range-parser](https://www.npmjs.com/package/range-parser) specializes in parsing range requests in HTTP headers as defined in RFC 7233, and it behaves in a way that is usually inappropriate for other purposes. For example, `'-5'` means "last 5 bytes". 176 | 177 | [parse-numeric-range](https://www.npmjs.com/package/parse-numeric-range) is fine for small ranges, but it always builds a "flat" array, which makes it very inefficient for large ranges such as byte ranges. Also, whether you like it or not, it handles overlapping or descending ranges as-is, without normalization. For example, `'4-2,1-3'` results in `[4, 3, 2, 1, 2, 3]`. 178 | 179 | multi-integer-range is a general-purpose library for handling this type of data structure. It has a default parser that is intuitive enough for many purposes, but you can also use a custom parser. Its real value lies in its ability to treat normalized ranges as intermediate forms, allowing for a variety of mathematical operations. See the [API reference](api-reference.md). 180 | 181 | | Input | multi-integer-range | range-parser | parse-numeric-range | 182 | | --------- | ----------------------------- | ----------------------------------------- | -------------------------- | 183 | | '1-3' | [[1, 3]] | [{ start: 1, end: 3 }] | [1, 2, 3] | 184 | | '1-1000' | [[1, 1000]] | [{ start: 1, end: 1000 }] | [1, 2, ..., 999, 1000 ] ⚠️ | 185 | | '5-1' | [[1, 5]] | (error) | [5, 4, 3, 2, 1] | 186 | | '4-2,1-3' | [[1, 4]] | [{ start: 1, end: 3 }] ⚠️1 | [4, 3, 2, 1, 2, 3] | 187 | | '-5' | [[-Infinity, 5]] 2 | [{ start: 9995, end: 9999 }] 3 | [-5] | 188 | | '5-' | [[5, Infinity]] 2 | [{ start: 5, end: 9999 }] 3 | [] | 189 | 190 | 1: With `combine` option. 2: With `parseUnbounded` option. 3: When `size` is 10000. 191 | 192 | ## Development 193 | 194 | To test: 195 | 196 | ``` 197 | npm ci 198 | npm test 199 | ``` 200 | 201 | To generate CJS and ESM builds: 202 | 203 | ``` 204 | npm ci 205 | npm run build 206 | ``` 207 | 208 | Please report bugs and suggestions using GitHub issues. 209 | 210 | ## Changelog 211 | 212 | See [CHANGELOG.md](CHANGELOG.md). 213 | 214 | ## Author 215 | 216 | Soichiro Miki (https://github.com/smikitky) 217 | 218 | ## License 219 | 220 | MIT 221 | -------------------------------------------------------------------------------- /api-reference.md: -------------------------------------------------------------------------------- 1 | # multi-integer-range API Reference 2 | 3 | - All functions are *pure* functions. They do not alter the input arguments nor do they have side effects. 4 | - All functions and types are exported as named exports. 5 | - All MultiIntegerRange returned by these functions are normalized. 6 | - The legacy `MultiRange` class is also available but is not documented here. See the docs for v4 for this. 7 | 8 | ## Contents 9 | 10 | - [`parse()`](#function-parse) 11 | - [`normalize()`](#function-normalize) 12 | - [`initialize()`](#function-initialize) 13 | - [`append()`](#function-append) 14 | - [`subtract()`](#function-subtract) 15 | - [`intersect()`](#function-intersect) 16 | - [`has()`](#function-has) 17 | - [`length()`](#function-length) 18 | - [`isUnbounded()`](#function-isunbounded) 19 | - [`equals()`](#function-equals) 20 | - [`min()`](#function-min) 21 | - [`max()`](#function-max) 22 | - [`at()`](#function-at) 23 | - [`tail()`](#function-tail) 24 | - [`init()`](#function-init) 25 | - [`stringify()`](#function-stringify) 26 | - [`flatten()`](#function-flatten) 27 | - [`iterate()`](#function-iterate) 28 | 29 | --- 30 | 31 | ## type: `Range` 32 | 33 | ```ts 34 | type Range = readonly [min: number, max: number]; 35 | ``` 36 | 37 | A `[min, max]` tuple to denote one integer range. 38 | 39 | --- 40 | 41 | ## type: `MultiIntegerRange` 42 | 43 | ```ts 44 | type MultiIntegerRange = readonly Range[]; 45 | ``` 46 | 47 | An immutable Range array. This is the fundamental data type of this package. 48 | 49 | **Warning**: Most functions of this package work correctly 50 | only when **normalized** MultiIntegerRange's are passed. 51 | If you have a Range array that may not be sorted, use [`normalize()`](#function-normalize) first. 52 | 53 | --- 54 | 55 | ## function: `parse` 56 | 57 | ```ts 58 | parse(data: string, options?: Options): MultiIntegerRange 59 | ``` 60 | 61 | | Param | Description | 62 | |-|-| 63 | | `data` | The string to parse. | 64 | | `options` | Options to modify the parsing behavior. | 65 | | Returns | A new normalized MultiIntegerRange. | 66 | 67 | Parses a string and creates a new MultiIntegerRange. 68 | 69 | - `options.parseNegative` (boolean): When set to true, parses negative integers enclosed in parentheses. 70 | - `options.parseUnbounded` (boolean): When set to true, parses unbounded ranges like `10-` or `-10`. 71 | 72 | This is the default parser, but you don't necessary have to use this. 73 | You can create your own parser to suit your needs 74 | as long as it produces a normalized array of `Range`s. 75 | 76 | ### Example 77 | 78 | ```ts 79 | parse('1-10'); // [[1, 10]] 80 | parse(' 10-, 7', { parseUnbounded: true }); // [[7, 7], [10, Infinity]] 81 | ``` 82 | 83 | --- 84 | 85 | ## function: `normalize` 86 | 87 | ```ts 88 | normalize(data?: (number | Range)[] | number): MultiIntegerRange 89 | ``` 90 | 91 | | Param | Description | 92 | |-|-| 93 | | `data` | A number or an unsorted array, e.g., `[[7, 5], 1]`. | 94 | | Returns | Normalized array, e.g., `[[1, 1], [5, 7]]`. | 95 | 96 | Takes a number or an unsorted array of ranges, 97 | and returns a new normalized MultiIntegerRange. 98 | 99 | Here, "normalized" means the range data is in the smallest possible 100 | representation and is sorted in ascending order. 101 | 102 | This is the only function that can take an unsorted array of Range's. 103 | Unsorted range data MUST be normalized before being passed to 104 | other functions such as [`append()`](#function-append) and [`length()`](#function-length). 105 | 106 | ### Example 107 | 108 | ```ts 109 | normalize(5); // [[5, 5]] 110 | normalize([1, 8]); // [[1, 1], [8, 8]] 111 | normalize([[1, 8]]); // [[1, 8]] 112 | normalize([2, 3, 1, 5, 4, 0, 1, 3]); // [[0, 5]] 113 | normalize([[Infinity, 1]]); // [[1, Infinity]] 114 | ``` 115 | 116 | --- 117 | 118 | ## function: `initialize` 119 | 120 | ```ts 121 | initialize( 122 | data?: (number | Range)[] | number | string, 123 | options?: Options 124 | ): MultiIntegerRange 125 | ``` 126 | 127 | | Param | Description | 128 | |-|-| 129 | | `data` | Anything understood by either [`parse()`](#function-parse) or [`normalize()`](#function-normalize). | 130 | | `options` | Parse options passed to [`parse()`](#function-parse). | 131 | | Returns | A new normalized MultiIntegerRange. | 132 | 133 | Takes any supported data and returns a normalized MultiIntegerRange. 134 | Conditionally calls either [`parse()`](#function-parse) or [`normalize()`](#function-normalize) under the hood. 135 | This is an equivalent of "initializer" constructor of version ≤ 4. 136 | 137 | ### Example 138 | 139 | ```ts 140 | initialize(5); // [[5, 5]] 141 | initialize('2-8'); // [[2,8]] 142 | ``` 143 | 144 | --- 145 | 146 | ## function: `append` 147 | 148 | ```ts 149 | append(a: MultiIntegerRange, b: MultiIntegerRange): MultiIntegerRange 150 | ``` 151 | 152 | | Param | Description | 153 | |-|-| 154 | | `a` | The first value. | 155 | | `b` | The second value. | 156 | | Returns | A new MultiIntegerRange containing all integers that belong to **either `a` or `b` (or both)**. | 157 | 158 | Appends two MultiIntegerRange's. 159 | 160 | ### Example 161 | 162 | ```ts 163 | append([[1, 5]], [[3, 8], [10, 15]]); // [[1, 8], [10, 15]] 164 | append([[5, 9]], [[-Infinity, 2]]); // [[-Infinity, 2], [5, 9]] 165 | ``` 166 | 167 | --- 168 | 169 | ## function: `subtract` 170 | 171 | ```ts 172 | subtract(a: MultiIntegerRange, b: MultiIntegerRange): MultiIntegerRange 173 | ``` 174 | 175 | | Param | Description | 176 | |-|-| 177 | | `a` | The value to be subtracted. | 178 | | `b` | The value to subtract. | 179 | | Returns | A new MultiIntegerRange containing all integers that belong to **`a` but not `b`**. | 180 | 181 | Subtracts the second value from the first value. 182 | 183 | ### Example 184 | 185 | ```ts 186 | subtract([[1, 7]], [[2, 4]]); // [[1, 1], [5, 7]] 187 | subtract([[-Infinity, Infinity]], [[2, 4]]); // [[-Infinity, 1], [5, Infinity]] 188 | ``` 189 | 190 | --- 191 | 192 | ## function: `intersect` 193 | 194 | ```ts 195 | intersect(a: MultiIntegerRange, b: MultiIntegerRange): MultiIntegerRange 196 | ``` 197 | 198 | | Param | Description | 199 | |-|-| 200 | | `a` | The first value. | 201 | | `b` | The second value. | 202 | | Returns | A new MultiIntegerRange containing all integers that belong to **both `a` and `b`**. | 203 | 204 | Calculates the intersection (common integers) of the two MultiIntegerRange's. 205 | 206 | ### Example 207 | 208 | ```ts 209 | intersect([[2, 5]], [[4, 9]]); // [[4, 5]] 210 | intersect([[5, 10]], [[-Infinity, Infinity]]); // [[5, 10]] 211 | ``` 212 | 213 | --- 214 | 215 | ## function: `has` 216 | 217 | ```ts 218 | has(a: MultiIntegerRange, b: MultiIntegerRange): boolean 219 | ``` 220 | 221 | | Param | Description | 222 | |-|-| 223 | | `a` | The value that possibly contains `b`. | 224 | | `b` | The value that is possibly contained by `a`. | 225 | | Returns | True if `b` is equal to or a subset of `a`. | 226 | 227 | Checks if `a` contains or is equal to `b` (a ⊇ b). 228 | 229 | ### Example 230 | 231 | ```ts 232 | has([[0, 100]], [[2, 10]]); // true 233 | has([[5, 7]], [[5, 7]]); // true 234 | has([[2, 10]], [[0, 100]]); // false 235 | ``` 236 | 237 | --- 238 | 239 | ## function: `length` 240 | 241 | ```ts 242 | length(data: MultiIntegerRange): number 243 | ``` 244 | 245 | | Param | Description | 246 | |-|-| 247 | | `data` | The value to calculate the length on. | 248 | | Returns | The number of integers contained in `data`. May be `Infinity`. | 249 | 250 | Calculates how many integers are included in the given MultiIntegerRange. 251 | 252 | Note: If you want to know the number of Ranges (segments), just use the 253 | standard `Array#length`. 254 | 255 | ### Example 256 | 257 | ```ts 258 | length([[1, 3], [8, 10]]); // 6 259 | length([[1, Infinity]]); // Infinity 260 | ``` 261 | 262 | --- 263 | 264 | ## function: `isUnbounded` 265 | 266 | ```ts 267 | isUnbounded(data: MultiIntegerRange): boolean 268 | ``` 269 | 270 | | Param | Description | 271 | |-|-| 272 | | `data` | The value to check. | 273 | | Returns | True if `data` is unbounded. | 274 | 275 | Checks if the data contains an unbounded (aka inifinite) range. 276 | 277 | ### Example 278 | 279 | ```ts 280 | isUnbounded([[1, Infinity]]); // true 281 | isUnbounded([[-Infinity, 4]]); // true 282 | isUnbounded([[7, 9]]); // false 283 | ``` 284 | 285 | --- 286 | 287 | ## function: `equals` 288 | 289 | ```ts 290 | equals(a: MultiIntegerRange, b: MultiIntegerRange): boolean 291 | ``` 292 | 293 | | Param | Description | 294 | |-|-| 295 | | `a` | The first value to compare. | 296 | | `b` | The second value to compare. | 297 | | Returns | True if `a` and `b` have the same range data. | 298 | 299 | Checks if the two values are the same. (Altenatively, you can use any 300 | "deep-equal" utility function.) 301 | 302 | ### Example 303 | 304 | ```ts 305 | equals([[1, 5], [7, 8]], [[1, 5], [7, 8]]); // true 306 | equals([[1, 5]], [[2, 7]]); // false 307 | ``` 308 | 309 | --- 310 | 311 | ## function: `min` 312 | 313 | ```ts 314 | min(data: MultiIntegerRange): number | undefined 315 | ``` 316 | 317 | | Param | Description | 318 | |-|-| 319 | | `data` | The value. | 320 | | Returns | The minimum integer. May be `undefined` or `-Infinity`. | 321 | 322 | Returns the minimum integer of the given MultiIntegerRange. 323 | 324 | ### Example 325 | 326 | ```ts 327 | min([[2, 5], [8, 10]]); // 2 328 | min([[-Infinity, 0]]); // -Infinity 329 | min([]); // undefined 330 | ``` 331 | 332 | --- 333 | 334 | ## function: `max` 335 | 336 | ```ts 337 | max(data: MultiIntegerRange): number | undefined 338 | ``` 339 | 340 | | Param | Description | 341 | |-|-| 342 | | `data` | The value. | 343 | | Returns | The minimum integer. May be `undefined` or `Infinity`. | 344 | 345 | Returns the maximum integer of the given MultiIntegerRange. 346 | 347 | ### Example 348 | 349 | ```ts 350 | max([[2, 5], [8, 10]]); // 10 351 | max([[3, Infinity]]); // Infinity 352 | max([]); // undefined 353 | ``` 354 | 355 | --- 356 | 357 | ## function: `at` 358 | 359 | ```ts 360 | at(data: MultiIntegerRange, index: number): number | undefined 361 | ``` 362 | 363 | | Param | Description | 364 | |-|-| 365 | | `data` | The value. | 366 | | `index` | The 0-based index of the integer to return. Can be negative. | 367 | | Returns | The integer at the specified index. Returns `undefined` if the index is out of bounds. | 368 | 369 | Returns the integer at the specified 0-based index. 370 | If a negative index is given, the index is counted from the end. 371 | 372 | ### Example 373 | 374 | ```ts 375 | at([[2, 4], [8, 10]], 4); // 9 376 | at([[2, 4], [8, 10]], 6); // undefined 377 | at([[2, 4], [8, 10]], -1); // 10 378 | ``` 379 | 380 | --- 381 | 382 | ## function: `tail` 383 | 384 | ```ts 385 | tail(data: MultiIntegerRange): MultiIntegerRange 386 | ``` 387 | 388 | | Param | Description | 389 | |-|-| 390 | | `data` | The value. | 391 | | Returns | A new MultiIntegerRange which is almost the same as `data` but with its minimum integer removed. | 392 | 393 | Returns all but the minimum integer. 394 | 395 | ### Example 396 | 397 | ```ts 398 | tail([[2, 5], [8, 10]]); // [[3, 5], [8, 10]] 399 | ``` 400 | 401 | --- 402 | 403 | ## function: `init` 404 | 405 | ```ts 406 | init(data: MultiIntegerRange): MultiIntegerRange 407 | ``` 408 | 409 | | Param | Description | 410 | |-|-| 411 | | `data` | The value. | 412 | | Returns | A new MultiIntegerRange which is almost the same as `data` but with its maximum integer removed. | 413 | 414 | Returns all but the maximum integer. 415 | 416 | ### Example 417 | 418 | ```ts 419 | init([[2, 5], [8, 10]]); // [[2, 5], [8, 9]] 420 | ``` 421 | 422 | --- 423 | 424 | ## function: `stringify` 425 | 426 | ```ts 427 | stringify( 428 | data: MultiIntegerRange, 429 | options: StringifyOptions = {} 430 | ): string 431 | ``` 432 | 433 | | Param | Description | 434 | |-|-| 435 | | `data` | The MultiIntegerRange to stringify. | 436 | | `options` | Options for the stringification. | 437 | | Returns | The string representation of the given data. | 438 | 439 | Returns the string respresentation of the given MultiIntegerRange. 440 | 441 | - `options.individualThreshold` (number): If set, small ranges with a length 442 | smaller than or equal to this will be output as individual integers. 443 | Defaults to `1`, which means only ranges with a length of 1 will be 444 | output as a single integer. 445 | 446 | ### Example 447 | 448 | ```ts 449 | stringify([[2, 3], [5, 5], [7, 9]]); // '2-3,5,7-9' 450 | stringify([[2, 3], [5, 5], [7, 9]], { individualThreshold: 0 }); // '2-3,5-5,7-9' 451 | stringify([[2, 3], [5, 5], [7, 9]], { individualThreshold: 2 }); // '2,3,5,7-9' 452 | stringify([[2, 3], [5, 5], [7, 9]], { individualThreshold: 3 }); // '2,3,5,7,8,9' 453 | stringify([[3, 5], [7, Infinity]]); // '3-5,7-' 454 | ``` 455 | 456 | --- 457 | 458 | ## function: `flatten` 459 | 460 | ```ts 461 | flatten(data: MultiIntegerRange): number[] 462 | ``` 463 | 464 | | Param | Description | 465 | |-|-| 466 | | `data` | The value to build an array on. | 467 | | Returns | The flattened array of numbers. | 468 | 469 | Builds a flattened array of integers. 470 | Note that this may be slow and memory-consuming for large ranges. 471 | Consider using the iterator whenever possible. 472 | 473 | ### Example 474 | 475 | ```ts 476 | flatten([[-1, 1], [7, 9]]); // [-1, 0, 1, 7, 8, 9] 477 | ``` 478 | 479 | --- 480 | 481 | ## function: `iterate` 482 | 483 | ```ts 484 | iterate( 485 | data: MultiIntegerRange, 486 | options: IterateOptions = {} 487 | ): Iterable 488 | ``` 489 | 490 | | Param | Description | 491 | |-|-| 492 | | `data` | The normalized MultiIntegerRange to iterate over. | 493 | | `options` | Options for the iteration. | 494 | | Returns | An Iterable object. | 495 | 496 | Returns an Iterable with which you can use `for-of` or the spread syntax. 497 | 498 | - `options.descending` (boolean): If set to true, the iterator will iterate in descending order. 499 | 500 | ### Example 501 | 502 | ```ts 503 | Array.from(iterate([[1, 3], [7, 9]])); // [1, 2, 3, 7, 8, 9] 504 | Array.from(iterate([[1, 3], [7, 9]], { descending: true })); // [9, 8, 7, 3, 2, 1] 505 | [...iterate([[-1, 2]])]; // [-1, 0, 1, 2] 506 | ``` 507 | -------------------------------------------------------------------------------- /example.js: -------------------------------------------------------------------------------- 1 | import * as mr from 'multi-integer-range'; 2 | 3 | const ranges1 = mr.parse('1-6,9-12'); 4 | const ranges2 = mr.parse('7-10,100'); 5 | const ranges3 = mr.normalize([1, 5, 6, [4, 2]]); 6 | 7 | const sum = mr.append(ranges1, ranges2); 8 | 9 | console.log('append', sum); 10 | console.log('subtract', mr.subtract(ranges1, ranges2)); 11 | console.log('intersect', mr.intersect(ranges1, ranges2)); 12 | 13 | console.log('stringify', mr.stringify(sum)); 14 | console.log('has', mr.has(ranges1, ranges3)); 15 | console.log('equals', mr.equals(ranges1, ranges2)); 16 | console.log('flatten', mr.flatten(ranges3)); 17 | console.log('length', mr.length(ranges1)); 18 | -------------------------------------------------------------------------------- /jest.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testMatch: ['**/?(*.)+(test).+(ts)'], 3 | transform: { 4 | '^.+\\.(ts|tsx)$': 'ts-jest' 5 | }, 6 | resolver: 'jest-ts-webcompat-resolver', 7 | extensionsToTreatAsEsm: ['.ts'], 8 | globals: { 9 | 'ts-jest': { 10 | useESM: true 11 | } 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "multi-integer-range", 3 | "version": "5.2.0", 4 | "description": "Parses and manipulates multiple comma-separated integer ranges (eg 1-3,8-10)", 5 | "repository": "https://github.com/smikitky/node-multi-integer-range.git", 6 | "sideEffects": false, 7 | "main": "./lib/cjs/index.js", 8 | "module": "./lib/esm/index.js", 9 | "exports": { 10 | "import": "./lib/esm/index.js", 11 | "require": "./lib/cjs/index.js" 12 | }, 13 | "types": "./lib/cjs/index.d.ts", 14 | "scripts": { 15 | "test": "jest --coverage", 16 | "doc": "node scripts/docgen.js", 17 | "clean": "rimraf lib", 18 | "build": "npm run clean && tsc -p tsconfig-esm.json && tsc -p tsconfig-cjs.json && node scripts/postbuild.js", 19 | "prepack": "npm run build", 20 | "prettier": "prettier --write src/**/*.{ts,js}" 21 | }, 22 | "keywords": [ 23 | "integer", 24 | "range", 25 | "page", 26 | "multiple", 27 | "parseInt", 28 | "parse" 29 | ], 30 | "author": "Soichiro Miki", 31 | "license": "MIT", 32 | "devDependencies": { 33 | "@types/jest": "^27.4.1", 34 | "jest": "^27.5.1", 35 | "jest-ts-webcompat-resolver": "^1.0.0", 36 | "prettier": "^2.5.1", 37 | "rimraf": "^3.0.2", 38 | "ts-jest": "^27.1.3", 39 | "typescript": "^4.6.2" 40 | }, 41 | "prettier": { 42 | "singleQuote": true, 43 | "arrowParens": "avoid", 44 | "trailingComma": "none" 45 | }, 46 | "runkitExampleFilename": "example.js" 47 | } 48 | -------------------------------------------------------------------------------- /scripts/docgen.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const fs = require('fs/promises'); 3 | 4 | // Yes I do know the ideal is to use a serious parser, but this works 5 | 6 | const parseDocComment = docComment => { 7 | const lines = docComment 8 | .split('\n') 9 | .map(s => s.replace(/^\s*(\/\*\*|\*)?\s?/g, '')); 10 | const tags = {}; 11 | let currentTag = 'summary'; 12 | let currentTagContent = ''; 13 | const push = (tagName, content) => { 14 | if (!(tagName in tags)) tags[tagName] = []; 15 | tags[tagName].push( 16 | content 17 | .replace('\\{', '{') 18 | .replace('\\}', '}') 19 | .replace( 20 | /`(.+?)\(\)`/g, 21 | (...m) => `[\`${m[1]}()\`](#function-${m[1].toLowerCase()})` 22 | ) 23 | .trim() 24 | ); 25 | }; 26 | for (const line of lines) { 27 | const match = line.trim().match(/^@([a-zA-Z]+)\s*(.*)/); 28 | if (!match) { 29 | currentTagContent += (currentTagContent.length ? '\n' : '') + line; 30 | } else { 31 | push(currentTag, currentTagContent); 32 | currentTag = match[1]; 33 | currentTagContent = match[2]; 34 | } 35 | } 36 | push(currentTag, currentTagContent); 37 | return tags; 38 | }; 39 | 40 | const parseScript = content => { 41 | return Array.from(content.matchAll(/\/\*\*(.+?)\*\/\n(.+?);\n/gs)) 42 | .filter(match => { 43 | return /^export (const|type)/.test(match[2]); 44 | }) 45 | .map(([, doc, identifier]) => { 46 | if (identifier.match(/export const/)) { 47 | const match = identifier.match(/export const (.+?) = (.+?) =>/s); 48 | return { 49 | type: 'function', 50 | name: match[1], 51 | doc: parseDocComment(doc), 52 | signature: match[2].replaceAll('MIR', 'MultiIntegerRange') 53 | }; 54 | } else { 55 | const match = identifier.match(/export type (.+?) = (.+)/s); 56 | return { 57 | type: 'type', 58 | name: match[1], 59 | doc: parseDocComment(doc), 60 | content: match[2] 61 | }; 62 | } 63 | }) 64 | .filter(item => !item.doc.private); 65 | }; 66 | 67 | const buildMarkdown = parsed => { 68 | const lines = [ 69 | '# multi-integer-range API Reference', 70 | '', 71 | '- All functions are *pure* functions. They do not alter the input arguments nor do they have side effects.', 72 | '- All functions and types are exported as named exports.', 73 | '- All MultiIntegerRange returned by these functions are normalized.', 74 | '- The legacy `MultiRange` class is also available but is not documented here. See the docs for v4 for this.', 75 | '', 76 | '## Contents', 77 | '' 78 | ]; 79 | 80 | // TOC 81 | parsed 82 | .filter(item => item.type === 'function') 83 | .forEach(item => { 84 | lines.push( 85 | `- [\`${item.name}()\`](#function-${item.name.toLowerCase()})` 86 | ); 87 | }); 88 | lines.push(''); 89 | 90 | parsed.forEach(item => { 91 | lines.push('---', '', `## ${item.type}: \`${item.name}\``, ''); 92 | if (item.type === 'function') { 93 | lines.push('```ts', `${item.name}${item.signature}`, '```', ''); 94 | } else { 95 | lines.push('```ts', `type ${item.name} = ${item.content};`, '```', ''); 96 | } 97 | if (item.doc.param) { 98 | lines.push('| Param | Description |', '|-|-|'); 99 | item.doc.param.forEach(param => { 100 | const [p, d] = param.split(' - ', 2); 101 | lines.push(`| \`${p}\` | ${d} |`); 102 | }); 103 | if (item.doc.returns) { 104 | lines.push( 105 | `| Returns | ${item.doc.returns[0].replaceAll('\n', ' ')} |` 106 | ); 107 | } 108 | lines.push(''); 109 | } 110 | lines.push(item.doc.summary, ''); 111 | if (item.doc.example) { 112 | lines.push('### Example', '', '```ts', item.doc.example, '```', ''); 113 | } 114 | }); 115 | return lines.join('\n'); 116 | }; 117 | 118 | const main = async () => { 119 | const script = await fs.readFile( 120 | path.join(__dirname, '../src/fp.ts'), 121 | 'utf8' 122 | ); 123 | const parsed = parseScript(script); 124 | const markdown = buildMarkdown(parsed); 125 | await fs.writeFile( 126 | path.join(__dirname, '../api-reference.md'), 127 | markdown, 128 | 'utf8' 129 | ); 130 | }; 131 | 132 | main(); 133 | -------------------------------------------------------------------------------- /scripts/postbuild.js: -------------------------------------------------------------------------------- 1 | // This file is to generate sub-directory package.json file after building 2 | 3 | // https://www.sensedeep.com/blog/posts/2021/how-to-create-single-source-npm-module.html 4 | 5 | const fs = require('fs'); 6 | const path = require('path'); 7 | 8 | fs.writeFileSync( 9 | path.join(__dirname, '../lib/cjs/package.json'), 10 | JSON.stringify({ type: 'commonjs' }), 11 | 'utf8' 12 | ); 13 | 14 | fs.writeFileSync( 15 | path.join(__dirname, '../lib/esm/package.json'), 16 | JSON.stringify({ type: 'module' }), 17 | 'utf8' 18 | ); 19 | -------------------------------------------------------------------------------- /src/MultiRange.test.ts: -------------------------------------------------------------------------------- 1 | import { MultiRange, Initializer, multirange } from './MultiRange'; 2 | 3 | const mr = (i?: Initializer) => { 4 | return multirange(i, { parseNegative: true, parseUnbounded: true }); 5 | }; 6 | 7 | const t = (mr: MultiRange, expected: any) => { 8 | expect(mr.toString()).toBe(expected); 9 | }; 10 | 11 | describe('constructor', () => { 12 | test('initialize with various types of initializer', () => { 13 | t(mr('10-8,7-5,1-4'), '1-10'); 14 | t(mr(-8), '(-8)'); 15 | t(mr([]), ''); 16 | t(mr(), ''); 17 | t(mr([-4, 5, [8, 10], [12, 15]]), '(-4),5,8-10,12-15'); 18 | t(mr(mr('5-10')), '5-10'); // clone 19 | // @ts-expect-error 20 | expect(() => mr(new Date())).toThrow(TypeError); 21 | expect(() => mr('2-5,8-10,*,99')).toThrow(SyntaxError); 22 | expect(() => mr('1-900719925474099100')).toThrow(RangeError); 23 | }); 24 | 25 | test('respect options', () => { 26 | expect(() => multirange('(-5)-(-1)')).toThrow(SyntaxError); 27 | expect(() => multirange('(-5)-', { parseUnbounded: true })).toThrow( 28 | SyntaxError 29 | ); 30 | expect(() => multirange('1-')).toThrow(SyntaxError); 31 | expect(() => multirange('-(-1)', { parseNegative: true })).toThrow( 32 | SyntaxError 33 | ); 34 | }); 35 | 36 | test('copy constructor copies options', () => { 37 | const o1 = multirange('5-10', { 38 | parseNegative: true, 39 | parseUnbounded: true 40 | }); 41 | const b1 = multirange(o1); 42 | expect(b1.append('-(-5)').toString()).toBe('-(-5),5-10'); 43 | 44 | const o2 = multirange('5-10'); 45 | const b2 = multirange(o2); 46 | expect(() => b2.append('-(-5)')).toThrow(SyntaxError); 47 | 48 | // If another options is explicitly provided, respect it 49 | const b3 = multirange(o1, {}); 50 | expect(() => b3.append('-5')).toThrow(SyntaxError); 51 | expect(() => b3.append('(-1)')).toThrow(SyntaxError); 52 | }); 53 | }); 54 | 55 | test('#clone', () => { 56 | const orig = mr('2-5'); 57 | const clone = orig.clone(); 58 | orig.append(6); 59 | clone.append(1); 60 | t(orig, '2-6'); 61 | t(clone, '1-5'); 62 | }); 63 | 64 | test('#append', () => { 65 | t( 66 | mr('(-5)-(-3)').append([-6, -2, 4, 5, [100, Infinity]]), 67 | '(-6)-(-2),4-5,100-' 68 | ); 69 | t( 70 | mr('1-50') 71 | .append(60) 72 | .append('70') 73 | .append(mr([80])), 74 | '1-50,60,70,80' 75 | ); 76 | }); 77 | 78 | test('#substract', () => { 79 | t(mr('(-10)-(-3),7-').subtract(-5), '(-10)-(-6),(-4)-(-3),7-'); 80 | t( 81 | mr('1-50') 82 | .subtract(40) 83 | .subtract('30') 84 | .subtract(mr([20])) 85 | .subtract([[10, 10]]), 86 | '1-9,11-19,21-29,31-39,41-50' 87 | ); 88 | }); 89 | 90 | test('#intersect', () => { 91 | t(mr('30-50,60-80,90-120').intersect('45-65,75-90'), '45-50,60-65,75-80,90'); 92 | t(mr('1-100').intersect('20-150').intersect('10-40'), '20-40'); 93 | }); 94 | 95 | test('#has', () => { 96 | expect(mr('5-20,25-100,150-300').has('5-20,25-100,150-300')).toBe(true); 97 | expect(mr('5-20,25-100,150-300').has([10, 20, 30, 40, 120])).toBe(false); 98 | // @ts-expect-error 99 | expect(() => mr(5).has()).toThrow(TypeError); 100 | expect(() => multirange('1-10').has('(-5)')).toThrow(SyntaxError); 101 | }); 102 | 103 | test('#length', () => { 104 | expect(mr('1,3,10-15,20-21').length()).toBe(10); 105 | }); 106 | 107 | test('#segmentLength', () => { 108 | expect(mr('').segmentLength()).toBe(0); 109 | expect(mr('1,3,10-15,20-21').segmentLength()).toBe(4); 110 | expect(mr('-3,8-').segmentLength()).toBe(2); 111 | expect(mr('-').segmentLength()).toBe(1); 112 | }); 113 | 114 | test('#equals', () => { 115 | expect(mr('(-7)-(-4),(-1)-3,5-').equals('(-7)-(-4),(-1)-3,5-')).toBe(true); 116 | expect(mr('2-8,10-12,15-20').equals('2-8,10-12,15-20,23-25')).toBe(false); 117 | // @ts-expect-error 118 | expect(() => mr('').equals()).toThrow(TypeError); 119 | expect(() => multirange('1-10').equals('(-5)')).toThrow(SyntaxError); 120 | }); 121 | 122 | test('#isUnbounded', () => { 123 | expect(mr([[-Infinity, 5]]).isUnbounded()).toBe(true); 124 | expect(mr(8).isUnbounded()).toBe(false); 125 | }); 126 | 127 | test('#min', () => { 128 | expect(mr('1,5,10-15').min()).toBe(1); 129 | expect(mr('-1,5,10').min()).toBe(-Infinity); 130 | expect(mr('').min()).toBe(undefined); 131 | }); 132 | 133 | test('#max', () => { 134 | expect(mr('1,5,10-15').max()).toBe(15); 135 | expect(mr('1,5,10-').max()).toBe(Infinity); 136 | expect(mr('').max()).toBe(undefined); 137 | }); 138 | 139 | test('#pop', () => { 140 | const r1 = mr('5,8-9'); 141 | expect(r1.pop()).toBe(9); 142 | expect(r1.pop()).toBe(8); 143 | expect(r1.pop()).toBe(5); 144 | expect(r1.pop()).toBe(undefined); 145 | expect(r1.pop()).toBe(undefined); 146 | expect(r1.segmentLength()).toBe(0); 147 | 148 | const r2 = mr('-5,9'); 149 | expect(r2.pop()).toBe(9); 150 | expect(r2.pop()).toBe(5); 151 | expect(r2.pop()).toBe(4); 152 | expect(r2.pop()).toBe(3); 153 | expect(r2.equals('-2')).toBe(true); 154 | 155 | expect(() => mr('8-').pop()).toThrow(RangeError); 156 | }); 157 | 158 | test('#shift', () => { 159 | const r1 = mr('5,8-9'); 160 | expect(r1.shift()).toBe(5); 161 | expect(r1.shift()).toBe(8); 162 | expect(r1.shift()).toBe(9); 163 | expect(r1.shift()).toBe(undefined); 164 | expect(r1.shift()).toBe(undefined); 165 | expect(r1.segmentLength()).toBe(0); 166 | 167 | const r2 = mr('5,9-'); 168 | expect(r2.shift()).toBe(5); 169 | expect(r2.shift()).toBe(9); 170 | expect(r2.shift()).toBe(10); 171 | expect(r2.shift()).toBe(11); 172 | expect(r2.equals('12-')).toBe(true); 173 | 174 | expect(() => mr('-8').shift()).toThrow(RangeError); 175 | }); 176 | 177 | test('#toString', () => { 178 | expect('' + mr('15-20')).toBe('15-20'); 179 | expect('' + mr([[10, Infinity]])).toBe('10-'); 180 | }); 181 | 182 | test('#toArray', () => { 183 | expect(mr('2-3,8,10-12').toArray()).toEqual([2, 3, 8, 10, 11, 12]); 184 | expect(() => mr('-5').toArray()).toThrow(RangeError); 185 | }); 186 | 187 | test('#getRanges', () => { 188 | const a = mr('5,12-15'); 189 | const ranges = a.getRanges(); 190 | expect(ranges).toEqual([ 191 | [5, 5], 192 | [12, 15] 193 | ]); 194 | ranges[0][1] = 7; 195 | ranges[1][0] = 14; 196 | t(a, '5,12-15'); // ensure the internal range data is not changed 197 | }); 198 | 199 | test('Iteration', () => { 200 | const testIter = (mr: MultiRange, expected: number[]) => { 201 | expect(Array.from(mr)).toEqual(expected); 202 | const iter = mr.getIterator(); 203 | const arr: number[] = []; 204 | let val; 205 | while (!(val = iter.next()).done) arr.push(val.value!); 206 | expect(arr).toEqual(expected); 207 | }; 208 | testIter(mr(''), []); 209 | testIter(mr('(-8)-(-6),0,2-3'), [-8, -7, -6, 0, 2, 3]); 210 | }); 211 | -------------------------------------------------------------------------------- /src/MultiRange.ts: -------------------------------------------------------------------------------- 1 | import * as mr from './fp.js'; 2 | 3 | export type Initializer = string | number | (number | mr.Range)[] | MultiRange; 4 | 5 | /** 6 | * Parses and manipulates multiple integer ranges. 7 | * This class exists for compatibility purposes. 8 | * Prefer the function style API instead. 9 | */ 10 | export class MultiRange { 11 | private ranges: mr.MultiIntegerRange; 12 | private options: mr.Options; 13 | 14 | /** 15 | * Creates a new MultiRange object. 16 | */ 17 | constructor(data?: Initializer, options?: mr.Options) { 18 | this.ranges = []; 19 | this.options = { 20 | parseNegative: !!(options || {}).parseNegative, 21 | parseUnbounded: !!(options || {}).parseUnbounded 22 | }; 23 | 24 | if (typeof data === 'string') { 25 | this.ranges = mr.parse(data, options); 26 | } else if (typeof data === 'number' || Array.isArray(data)) { 27 | this.ranges = mr.normalize(data); 28 | } else if (data instanceof MultiRange) { 29 | this.ranges = data.ranges; 30 | if (options === undefined) this.options = data.options; 31 | } else if (data !== undefined) { 32 | throw new TypeError('Invalid input'); 33 | } 34 | } 35 | 36 | /** 37 | * Clones this instance. 38 | * @returns The cloned instance. 39 | */ 40 | public clone(): MultiRange { 41 | return new MultiRange(this); 42 | } 43 | 44 | /** 45 | * Appends to this instance. 46 | * @param value - The data to append. 47 | */ 48 | public append(value: Initializer): MultiRange { 49 | this.ranges = mr.append( 50 | this.ranges, 51 | new MultiRange(value, this.options).ranges 52 | ); 53 | return this; 54 | } 55 | 56 | /** 57 | * Subtracts from this instance. 58 | * @param value - The data to subtract. 59 | */ 60 | public subtract(value: Initializer): MultiRange { 61 | this.ranges = mr.subtract( 62 | this.ranges, 63 | new MultiRange(value, this.options).ranges 64 | ); 65 | return this; 66 | } 67 | 68 | /** 69 | * Remove integers which are not included in `value`, 70 | * yielding the intersection of this and `value`. 71 | * @param value - The data to calculate the intersetion. 72 | */ 73 | public intersect(value: Initializer): MultiRange { 74 | this.ranges = mr.intersect( 75 | this.ranges, 76 | new MultiRange(value, this.options).ranges 77 | ); 78 | return this; 79 | } 80 | 81 | /** 82 | * Exports the whole range data as an array of arrays. 83 | * @returns An copied array of range segments. 84 | */ 85 | public getRanges(): number[][] { 86 | const result: number[][] = []; 87 | for (let r of this.ranges) result.push([r[0], r[1]]); 88 | return result; 89 | } 90 | 91 | /** 92 | * Checks if this instance contains the specified value. 93 | * @param value - Value to be checked. 94 | * @returns True if the specified value is included in the instance. 95 | */ 96 | public has(value: Initializer): boolean { 97 | if (value === undefined) throw new TypeError('Invalid input'); 98 | return mr.has(this.ranges, new MultiRange(value, this.options).ranges); 99 | } 100 | 101 | /** 102 | * Returns the number of range segments. 103 | * For example, the segmentLength of `2-5,7,9-11` is 3. 104 | * @returns The number of segments. Returns 0 for an empty instance. 105 | */ 106 | public segmentLength(): number { 107 | return this.ranges.length; 108 | } 109 | 110 | /** 111 | * Calculates how many numbers are effectively included in this instance. 112 | * For example, the length of `1-10,51-60,90` is 21. 113 | * @returns The number of integer values in this instance. 114 | * Returns `Infinity` for an unbounded range. 115 | */ 116 | public length(): number { 117 | return mr.length(this.ranges); 118 | } 119 | 120 | /** 121 | * Checks if two instances of MultiRange are identical. 122 | * @param cmp - The data to compare. 123 | * @returns True if `cmp` is exactly the same as this instance. 124 | */ 125 | public equals(cmp: Initializer): boolean { 126 | if (cmp === undefined) throw new TypeError('Invalid input'); 127 | return mr.equals(this.ranges, new MultiRange(cmp, this.options).ranges); 128 | } 129 | 130 | /** 131 | * Checks if the current instance is unbounded (i.e., infinite). 132 | */ 133 | public isUnbounded(): boolean { 134 | return mr.isUnbounded(this.ranges); 135 | } 136 | 137 | /** 138 | * Returns the minimum integer contained in this insntance. 139 | * Can be -Infinity or undefined. 140 | * @returns The minimum integer of this instance. 141 | */ 142 | public min(): number | undefined { 143 | return mr.min(this.ranges); 144 | } 145 | 146 | /** 147 | * Returns the maximum number contained in this insntance. 148 | * Can be Infinity or undefined. 149 | * @returns The maximum integer of this instance. 150 | */ 151 | public max(): number | undefined { 152 | return mr.max(this.ranges); 153 | } 154 | 155 | /** 156 | * Removes the smallest integer from this instance and returns it. 157 | * @returns The minimum integer removed from this instance. 158 | */ 159 | public shift(): number | undefined { 160 | const min = this.min(); 161 | this.ranges = mr.tail(this.ranges); 162 | return min; 163 | } 164 | 165 | /** 166 | * Removes the largest integer from this instance and returns it. 167 | * @returns The maximum integer removed from this instance. 168 | */ 169 | public pop(): number | undefined { 170 | const max = this.max(); 171 | this.ranges = mr.init(this.ranges); 172 | return max; 173 | } 174 | 175 | /** 176 | * Returns the string respresentation of this MultiRange. 177 | */ 178 | public toString(): string { 179 | return mr.stringify(this.ranges); 180 | } 181 | 182 | /** 183 | * Builds a flat array of integers which holds all elements in this instance. 184 | * Note that this may be slow and memory-consuming for large ranges. 185 | * Consider using the iterator whenever possible. 186 | */ 187 | public toArray(): number[] { 188 | return mr.flatten(this.ranges); 189 | } 190 | 191 | /** 192 | * Returns an ES6-compatible iterator. 193 | */ 194 | public getIterator(): { next: () => { done?: boolean; value?: number } } { 195 | return mr.iterate(this.ranges)[Symbol.iterator](); 196 | } 197 | 198 | public [Symbol.iterator]() { 199 | return mr.iterate(this.ranges)[Symbol.iterator](); 200 | } 201 | } 202 | 203 | export const multirange = (data?: Initializer, options?: mr.Options) => 204 | new MultiRange(data, options); 205 | -------------------------------------------------------------------------------- /src/fp.test.ts: -------------------------------------------------------------------------------- 1 | import * as mr from './fp'; 2 | import { MIR, Range } from './fp'; 3 | 4 | describe('parse', () => { 5 | test('no option', () => { 6 | expect(mr.parse('')).toEqual([]); 7 | expect(mr.parse('0')).toEqual([[0, 0]]); 8 | expect(mr.parse('1-3,5,7-10')).toEqual([ 9 | [1, 3], 10 | [5, 5], 11 | [7, 10] 12 | ]); 13 | expect(() => mr.parse('5-')).toThrow(SyntaxError); 14 | expect(() => mr.parse('(-10)')).toThrow(SyntaxError); 15 | }); 16 | 17 | test('parse negative', () => { 18 | expect(mr.parse('(-5)', { parseNegative: true })).toEqual([[-5, -5]]); 19 | expect( 20 | mr.parse('(-10)-(-7),(-5),(-3)-(-1)', { parseNegative: true }) 21 | ).toEqual([ 22 | [-10, -7], 23 | [-5, -5], 24 | [-3, -1] 25 | ]); 26 | }); 27 | 28 | test('parse unbounded', () => { 29 | expect(mr.parse('10-', { parseUnbounded: true })).toEqual([[10, Infinity]]); 30 | expect(mr.parse('-5', { parseUnbounded: true })).toEqual([[-Infinity, 5]]); 31 | expect(mr.parse('-5,10-', { parseUnbounded: true })).toEqual([ 32 | [-Infinity, 5], 33 | [10, Infinity] 34 | ]); 35 | }); 36 | 37 | test('strip spaces', () => { 38 | expect(mr.parse('1 -3, 5,\t7-10\n')).toEqual([ 39 | [1, 3], 40 | [5, 5], 41 | [7, 10] 42 | ]); 43 | }); 44 | 45 | test('normalize', () => { 46 | expect(mr.parse('1,8,2-4,7,5-6,10-9')).toEqual([[1, 10]]); 47 | expect(mr.parse('10-8,7-5,1-4')).toEqual([[1, 10]]); 48 | expect(parseAll('8-10,(-5),100-, 0,7,(-1)-(-4),1-6')).toEqual([ 49 | [-5, 10], 50 | [100, Infinity] 51 | ]); 52 | }); 53 | 54 | test('throw SyntaxError for invalid input', () => { 55 | expect(() => mr.parse('abc')).toThrow(SyntaxError); 56 | expect(() => mr.parse('1.5')).toThrow(SyntaxError); 57 | expect(() => mr.parse('2-5,8-10,*,99')).toThrow(SyntaxError); 58 | expect(() => mr.parse('2,,5')).toThrow(SyntaxError); 59 | expect(() => mr.parse(',')).toThrow(SyntaxError); 60 | // @ts-expect-error 61 | expect(() => mr.parse()).toThrow(TypeError); 62 | expect(() => mr.parse('')).not.toThrow(); 63 | }); 64 | 65 | test('throw RangeError for huge integer strings', () => { 66 | expect(() => mr.parse('1-900719925474099100')).toThrow(RangeError); 67 | expect(() => parseAll('(-900719925474099100)')).toThrow(RangeError); 68 | }); 69 | }); 70 | 71 | // prettier-ignore 72 | test('normalize', () => { 73 | const t = (a: Parameters[0], expected: Range[]) => 74 | expect(mr.normalize(a)).toEqual(expected); 75 | 76 | t([[7, 5], 1], [[1, 1], [5, 7]]); 77 | t([3, 1, [5, 7], [0, -3]], [[-3, 1], [3, 3], [5, 7]]); 78 | t(5, [[5, 5]]); 79 | t([[1, Infinity], [-Infinity, 0]], [[-Infinity, Infinity]]); 80 | t([], []); 81 | t(undefined, []); 82 | // @ts-expect-error 83 | expect(() => mr.normalize([[1]])).toThrow(TypeError); 84 | // @ts-expect-error 85 | expect(() => mr.normalize([[2, 8, 3]])).toThrow(TypeError); 86 | // @ts-expect-error 87 | expect(() => mr.normalize(['str'])).toThrow(TypeError); 88 | expect(() => mr.normalize([[Infinity, Infinity]])).toThrow(RangeError); 89 | expect(() => mr.normalize([[-Infinity, -Infinity]])).toThrow(RangeError); 90 | expect(() => mr.normalize([Infinity])).toThrow(RangeError); 91 | expect(() => mr.normalize(Infinity)).toThrow(RangeError); 92 | }); 93 | 94 | test('initialize', () => { 95 | const t = (a: Parameters[0], expected: Range[]) => 96 | expect(mr.initialize(a)).toEqual(expected); 97 | t('5-10', [[5, 10]]); 98 | t([[9, 2]], [[2, 9]]); 99 | t('', []); 100 | t([], []); 101 | t(undefined, []); 102 | }); 103 | 104 | const parseAll = (a: string) => 105 | mr.parse(a, { parseNegative: true, parseUnbounded: true }); 106 | 107 | const makeT1 = 108 | ( 109 | testFunc: (data: MIR) => T, 110 | resultFilter: (result: T) => R = i => i as unknown as R 111 | ) => 112 | (data: string, expected: R) => { 113 | expect(resultFilter(testFunc(parseAll(data)))).toBe(expected); 114 | }; 115 | 116 | const makeT2 = 117 | ( 118 | testFunc: (a: MIR, b: MIR) => T, 119 | resultFilter: (result: T) => R = i => i as unknown as R, 120 | swappable?: boolean 121 | ) => 122 | (a: string, b: string, expected: R) => { 123 | expect(resultFilter(testFunc(parseAll(a), parseAll(b)))).toBe(expected); 124 | if (swappable) { 125 | expect(resultFilter(testFunc(parseAll(b), parseAll(a)))).toBe(expected); 126 | } 127 | }; 128 | 129 | describe('append', () => { 130 | const t = makeT2(mr.append, mr.stringify, true); 131 | 132 | test('positive', () => { 133 | t('5-10', '5', '5-10'); 134 | t('5-10', '8', '5-10'); 135 | t('5-10', '10', '5-10'); 136 | t('5-10', '11', '5-11'); 137 | t('5-10', '4', '4-10'); 138 | t('5-10', '15', '5-10,15'); 139 | t('5-10', '1', '1,5-10'); 140 | t('5-10,15-20', '12', '5-10,12,15-20'); 141 | t('5-10,15-20', '3', '3,5-10,15-20'); 142 | t('5-10,15-20', '25', '5-10,15-20,25'); 143 | t('1-10,12-15,17-20', '11', '1-15,17-20'); 144 | t('1-10,12-15,17-20', '1-100', '1-100'); 145 | t('1-10,12-15,17-20,100', '5-14', '1-15,17-20,100'); 146 | t('1-10,12-15,17-20', '14-19', '1-10,12-20'); 147 | t('1,8,10', '2-3,4-5,6,7,9', '1-10'); 148 | }); 149 | 150 | test('negative', () => { 151 | t('(-5)-(-3)', '(-6),(-2),4,5', '(-6)-(-2),4-5'); 152 | t('(-5)-(-3)', '3', '(-5)-(-3),3'); 153 | }); 154 | 155 | test('unbounded', () => { 156 | t('5-', '10', '5-'); 157 | t('5-', '4', '4-'); 158 | t('5-', '3', '3,5-'); 159 | t('5-', '10-', '5-'); 160 | t('5-', '2-', '2-'); 161 | t('-5', '10', '-5,10'); 162 | t('-5', '6', '-6'); 163 | t('-5', '2', '-5'); 164 | t('-5', '-10', '-10'); 165 | t('-5', '-2', '-5'); 166 | t('-5', '3-', '-'); 167 | t('-5', '6-', '-'); 168 | t('-5,8-', '1-10', '-'); 169 | t('-3', '5-', '-3,5-'); 170 | t('-(-10)', '(-8),0,10-', '-(-10),(-8),0,10-'); 171 | t('-(-10)', '(-8),0,10-', '-(-10),(-8),0,10-'); 172 | t('-', '(-8),0,10-', '-'); 173 | t('-', '-', '-'); 174 | t('', '-', '-'); 175 | }); 176 | }); 177 | 178 | describe('subtract', () => { 179 | const t = makeT2(mr.subtract, mr.stringify); 180 | 181 | test('positive', () => { 182 | t('1-10', '100', '1-10'); 183 | t('1-10', '0', '1-10'); 184 | t('1-10', '11', '1-10'); 185 | t('1-10', '1', '2-10'); 186 | t('1-10', '10', '1-9'); 187 | t('1-10', '1-10', ''); 188 | t('1-10', '5-8', '1-4,9-10'); 189 | t('1-10,20-30', '11-19', '1-10,20-30'); 190 | t('1-10,20-30', '5-25', '1-4,26-30'); 191 | t('1-100', '1,3,5,7,9', '2,4,6,8,10-100'); 192 | }); 193 | 194 | test('negative', () => { 195 | t('(-10)-(-3)', '5', '(-10)-(-3)'); 196 | t('(-10)-(-3)', '(-10)', '(-9)-(-3)'); 197 | t('(-10)-(-3)', '(-3)', '(-10)-(-4)'); 198 | t('(-10)-(-3)', '(-5)', '(-10)-(-6),(-4)-(-3)'); 199 | t( 200 | '(-30),(-20)-(-10),(-8)-0,8', 201 | '(-20),(-12)-(-5)', 202 | '(-30),(-19)-(-13),(-4)-0,8' 203 | ); 204 | }); 205 | 206 | test('unbounded', () => { 207 | t('10-20', '15-', '10-14'); 208 | t('10-20', '-15', '16-20'); 209 | t('10-20', '-12,18-', '13-17'); 210 | t('-12,18-', '5', '-4,6-12,18-'); 211 | t('-12,18-', '5,20', '-4,6-12,18-19,21-'); 212 | t('-12,18-', '-20,3-', ''); 213 | t('-12,18-', '-', ''); 214 | t('-', '200-205', '-199,206-'); 215 | t('-', '-100,150-', '101-149'); 216 | t('-', '-100,120,130,150-', '101-119,121-129,131-149'); 217 | t('-', '-', ''); 218 | }); 219 | }); 220 | 221 | describe('intersect', () => { 222 | const t = makeT2(mr.intersect, mr.stringify, true); 223 | 224 | test('positive', () => { 225 | t('1-5', '8', ''); 226 | t('5-100', '1,10,50,70,80,90,100,101', '10,50,70,80,90,100'); 227 | t('5-100', '1-10,90-110', '5-10,90-100'); 228 | t('30-50,60-80,90-120', '45-65,75-90', '45-50,60-65,75-80,90'); 229 | t('10,12,14,16,18,20', '11,13,15,17,19,21', ''); 230 | t('10,12,14,16,18,20', '10,12,14,16,18,20', '10,12,14,16,18,20'); 231 | t('10-12,14-16,18-20', '11,13,15,17,19,21', '11,15,19'); 232 | t('10-12,14-16,18-20', '10-12,14-16,18-20', '10-12,14-16,18-20'); 233 | t('10-12,14-16,18-20', '20-22,24-26,28-30', '20'); 234 | t('', '', ''); 235 | }); 236 | 237 | test('negative', () => { 238 | t('0', '0', '0'); 239 | t('(-50)-50', '(-30)-30', '(-30)-30'); 240 | t('(-50)-50', '5-30', '5-30'); 241 | t('(-50)-50', '(-100)-(-20)', '(-50)-(-20)'); 242 | t('(-20)-(-18),(-16)-(-14),(-12)-(-10)', '1-50', ''); 243 | t( 244 | '(-20)-(-18),(-16)-(-14),(-12)-(-10)', 245 | '(-19)-(-12)', 246 | '(-19)-(-18),(-16)-(-14),(-12)' 247 | ); 248 | }); 249 | 250 | test('unbounded', () => { 251 | t('1-', '4-', '4-'); 252 | t('100-', '-300', '100-300'); 253 | t('-5', '-0', '-0'); 254 | t('-10,50,90-', '0-100', '0-10,50,90-100'); 255 | t('-40,70,80-', '-50,70,90-', '-40,70,90-'); 256 | t('-10', '80-', ''); 257 | t('-', '-', '-'); 258 | t('-', '-90', '-90'); 259 | t('-', '80-', '80-'); 260 | t('-', '40-45,(-20)', '(-20),40-45'); 261 | }); 262 | }); 263 | 264 | test('monkey test', () => { 265 | const arrs: number[][] = [[], [], []]; 266 | for (let i = -100; i <= 100; i++) { 267 | arrs[Math.floor(Math.random() * 3)].push(i); 268 | } 269 | 270 | const shuffle = (array: number[]) => { 271 | const result = array.slice(0); 272 | for (let i = result.length - 1; i > 0; i--) { 273 | const j = Math.floor(Math.random() * (i + 1)); 274 | [result[i], result[j]] = [result[j], result[i]]; 275 | } 276 | return result; 277 | }; 278 | 279 | const mirs = arrs.map(shuffle).map(mr.normalize); 280 | const res1 = mirs.reduce(mr.append, []); 281 | expect(mr.stringify(res1)).toBe('(-100)-100'); 282 | 283 | const res2 = mirs.reduce(mr.subtract, [[-Infinity, Infinity]]); 284 | expect(mr.stringify(res2)).toBe('-(-101),101-'); 285 | 286 | expect(mr.intersect(mirs[0], mirs[1]).length).toBe(0); 287 | expect(mr.intersect(mirs[0], mirs[2]).length).toBe(0); 288 | expect(mr.intersect(mirs[1], mirs[2]).length).toBe(0); 289 | }); 290 | 291 | describe('has', () => { 292 | const t = makeT2(mr.has); 293 | 294 | test('bounded', () => { 295 | t('5-20,25-100,150-300', '7', true); 296 | t('5-20,25-100,150-300', '25', true); 297 | t('5-20,25-100,150-300', '300', true); 298 | t('5-20,25-100,150-300', '5-10', true); 299 | t('5-20,25-100,150-300', '5-10,25', true); 300 | t('5-20,25-100,150-300', '25-40,160', true); 301 | t('5-20,25-100,150-300', '5-20,25-100,150-300', true); 302 | t('5-20,25-100,150-300', '5,80,18-7,280,100,15-20,25,200-250', true); 303 | t('5-20,25-100,150-300', '', true); 304 | t('(-300)-(-200),(-50)-(-30),20-25', '(-40),(-250)-(-280)', true); 305 | t('(-300)-(-200),(-50)-(-30),20-25', '(-200)-(-250),(-280)-(-220)', true); 306 | t('5-20,25-100,150-300', '3', false); 307 | t('5-20,25-100,150-300', '22', false); 308 | t('5-20,25-100,150-300', '500', false); 309 | t('5-20,25-100,150-300', '10-21', false); 310 | t('5-20,25-100,150-300', '149-400', false); 311 | t('5-20,25-100,150-300', '5-20,25-103,150-300', false); 312 | t('5-20,25-100,150-300', '5,80,18-7,280,100,15-20,25,200-250,301', false); 313 | t('(-300)-(-200),(-50)-(-30),20-25', '(-40),(-100)', false); 314 | }); 315 | 316 | test('unbounded', () => { 317 | t('-', '5', true); 318 | t('-20,40-', '70', true); 319 | t('-20,40', '10', true); 320 | t('-20,30-35,40-', '-10,30,31,50-', true); 321 | t('-', '-', true); 322 | t('-20,40-', '30', false); 323 | t('-20,40-', '10-50', false); 324 | t('-20,40-', '10-', false); 325 | t('-20,40-', '-50', false); 326 | t('-20,40-', '-', false); 327 | }); 328 | }); 329 | 330 | test('length', () => { 331 | const t = makeT1(mr.length); 332 | t('', 0); 333 | t('5', 1); 334 | t('5-10', 6); 335 | t('1,3,10-15,20-21', 10); 336 | t('(-7)-(-4),(-1)-3,5', 10); 337 | t('-5', Infinity); 338 | t('8-', Infinity); 339 | t('-', Infinity); 340 | }); 341 | 342 | test('equals', () => { 343 | const t = makeT2(mr.equals, b => b, true); 344 | t('', '', true); 345 | t('5', '5', true); 346 | t('2-8,10-12,15-20', '2-8,10-12,15-20', true); 347 | t('(-7)-(-4),(-1)-3,5', '(-7)-(-4),(-1)-3,5', true); 348 | t('-8,20-', '-8,20-', true); 349 | t('', '5', false); 350 | t('5', '5-6', false); 351 | t('2-8', '2-7', false); 352 | t('2-8,10-12,15-20', '2-8,10-12,15-20,23-25', false); 353 | const a = mr.parse('7-8,10'); 354 | expect(mr.equals(a, a)).toBe(true); 355 | }); 356 | 357 | test('isUnbounded', () => { 358 | const t = makeT1(mr.isUnbounded); 359 | t('-5', true); 360 | t('0-5,10-', true); 361 | t('5-8', false); 362 | t('', false); 363 | }); 364 | 365 | test('min', () => { 366 | const t = makeT1(mr.min); 367 | t('1,5,10-15', 1); 368 | t('-1,5,10', -Infinity); 369 | t('', undefined); 370 | }); 371 | 372 | test('max', () => { 373 | const t = makeT1(mr.max); 374 | t('1,5,10-15', 15); 375 | t('1,5,10-', Infinity); 376 | t('', undefined); 377 | }); 378 | 379 | test('at', () => { 380 | const t = ( 381 | s: string, 382 | index: number, 383 | v: number | undefined | typeof RangeError 384 | ) => 385 | v === RangeError 386 | ? expect(() => 387 | mr.at(mr.parse(s, { parseUnbounded: true }), index) 388 | ).toThrow(v) 389 | : expect(mr.at(mr.parse(s, { parseUnbounded: true }), index)).toBe(v); 390 | 391 | t('2-4,8-10', 0, 2); 392 | t('2-4,8-10', 1, 3); 393 | t('2-4,8-10', 2, 4); 394 | t('2-4,8-10', 3, 8); 395 | t('2-4,8-10', 5, 10); 396 | t('2-4,8-10', 6, undefined); 397 | t('2-4,8-10', -1, 10); 398 | t('2-4,8-10', -6, 2); 399 | t('2-4,8-10', -7, undefined); 400 | t('2-4,8-10', Infinity, RangeError); 401 | t('2-4,8-10', -Infinity, RangeError); 402 | 403 | t('2-4,8-', 6, 11); 404 | t('-4,8-10', 6, RangeError); 405 | t('-4,8-10', -7, 1); 406 | t('2-4,8-', -7, RangeError); 407 | 408 | t('', Infinity, RangeError); 409 | t('', -Infinity, RangeError); 410 | t('', 0, undefined); 411 | t('', 1, undefined); 412 | t('', -1, undefined); 413 | 414 | const a = mr.parse('(-3)-0,5-6,9,12-14', { parseNegative: true }); 415 | const vals = mr.flatten(a); 416 | for (let i = 0; i < vals.length; i++) { 417 | expect(mr.at(a, i)).toBe(vals[i]); 418 | expect(mr.at(a, i - vals.length)).toBe(vals[i]); 419 | } 420 | }); 421 | 422 | test('tail', () => { 423 | const t = makeT1(mr.tail, mr.stringify); 424 | t('1,5,10-15', '5,10-15'); 425 | t('0,5,10-', '5,10-'); 426 | t('', ''); 427 | expect(() => mr.tail(parseAll('-1,5,10'))).toThrow(RangeError); 428 | }); 429 | 430 | test('init', () => { 431 | const t = makeT1(mr.init, mr.stringify); 432 | t('1,5,10-15', '1,5,10-14'); 433 | t('-0,5,10', '-0,5'); 434 | t('', ''); 435 | expect(() => mr.init(parseAll('5,10-'))).toThrow(RangeError); 436 | }); 437 | 438 | test('stringify', () => { 439 | const t = (a: string) => expect(mr.stringify(parseAll(a))).toBe(a); 440 | t('15-20,30-70'); 441 | t('0'); 442 | t('(-8)-(-5)'); 443 | t('-'); 444 | t('-10'); 445 | t('10-'); 446 | t(''); 447 | 448 | const r = mr.parse('2-3,5,7-9'); 449 | const t2 = (individualThreshold: number, expected: string) => 450 | expect(mr.stringify(r, { individualThreshold })).toBe(expected); 451 | t2(0, '2-3,5-5,7-9'); 452 | t2(1, '2-3,5,7-9'); 453 | t2(2, '2,3,5,7-9'); 454 | t2(3, '2,3,5,7,8,9'); 455 | t2(4, '2,3,5,7,8,9'); 456 | }); 457 | 458 | test('flatten', () => { 459 | const t = (a: string, expected: number[]) => 460 | expect(mr.flatten(parseAll(a))).toEqual(expected); 461 | t('', []); 462 | t('2', [2]); 463 | t('2-5', [2, 3, 4, 5]); 464 | t('2-3,8,10-12', [2, 3, 8, 10, 11, 12]); 465 | t('(-8)-(-6),0,2-3', [-8, -7, -6, 0, 2, 3]); 466 | expect(() => mr.flatten([[-Infinity, -5]])).toThrow(RangeError); 467 | expect(() => mr.flatten([[3, Infinity]])).toThrow(RangeError); 468 | }); 469 | 470 | test('iterate', () => { 471 | expect([...mr.iterate([[1, 3]])]).toEqual([1, 2, 3]); 472 | expect([...mr.iterate([[1, 3]], { descending: true })]).toEqual([3, 2, 1]); 473 | 474 | const r = parseAll('(-8)-(-6),2,5-7'); 475 | expect(Array.from(mr.iterate(r))).toEqual([-8, -7, -6, 2, 5, 6, 7]); 476 | expect(Array.from(mr.iterate(r, { descending: true }))).toEqual([ 477 | 7, 6, 5, 2, -6, -7, -8 478 | ]); 479 | 480 | expect([...mr.iterate([])]).toEqual([]); 481 | expect([...mr.iterate([], { descending: true })]).toEqual([]); 482 | expect(() => mr.iterate(parseAll('3-'))).toThrow(RangeError); 483 | }); 484 | -------------------------------------------------------------------------------- /src/fp.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A `[min, max]` tuple to denote one integer range. 3 | */ 4 | export type Range = readonly [min: number, max: number]; 5 | 6 | /** 7 | * An immutable Range array. This is the fundamental data type of this package. 8 | * 9 | * **Warning**: Most functions of this package work correctly 10 | * only when **normalized** MultiIntegerRange's are passed. 11 | * If you have a Range array that may not be sorted, use `normalize()` first. 12 | */ 13 | export type MultiIntegerRange = readonly Range[]; 14 | 15 | export type MIR = MultiIntegerRange; // shorthand 16 | 17 | export type Options = { 18 | /** 19 | * If set to true, allows parsing negative integers enclosed in parentheses. 20 | */ 21 | readonly parseNegative?: boolean; 22 | /** 23 | * If set to true, allows parsing unbounded ranges like `10-` or `-10`. 24 | */ 25 | readonly parseUnbounded?: boolean; 26 | }; 27 | 28 | /** 29 | * Parses a string and creates a new MultiIntegerRange. 30 | * 31 | * - `options.parseNegative` (boolean): When set to true, parses negative integers enclosed in parentheses. 32 | * - `options.parseUnbounded` (boolean): When set to true, parses unbounded ranges like `10-` or `-10`. 33 | * 34 | * This is the default parser, but you don't necessarily have to use this. 35 | * You can create your own parser to suit your needs 36 | * as long as it produces a normalized array of `Range`s. 37 | * 38 | * @param data - The string to parse. 39 | * @param options - Options to modify the parsing behavior. 40 | * @returns A new normalized MultiIntegerRange. 41 | * @example 42 | * parse('1-10'); // [[1, 10]] 43 | * parse(' 10-, 7', \{ parseUnbounded: true \}); // [[7, 7], [10, Infinity]] 44 | */ 45 | export const parse = (data: string, options?: Options): MIR => { 46 | const { parseNegative = false, parseUnbounded = false } = options || {}; 47 | 48 | const toInt = (str: string): number => { 49 | const m = str.match(/^\(?(\-?\d+)/)!; 50 | const int = parseInt(m[1], 10); 51 | if (int < Number.MIN_SAFE_INTEGER || Number.MAX_SAFE_INTEGER < int) 52 | throw new RangeError('The number is too big or too small.'); 53 | return int; 54 | }; 55 | 56 | const s = data.replace(/\s/g, ''); 57 | if (!s.length) return []; 58 | 59 | const int = parseNegative ? '(\\d+|\\(\\-?\\d+\\))' : '(\\d+)'; 60 | const intMatch = new RegExp('^' + int + '$'); 61 | const rangeMatch = new RegExp('^' + int + '?\\-' + int + '?$'); 62 | 63 | const unsorted: (Range | number)[] = []; 64 | for (const str of s.split(',')) { 65 | // TODO: Switch to String.matchAll() 66 | let match: RegExpMatchArray | null; 67 | if ((match = str.match(intMatch))) { 68 | const val = toInt(match[1]); 69 | unsorted.push(val); 70 | } else if ((match = str.match(rangeMatch))) { 71 | if ( 72 | !parseUnbounded && 73 | (match[1] === undefined || match[2] === undefined) 74 | ) { 75 | throw new SyntaxError('Unexpected unbouded range notation'); 76 | } 77 | let min = match[1] === undefined ? -Infinity : toInt(match[1]); 78 | let max = match[2] === undefined ? +Infinity : toInt(match[2]); 79 | unsorted.push([min, max]); 80 | } else { 81 | throw new SyntaxError('Invalid input'); 82 | } 83 | } 84 | return normalize(unsorted); 85 | }; 86 | 87 | /** 88 | * Takes a number or an unsorted array of ranges, 89 | * and returns a new normalized MultiIntegerRange. 90 | * 91 | * Here, "normalized" means the range data is in the smallest possible 92 | * representation and is sorted in ascending order. 93 | * 94 | * This is the only function that can take an unsorted array of Range's. 95 | * Unsorted range data MUST be normalized before being passed to 96 | * other functions such as `append()` and `length()`. 97 | * 98 | * @param data - A number or an unsorted array, e.g., `[[7, 5], 1]`. 99 | * @returns Normalized array, e.g., `[[1, 1], [5, 7]]`. 100 | * @example 101 | * normalize(5); // [[5, 5]] 102 | * normalize([1, 8]); // [[1, 1], [8, 8]] 103 | * normalize([[1, 8]]); // [[1, 8]] 104 | * normalize([2, 3, 1, 5, 4, 0, 1, 3]); // [[0, 5]] 105 | * normalize([[Infinity, 1]]); // [[1, Infinity]] 106 | */ 107 | export const normalize = (data?: (number | Range)[] | number): MIR => { 108 | const result: Range[] = []; 109 | if (data === undefined) return result; 110 | if (typeof data === 'number') return normalize([data]); 111 | for (const r of data) { 112 | let newRange: Range; 113 | if (typeof r === 'number') { 114 | newRange = [r, r]; 115 | } else if (Array.isArray(r) && r.length === 2) { 116 | newRange = r[0] <= r[1] ? [r[0], r[1]] : [r[1], r[0]]; 117 | } else { 118 | throw new TypeError('Unrecognized range member.'); 119 | } 120 | if ( 121 | (newRange[0] === Infinity && newRange[1] === Infinity) || 122 | (newRange[0] === -Infinity && newRange[1] === -Infinity) 123 | ) { 124 | throw new RangeError( 125 | 'Infinity can be used only within an unbounded range segment' 126 | ); 127 | } 128 | const overlap = findOverlap(result, newRange); 129 | result.splice(overlap.lo, overlap.count, overlap.union); 130 | } 131 | return result; 132 | }; 133 | 134 | /** 135 | * Takes any supported data and returns a normalized MultiIntegerRange. 136 | * Conditionally calls either `parse()` or `normalize()` under the hood. 137 | * This is an equivalent of "initializer" constructor of version ≤ 4. 138 | * @param data - Anything understood by either `parse()` or `normalize()`. 139 | * @param options - Parse options passed to `parse()`. 140 | * @returns A new normalized MultiIntegerRange. 141 | * @example 142 | * initialize(5); // [[5, 5]] 143 | * initialize('2-8'); // [[2,8]] 144 | */ 145 | export const initialize = ( 146 | data?: (number | Range)[] | number | string, 147 | options?: Options 148 | ): MIR => { 149 | return typeof data === 'string' ? parse(data, options) : normalize(data); 150 | }; 151 | 152 | /** 153 | * Calculates the union of two specified ranges. 154 | * @param a - Range A. 155 | * @param b - Range B. 156 | * @private 157 | * @returns Union of `a` and `b`. 158 | * Returns `null` if `a` and `b` do not touch nor intersect. 159 | */ 160 | const calcUnion = (a: Range, b: Range): Range | null => { 161 | if (a[1] + 1 < b[0] || a[0] - 1 > b[1]) { 162 | return null; // cannot make union 163 | } 164 | return [a[0] < b[0] ? a[0] : b[0], a[1] > b[1] ? a[1] : b[1]]; 165 | }; 166 | 167 | /** 168 | * Determines how the given range overlaps or touches the existing ranges. 169 | * This is a helper method that calculates how an append/subtract operation 170 | * affects the existing range members. 171 | * @private 172 | * @param target - The range array to test. 173 | * @returns An object containing information about how the given range 174 | * overlaps or touches this instance. 175 | */ 176 | const findOverlap = ( 177 | data: MIR, 178 | target: Range 179 | ): { 180 | lo: number; 181 | count: number; 182 | union: Range; 183 | } => { 184 | // a b c d e f g h i j k l m 185 | //-------------------------------------------------------------------- 186 | // |----(0)----| |---(1)---| |---(2)---| |--(3)--| 187 | // |------------(A)--------------| 188 | // |-(B)-| 189 | // |-(C)-| 190 | // 191 | // (0)-(3) represent the existing ranges (data), 192 | // and (A)-(C) are the ranges being passed to this function (target). 193 | // 194 | // A pseudocode findOverlap(A) returns { lo: 0, count: 3, union: }, 195 | // meaning (A) overlaps the 3 existing ranges from index 0. 196 | // 197 | // findOverlap(B) returns { lo: 2, count: 1, union: }, 198 | // meaning (B) "touches" one range element, (2). 199 | // 200 | // findOverlap(C) returns { lo: 3, count: 0, union: } 201 | // meaning (C) is between (2) and (3) but overlaps/touches neither of them. 202 | 203 | const countOverlap = (lo: number) => { 204 | let count = 0, 205 | tmp: Range | null, 206 | union = target; 207 | while ( 208 | lo + count < data.length && 209 | (tmp = calcUnion(union, data[lo + count])) 210 | ) { 211 | union = tmp; 212 | count++; 213 | } 214 | return { lo, count, union }; 215 | }; 216 | 217 | const t0 = target[0]; 218 | if (data.length > 0 && t0 < data[0][0] - 1) { 219 | return countOverlap(0); 220 | } else if (data.length > 0 && t0 > data[data.length - 1][1] + 1) { 221 | return { lo: data.length, count: 0, union: target }; 222 | } else { 223 | // perform binary search 224 | let imin = 0, 225 | imax = data.length - 1; 226 | while (imax >= imin) { 227 | const imid = imin + Math.floor((imax - imin) / 2); 228 | if ( 229 | (imid === 0 || t0 > data[imid - 1][1] + 1) && 230 | t0 <= data[imid][1] + 1 231 | ) { 232 | return countOverlap(imid); 233 | } else if (data[imid][1] + 1 < t0) { 234 | imin = imid + 1; 235 | } else { 236 | imax = imid - 1; 237 | } 238 | } 239 | return { lo: 0, count: 0, union: target }; 240 | } 241 | }; 242 | 243 | /** 244 | * Appends two MultiIntegerRange's. 245 | * @param a - The first value. 246 | * @param b - The second value. 247 | * @returns A new MultiIntegerRange containing all integers that belong to 248 | * **either `a` or `b` (or both)**. 249 | * @example 250 | * append([[1, 5]], [[3, 8], [10, 15]]); // [[1, 8], [10, 15]] 251 | * append([[5, 9]], [[-Infinity, 2]]); // [[-Infinity, 2], [5, 9]] 252 | */ 253 | export const append = (a: MIR, b: MIR): MIR => { 254 | let result = a.slice(0); 255 | for (let r of b) { 256 | const overlap = findOverlap(result, r); 257 | result.splice(overlap.lo, overlap.count, overlap.union); 258 | } 259 | return result; 260 | }; 261 | 262 | /** 263 | * Subtracts the second value from the first value. 264 | * @param a - The value to be subtracted. 265 | * @param b - The value to subtract. 266 | * @returns A new MultiIntegerRange containing all integers that belong to 267 | * **`a` but not `b`**. 268 | * @example 269 | * subtract([[1, 7]], [[2, 4]]); // [[1, 1], [5, 7]] 270 | * subtract([[-Infinity, Infinity]], [[2, 4]]); // [[-Infinity, 1], [5, Infinity]] 271 | */ 272 | export const subtract = (a: MIR, b: MIR): MIR => { 273 | let result = a.slice(0); 274 | for (let r of b) { 275 | const overlap = findOverlap(result, r); 276 | if (overlap.count > 0) { 277 | const remaining: Range[] = []; 278 | if (result[overlap.lo][0] < r[0]) { 279 | remaining.push([result[overlap.lo][0], r[0] - 1]); 280 | } 281 | if (r[1] < result[overlap.lo + overlap.count - 1][1]) { 282 | remaining.push([r[1] + 1, result[overlap.lo + overlap.count - 1][1]]); 283 | } 284 | result.splice(overlap.lo, overlap.count, ...remaining); 285 | } 286 | } 287 | return result; 288 | }; 289 | 290 | /** 291 | * Calculates the intersection (common integers) of the two MultiIntegerRange's. 292 | * @param a - The first value. 293 | * @param b - The second value. 294 | * @returns A new MultiIntegerRange containing all integers 295 | * that belong to **both `a` and `b`**. 296 | * @example 297 | * intersect([[2, 5]], [[4, 9]]); // [[4, 5]] 298 | * intersect([[5, 10]], [[-Infinity, Infinity]]); // [[5, 10]] 299 | */ 300 | export const intersect = (a: MIR, b: MIR): MIR => { 301 | const result: Range[] = []; 302 | let jstart = 0; // used for optimization 303 | for (let i = 0; i < a.length; i++) { 304 | const r1 = a[i]; 305 | for (let j = jstart; j < b.length; j++) { 306 | const r2 = b[j]; 307 | if (r1[0] <= r2[1] && r1[1] >= r2[0]) { 308 | jstart = j; 309 | const min = r1[0] < r2[0] ? r2[0] : r1[0]; 310 | const max = r1[1] < r2[1] ? r1[1] : r2[1]; 311 | result.push([min, max]); 312 | } else if (r1[1] < r2[0]) { 313 | break; 314 | } 315 | } 316 | } 317 | return result; 318 | }; 319 | 320 | /** 321 | * Checks if `a` contains or is equal to `b` (a ⊇ b). 322 | * @param a - The value that possibly contains `b`. 323 | * @param b - The value that is possibly contained by `a`. 324 | * @returns True if `b` is equal to or a subset of `a`. 325 | * @example 326 | * has([[0, 100]], [[2, 10]]); // true 327 | * has([[5, 7]], [[5, 7]]); // true 328 | * has([[2, 10]], [[0, 100]]); // false 329 | */ 330 | export const has = (a: MIR, b: MIR): boolean => { 331 | const start = 0; 332 | const len = a.length; 333 | for (let r of b) { 334 | let i: number; 335 | for (i = start; i < len; i++) { 336 | const my = a[i]; 337 | if (r[0] >= my[0] && r[1] <= my[1] && r[1] >= my[0] && r[1] <= my[1]) 338 | break; 339 | } 340 | if (i === len) return false; 341 | } 342 | return true; 343 | }; 344 | 345 | /** 346 | * Calculates how many integers are included in the given MultiIntegerRange. 347 | * 348 | * Note: If you want to know the number of Ranges (segments), just use the 349 | * standard `Array#length`. 350 | * @param data - The value to calculate the length on. 351 | * @returns The number of integers contained in `data`. May be `Infinity`. 352 | * @example 353 | * length([[1, 3], [8, 10]]); // 6 354 | * length([[1, Infinity]]); // Infinity 355 | */ 356 | export const length = (data: MIR): number => { 357 | if (isUnbounded(data)) return Infinity; 358 | let result = 0; 359 | for (const r of data) result += r[1] - r[0] + 1; 360 | return result; 361 | }; 362 | 363 | /** 364 | * Checks if the data contains an unbounded (aka inifinite) range. 365 | * @param data - The value to check. 366 | * @returns True if `data` is unbounded. 367 | * @example 368 | * isUnbounded([[1, Infinity]]); // true 369 | * isUnbounded([[-Infinity, 4]]); // true 370 | * isUnbounded([[7, 9]]); // false 371 | */ 372 | export const isUnbounded = (data: MIR): boolean => { 373 | return ( 374 | data.length > 0 && 375 | (data[0][0] === -Infinity || data[data.length - 1][1] === Infinity) 376 | ); 377 | }; 378 | 379 | /** 380 | * Checks if the two values are the same. (Altenatively, you can use any 381 | * "deep-equal" utility function.) 382 | * @param a - The first value to compare. 383 | * @param b - The second value to compare. 384 | * @returns True if `a` and `b` have the same range data. 385 | * @example 386 | * equals([[1, 5], [7, 8]], [[1, 5], [7, 8]]); // true 387 | * equals([[1, 5]], [[2, 7]]); // false 388 | */ 389 | export const equals = (a: MIR, b: MIR): boolean => { 390 | if (a === b) return true; 391 | if (a.length !== b.length) return false; 392 | for (let i = 0; i < a.length; i++) { 393 | if (a[i][0] !== b[i][0] || a[i][1] !== b[i][1]) return false; 394 | } 395 | return true; 396 | }; 397 | 398 | /** 399 | * Returns the minimum integer of the given MultiIntegerRange. 400 | * @param data - The value. 401 | * @returns The minimum integer. May be `undefined` or `-Infinity`. 402 | * @example 403 | * min([[2, 5], [8, 10]]); // 2 404 | * min([[-Infinity, 0]]); // -Infinity 405 | * min([]); // undefined 406 | */ 407 | export const min = (data: MIR): number | undefined => { 408 | if (data.length === 0) return undefined; 409 | return data[0][0]; 410 | }; 411 | 412 | /** 413 | * Returns the maximum integer of the given MultiIntegerRange. 414 | * @param data - The value. 415 | * @returns The minimum integer. May be `undefined` or `Infinity`. 416 | * @example 417 | * max([[2, 5], [8, 10]]); // 10 418 | * max([[3, Infinity]]); // Infinity 419 | * max([]); // undefined 420 | */ 421 | export const max = (data: MIR): number | undefined => { 422 | if (data.length === 0) return undefined; 423 | return data[data.length - 1][1]; 424 | }; 425 | 426 | /** 427 | * Returns the integer at the specified 0-based index. 428 | * If a negative index is given, the index is counted from the end. 429 | * @param data - The value. 430 | * @param index - The 0-based index of the integer to return. Can be negative. 431 | * @returns The integer at the specified index. 432 | * Returns `undefined` if the index is out of bounds. 433 | * @example 434 | * at([[2, 4], [8, 10]], 4); // 9 435 | * at([[2, 4], [8, 10]], 6); // undefined 436 | * at([[2, 4], [8, 10]], -1); // 10 437 | */ 438 | export const at = (data: MIR, index: number): number | undefined => { 439 | if (!Number.isInteger(index)) 440 | throw new RangeError('at() was invoked with an invalid index'); 441 | if ( 442 | data.length > 0 && 443 | ((index >= 0 && data[0][0] === -Infinity) || 444 | (index < 0 && data[data.length - 1][1] === Infinity)) 445 | ) { 446 | throw new RangeError('at() was invoked on an unbounded range'); 447 | } 448 | 449 | let i = 0; 450 | const start = index >= 0 ? 0 : data.length - 1; 451 | const delta = index >= 0 ? 1 : -1; 452 | const nth = index >= 0 ? index : -index - 1; 453 | 454 | for (let j = start; j >= 0 && j < data.length; j += delta) { 455 | const r = data[j]; 456 | const len = r[1] - r[0] + 1; 457 | if (i + len > nth) return delta > 0 ? r[0] + nth - i : r[1] - nth + i; 458 | i += len; 459 | } 460 | return undefined; 461 | }; 462 | 463 | /** 464 | * Returns all but the minimum integer. 465 | * @param data - The value. 466 | * @returns A new MultiIntegerRange which is almost the same as `data` but with 467 | * its minimum integer removed. 468 | * @example 469 | * tail([[2, 5], [8, 10]]); // [[3, 5], [8, 10]] 470 | */ 471 | export const tail = (data: MIR): MIR => { 472 | const m = min(data); 473 | if (m === -Infinity) 474 | throw new RangeError( 475 | 'tail() was invoked on an unbounded MultiRange which contains -Infinity' 476 | ); 477 | if (m === undefined) return data; 478 | return subtract(data, [[m, m]]); 479 | }; 480 | 481 | /** 482 | * Returns all but the maximum integer. 483 | * @param data - The value. 484 | * @returns A new MultiIntegerRange which is almost the same as `data` but with 485 | * its maximum integer removed. 486 | * @example 487 | * init([[2, 5], [8, 10]]); // [[2, 5], [8, 9]] 488 | */ 489 | export const init = (data: MIR): MIR => { 490 | const m = max(data); 491 | if (m === Infinity) 492 | throw new RangeError( 493 | 'init() was invoked on an unbounded MultiRange which contains Infinity' 494 | ); 495 | if (m === undefined) return data; 496 | return subtract(data, [[m, m]]); 497 | }; 498 | 499 | /** 500 | * Options for the `stringify()` function. 501 | */ 502 | export interface StringifyOptions { 503 | individualThreshold?: number; 504 | } 505 | 506 | /** 507 | * Returns the string respresentation of the given MultiIntegerRange. 508 | * 509 | * - `options.individualThreshold` (number): If set, small ranges with a length 510 | * smaller than or equal to this will be output as individual integers. 511 | * Defaults to `1`, which means only ranges with a length of 1 will be 512 | * output as a single integer. 513 | * 514 | * @param data - The MultiIntegerRange to stringify. 515 | * @param options - Options for the stringification. 516 | * @returns The string representation of the given data. 517 | * @example 518 | * stringify([[2, 3], [5, 5], [7, 9]]); // '2-3,5,7-9' 519 | * stringify([[2, 3], [5, 5], [7, 9]], { individualThreshold: 0 }); // '2-3,5-5,7-9' 520 | * stringify([[2, 3], [5, 5], [7, 9]], { individualThreshold: 2 }); // '2,3,5,7-9' 521 | * stringify([[2, 3], [5, 5], [7, 9]], { individualThreshold: 3 }); // '2,3,5,7,8,9' 522 | * stringify([[3, 5], [7, Infinity]]); // '3-5,7-' 523 | */ 524 | export const stringify = ( 525 | data: MIR, 526 | options: StringifyOptions = {} 527 | ): string => { 528 | const { individualThreshold = 1 } = options; 529 | const wrap = (i: number) => (i >= 0 ? String(i) : `(${i})`); 530 | const ranges: string[] = []; 531 | for (let r of data) { 532 | if (r[0] === -Infinity) { 533 | if (r[1] === Infinity) { 534 | ranges.push('-'); 535 | } else { 536 | ranges.push(`-${wrap(r[1])}`); 537 | } 538 | } else if (r[1] === Infinity) { 539 | ranges.push(`${wrap(r[0])}-`); 540 | } else { 541 | if (individualThreshold && r[1] - r[0] + 1 <= individualThreshold) { 542 | for (let i = r[0]; i <= r[1]; i++) ranges.push(wrap(i)); 543 | } else ranges.push(`${wrap(r[0])}-${wrap(r[1])}`); 544 | } 545 | } 546 | return ranges.join(','); 547 | }; 548 | 549 | /** 550 | * Builds a flattened array of integers. 551 | * Note that this may be slow and memory-consuming for large ranges. 552 | * Consider using the iterator whenever possible. 553 | * @param data - The value to build an array on. 554 | * @returns The flattened array of numbers. 555 | * @example 556 | * flatten([[-1, 1], [7, 9]]); // [-1, 0, 1, 7, 8, 9] 557 | */ 558 | export const flatten = (data: MIR): number[] => { 559 | if (isUnbounded(data)) { 560 | throw new RangeError('You cannot build an array from an unbounded range'); 561 | } 562 | const result = new Array(length(data)); 563 | let idx = 0; 564 | for (let r of data) { 565 | for (let n = r[0]; n <= r[1]; n++) { 566 | result[idx++] = n; 567 | } 568 | } 569 | return result; 570 | }; 571 | 572 | /** 573 | * Options for the `iterate()` function. 574 | */ 575 | export interface IterateOptions { 576 | /** 577 | * Whether to iterate in descending order. 578 | */ 579 | readonly descending?: boolean; 580 | } 581 | 582 | /** 583 | * Returns an Iterable with which you can use `for-of` or the spread syntax. 584 | * 585 | * - `options.descending` (boolean): If set to true, the iterator will iterate in descending order. 586 | * 587 | * @param data - The normalized MultiIntegerRange to iterate over. 588 | * @param options - Options for the iteration. 589 | * @returns An Iterable object. 590 | * @example 591 | * Array.from(iterate([[1, 3], [7, 9]])); // [1, 2, 3, 7, 8, 9] 592 | * Array.from(iterate([[1, 3], [7, 9]], { descending: true })); // [9, 8, 7, 3, 2, 1] 593 | * [...iterate([[-1, 2]])]; // [-1, 0, 1, 2] 594 | */ 595 | export const iterate = ( 596 | data: MIR, 597 | options: IterateOptions = {} 598 | ): Iterable => { 599 | const { descending = false } = options; 600 | if (isUnbounded(data)) 601 | throw new RangeError('Unbounded ranges cannot be iterated over'); 602 | 603 | return { 604 | [Symbol.iterator]: () => { 605 | let i = descending ? data.length - 1 : 0, 606 | curRange: Range = data[i], 607 | j = curRange ? (descending ? curRange[1] : curRange[0]) : undefined; 608 | return { 609 | next: () => { 610 | if (!curRange || j === undefined) 611 | return { done: true, value: undefined }; 612 | const ret = j; 613 | if (descending ? --j < curRange[0] : ++j > curRange[1]) { 614 | curRange = data[descending ? --i : ++i]; 615 | j = curRange ? (descending ? curRange[1] : curRange[0]) : undefined; 616 | } 617 | return { done: false, value: ret }; 618 | } 619 | }; 620 | } 621 | }; 622 | }; 623 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /*! multi-integer-range (c) 2015-2022 Soichiro Miki */ 2 | export * from './fp.js'; 3 | export * from './MultiRange.js'; 4 | -------------------------------------------------------------------------------- /tsconfig-cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": false, 5 | "module": "CommonJS", 6 | "outDir": "./lib/cjs" 7 | }, 8 | "exclude": ["src/**/*test.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig-esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": false, 5 | "outDir": "./lib/esm" 6 | }, 7 | "exclude": ["src/**/*test.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noEmit": true, 4 | "rootDir": "src", 5 | "moduleResolution": "node", 6 | "target": "es2015", 7 | "module": "es2015", 8 | "newLine": "LF", 9 | "strict": true, 10 | "esModuleInterop": true, 11 | "declaration": true 12 | }, 13 | "include": ["src/**/*"] 14 | } 15 | --------------------------------------------------------------------------------