Skip to content

Commit

Permalink
feat: Add esquery selector textfield & highlighting of matched code
Browse files Browse the repository at this point in the history
Fixes eslint#79
  • Loading branch information
pkerschbaum committed Feb 9, 2025
1 parent 89a913c commit 1102a4b
Show file tree
Hide file tree
Showing 16 changed files with 192 additions and 9 deletions.
12 changes: 12 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-scope": "^8.1.0",
"espree": "^10.1.0",
"esquery": "^1.6.0",
"global": "^4.4.0",
"graphviz-react": "^1.2.5",
"lucide-react": "^0.407.0",
Expand All @@ -77,6 +78,7 @@
"devDependencies": {
"@types/eslint-scope": "^3.7.7",
"@types/espree": "^10.0.0",
"@types/esquery": "^1.5.4",
"@types/node": "^18.19.44",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
Expand Down
4 changes: 4 additions & 0 deletions src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,7 @@
.cm-tooltip.cm-tooltip-autocomplete > ul {
border-radius: 0.125rem;
}

.eslint-code-explorer_highlight-range {
@apply bg-editorRangeHighlightColor;
}
12 changes: 11 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,29 @@
import "./App.css";

import { Navbar } from "./components/navbar";
import { useExplorer } from "./hooks/use-explorer";
import { useHighlightRanges } from "./hooks/use-highlight-ranges";
import { tools } from "./lib/tools";
import { Editor } from "./components/editor";
import { EsquerySelectorInput } from "./components/esquery-selector-input";
import { ToolSelector } from "./components/tool-selector";
import { ThemeProvider } from "./components/theme-provider";
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";

function App() {
const { language, tool, code, setCode } = useExplorer();
const { language, tool, code, setCode, jsOptions } = useExplorer();

const highlightRanges = useHighlightRanges();

const activeTool = tools.find(({ value }) => value === tool) ?? tools[0];
return (
<ThemeProvider>
<div className="antialiased touch-manipulation font-sans">
<div className="flex flex-col h-screen">
<Navbar />
{jsOptions.esquerySelectorEnabled && (
<EsquerySelectorInput />
)}
<div className="h-full overflow-hidden">
<div className="border-t h-full">
<PanelGroup
Expand All @@ -24,6 +33,7 @@ function App() {
<Panel defaultSize={50} minSize={25}>
<Editor
value={code[language]}
highlightRanges={highlightRanges}
onChange={value => {
setCode({
...code,
Expand Down
2 changes: 2 additions & 0 deletions src/codemirror-themes.css
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
--editor-bracket-match-outline-color: var(--color-neutral-200);
--editor-bracket-match-background-color: var(--color-neutral-100);
--editor-bracket-match-color: none;
--editor-range-highlight-color: 209deg 54% 81%;
}

.dark {
Expand All @@ -94,4 +95,5 @@
--editor-bracket-match-outline-color: var(--color-neutral-600);
--editor-bracket-match-background-color: var(--color-neutral-700);
--editor-bracket-match-color: var(--color-neutral-25);
--editor-range-highlight-color: 209deg 54% 31%;
}
10 changes: 4 additions & 6 deletions src/components/ast/javascript-ast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { JavascriptAstTreeItem } from "./javascript-ast-tree-item";
import type { FC } from "react";
import { parseError } from "@/lib/parse-error";
import { ErrorState } from "../error-boundary";
import { parseJavascriptAST } from "@/lib/parse-javascript-ast";

export const JavascriptAst: FC = () => {
const explorer = useExplorer();
Expand All @@ -15,12 +16,9 @@ export const JavascriptAst: FC = () => {
let tree: ReturnType<typeof espree.parse> | null = null;

try {
tree = espree.parse(explorer.code.javascript, {
ecmaVersion: explorer.jsOptions.esVersion,
sourceType: explorer.jsOptions.sourceType,
ecmaFeatures: {
jsx: explorer.jsOptions.isJSX,
},
tree = parseJavascriptAST({
code: explorer.code.javascript,
jsOptions: explorer.jsOptions,
});

ast = JSON.stringify(tree, null, 2);
Expand Down
13 changes: 12 additions & 1 deletion src/components/editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ import {
ESLintPlaygroundTheme,
ESLintPlaygroundHighlightStyle,
} from "@/utils/codemirror-themes";
import {
highlightRangesExtension,
type HighlightRange,
} from "@/utils/highlight-ranges";

const languageExtensions: Record<string, (isJSX?: boolean) => LanguageSupport> =
{
Expand All @@ -28,10 +32,16 @@ const languageExtensions: Record<string, (isJSX?: boolean) => LanguageSupport> =
type EditorProperties = {
readOnly?: boolean;
value?: string;
highlightRanges?: HighlightRange[];
onChange?: (value: string) => void;
};

export const Editor: FC<EditorProperties> = ({ readOnly, value, onChange }) => {
export const Editor: FC<EditorProperties> = ({
readOnly,
value,
highlightRanges = [],
onChange,
}) => {
const { wrap, language, jsOptions } = useExplorer();
const { isJSX } = jsOptions;
const [isDragOver, setIsDragOver] = useState<boolean>(false);
Expand All @@ -50,6 +60,7 @@ export const Editor: FC<EditorProperties> = ({ readOnly, value, onChange }) => {
readOnly ? EditorState.readOnly.of(true) : [],
ESLintPlaygroundTheme,
ESLintPlaygroundHighlightStyle,
highlightRangesExtension(highlightRanges),
];

const debouncedOnChange = useCallback(
Expand Down
21 changes: 21 additions & 0 deletions src/components/esquery-selector-input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { useId, type FC } from "react";
import { Label } from "@/components/ui/label";
import { TextField } from "@/components/ui/text-field";
import { useExplorer } from "@/hooks/use-explorer";

export const EsquerySelectorInput: FC = () => {
const { esquerySelector, setEsquerySelector } = useExplorer();
const htmlId = useId();

return (
<div className="space-y-1.5 m-2">
<Label htmlFor={htmlId}>esquery Selector</Label>
<TextField
id={htmlId}
className="w-full"
value={esquerySelector}
onChange={e => setEsquerySelector(e.target.value)}
/>
</div>
);
};
17 changes: 16 additions & 1 deletion src/components/options.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@ const CssPanel: React.FC = () => {
const JavaScriptPanel = () => {
const explorer = useExplorer();
const { jsOptions, setJsOptions } = explorer;
const { parser, sourceType, esVersion, isJSX } = jsOptions;
const { parser, sourceType, esVersion, isJSX, esquerySelectorEnabled } =
jsOptions;
return (
<>
<LabeledSelect
Expand Down Expand Up @@ -137,6 +138,20 @@ const JavaScriptPanel = () => {
/>
<Label htmlFor="jsx">JSX</Label>
</div>

<div className="flex items-center gap-1.5">
<Switch
id="esquerySelector"
checked={esquerySelectorEnabled}
onCheckedChange={(value: boolean) => {
setJsOptions({
...jsOptions,
esquerySelectorEnabled: value,
});
}}
/>
<Label htmlFor="esquerySelector">esquery Selector</Label>
</div>
</>
);
};
Expand Down
27 changes: 27 additions & 0 deletions src/components/ui/text-field.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import * as React from "react";

import { cn } from "@/lib/utils";

export type TextFieldProps = Exclude<
React.InputHTMLAttributes<HTMLInputElement>,
"type"
>;

const TextField = React.forwardRef<HTMLInputElement, TextFieldProps>(
({ className, ...props }, ref) => {
return (
<input
type="text"
className={cn(
"flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-10 px-4 py-2",
className,
)}
ref={ref}
{...props}
/>
);
},
);
TextField.displayName = "TextField";

export { TextField };
8 changes: 8 additions & 0 deletions src/hooks/use-explorer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export type JsOptions = {
sourceType: SourceType;
esVersion: Version;
isJSX: boolean;
esquerySelectorEnabled: boolean;
};

export type JsonOptions = {
Expand Down Expand Up @@ -89,6 +90,9 @@ type ExplorerState = {

pathIndex: PathIndex;
setPathIndex: (pathIndex: PathIndex) => void;

esquerySelector: string;
setEsquerySelector: (esqueryQuery: string) => void;
};

const hashStorage: StateStorage = {
Expand Down Expand Up @@ -145,6 +149,10 @@ export const useExplorer = create<ExplorerState>()(

pathIndex: defaultPathIndex,
setPathIndex: pathIndex => set({ pathIndex }),

esquerySelector: "",
setEsquerySelector: esquerySelector =>
set({ esquerySelector }),
}),
{
name: "eslint-explorer",
Expand Down
39 changes: 39 additions & 0 deletions src/hooks/use-highlight-ranges.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import esquery from "esquery";
import { Node } from "acorn";
import type { Node as EstreeNode } from "estree";
import { useExplorer } from "@/hooks/use-explorer";
import { parseJavascriptAST } from "@/lib/parse-javascript-ast";
import type { HighlightRange } from "@/utils/highlight-ranges";

export function useHighlightRanges() {
const { language, code, jsOptions, esquerySelector } = useExplorer();

let highlightRanges: HighlightRange[] = [];
if (language === "javascript" && jsOptions.esquerySelectorEnabled) {
if (jsOptions.esquerySelectorEnabled) {
try {
const tree = parseJavascriptAST({
code: code.javascript,
jsOptions: jsOptions,
});
/**
* "esquery" uses type "Node" of "@types/estree", "espree" returns "Node" of "acorn"
* but they are compatible
* Therefore, cast between "Node" of "acorn" and "Node" of "@types/estree"
*/
const esqueryMatchedNodes = esquery.match(
tree as EstreeNode,
esquery.parse(esquerySelector),
) as Node[];
highlightRanges = esqueryMatchedNodes.map(node => [
node.start,
node.end,
]);
} catch {
// ignore
}
}
}

return highlightRanges;
}
1 change: 1 addition & 0 deletions src/lib/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,7 @@ export const defaultJsOptions: JsOptions = {
sourceType: "module",
esVersion: "latest",
isJSX: true,
esquerySelectorEnabled: false,
};

export const defaultJsonOptions: JsonOptions = {
Expand Down
15 changes: 15 additions & 0 deletions src/lib/parse-javascript-ast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { JsOptions } from "@/hooks/use-explorer";
import * as espree from "espree";

export function parseJavascriptAST(opts: {
code: string;
jsOptions: JsOptions;
}) {
return espree.parse(opts.code, {
ecmaVersion: opts.jsOptions.esVersion,
sourceType: opts.jsOptions.sourceType,
ecmaFeatures: {
jsx: opts.jsOptions.isJSX,
},
});
}
16 changes: 16 additions & 0 deletions src/utils/highlight-ranges.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Decoration, ViewPlugin } from "@codemirror/view";

const highlightRangeDecoration = Decoration.mark({
class: "eslint-code-explorer_highlight-range",
});
export type HighlightRange = [rangeFrom: number, rangeTo: number];

export const highlightRangesExtension = (ranges: HighlightRange[]) =>
ViewPlugin.define(() => ({}), {
decorations: () =>
Decoration.set(
ranges.map(([rangeFrom, rangeTo]) =>
highlightRangeDecoration.range(rangeFrom, rangeTo),
),
),
});
2 changes: 2 additions & 0 deletions tailwind.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ module.exports = {
dropContainer: "hsl(var(--drop-container-bg-color))",
dropMessage: "hsl(var(--drop-message-bg-color))",
editorBackground: "hsl(var(--editor-background))",
editorRangeHighlightColor:
"hsl(var(--editor-range-highlight-color))",
},
borderRadius: {
lg: "var(--radius)",
Expand Down

0 comments on commit 1102a4b

Please sign in to comment.