Skip to content

Commit

Permalink
refactor!: redesign entire API set (#4)
Browse files Browse the repository at this point in the history
  • Loading branch information
yuhr authored Jun 12, 2023
1 parent c7047aa commit 6a5de80
Show file tree
Hide file tree
Showing 37 changed files with 704 additions and 326 deletions.
21 changes: 13 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@

# DISTREE

[![GitHub](https://img.shields.io/github/license/yuhr/distree?color=%231e2327)](LICENSE)
[![License](https://img.shields.io/github/license/yuhr/distree?color=%231e2327)](LICENSE)

Directory structure trees upon plain objects.

<br><br></div>

`distree` provides a convenient access to deep properties in plain objects based on POSIX path representation. It is similar to JSON Pointer, but quite naïver and less complicated implementation.
`distree` provides a convenient access to deep properties in plain objects, using POSIX path representation. It is similar to JSON Pointer, but quite naïver and less complicated implementation.

It does not depend on any other packages nor platform-specific JavaScript features, so it should work in browsers, Deno and Node.js.
It's bundled with [`dnt`](https://github.com/denoland/dnt), so it should work in browsers, Deno and Node.js.

## Installation

Expand All @@ -29,16 +29,21 @@ For Node.js, just install `distree` from the npm registry:
## Usage

```ts
const { assertEquals } = await import("https://deno.land/std/testing/asserts.ts")
import { assertEquals } from "https://deno.land/std/testing/asserts.ts"
import Distree from "https://deno.land/x/distree/index.ts"

const init = { foo: { bar: { baz: "qux" } } }
const distree = from<string>(init)
const distree = Distree.from<string>(init)

assertEquals(distree["/"], distree)
assertEquals(distree["."], distree)
assertEquals(distree[".."], distree)

const path = "/foo/./../foo/bar//foo/bar/baz"
const path = "/foo/bar/baz"
assertEquals(distree[path], "qux")
assertEquals(replace(distree)([path, "quux"])[path], "quux")
```
assertEquals(Distree.insert(distree)([path, "quux"])[path], "quux")
```

## Semver Policy

Only the default exports are public APIs and remain stable throughout minor version bumps. Named exports should be considered private and unstable. Any single release may randomly contain breaking changes to named exports, so users should avoid using them where possible.
133 changes: 133 additions & 0 deletions deno.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion justfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ setup:
git config --local core.hooksPath .githooks

test:
deno test --import-map=tests/import-map.json --allow-net tests
deno test --allow-net --allow-read tests

bundle:
deno run -A bundle.ts
Expand Down
135 changes: 113 additions & 22 deletions src/Distree.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,132 @@
import { isPlainObject } from "./isPlainObject.ts"
import { normalizePath } from "./normalizePath.ts"

/**
* A frozen plain object that accepts paths as index reference. It also supports `Symbol.iterator` interface that enumerates nested leaves with the keys being paths.
* A frozen plain object that accepts paths as index reference. It also supports `Symbol.iterator` interface that enumerates nested values.
*/
export type Distree<T> = {
readonly [key: string]: Distree<T> | T | undefined
[Symbol.iterator](): Iterator<[string, T]>
type Distree<T> = {
readonly "/": Distree<T>
readonly ".": Distree<T>
readonly "..": Distree<T>
readonly [key: string]: Distree<T> | T
readonly [Symbol.iterator]: () => Iterator<[string, T]>
}

export const symbol: unique symbol = Symbol()
namespace Distree {
export type Initializer<T> = Distree<T> | { readonly [key: string]: Initializer<T> | T }
export type ItemInitializer<T> = Distree<T> | T | { readonly [key: string]: ItemInitializer<T> }
}

export const isPlainObject = (value: unknown): value is object => {
return (
typeof value === "object" && value !== null && Object.getPrototypeOf(value) === Object.prototype
)
type DistreeInternal<T> = {
"/": DistreeInternal<T>
".": DistreeInternal<T>
"..": DistreeInternal<T>
[key: string]: DistreeInternal<T> | T
[Symbol.iterator]: () => Iterator<[string, T]>
[symbol]: Metadata<T>
}

namespace DistreeInternal {
export type Initializer<T> = DistreeInternal<T> | { [key: string]: Initializer<T> | T }
export type ItemInitializer<T> = DistreeInternal<T> | T | { [key: string]: ItemInitializer<T> }
}

type Metadata<T> = { root: DistreeInternal<T> }

const symbol: unique symbol = Symbol()

/**
* Tests if a value is a distree.
*
* @param value The value to be tested.
*/
export const isDistree = <T>(value: unknown): value is Distree<T> => {
return isPlainObject(value) && symbol in value
const isDistree = <T>(value: unknown): value is Distree<T> => {
return isPlainObject(value) && Object.hasOwn(value, symbol)
}

const readonly = {
configurable: false,
enumerable: false,
writable: false,
} as const satisfies PropertyDescriptor

const resolve = <T>(
distree: DistreeInternal<T>,
path: string,
): DistreeInternal<T> | T | undefined => {
if (path === "/") return distree[symbol].root
const components = normalizePath(path).split(/\/+/)
let resolved: DistreeInternal<T> | T | undefined = distree
for (const component of components) {
if (isDistree(resolved)) {
if (component === "") {
resolved = resolved[symbol].root
} else if (Object.hasOwn(resolved, component)) {
resolved = resolved[component]
} else return undefined
} else return undefined
}
return resolved
}

const iterate = function* <T>(
distree: DistreeInternal<T>,
prefix: string | undefined,
): Generator<[string, T]> {
for (const [key, value] of Object.entries(distree)) {
const path = prefix === undefined ? key : prefix + "/" + key
if (isDistree(value)) yield* iterate(value, path)
else yield [path, value]
}
}

const rec = <T>(
content: DistreeInternal.Initializer<T>,
parent: DistreeInternal<T> | undefined,
): DistreeInternal<T> => {
const distree = new Proxy<DistreeInternal<T>>(Object.create(null) as DistreeInternal<T>, {
get: (target, key, receiver) => {
if (typeof key === "string") return resolve(target, key)
else return Reflect.get(target, key, receiver)
},
})

// Populate as a distree
Object.defineProperties(distree, {
[symbol]: { ...readonly, value: { root: parent?.[symbol].root ?? distree } },
".": { ...readonly, value: distree },
"..": { ...readonly, value: parent ?? distree },
[Symbol.iterator]: {
...readonly,
*value() {
yield* iterate(distree, undefined)
},
},
})

// Copy contents into the new distree
for (const [key, value] of Object.entries(content)) {
if (key.search(/\/+/) !== -1) throw new Error(`Keys are not allowed to contain slashes: ${key}`)
if (isPlainObject(value)) distree[key] = rec(value, distree)
else distree[key] = value
}

Object.setPrototypeOf(distree, Object.prototype)
Object.freeze(distree)

return distree
}

/**
* Tests if a value should be treaded as empty in a distree. An empty value is automatically removed from a distree when set. A value is empty when:
* Creates a distree from the provided content. It doesn't modify the passed object.
*
* 1. The value is `undefined`
* 2. The value is a plain object or array and it has no enumerable own string keys.
*
* @param value The value to be tested.
* @param content The initial content of the distree. Only enumerable own string keys are respected, and nested plain objects are recursively converted to distrees.
* @returns A new distree.
*/
export const isEmpty = <T>(value: Distree<T> | T | undefined): boolean => {
return (
value === undefined ||
((isPlainObject(value) || Array.isArray(value)) && Object.keys(value).length === 0)
)
const from = <T>(content: Distree.Initializer<T>): Distree<T> => {
if (isPlainObject(content)) return rec(content as DistreeInternal<T>, undefined)
else throw new Error("Provided content is not a plain object.")
}

export default Distree
export default Distree
export { isDistree, from }
3 changes: 2 additions & 1 deletion src/ancestors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import Distree from "./Distree.ts"

/**
* Generates over ancestors of a distree. Note that it yields the passed distree at first.
*
* @param distree The initial distree the generator iterates from.
*/
const ancestors = function* <T>(distree: Distree<T>): Generator<Distree<T>> {
const ancestors = function* <T>(distree: Distree<T>): Generator<Distree<T>, void, undefined> {
yield distree
const parent = distree[".."] as Distree<T>
if (parent !== distree) yield* ancestors(parent)
Expand Down
68 changes: 1 addition & 67 deletions src/from.ts
Original file line number Diff line number Diff line change
@@ -1,69 +1,3 @@
import Distree, { symbol, isPlainObject } from "./Distree.ts"
import iterate from "./iterate.ts"
import resolve from "./resolve.ts"

export const readonly: PropertyDescriptor = {
configurable: false,
enumerable: false,
writable: false,
} as const

const rec = <T>(
content: Distree<T> | { [key: string]: typeof content | T | undefined },
parent: Distree<T> | undefined,
): Distree<T> => {
type Mutable<T> = {
-readonly [P in keyof T]: T[P]
}

const distree = new Proxy<Mutable<Distree<T>>>(
// @ts-expect-error: populate `Symbol.iterator` afterwards
{},
{
get: (target, key, receiver) => {
if (typeof key === "string") return resolve(target)(key)
else return Reflect.get(target, key, receiver)
},
},
)

// Populate as a distree
Object.defineProperties(distree, {
[symbol]: { ...readonly, value: undefined },
"": { ...readonly, value: (parent && parent[""]) ?? distree },
".": { ...readonly, value: distree },
"..": { ...readonly, value: parent ?? distree },
[Symbol.iterator]: {
...readonly,
*value() {
yield* iterate(distree, undefined)
},
},
})

// Copy contents into the new distree
if (content) {
for (const [key, value] of Object.entries(content)) {
if (isPlainObject(value)) {
distree[key] = rec(value, distree)
} else {
distree[key] = value
}
}
}

return Object.freeze(distree)
}

/**
* Creates a distree from the provided content. It doesn't modify the passed object.
*
* @param content The initial content of the distree. Only enumerable own string keys are respected, and nested plain objects are recursively converted to distrees.
*/
const from = <T>(
content: Distree<T> | { [key: string]: typeof content | T | undefined },
): Distree<T> => {
return rec(content, undefined)
}
import { from } from "./Distree.ts"

export default from
56 changes: 56 additions & 0 deletions src/fromDirectory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import Distree from "./Distree.ts"
import from from "./from.ts"
import transform from "./transform.ts"
import transformAsync from "./transformAsync.ts"

const rec = async (path: string): Promise<typeof content> => {
const content: { [key: string]: typeof content | string } = Object.create(null)
for await (const item of Deno.readDir(path)) {
const pathItem = path + "/" + item.name
if (item.isDirectory) {
const [key, value] = [item.name, await rec(pathItem)]
content[key] = value
} else if (item.isFile) {
const [key, value] = [item.name, pathItem]
content[key] = value
} else {
// TODO: handle symlinks (breaking change)
}
}

Object.setPrototypeOf(content, Object.prototype)
return content
}

const fromDirectory: {
(path: string): Promise<Distree<string>>
(path: string, filter: string | RegExp): Promise<Distree<string>>
<T>(
path: string,
transform: (value: string, path: string) => Promise<Distree.ItemInitializer<T>>,
): Promise<Distree<T>>
} = async <
T,
F extends
| string
| RegExp
| undefined
| ((value: string, path: string) => Promise<Distree.ItemInitializer<T>>),
>(
path: string,
filter?: F,
): Promise<Distree<F extends string | RegExp | undefined ? string : T>> => {
const distree = from(await rec(path)) as Distree<string>
return (
filter === undefined
? distree
: typeof filter === "function"
? await transformAsync(distree, filter)
: transform(distree, value => {
if (value.match(filter)) return value
else throw undefined
})
) as Distree<F extends string | RegExp | undefined ? string : T>
}

export default fromDirectory
Loading

0 comments on commit 6a5de80

Please sign in to comment.