react-viewport-utils
Version:
Utility components for working with the viewport in react
287 lines (247 loc) • 8.95 kB
JavaScript
// @flow
import type {DiagnosticCodeHighlight} from '@parcel/diagnostic';
import chalk from 'chalk';
import emphasize from 'emphasize';
import stringWidth from 'string-width';
import sliceAnsi from 'slice-ansi';
type CodeFramePadding = {|
before: number,
after: number,
|};
type CodeFrameOptionsInput = $Shape<CodeFrameOptions>;
type CodeFrameOptions = {|
useColor: boolean,
syntaxHighlighting: boolean,
maxLines: number,
padding: CodeFramePadding,
terminalWidth: number,
language?: string,
|};
const NEWLINE = /\r\n|[\n\r\u2028\u2029]/;
const TAB_REPLACE_REGEX = /\t/g;
const TAB_REPLACEMENT = ' ';
const DEFAULT_TERMINAL_WIDTH = 80;
const highlightSyntax = (txt: string, lang?: string): string => {
if (lang) {
try {
return emphasize.highlight(lang, txt).value;
} catch (e) {
// fallback for unknown languages...
}
}
return emphasize.highlightAuto(txt).value;
};
export default function codeFrame(
code: string,
highlights: Array<DiagnosticCodeHighlight>,
inputOpts: CodeFrameOptionsInput = {},
): string {
if (highlights.length < 1) return '';
let opts: CodeFrameOptions = {
useColor: !!inputOpts.useColor,
syntaxHighlighting: !!inputOpts.syntaxHighlighting,
language: inputOpts.language,
maxLines: inputOpts.maxLines ?? 12,
terminalWidth: inputOpts.terminalWidth || DEFAULT_TERMINAL_WIDTH,
padding: inputOpts.padding || {
before: 1,
after: 2,
},
};
// Highlights messages and prefixes when colors are enabled
const highlighter = (s: string, bold?: boolean) => {
if (opts.useColor) {
let redString = chalk.red(s);
return bold ? chalk.bold(redString) : redString;
}
return s;
};
// Prefix lines with the line number
const lineNumberPrefixer = (params: {|
lineNumber?: string,
lineNumberLength: number,
isHighlighted: boolean,
|}) => {
let {lineNumber, lineNumberLength, isHighlighted} = params;
return `${isHighlighted ? highlighter('>') : ' '} ${
lineNumber
? lineNumber.padStart(lineNumberLength, ' ')
: ' '.repeat(lineNumberLength)
} | `;
};
// Make columns/lines start at 1
highlights = highlights.map(h => {
return {
start: {
column: h.start.column - 1,
line: h.start.line - 1,
},
end: {
column: h.end.column - 1,
line: h.end.line - 1,
},
message: h.message,
};
});
// Find first and last highlight
let firstHighlight =
highlights.length > 1
? highlights.sort((a, b) => a.start.line - b.start.line)[0]
: highlights[0];
let lastHighlight =
highlights.length > 1
? highlights.sort((a, b) => b.end.line - a.end.line)[0]
: highlights[0];
// Calculate first and last line index of codeframe
let startLine = firstHighlight.start.line - opts.padding.before;
startLine = startLine < 0 ? 0 : startLine;
let endLineIndex = lastHighlight.end.line + opts.padding.after;
endLineIndex =
endLineIndex - startLine > opts.maxLines
? startLine + opts.maxLines - 1
: endLineIndex;
let lineNumberLength = (endLineIndex + 1).toString(10).length;
// Split input into lines and highlight syntax
let lines = code.split(NEWLINE);
let syntaxHighlightedLines = (opts.syntaxHighlighting
? highlightSyntax(code, opts.language)
: code
)
.replace(TAB_REPLACE_REGEX, TAB_REPLACEMENT)
.split(NEWLINE);
// Loop over all lines and create codeframe
let resultLines = [];
for (
let currentLineIndex = startLine;
currentLineIndex < syntaxHighlightedLines.length;
currentLineIndex++
) {
if (currentLineIndex > endLineIndex) break;
if (currentLineIndex > syntaxHighlightedLines.length - 1) break;
// Find highlights that need to get rendered on the current line
let lineHighlights = highlights
.filter(
highlight =>
highlight.start.line <= currentLineIndex &&
highlight.end.line >= currentLineIndex,
)
.sort(
(a, b) =>
(a.start.line < currentLineIndex ? 0 : a.start.column) -
(b.start.line < currentLineIndex ? 0 : b.start.column),
);
// Check if this line has a full line highlight
let isWholeLine =
lineHighlights.length &&
!!lineHighlights.find(
h => h.start.line < currentLineIndex && h.end.line > currentLineIndex,
);
let lineLengthLimit =
opts.terminalWidth > lineNumberLength + 7
? opts.terminalWidth - (lineNumberLength + 5)
: 10;
// Split the line into line parts that will fit the provided terminal width
let colOffset = 0;
let lineEndCol = lineLengthLimit;
let syntaxHighlightedLine = syntaxHighlightedLines[currentLineIndex];
if (stringWidth(syntaxHighlightedLine) > lineLengthLimit) {
if (lineHighlights.length > 0) {
if (lineHighlights[0].start.line === currentLineIndex) {
colOffset = lineHighlights[0].start.column - 5;
} else if (lineHighlights[0].end.line === currentLineIndex) {
colOffset = lineHighlights[0].end.column - 5;
}
}
colOffset = colOffset > 0 ? colOffset : 0;
lineEndCol = colOffset + lineLengthLimit;
syntaxHighlightedLine = sliceAnsi(
syntaxHighlightedLine,
colOffset,
lineEndCol,
);
}
// Write the syntax highlighted line part
resultLines.push(
lineNumberPrefixer({
lineNumber: (currentLineIndex + 1).toString(10),
lineNumberLength,
isHighlighted: lineHighlights.length > 0,
}) + syntaxHighlightedLine,
);
let lineWidth = stringWidth(syntaxHighlightedLine);
let highlightLine = '';
if (isWholeLine) {
highlightLine = highlighter('^'.repeat(lineWidth));
} else if (lineHighlights.length > 0) {
let lastCol = 0;
let highlight = null;
let highlightHasEnded = false;
for (
let highlightIndex = 0;
highlightIndex < lineHighlights.length;
highlightIndex++
) {
// Set highlight to current highlight
highlight = lineHighlights[highlightIndex];
highlightHasEnded = false;
// Calculate the startColumn and get the real width by doing a substring of the original
// line and replacing tabs with our tab replacement to support tab handling
let startCol = 0;
if (
highlight.start.line === currentLineIndex &&
highlight.start.column > colOffset
) {
startCol = lines[currentLineIndex]
.substring(colOffset, highlight.start.column)
.replace(TAB_REPLACE_REGEX, TAB_REPLACEMENT).length;
}
// Calculate the endColumn and get the real width by doing a substring of the original
// line and replacing tabs with our tab replacement to support tab handling
let endCol = lineWidth - 1;
if (highlight.end.line === currentLineIndex) {
endCol = lines[currentLineIndex]
.substring(colOffset, highlight.end.column)
.replace(TAB_REPLACE_REGEX, TAB_REPLACEMENT).length;
// If the endCol is too big for this line part, trim it so we can handle it in the next one
if (endCol > lineWidth) {
endCol = lineWidth - 1;
}
highlightHasEnded = true;
}
// If endcol is smaller than lastCol it overlaps with another highlight and is no longer visible, we can skip those
if (endCol >= lastCol) {
let characters = endCol - startCol + 1;
if (startCol > lastCol) {
// startCol is before lastCol, so add spaces as padding before the highlight indicators
highlightLine += ' '.repeat(startCol - lastCol);
} else if (lastCol > startCol) {
// If last column is larger than the start, there's overlap in highlights
// This line adjusts the characters count to ensure we don't add too many characters
characters += startCol - lastCol;
}
// Append the highlight indicators
highlightLine += highlighter('^'.repeat(characters));
// Set the lastCol equal to character count between start of line part and highlight end-column
lastCol = endCol + 1;
}
// There's no point in processing more highlights if we reached the end of the line
if (endCol >= lineEndCol - 1) {
break;
}
}
// Append the highlight message if the current highlights ends on this line part
if (highlight && highlight.message && highlightHasEnded) {
highlightLine += ' ' + highlighter(highlight.message, true);
}
}
if (highlightLine) {
resultLines.push(
lineNumberPrefixer({
lineNumberLength,
isHighlighted: true,
}) + highlightLine,
);
}
}
return resultLines.join('\n');
}