From 00c42722bdcf379d420aa05d92d868e335f5911e Mon Sep 17 00:00:00 2001 From: Sara Vieira Date: Wed, 20 Mar 2024 15:38:05 +0000 Subject: [PATCH] assure it wont break when not sending null --- src/index.test.ts | 345 ++++++++++++++++++++++++++++------------------ src/index.ts | 146 +++++++++++--------- 2 files changed, 290 insertions(+), 201 deletions(-) diff --git a/src/index.test.ts b/src/index.test.ts index 5dfa6b6..3d12b49 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,145 +1,216 @@ -import { describe, it, beforeEach, afterEach } from 'bun:test' -import assert from 'node:assert' -import sinon from 'sinon' -import { Highlight } from './index.js' - -describe('default configuration', () => { - it('should correctly highlight a text', () => { - const text1 = 'The quick brown fox jumps over the lazy dog' - const searchTerm1 = 'fox' - const expectedResult1 = 'The quick brown fox jumps over the lazy dog' - - const text2 = 'Yesterday all my troubles seemed so far away, now it looks as though they\'re here to stay oh, I believe in yesterday' - const searchTerm2 = 'yesterday I was in trouble' - const expectedResult2 = 'Yesterday all my troubles seemed so far away, now it looks as though they\'re here to stay oh, I believe in yesterday' - - const highlighter = new Highlight() - - assert.strictEqual(highlighter.highlight(text1, searchTerm1).HTML, expectedResult1) - assert.strictEqual(highlighter.highlight(text2, searchTerm2).HTML, expectedResult2) - }) - - it('should return the correct positions', () => { - const text = 'The quick brown fox jumps over the lazy dog' - const searchTerm = 'fox' - const expectedPositions = [{ start: 16, end: 18 }] - - const highlighter = new Highlight() - - assert.deepStrictEqual(highlighter.highlight(text, searchTerm).positions, expectedPositions) - }) - - it('should return multiple positions', () => { - const text = 'The quick brown fox jumps over the lazy dog' - const searchTerm = 'the' - const expectedPositions = [{ start: 0, end: 2 }, { start: 31, end: 33 }] - - const highlighter = new Highlight() - - assert.deepStrictEqual(highlighter.highlight(text, searchTerm).positions, expectedPositions) - }) -}) - -describe('custom configuration', () => { - it('should correctly highlight a text (case sensitive)', () => { - const text1 = 'The quick brown fox jumps over the lazy dog' - const searchTerm1 = 'Fox' - const expectedResult1 = 'The quick brown fox jumps over the lazy dog' - - const text2 = 'Yesterday all my troubles seemed so far away, now it looks as though they\'re here to stay oh, I believe in yesterday' - const searchTerm2 = 'yesterday I was in trouble' - const expectedResult2 = 'Yesterday all my troubles seemed so far away, now it looks as though they\'re here to stay oh, I believe in yesterday' - - const highlighter = new Highlight({ caseSensitive: true }) - - assert.strictEqual(highlighter.highlight(text1, searchTerm1).HTML, expectedResult1) - assert.strictEqual(highlighter.highlight(text2, searchTerm2).HTML, expectedResult2) - }) - - it('should correctly set a custom CSS class', () => { - const text = 'The quick brown fox jumps over the lazy dog' - const searchTerm = 'fox' - const expectedResult = 'The quick brown fox jumps over the lazy dog' - - const highlighter = new Highlight({ CSSClass: 'custom-class' }) - - assert.strictEqual(highlighter.highlight(text, searchTerm).HTML, expectedResult) - }) - - it('should correctly use a custom HTML tag', () => { - const text = 'The quick brown fox jumps over the lazy dog' - const searchTerm = 'fox' - const expectedResult = 'The quick brown
fox
jumps over the lazy dog' - - const highlighter = new Highlight({ HTMLTag: 'div' }) - - assert.strictEqual(highlighter.highlight(text, searchTerm).HTML, expectedResult) - }) - - it('should correctly highlight whole words only', () => { - const text = 'The quick brown fox jumps over the lazy dog' - const searchTerm = 'fox jump' - const expectedResult = 'The quick brown fox jumps over the lazy dog' - - const highlighter = new Highlight({ wholeWords: true }) - - assert.strictEqual(highlighter.highlight(text, searchTerm).HTML, expectedResult) - }) -}) - -describe('highlight function - infinite loop protection', () => { - let regexExecStub: sinon.SinonStub +import { describe, it, beforeEach, afterEach } from "bun:test"; +import assert from "node:assert"; +import sinon from "sinon"; +import { Highlight } from "./index.js"; + +describe("default configuration", () => { + it("should correctly highlight a text", () => { + const text1 = "The quick brown fox jumps over the lazy dog"; + const searchTerm1 = "fox"; + const expectedResult1 = + 'The quick brown fox jumps over the lazy dog'; + + const text2 = + "Yesterday all my troubles seemed so far away, now it looks as though they're here to stay oh, I believe in yesterday"; + const searchTerm2 = "yesterday I was in trouble"; + const expectedResult2 = + 'Yesterday all my troubles seemed so far away, now it looks as though they\'re here to stay oh, I believe in yesterday'; + + const highlighter = new Highlight(); + + assert.strictEqual( + highlighter.highlight(text1, searchTerm1).HTML, + expectedResult1 + ); + assert.strictEqual( + highlighter.highlight(text2, searchTerm2).HTML, + expectedResult2 + ); + }); + + it("should return the correct positions", () => { + const text = "The quick brown fox jumps over the lazy dog"; + const searchTerm = "fox"; + const expectedPositions = [{ start: 16, end: 18 }]; + + const highlighter = new Highlight(); + + assert.deepStrictEqual( + highlighter.highlight(text, searchTerm).positions, + expectedPositions + ); + }); + + it("should return multiple positions", () => { + const text = "The quick brown fox jumps over the lazy dog"; + const searchTerm = "the"; + const expectedPositions = [ + { start: 0, end: 2 }, + { start: 31, end: 33 }, + ]; + + const highlighter = new Highlight(); + + assert.deepStrictEqual( + highlighter.highlight(text, searchTerm).positions, + expectedPositions + ); + }); +}); + +describe("custom configuration", () => { + it("should correctly highlight a text (case sensitive)", () => { + const text1 = "The quick brown fox jumps over the lazy dog"; + const searchTerm1 = "Fox"; + const expectedResult1 = "The quick brown fox jumps over the lazy dog"; + + const text2 = + "Yesterday all my troubles seemed so far away, now it looks as though they're here to stay oh, I believe in yesterday"; + const searchTerm2 = "yesterday I was in trouble"; + const expectedResult2 = + 'Yesterday all my troubles seemed so far away, now it looks as though they\'re here to stay oh, I believe in yesterday'; + + const highlighter = new Highlight({ caseSensitive: true }); + + assert.strictEqual( + highlighter.highlight(text1, searchTerm1).HTML, + expectedResult1 + ); + assert.strictEqual( + highlighter.highlight(text2, searchTerm2).HTML, + expectedResult2 + ); + }); + + it("should correctly set a custom CSS class", () => { + const text = "The quick brown fox jumps over the lazy dog"; + const searchTerm = "fox"; + const expectedResult = + 'The quick brown fox jumps over the lazy dog'; + + const highlighter = new Highlight({ CSSClass: "custom-class" }); + + assert.strictEqual( + highlighter.highlight(text, searchTerm).HTML, + expectedResult + ); + }); + + it("should correctly use a custom HTML tag", () => { + const text = "The quick brown fox jumps over the lazy dog"; + const searchTerm = "fox"; + const expectedResult = + 'The quick brown
fox
jumps over the lazy dog'; + + const highlighter = new Highlight({ HTMLTag: "div" }); + + assert.strictEqual( + highlighter.highlight(text, searchTerm).HTML, + expectedResult + ); + }); + + it("should correctly highlight whole words only", () => { + const text = "The quick brown fox jumps over the lazy dog"; + const searchTerm = "fox jump"; + const expectedResult = + 'The quick brown fox jumps over the lazy dog'; + + const highlighter = new Highlight({ wholeWords: true }); + + assert.strictEqual( + highlighter.highlight(text, searchTerm).HTML, + expectedResult + ); + }); +}); + +describe("highlight function - infinite loop protection", () => { + let regexExecStub: sinon.SinonStub; beforeEach(() => { - regexExecStub = sinon.stub(RegExp.prototype, 'exec') - }) + regexExecStub = sinon.stub(RegExp.prototype, "exec"); + }); afterEach(() => { - regexExecStub.restore() - }) + regexExecStub.restore(); + }); - it('should exit the loop if regex.lastIndex does not advance', () => { - const text = 'The quick brown fox jumps over the lazy dog' - const searchTerm = 'fox' + it("should exit the loop if regex.lastIndex does not advance", () => { + const text = "The quick brown fox jumps over the lazy dog"; + const searchTerm = "fox"; regexExecStub.callsFake(function () { // @ts-expect-error - this.lastIndex = 0 - return null - }) - - const highlighter = new Highlight() - const result = highlighter.highlight(text, searchTerm) - - assert.strictEqual(result.HTML, text) - - assert(regexExecStub.called) - }) -}) - -describe('trim method', () => { - it('should correctly trim the text', () => { - const text = 'The quick brown fox jumps over the lazy dog' - const searchTerm = 'fox' - const highlighter = new Highlight() - - assert.strictEqual(highlighter.highlight(text, searchTerm).trim(10), '...rown fox j...') - assert.strictEqual(highlighter.highlight(text, searchTerm).trim(5), '...n fox...') - assert.strictEqual(highlighter.highlight(text, 'the').trim(5), 'The q...') - assert.strictEqual(highlighter.highlight(text, 'dog').trim(5), '...y dog') - assert.strictEqual(highlighter.highlight(text, 'dog').trim(5, false), 'y dog') - assert.strictEqual(highlighter.highlight(text, 'the').trim(5, false), 'The q') - }) -}) - -describe('special characters', () => { - it('should correctly highlight a text with special characters', () => { - const text = 'C++ is a hell of a language' - const searchTerm = 'C++' - const expectedResult = 'C++ is a hell of a language' - - const highlighter = new Highlight() - - assert.strictEqual(highlighter.highlight(text, searchTerm).HTML, expectedResult) - }) -}) \ No newline at end of file + this.lastIndex = 0; + return null; + }); + + const highlighter = new Highlight(); + const result = highlighter.highlight(text, searchTerm); + + assert.strictEqual(result.HTML, text); + + assert(regexExecStub.called); + }); +}); + +describe("trim method", () => { + it("should correctly trim the text", () => { + const text = "The quick brown fox jumps over the lazy dog"; + const searchTerm = "fox"; + const highlighter = new Highlight(); + + assert.strictEqual( + highlighter.highlight(text, searchTerm).trim(10), + '...rown fox j...' + ); + assert.strictEqual( + highlighter.highlight(text, searchTerm).trim(5), + '...n fox...' + ); + assert.strictEqual( + highlighter.highlight(text, "the").trim(5), + 'The q...' + ); + assert.strictEqual( + highlighter.highlight(text, "dog").trim(5), + '...y dog' + ); + assert.strictEqual( + highlighter.highlight(text, "dog").trim(5, false), + 'y dog' + ); + assert.strictEqual( + highlighter.highlight(text, "the").trim(5, false), + 'The q' + ); + }); +}); + +describe("special characters", () => { + it("should correctly highlight a text with special characters", () => { + const text = "C++ is a hell of a language"; + const searchTerm = "C++"; + const expectedResult = + 'C++ is a hell of a language'; + + const highlighter = new Highlight(); + + assert.strictEqual( + highlighter.highlight(text, searchTerm).HTML, + expectedResult + ); + }); +}); + +describe("empty example", () => { + it("should not break when text is null", () => { + const searchTerm = "C"; + const highlighter = new Highlight(); + + // even though it is not expected we should make sure it won't break + // @ts-expect-error + assert.strictEqual(highlighter.highlight(null, searchTerm).HTML, ""); + }); +}); diff --git a/src/index.ts b/src/index.ts index eda5413..dbcd63d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,98 +1,116 @@ export interface HighlightOptions { - caseSensitive?: boolean - wholeWords?: boolean - HTMLTag?: string - CSSClass?: string + caseSensitive?: boolean; + wholeWords?: boolean; + HTMLTag?: string; + CSSClass?: string; } -export type Position = { start: number, end: number } +export type Position = { start: number; end: number }; -type Positions = Position[] +type Positions = Position[]; const defaultOptions: HighlightOptions = { caseSensitive: false, wholeWords: false, - HTMLTag: 'mark', - CSSClass: 'orama-highlight' -} + HTMLTag: "mark", + CSSClass: "orama-highlight", +}; export class Highlight { - private readonly options: HighlightOptions - private _positions: Positions = [] - private _HTML: string = '' - private _searchTerm: string = '' - private _originalText: string = '' - - constructor (options: HighlightOptions = defaultOptions) { - this.options = { ...defaultOptions, ...options } + private readonly options: HighlightOptions; + private _positions: Positions = []; + private _HTML: string = ""; + private _searchTerm: string = ""; + private _originalText: string = ""; + + constructor(options: HighlightOptions = defaultOptions) { + this.options = { ...defaultOptions, ...options }; } - public highlight (text: string, searchTerm: string): Highlight { - this._searchTerm = searchTerm - this._originalText = text - - const caseSensitive = this.options.caseSensitive ?? defaultOptions.caseSensitive - const wholeWords = this.options.wholeWords ?? defaultOptions.wholeWords - const HTMLTag = this.options.HTMLTag ?? defaultOptions.HTMLTag - const CSSClass = this.options.CSSClass ?? defaultOptions.CSSClass - const regexFlags = caseSensitive ? 'g' : 'gi' - const boundary = wholeWords ? '\\b' : '' - const searchTerms = this.escapeRegExp(caseSensitive ? searchTerm : searchTerm.toLowerCase()).trim().split(/\s+/).join('|') - const regex = new RegExp(`${boundary}${searchTerms}${boundary}`, regexFlags) - const positions: Array<{ start: number, end: number }> = [] - const highlightedParts: string[] = [] - - let match - let lastEnd = 0 - let previousLastIndex = -1 - - while ((match = regex.exec(text)) !== null) { + public highlight(text: string, searchTerm: string): Highlight { + this._searchTerm = searchTerm; + this._originalText = text ?? ""; + + const caseSensitive = + this.options.caseSensitive ?? defaultOptions.caseSensitive; + const wholeWords = this.options.wholeWords ?? defaultOptions.wholeWords; + const HTMLTag = this.options.HTMLTag ?? defaultOptions.HTMLTag; + const CSSClass = this.options.CSSClass ?? defaultOptions.CSSClass; + const regexFlags = caseSensitive ? "g" : "gi"; + const boundary = wholeWords ? "\\b" : ""; + const searchTerms = this.escapeRegExp( + caseSensitive ? searchTerm : searchTerm.toLowerCase() + ) + .trim() + .split(/\s+/) + .join("|"); + const regex = new RegExp( + `${boundary}${searchTerms}${boundary}`, + regexFlags + ); + const positions: Array<{ start: number; end: number }> = []; + const highlightedParts: string[] = []; + + let match; + let lastEnd = 0; + let previousLastIndex = -1; + + while ((match = regex.exec(this._originalText)) !== null) { if (regex.lastIndex === previousLastIndex) { - break + break; } - previousLastIndex = regex.lastIndex + previousLastIndex = regex.lastIndex; - const start = match.index - const end = start + match[0].length - 1 + const start = match.index; + const end = start + match[0].length - 1; - positions.push({ start, end }) + positions.push({ start, end }); - highlightedParts.push(text.slice(lastEnd, start)) - highlightedParts.push(`<${HTMLTag} class="${CSSClass}">${match[0]}`) + highlightedParts.push(this._originalText.slice(lastEnd, start)); + highlightedParts.push( + `<${HTMLTag} class="${CSSClass}">${match[0]}` + ); - lastEnd = end + 1 + lastEnd = end + 1; } - highlightedParts.push(text.slice(lastEnd)) + highlightedParts.push(this._originalText.slice(lastEnd)); - this._positions = positions - this._HTML = highlightedParts.join('') + this._positions = positions; + this._HTML = highlightedParts.join(""); - return this + return this; } - public trim (trimLength: number, ellipsis: boolean = true): string { - if (this._positions.length === 0 || this._originalText.length <= trimLength) { - return this._HTML + public trim(trimLength: number, ellipsis: boolean = true): string { + if ( + this._positions.length === 0 || + this._originalText.length <= trimLength + ) { + return this._HTML; } - const firstMatch = this._positions[0].start - const start = Math.max(firstMatch - Math.floor(trimLength / 2), 0) - const end = Math.min(start + trimLength, this._originalText.length) - const trimmedContent = `${start === 0 || !ellipsis ? '' : '...'}${this._originalText.slice(start, end)}${end < this._originalText.length && ellipsis ? '...' : ''}` - - this.highlight(trimmedContent, this._searchTerm) - return this._HTML + const firstMatch = this._positions[0].start; + const start = Math.max(firstMatch - Math.floor(trimLength / 2), 0); + const end = Math.min(start + trimLength, this._originalText.length); + const trimmedContent = `${ + start === 0 || !ellipsis ? "" : "..." + }${this._originalText.slice(start, end)}${ + end < this._originalText.length && ellipsis ? "..." : "" + }`; + + this.highlight(trimmedContent, this._searchTerm); + return this._HTML; } - get positions (): Positions { - return this._positions + get positions(): Positions { + return this._positions; } - get HTML (): string { - return this._HTML + get HTML(): string { + return this._HTML; } private escapeRegExp(string: string): string { - return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } }