Skip to content

Commit

Permalink
fix: highglight json lines (#1521)
Browse files Browse the repository at this point in the history
  • Loading branch information
abvthecity authored Sep 20, 2024
1 parent adef072 commit 0c987cd
Show file tree
Hide file tree
Showing 9 changed files with 242 additions and 133 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { FernSyntaxHighlighter } from "../../syntax-highlighting/FernSyntaxHighl
import { ScrollToHandle } from "../../syntax-highlighting/FernSyntaxHighlighterTokens";
import { JsonPropertyPath } from "./JsonPropertyPath";
import { TitledExample } from "./TitledExample";
import { useHighlightJsonLines } from "./getJsonLineNumbers";
import { useHighlightJsonLines } from "./useHighlightJsonLines";

export declare namespace CodeSnippetExample {
export interface Props extends Omit<TitledExample.Props, "copyToClipboardText"> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,13 @@ export declare namespace JsonPropertyPathPart {
propertyName?: string;
}

/**
* TODO: support more than just string values (e.g. other primitives)
*/
export interface ObjectFilter {
type: "objectFilter";
propertyName: string;
requiredValue: string;
requiredStringValue: string;
}

export interface ListItem {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import jq from "jsonpath";
import { getJsonLineNumbers } from "../useHighlightJsonLines";

const MOCK_JSON = {
// 0
data: [
// 1
{
// 2
a: {
// 3
b: "string", // 4
}, // 5
}, // 6
{
// 7
a: {
// 8
b: {
// 9
c: 1, // 10
}, // 11
d: "filter", // 12
}, // 13
}, // 14
{
// 15
b: {
// 16
c: 2, // 17
}, // 18
c: 3, // 19
}, // 20
{
// 21
a: {
// 22
d: "filter", // 23
}, // 24
}, // 25
], // 26
}; // 27

describe("useHighlightJsonLines", () => {
it("should return all range of all lines if path is empty", () => {
expect(getJsonLineNumbers(jq, MOCK_JSON, [])).toEqual([[0, 27]]);
});

it("should return nothing with invalid selector", () => {
expect(
getJsonLineNumbers(jq, MOCK_JSON, [
{ type: "objectProperty", propertyName: "data" },
{ type: "listItem" },
{ type: "objectProperty", propertyName: "d" },
]),
).toEqual([]);
});

it("should return line numbers with valid selector", () => {
expect(
getJsonLineNumbers(jq, MOCK_JSON, [
{ type: "objectProperty", propertyName: "data" },
{ type: "listItem" },
{ type: "objectProperty", propertyName: "a" },
{ type: "objectProperty", propertyName: "b" },
]),
).toEqual([4, [9, 11]]);
});

it("should return line numbers with valid selector and filter", () => {
expect(
getJsonLineNumbers(jq, MOCK_JSON, [
{ type: "objectProperty", propertyName: "data" },
{ type: "listItem" },
{ type: "objectProperty", propertyName: "a" },
{ type: "objectFilter", propertyName: "d", requiredStringValue: "filter" },
]),
).toEqual([
[8, 13],
[22, 24],
]);
});
});
108 changes: 0 additions & 108 deletions packages/ui/app/src/api-reference/examples/getJsonLineNumbers.ts

This file was deleted.

139 changes: 139 additions & 0 deletions packages/ui/app/src/api-reference/examples/useHighlightJsonLines.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { isPlainObject } from "@fern-ui/core-utils";
import { captureException } from "@sentry/nextjs";
import { useAtomValue } from "jotai";
import { atomWithLazy, loadable } from "jotai/utils";
import { useMemoOne } from "use-memo-one";
import { JsonPropertyPath, JsonPropertyPathPart } from "./JsonPropertyPath";
import { lineNumberOf } from "./utils";

const INDENT_SPACES = 2;

/**
* number = single line
* [number, number] = range of lines (inclusive)
*/
type HighlightLineResult = number | [number, number];

/**
* @internal
*
* This function recursively searches for the line numbers of a json object that matches the given json path.
* Note: `jq.query` can throw an error if the json object or path is invalid, so it needs to be try-catched.
*
* It assumes that the json object uses `JSON.stringify(json, undefined, 2)` to format the json object, and
* works by incrementally string-matching each part of the json path and merging the result of each part.
*
* @param jq jsonpath module (which we can't import directly because it dramatically increases bundle size)
* @param json unknown json object to query
* @param path json path, which is constructed from the api reference hover state
* @param start line number where the json object starts
* @returns a list of line numbers that match the json path
*/
export function getJsonLineNumbers(
jq: Awaited<typeof import("jsonpath")>,
json: unknown,
path: JsonPropertyPath,
start = 0,
): HighlightLineResult[] {
const jsonString = JSON.stringify(json, undefined, INDENT_SPACES);

const part = path[0];
if (part == null) {
const length = jsonString.split("\n").length;
return length === 0 ? [] : length === 1 ? [start] : [[start, start + length - 1]];
}

const query = "$" + getQueryPart(part);

const results: unknown[] = jq.query(json, query);
if (part.type === "objectFilter") {
if (isPlainObject(json) && json[part.propertyName] === part.requiredStringValue) {
return getJsonLineNumbers(jq, json, path.slice(1), start);
}
}

const recursiveMatches = results.map((result) => {
// get start of string by matching
const toMatch = jsonStringifyAndIndent(
result,
part.type === "objectProperty" ? part.propertyName : undefined,
1,
);

const startLine = lineNumberOf(jsonString, toMatch);
if (startLine === -1) {
return [];
}

const jsonLineNumbers = getJsonLineNumbers(jq, result, path.slice(1), startLine);

return jsonLineNumbers.map(
(line): HighlightLineResult =>
typeof line === "number" ? start + line : [start + line[0], start + line[1]],
);
});

return recursiveMatches.flat();
}

function jsonStringifyAndIndent(json: unknown, key: string | undefined, depth: number) {
let jsonString = JSON.stringify(json, undefined, INDENT_SPACES);
if (key != null) {
jsonString = `"${key}": ${jsonString}`;
}
return jsonString
.split("\n")
.map((line, idx) => (idx === 0 ? line : " ".repeat(depth) + line))
.join("\n");
}

function getQueryPart(path: JsonPropertyPathPart) {
switch (path.type) {
case "objectProperty":
return path.propertyName != null ? `['${path.propertyName}']` : "[*]";
case "listItem":
return "[*]";
case "objectFilter":
return `[?(@.${path.propertyName}=='${path.requiredStringValue}')]`;
}
}

function createHoveredJsonLinesAtom(json: unknown, hoveredPropertyPath: JsonPropertyPath = [], jsonStartLine = 0) {
const atom = atomWithLazy(async () => {
if (hoveredPropertyPath.length === 0 || jsonStartLine < 0 || typeof window === "undefined") {
return [];
}
/**
* dynamically import jsonpath on the client-side
*/
const jq = await import("jsonpath");
return getJsonLineNumbers(jq, json, hoveredPropertyPath, jsonStartLine + 1);
});

/**
* Loadable has built-in try-catch for the async function
*/
return loadable(atom);
}

export function useHighlightJsonLines(
json: unknown,
hoveredPropertyPath: JsonPropertyPath = [],
jsonStartLine = 0,
): HighlightLineResult[] {
const atom = useMemoOne(
() => createHoveredJsonLinesAtom(json, hoveredPropertyPath, jsonStartLine),
[hoveredPropertyPath, jsonStartLine, json],
);

const value = useAtomValue(atom);
if (value.state === "hasData") {
return value.data;
} else if (value.state === "hasError") {
captureException(value.error, {
extra: { json, hoveredPropertyPath, jsonStartLine },
});
}

return [];
}
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ export const DiscriminatedUnionVariant: React.FC<DiscriminatedUnionVariant.Props
{
type: "objectFilter",
propertyName: discriminant,
requiredValue: unionVariant.discriminantValue,
requiredStringValue: unionVariant.discriminantValue,
},
],
}),
Expand Down
Loading

0 comments on commit 0c987cd

Please sign in to comment.