Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/lucky-clocks-thank.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"rehype-pretty-code": patch
---

fix: stop character-highlight ranges leaking across patterns, so an id-only annotation like `/foo/#a` no longer makes a later pattern inherit the previous pattern's range, and a range-ignored occurrence no longer consumes the whole token it lives in (#169)

Range-excluded occurrences are counted without changing the token tree, so unhighlighted markup remains intact.

Partial highlights contained within a transformer-generated element preserve that surrounding semantic element instead of cloning it.
282 changes: 199 additions & 83 deletions packages/core/src/chars/charsHighlighter.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,190 @@
import type { Element } from 'hast';
import type { CharsHighlighterOptions, CharsElement } from '../types';
import { getElementsToHighlight } from './getElementsToHighlight';
import { wrapHighlightedChars } from './wrapHighlightedChars';
import { toString as hastToString } from 'hast-util-to-string';
import { isElement } from '../utils';
import type { CharsHighlighterOptions, CharsElement } from '../types';
import {
getContentLength,
getElementsToHighlight,
} from './getElementsToHighlight';
import {
wrapHighlightedChars,
wrapHighlightedCharsInRange,
} from './wrapHighlightedChars';

interface SelectedMatch {
chars: string;
start: number;
end: number;
element?: CharsElement;
}

interface MatchComponent {
firstIndex: number;
lastIndex: number;
start: number;
matches: Array<SelectedMatch>;
}

function isAvailable(occupied: Uint8Array, start: number, end: number) {
for (let index = start; index < end; index++) {
if (occupied[index] === 1) {
return false;
}
}

return true;
}

function findMatches(
source: string,
chars: string,
charsIndex: number,
occupied: Uint8Array,
options: CharsHighlighterOptions,
): Array<SelectedMatch> {
const selectedMatches: Array<SelectedMatch> = [];
const currentRange = options.ranges[charsIndex] || [];
const counterId = `${chars}-${charsIndex}`;
let searchStart = 0;

while (searchStart <= source.length - chars.length) {
const matchStart = source.indexOf(chars, searchStart);
if (matchStart === -1) {
break;
}

const matchEnd = matchStart + chars.length;
if (!isAvailable(occupied, matchStart, matchEnd)) {
searchStart = matchStart + 1;
continue;
}

const occurrence = (options.counterMap.get(counterId) || 0) + 1;
options.counterMap.set(counterId, occurrence);
occupied.fill(1, matchStart, matchEnd);

if (currentRange.length === 0 || currentRange.includes(occurrence)) {
selectedMatches.push({ chars, start: matchStart, end: matchEnd });
}

searchStart = matchEnd;
}

return selectedMatches;
}

function createMatchComponents(
element: Element,
selectedMatches: Array<SelectedMatch>,
) {
const ends: Array<number> = [];
let offset = 0;

for (const child of element.children) {
offset += getContentLength(child);
ends.push(offset);
}

const components: Array<MatchComponent> = [];
const matchesByPosition = [...selectedMatches].sort(
(first, second) => first.start - second.start,
);
let firstIndex = 0;
let lastIndex = 0;

for (const match of matchesByPosition) {
while (
firstIndex < element.children.length &&
ends[firstIndex] <= match.start
) {
firstIndex++;
}

lastIndex = Math.max(lastIndex, firstIndex);
while (lastIndex < element.children.length && ends[lastIndex] < match.end) {
lastIndex++;
}

const previous = components.at(-1);
if (previous && firstIndex <= previous.lastIndex) {
previous.lastIndex = Math.max(previous.lastIndex, lastIndex);
previous.matches.push(match);
} else {
components.push({
firstIndex,
lastIndex,
start: firstIndex === 0 ? 0 : ends[firstIndex - 1],
matches: [match],
});
}
}

return components;
}

function applyMatches(
element: Element,
selectedMatches: Array<SelectedMatch>,
options: CharsHighlighterOptions,
) {
const components = createMatchComponents(element, selectedMatches);

for (
let componentIndex = components.length - 1;
componentIndex >= 0;
componentIndex--
) {
const component = components[componentIndex];
const container: Element = {
type: 'element',
tagName: 'span',
properties: {},
children: element.children.slice(
component.firstIndex,
component.lastIndex + 1,
),
};

for (let index = component.matches.length - 1; index >= 0; index--) {
const match = component.matches[index];
const target = getElementsToHighlight(
container,
match.start - component.start,
match.end - component.start,
);
if (!target) {
continue;
}

if (target.type === 'elements') {
match.element = wrapHighlightedChars(
target.parent,
target.elements,
match.chars,
options,
);
} else {
match.element = wrapHighlightedCharsInRange(
target.parent,
target.childIndex,
target.start,
target.end,
match.chars,
options,
);
}
}

element.children.splice(
component.firstIndex,
component.lastIndex - component.firstIndex + 1,
...container.children,
);
}
}

/**
* Loops through the child nodes and finds the nodes that make up the chars.
* If the chars cross node boundaries, those nodes are wrapped with
* <span data-highlighted-chars-mark>, and that node is passed to
* onVisitHighlightedChars.
*
* If a node partially matches the chars, its content is replaced with the
* matched part, and the left and/or right parts are cloned to sibling nodes.
* Finds each requested string in the line, then applies the selected matches
* without materializing range-excluded occurrences.
*/
export function charsHighlighter(
element: Element,
Expand All @@ -23,81 +195,25 @@ export function charsHighlighter(
id: string | undefined,
) => void,
) {
const { ranges = [] } = options;
const textContent = hastToString(element);

// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: char matching is inherently branchy
charsList.forEach((chars, index) => {
if (chars && textContent?.includes(chars)) {
let textContent = hastToString(element);
let startIndex = 0;

while (textContent.includes(chars)) {
// Snapshot the remaining text so we can bail out if an iteration
// fails to make progress. The recomputed `textContent` excludes
// already-highlighted nodes, so a productive iteration always makes
// it strictly shorter; if it does not shrink, no occurrence was
// consumed and continuing would loop forever.
const previousTextContent = textContent;
const currentCharsRange = ranges[index] || [];
const id = `${chars}-${index}`;

options.counterMap.set(id, (options.counterMap.get(id) || 0) + 1);

const ignoreChars =
currentCharsRange.length > 0 &&
!currentCharsRange.includes(options.counterMap.get(id) ?? -1);

const elementsToWrap = getElementsToHighlight(
element,
chars,
startIndex,
ignoreChars,
);

// maybe throw / notify due to failure here
if (elementsToWrap.length === 0) break;
const source = hastToString(element);
const occupied = new Uint8Array(source.length);
const selectedMatches: Array<SelectedMatch> = [];

wrapHighlightedChars(
element,
elementsToWrap,
options,
ignoreChars,
onVisitHighlightedChars,
);

// re-start from the 'last' node (the chars or part of them may exist
// multiple times in the same node)
// account for possible extra nodes added from split with - 2
startIndex = Math.max(
elementsToWrap[elementsToWrap.length - 1].index - 2,
0,
);

textContent = element.children
.map((childNode) => {
const props = isElement(childNode) ? childNode.properties : {};
if (
props &&
!Object.hasOwn(props, 'rehype-pretty-code-visited') &&
!Object.hasOwn(props, 'data-highlighted-chars-mark')
) {
return hastToString(childNode);
}
})
.join('');

// Safety guard: if the remaining text did not shrink, this iteration
// consumed nothing, so stop instead of spinning forever.
if (textContent.length >= previousTextContent.length) break;
}
charsList.forEach((chars, charsIndex) => {
if (!chars) {
return;
}

selectedMatches.push(
...findMatches(source, chars, charsIndex, occupied, options),
);
});

element.children.forEach((childNode) => {
if (!isElement(childNode)) return;
if (Object.hasOwn(childNode.properties, 'rehype-pretty-code-visited')) {
childNode.properties['rehype-pretty-code-visited'] = undefined;
applyMatches(element, selectedMatches, options);

for (const match of selectedMatches) {
if (match.element) {
onVisitHighlightedChars?.(match.element, options.idsMap.get(match.chars));
}
});
}
}
Loading
Loading