diff --git a/packages/website-eslint/src/linter/linter.js b/packages/website-eslint/src/linter/linter.js index 1643dcf2562f..aae8ecc0feda 100644 --- a/packages/website-eslint/src/linter/linter.js +++ b/packages/website-eslint/src/linter/linter.js @@ -9,14 +9,17 @@ export function loadLinter() { const linter = new Linter(); let storedAST; let storedTsAST; + let storedScope; linter.defineParser(PARSER_NAME, { parseForESLint(code, options) { const toParse = parseForESLint(code, options); storedAST = toParse.ast; storedTsAST = toParse.tsAst; + storedScope = toParse.scopeManager; return toParse; - }, // parse(code: string, options: ParserOptions): ParseForESLintResult['ast'] { + }, + // parse(code: string, options: ParserOptions): ParseForESLintResult['ast'] { // const toParse = parseForESLint(code, options); // storedAST = toParse.ast; // return toParse.ast; @@ -37,6 +40,10 @@ export function loadLinter() { return { ruleNames: ruleNames, + getScope() { + return storedScope; + }, + getAst() { return storedAST; }, diff --git a/packages/website-eslint/types/index.d.ts b/packages/website-eslint/types/index.d.ts index 86e0c02eed6c..4e923c2fef1c 100644 --- a/packages/website-eslint/types/index.d.ts +++ b/packages/website-eslint/types/index.d.ts @@ -1,18 +1,18 @@ -import type { TSESLint } from '@typescript-eslint/experimental-utils'; +import type { TSESLint, TSESTree } from '@typescript-eslint/experimental-utils'; import type { ParserOptions } from '@typescript-eslint/types'; +import type { SourceFile } from 'typescript'; export type LintMessage = TSESLint.Linter.LintMessage; export type RuleFix = TSESLint.RuleFix; export type RulesRecord = TSESLint.Linter.RulesRecord; export type RuleEntry = TSESLint.Linter.RuleEntry; -export type ParseForESLintResult = TSESLint.Linter.ESLintParseResult; -export type ESLintAST = ParseForESLintResult['ast']; export interface WebLinter { ruleNames: { name: string; description?: string }[]; - getAst(): ESLintAST; - getTsAst(): Record; + getAst(): TSESTree.Program; + getTsAst(): SourceFile; + getScope(): Record; lint( code: string, @@ -25,11 +25,10 @@ export interface LinterLoader { loadLinter(): WebLinter; } -export type { TSESTree } from '@typescript-eslint/types'; - export type { DebugLevel, EcmaVersion, ParserOptions, SourceType, + TSESTree, } from '@typescript-eslint/types'; diff --git a/packages/website/src/components/ASTViewerESTree.tsx b/packages/website/src/components/ASTViewerESTree.tsx index 899daeedba9a..7d46efdfb110 100644 --- a/packages/website/src/components/ASTViewerESTree.tsx +++ b/packages/website/src/components/ASTViewerESTree.tsx @@ -1,52 +1,32 @@ -import React, { useCallback } from 'react'; +import React, { useEffect, useState } from 'react'; import ASTViewer from './ast/ASTViewer'; -import { isRecord } from './ast/utils'; -import type { ASTViewerBaseProps, SelectedRange } from './ast/types'; -import { TSESTree } from '@typescript-eslint/website-eslint'; +import type { ASTViewerBaseProps, ASTViewerModelMap } from './ast/types'; +import type { TSESTree } from '@typescript-eslint/website-eslint'; +import { serialize } from './ast/serializer/serializer'; +import { createESTreeSerializer } from './ast/serializer/serializerESTree'; -function isESTreeNode( - value: unknown, -): value is Record & TSESTree.BaseNode { - return isRecord(value) && 'type' in value && 'loc' in value; +export interface ASTESTreeViewerProps extends ASTViewerBaseProps { + readonly value: TSESTree.BaseNode | string; } -export const propsToFilter = ['parent', 'comments', 'tokens']; +export default function ASTViewerESTree({ + value, + position, + onSelectNode, +}: ASTESTreeViewerProps): JSX.Element { + const [model, setModel] = useState(''); -export default function ASTViewerESTree( - props: ASTViewerBaseProps, -): JSX.Element { - const filterProps = useCallback( - (item: [string, unknown]): boolean => - !propsToFilter.includes(item[0]) && - !item[0].startsWith('_') && - item[1] !== undefined, - [], - ); - - const getRange = useCallback( - (value: unknown): SelectedRange | undefined => - isESTreeNode(value) - ? { - start: value.loc.start, - end: value.loc.end, - } - : undefined, - [], - ); - - const getNodeName = useCallback( - (value: unknown): string | undefined => - isESTreeNode(value) ? String(value.type) : undefined, - [], - ); + useEffect(() => { + if (typeof value === 'string') { + setModel(value); + } else { + const astSerializer = createESTreeSerializer(); + setModel(serialize(value, astSerializer)); + } + }, [value]); return ( - + ); } diff --git a/packages/website/src/components/ASTViewerScope.tsx b/packages/website/src/components/ASTViewerScope.tsx new file mode 100644 index 000000000000..b14514855381 --- /dev/null +++ b/packages/website/src/components/ASTViewerScope.tsx @@ -0,0 +1,29 @@ +import React, { useEffect, useState } from 'react'; + +import ASTViewer from './ast/ASTViewer'; +import type { ASTViewerBaseProps, ASTViewerModelMap } from './ast/types'; + +import { serialize } from './ast/serializer/serializer'; +import { createScopeSerializer } from './ast/serializer/serializerScope'; + +export interface ASTScopeViewerProps extends ASTViewerBaseProps { + readonly value: Record | string; +} + +export default function ASTViewerScope({ + value, + onSelectNode, +}: ASTScopeViewerProps): JSX.Element { + const [model, setModel] = useState(''); + + useEffect(() => { + if (typeof value === 'string') { + setModel(value); + } else { + const scopeSerializer = createScopeSerializer(); + setModel(serialize(value, scopeSerializer)); + } + }, [value]); + + return ; +} diff --git a/packages/website/src/components/ASTViewerTS.tsx b/packages/website/src/components/ASTViewerTS.tsx index f30265ba574e..6cd080262960 100644 --- a/packages/website/src/components/ASTViewerTS.tsx +++ b/packages/website/src/components/ASTViewerTS.tsx @@ -1,16 +1,13 @@ import React, { useCallback, useEffect, useState } from 'react'; import ASTViewer from './ast/ASTViewer'; -import { isRecord } from './ast/utils'; -import type { - ASTViewerBaseProps, - SelectedRange, - SelectedPosition, -} from './ast/types'; -import type { Node, SourceFile } from 'typescript'; +import type { ASTViewerBaseProps, ASTViewerModelMap } from './ast/types'; +import type { SourceFile } from 'typescript'; +import { serialize } from './ast/serializer/serializer'; +import { createTsSerializer } from './ast/serializer/serializerTS'; export interface ASTTsViewerProps extends ASTViewerBaseProps { - readonly version: string; + readonly value: SourceFile | string; } function extractEnum( @@ -28,10 +25,6 @@ function extractEnum( return result; } -function isTsNode(value: unknown): value is Node { - return isRecord(value) && typeof value.kind === 'number'; -} - function getFlagNamesFromEnum( allFlags: Record, flags: number, @@ -42,108 +35,64 @@ function getFlagNamesFromEnum( .map(([_, name]) => `${prefix}.${name}`); } -export function getLineAndCharacterFor( - pos: number, - ast: SourceFile, -): SelectedPosition { - const loc = ast.getLineAndCharacterOfPosition(pos); - return { - line: loc.line + 1, - column: loc.character, - }; -} - -export function getLocFor( - start: number, - end: number, - ast: SourceFile, -): SelectedRange { - return { - start: getLineAndCharacterFor(start, ast), - end: getLineAndCharacterFor(end, ast), - }; -} - -export const propsToFilter = [ - 'parent', - 'jsDoc', - 'lineMap', - 'externalModuleIndicator', - 'bindDiagnostics', - 'transformFlags', - 'resolvedModules', - 'imports', -]; - -export default function ASTViewerTS(props: ASTTsViewerProps): JSX.Element { - const [syntaxKind, setSyntaxKind] = useState>({}); - const [nodeFlags, setNodeFlags] = useState>({}); - const [tokenFlags, setTokenFlags] = useState>({}); - const [modifierFlags, setModifierFlags] = useState>( - {}, - ); +export default function ASTViewerTS({ + value, + position, + onSelectNode, +}: ASTTsViewerProps): JSX.Element { + const [model, setModel] = useState(''); + const [syntaxKind] = useState(() => extractEnum(window.ts.SyntaxKind)); + const [nodeFlags] = useState(() => extractEnum(window.ts.NodeFlags)); + const [tokenFlags] = useState(() => extractEnum(window.ts.TokenFlags)); + const [modifierFlags] = useState(() => extractEnum(window.ts.ModifierFlags)); useEffect(() => { - setSyntaxKind(extractEnum(window.ts.SyntaxKind)); - setNodeFlags(extractEnum(window.ts.NodeFlags)); - setTokenFlags(extractEnum(window.ts.TokenFlags)); - setModifierFlags(extractEnum(window.ts.ModifierFlags)); - }, [props.version]); + if (typeof value === 'string') { + setModel(value); + } else { + const scopeSerializer = createTsSerializer(value, syntaxKind); + setModel(serialize(value, scopeSerializer)); + } + }, [value, syntaxKind]); + // TODO: move this to serializer const getTooltip = useCallback( - (key: string, value: unknown): string | undefined => { - if (key === 'flags' && typeof value === 'number') { - return getFlagNamesFromEnum(nodeFlags, value, 'NodeFlags').join('\n'); - } else if (key === 'numericLiteralFlags' && typeof value === 'number') { - return getFlagNamesFromEnum(tokenFlags, value, 'TokenFlags').join('\n'); - } else if (key === 'modifierFlagsCache' && typeof value === 'number') { - return getFlagNamesFromEnum(modifierFlags, value, 'ModifierFlags').join( - '\n', - ); - } else if (key === 'kind' && typeof value === 'number') { - return `SyntaxKind.${syntaxKind[value]}`; + (data: ASTViewerModelMap): string | undefined => { + if (data.model.type === 'number') { + switch (data.key) { + case 'flags': + return getFlagNamesFromEnum( + nodeFlags, + Number(data.model.value), + 'NodeFlags', + ).join('\n'); + case 'numericLiteralFlags': + return getFlagNamesFromEnum( + tokenFlags, + Number(data.model.value), + 'TokenFlags', + ).join('\n'); + case 'modifierFlagsCache': + return getFlagNamesFromEnum( + modifierFlags, + Number(data.model.value), + 'ModifierFlags', + ).join('\n'); + case 'kind': + return `SyntaxKind.${syntaxKind[Number(data.model.value)]}`; + } } return undefined; }, [nodeFlags, tokenFlags, syntaxKind], ); - const getNodeName = useCallback( - (value: unknown): string | undefined => - isTsNode(value) ? syntaxKind[value.kind] : undefined, - [syntaxKind], - ); - - const filterProps = useCallback( - (item: [string, unknown]): boolean => - !propsToFilter.includes(item[0]) && - !item[0].startsWith('_') && - item[1] !== undefined, - [], - ); - - const getRange = useCallback( - (value: unknown): SelectedRange | undefined => { - if (props.value && isTsNode(value)) { - return getLocFor( - value.pos, - value.end, - // @ts-expect-error: unsafe cast - props.value as SourceFile, - ); - } - return undefined; - }, - [props.value], - ); - return ( ); } diff --git a/packages/website/src/components/OptionsSelector.tsx b/packages/website/src/components/OptionsSelector.tsx index 64f573590b00..83715fc3ae5c 100644 --- a/packages/website/src/components/OptionsSelector.tsx +++ b/packages/website/src/components/OptionsSelector.tsx @@ -32,6 +32,7 @@ const ASTOptions = [ { value: false, label: 'Disabled' }, { value: 'es', label: 'ESTree' }, { value: 'ts', label: 'TypeScript' }, + { value: 'scope', label: 'Scope' }, ] as const; function OptionsSelector({ diff --git a/packages/website/src/components/Playground.tsx b/packages/website/src/components/Playground.tsx index 844b955dd8b8..b766bd9a9746 100644 --- a/packages/website/src/components/Playground.tsx +++ b/packages/website/src/components/Playground.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react'; +import React, { useReducer, useState } from 'react'; import type Monaco from 'monaco-editor'; import clsx from 'clsx'; import useThemeContext from '@theme/hooks/useThemeContext'; @@ -18,6 +18,25 @@ import ASTViewerTS from './ASTViewerTS'; import type { RuleDetails, SelectedRange } from './types'; import type { TSESTree } from '@typescript-eslint/website-eslint'; +import type { SourceFile } from 'typescript'; +import ASTViewerScope from '@site/src/components/ASTViewerScope'; + +function rangeReducer( + prevState: T, + action: T, +): T { + if (prevState !== action) { + if ( + !prevState || + !action || + !shallowEqual(prevState.start, action.start) || + !shallowEqual(prevState.end, action.end) + ) { + return action; + } + } + return prevState; +} function Playground(): JSX.Element { const [state, setState] = useHashState({ @@ -31,29 +50,14 @@ function Playground(): JSX.Element { }); const { isDarkTheme } = useThemeContext(); const [esAst, setEsAst] = useState(); - const [tsAst, setTsAST] = useState | string | null>(); + const [tsAst, setTsAST] = useState(); + const [scope, setScope] = useState | string | null>(); const [ruleNames, setRuleNames] = useState([]); const [isLoading, setIsLoading] = useState(true); const [tsVersions, setTSVersion] = useState([]); - const [selectedRange, setSelectedRange] = useState( - null, - ); + const [selectedRange, setSelectedRange] = useReducer(rangeReducer, null); const [position, setPosition] = useState(null); - const updateSelectedNode = useCallback( - (value: SelectedRange | null) => { - if ( - !value || - !selectedRange || - !shallowEqual(selectedRange.start, value.start) || - !shallowEqual(selectedRange.end, value.end) - ) { - setSelectedRange(value); - } - }, - [selectedRange], - ); - return (
@@ -85,6 +89,7 @@ function Playground(): JSX.Element { showAST={state.showAST} onEsASTChange={setEsAst} onTsASTChange={setTsAST} + onScopeChange={setScope} decoration={selectedRange} onChange={(code): void => setState({ code: code })} onLoaded={(ruleNames, tsVersions): void => { @@ -101,15 +106,21 @@ function Playground(): JSX.Element { )) || + (state.showAST === 'scope' && scope && ( + + )) || (esAst && ( ))}
diff --git a/packages/website/src/components/ast/ASTViewer.tsx b/packages/website/src/components/ast/ASTViewer.tsx index aa1e35687ec0..8b54f3420d58 100644 --- a/packages/website/src/components/ast/ASTViewer.tsx +++ b/packages/website/src/components/ast/ASTViewer.tsx @@ -3,38 +3,39 @@ import styles from './ASTViewer.module.css'; import type { SelectedPosition, ASTViewerProps } from './types'; -import { ComplexItem } from './Elements'; -import { isRecord } from './utils'; +import { ElementItem } from './Elements'; -function ASTViewer(props: ASTViewerProps): JSX.Element { +function ASTViewer({ + position, + value, + getTooltip, + onSelectNode, +}: ASTViewerProps): JSX.Element { const [selection, setSelection] = useState(null); useEffect(() => { setSelection( - props.position + position ? { - line: props.position.lineNumber, - column: props.position.column - 1, + line: position.lineNumber, + column: position.column - 1, } : null, ); - }, [props.position]); + }, [position]); - return isRecord(props.value) ? ( + return typeof value === 'string' ? ( +
{value}
+ ) : (
-
- ) : ( -
{props.value}
); } diff --git a/packages/website/src/components/ast/Elements.tsx b/packages/website/src/components/ast/Elements.tsx index be573770d94d..47521a5057e7 100644 --- a/packages/website/src/components/ast/Elements.tsx +++ b/packages/website/src/components/ast/Elements.tsx @@ -1,140 +1,117 @@ import React, { useCallback, useEffect, useState } from 'react'; -import type { GenericParams } from './types'; +import type { + GenericParams, + ASTViewerModelMap, + ASTViewerModelMapComplex, + ASTViewerModelMapSimple, +} from './types'; -import { hasChildInRange, isArrayInRange, isInRange, isRecord } from './utils'; +import { hasChildInRange, isArrayInRange, isInRange } from './utils'; -import styles from '@site/src/components/ast/ASTViewer.module.css'; +import styles from './ASTViewer.module.css'; -import PropertyValue from '@site/src/components/ast/PropertyValue'; -import ItemGroup from '@site/src/components/ast/ItemGroup'; -import HiddenItem from '@site/src/components/ast/HiddenItem'; -import Tooltip from '@site/src/components/inputs/Tooltip'; +import ItemGroup from './ItemGroup'; +import HiddenItem from './HiddenItem'; +import { SimpleItem } from './SimpleItem'; -export function ComplexItem( - props: GenericParams | unknown[]>, -): JSX.Element { - const [isExpanded, setIsExpanded] = useState( - () => props.level === 'ast', - ); +export function ComplexItem({ + data, + onSelectNode, + level, + selection, + getTooltip, +}: GenericParams): JSX.Element { + const [isExpanded, setIsExpanded] = useState(() => level === 'ast'); const [isSelected, setIsSelected] = useState(false); - const [model, setModel] = useState<[string, unknown][]>([]); - - useEffect(() => { - setModel( - Object.entries(props.value).filter(item => props.filterProps(item)), - ); - }, [props.value, props.filterProps]); const onHover = useCallback( (state: boolean) => { - if (props.onSelectNode) { - const range = props.getRange(props.value); + if (onSelectNode) { + const range = data.model.range; if (range) { - props.onSelectNode(state ? range : null); + onSelectNode(state ? range : null); } } }, - [props.value], + [data], ); useEffect(() => { - const selected = props.selection - ? props.isArray - ? isArrayInRange(props.selection, props.value, props.getRange) - : isInRange(props.selection, props.value, props.getRange) + const selected = selection + ? data.model.type === 'array' + ? isArrayInRange(selection, data.model) + : isInRange(selection, data.model) : false; setIsSelected( - props.level !== 'ast' && - selected && - !hasChildInRange(props.selection, model, props.getRange), + level !== 'ast' && selected && !hasChildInRange(selection, data.model), ); if (selected && !isExpanded) { setIsExpanded(selected); } - }, [model, props.selection, props.value, props.isArray, props.getRange]); + }, [selection, data]); return ( setIsExpanded(!isExpanded)} > - {props.isArray ? '[' : '{'} + {data.model.type === 'array' ? '[' : '{'} {isExpanded ? (
- {model.map((item, index) => ( + {data.model.value.map((item, index) => ( ))}
) : ( - + )} - {props.isArray ? ']' : '}'} + {data.model.type === 'array' ? ']' : '}'}
); } -export function SimpleItem(props: GenericParams): JSX.Element { - const [tooltip, setTooltip] = useState(); - - useEffect(() => { - setTooltip(props.getTooltip?.(props.propName ?? '', props.value)); - }, [props.getTooltip, props.propName, props.value]); - - return ( - - {tooltip ? ( - - - - ) : ( - - )} - - ); -} - -export function ElementItem(props: GenericParams): JSX.Element { - const isArray = Array.isArray(props.value); - if (isArray || isRecord(props.value)) { +export function ElementItem({ + level, + getTooltip, + selection, + data, + onSelectNode, +}: GenericParams): JSX.Element { + if (data.model.type === 'array' || data.model.type === 'object') { return ( ); } else { - return ; + return ( + + ); } } diff --git a/packages/website/src/components/ast/HiddenItem.tsx b/packages/website/src/components/ast/HiddenItem.tsx index 26717b349567..284bc2c2cad1 100644 --- a/packages/website/src/components/ast/HiddenItem.tsx +++ b/packages/website/src/components/ast/HiddenItem.tsx @@ -1,45 +1,48 @@ import React, { useEffect, useState } from 'react'; import styles from './ASTViewer.module.css'; import PropertyValue from './PropertyValue'; +import type { ASTViewerModelMap } from './types'; export interface HiddenItemProps { - readonly value: [string, unknown][]; + readonly value: ASTViewerModelMap[]; readonly level: string; readonly isArray?: boolean; } -export default function HiddenItem(props: HiddenItemProps): JSX.Element { +export default function HiddenItem({ + value, + level, + isArray, +}: HiddenItemProps): JSX.Element { const [isComplex, setIsComplex] = useState(true); const [length, setLength] = useState(0); useEffect(() => { - if (props.isArray) { - const filtered = props.value.filter(item => !isNaN(Number(item[0]))); - setIsComplex( - !filtered.some(item => typeof item[1] !== 'object' || item[1] === null), - ); + if (isArray) { + const filtered = value.filter(item => !isNaN(Number(item.key))); + setIsComplex(filtered.some(item => item.model.type !== 'number')); setLength(filtered.length); } - }, [props.value, props.isArray]); + }, [value, isArray]); return ( - {props.isArray && !isComplex ? ( - props.value.map((item, index) => ( - + {isArray && !isComplex ? ( + value.map((item, index) => ( + {index > 0 && ', '} - + )) - ) : props.isArray ? ( + ) : isArray ? ( <> {length} {length === 1 ? 'element' : 'elements'} ) : ( - props.value.map((item, index) => ( - + value.map((item, index) => ( + {index > 0 && ', '} - {String(item[0])} + {String(item.key)} )) )} diff --git a/packages/website/src/components/ast/ItemGroup.tsx b/packages/website/src/components/ast/ItemGroup.tsx index a117c9c05f89..bf6c5e3ca77d 100644 --- a/packages/website/src/components/ast/ItemGroup.tsx +++ b/packages/website/src/components/ast/ItemGroup.tsx @@ -4,15 +4,11 @@ import clsx from 'clsx'; import styles from './ASTViewer.module.css'; -import PropertyNameComp from './PropertyName'; -import type { GetNodeNameFn } from './types'; - -const PropertyName = React.memo(PropertyNameComp); +import PropertyName from './PropertyName'; +import type { ASTViewerModelMap } from './types'; export interface ItemGroupProps { - readonly propName?: string; - readonly value: unknown; - readonly getNodeName: GetNodeNameFn; + readonly data: ASTViewerModelMap; readonly isSelected?: boolean; readonly isExpanded?: boolean; readonly canExpand?: boolean; @@ -21,32 +17,39 @@ export interface ItemGroupProps { readonly children: JSX.Element | false | (JSX.Element | false)[]; } -export default function ItemGroup(props: ItemGroupProps): JSX.Element { +export default function ItemGroup({ + data, + isSelected, + isExpanded, + canExpand, + onClick, + onHover, + children, +}: ItemGroupProps): JSX.Element { const listItem = useRef(null); useEffect(() => { - if (listItem.current && props.isSelected) { + if (listItem.current && isSelected) { scrollIntoViewIfNeeded(listItem.current); } - }, [props.isSelected, listItem]); + }, [isSelected, listItem]); return (
props.onHover?.(true)} - onMouseLeave={(): void => props.onHover?.(false)} - onClick={(props.canExpand && props.onClick) || undefined} + propName={data.key} + typeName={data.model.name} + onHover={onHover} + onClick={(canExpand && onClick) || undefined} /> - {React.Children.map(props.children, child => child)} + {React.Children.map(children, child => child)}
); } diff --git a/packages/website/src/components/ast/PropertyName.tsx b/packages/website/src/components/ast/PropertyName.tsx index 36c3a6341534..8ff4b7c62406 100644 --- a/packages/website/src/components/ast/PropertyName.tsx +++ b/packages/website/src/components/ast/PropertyName.tsx @@ -1,24 +1,38 @@ -import React, { MouseEvent } from 'react'; +import React, { MouseEvent, useCallback } from 'react'; import styles from './ASTViewer.module.css'; export interface PropertyNameProps { readonly typeName?: string; readonly propName?: string; readonly onClick?: (e: MouseEvent) => void; - readonly onMouseEnter?: (e: MouseEvent) => void; - readonly onMouseLeave?: (e: MouseEvent) => void; + readonly onHover?: (e: boolean) => void; } export default function PropertyName(props: PropertyNameProps): JSX.Element { - return props.onClick ? ( + const onClick = useCallback( + (e: MouseEvent) => { + e.preventDefault(); + props.onClick?.(e); + }, + [props.onClick], + ); + + const onMouseEnter = useCallback(() => { + props.onHover?.(true); + }, [props.onHover]); + + const onMouseLeave = useCallback(() => { + props.onHover?.(false); + }, [props.onHover]); + + return props.onClick || props.onHover ? ( <> {props.propName && ( - // eslint-disable-next-line jsx-a11y/anchor-is-valid {props.propName} @@ -26,12 +40,11 @@ export default function PropertyName(props: PropertyNameProps): JSX.Element { )} {props.propName && : } {props.typeName && ( - // eslint-disable-next-line jsx-a11y/anchor-is-valid {props.typeName} diff --git a/packages/website/src/components/ast/PropertyValue.tsx b/packages/website/src/components/ast/PropertyValue.tsx index 4d777b3a4bf4..6539042d9af0 100644 --- a/packages/website/src/components/ast/PropertyValue.tsx +++ b/packages/website/src/components/ast/PropertyValue.tsx @@ -1,32 +1,33 @@ import React from 'react'; import styles from './ASTViewer.module.css'; -import { objType } from './utils'; +import type { ASTViewerModelMap } from './types'; export interface PropertyValueProps { - readonly value: unknown; + readonly value: ASTViewerModelMap; } -function PropertyValue(props: PropertyValueProps): JSX.Element { - if (typeof props.value === 'string') { - return ( - {JSON.stringify(props.value)} - ); - } else if (typeof props.value === 'number') { - return {props.value}; - } else if (typeof props.value === 'bigint') { - return {String(props.value)}n; - } else if (props.value instanceof RegExp) { - return {String(props.value)}; - } else if (typeof props.value === 'undefined' || props.value === null) { - return {String(props.value)}; - } else if (typeof props.value === 'boolean') { - return ( - - {props.value ? 'true' : 'false'} - - ); +function PropertyValue({ value }: PropertyValueProps): JSX.Element { + switch (value.model.type) { + case 'string': + return {value.model.value}; + case 'bigint': + return {value.model.value}; + case 'number': + return {value.model.value}; + case 'regexp': + return {value.model.value}; + case 'undefined': + return {value.model.value}; + case 'boolean': + return {value.model.value}; + case 'array': + case 'object': + return {value.key}; + case 'class': + case 'ref': + default: + return {value.model.value}; } - return {objType(props.value)}; } export default PropertyValue; diff --git a/packages/website/src/components/ast/SimpleItem.tsx b/packages/website/src/components/ast/SimpleItem.tsx new file mode 100644 index 000000000000..cb20dc0ccbb8 --- /dev/null +++ b/packages/website/src/components/ast/SimpleItem.tsx @@ -0,0 +1,49 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import ItemGroup from './ItemGroup'; +import Tooltip from '@site/src/components/inputs/Tooltip'; +import PropertyValue from './PropertyValue'; + +import type { + ASTViewerModelMapSimple, + GetTooltipFn, + OnSelectNodeFn, +} from './types'; + +export interface SimpleItemProps { + readonly getTooltip?: GetTooltipFn; + readonly data: ASTViewerModelMapSimple; + readonly onSelectNode?: OnSelectNodeFn; +} + +export function SimpleItem({ + getTooltip, + data, + onSelectNode, +}: SimpleItemProps): JSX.Element { + const [tooltip, setTooltip] = useState(); + + useEffect(() => { + setTooltip(getTooltip?.(data)); + }, [getTooltip, data]); + + const onHover = useCallback( + (state: boolean) => { + if (onSelectNode && data.model.range) { + onSelectNode(state ? data.model.range : null); + } + }, + [data], + ); + + return ( + + {tooltip ? ( + + + + ) : ( + + )} + + ); +} diff --git a/packages/website/src/components/ast/serializer/serializer.ts b/packages/website/src/components/ast/serializer/serializer.ts new file mode 100644 index 000000000000..3cbea9174a10 --- /dev/null +++ b/packages/website/src/components/ast/serializer/serializer.ts @@ -0,0 +1,89 @@ +import type { + ASTViewerModelSimple, + ASTViewerModelMap, + Serializer, +} from '../types'; +import { isRecord, objType } from '../utils'; + +function getSimpleModel(data: unknown): ASTViewerModelSimple { + if (typeof data === 'string') { + return { + value: JSON.stringify(data), + type: 'string', + }; + } else if (typeof data === 'number') { + return { + value: String(data), + type: 'number', + }; + } else if (typeof data === 'bigint') { + return { + value: `${data}n`, + type: 'bigint', + }; + } else if (data instanceof RegExp) { + return { + value: String(data), + type: 'regexp', + }; + } else if (typeof data === 'undefined' || data === null) { + return { + value: String(data), + type: 'undefined', + }; + } else if (typeof data === 'boolean') { + return { + value: data ? 'true' : 'false', + type: 'boolean', + }; + } + return { + value: objType(data), + type: 'class', + }; +} + +export function serialize( + data: unknown, + serializer?: Serializer, +): ASTViewerModelMap { + function processValue(data: [string, unknown][]): ASTViewerModelMap[] { + return data + .filter(item => !item[0].startsWith('_') && item[1] !== undefined) + .map(item => _serialize(item[1], item[0])); + } + + function _serialize(data: unknown, key?: string): ASTViewerModelMap { + if (isRecord(data)) { + const serialized = serializer + ? serializer(data, key, processValue) + : undefined; + if (serialized) { + return { key, model: serialized }; + } + return { + key, + model: { + value: processValue(Object.entries(data)), + type: 'object', + }, + }; + } else if (Array.isArray(data)) { + return { + key, + model: { + value: processValue(Object.entries(data)), + type: 'array', + }, + }; + } + + if (typeof data === 'function' && key) { + return { key: `${key}()`, model: getSimpleModel(data()) }; + } + + return { key, model: getSimpleModel(data) }; + } + + return _serialize(data); +} diff --git a/packages/website/src/components/ast/serializer/serializerESTree.ts b/packages/website/src/components/ast/serializer/serializerESTree.ts new file mode 100644 index 000000000000..7f467757c8a3 --- /dev/null +++ b/packages/website/src/components/ast/serializer/serializerESTree.ts @@ -0,0 +1,34 @@ +import type { ASTViewerModel, Serializer } from '../types'; +import { isRecord } from '../utils'; +import type { TSESTree } from '@typescript-eslint/website-eslint'; + +export const propsToFilter = ['parent', 'comments', 'tokens']; + +function isESTreeNode( + value: unknown, +): value is Record & TSESTree.BaseNode { + return isRecord(value) && 'type' in value && 'loc' in value; +} + +export function createESTreeSerializer(): Serializer { + return function serializer( + data, + _key, + processValue, + ): ASTViewerModel | undefined { + if (isESTreeNode(data)) { + return { + range: { + start: data.loc.start, + end: data.loc.end, + }, + type: 'object', + name: String(data.type), + value: processValue( + Object.entries(data).filter(item => !propsToFilter.includes(item[0])), + ), + }; + } + return undefined; + }; +} diff --git a/packages/website/src/components/ast/serializer/serializerScope.ts b/packages/website/src/components/ast/serializer/serializerScope.ts new file mode 100644 index 000000000000..176f49f92c5c --- /dev/null +++ b/packages/website/src/components/ast/serializer/serializerScope.ts @@ -0,0 +1,203 @@ +import type { ASTViewerModel, Serializer, SelectedRange } from '../types'; +import type { TSESTree } from '@typescript-eslint/website-eslint'; +import { isRecord } from '../utils'; + +function isESTreeNode( + value: unknown, +): value is Record & TSESTree.Node { + return Boolean(value) && isRecord(value) && 'type' in value && 'loc' in value; +} + +function getClassName(value: Record): string { + // eslint-disable-next-line @typescript-eslint/ban-types + return (Object.getPrototypeOf(value) as Object).constructor.name.replace( + /\$[0-9]+$/, + '', + ); +} + +function getNodeName( + className: string, + data: Record, +): string | undefined { + const id = data.$id != null ? `$${String(data.$id)}` : ''; + + if (className === 'ImplicitLibVariable' && data.name === 'const') { + className = 'ImplicitGlobalConstTypeVariable'; + } + + return `${className}${id}`; +} + +function getRange(value: Record): SelectedRange | undefined { + if (isESTreeNode(value.block)) { + return { + start: value.block.loc.start, + end: value.block.loc.end, + }; + } else if (isESTreeNode(value.identifier)) { + return { + start: { ...value.identifier.loc.start }, + end: { ...value.identifier.loc.end }, + }; + } else if (isESTreeNode(value.node)) { + return { + start: { ...value.node.loc.start }, + end: { ...value.node.loc.end }, + }; + } else if ( + Array.isArray(value.identifiers) && + value.identifiers.length > 0 && + isESTreeNode(value.identifiers[0]) + ) { + return { + start: { ...value.identifiers[0].loc.start }, + end: { ...value.identifiers[0].loc.end }, + }; + } + + return undefined; +} + +type NodeType = + | 'Scope' + | 'Definition' + | 'Variable' + | 'ScopeManager' + | 'Reference'; + +function getNodeType(nodeName: string | undefined): NodeType | undefined { + if (nodeName) { + if (nodeName === 'ScopeManager') { + return 'ScopeManager'; + } else if (nodeName.endsWith('Scope')) { + return 'Scope'; + } else if (nodeName.endsWith('Definition')) { + return 'Definition'; + } else if (nodeName === 'Variable' || nodeName === 'ImplicitLibVariable') { + return 'Variable'; + } else if (nodeName === 'Reference') { + return 'Reference'; + } + } + return undefined; +} + +function getProps(nodeType: NodeType | undefined): string[] | undefined { + switch (nodeType) { + case 'ScopeManager': + return ['scopes', 'globalScope', 'variables']; + case 'Scope': + return [ + 'block', + 'isStrict', + 'references', + 'through', + 'set', + 'type', + 'variables', + 'variableScope', + 'functionExpressionScope', + 'childScopes', + 'upper', + ]; + case 'Definition': + return [ + 'name', + 'type', + 'node', + 'isTypeDefinition', + 'isVariableDefinition', + 'rest', + 'parent', + ]; + case 'Reference': + return [ + 'init', + 'identifier', + 'from', + 'isTypeReference', + 'isValueReference', + 'maybeImplicitGlobal', + 'isRead', + 'isWrite', + 'resolved', + 'writeExpr', + ]; + case 'Variable': + return [ + 'name', + 'identifiers', + 'references', + 'defs', + 'eslintUsed', + 'tainted', + 'scope', + 'isValueVariable', + 'isTypeVariable', + 'writeable', + ]; + } + return undefined; +} + +export function createScopeSerializer(): Serializer { + const SEEN_THINGS = new Map(); + + return function serializer( + data, + _key, + processValue, + ): ASTViewerModel | undefined { + const className = getClassName(data); + + if (className !== 'Object') { + const nodeName = getNodeName(className, data); + const nodeType = getNodeType(className); + const value = data.name != null ? `<"${String(data.name)}">` : ''; + + const uniqName = `${nodeName}${value}`; + + if (SEEN_THINGS.has(uniqName)) { + return SEEN_THINGS.get(uniqName); + } + + const result: ASTViewerModel = { + range: getRange(data), + type: 'object', + name: nodeName, + value: [], + }; + SEEN_THINGS.set(uniqName, result); + + let values: [string, unknown][]; + + const props = getProps(nodeType); + if (props) { + values = props.map(key => { + const res = data[key]; + return [key, typeof res === 'function' ? res.bind(data) : res]; + }); + } else { + values = Object.entries(data); + } + + result.value = processValue(values); + return result; + } + + if (isESTreeNode(data)) { + return { + type: 'ref', + name: data.type, + range: { + start: { ...data.loc.start }, + end: { ...data.loc.end }, + }, + value: data.type === 'Identifier' ? `<"${data.name}">` : '', + }; + } + + return undefined; + }; +} diff --git a/packages/website/src/components/ast/serializer/serializerTS.ts b/packages/website/src/components/ast/serializer/serializerTS.ts new file mode 100644 index 000000000000..e1a86f054a75 --- /dev/null +++ b/packages/website/src/components/ast/serializer/serializerTS.ts @@ -0,0 +1,57 @@ +import type { ASTViewerModel, Serializer, SelectedPosition } from '../types'; +import type { SourceFile, Node } from 'typescript'; +import { isRecord } from '../utils'; + +export function getLineAndCharacterFor( + pos: number, + ast: SourceFile, +): SelectedPosition { + const loc = ast.getLineAndCharacterOfPosition(pos); + return { + line: loc.line + 1, + column: loc.character, + }; +} + +export const propsToFilter = [ + 'parent', + 'jsDoc', + 'lineMap', + 'externalModuleIndicator', + 'bindDiagnostics', + 'transformFlags', + 'resolvedModules', + 'imports', +]; + +function isTsNode(value: unknown): value is Node { + return isRecord(value) && typeof value.kind === 'number'; +} + +export function createTsSerializer( + root: SourceFile, + syntaxKind: Record, +): Serializer { + return function serializer( + data, + _key, + processValue, + ): ASTViewerModel | undefined { + if (root && isTsNode(data)) { + const nodeName = syntaxKind[data.kind]; + + return { + range: { + start: getLineAndCharacterFor(data.pos, root), + end: getLineAndCharacterFor(data.end, root), + }, + type: 'object', + name: nodeName, + value: processValue( + Object.entries(data).filter(item => !propsToFilter.includes(item[0])), + ), + }; + } + return undefined; + }; +} diff --git a/packages/website/src/components/ast/types.ts b/packages/website/src/components/ast/types.ts index 8b27865e3428..1d5976424d2d 100644 --- a/packages/website/src/components/ast/types.ts +++ b/packages/website/src/components/ast/types.ts @@ -1,37 +1,68 @@ import type { SelectedPosition, SelectedRange } from '../types'; -import { TSESTree } from '@typescript-eslint/website-eslint'; import Monaco from 'monaco-editor'; -export type GetNodeNameFn = (data: unknown) => string | undefined; -export type GetTooltipFn = (key: string, data: unknown) => string | undefined; -export type GetRangeFn = (data: unknown) => SelectedRange | undefined; +export type GetTooltipFn = (data: ASTViewerModelMap) => string | undefined; export type OnSelectNodeFn = (node: SelectedRange | null) => void; -export type FilterPropsFn = (item: [string, unknown]) => boolean; + +export type ASTViewerModelTypeSimple = + | 'ref' + | 'string' + | 'number' + | 'class' + | 'boolean' + | 'bigint' + | 'regexp' + | 'undefined'; + +export type ASTViewerModelTypeComplex = 'object' | 'array'; + +export interface ASTViewerModelBase { + name?: string; + range?: SelectedRange; +} + +export interface ASTViewerModelSimple extends ASTViewerModelBase { + type: ASTViewerModelTypeSimple; + value: string; +} + +export interface ASTViewerModelComplex extends ASTViewerModelBase { + type: ASTViewerModelTypeComplex; + value: ASTViewerModelMap[]; +} + +export type ASTViewerModel = ASTViewerModelSimple | ASTViewerModelComplex; + +export interface ASTViewerModelMap { + key?: string; + model: T; +} + +export type ASTViewerModelMapSimple = ASTViewerModelMap; +export type ASTViewerModelMapComplex = ASTViewerModelMap; export interface GenericParams { - readonly propName?: string; - readonly value: V; + readonly data: V; readonly level: string; readonly selection?: SelectedPosition | null; readonly onSelectNode?: OnSelectNodeFn; - readonly getNodeName: GetNodeNameFn; readonly getTooltip?: GetTooltipFn; - readonly filterProps: FilterPropsFn; - readonly getRange: GetRangeFn; - readonly isArray?: boolean; } export interface ASTViewerBaseProps { - readonly value: Record | TSESTree.Node | string; readonly position?: Monaco.Position | null; readonly onSelectNode?: OnSelectNodeFn; } export interface ASTViewerProps extends ASTViewerBaseProps { - readonly getNodeName: GetNodeNameFn; readonly getTooltip?: GetTooltipFn; - readonly getRange: GetRangeFn; - readonly filterProps: FilterPropsFn; + readonly value: ASTViewerModelMap | string; } +export type Serializer = ( + data: Record, + key: string | undefined, + processValue: (data: [string, unknown][]) => ASTViewerModelMap[], +) => ASTViewerModel | undefined; + export type { SelectedPosition, SelectedRange }; diff --git a/packages/website/src/components/ast/utils.ts b/packages/website/src/components/ast/utils.ts index a06436e9c356..bfe21860afd4 100644 --- a/packages/website/src/components/ast/utils.ts +++ b/packages/website/src/components/ast/utils.ts @@ -1,5 +1,5 @@ import type { SelectedPosition, SelectedRange } from './types'; -import { GetRangeFn } from './types'; +import { ASTViewerModel, ASTViewerModelComplex } from './types'; export function isWithinRange( loc: SelectedPosition, @@ -30,42 +30,35 @@ export function isRecord(value: unknown): value is Record { export function isInRange( position: SelectedPosition | null | undefined, - value: unknown, - getRange: GetRangeFn, + value: ASTViewerModel, ): boolean { - if (!position) { + if (!position || !value.range) { return false; } - const range = getRange(value); - if (!range) { - return false; - } - return isWithinRange(position, range); + return isWithinRange(position, value.range); } export function isArrayInRange( position: SelectedPosition | null | undefined, - value: unknown, - getRange: GetRangeFn, + value: ASTViewerModelComplex, ): boolean { return Boolean( - position && - Array.isArray(value) && - value.some(item => isInRange(position, item, getRange)), + position && value.value.some(item => isInRange(position, item.model)), ); } export function hasChildInRange( position: SelectedPosition | null | undefined, - value: [string, unknown][], - getRange: GetRangeFn, + value: ASTViewerModelComplex, ): boolean { return Boolean( position && - value.some( - ([, item]) => - isInRange(position, item, getRange) || - isArrayInRange(position, item, getRange), + value.value.some(item => + item.model.type === 'object' + ? isInRange(position, item.model) + : item.model.type === 'array' + ? isArrayInRange(position, item.model) + : false, ), ); } diff --git a/packages/website/src/components/editor/LoadedEditor.tsx b/packages/website/src/components/editor/LoadedEditor.tsx index 5a2180ba0fdc..a3e8395bc3d7 100644 --- a/packages/website/src/components/editor/LoadedEditor.tsx +++ b/packages/website/src/components/editor/LoadedEditor.tsx @@ -22,6 +22,7 @@ export const LoadedEditor: React.FC = ({ jsx, main, onEsASTChange, + onScopeChange, onTsASTChange, onChange, onSelect, @@ -65,6 +66,7 @@ export const LoadedEditor: React.FC = ({ onEsASTChange(fatalMessage ?? webLinter.getAst()); onTsASTChange(fatalMessage ?? webLinter.getTsAst()); + onScopeChange(fatalMessage ?? webLinter.getScope()); onSelect(sandboxInstance.editor.getPosition()); }, 500), [code, jsx, sandboxInstance, rules, sourceType, webLinter], diff --git a/packages/website/src/components/editor/types.ts b/packages/website/src/components/editor/types.ts index 56048321473f..edcbcf842d38 100644 --- a/packages/website/src/components/editor/types.ts +++ b/packages/website/src/components/editor/types.ts @@ -1,12 +1,14 @@ import type Monaco from 'monaco-editor'; import type { ConfigModel, SelectedRange } from '../types'; import type { TSESTree } from '@typescript-eslint/website-eslint'; +import type { SourceFile } from 'typescript'; export interface CommonEditorProps extends ConfigModel { readonly darkTheme: boolean; readonly decoration: SelectedRange | null; readonly onChange: (value: string) => void; - readonly onTsASTChange: (value: string | Record) => void; + readonly onTsASTChange: (value: string | SourceFile) => void; readonly onEsASTChange: (value: string | TSESTree.Program) => void; + readonly onScopeChange: (value: string | Record) => void; readonly onSelect: (position: Monaco.Position | null) => void; } diff --git a/packages/website/src/components/hooks/useHashState.ts b/packages/website/src/components/hooks/useHashState.ts index 9c495facc591..f8b7e1270171 100644 --- a/packages/website/src/components/hooks/useHashState.ts +++ b/packages/website/src/components/hooks/useHashState.ts @@ -15,12 +15,14 @@ function readQueryParam(value: string | null, fallback: string): string { : fallback; } -function readShowAST(value: string | null): 'ts' | 'es' | boolean { +function readShowAST(value: string | null): 'ts' | 'scope' | 'es' | boolean { switch (value) { case 'es': return 'es'; case 'ts': return 'ts'; + case 'scope': + return 'scope'; } return Boolean(value); } diff --git a/packages/website/src/components/types.ts b/packages/website/src/components/types.ts index 7830baa224c5..5e1e3a721950 100644 --- a/packages/website/src/components/types.ts +++ b/packages/website/src/components/types.ts @@ -42,7 +42,7 @@ export interface ConfigModel { tsConfig?: CompilerFlags; code: string; ts: string; - showAST?: boolean | 'ts' | 'es'; + showAST?: boolean | 'ts' | 'es' | 'scope'; } export interface SelectedPosition { diff --git a/packages/website/tsconfig.json b/packages/website/tsconfig.json index a689b00a6f5e..3488d04f2e95 100644 --- a/packages/website/tsconfig.json +++ b/packages/website/tsconfig.json @@ -6,7 +6,7 @@ "allowJs": true, "esModuleInterop": true, "jsx": "react", - "lib": ["DOM"], + "lib": ["DOM", "ESNext"], "noEmit": true, "noImplicitAny": false, "resolveJsonModule": true,