import { ZWaveError , ZWaveErrorCodes } from "@zwave-js/core" ; import { getErrorMessage } from "@zwave-js/shared" ; import { isArray , isObject } from "alcalzone-shared/typeguards" ; import * as fs from "fs-extra" ; import JSON5 from "json5" ; import * as path from "path" ; const IMPORT_KEY = "$import" ; const importSpecifierRegex = /^(?<filename>(?:~\/)?[\w\d\/\\\._-]+\.json)?(?:#(?<selector>[\w\d\/\._-]+(?:\[0x[0-9a-fA-F]+\])?))?$/i ; type FileCache = Map < string , Record < string , unknown > > ; const templateCache = new Map ( ) ; export function clearTemplateCache ( ) { templateCache . clear ( ) ; } /** Parses a JSON file with $import keys and replaces them with the selected objects */ export async function readJsonWithTemplate ( filename , rootDir ? ) { if ( ! ( await fs . pathExists ( filename ) ) ) { throw new ZWaveError ( `Could not open config file ${ filename }: not found!` , ZWaveErrorCodes . Config_NotFound ) ; } const fileCache = new Map ( templateCache ) ; const ret = await readJsonWithTemplateInternal ( filename , undefined , [ ] , fileCache , rootDir ) ; for ( const [ filename , cached ] of fileCache ) { if ( /[\\/]templates[\\/]/ . test ( filename ) ) { templateCache . set ( filename , cached ) ; } } return ret ; } function assertImportSpecifier ( val , source ? ) { if ( typeof val !== "string" ) { throw new ZWaveError ( `Invalid import specifier ${ String ( val ) }!${ source != undefined ? ` Source: ${ source }` : "" }` , ZWaveErrorCodes . Config_Invalid ) ; } if ( ! importSpecifierRegex . test ( val ) ) { throw new ZWaveError ( `Import specifier "${ val }" is invalid!${ source != undefined ? ` Source: ${ source }` : "" }` , ZWaveErrorCodes . Config_Invalid ) ; } } function getImportSpecifier ( filename , selector ? ) { let ret = filename ; if ( selector ) ret += `#${ selector }` ; return ret ; } function select ( obj , selector ) { let ret = obj ; const selectorParts = selector . split ( "/" ) . filter ( ( s ) : s is string => ! ! s ) ; for ( const part of selectorParts ) { if ( isArray ( ret ) ) { const item = ret . find ( ( r ) => isObject ( r ) && "#" in r && r [ "#" ] === part ) ; if ( item != undefined ) { const { [ "#" ] : _ , ... rest } = item as any ; ret = rest ; continue ; } } ret = ( ret as any ) [ part ] ; } if ( ! isObject ( ret ) ) { throw new ZWaveError ( `The import target "${ selector }" is not an object!` , ZWaveErrorCodes . Config_Invalid ) ; } return ret ; } function getImportStack ( visited , selector ) { const source = [ ... visited , selector ? `#${ selector }` : undefined ] . reverse ( ) . filter ( ( s ) => ! ! s ) as string [ ] ; if ( source . length > 0 ) { return `\nImport stack: ${ source . map ( ( s ) => `\n  in ${ s }` ) . join ( "" ) }` ; } return "" ; } async function readJsonWithTemplateInternal ( filename , selector , visited , fileCache , rootDir ? ) { filename = path . normalize ( filename ) ; if ( rootDir ) { const relativeToRoot = path . relative ( rootDir , filename ) ; if ( relativeToRoot . startsWith ( ".." ) ) { throw new ZWaveError ( `Tried to import config file "${ filename }" outside of root directory "${ rootDir }"!${ getImportStack ( visited , selector ) }` , ZWaveErrorCodes . Config_Invalid ) ; } } const specifier = getImportSpecifier ( filename , selector ) ; if ( visited . includes ( specifier ) ) { const msg = `Circular $import in config files: ${ [ ... visited , specifier , ] . join ( " -> " ) }\n` ; throw new ZWaveError ( msg , ZWaveErrorCodes . Config_CircularImport ) ; } let json ; if ( fileCache . has ( filename ) ) { json = fileCache . get ( filename ) ! ; } else { try { const fileContent = await fs . readFile ( filename , "utf8" ) ; json = JSON5 . parse ( fileContent ) ; fileCache . set ( filename , json ) ; } catch ( e ) { throw new ZWaveError ( `Could not parse config file ${ filename }: ${ getErrorMessage ( e ) }${ getImportStack ( visited , selector ) }` , ZWaveErrorCodes . Config_Invalid ) ; } } return resolveJsonImports ( selector ? select ( json , selector ) : json , filename , [ ... visited , specifier ] , fileCache , rootDir ) ; } /** Replaces all `$import` properties in a JSON object with object spreads of the referenced file/property */ async function resolveJsonImports ( json , filename , visited , fileCache , rootDir ? ) { const ret = { } ; for ( const [ prop , val ] of Object . entries ( json ) ) { if ( prop === IMPORT_KEY ) { assertImportSpecifier ( val , visited . join ( " -> " ) ) ; const { filename : importFilename , selector } = importSpecifierRegex . exec ( val ) ! . groups ! ; let newFilename ; if ( importFilename ) { if ( importFilename . startsWith ( "~/" ) ) { if ( rootDir ) { newFilename = path . join ( rootDir , importFilename . slice ( 2 ) ) ; } else { throw new ZWaveError ( `An $import specifier cannot start with ~/ when no root directory is defined!${ getImportStack ( visited , selector ) }` , ZWaveErrorCodes . Config_Invalid ) ; } } else { newFilename = path . join ( path . dirname ( filename ) , importFilename ) ; } } else { newFilename = filename ; } const imported = await readJsonWithTemplateInternal ( newFilename , selector , visited , fileCache , rootDir ) ; Object . assign ( ret , imported ) ; } else if ( isObject ( val ) ) { ret [ prop ] = await resolveJsonImports ( val , filename , visited , fileCache , rootDir ) ; } else if ( isArray ( val ) ) { const vals = [ ] ; for ( const v of val ) { if ( isObject ( v ) ) { vals . push ( await resolveJsonImports ( v , filename , visited , fileCache , rootDir ) ) ; } else { vals . push ( v ) ; } } ret [ prop ] = vals ; } else { ret [ prop ] = val ; } } return ret ; }