Skip to content

Latest commit

 

History

History
695 lines (519 loc) · 15.5 KB

File metadata and controls

695 lines (519 loc) · 15.5 KB

Struct for JavaScript

Plain JavaScript port; runtime-identical to the canonical TypeScript implementation.

For motivation, language-neutral concepts, and the cross-language parity matrix, see the top-level README and REPORT.md.

Install

The JS source is a single CommonJS module: src/struct.js. Inside the monorepo:

const struct = require('./javascript/src/struct.js')

When packaged the public name is @voxgig/structjs (see package.json).

Quick start

const {
  getpath, setpath, merge, walk,
  inject, transform, validate, select,
} = require('./javascript/src/struct.js')

getpath({ db: { host: 'localhost' } }, 'db.host')
// 'localhost'

transform(
  { user: { first: 'Ada', last: 'Lovelace' }, age: 36 },
  { name: '`user.first`', surname: '`user.last`', years: '`age`' }
)
// { name: 'Ada', surname: 'Lovelace', years: 36 }

validate(
  { name: 'Ada', age: 36 },
  { name: '`$STRING`', age: '`$INTEGER`' }
)
// { name: 'Ada', age: 36 }   (throws on mismatch)

Module exports

module.exports = {
  StructUtility, Injection,

  // 6 regex functions
  re_compile, re_find, re_find_all, re_replace, re_test, re_escape,

  // 42 utility functions (48 canonical = these 42 + the 6 re_* above)
  clone, delprop, escre, escurl, filter, flatten, getdef, getelem,
  getpath, getprop, haskey, inject, isempty, isfunc, iskey, islist,
  ismap, isnode, items, join, jsonify, keysof, merge, pad, pathify,
  select, setpath, setprop, size, slice, strkey, stringify,
  transform, typify, typename, validate, walk, jm, jt,
  checkPlacement, injectorArgs, injectChild,

  // sentinels
  SKIP, DELETE,

  // 15 type bit-flags
  T_any, T_noval, T_boolean, T_decimal, T_integer, T_number, T_string,
  T_function, T_symbol, T_null, T_list, T_map, T_instance, T_scalar,
  T_node,

  // mode flags
  M_KEYPRE, M_KEYPOST, M_VAL, MODENAME,
}

Function reference

Source: src/struct.js.

Predicates

isnode(val)     // true for maps and lists
ismap(val)      // true for plain objects
islist(val)    // true for arrays
iskey(key)      // true for non-empty strings or numbers
isempty(val)   // true for null/undefined/''/{}/[]
isfunc(val)    // true for functions
isnode({ a: 1 })          // true
isnode([1])               // true
isnode('x')               // false
ismap({ a: 1 })           // true
ismap([1])                // false
islist([1, 2])            // true
islist({ a: 1 })          // false
iskey('name')             // true
iskey(0)                  // true
iskey('')                 // false
isempty([])               // true
isempty(null)             // true
isempty(0)                // false
isfunc(() => 1)           // true

Type inspection

typify(val)               // -> int bitfield
typename(t)               // -> human-friendly type name
typify(1)                 // T_scalar | T_number | T_integer  (201326720)
typify(42)                // T_scalar | T_number | T_integer
typify('hi')              // T_scalar | T_string
typify(undefined)         // T_noval
typify(null)              // T_scalar | T_null
typename(8192)            // 'map'  (8192 === T_map)
typename(typify('hi'))    // 'string'
typename(typify({}))      // 'map'

Size, slice, pad

size(val)                            // -> int
slice(val, start?, end?, mutate?)    // sub-section of list/string/number
pad(str, padding?, padchar?)         // pad to width; negative -> left
size([1,2,3])             // 3
size({a:1,b:2})           // 2
size('abc')               // 3

slice keeps the first N; a negative start drops the last |start| items, and end is exclusive:

slice([1,2,3,4,5], 1, 4)  // [2, 3, 4]
slice('abcdef', -3)       // 'abc'  (drops the last 3)
pad('a', 3)               // 'a  '
pad('hi', 5)              // 'hi   '
pad('hi', -5, '*')        // '***hi'

Property access

getprop(val, key, alt?)           // -> any
setprop(parent, key, val)         // mutates and returns parent
delprop(parent, key)              // mutates and returns parent
getelem(list, key, alt?)          // list lookup; -1 from end
getdef(val, alt)                  // val unless undefined
haskey(val, key)                  // -> bool
keysof(val)                       // sorted keys
items(val)                        // [[key,val], ...]
items(val, fn)                    // map over entries
strkey(key)                       // canonical string form
getprop({x:1}, 'x')               // 1
getprop({a:1}, 'b', 'def')        // 'def'
setprop({a:1}, 'b', 2)            // { a:1, b:2 }
delprop({a:1, b:2}, 'a')          // { b:2 }
getelem([10,20,30], -1)           // 30
getdef(undefined, 'fb')           // 'fb'
haskey({a:1}, 'a')                // true
items({a:1, b:2})                 // [['a',1], ['b',2]]
strkey(2.2)                       // '2'
strkey(1)                         // '1'
keysof({b:4, a:5})                // ['a','b']  (sorted)

Path operations

getpath(store, path, injdef?)      // -> any
setpath(store, path, val, injdef?) // mutates and returns store
pathify(val, startin?, endin?)     // canonical dotted string
getpath({ a: { b: { c: 42 } } }, 'a.b.c')   // 42
getpath({ a: [10,20] }, 'a.1')              // 20
getpath({}, 'missing')                      // undefined

const store = {}
setpath(store, 'db.host', 'localhost')
// store === { db: { host: 'localhost' } }
setpath({ a: 1, b: 2 }, 'b', 22)            // { a: 1, b: 22 }
pathify(['a','b','c'])                      // 'a.b.c'

Tree operations

walk(val, before?, after?, maxdepth?)
  // before/after :: (key, val, parent, path) => any
merge(list, maxdepth?)
clone(val)
flatten(list, depth?)
filter(val, check)
walk(tree, undefined, (k, v) => v == null ? 'X' : v)

Last input wins; maps deep-merge; lists merge by index:

merge([
  { a:1, b:2, k:[10,20], x:{y:5,z:6} },
  { b:3, d:4, e:8, k:[11], x:{y:7} },
])
// { a:1, b:3, d:4, e:8, k:[11,20], x:{y:7,z:6} }
clone({ a:{ b:[1,2] } })        // { a:{ b:[1,2] } }  (a deep copy)
flatten([1,[2,[3]]])            // [1, 2, [3]]  (one level by default)
flatten([1,[2,[3,[4]]]])        // [1, 2, [3, [4]]]
flatten([1,[2,[3,[4]]]], 2)     // [1, 2, 3, [4]]

filter passes each [key, value] pair to the check and returns the matching values (not the pairs):

filter([1, 2, 3, 4, 5], ([k, v]) => v > 3)
// [4, 5]

String / URL / JSON

escre(s)                           // escape regex metachars
escurl(s)                          // URL-encode
join(arr, sep?, url?)              // join parts
jsonify(val, flags?)               // JSON serialise
stringify(val, maxlen?, pretty?)   // human-friendly compact
escre('a.b+c')                      // 'a\\.b\\+c'
escurl('hello world?')              // 'hello%20world%3F'
join(['a','b','c'], '/')            // 'a/b/c'
join(['http:', '/foo/'], '/', true) // 'http:/foo/'

jsonify pretty-prints by default (indent 2); pass { indent: 0 } for the compact form:

jsonify({ a: 1 })
// {
//   "a": 1
// }
jsonify({ a: 1, b: 2 }, { indent: 0 })  // '{"a":1,"b":2}'

stringify is the compact, quote-light form — keys are sorted and object braces are kept; the second argument caps the length (the ... counts):

stringify({ a: 1, b: [2, 3] })          // '{a:1,b:[2,3]}'
stringify('verylongstring', 5)          // 've...'

Inject / transform / validate / select

inject(val, store, injdef?)
transform(data, spec, injdef?)
validate(data, spec, injdef?)
select(children, query)
inject({ x: '`a`', y: 2 }, { a: 1 })    // { x: 1, y: 2 }
inject(
  { greeting: 'hello `name`' },
  { name: 'Ada' }
)
// { greeting: 'hello Ada' }

transform(
  { hold: { x: 1 }, top: 99 },
  { a: '`hold.x`', b: '`top`' }
)
// { a: 1, b: 99 }
validate(
  { name: 'Ada', age: 36 },
  { name: '`$STRING`', age: '`$INTEGER`' }
)
// { name: 'Ada', age: 36 }    (throws on mismatch)
select(
  { a: { name: 'Alice', age: 30 }, b: { name: 'Bob', age: 25 } },
  { age: 30 }
)
// [{ name: 'Alice', age: 30, $KEY: 'a' }]

Transform commands drive structural ops. A command like $EACH appears in value position — as the first element of a list ['$EACH', path, subspec] — mapping the sub-spec over every entry at path:

transform(
  { v: 1, a: [{ q: 13 }, { q: 23 }] },
  { x: { y: ['`$EACH`', 'a', { q: '`$COPY`', r: '`.q`', p: '`...v`' }] } }
)
// { x: { y: [ { q: 13, r: 13, p: 1 }, { q: 23, r: 23, p: 1 } ] } }

Putting a command in key position (or, for $APPLY, directly under a map) is an error — commands must be list values:

transform({}, { x: '`$APPLY`' })
// throws: $APPLY: invalid placement in parent map, expected: list.

Builders

jm('a', 1, 'b', 2)        // { a: 1, b: 2 }
jt(1, 2, 3)               // [1, 2, 3]

Injection helpers

Exposed for callers writing custom injectors:

checkPlacement(modes, ijname, parentTypes, inj)  // -> bool
injectorArgs(argTypes, args)                      // -> any
injectChild(child, store, inj)                    // -> Injection

Constants

Sentinels

SKIP        // emit nothing for this key
DELETE      // remove this key from the parent

Type bit-flags

T_any T_noval T_boolean T_decimal T_integer T_number T_string
T_function T_symbol T_null T_list T_map T_instance T_scalar T_node

typify(val) returns a bit-field combining a kind flag (T_scalar or T_node) with a specific type flag.

Walk / inject phase flags

M_KEYPRE       // pre-descent
M_KEYPOST      // post-descent
M_VAL          // leaf-value visit
MODENAME       // string[] mapping mode flags to names

Transform commands

Used as backtick-quoted strings inside a transform spec.

$DELETE  $COPY    $KEY     $META    $ANNO
$MERGE   $EACH    $PACK    $REF     $FORMAT  $APPLY

See the top-level README for purpose of each.

Validate checkers

Used as backtick-quoted strings inside a validate spec.

$MAP   $LIST   $STRING   $NUMBER   $INTEGER   $DECIMAL  $BOOLEAN
$NULL  $NIL    $FUNCTION $INSTANCE $ANY       $CHILD    $ONE     $EXACT

Notes

null versus undefined

JavaScript distinguishes the two natively; struct preserves the distinction:

  • undefined -> "absent". getprop returns it for a missing key.
  • null -> JSON null, a defined scalar.

If your JSON parser returns null for absent fields, convert to undefined (or use '__NULL__' placeholders) before passing in.

Lists mutate in place

merge, setpath, and inject rely on lists being reference-stable -- a mutation through one reference is visible to every holder. JavaScript arrays satisfy this natively.

Difference from canonical TypeScript

The exported function surface is identical (48 canonical functions, including the six re_* regex helpers). The only export difference is Injection: the JS port exports it as a runtime value, whereas TypeScript exports it only as a type. Otherwise functionally identical -- both run on V8.

Test status

90/90 tests pass against the shared corpus.

Regex

Uniform six-function regex API (see /design/REGEX_API.md). On JavaScript this is the ECMAScript RegExp built-in.

API

Function Maps to
re_compile(pattern, flags?) new RegExp(pattern, flags ?? 'g')
re_test(pattern, input) pattern.test(input)
re_find(pattern, input) input.match(pattern) (non-global pattern)
re_find_all(pattern, input) [...input.matchAll(pattern)]
re_replace(pattern, input, rep) input.replace(pattern, rep) (global pattern)
re_escape(s) escape `[.*+?^${}()

Dialect

Patterns must stay inside the RE2 subset documented in /design/REGEX.md. RegExp itself supports backreferences and lookaround, but other ports do not, so using those will not be portable.

Sharp edges

  • Catastrophic backtracking. RegExp is a backtracking engine; nested quantifiers like (a+)+ against a non-matching suffix can be exponential in input length (the discovery panel sees ~180 ms on Node 22 vs <0.1 ms on RE2-style engines). Prefer flat patterns and character classes over alternations.
  • Zero-width replace. re_replace("a*", "abc", "X") returns "XXbXcX" — the ECMA convention shared by all PCRE/ECMA/.NET/Java/Onigmo engines plus the in-tree Thompson ports. Go (RE2) returns "XbXcX" instead; see /design/REGEX_PATHOLOGICAL.md.

See /design/REGEX_PATHOLOGICAL.md for the cross-port pathological-input panel.

Build and test

cd javascript
make test           # runs the shared .jsonic corpus

Tests live in test/ and read fixtures from ../build/test/.