Skip to content

Commit 1102a4b

Browse files
committed
feat: Add esquery selector textfield & highlighting of matched code
Fixes eslint#79
1 parent 89a913c commit 1102a4b

16 files changed

+192
-9
lines changed

package-lock.json

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
"eslint-plugin-react-hooks": "^4.6.2",
6565
"eslint-scope": "^8.1.0",
6666
"espree": "^10.1.0",
67+
"esquery": "^1.6.0",
6768
"global": "^4.4.0",
6869
"graphviz-react": "^1.2.5",
6970
"lucide-react": "^0.407.0",
@@ -77,6 +78,7 @@
7778
"devDependencies": {
7879
"@types/eslint-scope": "^3.7.7",
7980
"@types/espree": "^10.0.0",
81+
"@types/esquery": "^1.5.4",
8082
"@types/node": "^18.19.44",
8183
"@types/react": "^18.3.3",
8284
"@types/react-dom": "^18.3.0",

src/App.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,3 +126,7 @@
126126
.cm-tooltip.cm-tooltip-autocomplete > ul {
127127
border-radius: 0.125rem;
128128
}
129+
130+
.eslint-code-explorer_highlight-range {
131+
@apply bg-editorRangeHighlightColor;
132+
}

src/App.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,29 @@
11
import "./App.css";
2+
23
import { Navbar } from "./components/navbar";
34
import { useExplorer } from "./hooks/use-explorer";
5+
import { useHighlightRanges } from "./hooks/use-highlight-ranges";
46
import { tools } from "./lib/tools";
57
import { Editor } from "./components/editor";
8+
import { EsquerySelectorInput } from "./components/esquery-selector-input";
69
import { ToolSelector } from "./components/tool-selector";
710
import { ThemeProvider } from "./components/theme-provider";
811
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
912

1013
function App() {
11-
const { language, tool, code, setCode } = useExplorer();
14+
const { language, tool, code, setCode, jsOptions } = useExplorer();
15+
16+
const highlightRanges = useHighlightRanges();
17+
1218
const activeTool = tools.find(({ value }) => value === tool) ?? tools[0];
1319
return (
1420
<ThemeProvider>
1521
<div className="antialiased touch-manipulation font-sans">
1622
<div className="flex flex-col h-screen">
1723
<Navbar />
24+
{jsOptions.esquerySelectorEnabled && (
25+
<EsquerySelectorInput />
26+
)}
1827
<div className="h-full overflow-hidden">
1928
<div className="border-t h-full">
2029
<PanelGroup
@@ -24,6 +33,7 @@ function App() {
2433
<Panel defaultSize={50} minSize={25}>
2534
<Editor
2635
value={code[language]}
36+
highlightRanges={highlightRanges}
2737
onChange={value => {
2838
setCode({
2939
...code,

src/codemirror-themes.css

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@
8080
--editor-bracket-match-outline-color: var(--color-neutral-200);
8181
--editor-bracket-match-background-color: var(--color-neutral-100);
8282
--editor-bracket-match-color: none;
83+
--editor-range-highlight-color: 209deg 54% 81%;
8384
}
8485

8586
.dark {
@@ -94,4 +95,5 @@
9495
--editor-bracket-match-outline-color: var(--color-neutral-600);
9596
--editor-bracket-match-background-color: var(--color-neutral-700);
9697
--editor-bracket-match-color: var(--color-neutral-25);
98+
--editor-range-highlight-color: 209deg 54% 31%;
9799
}

src/components/ast/javascript-ast.tsx

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { JavascriptAstTreeItem } from "./javascript-ast-tree-item";
66
import type { FC } from "react";
77
import { parseError } from "@/lib/parse-error";
88
import { ErrorState } from "../error-boundary";
9+
import { parseJavascriptAST } from "@/lib/parse-javascript-ast";
910

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

1718
try {
18-
tree = espree.parse(explorer.code.javascript, {
19-
ecmaVersion: explorer.jsOptions.esVersion,
20-
sourceType: explorer.jsOptions.sourceType,
21-
ecmaFeatures: {
22-
jsx: explorer.jsOptions.isJSX,
23-
},
19+
tree = parseJavascriptAST({
20+
code: explorer.code.javascript,
21+
jsOptions: explorer.jsOptions,
2422
});
2523

2624
ast = JSON.stringify(tree, null, 2);

src/components/editor.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ import {
1616
ESLintPlaygroundTheme,
1717
ESLintPlaygroundHighlightStyle,
1818
} from "@/utils/codemirror-themes";
19+
import {
20+
highlightRangesExtension,
21+
type HighlightRange,
22+
} from "@/utils/highlight-ranges";
1923

2024
const languageExtensions: Record<string, (isJSX?: boolean) => LanguageSupport> =
2125
{
@@ -28,10 +32,16 @@ const languageExtensions: Record<string, (isJSX?: boolean) => LanguageSupport> =
2832
type EditorProperties = {
2933
readOnly?: boolean;
3034
value?: string;
35+
highlightRanges?: HighlightRange[];
3136
onChange?: (value: string) => void;
3237
};
3338

34-
export const Editor: FC<EditorProperties> = ({ readOnly, value, onChange }) => {
39+
export const Editor: FC<EditorProperties> = ({
40+
readOnly,
41+
value,
42+
highlightRanges = [],
43+
onChange,
44+
}) => {
3545
const { wrap, language, jsOptions } = useExplorer();
3646
const { isJSX } = jsOptions;
3747
const [isDragOver, setIsDragOver] = useState<boolean>(false);
@@ -50,6 +60,7 @@ export const Editor: FC<EditorProperties> = ({ readOnly, value, onChange }) => {
5060
readOnly ? EditorState.readOnly.of(true) : [],
5161
ESLintPlaygroundTheme,
5262
ESLintPlaygroundHighlightStyle,
63+
highlightRangesExtension(highlightRanges),
5364
];
5465

5566
const debouncedOnChange = useCallback(
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { useId, type FC } from "react";
2+
import { Label } from "@/components/ui/label";
3+
import { TextField } from "@/components/ui/text-field";
4+
import { useExplorer } from "@/hooks/use-explorer";
5+
6+
export const EsquerySelectorInput: FC = () => {
7+
const { esquerySelector, setEsquerySelector } = useExplorer();
8+
const htmlId = useId();
9+
10+
return (
11+
<div className="space-y-1.5 m-2">
12+
<Label htmlFor={htmlId}>esquery Selector</Label>
13+
<TextField
14+
id={htmlId}
15+
className="w-full"
16+
value={esquerySelector}
17+
onChange={e => setEsquerySelector(e.target.value)}
18+
/>
19+
</div>
20+
);
21+
};

src/components/options.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,8 @@ const CssPanel: React.FC = () => {
8787
const JavaScriptPanel = () => {
8888
const explorer = useExplorer();
8989
const { jsOptions, setJsOptions } = explorer;
90-
const { parser, sourceType, esVersion, isJSX } = jsOptions;
90+
const { parser, sourceType, esVersion, isJSX, esquerySelectorEnabled } =
91+
jsOptions;
9192
return (
9293
<>
9394
<LabeledSelect
@@ -137,6 +138,20 @@ const JavaScriptPanel = () => {
137138
/>
138139
<Label htmlFor="jsx">JSX</Label>
139140
</div>
141+
142+
<div className="flex items-center gap-1.5">
143+
<Switch
144+
id="esquerySelector"
145+
checked={esquerySelectorEnabled}
146+
onCheckedChange={(value: boolean) => {
147+
setJsOptions({
148+
...jsOptions,
149+
esquerySelectorEnabled: value,
150+
});
151+
}}
152+
/>
153+
<Label htmlFor="esquerySelector">esquery Selector</Label>
154+
</div>
140155
</>
141156
);
142157
};

src/components/ui/text-field.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import * as React from "react";
2+
3+
import { cn } from "@/lib/utils";
4+
5+
export type TextFieldProps = Exclude<
6+
React.InputHTMLAttributes<HTMLInputElement>,
7+
"type"
8+
>;
9+
10+
const TextField = React.forwardRef<HTMLInputElement, TextFieldProps>(
11+
({ className, ...props }, ref) => {
12+
return (
13+
<input
14+
type="text"
15+
className={cn(
16+
"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",
17+
className,
18+
)}
19+
ref={ref}
20+
{...props}
21+
/>
22+
);
23+
},
24+
);
25+
TextField.displayName = "TextField";
26+
27+
export { TextField };

src/hooks/use-explorer.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export type JsOptions = {
3333
sourceType: SourceType;
3434
esVersion: Version;
3535
isJSX: boolean;
36+
esquerySelectorEnabled: boolean;
3637
};
3738

3839
export type JsonOptions = {
@@ -89,6 +90,9 @@ type ExplorerState = {
8990

9091
pathIndex: PathIndex;
9192
setPathIndex: (pathIndex: PathIndex) => void;
93+
94+
esquerySelector: string;
95+
setEsquerySelector: (esqueryQuery: string) => void;
9296
};
9397

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

146150
pathIndex: defaultPathIndex,
147151
setPathIndex: pathIndex => set({ pathIndex }),
152+
153+
esquerySelector: "",
154+
setEsquerySelector: esquerySelector =>
155+
set({ esquerySelector }),
148156
}),
149157
{
150158
name: "eslint-explorer",

src/hooks/use-highlight-ranges.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import esquery from "esquery";
2+
import { Node } from "acorn";
3+
import type { Node as EstreeNode } from "estree";
4+
import { useExplorer } from "@/hooks/use-explorer";
5+
import { parseJavascriptAST } from "@/lib/parse-javascript-ast";
6+
import type { HighlightRange } from "@/utils/highlight-ranges";
7+
8+
export function useHighlightRanges() {
9+
const { language, code, jsOptions, esquerySelector } = useExplorer();
10+
11+
let highlightRanges: HighlightRange[] = [];
12+
if (language === "javascript" && jsOptions.esquerySelectorEnabled) {
13+
if (jsOptions.esquerySelectorEnabled) {
14+
try {
15+
const tree = parseJavascriptAST({
16+
code: code.javascript,
17+
jsOptions: jsOptions,
18+
});
19+
/**
20+
* "esquery" uses type "Node" of "@types/estree", "espree" returns "Node" of "acorn"
21+
* but they are compatible
22+
* Therefore, cast between "Node" of "acorn" and "Node" of "@types/estree"
23+
*/
24+
const esqueryMatchedNodes = esquery.match(
25+
tree as EstreeNode,
26+
esquery.parse(esquerySelector),
27+
) as Node[];
28+
highlightRanges = esqueryMatchedNodes.map(node => [
29+
node.start,
30+
node.end,
31+
]);
32+
} catch {
33+
// ignore
34+
}
35+
}
36+
}
37+
38+
return highlightRanges;
39+
}

src/lib/const.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,7 @@ export const defaultJsOptions: JsOptions = {
373373
sourceType: "module",
374374
esVersion: "latest",
375375
isJSX: true,
376+
esquerySelectorEnabled: false,
376377
};
377378

378379
export const defaultJsonOptions: JsonOptions = {

src/lib/parse-javascript-ast.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { JsOptions } from "@/hooks/use-explorer";
2+
import * as espree from "espree";
3+
4+
export function parseJavascriptAST(opts: {
5+
code: string;
6+
jsOptions: JsOptions;
7+
}) {
8+
return espree.parse(opts.code, {
9+
ecmaVersion: opts.jsOptions.esVersion,
10+
sourceType: opts.jsOptions.sourceType,
11+
ecmaFeatures: {
12+
jsx: opts.jsOptions.isJSX,
13+
},
14+
});
15+
}

src/utils/highlight-ranges.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { Decoration, ViewPlugin } from "@codemirror/view";
2+
3+
const highlightRangeDecoration = Decoration.mark({
4+
class: "eslint-code-explorer_highlight-range",
5+
});
6+
export type HighlightRange = [rangeFrom: number, rangeTo: number];
7+
8+
export const highlightRangesExtension = (ranges: HighlightRange[]) =>
9+
ViewPlugin.define(() => ({}), {
10+
decorations: () =>
11+
Decoration.set(
12+
ranges.map(([rangeFrom, rangeTo]) =>
13+
highlightRangeDecoration.range(rangeFrom, rangeTo),
14+
),
15+
),
16+
});

tailwind.config.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ module.exports = {
5151
dropContainer: "hsl(var(--drop-container-bg-color))",
5252
dropMessage: "hsl(var(--drop-message-bg-color))",
5353
editorBackground: "hsl(var(--editor-background))",
54+
editorRangeHighlightColor:
55+
"hsl(var(--editor-range-highlight-color))",
5456
},
5557
borderRadius: {
5658
lg: "var(--radius)",

0 commit comments

Comments
 (0)