@freeword/meta
Version:
Meta package for Freeword: exports all core types, constants, and utilities from the src/ directory.
330 lines (312 loc) • 16.4 kB
text/typescript
import _ /**/ from 'lodash'
import readline from 'node:readline'
import { Readable } from 'node:stream'
import * as Zlib from 'node:zlib'
import NodeFS from 'node:fs'
import NodeFSP from 'node:fs/promises'
import PathUtils from 'node:path'
import * as UF from '../utils/UF.ts'
import type * as TY from '../types/index.ts'
import type * as FT from '../types/FilerTypes.ts'
import { throwable } from '../utils/OutcomeUtils.ts'
/**
* Helper function to create consistent bad outcomes
* @param err - The error to wrap
* @param gist - The gist of the error (e.g. 'badPath', 'badInput', 'blankPath')
* @param preambleMsg - A message to prepend to the error message
* @param tmi - Additional error extensions to add to the error
* @param pathinfo - The pathinfo object to add to the error
*/
function badOutcome<GT extends FT.FilerGist = FT.FilerGist>(err: Error, gist: GT, preambleMsg: string, tmi: TY.AnyBag, pathinfo: FT.PathinfoT): FT.BadFilerResult<GT>
function badOutcome<GT extends FT.FilerGist = FT.FilerGist>(err: Error, gist: GT, preambleMsg: string, tmi: TY.AnyBag, pathinfo?: FT.PathinfoT | undefined): FT.BadFilerResult<GT>
function badOutcome<GT extends FT.FilerGist = FT.FilerGist>(err: Error, gist: GT, preambleMsg: string, tmi: TY.AnyBag, pathinfo?: FT.PathinfoT | undefined): FT.BadFilerResult<GT> {
const origmsg = err.message
const extError: TY.ExtError = new Error(`${preambleMsg}: ${origmsg}`) as TY.ExtError
const errTMI: Omit<FT.BadFilerResult<GT>, 'err'> = {
...(pathinfo || {}),
...tmi,
ok: false,
gist,
origmsg,
}
extError.extensions = errTMI
return { ...errTMI, err: extError }
}
/**
* Creates directory recursively
* @param anypath - The pathname or pathinfo of the directory to create -- the `abspath` is used and NOT the `dirpath`
* @returns a GoodFilerMkdirResult or a BadFilerMkdirResult
*/
export async function mkdirp(anypath: FT.Anypath): Promise<FT.FilerMkdirResult> {
const pathinfo = pathinfoFor(anypath)
if (! pathinfo.ok) { return pathinfo }
try {
await NodeFSP.mkdir(pathinfo.dirpath, { recursive: true })
return { ...pathinfo, ok: true, gist: 'ok', val: pathinfo }
} catch (err) {
return badOutcome(err as Error, 'fsErr', 'Failed to create directory', { args: anypath }, pathinfo)
}
}
/**
* Given a pathinfo object, assemble the absolute path
*/
export function _abspathForPathparts(pathinfo: Pick<FT.PathinfoT, 'dirpath' | 'barename' | 'fext'>, ...pathsegs: TY.StringMaybe[]): FT.Abspath {
if (! _.isEmpty(pathsegs)) { const outcome = badOutcome(new Error('Cannot have path object and path segments'), 'badInput', 'Path segments are not a reasonable input', { args: { pathinfo, pathsegs } }); throw outcome.err }
const dirpathStr = (typeof pathinfo.dirpath === 'string') ? pathinfo.dirpath.trim() : pathinfo.dirpath
const barenameStr = (typeof pathinfo.barename === 'string') ? pathinfo.barename.trim() : pathinfo.barename
if (! dirpathStr || ! barenameStr) { const outcome = badOutcome(new Error('Blank path provided'), 'blankPath', 'Blank path is not a reasonable input', { args: { pathinfo } }); throw outcome.err }
const filename = pathinfo.fext ? `${barenameStr}.${pathinfo.fext}` : barenameStr
try {
return PathUtils.resolve(dirpathStr, filename)
} catch (err) {
const outcome = badOutcome(err as Error, 'badPath', 'Failed to resolve path', { args: { pathinfo } })
throw outcome.err
}
}
/**
* Converts a plain pathname to an absolute path
*/
export function _abspathForPathname(pathname: FT.Pathname, ...pathsegs: TY.StringMaybe[]): FT.Abspath {
const pathnameStr = (typeof pathname === 'string') ? pathname.trim() : pathname
if (! pathnameStr) {
const outcome = badOutcome(new Error('Blank path provided'), 'blankPath', 'Blank path is not a reasonable input', { args: { pathname } })
throw outcome.err
}
try {
return PathUtils.resolve(pathnameStr, ...UF.scrubNil(pathsegs))
} catch (err) {
const outcome = badOutcome(err as Error, 'badPath', 'Failed to resolve path', { args: { pathname } })
throw outcome.err
}
}
/** Assemble pathinfo using a (possibly relative) dirpath, a barename and a file extension
* @param pathinfo - The pathinfo dna to assemble
* dirpath - The directory path (relative or absolute)
* barename - The base name of the file (without extension)
* fext - The file extension (including the dot)
* @param pathsegs - Additional path segments to append to the pathname
* @returns A complete pathinfo object, with abspath and dirpath resolved
*/
export function pathinfoFor(anypath: FT.Anypath, ...pathsegs: TY.StringMaybe[]): FT.PathinfoT & { ok: true, basename: TY.Basename } | FT.BadFilerResult<'badPath' | 'badInput' | 'blankPath'> {
const pathnameStr = (typeof anypath === 'string') ? anypath.trim() : anypath
if (! pathnameStr) {
return badOutcome(new Error('Blank path provided'), 'blankPath', 'Blank path is not a reasonable input', { args: { anypath } })
}
try {
const abspath = (typeof anypath === 'string') ? _abspathForPathname(anypath, ...pathsegs) : _abspathForPathparts(anypath, ...pathsegs)
const dirpath = PathUtils.dirname(abspath)
const basename = PathUtils.basename(abspath)
const fext = PathUtils.extname(basename).slice(1)
const barename = fext ? basename.slice(0, (-1 - fext.length)) : basename
return {
ok: true,
barename,
basename,
fext,
dirpath,
abspath,
}
} catch (err) {
return badOutcome(err as Error, 'badPath', 'Failed to parse path', { anypath })
}
}
export function dirpathFor(anypath: FT.Anypath, ...pathsegs: TY.StringMaybe[]): FT.Abspath { return pathinfoFor(anypath, ...pathsegs).dirpath! }
export function abspathFor(anypath: FT.Anypath, ...pathsegs: TY.StringMaybe[]): FT.Abspath { return pathinfoFor(anypath, ...pathsegs).abspath! }
export function barenameFor(anypath: FT.Anypath, ...pathsegs: TY.StringMaybe[]): FT.Barename { return pathinfoFor(anypath, ...pathsegs).barename! }
export function fextFor(anypath: FT.Anypath, ...pathsegs: TY.StringMaybe[]): FT.Fext { return pathinfoFor(anypath, ...pathsegs).fext! }
export function __dirname(importMetaURL: TY.URLStr, ...relpaths: (TY.Relpath | undefined)[]): FT.Abspath {
const callerpath = String(importMetaURL).replace(/^file:\/\//, '/')
const pathinfo = pathinfoFor(PathUtils.dirname(callerpath), ...relpaths)
if (! pathinfo.ok) { throw pathinfo.err }
return pathinfo.abspath
}
export function __relname(importMetaURL: TY.URLStr, ...relpaths: (TY.Relpath | undefined)[]): FT.Abspath {
return __dirname(importMetaURL, ...relpaths)
}
/**
* Async generator that reads a file and yields each line
* Returns AsyncGenerator<string, FilerReadResult, unknown>
*/
export async function* starlinesFiddly(anypath: FT.Anypath): AsyncGenerator<string, FT.FilerReadResult<FT.PathinfoT, FT.CoreReadGist>, unknown> {
const pathinfo = pathinfoFor(anypath)
if (! pathinfo.ok) { return pathinfo }
let fileHandle
try {
fileHandle = await NodeFSP.open(pathinfo.abspath, 'r')
} catch (err) {
return badOutcome(err as Error, 'readErr', 'Issue opening file', { args: anypath }, pathinfo)
}
try {
const buffer = Buffer.alloc(4096)
let leftover = ''
while (true) {
const { bytesRead } = await fileHandle.read(buffer, 0, buffer.length, null)
if (bytesRead === 0) { break }
const chunk = leftover + buffer.toString('utf8', 0, bytesRead)
const lines = chunk.split('\n')
leftover = lines.pop()!
// Yield all complete lines except the last one (which might be incomplete)
for (const line of lines) {
try {
yield line
} catch (err) {
const outcome = badOutcome(err as Error, 'callerErr', 'Error processing line', { filepath: pathinfo.abspath, args: anypath })
throw outcome.err
}
}
}
// Yield the final line if there's anything left
if (leftover) {
try {
yield leftover
} catch (err) {
const outcome = badOutcome(err as Error, 'callerErr', 'Error processing final line', { filepath: pathinfo.abspath, args: anypath })
throw outcome.err
}
}
} finally {
await fileHandle.close()
}
return { ...pathinfo, ok: true, gist: 'ok', val: pathinfo }
}
function _openRawFilestream(anypath: FT.Anypath, { encoding = 'utf8' }: { encoding?: 'utf8' | 'utf16le' | 'binary' | null } = {}): FT.FilerReadResult<Readable, FT.CoreReadGist> {
const pathinfo = pathinfoFor(anypath); if (! pathinfo.ok) { return pathinfo }
try { NodeFS.accessSync(pathinfo.abspath, NodeFS.constants.R_OK) } catch (rawerr) {
const err = rawerr as NodeJS.ErrnoException
let linkExists = false
try { NodeFS.lstatSync(pathinfo.abspath); linkExists = true } catch { /* path does not exist */ }
if (linkExists) { return badOutcome(err, 'readErr', 'Issue opening file: filesystem link is incorrect', { args: anypath }, pathinfo) }
return badOutcome(err, 'fileNotFound', `Path ${pathinfo.abspath} is absent`, { args: anypath }, pathinfo)
}
try {
const contentsStream = NodeFS.createReadStream(pathinfo.abspath, { encoding: encoding ?? undefined })
return { ...pathinfo, gist: 'ok', ok: true, val: contentsStream }
} catch (rawerr) {
const err = rawerr as NodeJS.ErrnoException
if (err.code === 'ENOENT') {
return badOutcome(err, 'fileNotFound', `Path ${pathinfo.abspath} is absent`, { args: anypath }, pathinfo)
}
return badOutcome(err as Error, 'readErr', 'Issue opening file', { args: anypath }, pathinfo)
}
}
/** Opens a raw filestream with the (decompressed) file contents
* @param anypath - The pathname or pathinfo of the file to open. If it ends in `.gz` or `.bz2` it will be decompressed
* @returns A Readable stream or a BadFilerResult
*/
export function openFilestream(anypath: FT.Anypath): FT.FilerReadResult<Readable, FT.CoreReadGist> {
const pathinfo = pathinfoFor(anypath); if (! pathinfo.ok) { return pathinfo }
const encoding = /^(gz|bz2|zip)$/.test(pathinfo.fext) ? null : 'utf8'
const contentsStream = _openRawFilestream(pathinfo, { encoding }); if (! contentsStream.ok) { return contentsStream }
if (contentsStream.fext === 'gz') {
return { ...contentsStream, val: contentsStream.val.pipe(Zlib.createGunzip()) }
}
if (contentsStream.fext === 'zip') {
return { ...contentsStream, val: contentsStream.val.pipe(Zlib.createUnzip()) }
}
return contentsStream
}
export function openLinestream(anypath: FT.Anypath): FT.FilerReadResult<readline.Interface, FT.CoreReadGist> {
const contentsStream = openFilestream(anypath); if (! contentsStream.ok) { return contentsStream }
return { ...contentsStream, val: readline.createInterface({ input: contentsStream.val }) }
}
/** Loads the contents of a file, returning either { ok: true, val: stringContentsOfThatFile, ...pathinfo }, or a BadFilerResult
* Like the other filer methods, it never throws: consult result.ok for a tagged union
*/
export async function loadtext(anypath: FT.Anypath): Promise<FT.FilerReadResult<string, FT.CoreReadGist>> {
const contentsStream = openFilestream(anypath); if (! contentsStream.ok) { return contentsStream }
const contents = await UF.slurp(contentsStream.val)
return { ...contentsStream, val: contents.join('') }
}
/**
* Async generator that reads a file and yields each line
* Returns AsyncGenerator<string, FilerReadResult, unknown>
*/
export async function* starlines(anypath: FT.Anypath): AsyncGenerator<string, FT.FilerReadResult<number, FT.CoreReadGist>, unknown> {
const linestream = openLinestream(anypath) ; if (! linestream.ok) { throw linestream.err }
let lineNumber = 0
try {
for await (const line of linestream.val) {
lineNumber += 1
try {
yield line
} catch (err) { throw throwable(`Upstream error at line ${lineNumber}`, 'callerErr', { line, lineNumber, filepath: linestream.abspath, args: anypath }, err) }
}
} catch (err) {
if (err.extensions?.gist === 'consumeErr') { throw err }
if (err.code === 'ENOENT') {
throw throwable(`Path ${linestream.abspath} is absent`, 'fileNotFound', { lineNumber, filepath: anypath, args: { anypath } }, err)
}
if (err.code === 'Z_DATA_ERROR') {
throw throwable(`Decompression error ${anypath}:${lineNumber}`, 'compressErr', { lineNumber, filepath: anypath, args: { anypath } }, err)
}
throw throwable(`File read error ${anypath}:${lineNumber}`, 'consumeErr', { lineNumber, filepath: anypath, args: { anypath } }, err)
} finally {
if (linestream.val) { linestream.val.close() }
}
return { ...linestream, val: lineNumber }
}
const JSONKV_RE = /^\s*(?:([\}\]])\s*$|[\{\[,]\s*\t\s*("[^\t]*)\t\s*:\s*\t\s*(.*)|[\{\[,]\s*\t\s*(.*))$/
export async function* starjsonEntries<VT, KT extends string | number = number>(anypath: FT.Anypath): AsyncGenerator<[KT, VT, number], FT.FilerReadResult<number, FT.CoreReadGist | 'consumeErr'>, unknown> {
let val: VT; let key: KT
let lineNumber = 0
const pathinfo = pathinfoFor(anypath) ; if (! pathinfo.ok) { throw pathinfo.err }
for await (const line of starlines(pathinfo.abspath)) {
lineNumber += 1
const match = JSONKV_RE.exec(line)
if (match?.[1]) { continue }
const jskey = match?.[2]
const json = match?.[3] ?? match?.[4] ?? line
try {
key = jskey ? JSON.parse(jskey) : (lineNumber - 1)
val = JSON.parse(json)
} catch (err) { throw throwable(`Failed to parse JSON at line ${lineNumber}`, 'parseErr', { line, lineNumber, filepath: anypath, args: { anypath } }, err) }
try {
yield [key, val, (lineNumber - 1)]
} catch (err) { throw throwable(`Upstream error at line ${lineNumber}`, 'consumeErr', { val, line, lineNumber, filepath: anypath, args: { anypath } }, err) }
}
return { ...pathinfo, ok: true, gist: 'ok', val: lineNumber }
}
export function starjsonl<VT>(anypath: FT.Anypath): AsyncGenerator<VT, void, unknown>
export function starjsonl<VT>(anypath: FT.Anypath): AsyncGenerator<null, void, VT>
export function starjsonl(anypath: FT.Anypath): AsyncGenerator<any, void, unknown>
export async function* starjsonl<VT>(anypath: FT.Anypath): AsyncGenerator<VT | null, void, VT> {
for await (const [_key, val, _lineNumber] of starjsonEntries<VT>(anypath)) { yield val }
}
export async function* starjsonkeys<KT extends string | number = number>(anypath: FT.Anypath): AsyncGenerator<KT, void, KT> {
for await (const [key, _val, _lineNumber] of starjsonEntries<any, KT>(anypath)) { yield key }
}
/**
* Creates directory and writes each line from an iterable/async iterable to a file
* Returns FilerResult<PathinfoT>
*/
export async function dumptext(anypath: FT.Anypath, lines: Iterable<string> | AsyncIterable<string>): Promise<FT.FilerWriteResult<FT.PathinfoT, 'writeErr' | 'fsErr' | 'badPath' | 'badInput' | 'blankPath'>> {
const pathinfo = pathinfoFor(anypath)
if (! pathinfo.ok) {return pathinfo }
const mkdirResult = await mkdirp({ ...pathinfo, abspath: pathinfo.dirpath })
if (! mkdirResult.ok) { return mkdirResult }
try {
const fileHandle = await NodeFSP.open(pathinfo.abspath, 'w')
try {
for await (const line of lines) {
await fileHandle.write(line + '\n')
}
} finally {
await fileHandle.close()
}
return { ...pathinfo, ok: true, gist: 'ok', val: pathinfo }
} catch (err) {
return badOutcome(err as Error, 'writeErr', 'Failed to write file', { args: anypath }, pathinfo)
}
}
/**
* Pretty prints JSON and calls dumptext. Returns FilerResult<PathinfoT>
*/
export async function dumpjson(anypath: FT.Anypath, data: any): Promise<FT.FilerWriteResult<FT.PathinfoT, FT.CoreWriteGist>> {
let jsonString: string
try {
jsonString = JSON.stringify(data, null, 2)
} catch (err) {
return badOutcome(err as Error, 'parseErr', 'Failed to stringify data to JSON', { args: anypath })
}
return await dumptext(anypath, [jsonString])
}