From 813660f6947f190f3daf2339f1313fb80e936e43 Mon Sep 17 00:00:00 2001 From: Marco polo Date: Fri, 13 Dec 2024 13:12:52 -0500 Subject: [PATCH] [Selection syntax autocomplete](Refactor) input component to be generalized. (#26410) ## Summary & Motivation Last remaining piece of the generalized autocomplete work is to generalize the input component itself. The generalized SelectionAutoCompleteInput component will be used by the Run gantt chart selection input, the op selection input and the asset selection input. Note this PR is just a refactor. No new logic was added. ## How I Tested These Changes Existing jest tests + manually tested the asset selection filtering input and made sure it still works. --- .../src/app/DefaultFeatureFlags.oss.tsx | 3 + .../input/AssetSelectionInput.oss.tsx | 285 +++++------------- .../input/AssetSelectionLinter.ts | 34 --- .../AssetSelectionSyntaxErrorListener.tsx | 26 -- .../src/selection/SelectionAutoComplete.ts | 16 +- .../selection/SelectionAutoCompleteInput.tsx | 212 +++++++++++++ .../__tests__/SelectionAutoComplete.test.ts | 71 ++++- .../src/selection/createSelectionLinter.ts | 46 +++ 8 files changed, 413 insertions(+), 280 deletions(-) delete mode 100644 js_modules/dagster-ui/packages/ui-core/src/asset-selection/input/AssetSelectionLinter.ts delete mode 100644 js_modules/dagster-ui/packages/ui-core/src/asset-selection/input/AssetSelectionSyntaxErrorListener.tsx create mode 100644 js_modules/dagster-ui/packages/ui-core/src/selection/SelectionAutoCompleteInput.tsx create mode 100644 js_modules/dagster-ui/packages/ui-core/src/selection/createSelectionLinter.ts diff --git a/js_modules/dagster-ui/packages/ui-core/src/app/DefaultFeatureFlags.oss.tsx b/js_modules/dagster-ui/packages/ui-core/src/app/DefaultFeatureFlags.oss.tsx index 63a0632523530..4dab268bccfc8 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/app/DefaultFeatureFlags.oss.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/app/DefaultFeatureFlags.oss.tsx @@ -8,6 +8,9 @@ export const DEFAULT_FEATURE_FLAG_VALUES: Partial> [FeatureFlag.flagAssetSelectionSyntax]: new URLSearchParams(global?.location?.search ?? '').has( 'new-asset-selection-syntax', ), + [FeatureFlag.flagRunSelectionSyntax]: new URLSearchParams(global?.location?.search ?? '').has( + 'new-run-selection-syntax', + ), // Flags for tests [FeatureFlag.__TestFlagDefaultTrue]: true, diff --git a/js_modules/dagster-ui/packages/ui-core/src/asset-selection/input/AssetSelectionInput.oss.tsx b/js_modules/dagster-ui/packages/ui-core/src/asset-selection/input/AssetSelectionInput.oss.tsx index f3060d7f460f8..ef87451179f9b 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/asset-selection/input/AssetSelectionInput.oss.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/asset-selection/input/AssetSelectionInput.oss.tsx @@ -1,19 +1,15 @@ -import {Colors, Icon, Icons} from '@dagster-io/ui-components'; -import CodeMirror, {Editor, HintFunction} from 'codemirror'; -import {useLayoutEffect, useMemo, useRef} from 'react'; -import styled, {createGlobalStyle, css} from 'styled-components'; +import {Icons} from '@dagster-io/ui-components'; +import {useMemo} from 'react'; +import styled from 'styled-components'; -import {lintAssetSelection} from './AssetSelectionLinter'; import {assertUnreachable} from '../../app/Util'; import {AssetGraphQueryItem} from '../../asset-graph/useAssetGraphData'; -import {useUpdatingRef} from '../../hooks/useUpdatingRef'; -import {createSelectionHint} from '../../selection/SelectionAutoComplete'; -import { - SelectionAutoCompleteInputCSS, - applyStaticSyntaxHighlighting, -} from '../../selection/SelectionAutoCompleteHighlighter'; +import {SelectionAutoCompleteInput, iconStyle} from '../../selection/SelectionAutoCompleteInput'; +import {createSelectionLinter} from '../../selection/createSelectionLinter'; import {placeholderTextForItems} from '../../ui/GraphQueryInput'; import {buildRepoPathForHuman} from '../../workspace/buildRepoAddress'; +import {AssetSelectionLexer} from '../generated/AssetSelectionLexer'; +import {AssetSelectionParser} from '../generated/AssetSelectionParser'; import 'codemirror/addon/edit/closebrackets'; import 'codemirror/lib/codemirror.css'; @@ -32,215 +28,86 @@ interface AssetSelectionInputProps { const FUNCTIONS = ['sinks', 'roots']; export const AssetSelectionInput = ({value, onChange, assets}: AssetSelectionInputProps) => { - const editorRef = useRef(null); - const cmInstance = useRef(null); - - const currentValueRef = useRef(value); - - const hintRef = useUpdatingRef( - useMemo(() => { - const assetNamesSet: Set = new Set(); - const tagNamesSet: Set = new Set(); - const ownersSet: Set = new Set(); - const groupsSet: Set = new Set(); - const kindsSet: Set = new Set(); - const codeLocationSet: Set = new Set(); - - assets.forEach((asset) => { - assetNamesSet.add(asset.name); - asset.node.tags.forEach((tag) => { - if (tag.key && tag.value) { - tagNamesSet.add(`${tag.key}=${tag.value}`); - } else { - tagNamesSet.add(tag.key); - } - }); - asset.node.owners.forEach((owner) => { - switch (owner.__typename) { - case 'TeamAssetOwner': - ownersSet.add(owner.team); - break; - case 'UserAssetOwner': - ownersSet.add(owner.email); - break; - default: - assertUnreachable(owner); - } - }); - if (asset.node.groupName) { - groupsSet.add(asset.node.groupName); + const attributesMap = useMemo(() => { + const assetNamesSet: Set = new Set(); + const tagNamesSet: Set = new Set(); + const ownersSet: Set = new Set(); + const groupsSet: Set = new Set(); + const kindsSet: Set = new Set(); + const codeLocationSet: Set = new Set(); + + assets.forEach((asset) => { + assetNamesSet.add(asset.name); + asset.node.tags.forEach((tag) => { + if (tag.key && tag.value) { + tagNamesSet.add(`${tag.key}=${tag.value}`); + } else { + tagNamesSet.add(tag.key); } - asset.node.kinds.forEach((kind) => { - kindsSet.add(kind); - }); - const location = buildRepoPathForHuman( - asset.node.repository.name, - asset.node.repository.location.name, - ); - codeLocationSet.add(location); - }); - - const assetNames = Array.from(assetNamesSet); - const tagNames = Array.from(tagNamesSet); - const owners = Array.from(ownersSet); - const groups = Array.from(groupsSet); - const kinds = Array.from(kindsSet); - const codeLocations = Array.from(codeLocationSet); - - return createSelectionHint( - 'key', - { - key: assetNames, - tag: tagNames, - owner: owners, - group: groups, - kind: kinds, - code_location: codeLocations, - }, - FUNCTIONS, - ); - }, [assets]), - ); - - useLayoutEffect(() => { - if (editorRef.current && !cmInstance.current) { - cmInstance.current = CodeMirror(editorRef.current, { - value, - mode: 'assetSelection', - lineNumbers: false, - lineWrapping: false, - scrollbarStyle: 'native', - autoCloseBrackets: true, - lint: { - getAnnotations: lintAssetSelection, - async: false, - }, - placeholder: placeholderTextForItems('Type an asset subset…', assets), - extraKeys: { - 'Ctrl-Space': 'autocomplete', - Tab: (cm: Editor) => { - cm.replaceSelection(' ', 'end'); - }, - }, }); - - cmInstance.current.setSize('100%', 20); - - // Enforce single line by preventing newlines - cmInstance.current.on('beforeChange', (_instance: Editor, change) => { - if (change.text.some((line) => line.includes('\n'))) { - change.cancel(); - } - }); - - cmInstance.current.on('change', (instance: Editor, change) => { - const newValue = instance.getValue().replace(/\s+/g, ' '); - currentValueRef.current = newValue; - onChange(newValue); - - if (change.origin === 'complete' && change.text[0]?.endsWith('()')) { - // Set cursor inside the right parenthesis - const cursor = instance.getCursor(); - instance.setCursor({...cursor, ch: cursor.ch - 1}); + asset.node.owners.forEach((owner) => { + switch (owner.__typename) { + case 'TeamAssetOwner': + ownersSet.add(owner.team); + break; + case 'UserAssetOwner': + ownersSet.add(owner.email); + break; + default: + assertUnreachable(owner); } }); - - cmInstance.current.on('inputRead', (instance: Editor) => { - showHint(instance, hintRef.current); - }); - - cmInstance.current.on('cursorActivity', (instance: Editor) => { - applyStaticSyntaxHighlighting(instance); - showHint(instance, hintRef.current); - }); - - requestAnimationFrame(() => { - if (!cmInstance.current) { - return; - } - - applyStaticSyntaxHighlighting(cmInstance.current); + if (asset.node.groupName) { + groupsSet.add(asset.node.groupName); + } + asset.node.kinds.forEach((kind) => { + kindsSet.add(kind); }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // Update CodeMirror when value prop changes - useLayoutEffect(() => { - const noNewLineValue = value.replace('\n', ' '); - if (cmInstance.current && cmInstance.current.getValue() !== noNewLineValue) { - const instance = cmInstance.current; - const cursor = instance.getCursor(); - instance.setValue(noNewLineValue); - instance.setCursor(cursor); - showHint(instance, hintRef.current); - } - }, [hintRef, value]); + const location = buildRepoPathForHuman( + asset.node.repository.name, + asset.node.repository.location.name, + ); + codeLocationSet.add(location); + }); + const assetNames = Array.from(assetNamesSet); + const tagNames = Array.from(tagNamesSet); + const owners = Array.from(ownersSet); + const groups = Array.from(groupsSet); + const kinds = Array.from(kindsSet); + const codeLocations = Array.from(codeLocationSet); + + return { + key: assetNames, + tag: tagNames, + owner: owners, + group: groups, + kind: kinds, + code_location: codeLocations, + }; + }, [assets]); + + const linter = useMemo( + () => createSelectionLinter({Lexer: AssetSelectionLexer, Parser: AssetSelectionParser}), + [], + ); return ( - <> - - - -
- - - + + + ); }; -const iconStyle = (img: string) => css` - &:before { - content: ' '; - width: 14px; - mask-size: contain; - mask-repeat: no-repeat; - mask-position: center; - mask-image: url(${img}); - background: ${Colors.accentPrimary()}; - display: inline-block; - } -`; - -const InputDiv = styled.div` - ${SelectionAutoCompleteInputCSS} +const WrapperDiv = styled.div` .attribute-owner { ${iconStyle(Icons.owner.src)} } `; - -const GlobalHintStyles = createGlobalStyle` - .CodeMirror-hints { - background: ${Colors.popoverBackground()}; - border: none; - border-radius: 4px; - padding: 8px 4px; - .CodeMirror-hint { - border-radius: 4px; - font-size: 14px; - padding: 6px 8px 6px 12px; - color: ${Colors.textDefault()}; - &.CodeMirror-hint-active { - background-color: ${Colors.backgroundBlue()}; - color: ${Colors.textDefault()}; - } - } - } -`; - -function showHint(instance: Editor, hint: HintFunction) { - requestAnimationFrame(() => { - instance.showHint({ - hint, - completeSingle: false, - moveOnOverlap: true, - updateOnCursorActivity: true, - }); - }); -} diff --git a/js_modules/dagster-ui/packages/ui-core/src/asset-selection/input/AssetSelectionLinter.ts b/js_modules/dagster-ui/packages/ui-core/src/asset-selection/input/AssetSelectionLinter.ts deleted file mode 100644 index f1f1eb059c23e..0000000000000 --- a/js_modules/dagster-ui/packages/ui-core/src/asset-selection/input/AssetSelectionLinter.ts +++ /dev/null @@ -1,34 +0,0 @@ -import {CharStreams, CommonTokenStream} from 'antlr4ts'; -import CodeMirror from 'codemirror'; - -import {AssetSelectionSyntaxErrorListener} from './AssetSelectionSyntaxErrorListener'; -import {AssetSelectionLexer} from '../generated/AssetSelectionLexer'; -import {AssetSelectionParser} from '../generated/AssetSelectionParser'; - -export const lintAssetSelection = (text: string) => { - const errorListener = new AssetSelectionSyntaxErrorListener(); - - const inputStream = CharStreams.fromString(text); - const lexer = new AssetSelectionLexer(inputStream); - - lexer.removeErrorListeners(); - lexer.addErrorListener(errorListener); - - const tokens = new CommonTokenStream(lexer); - const parser = new AssetSelectionParser(tokens); - - parser.removeErrorListeners(); // Remove default console error listener - parser.addErrorListener(errorListener); - - parser.start(); - - // Map syntax errors to CodeMirror's lint format - const lintErrors = errorListener.errors.map((error) => ({ - message: error.message.replace(', ', ''), - severity: 'error', - from: CodeMirror.Pos(error.line, error.column), - to: CodeMirror.Pos(error.line, text.length), - })); - - return lintErrors; -}; diff --git a/js_modules/dagster-ui/packages/ui-core/src/asset-selection/input/AssetSelectionSyntaxErrorListener.tsx b/js_modules/dagster-ui/packages/ui-core/src/asset-selection/input/AssetSelectionSyntaxErrorListener.tsx deleted file mode 100644 index 89d87f2dbf09e..0000000000000 --- a/js_modules/dagster-ui/packages/ui-core/src/asset-selection/input/AssetSelectionSyntaxErrorListener.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import {ANTLRErrorListener, RecognitionException, Recognizer} from 'antlr4ts'; - -interface SyntaxError { - message: string; - line: number; - column: number; -} - -export class AssetSelectionSyntaxErrorListener implements ANTLRErrorListener { - public errors: SyntaxError[] = []; - - syntaxError( - _recognizer: Recognizer, - _offendingSymbol: T | undefined, - line: number, - charPositionInLine: number, - msg: string, - _e: RecognitionException | undefined, - ): void { - this.errors.push({ - message: msg, - line: line - 1, // CodeMirror lines are 0-based - column: charPositionInLine, - }); - } -} diff --git a/js_modules/dagster-ui/packages/ui-core/src/selection/SelectionAutoComplete.ts b/js_modules/dagster-ui/packages/ui-core/src/selection/SelectionAutoComplete.ts index 8603de6447fe8..8973cd0835cdd 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/selection/SelectionAutoComplete.ts +++ b/js_modules/dagster-ui/packages/ui-core/src/selection/SelectionAutoComplete.ts @@ -38,7 +38,7 @@ type TextCallback = (value: string) => string; const DEFAULT_TEXT_CALLBACK = (value: string) => value; // set to true for useful debug output. -const DEBUG = true; +const DEBUG = false; export class SelectionAutoCompleteVisitor extends AbstractParseTreeVisitor @@ -507,11 +507,15 @@ export class SelectionAutoCompleteVisitor } } -export function createSelectionHint, N extends keyof T>( - _nameBase: N, - attributesMap: T, - functions: string[], -): CodeMirror.HintFunction { +export function createSelectionHint, N extends keyof T>({ + nameBase: _nameBase, + attributesMap, + functions, +}: { + nameBase: N; + attributesMap: T; + functions: string[]; +}): CodeMirror.HintFunction { const nameBase = _nameBase as string; return function (cm: CodeMirror.Editor, _options: CodeMirror.ShowHintOptions): any { diff --git a/js_modules/dagster-ui/packages/ui-core/src/selection/SelectionAutoCompleteInput.tsx b/js_modules/dagster-ui/packages/ui-core/src/selection/SelectionAutoCompleteInput.tsx new file mode 100644 index 0000000000000..e84b930cf248d --- /dev/null +++ b/js_modules/dagster-ui/packages/ui-core/src/selection/SelectionAutoCompleteInput.tsx @@ -0,0 +1,212 @@ +import {Colors, Icon} from '@dagster-io/ui-components'; +import CodeMirror, {Editor, HintFunction} from 'codemirror'; +import {Linter} from 'codemirror/addon/lint/lint'; +import {useLayoutEffect, useMemo, useRef} from 'react'; +import styled, {createGlobalStyle, css} from 'styled-components'; + +import { + SelectionAutoCompleteInputCSS, + applyStaticSyntaxHighlighting, +} from './SelectionAutoCompleteHighlighter'; +import {useUpdatingRef} from '../hooks/useUpdatingRef'; +import {createSelectionHint} from '../selection/SelectionAutoComplete'; + +import 'codemirror/addon/edit/closebrackets'; +import 'codemirror/lib/codemirror.css'; +import 'codemirror/addon/hint/show-hint'; +import 'codemirror/addon/hint/show-hint.css'; +import 'codemirror/addon/lint/lint.css'; +import 'codemirror/addon/display/placeholder'; + +type SelectionAutoCompleteInputProps, N extends keyof T> = { + nameBase: N; + attributesMap: T; + placeholder: string; + functions: string[]; + linter: Linter; + value: string; + onChange: (value: string) => void; +}; + +export const SelectionAutoCompleteInput = , N extends keyof T>({ + value, + nameBase, + placeholder, + onChange, + functions, + linter, + attributesMap, +}: SelectionAutoCompleteInputProps) => { + const editorRef = useRef(null); + const cmInstance = useRef(null); + + const currentValueRef = useUpdatingRef(value); + const currentPendingValueRef = useRef(value); + const setValueTimeoutRef = useRef>(null); + + const hintRef = useUpdatingRef( + useMemo(() => { + return createSelectionHint({nameBase, attributesMap, functions}); + }, [nameBase, attributesMap, functions]), + ); + + useLayoutEffect(() => { + if (editorRef.current && !cmInstance.current) { + cmInstance.current = CodeMirror(editorRef.current, { + value, + mode: 'assetSelection', + lineNumbers: false, + lineWrapping: false, + scrollbarStyle: 'native', + autoCloseBrackets: true, + lint: { + getAnnotations: linter, + async: false, + }, + placeholder, + extraKeys: { + 'Ctrl-Space': 'autocomplete', + Tab: (cm: Editor) => { + cm.replaceSelection(' ', 'end'); + }, + }, + }); + + cmInstance.current.setSize('100%', 20); + + // Enforce single line by preventing newlines + cmInstance.current.on('beforeChange', (_instance: Editor, change) => { + if (change.text.some((line) => line.includes('\n'))) { + change.cancel(); + } + }); + + cmInstance.current.on('change', (instance: Editor, change) => { + const newValue = instance.getValue().replace(/\s+/g, ' '); + currentPendingValueRef.current = newValue; + if (setValueTimeoutRef.current) { + clearTimeout(setValueTimeoutRef.current); + } + setValueTimeoutRef.current = setTimeout(() => { + onChange(newValue); + }, 2000); + + if (change.origin === 'complete' && change.text[0]?.endsWith('()')) { + // Set cursor inside the right parenthesis + const cursor = instance.getCursor(); + instance.setCursor({...cursor, ch: cursor.ch - 1}); + } + }); + + cmInstance.current.on('inputRead', (instance: Editor) => { + showHint(instance, hintRef.current); + }); + + cmInstance.current.on('cursorActivity', (instance: Editor) => { + applyStaticSyntaxHighlighting(instance); + showHint(instance, hintRef.current); + }); + + cmInstance.current.on('blur', () => { + if (currentPendingValueRef.current !== currentValueRef.current) { + onChange(currentPendingValueRef.current); + } + }); + + requestAnimationFrame(() => { + if (!cmInstance.current) { + return; + } + + applyStaticSyntaxHighlighting(cmInstance.current); + }); + } + + return () => { + const cm = cmInstance.current; + if (cm) { + // Clean up the instance... + cm.closeHint(); + cm.getWrapperElement()?.parentNode?.removeChild(cm.getWrapperElement()); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Update CodeMirror when value prop changes + useLayoutEffect(() => { + const noNewLineValue = value.replace('\n', ' '); + if (cmInstance.current && cmInstance.current.getValue() !== noNewLineValue) { + const instance = cmInstance.current; + const cursor = instance.getCursor(); + instance.setValue(noNewLineValue); + instance.setCursor(cursor); + showHint(instance, hintRef.current); + } + }, [hintRef, value]); + + return ( + <> + + + +
+ + + ); +}; + +export const iconStyle = (img: string) => css` + &:before { + content: ' '; + width: 14px; + mask-size: contain; + mask-repeat: no-repeat; + mask-position: center; + mask-image: url(${img}); + background: ${Colors.accentPrimary()}; + display: inline-block; + } +`; + +export const InputDiv = styled.div` + ${SelectionAutoCompleteInputCSS} +`; + +const GlobalHintStyles = createGlobalStyle` + .CodeMirror-hints { + background: ${Colors.popoverBackground()}; + border: none; + border-radius: 4px; + padding: 8px 4px; + .CodeMirror-hint { + border-radius: 4px; + font-size: 14px; + padding: 6px 8px 6px 12px; + color: ${Colors.textDefault()}; + &.CodeMirror-hint-active { + background-color: ${Colors.backgroundBlue()}; + color: ${Colors.textDefault()}; + } + } + } +`; + +function showHint(instance: Editor, hint: HintFunction) { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + instance.showHint({ + hint, + completeSingle: false, + moveOnOverlap: true, + updateOnCursorActivity: true, + }); + }); + }); +} diff --git a/js_modules/dagster-ui/packages/ui-core/src/selection/__tests__/SelectionAutoComplete.test.ts b/js_modules/dagster-ui/packages/ui-core/src/selection/__tests__/SelectionAutoComplete.test.ts index 9e63b325f6d1a..88322779bd09a 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/selection/__tests__/SelectionAutoComplete.test.ts +++ b/js_modules/dagster-ui/packages/ui-core/src/selection/__tests__/SelectionAutoComplete.test.ts @@ -3,9 +3,9 @@ import {Hint, Hints, Position} from 'codemirror'; import {createSelectionHint} from '../SelectionAutoComplete'; describe('createAssetSelectionHint', () => { - const selectionHint = createSelectionHint( - 'key', - { + const selectionHint = createSelectionHint({ + nameBase: 'key', + attributesMap: { key: ['asset1', 'asset2', 'asset3'], tag: ['tag1', 'tag2', 'tag3'], owner: ['marco@dagsterlabs.com', 'team:frontend'], @@ -13,8 +13,8 @@ describe('createAssetSelectionHint', () => { kind: ['kind1', 'kind2'], code_location: ['repo1@location1', 'repo2@location2'], }, - ['sinks', 'roots'], - ); + functions: ['sinks', 'roots'], + }); type HintsModified = Omit & { list: Array; @@ -818,4 +818,65 @@ describe('createAssetSelectionHint', () => { to: 60, }); }); + + it('handles complex ands/ors', () => { + expect(testAutocomplete('key:"value"* or tag:"value"+ and owner:"owner" and |')).toEqual({ + from: 51, + list: [ + { + displayText: 'key_substring:', + text: 'key_substring:', + }, + { + displayText: 'key:', + text: 'key:', + }, + { + displayText: 'tag:', + text: 'tag:', + }, + { + displayText: 'owner:', + text: 'owner:', + }, + { + displayText: 'group:', + text: 'group:', + }, + { + displayText: 'kind:', + text: 'kind:', + }, + { + displayText: 'code_location:', + text: 'code_location:', + }, + { + displayText: 'sinks()', + text: 'sinks()', + }, + { + displayText: 'roots()', + text: 'roots()', + }, + { + displayText: 'not', + text: 'not ', + }, + { + displayText: '*', + text: '*', + }, + { + displayText: '+', + text: '+', + }, + { + displayText: '(', + text: '()', + }, + ], + to: 51, + }); + }); }); diff --git a/js_modules/dagster-ui/packages/ui-core/src/selection/createSelectionLinter.ts b/js_modules/dagster-ui/packages/ui-core/src/selection/createSelectionLinter.ts new file mode 100644 index 0000000000000..ac10d4c98f504 --- /dev/null +++ b/js_modules/dagster-ui/packages/ui-core/src/selection/createSelectionLinter.ts @@ -0,0 +1,46 @@ +import {CharStreams, CommonTokenStream, Lexer, Parser, ParserRuleContext} from 'antlr4ts'; +import CodeMirror from 'codemirror'; +import {Linter} from 'codemirror/addon/lint/lint'; + +import {CustomErrorListener} from './CustomErrorListener'; + +type LexerConstructor = new (...args: any[]) => Lexer; +type ParserConstructor = new (...args: any[]) => Parser & { + start: () => ParserRuleContext; +}; + +export function createSelectionLinter({ + Lexer: LexerKlass, + Parser: ParserKlass, +}: { + Lexer: LexerConstructor; + Parser: ParserConstructor; +}): Linter { + return (text: string) => { + const errorListener = new CustomErrorListener(); + + const inputStream = CharStreams.fromString(text); + const lexer = new LexerKlass(inputStream); + + lexer.removeErrorListeners(); + lexer.addErrorListener(errorListener); + + const tokens = new CommonTokenStream(lexer); + const parser = new ParserKlass(tokens); + + parser.removeErrorListeners(); // Remove default console error listener + parser.addErrorListener(errorListener); + + parser.start(); + + // Map syntax errors to CodeMirror's lint format + const lintErrors = errorListener.getErrors().map((error) => ({ + message: error.message.replace(', ', ''), + severity: 'error', + from: CodeMirror.Pos(error.line, error.column), + to: CodeMirror.Pos(error.line, text.length), + })); + + return lintErrors; + }; +}