docschema
Version:
Schema declaration and validation library using JsDoc comments
545 lines (460 loc) • 13 kB
JavaScript
/**
* @param {string} path
* @returns {string}
*/
export function dirname(path) {
return (path.match(/(.*)[\/\\]/u) ?? '')[1] ?? ''
}
/**
* @see https://stackoverflow.com/questions/17575790/environment-detection-node-js-or-browser
* @returns {boolean}
*/
export function isBrowserEnvironment() {
if (isBrowserEnvironment.isIt === undefined) {
const isBrowser = new Function(
'try {return this===window;}catch(e){ return false;}'
)
isBrowserEnvironment.isIt = isBrowser()
}
return isBrowserEnvironment.isIt ?? false
}
/**
* The environment doesn't change over time, so it's enough
* to determine it once. This variable is used to store the
* environment, it's a cache.
*
* @type {undefined | boolean}
*/
isBrowserEnvironment.isIt = undefined
/**
* @param {Object<any, any>} object
* @returns {boolean}
*/
export function isObjectEmpty(object) {
for (const prop in object) {
if (Object.hasOwn(object, prop)) {
return false
}
}
return true
}
/**
* Using binary search, find first element that is greater or
* equal to the given target.
*
* @param {number[]} arr Sorted array
* @param {number} target
* @returns {number}
*/
export function binarySearchFirstGTE(arr, target) {
let start = 0
let end = arr.length - 1
let result = -1
while (true) {
const mid = Math.floor((start + end) / 2)
// Discard left side
if (target >= (arr[mid] ?? 0)) {
start = mid + 1
}
// Discard right side
else {
end = mid - 1
}
result = end
if (start > end) {
break
}
}
return result
}
/**
* @param {string} inputString
* The 'start' symbol of the input string should be the opening
* bracket. The closing bracket is automatically determined.
*
* For example:
* ``
* {textInside{}} textOutside
* ``
* The output value for this example would be 13.
* @param {number} [start]
* The position where the opening quote is.
* @returns {number} The position of the found bracket,
* or 0 if not found
*/
export function findClosingBracketPosition(inputString, start = 0) {
const openingBracket = inputString[start] ?? ''
const closingBracket = closingBracketOf(openingBracket)
if (!closingBracket) return 0
let bracketsCounter = 0
let position = start
let found = false
for (position = start; position < inputString.length; position++) {
const char = inputString[position]
if (char === openingBracket) {
bracketsCounter += 1
}
else if (char === closingBracket) {
bracketsCounter -= 1
if (bracketsCounter === 0) {
found = true
break
}
}
}
return (found) ? position - start : 0
}
/**
* @param {string} inputString
* The 'start' symbol of the input string should be the
* opening quote.
* @param {number} [start]
* The position where the opening quote is.
* @returns {number}
* The position of the closing quote after 'start',
* or 0 if the string doesn't start with a quote,
* or 0 if no closing quote found.
*/
export function findClosingQuotePosition(inputString, start = 0) {
const quote = inputString[start] ?? ''
if (!quote || !findClosingQuotePosition.quotes.has(quote)) {
return 0
}
/**
* Note: Searching for the next occurrence of a symbol
* with indexOf is faster than looping through each symbol
*/
let pos = start + 1
while (true) {
const end = inputString.indexOf(quote, pos)
if (end === -1) {
pos = 0
break
}
if (end > 0 && inputString[end - 1] !== '\\') {
pos = end - start
break
}
}
return pos
}
findClosingQuotePosition.quotes = new Set(['\'', '"', '`'])
/**
* Find the position of the first EOL symbol (\n).
* If not found, return the position of the end.
*
* @param {string} inputString
* @param {number} [start]
* @returns {number}
* The position of the next \n after 'start', or -1 if not found
*/
export function findEOL(inputString, start = 0) {
let position = inputString.indexOf('\n', start)
if (position < 0) {
position = inputString.length - 1
if (position < 0) position = 0 // in case of empty string
}
return position - start
}
/**
* Remove // in the beginning or in the middle of the string
*
* @param {string} description
* @returns {string}
*/
export function flattenMultilineDescription(description) {
const pattern = /^\s*\/\/\s*|\n\s*\/\/\s*/umg
return description.replace(pattern, ' ').trim()
}
/**
* @param {string} openingBracket (, [, < or {
* @returns {string}
*/
export function closingBracketOf(openingBracket) {
switch (openingBracket) {
case '(': return ')'
case '[': return ']'
case '<': return '>'
case '{': return '}'
default: return ''
}
}
const openingBrackets = Object.freeze(new Set(['(', '[', '<', '{']))
const quotes = Object.freeze(new Set(['\'', '"', '`']))
/**
* Splits the type expression using custom separator.
* The split is aware of chunks of code (brackets, quotes,
* comments) and treats them as whole entities.
*
* @param {string} typeExpression
* @param {Set<string>} [separators] One or more separators
* @returns {string[]} An array with minimum 1 element.
*/
export function splitTypeExpression(
typeExpression,
separators = new Set(['|'])
) {
const union = []
/*
* First, cleanup the type expression by trimming it
* and removing wrapping braces
*/
const expression = removeWrappingBraces(typeExpression.trim())
/*
* + 1 is given to the length, so in case the type definition
* ends with bracket or quote, (which makes a jump in the loop
* beyond the closing bracket or quote), there is one extra
* iteration available to push the final member
*/
const length = expression.length + 1
let start = 0 // The start position of each member of the union
let prevChar = ''
for (let i = 0; i < length; i++) {
const char = expression[i] ?? ''
if (separators.has(char) || i === length - 1) {
const end = (i === length - 1) ? i + 1 : i
const member = expression.substring(start, end).trim()
union.push(member)
start = i + 1 // Set the 'start' for the following type
}
else if (openingBrackets.has(char)) {
const end = findClosingBracketPosition(expression, i)
if (end > 0) i += end // Jump after the closing bracket
}
else if (quotes.has(char)) {
const end = findClosingQuotePosition(expression, i)
if (end > 0) i += end // Jump after the closing quote
}
else if (char === '/' && prevChar === '/') {
const end = findEOL(expression, i)
if (end > 0) i += end // Jump after EOL
}
prevChar = char
}
return union
}
/**
* In JsDoc comments, single-line comments in object literals are
* considered descriptions. One such description can be made of
* multiple lines of single-line descriptions.
*
* When the input JsDoc expression has multiple lines and starts
* with a single-line comment (//...), that comment is a JsDoc
* description and is isolated from the rest of the expression.
*
* The returned value is a tuple with 2 values, where the
* isolated description is at index 0 and the rest is at index 1.
* Both values are trimmed.
*
* @param {string} expression
* @returns {[string, string]}
* @throws {SyntaxError}
*/
export function isolateFrontDescription(expression) {
/** @type {[string, string]} */
const tuple = ['', '']
const length = expression.length + 1
/** @type {string} */
let prevChar = ''
for (let i = 0; i < length; i++) {
const char = expression[i] ?? ''
prevChar = (i > 0)
? expression[i - 1] ?? ''
: ' ' // tried using empty string, but space works better
if (char === ' ' || char === '\r' || char === '\n') {
continue // skip
}
else if (char === '/') {
if (prevChar === '/') {
const end = findEOL(expression, i)
if (end > 0) i += end // Jump after EOL
}
else {
if (prevChar === ' ' || prevChar === '\r' || prevChar === '\n') {
/**
* We end up here in the beginning of a comment, or on
* the empty space before // when // is on a new row.
*/
continue
}
else {
/**
* Single / detected, it's a problem
*/
throw new SyntaxError(
`Unexpected single slash in expression "${expression}"`
)
}
}
}
else {
tuple[0] = expression.substring(0, i).trim()
tuple[0] = flattenMultilineDescription(tuple[0])
tuple[1] = expression.substring(i).trim()
break
}
}
return tuple
}
/**
* @param {string} expression
* A trimmed JsDoc expression, containing type and
* (optionally) a description.
* The description can have multiple lines, where
* each line starts with //.
* @returns {[string, string]}
* For example, if the expression is "number // description",
* the output will be ['number', 'description'].
* If the expression is "{number} // description",
* the output will be ['{number}', 'description'].
*/
export function isolateEndDescription(expression) {
/** @type {[string, string]} */
const tuple = ['', '']
/*
* In case the expression starts with a bracket,
* find the end bracket
*/
const endBracketPos = findClosingBracketPosition(expression)
const commentPos = expression.indexOf('//', endBracketPos)
if (commentPos > -1) {
tuple[0] = expression.substring(0, commentPos).trim()
tuple[1] = expression.substring(commentPos + 2).trim()
tuple[1] = flattenMultilineDescription(tuple[1])
}
else {
tuple[0] = expression.trim()
}
return tuple
}
/**
* @see https://gist.github.com/creationix/7435851#gistcomment-3698888
*
* @param {...string} segments
* @returns {string}
*/
export function joinPath(...segments) {
const parts = segments.reduce(joinPath.reducer, [])
const resultParts = []
for (const part of parts) {
if (part === '.') {
continue
}
if (part === '..') {
resultParts.pop()
continue
}
resultParts.push(part)
}
return resultParts.join('/')
}
/**
* @param {string[]} parts
* @param {string} segment
* @returns {string[]}
*/
joinPath.reducer = (parts, segment) => {
// Remove leading slashes from non-first part.
if (parts.length > 0) {
segment = segment.replace(/^[\/\\]+/u, '')
}
// Remove trailing slashes.
segment = segment.replace(/[\/\\]+$/u, '')
return parts.concat(segment.split(/[\/\\]/u))
}
/**
* If the input value is a string, wrap it into "" quotes,
* otherwise turn it into a string and return it
*
* @param {*} value
* @param {string} quote
* @returns {string}
*/
export function enquoteString(value, quote = '"') {
if (typeof value === 'string') {
return `${quote}${value}${quote}`
}
else if (value instanceof Object) {
return JSON.stringify(value)
}
// Remember, don't use toString() on undefined or null
return (value ?? '').toString()
}
/**
* If the input value is a string, remove the wrapping quotes,
* otherwise turn it into a string and return it
*
* @param {*} value
* @returns {string}
*/
export function dequoteString(value) {
if (typeof value === 'string') {
if (
(value.startsWith('\'') && value.endsWith('\''))
|| (value.startsWith('"') && value.endsWith('"'))
) {
return value.substring(1, value.length - 1)
}
}
return (value ?? '').toString()
}
/**
* Removes ( and ) braces, if they are the first and
* the last symbols of the trimmed input string
*
* @param {string} str
* @returns {string}
*/
export function removeWrappingBraces(str) {
return str.replace(/^\s*\((.*)\)\s*$/u, '$1').trim()
}
/**
* @param {any[]} object
* @param {number} [maxSymbols]
* @returns {string}
* @throws {TypeError}
*/
export function objectStringify(object, maxSymbols = 30) {
if (!(object instanceof Object)) {
return String(object)
}
let objectStr = JSON.stringify(object)
objectStr = (objectStr.length > maxSymbols)
? `${objectStr.substring(0, maxSymbols - 4)} ...`
: objectStr
return objectStr
}
/**
* Trim each value of the chopped commend (array)
*
* @param {string[]} array
*/
export function trimArrayElements(array) {
for (let index = 0; index < array.length; index++) {
array[index] = (array[index] ?? '').trim()
}
}
/**
* Search for simple ES imports and return the file names.
*
* @example
* `
* import './fileOne.js'
* import './fileTwo.js'
* `
* This returns [ './fileOne.js', './fileTwo.js' ]
*
* @param {string} code
* @returns {string[]}
*/
export function extractSimpleImports(code) {
const output = []
const pattern = /(?:^|\n|;)import\s*('[^']+'|"[^"]+"|`[^`]+`)/ugm
const matches = code.matchAll(pattern)
for (const match of matches) {
let fileName = match[1] ?? ''
fileName = fileName.substring(1, fileName.length - 1) // Remove quotes
output.push(fileName)
}
return output
}