From b4b756815dcecc553781649e95cf8e7a477a7f9e Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Wed, 23 Nov 2022 03:15:47 +0300 Subject: [PATCH] feat: initial implementation --- .github/workflows/test.yml | 6 +- src/index.spec.ts | 114 +++++++++++++++++++++++++++++++++---- src/index.ts | 90 ++++++++++++++++++++++++++++- 3 files changed, 197 insertions(+), 13 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e75b2a4..c234084 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,4 +21,8 @@ jobs: - name: Build & Test run: | npm run build - npm run test + npm run test:coverage + - name: Coveralls + uses: coverallsapp/github-action@master + with: + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/src/index.spec.ts b/src/index.spec.ts index 8a5e5c5..b30cdbe 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -1,17 +1,111 @@ import { describe, it, expect } from 'vitest' -import { identity } from './index' +import un from './index' -describe('index.ts', () => { - it('given a primitive, should return the same primitive', async () => { - expect(await identity('string')).toBe('string') - expect(await identity(1986)).toBe(1986) - expect(await identity(true)).toBe(true) - expect(await identity(undefined)).toBe(undefined) +function concat(...segments: Array): string { + return segments.join('\n') + '\n' +} + +describe('un (tagged)', () => { + it('should unindent if given a multiline string', () => { + const actual = un` + begin + - indented line + - indented line + end + ` + + // prettier-ignore + const expected = concat( + 'begin', + ' - indented line', + ' - indented line', + 'end' + ) + + expect(actual).toBe(expected) + }) + + it('should unindent if given a multiline string that starts right after quote/backtick', () => { + const actual = un`begin + - indented line + - indented line + end + ` + + // prettier-ignore + const expected = concat( + 'begin', + ' - indented line', + ' - indented line', + 'end' + ) + + expect(actual).toBe(expected) + }) + + it('should return as-is if given a single-line string', () => { + const actual = un`first` + const expected = `first` + + expect(actual).toBe(expected) + }) + + it('should throw if provided with not a string or template strings array', () => { + // Creating bare object that doesn't have any means in the prototype chain to let it be + // converted into string (toString/toPrimitive). + const bare = Object.create(null) + + expect(() => un`${bare as unknown as string}`).toThrow() + }) +}) + +describe('un (untagged)', () => { + it('should unindent given multiline string', () => { + const actual = un(` + begin + - indented line + - indented line + end + `) + + // prettier-ignore + const expected = concat( + 'begin', + ' - indented line', + ' - indented line', + 'end' + ) + + expect(actual).toBe(expected) + }) + + it('should unindent if given a multiline string that starts right after quote/backtick', () => { + const actual = un(`begin + - indented line + - indented line + end + `) + + // prettier-ignore + const expected = concat( + 'begin', + ' - indented line', + ' - indented line', + 'end' + ) + + expect(actual).toBe(expected) + }) + + it('should return as-is if given a single-line string', () => { + const actual = un('first') + const expected = 'first' + + expect(actual).toBe(expected) }) - it('given an object, should return the same object', async () => { - const value = { foo: 'bar' } - expect(await identity(value)).toBe(value) + it('should throw if provided with not a string or template strings array', () => { + expect(() => un({} as unknown as string)).toThrow() }) }) diff --git a/src/index.ts b/src/index.ts index baf3d6e..e7017c2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,89 @@ -export async function identity(value: T): Promise { - return value +const NG_ONE = -1 + +const Chars = { + /** Space. */ + SP: '\u0020', + /** Horizontal tab. */ + HT: '\u0009', + /** Line feed (Unix/macOS). */ + LF: '\u000A', + /** Carriage return + Line feed (Windows). */ + CRLF: '\u000D\u000A' +} as const + +function nonNegative(count: number): boolean { + return count > NG_ONE } + +function toSpacesCount(line: string) { + const chars = [...line].entries() + + for (const [idx, char] of chars) { + if (char !== Chars.SP && char !== Chars.HT) { + return idx + } + } + + return NG_ONE +} + +function un$untagged(s: string): string { + // Document may start either on the same line as opening quote/backtick or on the next line. + const shouldIgnoreFirstLine = s.startsWith(Chars.LF) + + // TODO: Handle the `\r\n` case as well. + const lines = s.split(Chars.LF) + + // Get everything but the 1st line. + const [, ...rest] = lines + + // Get number of spaces for each line and leave only non-negative values. + const counts = rest.length ? rest.map(toSpacesCount).filter(nonNegative) : [0] + + // Largest number of spaces that can be removed from every non-whitespace-only line after the 1st. + const spaces = Math.min(...counts) + + // Resulting string. + let result = String() + + for (const [idx, line] of lines.entries()) { + if (idx > 1 || (idx === 1 && !shouldIgnoreFirstLine)) { + result += Chars.LF + } + + // Do not unindent anything on same line as opening quote/backtick. + if (idx === 0) { + result += line + } + // Whitespace-only lines may have fewer than the number of spaces being removed. + else if (line.length > spaces) { + result += line.slice(spaces) + } + } + + return result +} + +function un$tagged(strings: TemplateStringsArray, ...values: Array): string { + return un$untagged(String.raw({ raw: strings }, ...values)) +} + +/** + * Unindents multiline string. + * + * This function takes a multiline string and unindents it so the leftmost non-space character is in + * the first column. + */ +function un(s: string): string +function un(s: TemplateStringsArray, ...values: Array): string +function un(s: string | TemplateStringsArray, ...values: Array): string { + if (typeof s === 'string') { + return un$untagged(s) + } else if (Array.isArray(s)) { + return un$tagged(s, ...values) + } + + throw new Error(`Only 'string' and 'template strings array' allowed, but got '${typeof s}'.`) +} + +export default un