From ed2d2b6a8f27d947dd7198079c7473babf98c757 Mon Sep 17 00:00:00 2001 From: Chet Joswig Date: Thu, 26 Sep 2024 11:39:35 -0700 Subject: [PATCH] FCPL branch enhancements (#1455) * folding for seqN conditionals, waits, and loops * outlining of paired blocks, may change definition of paired * highlight if, else, end together * added linting for block structure * auto-indent blocks and fix folding when there's indentation * only show last unclosed block error * removed some unused parameters * support alternate loop construct names * clear selection after indent on open, make function return types explicit * better variable name and remove unused parameter * more detailed parameter name, explicit return types * restore branch structure checking * removed some unused parameters * create USER_ stems --- .../sequencing/SequenceEditor.svelte | 101 +++++- src/utilities/codemirror/custom-folder.ts | 319 +++++++++++++++++- .../sequence-editor/sequence-autoindent.ts | 9 + .../sequence-editor/sequence-linter.ts | 160 +++------ 4 files changed, 460 insertions(+), 129 deletions(-) diff --git a/src/components/sequencing/SequenceEditor.svelte b/src/components/sequencing/SequenceEditor.svelte index a666f61a4b..84fd00638c 100644 --- a/src/components/sequencing/SequenceEditor.svelte +++ b/src/components/sequencing/SequenceEditor.svelte @@ -5,8 +5,8 @@ import { json } from '@codemirror/lang-json'; import { indentService, syntaxTree } from '@codemirror/language'; import { lintGutter } from '@codemirror/lint'; - import { Compartment, EditorState } from '@codemirror/state'; - import type { ViewUpdate } from '@codemirror/view'; + import { Compartment, EditorState, Prec } from '@codemirror/state'; + import { Decoration, ViewPlugin, type DecorationSet, type ViewUpdate } from '@codemirror/view'; import type { SyntaxNode } from '@lezer/common'; import type { ChannelDictionary, CommandDictionary, ParameterDictionary } from '@nasa-jpl/aerie-ampcs'; import ChevronDownIcon from '@nasa-jpl/stellar/icons/chevron_down.svg?component'; @@ -17,6 +17,7 @@ import { EditorView, basicSetup } from 'codemirror'; import { debounce } from 'lodash-es'; import { createEventDispatcher, onDestroy, onMount } from 'svelte'; + import { TOKEN_COMMAND } from '../../constants/seq-n-grammar-constants'; import { inputFormat, outputFormat, @@ -37,10 +38,12 @@ import type { User } from '../../types/app'; import type { IOutputFormat, Parcel } from '../../types/sequencing'; import { setupLanguageSupport } from '../../utilities/codemirror'; + import { computeBlocks, isBlockCommand } from '../../utilities/codemirror/custom-folder'; import effects from '../../utilities/effects'; import { downloadBlob, downloadJSON } from '../../utilities/generic'; import { inputLinter, outputLinter } from '../../utilities/sequence-editor/extension-points'; import { sequenceTooltip } from '../../utilities/sequence-editor/sequence-tooltip'; + import { getNearestAncestorNodeOfType } from '../../utilities/sequence-editor/tree-utils'; import { showFailureToast, showSuccessToast } from '../../utilities/toast'; import { tooltip } from '../../utilities/tooltip'; import Menu from '../menus/Menu.svelte'; @@ -107,6 +110,12 @@ dispatch: transaction => editorSequenceView.update([transaction]), state: editorSequenceView.state, }); + // clear selection + editorSequenceView.update([ + editorSequenceView.state.update({ + selection: { anchor: 0, head: 0 }, + }), + ]); } } @@ -194,6 +203,8 @@ compartmentSeqTooltip.of(sequenceTooltip()), EditorView.updateListener.of(debounce(sequenceUpdateListener, 250)), EditorView.updateListener.of(selectedCommandUpdateListener), + EditorView.updateListener.of(debounce(highlightBlock, 250)), + Prec.highest([blockTheme, blockHighlighter]), compartmentSeqAutocomplete.of(indentService.of($sequenceAdaptation.autoIndent())), EditorState.readOnly.of(readOnly), ], @@ -244,7 +255,7 @@ setSequenceAdaptation(undefined); } - async function sequenceUpdateListener(viewUpdate: ViewUpdate) { + async function sequenceUpdateListener(viewUpdate: ViewUpdate): Promise { const sequence = viewUpdate.state.doc.toString(); disableCopyAndExport = sequence === ''; const tree = syntaxTree(viewUpdate.state); @@ -261,7 +272,7 @@ } } - function selectedCommandUpdateListener(viewUpdate: ViewUpdate) { + function selectedCommandUpdateListener(viewUpdate: ViewUpdate): void { // This is broken out into a different listener as debouncing this can cause cursor to move around const tree = syntaxTree(viewUpdate.state); // Command Node includes trailing newline and white space, move to next command @@ -274,7 +285,79 @@ } } - function downloadOutputFormat(outputFormat: IOutputFormat) { + const blockMark = Decoration.mark({ class: 'cm-block-match' }); + + const blockTheme = EditorView.baseTheme({ + '.cm-block-match': { + outline: '1px dashed', + }, + }); + + function highlightBlock(viewUpdate: ViewUpdate): SyntaxNode[] { + const tree = syntaxTree(viewUpdate.state); + // Command Node includes trailing newline and white space, move to next command + const selectionLine = viewUpdate.state.doc.lineAt(viewUpdate.state.selection.asSingle().main.from); + const leadingWhiteSpaceLength = selectionLine.text.length - selectionLine.text.trimStart().length; + const updatedSelectionNode = tree.resolveInner(selectionLine.from + leadingWhiteSpaceLength, 1); + const stemNode = getNearestAncestorNodeOfType(updatedSelectionNode, [TOKEN_COMMAND])?.getChild('Stem'); + + if (!stemNode || !isBlockCommand(viewUpdate.state.sliceDoc(stemNode.from, stemNode.to))) { + return []; + } + + const blocks = computeBlocks(viewUpdate.state); + if (!blocks) { + return []; + } + + const pairs = Object.values(blocks); + const matchedNodes: SyntaxNode[] = [stemNode]; + + // when cursor on end -- select else and if + let current: SyntaxNode | undefined = stemNode; + while (current) { + current = pairs.find(block => block.end?.from === current!.from)?.start; + if (current) { + matchedNodes.push(current); + } + } + + // when cursor on if -- select else and end + current = stemNode; + while (current) { + current = pairs.find(block => block.start?.from === current!.from)?.end; + if (current) { + matchedNodes.push(current); + } + } + + return matchedNodes; + } + + const blockHighlighter = ViewPlugin.fromClass( + class { + decorations: DecorationSet; + constructor() { + this.decorations = Decoration.none; + } + update(viewUpdate: ViewUpdate): DecorationSet | null { + if (viewUpdate.selectionSet || viewUpdate.docChanged || viewUpdate.viewportChanged) { + const blocks = highlightBlock(viewUpdate); + this.decorations = Decoration.set( + // codemirror requires marks to be in sorted order + blocks.sort((a, b) => a.from - b.from).map(block => blockMark.range(block.from, block.to)), + ); + return this.decorations; + } + return null; + } + }, + { + decorations: viewPluginSpecification => viewPluginSpecification.decorations, + }, + ); + + function downloadOutputFormat(outputFormat: IOutputFormat): void { const fileExtension = `${sequenceName}.${selectedOutputFormat?.fileExtension}`; if (outputFormat?.fileExtension === 'json') { @@ -284,11 +367,11 @@ } } - function downloadInputFormat() { + function downloadInputFormat(): void { downloadBlob(new Blob([editorSequenceView.state.doc.toString()], { type: 'text/plain' }), `${sequenceName}.txt`); } - async function copyOutputFormatToClipboard() { + async function copyOutputFormatToClipboard(): Promise { try { await navigator.clipboard.writeText(editorOutputView.state.doc.toString()); showSuccessToast(`${selectedOutputFormat?.name} copied to clipboard`); @@ -297,7 +380,7 @@ } } - async function copyInputFormatToClipboard() { + async function copyInputFormatToClipboard(): Promise { try { await navigator.clipboard.writeText(editorSequenceView.state.doc.toString()); showSuccessToast(`${$inputFormat?.name} copied to clipboard`); @@ -306,7 +389,7 @@ } } - function toggleSeqJsonEditor() { + function toggleSeqJsonEditor(): void { toggleSeqJsonPreview = !toggleSeqJsonPreview; } diff --git a/src/utilities/codemirror/custom-folder.ts b/src/utilities/codemirror/custom-folder.ts index 71bf486efa..af1ce89362 100644 --- a/src/utilities/codemirror/custom-folder.ts +++ b/src/utilities/codemirror/custom-folder.ts @@ -1,5 +1,7 @@ -import type { EditorState } from '@codemirror/state'; +import { syntaxTree } from '@codemirror/language'; +import { EditorState } from '@codemirror/state'; import type { SyntaxNode } from '@lezer/common'; +import { TOKEN_COMMAND } from '../../constants/seq-n-grammar-constants'; import { getFromAndTo } from '../sequence-editor/tree-utils'; export function customFoldInside(node: SyntaxNode, state: EditorState): { from: number; to: number } | null { @@ -44,6 +46,13 @@ export function foldSteps( return null; } + if (nodeName === 'Stem') { + const blockFold = blockFolder(node, state); + if (blockFold) { + return blockFold; + } + } + // Get all Args nodes, LineComment node, Metadata nodes, and Models nodes. const argsNodes = containerNode.getChildren('Args'); const commentNode = containerNode.getChild('LineComment'); @@ -129,3 +138,311 @@ function foldMetadataOrModel( to: newEnd, }; } + +type BlockStackNode = Readonly<{ + node: SyntaxNode; + stem: string; +}>; + +type BlockStack = BlockStackNode[]; + +export type PairedCommands = { + end: SyntaxNode; + endPos: number; + start: SyntaxNode; + startPos: number; +}; + +type PartialPairedCommands = Partial; + +type TreeState = { + [startPos: number]: PartialPairedCommands; +}; + +export function isPairedCommands(pair: unknown): pair is PairedCommands { + const pc = pair as PairedCommands; + return !!pc?.start && !!pc?.end; +} + +type BlockType = Readonly<{ + close: string; + open: string[]; + partition?: string; +}>; + +const SEQ_DIR_END_IF = 'SEQ_DIR_END_IF'; +const SEQ_DIR_IF = 'SEQ_DIR_IF'; +const SEQ_DIR_IF_OR = 'SEQ_DIR_IF_OR'; +const SEQ_DIR_IF_AND = 'SEQ_DIR_IF_AND'; +const SEQ_DIR_ELSE = 'SEQ_DIR_ELSE'; +const SEQ_DIR_END_WAIT_UNTIL = 'SEQ_DIR_END_WAIT_UNTIL'; +const SEQ_DIR_WAIT_UNTIL = 'SEQ_DIR_WAIT_UNTIL'; +const SEQ_DIR_WAIT_UNTIL_VAR = 'SEQ_DIR_WAIT_UNTIL_VAR'; +const SEQ_DIR_WAIT_UNTIL_AND = 'SEQ_DIR_WAIT_UNTIL_AND'; +const SEQ_DIR_WAIT_UNTIL_OR = 'SEQ_DIR_WAIT_UNTIL_OR'; +const SEQ_DIR_WAIT_UNTIL_TIMEOUT = 'SEQ_DIR_WAIT_UNTIL_TIMEOUT'; +const SEQ_DIR_END_LOOP = 'SEQ_DIR_END_LOOP'; +const SEQ_DIR_LOOP = 'SEQ_DIR_LOOP'; +const SEQ_DIR_WHILE_LOOP = 'SEQ_DIR_WHILE_LOOP'; +const SEQ_DIR_WHILE_LOOP_AND = 'SEQ_DIR_WHILE_LOOP_AND'; +const SEQ_DIR_WHILE_LOOP_OR = 'SEQ_DIR_WHILE_LOOP_OR'; +const SEQ_DIR_END_WHILE_LOOP = 'SEQ_DIR_END_WHILE_LOOP'; + +// user-friendly versions, move to adaption when hooks are available +const USER_SEQ_DIR_END_IF = 'USER_SEQ_DIR_END_IF'; +const USER_SEQ_DIR_IF = 'USER_SEQ_DIR_IF'; +const USER_SEQ_DIR_IF_OR = 'USER_SEQ_DIR_IF_OR'; +const USER_SEQ_DIR_IF_AND = 'USER_SEQ_DIR_IF_AND'; +const USER_SEQ_DIR_ELSE = 'USER_SEQ_DIR_ELSE'; +const USER_SEQ_DIR_END_WAIT_UNTIL = 'USER_SEQ_DIR_END_WAIT_UNTIL'; +const USER_SEQ_DIR_WAIT_UNTIL = 'USER_SEQ_DIR_WAIT_UNTIL'; +const USER_SEQ_DIR_WAIT_UNTIL_VAR = 'USER_SEQ_DIR_WAIT_UNTIL_VAR'; +const USER_SEQ_DIR_WAIT_UNTIL_AND = 'USER_SEQ_DIR_WAIT_UNTIL_AND'; +const USER_SEQ_DIR_WAIT_UNTIL_OR = 'USER_SEQ_DIR_WAIT_UNTIL_OR'; +const USER_SEQ_DIR_WAIT_UNTIL_TIMEOUT = 'USER_SEQ_DIR_WAIT_UNTIL_TIMEOUT'; +const USER_SEQ_DIR_END_LOOP = 'USER_SEQ_DIR_END_LOOP'; +const USER_SEQ_DIR_LOOP = 'USER_SEQ_DIR_LOOP'; +const USER_SEQ_DIR_WHILE_LOOP = 'USER_SEQ_DIR_WHILE_LOOP'; +const USER_SEQ_DIR_WHILE_LOOP_AND = 'USER_SEQ_DIR_WHILE_LOOP_AND'; +const USER_SEQ_DIR_WHILE_LOOP_OR = 'USER_SEQ_DIR_WHILE_LOOP_OR'; +const USER_SEQ_DIR_END_WHILE_LOOP = 'USER_SEQ_DIR_END_WHILE_LOOP'; + +const BLOCK_TYPES: readonly BlockType[] = [ + { + close: SEQ_DIR_END_IF, + open: [SEQ_DIR_IF, SEQ_DIR_IF_OR, SEQ_DIR_IF_AND], + partition: SEQ_DIR_ELSE, + }, + { + close: SEQ_DIR_END_WAIT_UNTIL, + open: [SEQ_DIR_WAIT_UNTIL, SEQ_DIR_WAIT_UNTIL_VAR, SEQ_DIR_WAIT_UNTIL_AND, SEQ_DIR_WAIT_UNTIL_OR], + partition: SEQ_DIR_WAIT_UNTIL_TIMEOUT, + }, + { + close: SEQ_DIR_END_LOOP, + open: [SEQ_DIR_LOOP], + }, + { + close: SEQ_DIR_END_WHILE_LOOP, + open: [SEQ_DIR_WHILE_LOOP, SEQ_DIR_WHILE_LOOP_AND, SEQ_DIR_WHILE_LOOP_OR], + }, + { + close: USER_SEQ_DIR_END_IF, + open: [USER_SEQ_DIR_IF, USER_SEQ_DIR_IF_OR, USER_SEQ_DIR_IF_AND], + partition: USER_SEQ_DIR_ELSE, + }, + { + close: USER_SEQ_DIR_END_WAIT_UNTIL, + open: [ + USER_SEQ_DIR_WAIT_UNTIL, + USER_SEQ_DIR_WAIT_UNTIL_VAR, + USER_SEQ_DIR_WAIT_UNTIL_AND, + USER_SEQ_DIR_WAIT_UNTIL_OR, + ], + partition: USER_SEQ_DIR_WAIT_UNTIL_TIMEOUT, + }, + { + close: USER_SEQ_DIR_END_LOOP, + open: [USER_SEQ_DIR_LOOP], + }, + { + close: USER_SEQ_DIR_END_WHILE_LOOP, + open: [USER_SEQ_DIR_WHILE_LOOP, USER_SEQ_DIR_WHILE_LOOP_AND, USER_SEQ_DIR_WHILE_LOOP_OR], + }, +]; + +const OPEN_SUGGESTION: { [open: string]: string } = Object.fromEntries( + BLOCK_TYPES.flatMap(blockType => [ + [blockType.close, blockType.open.join(', ')], + blockType.partition ? [blockType.partition, blockType.open.join(', ')] : [], + ]), +); + +const CLOSE_SUGGESTION: { [open: string]: string } = Object.fromEntries( + BLOCK_TYPES.flatMap(blockType => [ + ...blockType.open.map(opener => [opener, blockType.close]), + ...(blockType.partition ? [[blockType.partition, blockType.close]] : []), + ]), +); + +export function closeSuggestion(stem: string): string | undefined { + return CLOSE_SUGGESTION[stem]; +} + +export function openSuggestion(stem: string): string | undefined { + return OPEN_SUGGESTION[stem]; +} + +const blockOpeningStems: Set = new Set([ + SEQ_DIR_IF, + SEQ_DIR_IF_OR, + SEQ_DIR_IF_AND, + SEQ_DIR_ELSE, + SEQ_DIR_WAIT_UNTIL, + SEQ_DIR_WAIT_UNTIL_VAR, + SEQ_DIR_WAIT_UNTIL_AND, + SEQ_DIR_WAIT_UNTIL_OR, + SEQ_DIR_WAIT_UNTIL_TIMEOUT, + SEQ_DIR_LOOP, + SEQ_DIR_WHILE_LOOP, + SEQ_DIR_WHILE_LOOP_AND, + SEQ_DIR_WHILE_LOOP_OR, + + USER_SEQ_DIR_IF, + USER_SEQ_DIR_IF_OR, + USER_SEQ_DIR_IF_AND, + USER_SEQ_DIR_ELSE, + USER_SEQ_DIR_WAIT_UNTIL, + USER_SEQ_DIR_WAIT_UNTIL_VAR, + USER_SEQ_DIR_WAIT_UNTIL_AND, + USER_SEQ_DIR_WAIT_UNTIL_OR, + USER_SEQ_DIR_WAIT_UNTIL_TIMEOUT, + USER_SEQ_DIR_LOOP, + USER_SEQ_DIR_WHILE_LOOP, + USER_SEQ_DIR_WHILE_LOOP_AND, + USER_SEQ_DIR_WHILE_LOOP_OR, +]); + +const blockClosingStems: Set = new Set([ + SEQ_DIR_ELSE, // also opens + SEQ_DIR_WAIT_UNTIL_TIMEOUT, // also opens + + SEQ_DIR_END_IF, + SEQ_DIR_END_WAIT_UNTIL, + SEQ_DIR_END_LOOP, + SEQ_DIR_END_WHILE_LOOP, + + USER_SEQ_DIR_ELSE, // also opens + USER_SEQ_DIR_WAIT_UNTIL_TIMEOUT, // also opens + + USER_SEQ_DIR_END_IF, + USER_SEQ_DIR_END_WAIT_UNTIL, + USER_SEQ_DIR_END_LOOP, + USER_SEQ_DIR_END_WHILE_LOOP, +]); + +export function isBlockCommand(stem: string): boolean { + return blockOpeningStems.has(stem) || blockClosingStems.has(stem); +} + +function closesBlock(stem: string, blockStem: string): boolean { + // not the same as `closeSuggestion(blockStem) === stem;` as else types are optional + switch (stem) { + case SEQ_DIR_END_IF: + return blockStem === SEQ_DIR_ELSE || blockStem.startsWith(SEQ_DIR_IF); + case SEQ_DIR_ELSE: + return blockStem.startsWith(SEQ_DIR_IF); + case SEQ_DIR_END_WAIT_UNTIL: + return blockStem === SEQ_DIR_WAIT_UNTIL_TIMEOUT || blockStem.startsWith(SEQ_DIR_WAIT_UNTIL); + case SEQ_DIR_WAIT_UNTIL_TIMEOUT: + return blockStem.startsWith(SEQ_DIR_WAIT_UNTIL); + case SEQ_DIR_END_LOOP: + return blockStem === SEQ_DIR_LOOP; + case SEQ_DIR_END_WHILE_LOOP: + return blockStem.startsWith(SEQ_DIR_WHILE_LOOP); + + case USER_SEQ_DIR_END_IF: + return blockStem === USER_SEQ_DIR_ELSE || blockStem.startsWith(USER_SEQ_DIR_IF); + case USER_SEQ_DIR_ELSE: + return blockStem.startsWith(USER_SEQ_DIR_IF); + case USER_SEQ_DIR_END_WAIT_UNTIL: + return blockStem === USER_SEQ_DIR_WAIT_UNTIL_TIMEOUT || blockStem.startsWith(USER_SEQ_DIR_WAIT_UNTIL); + case USER_SEQ_DIR_WAIT_UNTIL_TIMEOUT: + return blockStem.startsWith(USER_SEQ_DIR_WAIT_UNTIL); + case USER_SEQ_DIR_END_LOOP: + return blockStem === USER_SEQ_DIR_LOOP; + case USER_SEQ_DIR_END_WHILE_LOOP: + return blockStem.startsWith(USER_SEQ_DIR_WHILE_LOOP); + } + return false; +} + +const blocksForState = new WeakMap(); + +export function computeBlocks(state: EditorState): TreeState { + // avoid scanning for each command + const blocks = blocksForState.get(state); + if (!blocks) { + // find all command nodes in sequence + const commandNodes: SyntaxNode[] = []; + syntaxTree(state).iterate({ + enter: node => { + if (node.name === TOKEN_COMMAND) { + const stemNode = node.node.getChild('Stem'); + if (stemNode) { + commandNodes.push(stemNode); + } + } + }, + }); + + const treeState: TreeState = {}; + const stack: BlockStack = []; + const docString = state.sliceDoc(); + + commandNodes + // filter out ones that don't impact blocks + .filter(stemNode => isBlockCommand(state.sliceDoc(stemNode.from, stemNode.to))) + .forEach(stemNode => { + const stem = state.sliceDoc(stemNode.from, stemNode.to); + const topStem = stack.at(-1)?.stem; + + if (topStem && closesBlock(stem, topStem)) { + // close current block + const blockInfo: BlockStackNode | undefined = stack.pop(); + if (blockInfo) { + // pair end with existing start to provide info for fold region + const commandStr = state.toText(docString).lineAt(stemNode.from).text; + const leadingSpaces = commandStr.length - commandStr.trimStart().length; + let endPos: undefined | number = undefined; + if (stemNode.parent) { + // don't fold up preceding new line and indentation + endPos = stemNode.parent.from - leadingSpaces - 1; + } + + Object.assign(treeState[blockInfo.node.from], { end: stemNode, endPos }); + } + } else if (blockClosingStems.has(stem)) { + // unexpected close + treeState[stemNode.from] = { + end: stemNode, + }; + return; // don't open a new block for else_if type + } + + if (blockOpeningStems.has(stem)) { + // open new block + + let startPos: undefined | number = undefined; + if (stemNode.parent) { + const fullCommand = state.sliceDoc(stemNode.parent.from, stemNode.parent.to); + startPos = stemNode.parent.to - (fullCommand.length - fullCommand.trimEnd().length); + } + treeState[stemNode.from] = { + start: stemNode, + startPos, + }; + + stack.push({ + node: stemNode, + stem, + }); + } + }); + blocksForState.set(state, treeState); + } + return blocksForState.get(state)!; +} + +function blockFolder(stemNode: SyntaxNode, state: EditorState): { from: number; to: number } | null { + const localBlock = computeBlocks(state)?.[stemNode.from]; + if (isPairedCommands(localBlock) && localBlock.startPos !== undefined && localBlock.endPos !== undefined) { + // display lines that open and close block + return { + from: localBlock.startPos, + to: localBlock.endPos, + }; + } + + return null; +} diff --git a/src/utilities/sequence-editor/sequence-autoindent.ts b/src/utilities/sequence-editor/sequence-autoindent.ts index a7797c347f..d9210ff878 100644 --- a/src/utilities/sequence-editor/sequence-autoindent.ts +++ b/src/utilities/sequence-editor/sequence-autoindent.ts @@ -1,4 +1,5 @@ import { syntaxTree, type IndentContext } from '@codemirror/language'; +import { computeBlocks } from '../codemirror/custom-folder'; import { getNearestAncestorNodeOfType } from './tree-utils'; const TAB_SIZE = 2; @@ -41,6 +42,14 @@ export function sequenceAutoIndent(): (context: IndentContext, pos: number) => n return 0; } + const blocks = computeBlocks(context.state); + if (blocks && Object.values(blocks).length) { + const openPairsAtPosition = Object.values(blocks).filter( + pair => pair.start && pair.start?.from < pos && (!pair.endPos || pair.endPos >= pos), + ); + return openPairsAtPosition.length * TAB_SIZE; + } + // otherwise, don't indent return 0; }; diff --git a/src/utilities/sequence-editor/sequence-linter.ts b/src/utilities/sequence-editor/sequence-linter.ts index 2f93024068..cb4580e34f 100644 --- a/src/utilities/sequence-editor/sequence-linter.ts +++ b/src/utilities/sequence-editor/sequence-linter.ts @@ -1,5 +1,6 @@ import { syntaxTree } from '@codemirror/language'; import { type Diagnostic } from '@codemirror/lint'; +import { EditorState } from '@codemirror/state'; import type { SyntaxNode, Tree } from '@lezer/common'; import type { ChannelDictionary, @@ -20,6 +21,7 @@ import { TimeTypes } from '../../enums/time'; import { getGlobals, sequenceAdaptation } from '../../stores/sequence-adaptation'; import { CustomErrorCodes } from '../../workers/customCodes'; import { addDefaultArgs, quoteEscape } from '../codemirror/codemirror-utils'; +import { closeSuggestion, computeBlocks, openSuggestion } from '../codemirror/custom-folder'; import { getBalancedDuration, getDoyTime, @@ -55,18 +57,6 @@ function closestStrings(value: string, potentialMatches: string[], n: number) { return distances.slice(0, n).map(pair => pair.s); } -type WhileOpener = { - command: SyntaxNode; - from: number; - stemToClose: string; - to: number; - word: string; -}; - -type IfOpener = WhileOpener & { - hasElse: boolean; -}; - type VariableMap = { [name: string]: VariableDeclaration; }; @@ -162,7 +152,7 @@ export function sequenceLinter( ); diagnostics.push( - ...conditionalAndLoopKeywordsLinter(treeNode.getChild('Commands')?.getChildren(TOKEN_COMMAND) || [], docText), + ...conditionalAndLoopKeywordsLinter(treeNode.getChild('Commands')?.getChildren(TOKEN_COMMAND) || [], view.state), ); const inputLinter = get(sequenceAdaptation)?.inputFormat.linter; @@ -199,118 +189,50 @@ function validateParserErrors(tree: Tree) { return diagnostics; } -function conditionalAndLoopKeywordsLinter(commandNodes: SyntaxNode[], text: string): Diagnostic[] { +function conditionalAndLoopKeywordsLinter(commandNodes: SyntaxNode[], state: EditorState): Diagnostic[] { const diagnostics: Diagnostic[] = []; - const conditionalStack: IfOpener[] = []; - const loopStack: WhileOpener[] = []; - const conditionalKeywords = []; - const loopKeywords = []; - const sequenceAdaptationConditionalKeywords = get(sequenceAdaptation).conditionalKeywords; - const sequenceAdaptationLoopKeywords = get(sequenceAdaptation).loopKeywords; - - conditionalKeywords.push( - sequenceAdaptationConditionalKeywords?.else, - ...(sequenceAdaptationConditionalKeywords?.elseIf ?? []), - sequenceAdaptationConditionalKeywords?.endIf, - ); - loopKeywords.push( - sequenceAdaptationLoopKeywords?.break, - sequenceAdaptationLoopKeywords?.continue, - sequenceAdaptationLoopKeywords?.endWhileLoop, - ); - for (const command of commandNodes) { - const stem = command.getChild('Stem'); - if (stem) { - const word = text.slice(stem.from, stem.to); + const blocks = computeBlocks(state); - if (sequenceAdaptationConditionalKeywords?.if.includes(word)) { - conditionalStack.push({ - command, - from: stem.from, - hasElse: false, - stemToClose: sequenceAdaptationConditionalKeywords.endIf, - to: stem.to, - word, + if (blocks) { + const pairs = Object.values(blocks); + pairs.forEach(pair => { + if (!pair.start && pair.end) { + const stem = state.sliceDoc(pair.end.from, pair.end.to); + diagnostics.push({ + from: pair.end.from, + message: `${stem} must match a preceding ${openSuggestion(stem)}`, + severity: 'error', + to: pair.end.to, }); - } - - if (conditionalKeywords.includes(word)) { - if (conditionalStack.length === 0) { - diagnostics.push({ - from: stem.from, - message: `${word} doesn't match a preceding ${sequenceAdaptationConditionalKeywords?.if.join(', ')}.`, - severity: 'error', - to: stem.to, - }); - } else if (word === sequenceAdaptationConditionalKeywords?.else) { - if (!conditionalStack[conditionalStack.length - 1].hasElse) { - conditionalStack[conditionalStack.length - 1].hasElse = true; - } else { - diagnostics.push({ - from: stem.from, - message: `${word} doesn't match a preceding ${sequenceAdaptationConditionalKeywords?.if.join(', ')}.`, - severity: 'error', - to: stem.to, - }); - } - } else if (word === sequenceAdaptationConditionalKeywords?.endIf) { - conditionalStack.pop(); - } - } - - if (sequenceAdaptationLoopKeywords?.whileLoop.includes(word)) { - loopStack.push({ - command, - from: stem.from, - stemToClose: sequenceAdaptationLoopKeywords.endWhileLoop, - to: stem.to, - word, + } else if (pair.start && !pair.end) { + const stem = state.sliceDoc(pair.start.from, pair.start.to); + const suggestion = closeSuggestion(stem); + diagnostics.push({ + actions: [ + { + apply(view: EditorView) { + if (pair.start?.parent) { + view.dispatch({ + changes: { + from: pair.start?.parent.to, + insert: `\nC ${suggestion}\n`, + }, + }); + } + }, + name: `Insert ${suggestion}`, + }, + ], + from: pair.start.from, + message: `Block opened by ${stem} is not closed`, + severity: 'error', + to: pair.start.to, }); } - - if (loopKeywords.includes(word)) { - if (loopStack.length === 0) { - diagnostics.push({ - from: stem.from, - message: `${word} doesn't match a preceding ${sequenceAdaptationLoopKeywords?.whileLoop.join(', ')}.`, - severity: 'error', - to: stem.to, - }); - } - - if (word === sequenceAdaptationLoopKeywords?.endWhileLoop) { - loopStack.pop(); - } - } - } + }); } - // Anything left on the stack is unclosed - diagnostics.push( - ...[...loopStack, ...conditionalStack].map(block => { - return { - actions: [ - { - apply(view: EditorView, _from: number, _to: number) { - view.dispatch({ - changes: { - from: block.command.to, - insert: `\nC ${block.stemToClose}\n`, - }, - }); - }, - name: `Insert ${block.stemToClose}`, - }, - ], - from: block.from, - message: `Unclosed ${block.word}`, - severity: 'error', - to: block.to, - } as const; - }), - ); - return diagnostics; } @@ -555,7 +477,7 @@ function validateCustomDirectives(node: SyntaxNode, text: string): Diagnostic[] function insertAction(name: string, insert: string) { return { - apply(view: EditorView, from: number, _to: number) { + apply(view: EditorView, from: number) { view.dispatch({ changes: { from, insert } }); }, name, @@ -1077,7 +999,7 @@ function validateAndLintArguments( diagnostics.push({ actions: [ { - apply(view, _from, _to) { + apply(view) { if (commandDictionary) { addDefaultArgs(commandDictionary, view, command, dictArgs.slice(argNode.length)); }