├── .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 | [](https://github.com/smikitky/node-multi-integer-range/actions)
4 | [](https://coveralls.io/github/smikitky/node-multi-integer-range)
5 | [](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 |
--------------------------------------------------------------------------------