UNPKG

@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
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]) }