chrono-node
Version:
A natural language date parser in Javascript
195 lines (163 loc) • 6.8 kB
text/typescript
import { ReferenceWithTimezone, ParsingComponents, ParsingResult } from "./results";
import { Component, ParsedResult, ParsingOption, ParsingReference } from "./types";
import { AsyncDebugBlock, DebugHandler } from "./debugging";
import ENDefaultConfiguration from "./locales/en/configuration";
/**
* Chrono configuration.
* It is simply an ordered list of parsers and refiners
*/
export interface Configuration {
parsers: Parser[];
refiners: Refiner[];
}
/**
* An abstraction for Chrono *Parser*.
*
* Each parser should recognize and handle a certain date format.
* Chrono uses multiple parses (and refiners) together for parsing the input.
*
* The parser implementation must provide {@Link pattern | pattern()} for the date format.
*
* The {@Link extract | extract()} method is called with the pattern's *match*.
* The matching and extracting is controlled and adjusted to avoid for overlapping results.
*/
export interface Parser {
pattern(context: ParsingContext): RegExp;
extract(
context: ParsingContext,
match: RegExpMatchArray
): ParsingComponents | ParsingResult | { [c in Component]?: number } | null;
}
/**
* A abstraction for Chrono *Refiner*.
*
* Each refiner takes the list of results (from parsers or other refiners) and returns another list of results.
* Chrono applies each refiner in order and return the output from the last refiner.
*/
export interface Refiner {
refine: (context: ParsingContext, results: ParsingResult[]) => ParsingResult[];
}
/**
* The Chrono object.
*/
export class Chrono {
parsers: Array<Parser>;
refiners: Array<Refiner>;
defaultConfig = new ENDefaultConfiguration();
constructor(configuration?: Configuration) {
configuration = configuration || this.defaultConfig.createCasualConfiguration();
this.parsers = [...configuration.parsers];
this.refiners = [...configuration.refiners];
}
/**
* Create a shallow copy of the Chrono object with the same configuration (`parsers` and `refiners`)
*/
clone(): Chrono {
return new Chrono({
parsers: [...this.parsers],
refiners: [...this.refiners],
});
}
/**
* A shortcut for calling {@Link parse | parse() } then transform the result into Javascript's Date object
* @return Date object created from the first parse result
*/
parseDate(text: string, referenceDate?: ParsingReference | Date, option?: ParsingOption): Date | null {
const results = this.parse(text, referenceDate, option);
return results.length > 0 ? results[0].start.date() : null;
}
parse(text: string, referenceDate?: ParsingReference | Date, option?: ParsingOption): ParsedResult[] {
const context = new ParsingContext(text, referenceDate, option);
let results = [];
this.parsers.forEach((parser) => {
const parsedResults = Chrono.executeParser(context, parser);
results = results.concat(parsedResults);
});
results.sort((a, b) => {
return a.index - b.index;
});
this.refiners.forEach(function (refiner) {
results = refiner.refine(context, results);
});
return results;
}
private static executeParser(context: ParsingContext, parser: Parser) {
const results = [];
const pattern = parser.pattern(context);
const originalText = context.text;
let remainingText = context.text;
let match = pattern.exec(remainingText);
while (match) {
// Calculate match index on the full text;
const index = match.index + originalText.length - remainingText.length;
match.index = index;
const result = parser.extract(context, match);
if (!result) {
// If fails, move on by 1
remainingText = originalText.substring(match.index + 1);
match = pattern.exec(remainingText);
continue;
}
let parsedResult: ParsingResult = null;
if (result instanceof ParsingResult) {
parsedResult = result;
} else if (result instanceof ParsingComponents) {
parsedResult = context.createParsingResult(match.index, match[0]);
parsedResult.start = result;
} else {
parsedResult = context.createParsingResult(match.index, match[0], result);
}
const parsedIndex = parsedResult.index;
const parsedText = parsedResult.text;
context.debug(() =>
console.log(`${parser.constructor.name} extracted (at index=${parsedIndex}) '${parsedText}'`)
);
results.push(parsedResult);
remainingText = originalText.substring(parsedIndex + parsedText.length);
match = pattern.exec(remainingText);
}
return results;
}
}
export class ParsingContext implements DebugHandler {
readonly text: string;
readonly option: ParsingOption;
readonly reference: ReferenceWithTimezone;
/**
* @deprecated. Use `reference.instant` instead.
*/
readonly refDate: Date;
constructor(text: string, refDate?: ParsingReference | Date, option?: ParsingOption) {
this.text = text;
this.reference = new ReferenceWithTimezone(refDate);
this.option = option ?? {};
this.refDate = this.reference.instant;
}
createParsingComponents(components?: { [c in Component]?: number } | ParsingComponents): ParsingComponents {
if (components instanceof ParsingComponents) {
return components;
}
return new ParsingComponents(this.reference, components);
}
createParsingResult(
index: number,
textOrEndIndex: number | string,
startComponents?: { [c in Component]?: number } | ParsingComponents,
endComponents?: { [c in Component]?: number } | ParsingComponents
): ParsingResult {
const text = typeof textOrEndIndex === "string" ? textOrEndIndex : this.text.substring(index, textOrEndIndex);
const start = startComponents ? this.createParsingComponents(startComponents) : null;
const end = endComponents ? this.createParsingComponents(endComponents) : null;
return new ParsingResult(this.reference, index, text, start, end);
}
debug(block: AsyncDebugBlock): void {
if (this.option.debug) {
if (this.option.debug instanceof Function) {
this.option.debug(block);
} else {
const handler: DebugHandler = <DebugHandler>this.option.debug;
handler.debug(block);
}
}
}
}