From 1a47d13842f0173b47957fd79a347cef271da30e Mon Sep 17 00:00:00 2001 From: Abhishek Chauhan Date: Wed, 18 Mar 2026 13:25:25 -0500 Subject: [PATCH] feat(types): improve isArray option type inference Add discriminated union types for X2jOptions based on the jPath option. When jPath is true (default), callbacks like isArray, tagValueProcessor, attributeValueProcessor, etc. receive jPath as a string. When jPath is false, callbacks receive a Matcher instance. This allows TypeScript to properly narrow the type of the callback parameter based on the jPath option value, eliminating the need for type assertions when using string methods on jPath. Exports new types: - X2jOptionsBase: Base options shared by both modes - X2jOptionsWithJPathString: Options for jPath: true (string callbacks) - X2jOptionsWithMatcher: Options for jPath: false (Matcher callbacks) Fixes #803 --- lib/fxp.d.cts | 488 ++++++++++++++-------- src/fxp.d.ts | 452 +++++++++++++------- test/types/isArray-type-errors.test.ts | 47 +++ test/types/isArray-type-inference.test.ts | 135 ++++++ 4 files changed, 787 insertions(+), 335 deletions(-) create mode 100644 test/types/isArray-type-errors.test.ts create mode 100644 test/types/isArray-type-inference.test.ts diff --git a/lib/fxp.d.cts b/lib/fxp.d.cts index 1c349d60..99413f2f 100644 --- a/lib/fxp.d.cts +++ b/lib/fxp.d.cts @@ -1,314 +1,451 @@ -type ProcessEntitiesOptions = { - /** - * Whether to enable entity processing - * - * Defaults to `true` - */ - enabled?: boolean; - - /** - * Maximum size in characters for a single entity definition - * - * Defaults to `10000` - */ - maxEntitySize?: number; - - /** - * Maximum depth for nested entity references (reserved for future use) - * - * Defaults to `10` - */ - maxExpansionDepth?: number; - - /** - * Maximum total number of entity expansions allowed - * - * Defaults to `1000` - */ - maxTotalExpansions?: number; - - /** - * Maximum total expanded content length in characters - * - * Defaults to `100000` - */ - maxExpandedLength?: number; - - /** - * Maximum number of entities allowed in the XML - * - * Defaults to `100` - */ - maxEntityCount?: number; - - /** - * Array of tag names where entity replacement is allowed. - * If null, entities are replaced in all tags. - * - * Defaults to `null` - */ - allowedTags?: string[] | null; +//import type { Matcher, Expression } from 'path-expression-matcher'; - /** - * Custom filter function to determine if entities should be replaced in a tag - * - * @param tagName - The name of the current tag - * @param jPath - The jPath of the current tag - * @returns `true` to allow entity replacement, `false` to skip - * - * Defaults to `null` - */ - tagFilter?: ((tagName: string, jPath: string) => boolean) | null; -}; +type Matcher = unknown; +type Expression = unknown; -type X2jOptions = { +/** + * Base options shared by both jPath modes + */ +export type X2jOptionsBase = { /** * Preserve the order of tags in resulting JS object - * + * * Defaults to `false` */ preserveOrder?: boolean; /** * Give a prefix to the attribute name in the resulting JS object - * + * * Defaults to '@_' */ attributeNamePrefix?: string; /** * A name to group all attributes of a tag under, or `false` to disable - * + * * Defaults to `false` */ attributesGroupName?: false | string; /** * The name of the next node in the resulting JS - * + * * Defaults to `#text` */ textNodeName?: string; - /** - * Whether to ignore attributes when parsing - * - * When `true` - ignores all the attributes - * - * When `false` - parses all the attributes - * - * When `Array` - filters out attributes that match provided patterns - * - * When `Function` - calls the function for each attribute and filters out those for which the function returned `true` - * - * Defaults to `true` - */ - ignoreAttributes?: boolean | (string | RegExp)[] | ((attrName: string, jPath: string) => boolean); - /** * Whether to remove namespace string from tag and attribute names - * + * * Defaults to `false` */ removeNSPrefix?: boolean; /** * Whether to allow attributes without value - * + * * Defaults to `false` */ allowBooleanAttributes?: boolean; /** * Whether to parse tag value with `strnum` package - * + * * Defaults to `true` */ parseTagValue?: boolean; /** * Whether to parse attribute value with `strnum` package - * + * * Defaults to `false` */ parseAttributeValue?: boolean; /** * Whether to remove surrounding whitespace from tag or attribute value - * + * * Defaults to `true` */ trimValues?: boolean; /** * Give a property name to set CDATA values to instead of merging to tag's text value - * + * * Defaults to `false` */ cdataPropName?: false | string; /** * If set, parse comments and set as this property - * + * * Defaults to `false` */ commentPropName?: false | string; - /** - * Control how tag value should be parsed. Called only if tag value is not empty - * - * @returns {undefined|null} `undefined` or `null` to set original value. - * @returns {unknown} - * - * 1. Different value or value with different data type to set new value. - * 2. Same value to set parsed value if `parseTagValue: true`. - * - * Defaults to `(tagName, val, jPath, hasAttributes, isLeafNode) => val` - */ - tagValueProcessor?: (tagName: string, tagValue: string, jPath: string, hasAttributes: boolean, isLeafNode: boolean) => unknown; - - /** - * Control how attribute value should be parsed - * - * @param attrName - * @param attrValue - * @param jPath - * @returns {undefined|null} `undefined` or `null` to set original value - * @returns {unknown} - * - * Defaults to `(attrName, val, jPath) => val` - */ - attributeValueProcessor?: (attrName: string, attrValue: string, jPath: string) => unknown; - /** * Options to pass to `strnum` for parsing numbers - * + * * Defaults to `{ hex: true, leadingZeros: true, eNotation: true }` */ numberParseOptions?: strnumOptions; /** * Nodes to stop parsing at - * + * + * Accepts string patterns or Expression objects from path-expression-matcher + * + * String patterns starting with "*." are automatically converted to ".." for backward compatibility + * * Defaults to `[]` */ - stopNodes?: string[]; + stopNodes?: (string | Expression)[]; /** * List of tags without closing tags - * + * * Defaults to `[]` */ unpairedTags?: string[]; /** * Whether to always create a text node - * + * * Defaults to `false` */ alwaysCreateTextNode?: boolean; - /** - * Determine whether a tag should be parsed as an array - * - * @param tagName - * @param jPath - * @param isLeafNode - * @param isAttribute - * @returns {boolean} - * - * Defaults to `() => false` - */ - isArray?: (tagName: string, jPath: string, isLeafNode: boolean, isAttribute: boolean) => boolean; - /** * Whether to process default and DOCTYPE entities - * + * * When `true` - enables entity processing with default limits - * + * * When `false` - disables all entity processing - * + * * When `ProcessEntitiesOptions` - enables entity processing with custom configuration - * + * * Defaults to `true` */ processEntities?: boolean | ProcessEntitiesOptions; /** * Whether to process HTML entities - * + * * Defaults to `false` */ htmlEntities?: boolean; /** * Whether to ignore the declaration tag from output - * + * * Defaults to `false` */ ignoreDeclaration?: boolean; /** * Whether to ignore Pi tags - * + * * Defaults to `false` */ ignorePiTags?: boolean; /** * Transform tag names - * + * * Defaults to `false` */ transformTagName?: ((tagName: string) => string) | false; /** * Transform attribute names - * + * * Defaults to `false` */ transformAttributeName?: ((attributeName: string) => string) | false; + /** + * If true, adds a Symbol to all object nodes, accessible by {@link XMLParser.getMetaDataSymbol} with + * metadata about each the node in the XML file. + */ + captureMetaData?: boolean; + + /** + * Maximum number of nested tags + * + * Defaults to `100` + */ + maxNestedTags?: number; + + /** + * Whether to strictly validate tag names + * + * Defaults to `true` + */ + strictReservedNames?: boolean; + + /** + * Function to sanitize dangerous property names + * + * @param name - The name of the property + * @returns {string} The sanitized name + * + * Defaults to `(name) => __name` + */ + onDangerousProperty?: (name: string) => string; +}; + +/** + * Options when jPath is true (default) - callbacks receive jPath as string + */ +export type X2jOptionsWithJPathString = X2jOptionsBase & { + /** + * Controls whether callbacks receive jPath as string or Matcher instance + * + * When `true` - callbacks receive jPath as string (backward compatible) + * + * Defaults to `true` + */ + jPath?: true; + + /** + * Whether to ignore attributes when parsing + * + * When `true` - ignores all the attributes + * + * When `false` - parses all the attributes + * + * When `Array` - filters out attributes that match provided patterns + * + * When `Function` - calls the function for each attribute and filters out those for which the function returned `true` + * + * Defaults to `true` + */ + ignoreAttributes?: boolean | (string | RegExp)[] | ((attrName: string, jPath: string) => boolean); + + /** + * Control how tag value should be parsed. Called only if tag value is not empty + * + * @param tagName - The name of the tag + * @param tagValue - The value of the tag + * @param jPath - The jPath string representing the tag's location + * @param hasAttributes - Whether the tag has attributes + * @param isLeafNode - Whether the tag is a leaf node + * @returns {undefined|null} `undefined` or `null` to set original value. + * @returns {unknown} + * + * 1. Different value or value with different data type to set new value. + * 2. Same value to set parsed value if `parseTagValue: true`. + * + * Defaults to `(tagName, val, jPath, hasAttributes, isLeafNode) => val` + */ + tagValueProcessor?: (tagName: string, tagValue: string, jPath: string, hasAttributes: boolean, isLeafNode: boolean) => unknown; + + /** + * Control how attribute value should be parsed + * + * @param attrName - The name of the attribute + * @param attrValue - The value of the attribute + * @param jPath - The jPath string representing the attribute's location + * @returns {undefined|null} `undefined` or `null` to set original value + * @returns {unknown} + * + * Defaults to `(attrName, val, jPath) => val` + */ + attributeValueProcessor?: (attrName: string, attrValue: string, jPath: string) => unknown; + + /** + * Determine whether a tag should be parsed as an array + * + * @param tagName - The name of the tag + * @param jPath - The jPath string representing the tag's location + * @param isLeafNode - Whether the tag is a leaf node + * @param isAttribute - Whether this is an attribute + * @returns {boolean} + * + * Defaults to `() => false` + */ + isArray?: (tagName: string, jPath: string, isLeafNode: boolean, isAttribute: boolean) => boolean; + /** * Change the tag name when a different name is returned. Skip the tag from parsed result when false is returned. * Modify `attrs` object to control attributes for the given tag. - * + * + * @param tagName - The name of the tag + * @param jPath - The jPath string representing the tag's location + * @param attrs - The attributes object * @returns {string} new tag name. * @returns false to skip the tag - * + * * Defaults to `(tagName, jPath, attrs) => tagName` */ updateTag?: (tagName: string, jPath: string, attrs: { [k: string]: string }) => string | boolean; +}; +/** + * Options when jPath is false - callbacks receive Matcher instance for advanced pattern matching + */ +export type X2jOptionsWithMatcher = X2jOptionsBase & { /** - * If true, adds a Symbol to all object nodes, accessible by {@link XMLParser.getMetaDataSymbol} with - * metadata about each the node in the XML file. + * Controls whether callbacks receive jPath as string or Matcher instance + * + * When `false` - callbacks receive Matcher instance for advanced pattern matching */ - captureMetaData?: boolean; + jPath: false; /** - * Maximum number of nested tags + * Whether to ignore attributes when parsing + * + * When `true` - ignores all the attributes + * + * When `false` - parses all the attributes + * + * When `Array` - filters out attributes that match provided patterns + * + * When `Function` - calls the function for each attribute and filters out those for which the function returned `true` + * + * Defaults to `true` + */ + ignoreAttributes?: boolean | (string | RegExp)[] | ((attrName: string, matcher: Matcher) => boolean); + + /** + * Control how tag value should be parsed. Called only if tag value is not empty + * + * @param tagName - The name of the tag + * @param tagValue - The value of the tag + * @param matcher - The Matcher instance for advanced pattern matching + * @param hasAttributes - Whether the tag has attributes + * @param isLeafNode - Whether the tag is a leaf node + * @returns {undefined|null} `undefined` or `null` to set original value. + * @returns {unknown} + * + * 1. Different value or value with different data type to set new value. + * 2. Same value to set parsed value if `parseTagValue: true`. + * + * Defaults to `(tagName, val, matcher, hasAttributes, isLeafNode) => val` + */ + tagValueProcessor?: (tagName: string, tagValue: string, matcher: Matcher, hasAttributes: boolean, isLeafNode: boolean) => unknown; + + /** + * Control how attribute value should be parsed + * + * @param attrName - The name of the attribute + * @param attrValue - The value of the attribute + * @param matcher - The Matcher instance for advanced pattern matching + * @returns {undefined|null} `undefined` or `null` to set original value + * @returns {unknown} + * + * Defaults to `(attrName, val, matcher) => val` + */ + attributeValueProcessor?: (attrName: string, attrValue: string, matcher: Matcher) => unknown; + + /** + * Determine whether a tag should be parsed as an array + * + * @param tagName - The name of the tag + * @param matcher - The Matcher instance for advanced pattern matching + * @param isLeafNode - Whether the tag is a leaf node + * @param isAttribute - Whether this is an attribute + * @returns {boolean} + * + * Defaults to `() => false` + */ + isArray?: (tagName: string, matcher: Matcher, isLeafNode: boolean, isAttribute: boolean) => boolean; + + /** + * Change the tag name when a different name is returned. Skip the tag from parsed result when false is returned. + * Modify `attrs` object to control attributes for the given tag. + * + * @param tagName - The name of the tag + * @param matcher - The Matcher instance for advanced pattern matching + * @param attrs - The attributes object + * @returns {string} new tag name. + * @returns false to skip the tag + * + * Defaults to `(tagName, matcher, attrs) => tagName` + */ + updateTag?: (tagName: string, matcher: Matcher, attrs: { [k: string]: string }) => string | boolean; +}; + +/** + * XMLParser options - a discriminated union based on the jPath option + * + * When jPath is true (default) or undefined, callbacks receive jPath as a string. + * When jPath is false, callbacks receive a Matcher instance for advanced pattern matching. + */ +export type X2jOptions = X2jOptionsWithJPathString | X2jOptionsWithMatcher; + +export type ProcessEntitiesOptions = { + /** + * Whether to enable entity processing + * + * Defaults to `true` + */ + enabled?: boolean; + + /** + * Maximum size in characters for a single entity definition + * + * Defaults to `10000` + */ + maxEntitySize?: number; + + /** + * Maximum depth for nested entity references (reserved for future use) + * + * Defaults to `10` + */ + maxExpansionDepth?: number; + + /** + * Maximum total number of entity expansions allowed + * + * Defaults to `1000` + */ + maxTotalExpansions?: number; + + /** + * Maximum total expanded content length in characters + * + * Defaults to `100000` + */ + maxExpandedLength?: number; + + /** + * Maximum number of entities allowed in the XML * * Defaults to `100` */ - maxNestedTags?: number; + maxEntityCount?: number; /** - * Whether to strictly validate tag names + * Array of tag names where entity replacement is allowed. + * If null, entities are replaced in all tags. * - * Defaults to `true` + * Defaults to `null` */ - strictReservedNames?: boolean; + allowedTags?: string[] | null; + + /** + * Custom filter function to determine if entities should be replaced in a tag + * + * @param tagName - The name of the current tag + * @param jPathOrMatcher - The jPath string (if jPath: true) or Matcher instance (if jPath: false) + * @returns `true` to allow entity replacement, `false` to skip + * + * Defaults to `null` + */ + tagFilter?: ((tagName: string, jPathOrMatcher: string | Matcher) => boolean) | null; }; -type strnumOptions = { +export type strnumOptions = { hex: boolean; leadingZeros: boolean, skipLike?: RegExp, eNotation?: boolean } -type validationOptions = { +export type validationOptions = { /** * Whether to allow attributes without value * @@ -324,7 +461,7 @@ type validationOptions = { unpairedTags?: string[]; }; -type XmlBuilderOptions = { +export type XmlBuilderOptions = { /** * Give a prefix to the attribute name in the resulting JS object * @@ -435,9 +572,11 @@ type XmlBuilderOptions = { /** * Nodes to stop parsing at * + * Accepts string patterns or Expression objects from path-expression-matcher + * * Defaults to `[]` */ - stopNodes?: string[]; + stopNodes?: (string | Expression)[]; /** * Control how tag value should be parsed. Called only if tag value is not empty @@ -474,11 +613,18 @@ type XmlBuilderOptions = { oneListGroup?: boolean; + + /** + * Maximum number of nested tags + * + * Defaults to `100` + */ + maxNestedTags?: number; }; type ESchema = string | object | Array; -type ValidationError = { +export type ValidationError = { err: { code: string; msg: string, @@ -487,7 +633,7 @@ type ValidationError = { }; }; -declare class XMLParser { +export class XMLParser { constructor(options?: X2jOptions); parse(xmlData: string | Uint8Array, validationOptions?: validationOptions | boolean): any; /** @@ -510,39 +656,19 @@ declare class XMLParser { static getMetaDataSymbol(): Symbol; } -declare class XMLValidator { +export class XMLValidator { static validate(xmlData: string, options?: validationOptions): true | ValidationError; } - -declare class XMLBuilder { +export class XMLBuilder { constructor(options?: XmlBuilderOptions); build(jObj: any): string; } - /** * This object is available on nodes via the symbol {@link XMLParser.getMetaDataSymbol} * when {@link X2jOptions.captureMetaData} is true. */ -declare interface XMLMetaData { +export interface XMLMetaData { /** The index, if available, of the character where the XML node began in the input stream. */ startIndex?: number; -} - -declare namespace fxp { - export { - XMLParser, - XMLValidator, - XMLBuilder, - XMLMetaData, - XmlBuilderOptions, - X2jOptions, - ESchema, - ValidationError, - strnumOptions, - validationOptions, - ProcessEntitiesOptions, - } -} - -export = fxp; +} \ No newline at end of file diff --git a/src/fxp.d.ts b/src/fxp.d.ts index f0048cb8..99413f2f 100644 --- a/src/fxp.d.ts +++ b/src/fxp.d.ts @@ -1,307 +1,442 @@ -export type ProcessEntitiesOptions = { - /** - * Whether to enable entity processing - * - * Defaults to `true` - */ - enabled?: boolean; - - /** - * Maximum size in characters for a single entity definition - * - * Defaults to `10000` - */ - maxEntitySize?: number; - - /** - * Maximum depth for nested entity references (reserved for future use) - * - * Defaults to `10` - */ - maxExpansionDepth?: number; - - /** - * Maximum total number of entity expansions allowed - * - * Defaults to `1000` - */ - maxTotalExpansions?: number; - - /** - * Maximum total expanded content length in characters - * - * Defaults to `100000` - */ - maxExpandedLength?: number; - - /** - * Maximum number of entities allowed in the XML - * - * Defaults to `100` - */ - maxEntityCount?: number; - - /** - * Array of tag names where entity replacement is allowed. - * If null, entities are replaced in all tags. - * - * Defaults to `null` - */ - allowedTags?: string[] | null; +//import type { Matcher, Expression } from 'path-expression-matcher'; - /** - * Custom filter function to determine if entities should be replaced in a tag - * - * @param tagName - The name of the current tag - * @param jPath - The jPath of the current tag - * @returns `true` to allow entity replacement, `false` to skip - * - * Defaults to `null` - */ - tagFilter?: ((tagName: string, jPath: string) => boolean) | null; -}; +type Matcher = unknown; +type Expression = unknown; -export type X2jOptions = { +/** + * Base options shared by both jPath modes + */ +export type X2jOptionsBase = { /** * Preserve the order of tags in resulting JS object - * + * * Defaults to `false` */ preserveOrder?: boolean; /** * Give a prefix to the attribute name in the resulting JS object - * + * * Defaults to '@_' */ attributeNamePrefix?: string; /** * A name to group all attributes of a tag under, or `false` to disable - * + * * Defaults to `false` */ attributesGroupName?: false | string; /** * The name of the next node in the resulting JS - * + * * Defaults to `#text` */ textNodeName?: string; - /** - * Whether to ignore attributes when parsing - * - * When `true` - ignores all the attributes - * - * When `false` - parses all the attributes - * - * When `Array` - filters out attributes that match provided patterns - * - * When `Function` - calls the function for each attribute and filters out those for which the function returned `true` - * - * Defaults to `true` - */ - ignoreAttributes?: boolean | (string | RegExp)[] | ((attrName: string, jPath: string) => boolean); - /** * Whether to remove namespace string from tag and attribute names - * + * * Defaults to `false` */ removeNSPrefix?: boolean; /** * Whether to allow attributes without value - * + * * Defaults to `false` */ allowBooleanAttributes?: boolean; /** * Whether to parse tag value with `strnum` package - * + * * Defaults to `true` */ parseTagValue?: boolean; /** * Whether to parse attribute value with `strnum` package - * + * * Defaults to `false` */ parseAttributeValue?: boolean; /** * Whether to remove surrounding whitespace from tag or attribute value - * + * * Defaults to `true` */ trimValues?: boolean; /** * Give a property name to set CDATA values to instead of merging to tag's text value - * + * * Defaults to `false` */ cdataPropName?: false | string; /** * If set, parse comments and set as this property - * + * * Defaults to `false` */ commentPropName?: false | string; - /** - * Control how tag value should be parsed. Called only if tag value is not empty - * - * @returns {undefined|null} `undefined` or `null` to set original value. - * @returns {unknown} - * - * 1. Different value or value with different data type to set new value. - * 2. Same value to set parsed value if `parseTagValue: true`. - * - * Defaults to `(tagName, val, jPath, hasAttributes, isLeafNode) => val` - */ - tagValueProcessor?: (tagName: string, tagValue: string, jPath: string, hasAttributes: boolean, isLeafNode: boolean) => unknown; - - /** - * Control how attribute value should be parsed - * - * @param attrName - * @param attrValue - * @param jPath - * @returns {undefined|null} `undefined` or `null` to set original value - * @returns {unknown} - * - * Defaults to `(attrName, val, jPath) => val` - */ - attributeValueProcessor?: (attrName: string, attrValue: string, jPath: string) => unknown; - /** * Options to pass to `strnum` for parsing numbers - * + * * Defaults to `{ hex: true, leadingZeros: true, eNotation: true }` */ numberParseOptions?: strnumOptions; /** * Nodes to stop parsing at - * + * + * Accepts string patterns or Expression objects from path-expression-matcher + * + * String patterns starting with "*." are automatically converted to ".." for backward compatibility + * * Defaults to `[]` */ - stopNodes?: string[]; + stopNodes?: (string | Expression)[]; /** * List of tags without closing tags - * + * * Defaults to `[]` */ unpairedTags?: string[]; /** * Whether to always create a text node - * + * * Defaults to `false` */ alwaysCreateTextNode?: boolean; - /** - * Determine whether a tag should be parsed as an array - * - * @param tagName - * @param jPath - * @param isLeafNode - * @param isAttribute - * @returns {boolean} - * - * Defaults to `() => false` - */ - isArray?: (tagName: string, jPath: string, isLeafNode: boolean, isAttribute: boolean) => boolean; - /** * Whether to process default and DOCTYPE entities - * + * * When `true` - enables entity processing with default limits - * + * * When `false` - disables all entity processing - * + * * When `ProcessEntitiesOptions` - enables entity processing with custom configuration - * + * * Defaults to `true` */ processEntities?: boolean | ProcessEntitiesOptions; /** * Whether to process HTML entities - * + * * Defaults to `false` */ htmlEntities?: boolean; /** * Whether to ignore the declaration tag from output - * + * * Defaults to `false` */ ignoreDeclaration?: boolean; /** * Whether to ignore Pi tags - * + * * Defaults to `false` */ ignorePiTags?: boolean; /** * Transform tag names - * + * * Defaults to `false` */ transformTagName?: ((tagName: string) => string) | false; /** * Transform attribute names - * + * * Defaults to `false` */ transformAttributeName?: ((attributeName: string) => string) | false; + /** + * If true, adds a Symbol to all object nodes, accessible by {@link XMLParser.getMetaDataSymbol} with + * metadata about each the node in the XML file. + */ + captureMetaData?: boolean; + + /** + * Maximum number of nested tags + * + * Defaults to `100` + */ + maxNestedTags?: number; + + /** + * Whether to strictly validate tag names + * + * Defaults to `true` + */ + strictReservedNames?: boolean; + + /** + * Function to sanitize dangerous property names + * + * @param name - The name of the property + * @returns {string} The sanitized name + * + * Defaults to `(name) => __name` + */ + onDangerousProperty?: (name: string) => string; +}; + +/** + * Options when jPath is true (default) - callbacks receive jPath as string + */ +export type X2jOptionsWithJPathString = X2jOptionsBase & { + /** + * Controls whether callbacks receive jPath as string or Matcher instance + * + * When `true` - callbacks receive jPath as string (backward compatible) + * + * Defaults to `true` + */ + jPath?: true; + + /** + * Whether to ignore attributes when parsing + * + * When `true` - ignores all the attributes + * + * When `false` - parses all the attributes + * + * When `Array` - filters out attributes that match provided patterns + * + * When `Function` - calls the function for each attribute and filters out those for which the function returned `true` + * + * Defaults to `true` + */ + ignoreAttributes?: boolean | (string | RegExp)[] | ((attrName: string, jPath: string) => boolean); + + /** + * Control how tag value should be parsed. Called only if tag value is not empty + * + * @param tagName - The name of the tag + * @param tagValue - The value of the tag + * @param jPath - The jPath string representing the tag's location + * @param hasAttributes - Whether the tag has attributes + * @param isLeafNode - Whether the tag is a leaf node + * @returns {undefined|null} `undefined` or `null` to set original value. + * @returns {unknown} + * + * 1. Different value or value with different data type to set new value. + * 2. Same value to set parsed value if `parseTagValue: true`. + * + * Defaults to `(tagName, val, jPath, hasAttributes, isLeafNode) => val` + */ + tagValueProcessor?: (tagName: string, tagValue: string, jPath: string, hasAttributes: boolean, isLeafNode: boolean) => unknown; + + /** + * Control how attribute value should be parsed + * + * @param attrName - The name of the attribute + * @param attrValue - The value of the attribute + * @param jPath - The jPath string representing the attribute's location + * @returns {undefined|null} `undefined` or `null` to set original value + * @returns {unknown} + * + * Defaults to `(attrName, val, jPath) => val` + */ + attributeValueProcessor?: (attrName: string, attrValue: string, jPath: string) => unknown; + + /** + * Determine whether a tag should be parsed as an array + * + * @param tagName - The name of the tag + * @param jPath - The jPath string representing the tag's location + * @param isLeafNode - Whether the tag is a leaf node + * @param isAttribute - Whether this is an attribute + * @returns {boolean} + * + * Defaults to `() => false` + */ + isArray?: (tagName: string, jPath: string, isLeafNode: boolean, isAttribute: boolean) => boolean; + /** * Change the tag name when a different name is returned. Skip the tag from parsed result when false is returned. * Modify `attrs` object to control attributes for the given tag. - * + * + * @param tagName - The name of the tag + * @param jPath - The jPath string representing the tag's location + * @param attrs - The attributes object * @returns {string} new tag name. * @returns false to skip the tag - * + * * Defaults to `(tagName, jPath, attrs) => tagName` */ updateTag?: (tagName: string, jPath: string, attrs: { [k: string]: string }) => string | boolean; +}; +/** + * Options when jPath is false - callbacks receive Matcher instance for advanced pattern matching + */ +export type X2jOptionsWithMatcher = X2jOptionsBase & { /** - * If true, adds a Symbol to all object nodes, accessible by {@link XMLParser.getMetaDataSymbol} with - * metadata about each the node in the XML file. + * Controls whether callbacks receive jPath as string or Matcher instance + * + * When `false` - callbacks receive Matcher instance for advanced pattern matching */ - captureMetaData?: boolean; + jPath: false; /** - * Maximum number of nested tags - * - * Defaults to `100` + * Whether to ignore attributes when parsing + * + * When `true` - ignores all the attributes + * + * When `false` - parses all the attributes + * + * When `Array` - filters out attributes that match provided patterns + * + * When `Function` - calls the function for each attribute and filters out those for which the function returned `true` + * + * Defaults to `true` */ - maxNestedTags?: number; + ignoreAttributes?: boolean | (string | RegExp)[] | ((attrName: string, matcher: Matcher) => boolean); /** - * Whether to strictly validate tag names + * Control how tag value should be parsed. Called only if tag value is not empty + * + * @param tagName - The name of the tag + * @param tagValue - The value of the tag + * @param matcher - The Matcher instance for advanced pattern matching + * @param hasAttributes - Whether the tag has attributes + * @param isLeafNode - Whether the tag is a leaf node + * @returns {undefined|null} `undefined` or `null` to set original value. + * @returns {unknown} + * + * 1. Different value or value with different data type to set new value. + * 2. Same value to set parsed value if `parseTagValue: true`. + * + * Defaults to `(tagName, val, matcher, hasAttributes, isLeafNode) => val` + */ + tagValueProcessor?: (tagName: string, tagValue: string, matcher: Matcher, hasAttributes: boolean, isLeafNode: boolean) => unknown; + + /** + * Control how attribute value should be parsed + * + * @param attrName - The name of the attribute + * @param attrValue - The value of the attribute + * @param matcher - The Matcher instance for advanced pattern matching + * @returns {undefined|null} `undefined` or `null` to set original value + * @returns {unknown} + * + * Defaults to `(attrName, val, matcher) => val` + */ + attributeValueProcessor?: (attrName: string, attrValue: string, matcher: Matcher) => unknown; + + /** + * Determine whether a tag should be parsed as an array + * + * @param tagName - The name of the tag + * @param matcher - The Matcher instance for advanced pattern matching + * @param isLeafNode - Whether the tag is a leaf node + * @param isAttribute - Whether this is an attribute + * @returns {boolean} + * + * Defaults to `() => false` + */ + isArray?: (tagName: string, matcher: Matcher, isLeafNode: boolean, isAttribute: boolean) => boolean; + + /** + * Change the tag name when a different name is returned. Skip the tag from parsed result when false is returned. + * Modify `attrs` object to control attributes for the given tag. + * + * @param tagName - The name of the tag + * @param matcher - The Matcher instance for advanced pattern matching + * @param attrs - The attributes object + * @returns {string} new tag name. + * @returns false to skip the tag + * + * Defaults to `(tagName, matcher, attrs) => tagName` + */ + updateTag?: (tagName: string, matcher: Matcher, attrs: { [k: string]: string }) => string | boolean; +}; + +/** + * XMLParser options - a discriminated union based on the jPath option + * + * When jPath is true (default) or undefined, callbacks receive jPath as a string. + * When jPath is false, callbacks receive a Matcher instance for advanced pattern matching. + */ +export type X2jOptions = X2jOptionsWithJPathString | X2jOptionsWithMatcher; + +export type ProcessEntitiesOptions = { + /** + * Whether to enable entity processing * * Defaults to `true` */ - strictReservedNames?: boolean; -}; + enabled?: boolean; + /** + * Maximum size in characters for a single entity definition + * + * Defaults to `10000` + */ + maxEntitySize?: number; + /** + * Maximum depth for nested entity references (reserved for future use) + * + * Defaults to `10` + */ + maxExpansionDepth?: number; + + /** + * Maximum total number of entity expansions allowed + * + * Defaults to `1000` + */ + maxTotalExpansions?: number; + + /** + * Maximum total expanded content length in characters + * + * Defaults to `100000` + */ + maxExpandedLength?: number; + + /** + * Maximum number of entities allowed in the XML + * + * Defaults to `100` + */ + maxEntityCount?: number; + + /** + * Array of tag names where entity replacement is allowed. + * If null, entities are replaced in all tags. + * + * Defaults to `null` + */ + allowedTags?: string[] | null; + + /** + * Custom filter function to determine if entities should be replaced in a tag + * + * @param tagName - The name of the current tag + * @param jPathOrMatcher - The jPath string (if jPath: true) or Matcher instance (if jPath: false) + * @returns `true` to allow entity replacement, `false` to skip + * + * Defaults to `null` + */ + tagFilter?: ((tagName: string, jPathOrMatcher: string | Matcher) => boolean) | null; +}; export type strnumOptions = { hex: boolean; @@ -437,9 +572,11 @@ export type XmlBuilderOptions = { /** * Nodes to stop parsing at * + * Accepts string patterns or Expression objects from path-expression-matcher + * * Defaults to `[]` */ - stopNodes?: string[]; + stopNodes?: (string | Expression)[]; /** * Control how tag value should be parsed. Called only if tag value is not empty @@ -476,6 +613,13 @@ export type XmlBuilderOptions = { oneListGroup?: boolean; + + /** + * Maximum number of nested tags + * + * Defaults to `100` + */ + maxNestedTags?: number; }; type ESchema = string | object | Array; diff --git a/test/types/isArray-type-errors.test.ts b/test/types/isArray-type-errors.test.ts new file mode 100644 index 00000000..c3253fef --- /dev/null +++ b/test/types/isArray-type-errors.test.ts @@ -0,0 +1,47 @@ +/** + * Type error tests for isArray option type inference (Issue #803) + * + * These tests verify that TypeScript correctly catches type errors + * when the jPath option doesn't match the callback parameter usage. + */ + +import { XMLParser, X2jOptions, X2jOptionsWithJPathString, X2jOptionsWithMatcher } from '../../src/fxp'; + +// ERROR TEST 1: Using string methods when jPath: false +// This should cause a TypeScript error because matcher is unknown, not string +const errorOptions1: X2jOptions = { + jPath: false, + isArray: (tagName, matcher, isLeafNode, isAttribute) => { + // @ts-expect-error - matcher is unknown, not string - includes() not available + return ['root.items'].includes(matcher); + }, +}; + +// ERROR TEST 2: Explicitly typed as X2jOptionsWithMatcher but using string methods +const errorOptions2: X2jOptionsWithMatcher = { + jPath: false, + isArray: (tagName, matcher, isLeafNode, isAttribute) => { + // @ts-expect-error - matcher is unknown, not string + return matcher.startsWith('root.'); + }, +}; + +// ERROR TEST 3: Trying to set jPath: true on X2jOptionsWithMatcher type +const errorOptions3: X2jOptionsWithMatcher = { + // @ts-expect-error - jPath must be false for X2jOptionsWithMatcher + jPath: true, + isArray: (tagName, jPath, isLeafNode, isAttribute) => { + return false; + }, +}; + +// ERROR TEST 4: Trying to set jPath: false on X2jOptionsWithJPathString type +const errorOptions4: X2jOptionsWithJPathString = { + // @ts-expect-error - jPath must be true or undefined for X2jOptionsWithJPathString + jPath: false, + isArray: (tagName, jPath, isLeafNode, isAttribute) => { + return false; + }, +}; + +console.log('All error type tests defined correctly!'); diff --git a/test/types/isArray-type-inference.test.ts b/test/types/isArray-type-inference.test.ts new file mode 100644 index 00000000..c97defdf --- /dev/null +++ b/test/types/isArray-type-inference.test.ts @@ -0,0 +1,135 @@ +/** + * Type tests for isArray option type inference (Issue #803) + * + * These tests verify that TypeScript correctly narrows the type of the + * jPath/matcher parameter in callbacks based on the jPath option value. + */ + +import { XMLParser, X2jOptions, X2jOptionsBase, X2jOptionsWithJPathString, X2jOptionsWithMatcher } from '../../src/fxp'; + +// Helper type to verify type narrowing at compile time +type AssertType = T extends Expected ? (Expected extends T ? true : false) : false; + +// Test 1: Default options (jPath: true or undefined) - callbacks receive string +const defaultOptions: X2jOptions = { + // jPath defaults to true, so callbacks should receive string + isArray: (tagName, jPath, isLeafNode, isAttribute) => { + // TypeScript should infer jPath as string + const path: string = jPath; + return ['root.items', 'root.users'].includes(jPath); + }, + tagValueProcessor: (tagName, tagValue, jPath, hasAttributes, isLeafNode) => { + // jPath should be string + const path: string = jPath; + return tagValue.toUpperCase(); + }, + attributeValueProcessor: (attrName, attrValue, jPath) => { + // jPath should be string + const path: string = jPath; + return attrValue; + }, + updateTag: (tagName, jPath, attrs) => { + // jPath should be string + const path: string = jPath; + return tagName; + }, +}; + +// Test 2: Explicit jPath: true - callbacks receive string +const optionsWithJPathTrue: X2jOptions = { + jPath: true, + isArray: (tagName, jPath, isLeafNode, isAttribute) => { + // TypeScript should infer jPath as string + const path: string = jPath; + return jPath.includes('items'); + }, + ignoreAttributes: (attrName, jPath) => { + // jPath should be string + const path: string = jPath; + return attrName.startsWith('_'); + }, +}; + +// Test 3: jPath: false - callbacks receive Matcher (unknown) +const optionsWithJPathFalse: X2jOptions = { + jPath: false, + isArray: (tagName, matcher, isLeafNode, isAttribute) => { + // matcher should be Matcher (unknown) + // We can't use string methods directly - this is the expected behavior + // Users must type-cast or use Matcher methods + return false; + }, + tagValueProcessor: (tagName, tagValue, matcher, hasAttributes, isLeafNode) => { + // matcher should be Matcher (unknown) + return tagValue; + }, +}; + +// Test 4: Explicit type annotation with X2jOptionsWithJPathString +const stringPathOptions: X2jOptionsWithJPathString = { + jPath: true, // or omit for default + isArray: (tagName, jPath, isLeafNode, isAttribute) => { + // jPath is definitely string here + return ['root.a', 'root.b'].includes(jPath); + }, +}; + +// Test 5: Explicit type annotation with X2jOptionsWithMatcher +const matcherOptions: X2jOptionsWithMatcher = { + jPath: false, + isArray: (tagName, matcher, isLeafNode, isAttribute) => { + // matcher is Matcher (unknown) here + return false; + }, +}; + +// Test 6: Real-world use case from issue #803 +const alwaysArray = ['root.items.item', 'root.users.user', 'root.config.settings']; + +const realWorldOptions: X2jOptions = { + ignoreAttributes: false, + isArray: (tagName, jPath, isLeafNode, isAttribute) => { + // This should work without type assertion now! + return alwaysArray.includes(jPath); + }, +}; + +// Test 7: Using the parser with these options +const parser1 = new XMLParser(defaultOptions); +const parser2 = new XMLParser(optionsWithJPathTrue); +const parser3 = new XMLParser(optionsWithJPathFalse); +const parser4 = new XMLParser(stringPathOptions); +const parser5 = new XMLParser(matcherOptions); +const parser6 = new XMLParser(realWorldOptions); + +// Test 8: Inline options should also work +const parser7 = new XMLParser({ + isArray: (tagName, jPath, isLeafNode, isAttribute) => { + return ['root.items'].includes(jPath); + }, +}); + +const parser8 = new XMLParser({ + jPath: true, + isArray: (tagName, jPath, isLeafNode, isAttribute) => { + return jPath.startsWith('root.'); + }, +}); + +// Test 9: Combined with other options +const combinedOptions: X2jOptions = { + preserveOrder: false, + ignoreAttributes: false, + attributeNamePrefix: '@_', + isArray: (tagName, jPath, isLeafNode, isAttribute) => { + return jPath.endsWith('.item'); + }, + tagValueProcessor: (tagName, tagValue, jPath) => { + if (jPath.includes('numbers')) { + return parseInt(tagValue, 10); + } + return tagValue; + }, +}; + +console.log('All type tests passed!');