Skip to content

Commit

Permalink
feat: initial implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
norskeld committed Nov 23, 2022
1 parent 047aa13 commit b4b7568
Show file tree
Hide file tree
Showing 3 changed files with 197 additions and 13 deletions.
6 changes: 5 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
114 changes: 104 additions & 10 deletions src/index.spec.ts
Original file line number Diff line number Diff line change
@@ -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>): 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()
})
})
90 changes: 88 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,89 @@
export async function identity<T>(value: T): Promise<T> {
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<unknown>): 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<unknown>): string
function un(s: string | TemplateStringsArray, ...values: Array<unknown>): 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

0 comments on commit b4b7568

Please sign in to comment.