From 97c894bf3646169a388d3de9b7307a0351996110 Mon Sep 17 00:00:00 2001 From: Lexus Drumgold Date: Mon, 6 May 2024 04:41:47 -0400 Subject: [PATCH] feat: `Location` Signed-off-by: Lexus Drumgold --- .attw.json | 2 +- .codecov.yml | 3 +- .github/infrastructure.yml | 2 +- README.md | 91 ++++++++++++++-- __fixtures__/.gitkeep | 0 __fixtures__/hrt.ts | 49 +++++++++ __tests__/setup/faker.ts | 9 ++ __tests__/setup/index.ts | 6 ++ package.json | 3 +- src/__tests__/index.e2e.spec.ts | 12 +++ src/__tests__/location.spec.ts | 130 +++++++++++++++++++++++ src/index.ts | 3 +- src/interfaces/__tests__/point.spec-d.ts | 18 ++++ src/interfaces/index.ts | 6 ++ src/interfaces/point.ts | 25 +++++ src/location.ts | 129 ++++++++++++++++++++++ tsconfig.typecheck.json | 2 +- typings/@faker-js/faker/global.d.ts | 5 + yarn.lock | 8 ++ 19 files changed, 491 insertions(+), 12 deletions(-) delete mode 100644 __fixtures__/.gitkeep create mode 100644 __fixtures__/hrt.ts create mode 100644 __tests__/setup/faker.ts create mode 100644 __tests__/setup/index.ts create mode 100644 src/__tests__/index.e2e.spec.ts create mode 100644 src/__tests__/location.spec.ts create mode 100644 src/interfaces/__tests__/point.spec-d.ts create mode 100644 src/interfaces/index.ts create mode 100644 src/interfaces/point.ts create mode 100644 src/location.ts create mode 100644 typings/@faker-js/faker/global.d.ts diff --git a/.attw.json b/.attw.json index 251fe5e..a4bd686 100644 --- a/.attw.json +++ b/.attw.json @@ -2,6 +2,6 @@ "color": true, "emoji": true, "format": "ascii", - "ignoreRules": ["cjs-resolves-to-esm", "no-resolution"], + "ignoreRules": ["cjs-resolves-to-esm"], "summary": true } diff --git a/.codecov.yml b/.codecov.yml index e3e359b..024de28 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -89,4 +89,5 @@ ignore: - '!src/index.ts' profiling: - critical_files_paths: [] + critical_files_paths: + - src/location.ts diff --git a/.github/infrastructure.yml b/.github/infrastructure.yml index 265071f..61242d4 100644 --- a/.github/infrastructure.yml +++ b/.github/infrastructure.yml @@ -168,7 +168,7 @@ repository: automated_security_fixes: true default_branch: main delete_branch_on_merge: true - description: utility to convert between positional and offset based locations + description: utility to convert between point and offset based locations has_issues: true has_projects: true has_wiki: false diff --git a/README.md b/README.md index 2687306..718c67b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![github release](https://img.shields.io/github/v/release/flex-development/vfile-location.svg?include_prereleases&sort=semver)](https://github.com/flex-development/vfile-location/releases/latest) [![npm](https://img.shields.io/npm/v/@flex-development/vfile-location.svg)](https://npmjs.com/package/@flex-development/vfile-location) -[![codecov](https://codecov.io/gh/flex-development/vfile-location/graph/badge.svg?token=)](https://codecov.io/gh/flex-development/vfile-location) +[![codecov](https://codecov.io/gh/flex-development/vfile-location/graph/badge.svg?token=81iuGRII5a)](https://codecov.io/gh/flex-development/vfile-location) [![module type: esm](https://img.shields.io/badge/module%20type-esm-brightgreen)](https://github.com/voxpelli/badges-cjs-esm) [![license](https://img.shields.io/github/license/flex-development/vfile-location.svg)](LICENSE.md) [![conventional commits](https://img.shields.io/badge/-conventional%20commits-fe5196?logo=conventional-commits&logoColor=ffffff)](https://conventionalcommits.org/) @@ -10,7 +10,7 @@ [![vitest](https://img.shields.io/badge/-vitest-6e9f18?style=flat&logo=vitest&logoColor=ffffff)](https://vitest.dev/) [![yarn](https://img.shields.io/badge/-yarn-2c8ebb?style=flat&logo=yarn&logoColor=ffffff)](https://yarnpkg.com/) -[vfile][vfile] utility to convert between positional (line and column) and offset (range) based locations +[vfile][vfile] utility to convert between point (line/column) and offset (range) based locations ## Contents @@ -19,16 +19,21 @@ - [Install](#install) - [Use](#use) - [API](#api) + - [`Location(file[, start])`](#locationfile-start) + - [`Location#offset([point])`](#locationoffsetpoint) + - [`Location#point([offset])`](#locationpointoffset) + - [`Point`](#point) - [Types](#types) - [Contribute](#contribute) ## What is this? -**TODO**: what is this? +This is a tiny but useful package that facilitates conversions between [points and offsets][point] in a file. ## When should I use this? -**TODO**: when should i use this? +This utility is useful when adding [*positional information*][positional information] to [unist][unist] nodes, or when +building packages that require location data, such as a set of lint rules. ## Install @@ -63,11 +68,79 @@ In browsers with [`esm.sh`][esmsh]: ## Use -**TODO**: use +```ts +import { Location, type Point } from '@flex-development/vfile-location' +import { read } from 'to-vfile' +import type * as unist from 'unist' +import type { VFile, Value } from 'vfile' + +const point: Point = { column: 1, line: 21, offset: 474 } +const pt: Point = { column: 2, line: 47, offset: 1124 } + +const file: VFile = await read('hrt.ts') +const val: Value = String(file).slice(point.offset, pt.offset + 1) + +const location: Location = new Location(file) +const loc: Location = new Location(val, point) + +console.log(location.offset({ ...point, offset: undefined })) // => point.offset +console.log(location.point(point.offset)) // => point + +console.log(loc.offset({ ...pt, offset: undefined })) // => pt.offset +console.log(loc.point(pt.offset)) // => pt +``` ## API -**TODO**: api +This package exports the identifier [`Location`](#locationfile-start). There is no default export. + +### `Location(file[, start])` + +Create a new location index to translate between point and offset based locations in `file`. + +Pass a `start` point to make relative conversions. Any point or offset accessed will be relative to the given point. + +- `file` ([`Value`][vfile-value] | [`VFile`][vfile-api]) — file to index +- `start` ([`Point`](#point) | `null` | `undefined`) — point before first character in `file` + +#### `Location#offset([point])` + +Get an offset for `point`. + +> 👉 The offset for `point` is greater than or equal to `0` when `point` is valid, and `-1` when `point` is invalid. + +##### Parameters + +- `point` ([`unist.Point`][point] | `null` | `undefined`) — place in source file + +##### Returns + +([`Offset`][offset]) Index of character in source file or `-1`. + +#### `Location#point([offset])` + +Get a point for `offset`. + +> 👉 `point.column` and `point.line` are greater than or equal to `1` when `offset` is valid, and `-1` when `offset` is +> invalid. + +##### Parameters + +- `offset` ([`Offset`][offset] | `null` | `undefined`) — index of character in source file + +##### Returns + +([`Point`](#point)) Place in source file. + +### `Point` + +One place in a source file (TypeScript interface). + +#### Properties + +- `column` (`number`) — column in source file (`1`-indexed integer) +- `line` (`number`) — line in source file (`1`-indexed integer) +- `offset` ([`Offset`][offset]) — index of character in source file (`0`-indexed integer) ## Types @@ -82,6 +155,12 @@ community you agree to abide by its terms. [esm]: https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c [esmsh]: https://esm.sh/ +[offset]: https://github.com/flex-development/unist-util-types#offset +[point]: https://github.com/syntax-tree/unist#point +[positional information]: https://github.com/syntax-tree/unist#positional-information [typescript]: https://www.typescriptlang.org +[unist]: https://github.com/syntax-tree/unist [vfile]: https://github.com/vfile/vfile +[vfile-api]: https://github.com/vfile/vfile#vfileoptions +[vfile-value]: https://github.com/vfile/vfile#value [yarn]: https://yarnpkg.com diff --git a/__fixtures__/.gitkeep b/__fixtures__/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/__fixtures__/hrt.ts b/__fixtures__/hrt.ts new file mode 100644 index 0000000..2d98d7e --- /dev/null +++ b/__fixtures__/hrt.ts @@ -0,0 +1,49 @@ +/** + * @file Fixtures - hrt + * @module fixtures/hrt + * @see https://codewars.com/kata/52685f7382004e774f0001f7 + */ + +/** + * Given a non-negative integer, `seconds`, the functions returns the time in a + * human-readable format (`HH:MM:SS`). + * + * @example + * hrt(0) // '00:00:00' + * @example + * hrt(60) // '00:01:00' + * @example + * hrt(359999) // '99:59:59' + * + * @param {number} seconds - Time in seconds + * @return {string} Time in human-readable format (`HH:MM:SS`) + */ +const hrt = (seconds: number): string => { + /** + * {@linkcode seconds} in human-readable format. + * + * @var {string} formatted + */ + let formatted: string = '' + + // convert seconds to human-readable time format + for (const converter of [3600, 60, 1]) { + /** + * {@linkcode seconds} in hours, minutes, or seconds. + * + * @const {number} time + */ + const time: number = seconds / converter | 0 + + // update formatted time + formatted += time < 10 ? `0${time}` : time + if (converter !== 1) formatted += ':' + + // remove converted seconds from total seconds + seconds -= time * converter + } + + return formatted +} + +export default hrt diff --git a/__tests__/setup/faker.ts b/__tests__/setup/faker.ts new file mode 100644 index 0000000..0f1afe4 --- /dev/null +++ b/__tests__/setup/faker.ts @@ -0,0 +1,9 @@ +/** + * @file Test Setup - faker + * @module tests/setup/faker + * @see https://github.com/faker-js/faker + */ + +import { faker } from '@faker-js/faker' + +global.faker = faker diff --git a/__tests__/setup/index.ts b/__tests__/setup/index.ts new file mode 100644 index 0000000..3678a46 --- /dev/null +++ b/__tests__/setup/index.ts @@ -0,0 +1,6 @@ +/** + * @file Entry Point - Test Setup + * @module tests/setup + */ + +import './faker' diff --git a/package.json b/package.json index c3fc88b..6126c5f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@flex-development/vfile-location", - "description": "utility to convert between positional (line and column) and offset (range) based locations", + "description": "utility to convert between point (line/column) and offset (range) based locations", "version": "1.0.0", "keywords": [ "location", @@ -80,6 +80,7 @@ "@arethetypeswrong/cli": "0.15.3", "@commitlint/cli": "19.3.0", "@commitlint/types": "19.0.3", + "@faker-js/faker": "9.0.0-alpha.0", "@flex-development/commitlint-config": "1.0.1", "@flex-development/decorator-regex": "2.0.0", "@flex-development/esm-types": "2.0.0", diff --git a/src/__tests__/index.e2e.spec.ts b/src/__tests__/index.e2e.spec.ts new file mode 100644 index 0000000..306b7d7 --- /dev/null +++ b/src/__tests__/index.e2e.spec.ts @@ -0,0 +1,12 @@ +/** + * @file E2E Tests - api + * @module vfile-location/tests/e2e/api + */ + +import * as testSubject from '../index' + +describe('e2e:vfile-location', () => { + it('should expose public api', () => { + expect(testSubject).to.have.keys(['Location']) + }) +}) diff --git a/src/__tests__/location.spec.ts b/src/__tests__/location.spec.ts new file mode 100644 index 0000000..71b9bec --- /dev/null +++ b/src/__tests__/location.spec.ts @@ -0,0 +1,130 @@ +/** + * @file Unit Tests - Location + * @module vfile-location/tests/unit/Location + */ + +import type { Point } from '#src/interfaces' +import type { Times } from '@flex-development/tutils' +import type { Offset } from '@flex-development/unist-util-types' +import { read } from 'to-vfile' +import type { VFile } from 'vfile' +import TestSubject from '../location' + +describe('unit:Location', () => { + let fi: string + let file: VFile + let length: number + let points: Times<6, Point> + let pts: Times<5, Point> + let start: Point + + beforeAll(async () => { + file = await read('__fixtures__/hrt.ts') + + points = [ + { column: 1, line: 1, offset: 0 }, + ...(pts = [ + start = { column: 1, line: 21, offset: 474 }, + { column: 13, line: 30, offset: 707 }, + { column: 42, line: 40, offset: 1014 }, + { column: 2, line: 47, offset: 1124 }, + { column: 1, line: 50, offset: length = String(file).length } + ]) + ] + + fi = String(file).slice(start.offset) + }) + + describe('#offset', () => { + let subject: TestSubject + let sub: TestSubject + + beforeAll(() => { + subject = new TestSubject(file) + sub = new TestSubject(fi, start) + }) + + it('should return -1 if point.column < 1', () => { + expect(subject.offset({ column: 0, line: 1 })).to.eq(-1) + }) + + it('should return -1 if point.column is not found', () => { + expect(subject.offset({ column: 40, line: 2 })).to.eq(-1) + }) + + it('should return -1 if point.line < 1', () => { + expect(subject.offset({ column: 1, line: 0 })).to.eq(-1) + }) + + it('should return -1 if point.line > total number of lines', () => { + expect(subject.offset({ column: 1, line: 100 })).to.eq(-1) + }) + + it('should return -1 if point is nil', () => { + ;[, null].forEach(offset => expect(subject.offset(offset)).to.eq(-1)) + }) + + it('should return index of character in source file', () => { + points.forEach(point => expect(subject.offset(point)).to.eq(point.offset)) + }) + + it('should return index of character in source file (relative)', () => { + pts.forEach(point => expect(sub.offset(point)).to.eq(point.offset)) + }) + }) + + describe('#point', () => { + let subject: TestSubject + let sub: TestSubject + + beforeAll(() => { + subject = new TestSubject(file) + sub = new TestSubject(fi, start) + }) + + it('should return invalid point if offset < 0', () => { + // Arrange + const offset: Offset = faker.number.int({ + max: -1, + min: Number.NEGATIVE_INFINITY + }) + + // Act + Expect + expect(subject.point(offset)).to.eql({ column: -1, line: -1, offset }) + }) + + it('should return invalid point if offset > source file length', () => { + // Arrange + const offset: Offset = faker.number.int({ min: length + 1 }) + + // Act + Expect + expect(subject.point(offset)).to.eql({ column: -1, line: -1, offset }) + }) + + it('should return invalid point if offset is a float', () => { + // Arrange + const offset: Offset = faker.number.float({ max: length, min: 0 }) + + // Act + Expect + expect(subject.point(offset)).to.eql({ column: -1, line: -1, offset }) + }) + + it('should return invalid point if offset is nil', () => { + ;[, null].forEach(offset => { + expect(subject.point(offset)).to.eql({ + column: -1, + line: -1, + offset: -1 + }) + }) + }) + + it('should return place in source file', () => { + points.forEach(point => expect(subject.point(point.offset)).to.eql(point)) + }) + + it('should return place in source file (relative)', () => { + pts.forEach(point => expect(sub.point(point.offset)).to.eql(point)) + }) + }) +}) diff --git a/src/index.ts b/src/index.ts index a32d622..d3b208a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,4 +3,5 @@ * @module vfile-location */ -export {} +export type * from './interfaces' +export { default as Location } from './location' diff --git a/src/interfaces/__tests__/point.spec-d.ts b/src/interfaces/__tests__/point.spec-d.ts new file mode 100644 index 0000000..dd0f4a4 --- /dev/null +++ b/src/interfaces/__tests__/point.spec-d.ts @@ -0,0 +1,18 @@ +/** + * @file Type Tests - Point + * @module vfile-location/interfaces/tests/unit-d/Point + */ + +import type { Offset } from '@flex-development/unist-util-types' +import type * as unist from 'unist' +import type TestSubject from '../point' + +describe('unit-d:interfaces/Point', () => { + it('should extend unist.Point', () => { + expectTypeOf().toMatchTypeOf() + }) + + it('should match [offset: Offset]', () => { + expectTypeOf().toHaveProperty('offset').toEqualTypeOf() + }) +}) diff --git a/src/interfaces/index.ts b/src/interfaces/index.ts new file mode 100644 index 0000000..6b8872f --- /dev/null +++ b/src/interfaces/index.ts @@ -0,0 +1,6 @@ +/** + * @file Entry Point - Interfaces + * @module vfile-location/interfaces + */ + +export type { default as Point } from './point' diff --git a/src/interfaces/point.ts b/src/interfaces/point.ts new file mode 100644 index 0000000..926fbef --- /dev/null +++ b/src/interfaces/point.ts @@ -0,0 +1,25 @@ +/** + * @file Interfaces - Point + * @module vfile-location/interfaces/Point + */ + +import type { Offset } from '@flex-development/unist-util-types' +import type * as unist from 'unist' + +/** + * One place in a source file. + * + * @see {@linkcode unist.Point} + * + * @extends {unist.Point} + */ +interface Point extends unist.Point { + /** + * Index of character in a source file (`0`-indexed integer). + * + * @see {@linkcode Offset} + */ + offset: Offset +} + +export type { Point as default } diff --git a/src/location.ts b/src/location.ts new file mode 100644 index 0000000..4218632 --- /dev/null +++ b/src/location.ts @@ -0,0 +1,129 @@ +/** + * @file location + * @module vfile-location/location + */ + +import type { Offset } from '@flex-development/unist-util-types' +import type * as unist from 'unist' +import type { VFile, Value } from 'vfile' +import type { Point } from './interfaces' + +/** + * Location index. + * + * Facilitates conversions between point and offset based locations. + * + * @class + */ +class Location { + /** + * List, where each index is the original index of a character in the source + * file, and each item is a {@linkcode Point} relative to {@linkcode start}. + * + * @private + * @readonly + * @instance + * @member {Readonly[]} + */ + readonly #indices: Readonly[] + + /** + * Point before first character in source file. + * + * @see {@linkcode Point} + * + * @public + * @readonly + * @instance + * @member {Readonly} start + */ + public readonly start: Readonly + + /** + * Create a new location index to translate between point and offset based + * locations in `file`. + * + * Pass a `start` point to make relative conversions. Any point or offset + * accessed will be relative to the given point. + * + * @see {@linkcode Point} + * @see {@linkcode VFile} + * @see {@linkcode Value} + * + * @param {Value | VFile} file - File to index + * @param {(Point | null)?} [start] - Point before first character in `file` + */ + constructor(file: Value | VFile, start?: Point | null) { + this.#indices = [] + this.start = Object.assign({}, start ?? { column: 1, line: 1, offset: 0 }) + this.start = Object.freeze(this.start) + + /** + * Iteration point. + * + * @const {Point} point + */ + const point: Point = { ...this.start } + + // index file + for (const char of String(file) + '\n') { + this.#indices.push(Object.freeze({ ...point })) + + // advance point + if (/[\n\r]/.test(char)) { + point.column = 1 + point.line++ + point.offset++ + } else { + point.column++ + point.offset++ + } + } + } + + /** + * Get an offset for `point`. + * + * > 👉 The offset for `point` is greater than or equal to `0` when `point` is + * > valid, and `-1` when `point` is invalid. + * + * @see {@linkcode Offset} + * @see {@linkcode unist.Point} + * + * @public + * @instance + * + * @param {(unist.Point | null)?} [point] - Place in source file + * @return {Offset} Index of character in source file or `-1` + */ + public offset(point?: unist.Point | null): Offset { + return this.#indices.find(pt => { + return !!point && pt.line === point.line && pt.column === point.column + })?.offset ?? -1 + } + + /** + * Get a point for `offset`. + * + * > 👉 `point.column` and `point.line` are greater than or equal to `1` when + * > `offset` is valid, and `-1` when `offset` is invalid. + * + * @see {@linkcode Offset} + * @see {@linkcode Point} + * + * @public + * @instance + * + * @param {(Offset | null)?} [offset] - Index of character in source file + * @return {Point} Place in source file + */ + public point(offset?: Offset | null): Point { + return this.#indices.find(pt => pt.offset === offset) ?? { + column: -1, + line: -1, + offset: offset ?? -1 + } + } +} + +export default Location diff --git a/tsconfig.typecheck.json b/tsconfig.typecheck.json index cddea49..e69fb6e 100644 --- a/tsconfig.typecheck.json +++ b/tsconfig.typecheck.json @@ -1,7 +1,7 @@ { "exclude": ["**/coverage", "**/dist", "**/node_modules"], "extends": "./tsconfig.json", - "files": ["vitest-env.d.ts"], + "files": ["typings/@faker-js/faker/global.d.ts", "vitest-env.d.ts"], "include": [ "__fixtures__/**/**.ts", "__tests__/**/**.ts", diff --git a/typings/@faker-js/faker/global.d.ts b/typings/@faker-js/faker/global.d.ts new file mode 100644 index 0000000..05c532e --- /dev/null +++ b/typings/@faker-js/faker/global.d.ts @@ -0,0 +1,5 @@ +declare global { + var faker: (typeof import('@faker-js/faker'))['faker'] +} + +export {} diff --git a/yarn.lock b/yarn.lock index ee409bb..77945b0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1378,6 +1378,13 @@ __metadata: languageName: node linkType: hard +"@faker-js/faker@npm:9.0.0-alpha.0": + version: 9.0.0-alpha.0 + resolution: "@faker-js/faker@npm:9.0.0-alpha.0" + checksum: 10/e9bb4b32ec170be4bb484826d221de6bfd3a515f57bb9d9f7f6e24816187399dd17d1527a8b4d0f02f891c1fac6d8751f0ab0d0d8d6a9854309ab3102e1267cd + languageName: node + linkType: hard + "@flex-development/aggregate-error-ponyfill@npm:3.1.1": version: 3.1.1 resolution: "@flex-development/aggregate-error-ponyfill@npm:3.1.1::__archiveUrl=https%3A%2F%2Fnpm.pkg.github.com%2Fdownload%2F%40flex-development%2Faggregate-error-ponyfill%2F3.1.1%2F06789d036a0573f507331cc4d1068616a4e58c9e" @@ -1804,6 +1811,7 @@ __metadata: "@arethetypeswrong/cli": "npm:0.15.3" "@commitlint/cli": "npm:19.3.0" "@commitlint/types": "npm:19.0.3" + "@faker-js/faker": "npm:9.0.0-alpha.0" "@flex-development/commitlint-config": "npm:1.0.1" "@flex-development/decorator-regex": "npm:2.0.0" "@flex-development/esm-types": "npm:2.0.0"