diff --git a/src/components/sequencing/ArgumentTooltip.svelte b/src/components/sequencing/ArgumentTooltip.svelte index c75fab673a..cedf602ff1 100644 --- a/src/components/sequencing/ArgumentTooltip.svelte +++ b/src/components/sequencing/ArgumentTooltip.svelte @@ -5,13 +5,20 @@ import { getAllEnumSymbols } from '../../utilities/sequence-editor/sequence-linter'; export let arg: FswCommandArgument; - export let commandDictionary: CommandDictionary; + export let commandDictionary: CommandDictionary | null; - let enumSymbolsDisplayStr: string; + const MAX_ENUMS_TO_DISPLAY = 20; + + let enumSymbolsDisplayStr: string = ''; $: if (commandDictionary && arg?.arg_type === 'enum') { const enumValues = getAllEnumSymbols(commandDictionary.enumMap, arg.enum_name); - enumSymbolsDisplayStr = enumValues?.join(' | ') ?? ''; + const values = enumValues ?? []; + enumSymbolsDisplayStr = values.slice(0, MAX_ENUMS_TO_DISPLAY).join(' | '); + const numHiddenValues = values.length - MAX_ENUMS_TO_DISPLAY; + if (numHiddenValues > 0) { + enumSymbolsDisplayStr += ` ... ${numHiddenValues} more`; + } } diff --git a/src/components/sequencing/SequenceEditor.svelte b/src/components/sequencing/SequenceEditor.svelte index 2965095f1d..99769a16a1 100644 --- a/src/components/sequencing/SequenceEditor.svelte +++ b/src/components/sequencing/SequenceEditor.svelte @@ -1,12 +1,11 @@ - + {title}
+ + Copy Download -
menu.toggle()}> - - - - {#each outputFormats as outputFormatItem} -
- - - {outputFormatItem?.name} - -
- -
- downloadOutputFormat(outputFormatItem)} disabled={disableCopyAndExport}> - - {outputFormatItem?.name} - -
- {/each} -
-
+ {#if showOutputs} +
menu.toggle()}> + - {/if} -
- + + - -
- - - - - - - {selectedOutputFormat?.name} (Read-only) - -
- {#if outputFormats} -
- - +
- {/if} - + {#if selectedOutputFormat?.compile} + + {/if} + {/if}
-
+
+ + {#if showOutputs} + + + + {selectedOutputFormat?.name} (Read-only) + +
+ {#if outputFormats} +
+ + +
+ {/if} + + +
+
+ + +
+ + + {/if} @@ -526,6 +542,7 @@ node={selectedNode} {channelDictionary} {commandDictionary} + {commandInfoMapper} {editorSequenceView} {parameterDictionaries} /> diff --git a/src/components/sequencing/SequenceForm.svelte b/src/components/sequencing/SequenceForm.svelte index 1a24331475..c682ec547e 100644 --- a/src/components/sequencing/SequenceForm.svelte +++ b/src/components/sequencing/SequenceForm.svelte @@ -6,7 +6,6 @@ import { base } from '$app/paths'; import type { ParameterDictionary } from '@nasa-jpl/aerie-ampcs'; import { SearchParameters } from '../../enums/searchParameters'; - import { outputFormat } from '../../stores/sequence-adaptation'; import { parameterDictionaries as parameterDictionariesStore, parcelToParameterDictionaries, @@ -102,7 +101,7 @@ } }); - const [output, parsedChannelDictionary, ...parsedParameterDictionaries] = await Promise.all([ + const [fileNameAndContents, parsedChannelDictionary, ...parsedParameterDictionaries] = await Promise.all([ parseOutputFromFile(e.currentTarget.files), parcel?.channel_dictionary_id ? effects.getParsedAmpcsChannelDictionary(parcel?.channel_dictionary_id, user) @@ -112,9 +111,11 @@ }), ]); - if (output !== null) { + if (fileNameAndContents !== null) { + sequenceName = fileNameAndContents.name; + const sequence = await toInputFormat( - output, + fileNameAndContents.contents, parsedParameterDictionaries.filter((pd): pd is ParameterDictionary => pd !== null), parsedChannelDictionary, ); @@ -146,13 +147,15 @@ } } - async function parseOutputFromFile(files: FileList | null | undefined): Promise { + async function parseOutputFromFile( + files: FileList | null | undefined, + ): Promise<{ contents: string; name: string } | null> { if (files) { const file = files.item(0); if (file) { try { - return await file.text(); + return { contents: await file.text(), name: file.name }; } catch (e) { const errorMessage = (e as Error).message; logError(errorMessage); @@ -184,9 +187,9 @@ const newSequenceId = await effects.createUserSequence(newSequence, user); if (newSequenceId !== null) { - goto( - `${base}/sequencing/edit/${newSequenceId}${'?' + SearchParameters.WORKSPACE_ID + '=' + getSearchParameterNumber(SearchParameters.WORKSPACE_ID) ?? ''}`, - ); + const newSequenceUrl = `${base}/sequencing/edit/${newSequenceId}`; + const workspaceId = getSearchParameterNumber(SearchParameters.WORKSPACE_ID); + goto(newSequenceUrl + (workspaceId !== null ? `?${SearchParameters.WORKSPACE_ID}=${workspaceId}` : '')); } } else if (mode === 'edit' && sequenceId !== null) { const updatedSequence: Partial = { @@ -220,10 +223,11 @@
@@ -296,7 +300,7 @@
- + import type { SyntaxNode } from '@lezer/common'; import type { CommandDictionary, FswCommandArgument } from '@nasa-jpl/aerie-ampcs'; + import type { CommandInfoMapper } from '../../../utilities/codemirror/commandInfoMapper'; import { getMissingArgDefs, isFswCommandArgumentBoolean, @@ -24,6 +25,7 @@ export let commandDictionary: CommandDictionary; export let setInEditor: (token: SyntaxNode, val: string) => void; export let addDefaultArgs: (commandNode: SyntaxNode, argDefs: FswCommandArgument[]) => void; + export let commandInfoMapper: CommandInfoMapper; $: enableRepeatAdd = argInfo.argDef && @@ -56,7 +58,7 @@ {:else} {#if argInfo.argDef.arg_type === 'enum' && argInfo.node} - {#if argInfo.node?.name === 'String'} + {#if commandInfoMapper.nodeTypeEnumCompatible(argInfo.node)} {/if} - {:else if isNumberArg(argInfo.argDef) && argInfo.node?.name === 'Number'} + {:else if isNumberArg(argInfo.argDef) && commandInfoMapper.nodeTypeNumberCompatible(argInfo.node ?? null)} + {/if} {/each} {#if argInfo.children.find(childArgInfo => !childArgInfo.node)} diff --git a/src/components/sequencing/form/SelectedCommand.svelte b/src/components/sequencing/form/SelectedCommand.svelte index 77dd7a9c82..0c1feff70f 100644 --- a/src/components/sequencing/form/SelectedCommand.svelte +++ b/src/components/sequencing/form/SelectedCommand.svelte @@ -14,7 +14,7 @@ import type { EditorView } from 'codemirror'; import { debounce } from 'lodash-es'; import { TOKEN_ERROR } from '../../../constants/seq-n-grammar-constants'; - import { getAncestorStepOrRequest, getNameNode } from '../../../utilities/codemirror/seq-n-tree-utils'; + import type { CommandInfoMapper } from '../../../utilities/codemirror/commandInfoMapper'; import { getCustomArgDef } from '../../../utilities/sequence-editor/extension-points'; import Collapse from '../../Collapse.svelte'; import Panel from '../../ui/Panel.svelte'; @@ -36,22 +36,24 @@ export let commandDictionary: CommandDictionary; export let parameterDictionaries: ParameterDictionary[]; export let node: SyntaxNode | null; + export let commandInfoMapper: CommandInfoMapper; const ID_COMMAND_DETAIL_PANE = 'ID_COMMAND_DETAIL_PANE'; let argInfoArray: ArgTextDef[] = []; let commandNode: SyntaxNode | null = null; + let commandNameNode: SyntaxNode | null = null; let commandDef: FswCommand | null = null; let editorArgInfoArray: ArgTextDef[] = []; let missingArgDefArray: FswCommandArgument[] = []; let timeTagNode: TimeTagInfo = null; - $: commandNode = getAncestorStepOrRequest(node); - $: commandNameNode = getNameNode(commandNode); + $: commandNode = commandInfoMapper.getContainingCommand(node); + $: commandNameNode = commandInfoMapper.getNameNode(commandNode); $: commandName = commandNameNode && editorSequenceView.state.sliceDoc(commandNameNode.from, commandNameNode.to); $: commandDef = getCommandDef(commandDictionary, commandName ?? ''); $: argInfoArray = getArgumentInfo( - commandNode?.getChild('Args') ?? null, + commandInfoMapper.getArgumentNodeContainer(commandNode), commandDef?.arguments, undefined, parameterDictionaries, @@ -78,22 +80,25 @@ parameterDictionaries: ParameterDictionary[], ) { const argArray: ArgTextDef[] = []; - let node = args?.firstChild; - const precedingArgValues: string[] = []; + const parentRepeatLength = parentArgDef?.repeat?.arguments.length; + + if (args) { + for (const node of commandInfoMapper.getArgumentsFromContainer(args)) { + if (node.name === TOKEN_ERROR) { + continue; + } + + let argDef: FswCommandArgument | undefined = undefined; + if (argumentDefs) { + let argDefIndex = argArray.length; + if (parentRepeatLength !== undefined) { + // for repeat args shift index + argDefIndex %= parentRepeatLength; + } + argDef = argumentDefs[argDefIndex]; + } - // loop through nodes in editor and pair with definitions - while (node) { - // TODO - Consider early out on grammar error as higher chance of argument mismatch - // skip error tokens in grammar and try to give best guess at what argument we're on - if (node.name !== TOKEN_ERROR) { - let argDef = - argumentDefs && - argumentDefs[ - parentArgDef?.repeat?.arguments.length !== undefined - ? argArray.length % parentArgDef?.repeat?.arguments.length - : argArray.length - ]; if (commandDef && argDef) { argDef = getCustomArgDef( commandDef?.stem, @@ -118,9 +123,7 @@ }); precedingArgValues.push(argValue); } - node = node.nextSibling; } - // add entries for defined arguments missing from editor if (argumentDefs) { if (!parentArgDef) { @@ -138,7 +141,7 @@ return argArray; } - function getCommandDef(commandDictionary: CommandDictionary | null, stemName: string) { + function getCommandDef(commandDictionary: CommandDictionary | null, stemName: string): FswCommand | null { return commandDictionary?.fswCommandMap[stemName] ?? null; } @@ -204,7 +207,7 @@
{/if} {#if !!commandNode} - {#if commandNode.name === 'Command'} + {#if commandInfoMapper.nodeTypeHasArguments(commandNode)} {#if !!commandDef}
{commandDef.description} @@ -214,9 +217,16 @@ - addDefaultArgs(commandDictionary, editorSequenceView, commandNode, missingArgDefArray)} + addDefaultArgs( + commandDictionary, + editorSequenceView, + commandNode, + missingArgDefArray, + commandInfoMapper, + )} /> {/each} @@ -225,7 +235,13 @@ { if (commandNode) { - addDefaultArgs(commandDictionary, editorSequenceView, commandNode, missingArgDefArray); + addDefaultArgs( + commandDictionary, + editorSequenceView, + commandNode, + missingArgDefArray, + commandInfoMapper, + ); } }} /> diff --git a/src/constants/seq-n-grammar-constants.ts b/src/constants/seq-n-grammar-constants.ts index d7748d85e6..7480f4dc64 100644 --- a/src/constants/seq-n-grammar-constants.ts +++ b/src/constants/seq-n-grammar-constants.ts @@ -1,3 +1,11 @@ +export const RULE_ARGS = 'Args'; +export const RULE_SEQUENCE_NAME = 'SequenceName'; +export const RULE_GROUND_NAME = 'GroundName'; +export const RULE_STEM = 'Stem'; +export const RULE_REQUEST_NAME = 'RequestName'; +export const RULE_COMMAND = 'Command'; + +// Some of these are rules, not tokens consider renaming export const TOKEN_ACTIVATE = 'Activate'; export const TOKEN_GROUND_BLOCK = 'GroundBlock'; export const TOKEN_GROUND_EVENT = 'GroundEvent'; @@ -6,3 +14,5 @@ export const TOKEN_COMMAND = 'Command'; export const TOKEN_REQUEST = 'Request'; export const TOKEN_REPEAT_ARG = 'RepeatArg'; export const TOKEN_ERROR = '⚠'; +export const TOKEN_STRING = 'String'; +export const TOKEN_NUMBER = 'Number'; diff --git a/src/stores/sequence-adaptation.ts b/src/stores/sequence-adaptation.ts index e70d301f71..1e83e2b1e9 100644 --- a/src/stores/sequence-adaptation.ts +++ b/src/stores/sequence-adaptation.ts @@ -12,24 +12,12 @@ const defaultAdaptation: ISequenceAdaptation = { argDelegator: undefined, autoComplete: sequenceCompletion, autoIndent: sequenceAutoIndent, - conditionalKeywords: { - else: 'CMD_ELSE', - elseIf: ['CMD_ELSE_IF'], - endIf: 'CMD_END_IF', - if: ['CMD_IF'], - }, globals: [], inputFormat: { linter: undefined, name: 'SeqN', toInputFormat: seqJsonToSequence, }, - loopKeywords: { - break: 'CMD_BREAK', - continue: 'CMD_CONTINUE', - endWhileLoop: 'CMD_END_WHILE_LOOP', - whileLoop: ['CMD_WHILE_LOOP', 'CMD_WHILE_LOOP_OR'], - }, modifyOutput: undefined, modifyOutputParse: undefined, outputFormat: [ @@ -64,29 +52,17 @@ export function getGlobals(): GlobalType[] { return get(sequenceAdaptation).globals ?? []; } -export function setSequenceAdaptation(newSequenceAdaptation: ISequenceAdaptation | undefined): void { +export function setSequenceAdaptation(newSequenceAdaptation: Partial | undefined): void { sequenceAdaptation.set({ argDelegator: newSequenceAdaptation?.argDelegator ?? defaultAdaptation.argDelegator, autoComplete: newSequenceAdaptation?.autoComplete ?? defaultAdaptation.autoComplete, autoIndent: newSequenceAdaptation?.autoIndent ?? defaultAdaptation.autoIndent, - conditionalKeywords: { - else: newSequenceAdaptation?.conditionalKeywords?.else ?? defaultAdaptation.conditionalKeywords.else, - elseIf: newSequenceAdaptation?.conditionalKeywords?.elseIf ?? defaultAdaptation.conditionalKeywords.elseIf, - endIf: newSequenceAdaptation?.conditionalKeywords?.endIf ?? defaultAdaptation.conditionalKeywords.endIf, - if: newSequenceAdaptation?.conditionalKeywords?.if ?? defaultAdaptation.conditionalKeywords.if, - }, globals: newSequenceAdaptation?.globals ?? defaultAdaptation.globals, inputFormat: { linter: newSequenceAdaptation?.inputFormat?.linter ?? defaultAdaptation.inputFormat.linter, name: newSequenceAdaptation?.inputFormat?.name ?? defaultAdaptation.inputFormat.name, toInputFormat: newSequenceAdaptation?.inputFormat?.toInputFormat ?? defaultAdaptation.inputFormat.toInputFormat, }, - loopKeywords: { - break: newSequenceAdaptation?.loopKeywords?.break ?? defaultAdaptation.loopKeywords.break, - continue: newSequenceAdaptation?.loopKeywords?.continue ?? defaultAdaptation.loopKeywords.continue, - endWhileLoop: newSequenceAdaptation?.loopKeywords?.endWhileLoop ?? defaultAdaptation.loopKeywords.endWhileLoop, - whileLoop: newSequenceAdaptation?.loopKeywords?.whileLoop ?? defaultAdaptation.loopKeywords.whileLoop, - }, modifyOutput: newSequenceAdaptation?.modifyOutput ?? defaultAdaptation.modifyOutput, modifyOutputParse: newSequenceAdaptation?.modifyOutputParse ?? defaultAdaptation.modifyOutputParse, outputFormat: newSequenceAdaptation?.outputFormat ?? defaultAdaptation.outputFormat, diff --git a/src/tests/mocks/sequencing/dictionaries/CDL_1.0.0_REV_M00 b/src/tests/mocks/sequencing/dictionaries/CDL_1.0.0_REV_M00 new file mode 100644 index 0000000000..de1e748d3b --- /dev/null +++ b/src/tests/mocks/sequencing/dictionaries/CDL_1.0.0_REV_M00 @@ -0,0 +1,74 @@ +! +! This is an example of a CDL dictionary. +! Flight dictionaries are not suitable for release +PROJECT : "Aerie" +VERSION : "1.0.0" +FIELD DELIMITER :, +COMMAND DELIMITER :; +BRACKETS :() +MAXIMUM COMMANDS PER MESSAGE: 40 +MAXIMUM BITS PER MESSAGE: 2304 + +SPACECRAFT LITERALS + CONVERSION : HEX + LENGTH : 10 + + 1 = '1' +END SPACECRAFT LITERALS + +LOOKUP ARGUMENT : lookup_arg_1 + TITLE : "has multiple choices" + CONVERSION : HEX + LENGTH : 32 + 'CHOOSE_A' = '0' + 'CHOOSE_B' = '1' + 'CHOOSE_C' = '2' +END LOOKUP ARGUMENT + +NUMERIC ARGUMENT : numeric_arg_1 + TITLE : "a numeric arg whose default is in hexadecimal" + CONVERSION : HEX + LENGTH : 8 + DEFAULT : 'F' + '00' to 'FF' +END NUMERIC ARGUMENT + +NUMERIC ARGUMENT : numeric_arg_2 + TITLE : "a numeric arg with a default" + CONVERSION : DECIMAL + LENGTH : 8 + DEFAULT : '50' + '0' to '100' +END NUMERIC ARGUMENT + +! in limited examples global arguments preceded stem definitions + + +STEM : CMD_EXECUTE () + +READ ARGUMENT lookup_arg_1 +READ ARGUMENT numeric_arg_1 + +END STEM + +STEM : CMD_DEBUG () + +LOOKUP ARGUMENT : lookup_local_arg_1 + TITLE : "Only used by stem CMD_DEBUG" + CONVERSION : HEX + LENGTH : 3 + 'MODE_A' = '0' + 'MODE_B' = '1' + 'MODE_C' = '2' +END LOOKUP ARGUMENT + +READ ARGUMENT numeric_arg_2 +READ ARGUMENT lookup_local_arg_1 + +!@ ATTACHMENT : desc +!@ "This command has a description" +!@ END ATTACHMENT + + +END STEM + diff --git a/src/types/sequencing.ts b/src/types/sequencing.ts index c8e356cb05..f65e136967 100644 --- a/src/types/sequencing.ts +++ b/src/types/sequencing.ts @@ -65,8 +65,7 @@ export interface ISequenceAdaptation { commandDictionary: AmpcsCommandDictionary | null, parameterDictionaries: AmpcsParameterDictionary[], ) => (context: CompletionContext) => CompletionResult | null; - autoIndent: () => (context: IndentContext, pos: number) => number | null | undefined; - conditionalKeywords: { else: string; elseIf: string[]; endIf: string; if: string[] }; + autoIndent?: () => (context: IndentContext, pos: number) => number | null | undefined; globals?: GlobalType[]; inputFormat: { linter?: ( @@ -78,7 +77,6 @@ export interface ISequenceAdaptation { name: string; toInputFormat?(input: string): Promise; }; - loopKeywords: { break: string; continue: string; endWhileLoop: string; whileLoop: string[] }; modifyOutput?: ( output: string, parameterDictionaries: AmpcsParameterDictionary[], diff --git a/src/utilities/codemirror/cdlDictionary.test.ts b/src/utilities/codemirror/cdlDictionary.test.ts new file mode 100644 index 0000000000..a3c550721b --- /dev/null +++ b/src/utilities/codemirror/cdlDictionary.test.ts @@ -0,0 +1,198 @@ +import { + type FswCommandArgumentEnum, + type FswCommandArgumentInteger, + type FswCommandArgumentVarString, +} from '@nasa-jpl/aerie-ampcs'; +import { readFileSync } from 'fs'; +import { describe, expect, test } from 'vitest'; +import { parseCdlDictionary, toAmpcsXml } from './cdlDictionary'; + +const cdlString = `! +! Example dictionary +! for demo purposes the first line is required to be an empty comment +PROJECT : "Unit_test" +VERSION : "1.2.3.4" +FIELD DELIMITER :, +COMMAND DELIMITER :; +BRACKETS :() +MAXIMUM COMMANDS PER MESSAGE: 40 +MAXIMUM BITS PER MESSAGE: 2304 + +SITES + EXAMPLE_SITE = 0 +END SITES + +CLASSIFICATIONS : cmd-type + CAT1 + CAT2 +END CLASSIFICATIONS + + +SPACECRAFT LITERALS + CONVERSION : HEX + LENGTH : 10 + 255 = 'FF' +END SPACECRAFT LITERALS + +LOOKUP ARGUMENT : arg_0_lookup + TITLE : "Used in commands" + CONVERSION : HEX + LENGTH : 32 + 'CHOICE_A' = '0' + 'CHOICE_B' = '1' + 'CHOICE_C' = '2' +END LOOKUP ARGUMENT + +NUMERIC ARGUMENT : arg_1_number + TITLE : "Used in 1 commands" + CONVERSION : SIGNED DECIMAL + LENGTH : 16 + '-255' to '255' +END NUMERIC ARGUMENT + +NUMERIC ARGUMENT : arg_2_string + TITLE : "Used in 13 commands" + CONVERSION : ASCII_STRING + LENGTH : 312 +END NUMERIC ARGUMENT + +NUMERIC ARGUMENT : arg_3_string + TITLE : "Used in 2 commands" + CONVERSION : ASCII_STRING + LENGTH : 1024 + DEFAULT : '' +END NUMERIC ARGUMENT + +NUMERIC ARGUMENT : arg_4_number_units + TITLE : "Used in 1 commands" + CONVERSION : DECIMAL + LENGTH : 32 + '1' to '500000000' : "Rows" +END NUMERIC ARGUMENT + +NUMERIC ARGUMENT : arg_5_number_complex_units + TITLE : "Used in 1 commands" + CONVERSION : DECIMAL + LENGTH : 16 + '0' to '3600' : "Pckts/Hr" +END NUMERIC ARGUMENT + +STEM : CAT1_TEST_COMMAND1 ( ccode : 16, data : 0 ) + +cmd-type : CAT1 + +READ ARGUMENT arg_0_lookup + +ccode := [ 16 ] '0001' HEX + +!@ ATTACHMENT : desc +!@ "Test Command with lookup argument" +!@ END ATTACHMENT + +END STEM + +STEM : CAT1_TEST_COMMAND_WITH_3ARGS ( ccode : 16, data : 0 ) + +cmd-type : CAT1 + +READ ARGUMENT arg_0_lookup +READ ARGUMENT arg_1_number +READ ARGUMENT arg_2_string +READ ARGUMENT arg_3_string +READ ARGUMENT arg_4_number_units +READ ARGUMENT arg_5_number_complex_units + +ccode := [ 16 ] '0002' HEX + +!@ ATTACHMENT : desc +!@ "Test Command with 3 arguments" +!@ END ATTACHMENT + +END STEM + + +STEM : CAT2_STEM_INLINE_ARG ( ccode : 16, data : 0 ) + +cmd-type : CAT2 + +LOOKUP ARGUMENT : arg_inline + TITLE : "Used in 1 commands" + CONVERSION : HEX + LENGTH : 8 + 'AAA' = '0' + 'BBB' = '1' + 'CCC' = '2' + 'DDD' = '3' +END LOOKUP ARGUMENT + +READ ARGUMENT arg_inline + +ccode := [ 16 ] '0002' HEX + +!@ ATTACHMENT : desc +!@ "Test Command with inline argument" +!@ END ATTACHMENT + +END STEM + + +`; + +describe('cdl parse tests', async () => { + test('inline definition', () => { + const cdlDictionary = parseCdlDictionary(cdlString); + expect(cdlDictionary.header.mission_name).toBe('Unit_test'); + expect(cdlDictionary.header.spacecraft_ids).toEqual([255]); + + expect(cdlDictionary.fswCommands.length).toBe(3); + + expect(cdlDictionary.fswCommands[1].arguments.length).toBe(6); + const arg1Range = (cdlDictionary.fswCommands[1].arguments[1] as FswCommandArgumentInteger).range; + expect(arg1Range?.min).toBe(-255); + expect(arg1Range?.max).toBe(255); + + const arg_2_string: FswCommandArgumentVarString = cdlDictionary.fswCommands[1].argumentMap + .arg_2_string as FswCommandArgumentVarString; + expect(arg_2_string.arg_type).toBe('var_string'); + expect(arg_2_string.max_bit_length).toBe(312); + + expect(cdlDictionary.fswCommands[1].description).toEqual('Test Command with 3 arguments'); + + // console.log(JSON.stringify(cdlDictionary, null, 2)); + }); + + test('round trip', () => { + const cdlDictionary = parseCdlDictionary(cdlString); + const xmlDictionary = toAmpcsXml(cdlDictionary); + if (xmlDictionary) { + // expect(JSON.stringify(parse(xmlDictionary), null, 2)).toBe(JSON.stringify(cdlDictionary, null, 2)); + } + }); + + test('load from file', () => { + const path = 'src/tests/mocks/sequencing/dictionaries/CDL_1.0.0_REV_M00'; + + const contents = readFileSync(path, 'utf-8'); + + const cdlDictionary = parseCdlDictionary(contents); + + expect(cdlDictionary.header.mission_name).toBe('Aerie'); + expect(cdlDictionary.header.spacecraft_ids).toEqual([1]); + + expect(cdlDictionary.fswCommands.length).toBe(2); + + const cmd_0 = cdlDictionary.fswCommands[0]; + expect(cmd_0.arguments.length).toBe(2); + + const cmd_1 = cdlDictionary.fswCommands[1]; + expect(cmd_1.arguments.length).toBe(2); + + const cmd_1_arg_1 = cmd_1.argumentMap.numeric_arg_2 as FswCommandArgumentInteger; + expect(cmd_1_arg_1.arg_type).toBe('integer'); + + const localEnum = cmd_1.argumentMap.lookup_local_arg_1 as FswCommandArgumentEnum; + expect(localEnum.arg_type).toBe('enum'); + expect(localEnum.range).toEqual(['MODE_A', 'MODE_B', 'MODE_C']); + expect(localEnum.description).toBe('Only used by stem CMD_DEBUG'); + }); +}); diff --git a/src/utilities/codemirror/cdlDictionary.ts b/src/utilities/codemirror/cdlDictionary.ts new file mode 100644 index 0000000000..0eb936e55e --- /dev/null +++ b/src/utilities/codemirror/cdlDictionary.ts @@ -0,0 +1,491 @@ +import type { + CommandDictionary, + Enum, + EnumValue, + FswCommand, + FswCommandArgument, + FswCommandArgumentEnum, + FswCommandArgumentMap, + Header, + NumericRange, +} from '@nasa-jpl/aerie-ampcs'; + +const START_LOOKUP_ARG = /^\s*LOOKUP ARGUMENT\s*:\s*(\w+)\s*$/; +const END_LOOKUP_ARG = /^\s*END\s+LOOKUP\s+ARGUMENT\s*$/; +const START_NUMERIC_ARG = /^\s*NUMERIC ARGUMENT\s*:\s*(\w+)\s*$/; +const END_NUMERIC_ARG = /^\s*END\s+NUMERIC\s+ARGUMENT\s*$/; +const START_STEM = /^\s*STEM\s*:\s*(\w+)\s*\(.*\)\s*$/; +const END_STEM = /^\s*END\s*STEM\s*$/; +const CONVERSION = /^\s*CONVERSION\s*:\s*(\w+(?:\s+\w+)?)/; +const ARG_TITLE = /^\s*TITLE\s*:\s*"(.*)"/; + +export function parseCdlDictionary(contents: string, id?: string, path?: string): CommandDictionary { + const lines = contents.split('\n').filter(line => line.trim()); + + let mission_name = ''; + let version = ''; + const spacecraft_ids: number[] = []; + + const lineIterator = lines.values(); + + for (const line of lineIterator) { + const projectMatch = line.match(/^PROJECT\s*:\s*"([^"]*)"/); + if (projectMatch) { + mission_name = projectMatch[1]; + break; + } + } + + for (const line of lineIterator) { + const versionMatch = line.match(/^VERSION\s*:\s*"([^"]+)"/); + if (versionMatch) { + version = versionMatch[1]; + break; + } + } + + SC_LITERALS_LOOP: for (const line of lineIterator) { + if (/^\s*SPACECRAFT\s+LITERALS/.test(line)) { + for (const childLine of lineIterator) { + if (/^\s*END\s+SPACECRAFT\s+LITERALS/.test(childLine)) { + break SC_LITERALS_LOOP; + } + const spacecraftIdMatch = childLine.match(/^\s*(\d+)\s*=\s*'[\dA-Fa-f]+'/); + if (spacecraftIdMatch) { + spacecraft_ids.push(parseInt(spacecraftIdMatch[1])); + } + } + } + } + + const header: Readonly
= { + mission_name, + schema_version: '1.0', + spacecraft_ids, + version, + }; + + const enums: Enum[] = []; + + const globalArguments: FswCommandArgumentMap = {}; + const fswCommands: FswCommand[] = []; + // parse globals and stems + // assumes all global arguments are defined prior to stems + for (const line of lineIterator) { + if (line.match(START_LOOKUP_ARG)) { + const lookupLines: string[] = [line]; + for (const lineOfLookup of lineIterator) { + lookupLines.push(lineOfLookup); + if (lineOfLookup.match(END_LOOKUP_ARG)) { + const [lookupArg, lookupEnum] = parseLookupArgument(lookupLines); + // empty enums aren't allowed in ampcs dictionary format + if (lookupEnum.values.length) { + enums.push(lookupEnum); + globalArguments[lookupArg.name] = lookupArg; + } + break; + } + } + } else if (line.match(START_NUMERIC_ARG)) { + const numericLines: string[] = [line]; + for (const lineOfNumeric of lineIterator) { + numericLines.push(lineOfNumeric); + if (lineOfNumeric.match(END_NUMERIC_ARG)) { + const numericArgument = parseNumericArgument(numericLines); + globalArguments[numericArgument.name] = numericArgument; + break; + } + } + } else if (line.match(START_STEM)) { + const numericLines: string[] = [line]; + for (const stemLine of lineIterator) { + numericLines.push(stemLine); + if (stemLine.match(END_STEM)) { + const [cmd, cmdEnums] = parseStem(numericLines, globalArguments); + fswCommands.push(cmd); + enums.push(...cmdEnums); + break; + } + } + } + } + + return { + enumMap: Object.fromEntries(enums.map(e => [e.name, e])), + enums, + fswCommandMap: Object.fromEntries(fswCommands.map(cmd => [cmd.stem, cmd])), + fswCommands, + header, + hwCommandMap: {}, + hwCommands: [], + id: id ?? '', + path: path ?? '', + }; +} + +export function parseStem(lines: string[], globalArguments: FswCommandArgumentMap): [FswCommand, Enum[]] { + let stem = ''; + for (const line of lines) { + const m = line.match(START_STEM); + if (m) { + stem = m[1]; + break; + } + } + + let description = ''; + const descriptionLineNumber = lines.findIndex(line => /!@\s+ATTACHMENT\s*:\s*desc/.test(line)) + 1; + const descriptionLineMatch = lines.at(descriptionLineNumber)?.match(/^!@\s*"(.*)"\s*$/); + if (descriptionLineMatch) { + description = descriptionLineMatch[1]; + } + + // stems may also have arguments defined inline + const localArguments: FswCommandArgumentMap = {}; + const localEnums: Enum[] = []; + const lineIterator = lines.values(); + for (const line of lineIterator) { + if (line.match(START_LOOKUP_ARG)) { + const lookupLines: string[] = [line]; + for (const lineOfLookup of lineIterator) { + lookupLines.push(lineOfLookup); + if (lineOfLookup.match(END_LOOKUP_ARG)) { + const [lookupArg, lookupEnum] = parseLookupArgument(lookupLines, stem); + // empty enums aren't allowed in ampcs dictionary format + if (lookupEnum.values.length) { + localEnums.push(lookupEnum); + localArguments[lookupArg.name] = lookupArg; + } + break; + } + } + } else if (line.match(START_NUMERIC_ARG)) { + const numericLines: string[] = [line]; + for (const lineOfNumeric of lineIterator) { + numericLines.push(lineOfNumeric); + if (lineOfNumeric.match(END_NUMERIC_ARG)) { + const numericArgument = parseNumericArgument(numericLines); + localArguments[numericArgument.name] = numericArgument; + break; + } + } + } + } + + const fswArguments: FswCommandArgument[] = []; + + for (const line of lines) { + const readArgMatch = line.match(/^\s*READ\s+ARGUMENT\s+(\w+)\s*/); + if (readArgMatch) { + const argName = readArgMatch[1]; + const argDef = localArguments[argName] ?? globalArguments[argName]; + if (argDef) { + fswArguments.push(argDef); + } + } + } + + return [ + { + argumentMap: Object.fromEntries(fswArguments.map(arg => [arg.name, arg])), + arguments: fswArguments, + description, + stem, + type: 'fsw_command', + }, + localEnums, + ]; +} + +export function parseNumericArgument(lines: string[]): FswCommandArgument { + const lineIterator = lines.values(); + + let name = ''; + for (const line of lineIterator) { + const m = line.match(START_NUMERIC_ARG); + if (m) { + name = m[1]; + break; + } + } + + let conversion = ''; + let range: NumericRange | null = null; + let bit_length: number | null = null; + let default_value_string: string | null = null; + let description: string = ''; + let default_value: number | null = null; + + for (const line of lines) { + if (line.match(END_NUMERIC_ARG)) { + break; + } else if (conversion) { + const rangeMatch = line.match(/^\s*'([-\w]+)'\s*to\s*'([-\w]+)/); + if (rangeMatch) { + if (conversion.includes('DECIMAL')) { + const defaultMatch = line.match(/^\s*DEFAULT\s*:\s*'(-?\d+)'/); + if (defaultMatch) { + default_value = parseInt(defaultMatch[1], 10); + } + range = { + max: parseInt(rangeMatch[2], 10), + min: parseInt(rangeMatch[1], 10), + }; + } else if (conversion === 'HEX') { + const defaultMatch = line.match(/^\s*DEFAULT\s*:\s*'([\dA-Fa-f]+)'/); + if (defaultMatch) { + default_value = parseInt(defaultMatch[1], 16); + } + range = { + max: parseInt(rangeMatch[2], 16), + min: parseInt(rangeMatch[1], 16), + }; + } else if (conversion === 'IEEE64FLOAT') { + const defaultMatch = line.match(/^\s*DEFAULT\s*:\s*'(.*)'/); + if (defaultMatch) { + default_value = parseFloat(defaultMatch[1]); + } + range = { + max: Number.MAX_VALUE, + min: Number.MIN_VALUE, + }; + } + } + if (conversion === 'ASCII_STRING') { + // LENGTH : 1024 + const maxBitMatch = line.match(/^\s*LENGTH\s*:\s*(\d+)/); + if (maxBitMatch) { + bit_length = parseInt(maxBitMatch[1], 10); + } + + // DEFAULT : '' + // doesn't handle escaped quotes + const defaultMatch = line.match(/^\s*DEFAULT\s*:\s*'(.*)'/); + if (defaultMatch) { + default_value_string = defaultMatch[1]; + } + } + } else { + const titleMatch = line.match(ARG_TITLE); + if (titleMatch) { + description = titleMatch[1].trim(); + } + + const conversionMatch = line.match(CONVERSION); + if (conversionMatch) { + conversion = conversionMatch[1].trim(); + } + } + } + + if (conversion === 'ASCII_STRING') { + return { + arg_type: 'var_string', + default_value: default_value_string, + description, + max_bit_length: bit_length, + name, + prefix_bit_length: null, + valid_regex: null, + }; + } else if (conversion.includes('DECIMAL') || conversion === 'HEX' || conversion === 'MPFTIME') { + return { + arg_type: 'integer', + bit_length, + default_value, + description, + name, + range, + units: '', + }; + } + + return { + arg_type: 'float', + bit_length, + default_value, + description, + name, + range, + units: '', + }; +} + +export function parseLookupArgument(lines: string[], namespace?: string): [FswCommandArgumentEnum, Enum] { + const lineIterator = lines.values(); + + let name = ''; + for (const line of lineIterator) { + const lookupStartMatch = line.match(START_LOOKUP_ARG); + if (lookupStartMatch) { + name = lookupStartMatch[1]; + } + } + + let description = ''; + let conversion = ''; + let bit_length: null | number = null; + const values: EnumValue[] = []; + for (const line of lines) { + if (line.match(END_LOOKUP_ARG)) { + break; + } + + const lengthMatch = line.match(/^\s*LENGTH\s*:\s*(\d+)/); + if (lengthMatch) { + bit_length = parseInt(lengthMatch[1], 10); + continue; + } + + const titleMatch = line.match(ARG_TITLE); + if (titleMatch) { + description = titleMatch[1].trim(); + continue; + } + + if (conversion) { + const lookupMatch = line.match(/^\s*'(\w+)'\s*=\s*'(\w+)'/); + if (lookupMatch) { + const symbol = lookupMatch[1]; + const valueStr = lookupMatch[2]; + let numeric: number | null = null; + if (conversion === 'DECIMAL') { + numeric = parseInt(valueStr, 10); + } else if (conversion === 'HEX') { + numeric = parseInt(valueStr, 16); + } + values.push({ + numeric, + symbol, + }); + } + } else { + const conversionMatch = line.match(CONVERSION); + if (conversionMatch) { + conversion = conversionMatch[1]; + } + } + } + + const enum_name = namespace ? `__${namespace}_${name}` : name; + return [ + { + arg_type: 'enum', + bit_length, + default_value: null, + description, + enum_name, + name, + range: values.map(value => value.symbol), + }, + { + name: enum_name, + values, + }, + ]; +} + +function escapeHtml(unsafe: string) { + return unsafe + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +export function toAmpcsXml(cdl: CommandDictionary): string { + const spacecraftIds = cdl.header.spacecraft_ids.map(sc => ``); + const enumDefs = cdl.enums.map(enumDef => { + if (!enumDef.values.length) { + console.log(`no values: ${enumDef.name}`); + return ''; + } + const enumVals = enumDef.values.map(v => ``); + return ` + + ${enumVals.join('\n')} + + `; + }); + + const commandDefs = cdl.fswCommands.map(cmdDef => { + const argumentDefs: string[] = cmdDef.arguments.map(argDef => { + switch (argDef.arg_type) { + case 'float': + case 'unsigned': + case 'numeric': + case 'integer': { + const xmlTag = { + float: 'float_arg', + integer: 'integer_arg', + numeric: 'integer_arg', + unsigned: 'unsigned_arg', + }[argDef.arg_type]; + + let rangeText = ''; + if (argDef.range) { + rangeText = ` + +`; + } + + return `<${xmlTag} name="${argDef.name}" bit_length="${argDef.bit_length ?? 32}" units="bytes"> + ${rangeText} + ${argDef.description || 'placeholder description'} + `; + } + case 'enum': { + return ` + ${argDef.description || 'placeholder description'} + `; + } + case 'var_string': { + return ` + ${argDef.description || 'placeholder description'} + `; + } + } + return ``; + }); + const argumentsBlock = argumentDefs.filter(argDef => argDef.trim().length).length + ? ` + ${argumentDefs.join('\n')} + ` + : ''; + return ` + ${argumentsBlock} + + cmd_svc + CMD + + ${escapeHtml(cmdDef.description) || ' '} + TBD + + + + + `; + }); + + return ` + +
+ + ${spacecraftIds.join('\n')} + +
+ + + + + + + ${enumDefs.join('\n')} + + + ${commandDefs.join('\n')} + +
+`; +} diff --git a/src/utilities/codemirror/codemirror-utils.ts b/src/utilities/codemirror/codemirror-utils.ts index 26602e8c23..70c0c56abd 100644 --- a/src/utilities/codemirror/codemirror-utils.ts +++ b/src/utilities/codemirror/codemirror-utils.ts @@ -13,8 +13,8 @@ import type { FswCommandArgumentVarString, } from '@nasa-jpl/aerie-ampcs'; import type { EditorView } from 'codemirror'; -import { TOKEN_REPEAT_ARG } from '../../constants/seq-n-grammar-constants'; import { fswCommandArgDefault } from '../sequence-editor/command-dictionary'; +import type { CommandInfoMapper } from './commandInfoMapper'; export function isFswCommandArgumentEnum(arg: FswCommandArgument): arg is FswCommandArgumentEnum { return arg.arg_type === 'enum'; @@ -86,48 +86,39 @@ export function addDefaultArgs( view: EditorView, commandNode: SyntaxNode, argDefs: FswCommandArgument[], + commandInfoMapper: CommandInfoMapper, ) { - let insertPosition: undefined | number = undefined; - const str = ' ' + argDefs.map(argDef => fswCommandArgDefault(argDef, commandDictionary.enumMap)).join(' '); - const argsNode = commandNode.getChild('Args'); - const stemNode = commandNode.getChild('Stem'); - if (stemNode) { - insertPosition = argsNode?.to ?? stemNode.to; - if (insertPosition !== undefined) { - const transaction = view.state.update({ - changes: { from: insertPosition, insert: str }, - }); - view.dispatch(transaction); - } - } else if (commandNode.name === TOKEN_REPEAT_ARG) { - insertPosition = commandNode.to - 1; - if (insertPosition !== undefined) { - const transaction = view.state.update({ - changes: { from: insertPosition, insert: str }, - }); - view.dispatch(transaction); - } + const insertPosition = commandInfoMapper.getArgumentAppendPosition(commandNode); + if (insertPosition !== undefined) { + const str = commandInfoMapper.formatArgumentArray( + argDefs.map(argDef => fswCommandArgDefault(argDef, commandDictionary.enumMap)), + commandNode, + ); + const transaction = view.state.update({ + changes: { from: insertPosition, insert: str }, + }); + view.dispatch(transaction); } } -export function getMissingArgDefs(argInfoArray: ArgTextDef[]) { +export function getMissingArgDefs(argInfoArray: ArgTextDef[]): FswCommandArgument[] { return argInfoArray .filter((argInfo): argInfo is { argDef: FswCommandArgument } => !argInfo.node && !!argInfo.argDef) .map(argInfo => argInfo.argDef); } -export function isQuoted(s: string) { +export function isQuoted(s: string): boolean { return s.startsWith('"') && s.endsWith('"'); } -export function unquoteUnescape(s: string) { +export function unquoteUnescape(s: string): string { if (isQuoted(s) && s.length > 1) { return s.slice(1, -1).replaceAll('\\"', '"'); } return s; } -export function quoteEscape(s: string) { +export function quoteEscape(s: string): string { return `"${s.replaceAll('"', '\\"')}"`; } diff --git a/src/utilities/codemirror/commandInfoMapper.ts b/src/utilities/codemirror/commandInfoMapper.ts new file mode 100644 index 0000000000..12d77eda7b --- /dev/null +++ b/src/utilities/codemirror/commandInfoMapper.ts @@ -0,0 +1,30 @@ +import type { SyntaxNode } from '@lezer/common'; + +export interface CommandInfoMapper { + /** format string of multiple arguments */ + formatArgumentArray(values: string[], commandNode: SyntaxNode | null): string; + + /** get insert position for missing arguments */ + getArgumentAppendPosition(node: SyntaxNode | null): number | undefined; + + /** gets container of arguments from subtree */ + getArgumentNodeContainer(commandNode: SyntaxNode | null): SyntaxNode | null; + + /** collects argument nodes from sub-tree of this command argument container */ + getArgumentsFromContainer(containerNode: SyntaxNode): SyntaxNode[]; + + /** ascends parse tree to find scope to display in form editor */ + getContainingCommand(node: SyntaxNode | null): SyntaxNode | null; + + /** finds the node in the parse tree containing the name */ + getNameNode(stepNode: SyntaxNode | null): SyntaxNode | null; + + /** checks if select list should be used */ + nodeTypeEnumCompatible(node: SyntaxNode | null): boolean; + + /** checks if node has knowable argument types */ + nodeTypeHasArguments(node: SyntaxNode | null): boolean; + + /** checks if numeric argument editor should be displayed */ + nodeTypeNumberCompatible(node: SyntaxNode | null): boolean; +} diff --git a/src/utilities/codemirror/seq-n-highlighter.ts b/src/utilities/codemirror/seq-n-highlighter.ts new file mode 100644 index 0000000000..c09c195e74 --- /dev/null +++ b/src/utilities/codemirror/seq-n-highlighter.ts @@ -0,0 +1,67 @@ +import { syntaxTree } from '@codemirror/language'; +import { Decoration, ViewPlugin, type DecorationSet, type ViewUpdate } from '@codemirror/view'; +import type { SyntaxNode } from '@lezer/common'; +import { TOKEN_COMMAND } from '../../constants/seq-n-grammar-constants'; +import { getNearestAncestorNodeOfType } from '../sequence-editor/tree-utils'; +import { computeBlocks, isBlockCommand } from './custom-folder'; +import { blockMark } from './themes/block'; + +export const seqqNBlockHighlighter = ViewPlugin.fromClass( + class { + decorations: DecorationSet; + constructor() { + this.decorations = Decoration.none; + } + update(viewUpdate: ViewUpdate): DecorationSet | null { + if (viewUpdate.selectionSet || viewUpdate.docChanged || viewUpdate.viewportChanged) { + const blocks = seqNHighlightBlock(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, + }, +); + +export function seqNHighlightBlock(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); + 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; +} diff --git a/src/utilities/codemirror/seq-n-tree-utils.ts b/src/utilities/codemirror/seq-n-tree-utils.ts index 6ef53d3d6b..bee5284b61 100644 --- a/src/utilities/codemirror/seq-n-tree-utils.ts +++ b/src/utilities/codemirror/seq-n-tree-utils.ts @@ -1,27 +1,37 @@ import type { SyntaxNode } from '@lezer/common'; import { + RULE_ARGS, + RULE_COMMAND, + RULE_GROUND_NAME, + RULE_REQUEST_NAME, + RULE_SEQUENCE_NAME, + RULE_STEM, TOKEN_ACTIVATE, TOKEN_COMMAND, TOKEN_GROUND_BLOCK, TOKEN_GROUND_EVENT, TOKEN_LOAD, + TOKEN_NUMBER, + TOKEN_REPEAT_ARG, TOKEN_REQUEST, + TOKEN_STRING, } from '../../constants/seq-n-grammar-constants'; -import { getNearestAncestorNodeOfType } from '../sequence-editor/tree-utils'; +import { getFromAndTo, getNearestAncestorNodeOfType } from '../sequence-editor/tree-utils'; +import type { CommandInfoMapper } from './commandInfoMapper'; export function getNameNode(stepNode: SyntaxNode | null) { if (stepNode) { switch (stepNode.name) { case TOKEN_ACTIVATE: case TOKEN_LOAD: - return stepNode.getChild('SequenceName'); + return stepNode.getChild(RULE_SEQUENCE_NAME); case TOKEN_GROUND_BLOCK: case TOKEN_GROUND_EVENT: - return stepNode.getChild('GroundName'); + return stepNode.getChild(RULE_GROUND_NAME); case TOKEN_COMMAND: - return stepNode.getChild('Stem'); + return stepNode.getChild(RULE_STEM); case TOKEN_REQUEST: - return stepNode.getChild('RequestName'); + return stepNode.getChild(RULE_REQUEST_NAME); } } @@ -38,3 +48,56 @@ export function getAncestorStepOrRequest(node: SyntaxNode | null) { TOKEN_REQUEST, ]); } + +export class SeqNCommandInfoMapper implements CommandInfoMapper { + formatArgumentArray(values: string[]): string { + return ' ' + values.join(' '); + } + + getArgumentAppendPosition(commandOrRepeatArgNode: SyntaxNode | null): number | undefined { + if (commandOrRepeatArgNode?.name === RULE_COMMAND) { + const argsNode = commandOrRepeatArgNode.getChild('Args'); + const stemNode = commandOrRepeatArgNode.getChild('Stem'); + return getFromAndTo([stemNode, argsNode]).to; + } else if (commandOrRepeatArgNode?.name === TOKEN_REPEAT_ARG) { + return commandOrRepeatArgNode.to - 1; + } + return undefined; + } + + getArgumentNodeContainer(commandNode: SyntaxNode | null): SyntaxNode | null { + return commandNode?.getChild(RULE_ARGS) ?? null; + } + + getArgumentsFromContainer(containerNode: SyntaxNode | null): SyntaxNode[] { + const children: SyntaxNode[] = []; + + let child = containerNode?.firstChild; + while (child) { + children.push(child); + child = child.nextSibling; + } + + return children; + } + + getContainingCommand(node: SyntaxNode | null): SyntaxNode | null { + return getAncestorStepOrRequest(node); + } + + getNameNode(stepNode: SyntaxNode | null): SyntaxNode | null { + return getNameNode(stepNode); + } + + nodeTypeEnumCompatible(node: SyntaxNode | null): boolean { + return node?.name === TOKEN_STRING; + } + + nodeTypeHasArguments(node: SyntaxNode | null): boolean { + return node?.name === TOKEN_COMMAND; + } + + nodeTypeNumberCompatible(node: SyntaxNode | null): boolean { + return node?.name === TOKEN_NUMBER; + } +} diff --git a/src/utilities/codemirror/themes/block.ts b/src/utilities/codemirror/themes/block.ts new file mode 100644 index 0000000000..38a8e91402 --- /dev/null +++ b/src/utilities/codemirror/themes/block.ts @@ -0,0 +1,10 @@ +import { Decoration } from '@codemirror/view'; +import { EditorView } from 'codemirror'; + +export const blockMark = Decoration.mark({ class: 'cm-block-match' }); + +export const blockTheme = EditorView.baseTheme({ + '.cm-block-match': { + outline: '1px dashed', + }, +}); diff --git a/src/utilities/codemirror/vml/vml.grammar b/src/utilities/codemirror/vml/vml.grammar new file mode 100644 index 0000000000..b1e9b26e25 --- /dev/null +++ b/src/utilities/codemirror/vml/vml.grammar @@ -0,0 +1,867 @@ +// VML - Virtual Machine Language +// Based on 2.0.11 SIS + +// A module is a set of functions (absolute sequences, relative sequences, and blocks) and +// optional variable definitions. There may be one or more functions in a module. Typically, +// a module either contains one function (e.g. a master sequence, a special-purpose relative +// sequence, or a special-purpose block), or it contains a set of general-purpose blocks +// stored together for convenience and known as a library. + +// Exactly one module exists per source file. A module is bounded +// by the keywords MODULE and END_MODULE. Comments may appear before and +// after the module portion of the file, and anywhere between the keywords. + +// VML is not case sensitive: case is ignored. All keywords and symbols may be in upper and/or lower case. +// It is recommended that keywords (like MODULE and BLOCK) be placed in upper case, and that symbols which +// the user has control over be placed in lower case. This upper case / lower case convention is followed +// throughout this document. + +// Grammar implementation notes +// Token and rule names are taken from VMLCOMP 2.0.11 Interface Specification and then capitalized +// since code mirror does not create tree nodes for lower case names. + +// TODOs +// * figure out what vml_create_default(0) means +// * confirm if order is strict on flags +// * vml_create_range ? +// * see if multiple ranges are allowed INPUT INT mode := 3 VALUES 1..6, 8..9 +// * Confirm EXTERNAL_CALL if there's comma after simple expression before call parameters +// * Are assignments allowed in external_call parameters +// * Confirm if keywords are case sensitive +// * vml_utils_check_for_keyword +// * external tokenizer to support case insensitive keywords +// https://github.com/lezer-parser/generator/blob/10a697a11ebdef98cd08a2c6ac2ef6619604385c/test/cases/ExternalSpecializer.txt#L9 +// https://github.com/lezer-parser/css/blob/692710b42f83961151eee4cf73b0ae18ebccffad/src/tokens.js#L35 +// https://github.com/lezer-parser/css/blob/692710b42f83961151eee4cf73b0ae18ebccffad/src/css.grammar#L147 + +@skip { space | Comment } + +@top Text_file { + VML_HEADER? + optional_end_lines + MODULE + End_lines + Optional_static_variable_section { + VARIABLES + End_lines + Variable_declarations_with_optional_tlm_ids + END_VARIABLES + End_lines + }? + Functions { + Function* + } + END_MODULE + ( End_lines VML_EOF )? + ( End_lines | EOF ) +} + +End_lines { + END_OF_LINE+ +} + +optional_end_lines { + End_lines? +} + +Function { + Block + | Relative_sequence + | Absolute_Sequence + | Sequence +} + +Block { + BLOCK + Common_Function +} + +Relative_sequence { + RELATIVE_SEQUENCE + Common_Function +} + +Absolute_Sequence { + ABSOLUTE_SEQUENCE + Common_Function +} + +Sequence { + SEQUENCE + Common_Function +} + +Function_name { SYMBOL_CONST } + +Common_Function { + Function_name End_lines + Parameters + Flags { + FLAGS + Flags_symbols { + // Confirm if order is strict + AUTOEXECUTE? + AUTOUNLOAD? + REENTRANT? + } + End_lines + }? + Variable_declarations { + Variable_declaration* + } + Body { + BODY + End_lines + Time_tagged_statements { + Time_tagged_statement { + TIME_CONST + Statement + }* + } + END_BODY + End_lines + } +} + +Statement { + statement_no_endline[@isGroup=StatementSub] { + Issue + | Issue_dynamic + | Issue_hex + | Noop + | Clear + | Assignment + | Simple_call + | External_call + | For_statement + | End_for + | Label + | Flow + | Vm_management + + | If + | Else_if + | Else + | End_if + + | While + | End_while + } + End_lines +} + +Noop { + NOOP +} + +Clear { + CLEAR +} + +Issue { + ISSUE + // Function_name, Call_parameters not shown in schema + Function_name + Call_parameters +} + +// Some examples include commas between arguments +Issue_dynamic { + ISSUE_DYNAMIC + // Function_name not shown in schema + Function_name + Call_parameters +} + +// Issue_no_time { +// incomplete in schema +// } + +Parameters { + Parameter* +} + +Parameter { + Input_parameter { + INPUT + Data_kind? + Variable_name + Optional_default_input_value? + Optional_value_list? + End_lines + } + | Input_output_parameter { + INPUT_OUTPUT + Data_kind? + Variable_name + Optional_default_input_value? + Optional_value_list? + End_lines + } +} + +Optional_default_input_value { + ASSIGNMENT + Constant +} + +Optional_value_list { + VALUES + Input_value + (COMMA Input_value)* +} + +Input_value { + Constant + | Input_Range { + INT_RANGE_CONST + } +} + +Variable_declaration { + DECLARE + Variable_declaration_type + End_lines +} + +Variable_name { SYMBOL_CONST } + +Variable_declarations_with_optional_tlm_ids { + Variable_declaration_with_optional_tlm_id { + DECLARE + EXTERNAL? + READ_ONLY? + Variable_declaration_type + (TLM_ID Tlm_id_const { + INT_CONST + | UINT_CONST + | DEFINED_SYMBOL_CONST + })? + End_lines + }+ +} + +Variable_declaration_type { + Variable_name_constant { + Data_kind + Variable_name + ASSIGNMENT + Constant + } +} + +Data_kind { + INT + | UINT + | LOGICAL + | DOUBLE + | STRING + | UNKNOWN + | TIME + | ABSOLUTE_TIME + | RELATIVE_TIME +} + +Constant { + INT_CONST + | UINT_CONST + | HEX_CONST + | DOUBLE_CONST + | STRING_CONST + | UNKNOWN_CONST + | Boolean + | TIME_CONST + | DEFINED_SYMBOL_CONST +} + +Boolean { + True_const + | False_const +} + +True_const { TRUE_CONST } +False_const { FALSE_CONST } + +UNKNOWN_CONST { + UNKNOWN +} + +TIME_CONST { + Full_or_spacecraft_or_day_or_short_time { + Full_time_const { FULL_TIME_CONST } + | Day_time_const { DAY_TIME_CONST } + | Short_time_const { SHORT_TIME_CONST } + | Spacecraft_time_const { SPACECRAFT_TIME_CONST } + } +} + +Issue_hex { + ISSUE_HEX + Hex_const + Issue_hex_values +} + +Issue_hex_no_time { + ISSUE_HEX + Hex_const + Issue_hex_values +} + +Issue_hex_values { Issue_hex_value* } +Issue_hex_value { HEX_CONST } +Hex_const { HEX_CONST } + +Assignment { + Variable_name + ASSIGNMENT + Assignment_source { +// | issue_no_time + Issue_hex_no_time +// | external_call_no_time + | Compound_expr + | Simple_call_no_time + | Wait + | Test_and_set + } +} + +External_call { + EXTERNAL_CALL + Simple_expr + Call_parameters +} + +External_call_no_time { + EXTERNAL_CALL + Simple_expr + Call_parameters +} + +Tlm_id_const { + INT_CONST | UINT_CONST | DEFINED_SYMBOL_CONST +} + +Call_parameters { + ( + Call_parameter + (COMMA Call_parameter)* + )? +} + +Call_parameter { + Simple_expr +} + +Simple_call { + CALL + Function_name + Call_parameters +} + +Simple_call_no_time { + CALL + Function_name + Call_parameters +} + +Compound_expr_base { + Simple_expr + | Unop Simple_expr + | Built_in_function +} + +Compound_expr_mid { + Compound_expr_base + | (OPEN_PAREN Compound_expr CLOSE_PAREN) +} + +Compound_expr { + Compound_expr_mid + | (Compound_expr_mid Binop Compound_expr) +} + +Simple_expr { + Constant + | Variable_name +} + +Unop { + LOGICAL_NOT + // | BIT_INVERT + | BIT_NOT + + // | Subtract +} + +Built_in_function { + // Grammar shows 2 sets of paranthesis + (Zero_parm_built_in_function_name OPEN_PAREN CLOSE_PAREN) + | (Unary_parm_built_in_function_name OPEN_PAREN Compound_expr CLOSE_PAREN) +} + +Zero_parm_built_in_function_name { + SPACECRAFT_TIME + // Odd to not have a name??? + OPEN_PAREN CLOSE_PAREN +} + +Unary_parm_built_in_function_name { + Simple_coerce + | Trig + | LENGTH + | ABS +} + +Simple_coerce { + INT_COERCE + | UINT_COERCE + | LOGICAL_COERCE + | DOUBLE_COERCE + | STRING_COERCE + | TIME_COERCE + | UNKNOWN_COERCE +} + +Trig { + SIN + | COS + | TAN + | ASIN + | ACOS + | ATAN +} + +Binop { + Arith_binop + | Bit_binop + | Comparison_binop + | Logical_binop + | String_binop +} + +Arith_binop { + ADD + | SUBTRACT + | MULTIPLY + | DIVIDE + | MODULO + | POWER +} + +Bit_binop { + BIT_AND + | BIT_OR + | BIT_XOR + | SHIFT_LEFT + | SHIFT_RIGHT +} + +Comparison_binop { + EQUAL + | NOT_EQUAL + | LESS + | LESS_OR_EQUAL + | GREATER + | GREATER_OR_EQUAL +} + +Logical_binop { + LOGICAL_AND + | LOGICAL_OR + | LOGICAL_XOR +} + +String_binop { + CONCAT | SPLIT_LEFT | SPLIT_RIGHT +} + +Wait_comparison_binop { + EQUAL + | NOT_EQUAL + | LESS + | LESS_OR_EQUAL + | GREATER + | GREATER_OR_EQUAL +} + +Wait { + Wait_simple + | Wait_change + | Wait_compare +} + +Wait_simple { + WAIT + Variable_name + Optional_timeout? +} + +Wait_change { + WAIT_CHANGE + Variable_name + Optional_timeout? +} + +Wait_compare { + WAIT + Variable_name + Wait_comparison_binop + Simple_expr + Optional_timeout? +} + +Optional_timeout { + TIMEOUT + Simple_expr // not shown in example +} + +Test_and_set { + TEST_AND_SET + Variable_name + Optional_timeout +} + +Label { + LABEL + Label_name +} + +Label_name { SYMBOL_CONST } + +Flow { + Branch + | Return + | Delay +} + +Branch { Branch_always | Branch_true | Branch_false } + +Branch_always { + BRANCH + Label_name +} + +Branch_true { + BRANCH_TRUE + Simple_expr + Label_name +} + +Branch_false { + BRANCH_FALSE + Simple_expr + Label_name +} + +If { + IF + If_condition + // not shown in grammar + THEN +} + +Else_if { + ELSE_IF + If_condition + // not shown in grammar + THEN +} + +Else { + ELSE +} + +End_if { + END_IF +} + +If_condition { + Compound_expr +} + +While { + WHILE + While_condition { + Compound_expr + } + DO // not shown in grammar, but in example +} + +End_while { + END_WHILE +} + +For_statement { + FOR + For_assignment + For_bound + Optional_step { + STEP Compound_expr + }? + DO +} + +End_for { + END_FOR +} + +For_assignment { + Variable_name + ASSIGNMENT + Compound_expr +} + +For_bound { + For_direction { + (TO | DOWN_TO) + } + Compound_expr +} + +Return { + RETURN + Simple_expr +} + +Delay { + Delay_by { + DELAY_BY + Simple_expr + } + | Delay_until { + DELAY_UNTIL + Simple_expr + } +} + +Vm_management { Halt | Pause | Resume | Spawn } + +Halt { + HALT + Simple_expr +} + +Pause { + PAUSE + Simple_expr +} + +Resume { + RESUME + Simple_expr +} + +Spawn { + SPAWN + Simple_expr + Function_name + Call_parameters +} + +// VMLmodulelexerconstructs +ABS { @specialize | @specialize } +ABSOLUTE_SEQUENCE { @specialize | @specialize} +ABSOLUTE_TIME { @specialize | @specialize} +ACOS { @specialize | @specialize } +ASIN { @specialize | @specialize } +ATAN { @specialize | @specialize } +AUTOEXECUTE { @specialize | @specialize } +AUTOUNLOAD { @specialize | @specialize } +BIT_INVERT { @specialize | @specialize} +BLOCK { @specialize | @specialize } +BODY { @specialize | @specialize } +BRANCH { @specialize | @specialize } +BRANCH_FALSE { @specialize | @specialize} +BRANCH_TRUE { @specialize | @specialize } +CALL { @specialize | @specialize} +CLEAR { @specialize | @specialize} +CONCAT { @specialize | @specialize } +COS { @specialize | @specialize } +DECLARE { @specialize | @specialize } +DEFINED_SYMBOL_CONST { @specialize | @specialize } +DELAY_BY { @specialize | @specialize } +DELAY_UNTIL { @specialize | @specialize } +DO { @specialize | @specialize } +DOUBLE { @specialize | @specialize } +DOUBLE_COERCE { @specialize | @specialize } +DOWN_TO { @specialize | @specialize} +ELSE { @specialize | @specialize} +ELSE_IF { @specialize | @specialize } +END_BODY { @specialize | @specialize} +END_FOR { @specialize | @specialize } +END_IF { @specialize | @specialize } +END_MODULE { @specialize | @specialize } +END_VARIABLES { @specialize | @specialize } +END_WHILE { @specialize | @specialize } +EXTERNAL { @specialize | @specialize} +EXTERNAL_CALL { @specialize | @specialize } +FALSE_CONST { @specialize | @specialize } +FLAGS { @specialize | @specialize } +FOR { @specialize | @specialize } +HALT { @specialize | @specialize } +IF { @specialize | @specialize } +INPUT { @specialize | @specialize } +INPUT_OUTPUT { @specialize | @specialize } +INT { @specialize | @specialize } +INT_COERCE { @specialize | @specialize } +ISSUE { @specialize | @specialize } +ISSUE_DYNAMIC { @specialize | @specialize} +ISSUE_HEX { @specialize | @specialize } +LABEL { @specialize | @specialize } +LENGTH { @specialize | @specialize } +LOGICAL { @specialize | @specialize } +LOGICAL_COERCE { @specialize | @specialize } +MODULE { @specialize | @specialize } +NOOP { @specialize | @specialize} +PAUSE { @specialize | @specialize } +READ_ONLY { @specialize | @specialize } +REENTRANT { @specialize | @specialize } +RELATIVE_SEQUENCE { @specialize | @specialize } +RELATIVE_TIME { @specialize | @specialize } +RESUME { @specialize | @specialize } +RETURN { @specialize | @specialize } +SEQUENCE { @specialize | @specialize} +SIN { @specialize | @specialize } +SPACECRAFT_TIME { @specialize | @specialize } +SPAWN { @specialize | @specialize } +STEP { @specialize | @specialize} +STRING { @specialize | @specialize } +STRING_COERCE { @specialize | @specialize } +SYNTAX_ERROR { @specialize } +TAN { @specialize | @specialize } +TEST_AND_SET { @specialize | @specialize } +THEN { @specialize | @specialize } +TIME { @specialize | @specialize } +TIME_COERCE { @specialize | @specialize } +TIMEOUT { @specialize | @specialize } +TLM_ID { @specialize | @specialize } +TO { @specialize | @specialize } +TRUE_CONST { @specialize | @specialize } +UINT { @specialize | @specialize } +UINT_COERCE { @specialize | @specialize } +UNKNOWN { @specialize | @specialize } +UNKNOWN_COERCE { @specialize | @specialize } +VALUES { @specialize | @specialize} +VARIABLES { @specialize | @specialize } +WAIT { @specialize | @specialize } +WAIT_CHANGE { @specialize | @specialize } +WHILE { @specialize | @specialize } + +// grammar shows "&", but example uses "BIT_AND" +BIT_AND { + BIT_AND_OP + | @specialize +} + +BIT_NOT { + BIT_INVERT + | BIT_INVERT_OP + // not in grammar, but in example + | @specialize +} + +// not in grammar, but in example +LOGICAL_NOT { + LOGICAL_NOT_OP + | @specialize +} + +@tokens { + space { $[ \t]+ } + Comment { ";" ![\n]* } + + VML_HEADER { 'CCSD3ZF' ![@]* '$$EOH' } + @precedence { VML_HEADER, SYMBOL_CONST } + VML_EOF { "$$EOF" } + + SYMBOL_CONST { @asciiLetter (@asciiLetter| @digit | "_" | "-")* } + + ASSIGNMENT { ":=" } + OPEN_PAREN { "(" } + CLOSE_PAREN { ")" } + + ADD { "+" } + SUBTRACT { "-" } + MULTIPLY { "*" } + DIVIDE { "/" } + MODULO { "%" } + POWER { "^" } + + EQUAL { "=""="? } + NOT_EQUAL { "!=" } + GREATER {">"} + GREATER_OR_EQUAL {">="} + LESS {"<"} + LESS_OR_EQUAL {"<="} + + BIT_INVERT_OP { "~" } + BIT_AND_OP { "&" } + BIT_OR { "|" } + BIT_XOR { "@" } + SHIFT_LEFT { "<<" } + SHIFT_RIGHT { ">>" } + + LOGICAL_NOT_OP {"!"} + LOGICAL_AND {"&&"} + LOGICAL_OR {"||"} + LOGICAL_XOR {"@@"} + + SPLIT_LEFT { "-|" } + SPLIT_RIGHT { "|-" } + + FULL_TIME_CONST { + $[aArR]@digit+"-"@digit+$[Tt]@digit+":"@digit+":"@digit+("."@digit+)? + } + + DAY_TIME_CONST { // [r|R][0-9]+[t|T][0-9]+:[0-9]+:[0-9]+(\.[0-9]+)? + $[rR]@digit+$[tT]@digit+":"@digit+":"@digit+("."@digit+)? + } + + SHORT_TIME_CONST { // [r|R][0-9]+:[0-9]+:[0-9]+(\.[0-9]+)? + $[rR]@digit+":"@digit+":"@digit+("."@digit+)? + } + + // Fractional second requirement is present in VMLCOMP document, but contradicted by verbal communication + // need test data to confirm + SPACECRAFT_TIME_CONST { // [s|S][0-9]+\.[0-9]+ + $[sS]@digit+"."@digit+ + } + + COMMA { "," } + + // Numbers + + INT_CONST { + ("+" | "-")? @digit+ + } + + DOUBLE_CONST { + ("+" | "-")? + ( + (@digit+ "." @digit*) + | ("." @digit+) + ) + } + + UINT_CONST { + @digit+ ("u" | "U") + } + + HEX_CONST { + "0" ("x" | "X") (@digit | $[a-f] | $[A-F])+ + } + + INT_RANGE_CONST { + INT_CONST ".." INT_CONST + } + + STRING_CONST { '"' (!["\\] | "\\" _)* '"' } + + @precedence { LESS_OR_EQUAL, SHIFT_LEFT, LESS } + @precedence { GREATER_OR_EQUAL, SHIFT_RIGHT, GREATER } + @precedence { INT_RANGE_CONST, DOUBLE_CONST, UINT_CONST, HEX_CONST, INT_CONST, ADD, SUBTRACT } + @precedence { FULL_TIME_CONST, DAY_TIME_CONST, SHORT_TIME_CONST, SPACECRAFT_TIME_CONST, SYMBOL_CONST } + + END_OF_LINE { "\n" } + EOF { @eof } +} diff --git a/src/utilities/codemirror/vml/vml.grammar.d.ts b/src/utilities/codemirror/vml/vml.grammar.d.ts new file mode 100644 index 0000000000..c306a0405f --- /dev/null +++ b/src/utilities/codemirror/vml/vml.grammar.d.ts @@ -0,0 +1,2 @@ +import { LRParser } from '@lezer/lr'; +export declare const parser: LRParser; diff --git a/src/utilities/codemirror/vml/vml.test.ts b/src/utilities/codemirror/vml/vml.test.ts new file mode 100644 index 0000000000..cf5a810b55 --- /dev/null +++ b/src/utilities/codemirror/vml/vml.test.ts @@ -0,0 +1,479 @@ +import type { SyntaxNode } from '@lezer/common'; +import type { FswCommandArgumentInteger } from '@nasa-jpl/aerie-ampcs'; +import { assert, describe, expect, it } from 'vitest'; +import { filterNodes, nodeContents } from '../../sequence-editor/tree-utils'; +import { VmlLanguage } from './vml'; +import { vmlBlockLibraryToCommandDictionary } from './vmlBlockLibrary'; +import { + GROUP_STATEMENT_SUB as GROUP_STATEMENT_SUBTYPES, + RULE_ABSOLUTE_SEQUENCE, + RULE_ASSIGNMENT, + RULE_BLOCK, + RULE_BODY, + RULE_COMMON_FUNCTION, + RULE_FUNCTION, + RULE_FUNCTION_NAME, + RULE_FUNCTIONS, + RULE_SIMPLE_CALL, + RULE_SPAWN, + RULE_STATEMENT, + RULE_TEXT_FILE, + RULE_TIME_TAGGED_STATEMENT, + RULE_TIME_TAGGED_STATEMENTS, + RULE_VM_MANAGEMENT, + TOKEN_ERROR, +} from './vmlConstants'; + +// In versions of VML prior to 2.1, explicit time tags were required on every statement +// confirm what version we're using + +// https://dataverse.jpl.nasa.gov/dataset.xhtml?persistentId=hdl:2014/45392 +// shows Juno on 2.1, MRO on 2.0 + +const grammarVmlVersion = '2.0.11'.split('.').map(part => parseInt(part, 10)); + +describe('vml tree', () => { + it('module with variables', () => { + const input = ` +;************************************************** +; This module contains examples of VML constructs +;************************************************** +MODULE +VARIABLES + DECLARE DOUBLE partial_product := 0.0 + DECLARE STRING file_base := "d:/cfg/instrument_" +END_VARIABLES + +;************************************************** +; Purpose: provide a special watch for spacecraft components +;************************************************** + + +END_MODULE +`; + assertNoErrorNodes(input); + }); + + it('module with absolute sequence', () => { + const input = ` +;************************************************** +; This module contains examples of VML constructs +;************************************************** +MODULE + +ABSOLUTE_SEQUENCE master_sequence_day_154 +BODY +A2003-154T10:00:00.0 SPAWN 65535 observe 12, "abc", 37.8 +A2003-154T10:19:21.0 gv_success := CALL special_watch 15.1 +A2003-154T10:19:45.0 CALL special_watch 18 +END_BODY + +END_MODULE +`; + assertNoErrorNodes(input); + + const tree = VmlLanguage.parser.parse(input); + expect(tree.topNode.name).toBe(RULE_TEXT_FILE); + + const functionsNode = tree.topNode.getChild(RULE_FUNCTIONS); + expect(functionsNode).toBeDefined(); + + const functionNodes = functionsNode?.getChildren(RULE_FUNCTION); + expect(functionNodes).toBeDefined(); + expect(functionNodes?.length).toBe(1); + + const seqNode0 = functionNodes?.[0].getChild(RULE_ABSOLUTE_SEQUENCE)?.getChild(RULE_COMMON_FUNCTION); + expect(seqNode0).toBeDefined(); + + const functionNameNode = seqNode0?.getChild(RULE_FUNCTION_NAME); + expect(functionNameNode).toBeDefined(); + expect(nodeContents(input, functionNameNode!)).toBe('master_sequence_day_154'); + + const statementNodes = seqNode0 + ?.getChild(RULE_BODY) + ?.getChild(RULE_TIME_TAGGED_STATEMENTS) + ?.getChildren(RULE_TIME_TAGGED_STATEMENT); + expect(statementNodes?.length).toBe(3); + expect(statementNodes?.[0].firstChild?.nextSibling?.firstChild?.firstChild?.name).toBe(RULE_SPAWN); + expect(statementNodes?.[1].firstChild?.nextSibling?.firstChild?.name).toBe(RULE_ASSIGNMENT); + expect(statementNodes?.[2].firstChild?.nextSibling?.firstChild?.name).toBe(RULE_SIMPLE_CALL); + + expect(statementNodes?.[0].getChild(RULE_STATEMENT)?.getChild(GROUP_STATEMENT_SUBTYPES)?.name).toBe( + RULE_VM_MANAGEMENT, + ); + expect(statementNodes?.[1].getChild(RULE_STATEMENT)?.getChild(GROUP_STATEMENT_SUBTYPES)?.name).toBe( + RULE_ASSIGNMENT, + ); + expect(statementNodes?.[2].getChild(RULE_STATEMENT)?.getChild(GROUP_STATEMENT_SUBTYPES)?.name).toBe( + RULE_SIMPLE_CALL, + ); + }); + + it('module with block', () => { + const input = ` +;************************************************** +; This module contains examples of VML constructs +;************************************************** +MODULE + +BLOCK special_watch +INPUT delay_time +INPUT INT mode := 3 VALUES 1..6, 8..9 +DECLARE INT i := 0 +DECLARE INT value := 0 +DECLARE UINT mask := 0xffff +DECLARE STRING file_name := "" +DECLARE STRING str := "" +BODY + +R00:00:00.1 value := delay_time + 47 +R00:00:00.1 str := value +R00:00:00.1 file_name := "d:/cfg/high_gain_" CONCAT str +R00:00:00.1 mask := mask BIT_AND 0x132 +R00:00:00.1 mask := BIT_NOT mask +; R00:00:00.1 EXTERNAL_CALL "issue_cmd", "SSPA_LEVEL", mask i := 1 +R00:00:00.1 i := 1 +R00:00:00.1 WHILE i <= 5 DO +R00:00:02.0 ISSUE INCREMENT_GAIN +R00:00:00.1 i := i + 1 +R00:00:00.1 END_WHILE +; R00:00:00.1 FOR i := 1 to mode STEP 2 DO +R00:00:00.1 FOR i := 1 TO mode STEP 2 DO +R00:00:02.0 ISSUE INCREMENT_mode +R00:00:00.1 END_FOR +R00:00:00.1 IF delay_time > 100.0 THEN +R00:00:00.1 delay_time := 100.0 +R00:00:00.0 ELSE_IF delay_time > 80.0 THEN +R00:00:00.1 delay_time := delay_time * 0.98 +R00:00:00.0 ELSE_IF delay_time > 60.0 THEN +R00:00:00.1 delay_time := delay_time * 0.95 +R00:00:00.0 ELSE_IF delay_time < 0.0 THEN +R00:00:00.1 RETURN FALSE +R00:00:00.0 ELSE +R00:00:00.1 delay_time := delay_time * 0.9 +R00:00:00.0 END_IF + +R00:00:00.0 DELAY_BY delay_time +; R00:00:00.0 value := WAIT gv_complete = 1 TIMEOUT +R00:00:00.0 value := WAIT gv_complete = 1 TIMEOUT 5 +R00:05:00.0 RETURN TRUE + +END_BODY + +END_MODULE + `; + assertNoErrorNodes(input, true); + }); + + it('test from Seqgen for Sequence Template V&V using Sleekgen', () => { + const input = ` +MODULE + +RELATIVE_SEQUENCE vnv +FLAGS AUTOEXECUTE AUTOUNLOAD +BODY +;initialize variables +R00:00:01.00 ISSUE VM_GV_SET_UINT "GV_SEIS_SAFED",FALSE_VM_CONST +R00:00:01.00 ISSUE VM_GV_SET_UINT "GV_SEIS_INIT_COMPLETE",TRUE_VM_CONST + + +;TEST CASE 1 +R00:01:00 CALL pay_spawn "seis_pwr_on_r01_1" + + +END_BODY +END_MODULE +`; + assertNoErrorNodes(input, true); + }); + + it('nested ifs', () => { + const input = ` +MODULE + +BLOCK special_watch +BODY +R00:00:00.1 IF mode = STANDBY_MODE_VM_CONST THEN + R00:00:00.1 IF x = 1 THEN + ; do something + R00:00:00.0 ELSE + ; do something else + R00:00:00.0 END_IF + R00:00:00.1 IF y = 1 THEN + ; do something + R00:00:00.0 ELSE + ; do something else + R00:00:00.0 END_IF +R00:00:00.0 ELSE_IF a = b THEN + ; do something appropriate +R00:00:00.0 ELSE + ; do something appropriate +R00:00:00.0 END_IF +END_BODY +END_MODULE +`; + assertNoErrorNodes(input, true); + }); + + it('abs and length', () => { + const input = wrapInModule(`R00:00:00.1 a := ABS(b) +R00:00:00.1 len := LENGTH(file_name)`); + assertNoErrorNodes(input, true); + }); + + it('trig functions', () => { + const input = wrapInModule(`R00:00:00.1 a := SIN(b) +R00:00:00.1 a := ATAN(b)`); + assertNoErrorNodes(input, true); + }); + + it('Unary operators: BIT_INVERT, LOGICAL_NOT', () => { + const input = wrapInModule(`R00:00:00.1 mask_complement := ~source_mask ;or BIT_INVERT source_mask +R00:00:00.1 mask_complement := BIT_INVERT source_mask +R00:00:00.1 dont_do_it := !do_it; ;or LOGICAL_NOT do_it +R00:00:00.1 dont_do_it := LOGICAL_NOT do_it +`); + assertNoErrorNodes(input, true); + }); + + it('Arithmetic binary operators: +, -, *, /, % (MODULO), ^', () => { + const input = wrapInModule(`R00:00:00.1 a := b + c ; add +R00:00:00.1 a := b - c ; subtract +R00:00:00.1 a := b * c ; multiply +R00:00:00.1 a := b / c ; divide +R00:00:00.1 a := b % c ; modulo (remainder): or b MODULO c +; R00:00:00.1 a := b MODULO c ; modulo (remainder): or b MODULO c +R00:00:00.1 a := b ^ c ; power +`); + assertNoErrorNodes(input, true); + }); + + it('Issue hex', () => { + const input = + wrapInModule(`R00:00:00.1 ISSUE_HEX 0x0506 0x64 0x3A 0x2F 0x63 0x66 0x67 0x2F 0x63 0x72 0x75 0x69 0x73 0x65 0x5F 0x64 0x70 0x74 0x2E 0x63 0x66 0x67 0x0A +`); + assertNoErrorNodes(input, true); + }); + + it('Issue dynamic', () => { + // grammar shows no commas between arguments + // assertNoErrorNodes(wrapInModule(`R00:00:00.1 ISSUE_DYNAMIC "FSW_OBJ_INITIAL", "DWN", file_name, ""`), true); + }); + + it('file with header', () => { + const input = `CCSD3ZF0000100000001NJPL3KS0L015$$MARK$$; + DATA_SET_ID=VIRTUAL_MACHINE_LANGUAGE; + MISSION_NAME=AERIE; + CCSD$$MARKER$$MARK$$NJPL3IF0040300000001; + $MRO VIRTUAL MACHINE LANGUAGE FILE + ************************************************************ + *PROJECT AERIE + *Input files used: + *File Type Last modified File name + *SSF Wed Jun 12 20:18:11 2024 rm461a.ssf + ************************************************************ + $$EOH + MODULE + SEQUENCE rm461 + FLAGS AUTOEXECUTE AUTOUNLOAD + BODY + S1.14 issue CMD "enum_arg",0 + END_BODY + END_MODULE + $$EOF + `; + assertNoErrorNodes(input, true); + }); + + it('folding', () => { + const input = `MODULE +SEQUENCE unit_test +FLAGS AUTOEXECUTE AUTOUNLOAD +BODY + +R00:00:00.1 FOR i := 1 TO mode STEP 2 DO +R00:00:00.1 FOR i := 1 TO mode STEP 2 DO +R00:00:00.1 FOR i := 1 TO mode STEP 2 DO +; only a comment +R00:00:00.1 END_FOR +R00:00:00.1 END_FOR +R00:00:00.1 END_FOR + +END_BODY +END_MODULE +`; + assertNoErrorNodes(input, true); + }); + + it('block library', () => { + const input = `MODULE +BLOCK block1 + INPUT arg1 +BODY +END_BODY + +BLOCK block2 + INPUT arg1 + INPUT arg2 +BODY +END_BODY + +END_MODULE`; + assertNoErrorNodes(input, true); + const parsed = VmlLanguage.parser.parse(input); + const functionNodes = parsed.topNode.getChild(RULE_FUNCTIONS)?.getChildren(RULE_FUNCTION); + expect(functionNodes).toBeDefined(); + expect(functionNodes?.length).toEqual(2); + const blockNameNode = functionNodes?.[0] + ?.getChild(RULE_BLOCK) + ?.getChild(RULE_COMMON_FUNCTION) + ?.getChild(RULE_FUNCTION_NAME); + expect(blockNameNode).toBeDefined(); + const block0Name = nodeContents(input, blockNameNode!); + expect(block0Name).toEqual('block1'); + }); + + it('block library parsing', () => { + const input = `MODULE + BLOCK block1 + INPUT arg1 + BODY + END_BODY + + BLOCK block2 + INPUT STRING arg1 ; arg1 if block library includes parameter descriptions as comments + INPUT INT arg2 := 32 VALUES 0..127 ; arg2 if block library includes parameter descriptions as comments + BODY + END_BODY + + END_MODULE`; + + const commandDictionary = vmlBlockLibraryToCommandDictionary(input, 'id', '/test'); + expect(commandDictionary.fswCommands.length).toBe(2); + + expect(commandDictionary.fswCommands[0].stem).toBe('block1'); + + expect(commandDictionary.fswCommands[1].stem).toBe('block2'); + expect(commandDictionary.fswCommands[1].arguments[0].description).toBe( + '[INPUT] arg1 if block library includes parameter descriptions as comments', + ); + + const cmd2arg2 = commandDictionary.fswCommands[1].arguments[1] as FswCommandArgumentInteger; + expect(cmd2arg2.name).toBe('arg2'); + expect(cmd2arg2.default_value).toBe(32); + expect(cmd2arg2.range?.min).toBe(0); + expect(cmd2arg2.range?.max).toBe(127); + }); + + it('call', () => { + const input = ` +MODULE +ABSOLUTE_SEQUENCE master_4 +FLAGS AUTOEXECUTE AUTOUNLOAD +BODY +A2010-020T01:23:14.0 VM_LOAD 5, "d:/seq/observe_day_39.mod" +R10.0 SPAWN 5 observe_day_39 +A2010-020T07:34:21.0 PAUSE 5 +A2010-020T07:34:22.0 CALL dsn_contact_start "main", 1024 +A2010-020T08:11:15.0 CALL dsn_contact_end +A2010-020T08:11:16.0 RESUME 5 + +A2010-050T10:11:00.0 HALT 5 ; stop camera observations +A2010-050T10:11:00.0 ISSUE VM_UNLOAD 5 +A2010-050T10:11:01.0 ISSUE VM_LOAD 1, "d:/seq/master_5.abs" +END_BODY +END_MODULE +`; + if (allowedInVmlVersion('2.1')) { + assertNoErrorNodes(input, true); + } + }); + + it('no time tags', () => { + const input = ` +MODULE +BLOCK convert_bit_rate +INPUT bit_rate +DECLARE INT bit_rate_index := 0 +BODY +IF bit_rate < 6 THEN +bit_rate_index := 0 +ELSE_IF 6 <= bit_rate && bit_rate <= 1024 THEN +bit_rate_index := 1 + (bit_rate - 1) / 10 +ELSE +bit_rate_index := 103 +END_IF +RETURN bit_rate_index +END_BODY +BLOCK dsn_contact_start +INPUT STRING mode VALUES "standby", "half", "full" +INPUT bit_rate +DECLARE INT bit_rate_index := 0 +BODY +bit_rate_index := CALL convert_bit_rate bit_rate +ISSUE sspa_mode standby +R0.2 ISSUE set_power_switch sspa, on +R1.0 ISSUE_DYNAMIC "sspa_mode", mode +DELAY_BY gv_sspa_on_warmup_time +R4.0 ISSUE_DYNAMIC "transponder_on", bit_rate_index +gv_dsn_contact := TRUE +END_BODY +BLOCK dsn_contact_end +BODY +ISSUE sspa_mode standby +ISSUE set_power_switch sspa, off +gv_dsn_contact := FALSE +END_BODY +END_MODULE +`; + + if (allowedInVmlVersion('2.1')) { + assertNoErrorNodes(input, true); + } + }); +}); + +function printNodes(input: string): void { + for (const node of filterNodes(VmlLanguage.parser.parse(input).cursor())) { + printNode(input, node); + } +} + +function allowedInVmlVersion(minVmlVersion: string): boolean { + const minVersionArray = minVmlVersion.split('.').map(part => parseInt(part, 10)); + for (let i = 0; i < Math.max(grammarVmlVersion.length, minVersionArray.length); i++) { + if (minVersionArray[i] ?? 0 > grammarVmlVersion[i] ?? 0) { + return false; + } + } + return true; +} + +function wrapInModule(vmlBody: string): string { + return ` +MODULE +BLOCK placeholder +BODY +${vmlBody} +END_BODY +END_MODULE + `; +} + +function assertNoErrorNodes(input: string, printPrefix?: boolean): void { + const cursor = VmlLanguage.parser.parse(input).cursor(); + do { + const { node } = cursor; + if (printPrefix) { + if (node.type.name === TOKEN_ERROR) { + printNodes(input.substring(0, node.to + 10)); + console.log(input.substring(0, node.to)); + } + } + assert.notStrictEqual(node.type.name, TOKEN_ERROR); + } while (cursor.next()); +} + +function printNode(input: string, node: SyntaxNode): void { + console.log(`${node.type.name}[${node.from}.${node.to}] --> '${nodeContents(input, node)}'`); +} diff --git a/src/utilities/codemirror/vml/vml.ts b/src/utilities/codemirror/vml/vml.ts new file mode 100644 index 0000000000..d85d10f45a --- /dev/null +++ b/src/utilities/codemirror/vml/vml.ts @@ -0,0 +1,204 @@ +import { CompletionContext, type CompletionResult } from '@codemirror/autocomplete'; +import { LRLanguage, LanguageSupport, foldInside, foldNodeProp, syntaxTree } from '@codemirror/language'; +import { Decoration, ViewPlugin, type DecorationSet, type ViewUpdate } from '@codemirror/view'; +import type { SyntaxNode } from '@lezer/common'; +import { styleTags, tags as t } from '@lezer/highlight'; +import type { ChannelDictionary, CommandDictionary } from '@nasa-jpl/aerie-ampcs'; +import type { ISequenceAdaptation } from '../../../types/sequencing'; +import { getNearestAncestorNodeOfType } from '../../sequence-editor/tree-utils'; +import { blockMark } from '../themes/block'; +import { parser } from '../vml/vml.grammar'; +import { vmlAutoComplete } from './vmlAdaptation'; +import { + RULE_TIME_TAGGED_STATEMENT, + TOKEN_CALL, + TOKEN_DO, + TOKEN_ELSE, + TOKEN_ELSE_IF, + TOKEN_END_FOR, + TOKEN_END_IF, + TOKEN_END_WHILE, + TOKEN_EXTERNAL_CALL, + TOKEN_FOR, + TOKEN_IF, + TOKEN_ISSUE, + TOKEN_SPAWN, + TOKEN_STEP, + TOKEN_THEN, + TOKEN_TO, + TOKEN_WHILE, +} from './vmlConstants'; +import { computeBlocks, isBlockCommand, vmlBlockFolder } from './vmlFolder'; + +const FoldBehavior: { + [tokenName: string]: (node: SyntaxNode) => ReturnType; +} = { + // only called on multi-line rules, may need custom service to handle FOR, WHILE, etc. + Body: foldInside, + VML_HEADER: foldInside, +}; + +export const VmlLanguage = LRLanguage.define({ + languageData: { + commentTokens: { line: ';' }, + }, + name: 'vml', + parser: parser.configure({ + props: [ + foldNodeProp.add(FoldBehavior), + styleTags({ + ADD: t.arithmeticOperator, + ASSIGNMENT: t.updateOperator, + BLOCK: t.namespace, + BODY: t.namespace, + Comment: t.comment, + DAY_TIME_CONST: t.className, + DECLARE: t.keyword, + DELAY_BY: t.keyword, + DIVIDE: t.arithmeticOperator, + DOUBLE_CONST: t.number, + END_BODY: t.namespace, + END_MODULE: t.namespace, + EXTERNAL_CALL: t.controlKeyword, + FULL_TIME_CONST: t.className, + HEX_CONST: t.number, + INPUT: t.keyword, + INT_CONST: t.number, + ISSUE: t.controlKeyword, + MODULE: t.namespace, + MODULO: t.arithmeticOperator, + MULTIPLY: t.arithmeticOperator, + POWER: t.arithmeticOperator, + RETURN: t.keyword, + SEQUENCE: t.macroName, + SHORT_TIME_CONST: t.className, + SPACECRAFT_TIME_CONST: t.className, + SPAWN: t.controlKeyword, + STRING_CONST: t.string, + SUBTRACT: t.arithmeticOperator, + TIMEOUT: t.keyword, + UINT_CONST: t.number, + VML_EOF: t.docComment, + VML_HEADER: t.docComment, + Variable_name: t.variableName, + WAIT: t.keyword, + ...Object.fromEntries( + [ + TOKEN_EXTERNAL_CALL, + TOKEN_CALL, + TOKEN_SPAWN, + TOKEN_ISSUE, + // conditionals + TOKEN_IF, + TOKEN_THEN, + TOKEN_ELSE_IF, + TOKEN_ELSE, + TOKEN_END_IF, + // for + TOKEN_FOR, + TOKEN_TO, + TOKEN_STEP, + TOKEN_DO, + TOKEN_END_FOR, + // while + TOKEN_WHILE, + TOKEN_END_WHILE, + ].map(token => [token, t.controlKeyword]), + ), + }), + ], + }), +}); + +export function setupVmlLanguageSupport( + autocomplete?: (context: CompletionContext) => CompletionResult | null, +): LanguageSupport { + if (autocomplete) { + const autocompleteExtension = VmlLanguage.data.of({ autocomplete }); + return new LanguageSupport(VmlLanguage, [vmlBlockFolder, autocompleteExtension]); + } else { + return new LanguageSupport(VmlLanguage, [vmlBlockFolder]); + } +} + +export const vmlAdaptation: ISequenceAdaptation = { + argDelegator: undefined, + autoComplete: (_channelDictionary: ChannelDictionary | null, commandDictionary: CommandDictionary | null) => + vmlAutoComplete(commandDictionary), + inputFormat: { + linter: undefined, + name: 'SeqN', + toInputFormat: (vml: string) => Promise.resolve(vml), + }, + modifyOutput: undefined, + modifyOutputParse: undefined, + // vml input and output are identical + outputFormat: [], +}; + +function timeTaggedToKeyword(node: SyntaxNode | undefined | null): SyntaxNode | undefined { + return node?.firstChild?.nextSibling?.firstChild?.firstChild ?? undefined; +} + +export function vmlHighlightBlock(viewUpdate: ViewUpdate): SyntaxNode[] { + const tree = syntaxTree(viewUpdate.state); + 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); + // walk up the tree to Time_tagged_statement, and then back down to the block command e.g. "ELSE" + const timeTaggedNode = getNearestAncestorNodeOfType(updatedSelectionNode, [RULE_TIME_TAGGED_STATEMENT]) ?? undefined; + + const keywordNode = timeTaggedToKeyword(timeTaggedNode); + if (!keywordNode || !keywordNode.parent || !isBlockCommand(keywordNode.parent?.name)) { + return []; + } + + const blocks = computeBlocks(viewUpdate.state); + const pairs = Object.values(blocks); + const matchedNodes: SyntaxNode[] = [keywordNode]; + + // when cursor on end -- select else and if + let current: SyntaxNode | undefined = timeTaggedNode; + while (current) { + current = pairs.find(block => block.end?.from === current!.from)?.start; + const pairedStemToken = timeTaggedToKeyword(current); + if (pairedStemToken) { + matchedNodes.push(pairedStemToken); + } + } + + // when cursor on if -- select else and end + current = timeTaggedNode; + while (current) { + current = pairs.find(block => block.start?.from === current!.from)?.end; + const pairedStemToken = timeTaggedToKeyword(current); + if (pairedStemToken) { + matchedNodes.push(pairedStemToken); + } + } + + return matchedNodes; +} + +export const vmlBlockHighlighter = ViewPlugin.fromClass( + class { + decorations: DecorationSet; + constructor() { + this.decorations = Decoration.none; + } + update(viewUpdate: ViewUpdate): DecorationSet | null { + if (viewUpdate.selectionSet || viewUpdate.docChanged || viewUpdate.viewportChanged) { + const blocks = vmlHighlightBlock(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, + }, +); diff --git a/src/utilities/codemirror/vml/vmlAdaptation.ts b/src/utilities/codemirror/vml/vmlAdaptation.ts new file mode 100644 index 0000000000..8436915b47 --- /dev/null +++ b/src/utilities/codemirror/vml/vmlAdaptation.ts @@ -0,0 +1,111 @@ +import { type CompletionContext, type CompletionResult } from '@codemirror/autocomplete'; +import { syntaxTree } from '@codemirror/language'; +import type { CommandDictionary, FswCommand, FswCommandArgument } from '@nasa-jpl/aerie-ampcs'; +import { getNearestAncestorNodeOfType } from '../../sequence-editor/tree-utils'; +import { RULE_FUNCTION_NAME, RULE_ISSUE, RULE_STATEMENT, TOKEN_STRING_CONST } from './vmlConstants'; +import { getArgumentPosition } from './vmlTreeUtils'; + +export function vmlAutoComplete( + commandDictionary: CommandDictionary | null, +): (context: CompletionContext) => CompletionResult | null { + return (context: CompletionContext) => { + if (!commandDictionary) { + return null; + } + + const tree = syntaxTree(context.state); + const nodeBefore = tree.resolveInner(context.pos, -1); + const nodeCurrent = tree.resolveInner(context.pos, 0); + if (nodeBefore.name === RULE_ISSUE) { + return { + from: context.pos, + options: commandDictionary.fswCommands.map((fswCommand: FswCommand) => ({ + apply: getStemAndDefaultArguments(commandDictionary, fswCommand), + info: fswCommand.description, + label: fswCommand.stem, + section: 'Command', + type: 'function', + })), + }; + } else if (nodeCurrent.name === TOKEN_STRING_CONST) { + // also show if before argument + + const containingStatement = getNearestAncestorNodeOfType(nodeCurrent, [RULE_STATEMENT]); + if (containingStatement) { + const functionNameNode = containingStatement.firstChild?.getChild(RULE_FUNCTION_NAME); + if (functionNameNode) { + const stem = context.state.sliceDoc(functionNameNode.from, functionNameNode.to); + const cmdDef = commandDictionary.fswCommandMap[stem]; + if (!cmdDef) { + return null; + } + + const argPos = getArgumentPosition(nodeCurrent); + if (argPos === -1) { + return null; + } + + const argDef = cmdDef.arguments[argPos]; + if (!argDef || argDef.arg_type !== 'enum') { + return null; + } + + const enumValues = commandDictionary.enumMap[argDef.enum_name].values; + return { + filter: false, + from: nodeCurrent.from, + options: enumValues.map(enumValue => ({ + apply: `"${enumValue.symbol}"`, + label: `${enumValue.symbol} (${enumValue.numeric})`, + section: `${argDef.name} values`, + type: 'keyword', + })), + to: nodeCurrent.to, + }; + } + } + } + + return null; + }; +} + +function getStemAndDefaultArguments(commandDictionary: CommandDictionary, cmd: FswCommand): string { + if (cmd.arguments.length) { + return `${cmd.stem} ${cmd.arguments.map(argNode => getDefaultArgumentValue(commandDictionary, argNode)).join(',')}`; + } + return cmd.stem; +} + +function getDefaultArgumentValue(commandDictionary: CommandDictionary, argDef: FswCommandArgument): string { + switch (argDef.arg_type) { + case 'boolean': + return argDef.default_value ?? 'TRUE'; + case 'float': + case 'numeric': + case 'integer': + case 'unsigned': + // ignores conversion setting + return (argDef.default_value ?? argDef.range?.min)?.toString(10) ?? '0'; + case 'enum': + return `"${commandDictionary.enumMap[argDef.enum_name]?.values[0]?.symbol ?? ''}"`; + case 'var_string': + return '""'; + } + + return '""'; +} + +export function statementTypeCompletions(): string[] { + return [ + `WHILE condition DO`, + `END_WHILE`, + `FOR i := 1 TO mode STEP 2 DO`, + `END_FOR`, + `IF delay_time > 100.0 THEN`, + `ELSE_IF delay_time > 80.0 THEN`, + `ELSE`, + `END_IF`, + `ISSUE`, + ]; +} diff --git a/src/utilities/codemirror/vml/vmlBlockLibrary.ts b/src/utilities/codemirror/vml/vmlBlockLibrary.ts new file mode 100644 index 0000000000..bda41e72e2 --- /dev/null +++ b/src/utilities/codemirror/vml/vmlBlockLibrary.ts @@ -0,0 +1,251 @@ +import type { SyntaxNode } from '@lezer/common'; +import type { + CommandDictionary, + Enum, + FswCommand, + FswCommandArgument, + Header, + HwCommand, + NumericRange, +} from '@nasa-jpl/aerie-ampcs'; +import { VmlLanguage } from './vml'; +import { + RULE_BLOCK, + RULE_COMMENT, + RULE_COMMON_FUNCTION, + RULE_CONSTANT, + RULE_DATA_KIND, + RULE_FUNCTION, + RULE_FUNCTION_NAME, + RULE_FUNCTIONS, + RULE_INPUT_OUTPUT_PARAMETER, + RULE_INPUT_RANGE, + RULE_INPUT_VALUE, + RULE_OPTIONAL_DEFAULT_INPUT_VALUE, + RULE_OPTIONAL_VALUE_LIST, + RULE_PARAMETER, + RULE_PARAMETERS, + RULE_VARIABLE_NAME, + TOKEN_ABSOLUTE_TIME, + TOKEN_DOUBLE, + TOKEN_DOUBLE_CONST, + TOKEN_HEX_CONST, + TOKEN_INT, + TOKEN_INT_CONST, + TOKEN_INT_RANGE_CONST, + TOKEN_RELATIVE_TIME, + TOKEN_STRING, + TOKEN_TIME, + TOKEN_UINT, + TOKEN_UINT_CONST, +} from './vmlConstants'; + +export function vmlBlockLibraryToCommandDictionary(vml: string, id?: string, path?: string): CommandDictionary { + const parsed = VmlLanguage.parser.parse(vml); + + const enums: Enum[] = []; + const hwCommands: HwCommand[] = []; + const fswCommands: FswCommand[] = [ + ...(parsed.topNode.getChild(RULE_FUNCTIONS)?.getChildren(RULE_FUNCTION) ?? []).map(blockNode => + blockToCommandDef(blockNode, vml), + ), + ].filter((maybeCommandDef): maybeCommandDef is FswCommand => !!maybeCommandDef); + + const mission_name = ''; + const spacecraft_ids = [0]; + const version = ''; + + const header: Readonly
= { + mission_name, + schema_version: '1.0', + spacecraft_ids, + version, + }; + + return { + enumMap: Object.fromEntries(enums.map(e => [e.name, e])), + enums, + fswCommandMap: Object.fromEntries(fswCommands.map(cmd => [cmd.stem, cmd])), + fswCommands, + header, + hwCommandMap: Object.fromEntries(hwCommands.map(cmd => [cmd.stem, cmd])), + hwCommands, + id: id ?? '', + path: path ?? '', + }; +} + +function blockToCommandDef(functionNode: SyntaxNode, vml: string): FswCommand | null { + const commonFunctionNode = functionNode.getChild(RULE_BLOCK)?.getChild(RULE_COMMON_FUNCTION); + + const stemNode = commonFunctionNode?.getChild(RULE_FUNCTION_NAME); + const stem = stemNode && vml.slice(stemNode.from, stemNode.to); + + const parameterNodes = commonFunctionNode?.getChild(RULE_PARAMETERS)?.getChildren(RULE_PARAMETER) ?? []; + const fswArguments: FswCommandArgument[] = parameterNodes + ?.map(parameterNode => inputToArgument(parameterNode, vml)) + .filter((maybeArg): maybeArg is FswCommandArgument => !!maybeArg); + + if (stem) { + return { + argumentMap: Object.fromEntries(fswArguments.map(arg => [arg.name, arg])), + arguments: fswArguments, + description: '', + stem, + type: 'fsw_command', + }; + } + return null; +} + +function inputToArgument(parameterNode: SyntaxNode, vml: string): FswCommandArgument | null { + const nameNode = parameterNode.firstChild?.getChild(RULE_VARIABLE_NAME); + const name = nameNode && vml.slice(nameNode?.from, nameNode.to); + + if (!name) { + return null; + } + + const default_value: number | string | null = parseDefaultValue(parameterNode.firstChild, vml); + const description = parameterNodeToDescription(parameterNode, vml); + const units = ''; // not specified in VML + const range = parseRange(parameterNode.firstChild, vml); + // string arguments with ranges of string[] could converted to enums + // consider making singleton ranges 'fixed_string' type + + const dataKindNode = parameterNode.firstChild?.getChild(RULE_DATA_KIND)?.firstChild; + if (dataKindNode) { + switch (dataKindNode.name) { + case TOKEN_UINT: + case TOKEN_INT: + case TOKEN_DOUBLE: { + const arg_type: 'float' | 'integer' | 'unsigned' = ( + { + [TOKEN_DOUBLE]: 'float', + [TOKEN_INT]: 'integer', + [TOKEN_UINT]: 'unsigned', + } as const + )[dataKindNode.name]; + + const bit_length: number = dataKindNode.name === TOKEN_DOUBLE ? 64 : 32; + + return { + arg_type, + bit_length, + default_value: typeof default_value === 'number' ? default_value : null, + description, + name, + range: isNumericRange(range) ? range : null, + units, + }; + } + case TOKEN_STRING: { + return { + arg_type: 'var_string', + default_value: typeof default_value === 'string' ? default_value : null, + description, + max_bit_length: null, + name, + prefix_bit_length: null, + valid_regex: null, + }; + } + case TOKEN_TIME: + case TOKEN_ABSOLUTE_TIME: + case TOKEN_RELATIVE_TIME: { + return { + arg_type: 'time', + bit_length: 32, + default_value, + description, + name, + units, + }; + } + } + } + + // default to string type, no specific handling for LOGICAL, UNKNOWN + return { + arg_type: 'var_string', + default_value: '', + description, + max_bit_length: null, + name, + prefix_bit_length: null, + valid_regex: null, + }; +} + +function isNumericRange(range: any): range is NumericRange { + const castedRange = range as NumericRange; + return typeof castedRange?.min === 'number' && typeof range?.max === 'number'; +} + +function parseRange(parameterNode: SyntaxNode | null, vml: string): null | string[] | number[] | NumericRange { + const defaultValueNode = parameterNode?.getChild(RULE_OPTIONAL_VALUE_LIST)?.getChildren(RULE_INPUT_VALUE); + if (defaultValueNode) { + const rangeValues: (number | string | NumericRange)[] = defaultValueNode + .map(defValNode => { + const constantNode = defValNode.getChild(RULE_CONSTANT); + if (constantNode) { + return getConstantValue(constantNode, vml); + } + + const rangeNodes = defValNode.getChild(RULE_INPUT_RANGE)?.getChild(TOKEN_INT_RANGE_CONST); + if (rangeNodes) { + const minMaxStrings = vml.slice(rangeNodes.from, rangeNodes.to).split('..'); + if (minMaxStrings.length === 2) { + const [min, max] = vml + .slice(rangeNodes.from, rangeNodes.to) + .split('..') + .map(i => parseInt(i, 10)); + return { max, min }; + } + } + return null; + }) + .filter((maybeRangeValue): maybeRangeValue is number | string | NumericRange => !!maybeRangeValue); + + // mixed arrays aren't resolved due to undefined meaning + if (rangeValues.every(rangeValue => typeof rangeValue === 'number')) { + return rangeValues as number[]; + } else if (rangeValues.every(rangeValue => typeof rangeValue === 'string')) { + return rangeValues as string[]; + } else if (rangeValues.every(isNumericRange)) { + // ampcs dictionary doesn't support discontinuous ranges for numeric values, create span covering all ranges + return { + max: Math.max(...rangeValues.map(range => range.max)), + min: Math.min(...rangeValues.map(range => range.min)), + }; + } + } + return null; +} + +function parseDefaultValue(parameterNode: SyntaxNode | null, vml: string): number | string | null { + const defaultValueNode = parameterNode?.getChild(RULE_OPTIONAL_DEFAULT_INPUT_VALUE)?.getChild(RULE_CONSTANT); + return defaultValueNode ? getConstantValue(defaultValueNode, vml) : null; +} + +function getConstantValue(constantNode: SyntaxNode, vml: string): number | string | null { + const constantValueString = vml.slice(constantNode.from, constantNode.to); + switch (constantNode.firstChild?.name) { + case TOKEN_UINT_CONST: + case TOKEN_INT_CONST: + return parseInt(constantValueString, 10); + case TOKEN_HEX_CONST: + return parseInt(constantValueString, 16); + case TOKEN_DOUBLE_CONST: + return parseFloat(constantValueString); + } + + return null; +} + +function parameterNodeToDescription(parameterNode: SyntaxNode, vml: string): string { + const isInputOutputParameter = !!parameterNode.getChild(RULE_INPUT_OUTPUT_PARAMETER); + const ioType = isInputOutputParameter ? '[INPUT_OUTPUT] ' : '[INPUT] '; + const commentNode = parameterNode.firstChild?.getChild(RULE_COMMENT); + return commentNode ? ioType + vml.slice(commentNode.from, commentNode.to).slice(1).trim() : ''; +} diff --git a/src/utilities/codemirror/vml/vmlConstants.ts b/src/utilities/codemirror/vml/vmlConstants.ts new file mode 100644 index 0000000000..b78f1a4c91 --- /dev/null +++ b/src/utilities/codemirror/vml/vmlConstants.ts @@ -0,0 +1,95 @@ +// The value here must match the rule and token names in vml.grammar + +export const RULE_TEXT_FILE = 'Text_file'; + +export const RULE_FUNCTIONS = 'Functions'; +export const RULE_FUNCTION = 'Function'; + +export const RULE_ABSOLUTE_SEQUENCE = 'Absolute_Sequence'; + +export const RULE_COMMON_FUNCTION = 'Common_Function'; + +export const RULE_BODY = 'Body'; + +export const RULE_BLOCK = 'Block'; + +export const RULE_IF = 'If'; +export const RULE_ELSE_IF = 'Else_if'; +export const RULE_ELSE = 'Else'; +export const RULE_END_IF = 'End_if'; + +export const RULE_WHILE = 'While'; +export const RULE_END_WHILE = 'End_while'; + +export const RULE_FOR = 'For_statement'; +export const RULE_END_FOR = 'End_for'; + +export const RULE_ASSIGNMENT = 'Assignment'; +export const RULE_STATEMENT = 'Statement'; +export const RULE_TIME_TAGGED_STATEMENTS = 'Time_tagged_statements'; +export const RULE_TIME_TAGGED_STATEMENT = 'Time_tagged_statement'; +export const RULE_VM_MANAGEMENT = 'Vm_management'; +export const RULE_SPAWN = 'Spawn'; +export const RULE_HALT = 'Halt'; +export const RULE_PAUSE = 'Pause'; +export const RULE_RESUME = 'Resume'; +export const RULE_ISSUE = 'Issue'; +export const RULE_FUNCTION_NAME = 'Function_name'; +export const RULE_SIMPLE_EXPR = 'Simple_expr'; +export const RULE_CALL_PARAMETER = 'Call_parameter'; +export const RULE_CALL_PARAMETERS = 'Call_parameters'; +export const RULE_PARAMETER = 'Parameter'; +export const RULE_PARAMETERS = 'Parameters'; +export const RULE_FLOW = 'Flow'; +export const RULE_SIMPLE_CALL = 'Simple_call'; +export const RULE_EXTERNAL_CALL = 'External_call'; +export const RULE_CONSTANT = 'Constant'; +export const RULE_VARIABLE_NAME = 'Variable_name'; +export const RULE_DATA_KIND = 'Data_kind'; +export const RULE_COMMENT = 'Comment'; +export const RULE_INPUT_OUTPUT_PARAMETER = 'Input_output_parameter'; +export const RULE_OPTIONAL_DEFAULT_INPUT_VALUE = 'Optional_default_input_value'; +export const RULE_OPTIONAL_VALUE_LIST = 'Optional_value_list'; +export const RULE_INPUT_RANGE = 'Input_Range'; +export const RULE_INPUT_VALUE = 'Input_value'; + +export const GROUP_STATEMENT_SUB = 'StatementSub'; + +// Terminals in grammar +export const TOKEN_INT = 'INT'; +export const TOKEN_UINT = 'UINT'; +export const TOKEN_DOUBLE = 'DOUBLE'; +export const TOKEN_STRING = 'STRING'; +export const TOKEN_TIME = 'TIME'; +export const TOKEN_ABSOLUTE_TIME = 'ABSOLUTE_TIME'; +export const TOKEN_RELATIVE_TIME = 'RELATIVE_TIME'; + +export const TOKEN_ERROR = '⚠'; +export const TOKEN_COMMA = 'COMMA'; +export const TOKEN_STRING_CONST = 'STRING_CONST'; +export const TOKEN_DOUBLE_CONST = 'DOUBLE_CONST'; +export const TOKEN_INT_CONST = 'INT_CONST'; +export const TOKEN_UINT_CONST = 'UINT_CONST'; +export const TOKEN_HEX_CONST = 'HEX_CONST'; +export const TOKEN_TIME_CONST = 'TIME_CONST'; +export const TOKEN_INT_RANGE_CONST = 'INT_RANGE_CONST'; + +export const TOKEN_EXTERNAL_CALL = 'EXTERNAL_CALL'; +export const TOKEN_CALL = 'CALL'; +export const TOKEN_ISSUE = 'ISSUE'; +export const TOKEN_SPAWN = 'SPAWN'; + +export const TOKEN_IF = 'IF'; +export const TOKEN_ELSE_IF = 'ELSE_IF'; +export const TOKEN_ELSE = 'ELSE'; +export const TOKEN_END_IF = 'END_IF'; +export const TOKEN_THEN = 'THEN'; + +export const TOKEN_WHILE = 'WHILE'; +export const TOKEN_END_WHILE = 'END_WHILE'; + +export const TOKEN_FOR = 'FOR'; +export const TOKEN_TO = 'TO'; +export const TOKEN_STEP = 'STEP'; +export const TOKEN_DO = 'DO'; +export const TOKEN_END_FOR = 'END_FOR'; diff --git a/src/utilities/codemirror/vml/vmlFolder.ts b/src/utilities/codemirror/vml/vmlFolder.ts new file mode 100644 index 0000000000..ded303e59b --- /dev/null +++ b/src/utilities/codemirror/vml/vmlFolder.ts @@ -0,0 +1,156 @@ +import { foldService, syntaxTree } from '@codemirror/language'; +import type { EditorState } from '@codemirror/state'; +import type { SyntaxNode } from '@lezer/common'; +import { + RULE_ELSE, + RULE_ELSE_IF, + RULE_END_FOR, + RULE_END_IF, + RULE_END_WHILE, + RULE_FOR, + RULE_IF, + RULE_TIME_TAGGED_STATEMENT, + RULE_WHILE, +} from './vmlConstants'; + +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; +} + +const blocksForState = new WeakMap(); + +const blockOpeningStems: Set = new Set([RULE_IF, RULE_ELSE_IF, RULE_ELSE, RULE_WHILE, RULE_FOR]); + +const blockClosingStems: Set = new Set([RULE_ELSE, RULE_ELSE_IF, RULE_END_IF, RULE_END_WHILE, RULE_END_FOR]); + +export function isBlockCommand(stem: string): boolean { + return blockOpeningStems.has(stem) || blockClosingStems.has(stem); +} + +function closesBlock(stem: string, blockStem: string): boolean { + switch (stem) { + case RULE_END_IF: + return [RULE_IF, RULE_ELSE_IF, RULE_ELSE].includes(blockStem); + case RULE_ELSE: + case RULE_ELSE_IF: + return [RULE_IF, RULE_ELSE_IF].includes(blockStem); + case RULE_END_WHILE: + return blockStem === RULE_WHILE; + case RULE_END_FOR: + return blockStem === RULE_FOR; + } + return false; +} + +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 statementAndCategory: [SyntaxNode, string][] = []; + syntaxTree(state).iterate({ + enter: node => { + if (node.name === RULE_TIME_TAGGED_STATEMENT) { + const statementCategory = node.node.firstChild?.nextSibling?.firstChild?.name; + if (statementCategory && isBlockCommand(statementCategory)) { + statementAndCategory.push([node.node, statementCategory]); + } + } + }, + }); + + // console.log(`statementAndCategory: ${statementAndCategory.length}`); + + const treeState: TreeState = {}; + const stack: BlockStack = []; + const docString = state.sliceDoc(); + + statementAndCategory.forEach(([node, category]) => { + // const stem = state.sliceDoc(stemNode.from, stemNode.to); + const topStem = stack.at(-1)?.stem; + + if (topStem && closesBlock(category, 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(node.from).text; + const leadingSpaces = commandStr.length - commandStr.trimStart().length; + const endPos: undefined | number = node.from - leadingSpaces - 1; + Object.assign(treeState[blockInfo.node.from], { end: node, endPos }); + } + } else if (blockClosingStems.has(category)) { + // unexpected close + treeState[node.from] = { + end: node, + }; + return; // don't open a new block for else_if type + } + + if (blockOpeningStems.has(category)) { + // open new block + + // Time_tagged_statement -> Statement + // Statement -> Statement-subtype (If/Else_if/Else....) notably exclude Endlines + // Statement-subtype -> last token + const startPos = (node.getChild('Statement')?.firstChild?.lastChild ?? node).to; + // const startPos = (node.lastChild?.firstChild?.lastChild ?? node).to; + + treeState[node.from] = { + start: node, + startPos, + }; + + stack.push({ + node: node, + stem: category, + }); + } + }); + + blocksForState.set(state, treeState); + } + return blocksForState.get(state)!; +} + +export const vmlBlockFolder = foldService.of( + (state: EditorState, start: number, end: number): null | { from: number; to: number } => { + const blocks = computeBlocks(state); + for (let node: SyntaxNode | null = syntaxTree(state).resolveInner(end, -1); node; node = node.parent) { + if (node.from < start) { + break; + } + + const block = blocks[start]; + if (block?.startPos !== undefined && block?.endPos !== undefined) { + return { + from: block.startPos, + to: block.endPos, + }; + } + } + + return null; + }, +); diff --git a/src/utilities/codemirror/vml/vmlFormatter.ts b/src/utilities/codemirror/vml/vmlFormatter.ts new file mode 100644 index 0000000000..a8d2f3855f --- /dev/null +++ b/src/utilities/codemirror/vml/vmlFormatter.ts @@ -0,0 +1,318 @@ +import { syntaxTree } from '@codemirror/language'; +import type { ChangeSpec, EditorState } from '@codemirror/state'; +import type { SyntaxNode } from '@lezer/common'; +import { EditorView } from 'codemirror'; +import { + RULE_ASSIGNMENT, + RULE_CALL_PARAMETERS, + RULE_ELSE, + RULE_ELSE_IF, + RULE_END_FOR, + RULE_END_IF, + RULE_END_WHILE, + RULE_EXTERNAL_CALL, + RULE_FLOW, + RULE_FOR, + RULE_FUNCTION_NAME, + RULE_IF, + RULE_ISSUE, + RULE_SIMPLE_CALL, + RULE_SIMPLE_EXPR, + RULE_SPAWN, + RULE_STATEMENT, + RULE_TIME_TAGGED_STATEMENT, + RULE_VM_MANAGEMENT, + RULE_WHILE, + TOKEN_ERROR, + TOKEN_TIME_CONST, +} from './vmlConstants'; +import { computeBlocks } from './vmlFolder'; + +type LineOfNodes = (SyntaxNode | undefined)[]; + +const INDENT_COLUMN_INDEX: number = 1; + +const INDENT_SIZE = 2; + +const rulesWithNoCallParameters: Set = new Set([ + RULE_ASSIGNMENT, + RULE_IF, + RULE_ELSE_IF, + RULE_ELSE, + RULE_END_IF, + RULE_WHILE, + RULE_END_WHILE, + RULE_FOR, + RULE_END_FOR, + RULE_FLOW, +]); + +const rulesSupportingCallParameters: Set = new Set([ + RULE_VM_MANAGEMENT, + RULE_ISSUE, + RULE_SIMPLE_CALL, + RULE_EXTERNAL_CALL, +]); + +function usesTwoColumnFormat(statementType: string): boolean { + return rulesWithNoCallParameters.has(statementType); +} + +function usesTableFormat(statementType: string): boolean { + return rulesSupportingCallParameters.has(statementType); +} + +function isTypeWithCallParameters(node: SyntaxNode): boolean { + const parentName = node.parent?.name; + if (parentName && usesTableFormat(parentName)) { + return true; + } + // Vm_management type has subtypes, so need to walk up + return node.parent?.parent?.name === RULE_VM_MANAGEMENT; +} + +/* + Formats code into columns + + 0 - time + 1 - category + 2 - engine id (only present on 'Vm_management') + 3 - stem or block name + 4 - arguments +*/ +export function vmlFormat(view: EditorView): void { + const state = view.state; + const tree = syntaxTree(state); + + const timeTaggedStatements: SyntaxNode[] = []; + // gather spawn and issue type commands + tree.iterate({ + enter: nodeRef => { + if (nodeRef.name === RULE_TIME_TAGGED_STATEMENT) { + timeTaggedStatements.push(nodeRef.node); + } + }, + }); + + const errorFreeTimeTaggedStatements = timeTaggedStatements.filter(node => { + const ruleType = node.getChild(RULE_STATEMENT)?.firstChild?.name; + if (ruleType && (usesTwoColumnFormat(ruleType) || usesTableFormat(ruleType))) { + const childCursor = node.toTree().cursor(); + do { + // formatting algorithm doesn't correct for error tokens, ignore those lines + if (childCursor.node.name === TOKEN_ERROR) { + return false; + } + } while (childCursor.next()); + return true; + } + return false; + }); + + const linesToFormat: LineOfNodes[] = errorFreeTimeTaggedStatements + .map(splitLinesIntoColumns) + .filter((lineInfo): lineInfo is LineOfNodes => lineInfo !== null); + + // this computes indents, but changes applied need to be included in widths + const commandIndentChangeMap = indentCommandColumn(state, linesToFormat); + + const targetWidths: number[] = linesToFormat.reduce( + (maxWidthsByCol, currentRow) => + maxWidthsByCol.map((maxSoFar, columnIndex) => { + const cellToken = currentRow[columnIndex]; + if (cellToken) { + if (columnIndex === INDENT_COLUMN_INDEX) { + if (isTypeWithCallParameters(cellToken)) { + // include col[1] indentation in width calculation + const indentChange = commandIndentChangeMap.get(currentRow) as { insert?: string }; + const commandIndent = indentChange?.insert?.length ?? 0; + return Math.max(maxSoFar, commandIndent + cellToken.to - cellToken.from); + } else { + // IF, WHILE, ASSIGNMENT, ... consume rest of line + return maxSoFar; + } + } + return Math.max(maxSoFar, cellToken.to - cellToken.from); + } + return maxSoFar; + }), + new Array(4).fill(0), + ); + + const docText = state.toText(state.sliceDoc()); + + const maybeChanges = linesToFormat.flatMap((line: LineOfNodes) => { + const firstNode = line.find(maybeNode => !!maybeNode); + if (firstNode === undefined) { + // unexpected case of no nodes on line + return []; + } + + const commandLine = docText.lineAt(firstNode.from); + + const filteredArray: SyntaxNode[] = line.filter((maybeNode): maybeNode is SyntaxNode => !!maybeNode); + const deletions: ChangeSpec[] = []; + + // remove indentation at start of line + if (commandLine.from < firstNode.from) { + deletions.push({ + from: commandLine.from, + to: firstNode.from, + }); + } + + // collapse spacing between tokens + deletions.push( + ...filteredArray.slice(1).map((node, index) => ({ + from: filteredArray[index].to, + insert: ' ', + to: node.from, + })), + ); + + // don't insert whitespace after last token + const lineWithoutLastColumn = line.slice(0, line.length - 1); + const insertions: (ChangeSpec | null)[] = lineWithoutLastColumn.map( + (node: SyntaxNode | undefined, columnNumber: number) => { + if (!node) { + // no node, fill with engine width plus one space + const priorNode = line.slice(0, columnNumber).findLast(otherNode => !!otherNode); + if (priorNode) { + return { + from: priorNode.to, + insert: ' '.repeat(targetWidths[columnNumber] + 1), + }; + } + + return null; + } + + let length = node.to - node.from; + if (columnNumber === INDENT_COLUMN_INDEX && node && isTypeWithCallParameters(node)) { + // These values may be indented within column, so include indentation in their length + const indentChange = commandIndentChangeMap.get(line) as { insert?: string }; + const commandIndent = indentChange?.insert?.length ?? 0; + length += commandIndent; + } + + const pad = targetWidths[columnNumber] - length; + if (pad <= 0) { + return null; + } + return { + from: node.to, + insert: ' '.repeat(pad), + }; + }, + ); + + return [...deletions, ...insertions]; + }); + + const changes = [ + ...commandIndentChangeMap.values(), + ...maybeChanges.filter((maybeChange): maybeChange is ChangeSpec => !!maybeChange), + ]; + + // Consider delete end of line whitespace + // Consider alignment of comments + + view.update([ + state.update({ + changes, + }), + ]); +} + +/** + * Returns map of lines to changes that insert spaces at left edge of column[1], this leaves the times left aligned. + */ +function indentCommandColumn(state: EditorState, linesToFormat: LineOfNodes[]): Map { + const map: Map = new Map(); + const blocks = computeBlocks(state); + const blockValues = Object.values(blocks); + for (const line of linesToFormat) { + const startOfLine = line.find(cell => cell)?.from; + if (startOfLine !== undefined && line[INDENT_COLUMN_INDEX]) { + const numOpenedBlocks = blockValues.filter(block => block.start && block.start.from < startOfLine).length; + const numClosedBlocks = blockValues.filter(block => block.end && block.end.from <= startOfLine).length; + const indentLevel = Math.max(0, numOpenedBlocks - numClosedBlocks); + if (indentLevel) { + // whitespace in column, left of text value + const indentAmount = indentLevel * INDENT_SIZE; + const insert = ' '.repeat(indentAmount); + const from = line[INDENT_COLUMN_INDEX].from; + map.set(line, { from, insert }); + } + } + } + return map; +} + +function splitLinesIntoColumns(statement: SyntaxNode): LineOfNodes | null { + const timeNode = statement.getChild(TOKEN_TIME_CONST); + const statementTypeNode = statement.getChild(RULE_STATEMENT)?.firstChild; + const statementTypeName = statementTypeNode?.name; + + if (!statementTypeNode || !statementTypeName) { + return null; + } + + // account for block level + if (statementTypeName) { + if (usesTwoColumnFormat(statementTypeName)) { + if (timeNode) { + return [timeNode, statementTypeNode]; + } + } + } + + switch (statementTypeName) { + case RULE_VM_MANAGEMENT: + { + const vmManagementType = statementTypeNode.firstChild; + if (vmManagementType) { + const directiveNode = vmManagementType.firstChild; + const engineNode = vmManagementType.getChild(RULE_SIMPLE_EXPR); + switch (vmManagementType.name) { + case RULE_SPAWN: { + const functionNameNode = vmManagementType.getChild(RULE_FUNCTION_NAME); + const ruleParametersNode = vmManagementType.getChild(RULE_CALL_PARAMETERS); + if (timeNode && directiveNode && engineNode) { + return [ + timeNode, + directiveNode, + engineNode, + functionNameNode ?? undefined, + ruleParametersNode ?? undefined, + ]; + } + } + } + } + } + break; + case RULE_SIMPLE_CALL: + case RULE_EXTERNAL_CALL: + case RULE_ISSUE: { + const directiveNode = statementTypeNode.firstChild; + // Issue uses a function_name, calls may use a string literal or variable + const functionNameNode = statementTypeNode.getChild( + statementTypeName === RULE_ISSUE ? RULE_FUNCTION_NAME : RULE_SIMPLE_EXPR, + ); + const ruleParametersNode = statementTypeNode.getChild(RULE_CALL_PARAMETERS); + if (timeNode && directiveNode && functionNameNode) { + return [ + timeNode, + directiveNode, + undefined /* reserved for engine number */, + functionNameNode, + ruleParametersNode ?? undefined, + ]; + } + break; + } + } + + return null; +} diff --git a/src/utilities/codemirror/vml/vmlLinter.ts b/src/utilities/codemirror/vml/vmlLinter.ts new file mode 100644 index 0000000000..75e4e9c68b --- /dev/null +++ b/src/utilities/codemirror/vml/vmlLinter.ts @@ -0,0 +1,302 @@ +import { syntaxTree } from '@codemirror/language'; +import { linter, type Diagnostic } from '@codemirror/lint'; +import type { Extension, Text } from '@codemirror/state'; +import type { SyntaxNode, Tree } from '@lezer/common'; +import type { CommandDictionary, FswCommand, FswCommandArgument } from '@nasa-jpl/aerie-ampcs'; +import type { EditorView } from 'codemirror'; +import { closest } from 'fastest-levenshtein'; +import { filterNodes } from '../../sequence-editor/tree-utils'; +import { VmlLanguage } from './vml'; +import { + RULE_CALL_PARAMETER, + RULE_CALL_PARAMETERS, + RULE_FUNCTION_NAME, + RULE_ISSUE, + TOKEN_DOUBLE_CONST, + TOKEN_ERROR, + TOKEN_HEX_CONST, + TOKEN_INT_CONST, + TOKEN_STRING_CONST, + TOKEN_UINT_CONST, +} from './vmlConstants'; + +/** + * Limitations + * + * * Variables aren't checked, defer to external engine to determine if they exist when referenced + */ + +// Absolute time tags may appear in functions beginning with SEQUENCE or ABSOLUTE_SEQUENCE +// Functions beginning with BLOCK or RELATIVE_SEQUENCE may have only relative time tags. + +// Limit how many grammar problems are annotated +const MAX_PARSER_ERRORS = 100; + +export function vmlLinter(commandDictionary: CommandDictionary | null = null): Extension { + return linter(view => { + const diagnostics: Diagnostic[] = []; + const tree = syntaxTree(view.state); + const sequence = view.state.sliceDoc(); + diagnostics.push(...validateParserErrors(tree, sequence, view.state.toText(sequence))); + if (!commandDictionary) { + return diagnostics; + } + + const parsed = VmlLanguage.parser.parse(sequence); + + diagnostics.push(...validateCommands(commandDictionary, sequence, parsed)); + + return diagnostics; + }); +} + +function validateCommands(commandDictionary: CommandDictionary, docText: string, parsed: Tree): Diagnostic[] { + const diagnostics: Diagnostic[] = []; + const cursor = parsed.cursor(); + do { + const { node } = cursor; + const tokenType = node.type.name; + + if (tokenType === RULE_ISSUE) { + const functionNameNode = node.getChild(RULE_FUNCTION_NAME); + if (functionNameNode) { + const functionName = docText.slice(functionNameNode.from, functionNameNode.to); + const commandDef = commandDictionary.fswCommandMap[functionName]; + if (!commandDef) { + const alternative = closest(functionName, Object.keys(commandDictionary.fswCommandMap)); + const { from, to } = functionNameNode; + diagnostics.push({ + actions: [ + { + apply(view: EditorView, from: number, to: number) { + view.dispatch({ + changes: { + from, + insert: alternative, + to, + }, + }); + }, + name: `Change to ${alternative}`, + }, + ], + from, + message: `Unknown function name ${functionName}`, + severity: 'error', + to, + }); + } else { + diagnostics.push(...validateArguments(commandDictionary, commandDef, node, functionNameNode, docText)); + } + } + } + } while (cursor.next()); + return diagnostics; +} + +function validateArguments( + commandDictionary: CommandDictionary, + commandDef: FswCommand, + functionNode: SyntaxNode, + functionNameNode: SyntaxNode, + docText: string, +): Diagnostic[] { + const diagnostics: Diagnostic[] = []; + const parametersNode = functionNode.getChild(RULE_CALL_PARAMETERS)?.getChildren(RULE_CALL_PARAMETER) ?? []; + const functionName = docText.slice(functionNameNode.from, functionNameNode.to); + for (let i = 0; i < commandDef.arguments.length; i++) { + const argDef: FswCommandArgument | undefined = commandDef.arguments[i]; + const argNode = parametersNode[i]; + + if (argDef && argNode) { + // validate expected argument + diagnostics.push(...validateArgument(commandDictionary, argDef, argNode, docText)); + } else if (!argNode && !!argDef) { + const { from, to } = functionNameNode; + diagnostics.push({ + from, + message: `${functionName} is missing argument ${argDef.name}`, + severity: 'error', + to, + }); + } + } + const extraArgs = parametersNode.slice(commandDef.arguments.length); + diagnostics.push( + ...extraArgs.map((extraArg: SyntaxNode): Diagnostic => { + const { from, to } = extraArg; + return { + from, + message: `${functionName} has an extra argument ${docText.slice(from, to)}`, + severity: 'error', + to, + }; + }), + ); + return diagnostics; +} + +function validateArgument( + commandDictionary: CommandDictionary, + argDef: FswCommandArgument, + argNode: SyntaxNode, + docText: string, +): Diagnostic[] { + const diagnostics: Diagnostic[] = []; + + // could also be a variable + const constantNode = argNode.getChild('Simple_expr')?.getChild('Constant')?.firstChild; + + if (constantNode) { + const { from, to } = constantNode; + switch (argDef.arg_type) { + case 'integer': + { + if (![TOKEN_INT_CONST, TOKEN_UINT_CONST, TOKEN_HEX_CONST].includes(constantNode.name)) { + return [ + { + from, + message: `Expected integer value`, + severity: 'error', + to, + }, + ]; + } else if (argDef.range) { + // TODO: CDL dictionary provides a conversion, HEX arguments should prefer hexadecimal + const base = constantNode.name === TOKEN_HEX_CONST ? 16 : 10; + const argValue = parseInt(docText.slice(argNode.from, argNode.to), base); + if (argValue < argDef.range.min || argValue > argDef.range.max) { + return [ + { + from, + message: `Value should be between ${argDef.range.min.toString(base)} and ${argDef.range.max.toString(base)}`, + severity: 'error', + to, + }, + ]; + } + } + } + break; + case 'float': + if (![TOKEN_INT_CONST, TOKEN_DOUBLE_CONST].includes(constantNode.name)) { + return [ + { + from, + message: `Expected float or integer value`, + severity: 'error', + to, + }, + ]; + } else if (argDef.range) { + const argValue = parseFloat(docText.slice(argNode.from, argNode.to)); + if (argValue < argDef.range.min || argValue > argDef.range.max) { + return [ + { + from, + message: `Value should be between ${argDef.range.min.toString()} and ${argDef.range.max.toString()}`, + severity: 'error', + to, + }, + ]; + } + } + break; + case 'fixed_string': + case 'var_string': + if (TOKEN_STRING_CONST !== constantNode.name) { + return [ + { + from, + message: `Expected string value`, + severity: 'error', + to, + }, + ]; + } + break; + case 'enum': { + if (TOKEN_STRING_CONST !== constantNode.name) { + return [ + { + from, + message: `Expected type ${constantNode.name} for enum argument`, + severity: 'error', + to, + }, + ]; + } else { + const enumVal = unquote(docText.slice(constantNode.from, constantNode.to)); + const enumDef = commandDictionary.enumMap[argDef.enum_name]; + if (enumDef) { + const allowedValues = enumDef.values.map(ev => ev.symbol); + if (!allowedValues.includes(enumVal)) { + const alternative = `"${closest(enumVal, allowedValues)}"`; + return [ + { + actions: [ + { + apply(view: EditorView, from: number, to: number) { + view.dispatch({ + changes: { + from, + insert: alternative, + to, + }, + }); + }, + name: `Change to ${alternative}`, + }, + ], + from, + message: `Unexpected enum value ${enumVal}`, + severity: 'error', + to, + }, + ]; + } + } + } + break; + } + } + } + + return diagnostics; +} + +function unquote(s: string): string { + return s.slice(1, s.length - 1); +} + +/** + * Checks for unexpected tokens. + * + * @param tree + * @returns + */ +function validateParserErrors(tree: Tree, sequence: string, text: Text): Diagnostic[] { + const errorRegions: { from: number; to: number }[] = []; + for (const node of filterNodes(tree.cursor(), node => node.name === TOKEN_ERROR)) { + const currentRegion = errorRegions.at(-1); + if (currentRegion?.to === node.from) { + currentRegion.to = node.to; + } else { + errorRegions.push({ from: node.from, to: node.to }); + } + + if (errorRegions.length > MAX_PARSER_ERRORS) { + break; + } + } + + return errorRegions.slice(0, MAX_PARSER_ERRORS).map(({ from, to }) => { + const line = text.lineAt(from); + return { + from, + message: `Unexpected token: "${sequence.slice(from, to)}" [Line ${line.number}, Col ${from - line.from}]`, + severity: 'error', + to, + }; + }); +} diff --git a/src/utilities/codemirror/vml/vmlTooltip.ts b/src/utilities/codemirror/vml/vmlTooltip.ts new file mode 100644 index 0000000000..40b5df8fe9 --- /dev/null +++ b/src/utilities/codemirror/vml/vmlTooltip.ts @@ -0,0 +1,147 @@ +import { syntaxTree } from '@codemirror/language'; +import type { Extension } from '@codemirror/state'; +import { hoverTooltip, type Tooltip } from '@codemirror/view'; +import type { + CommandDictionary, + FswCommand, + FswCommandArgument, + FswCommandArgumentInteger, +} from '@nasa-jpl/aerie-ampcs'; +import type { EditorView } from 'codemirror'; +import ArgumentTooltip from '../../../components/sequencing/ArgumentTooltip.svelte'; +import CommandTooltip from '../../../components/sequencing/CommandTooltip.svelte'; +import { getTokenPositionInLine } from '../../sequence-editor/sequence-tooltip'; +import { checkContainment, getNearestAncestorNodeOfType } from '../../sequence-editor/tree-utils'; +import { + RULE_CALL_PARAMETER, + RULE_CALL_PARAMETERS, + RULE_CONSTANT, + RULE_FUNCTION_NAME, + RULE_ISSUE, + RULE_SIMPLE_EXPR, + RULE_STATEMENT, + RULE_TIME_TAGGED_STATEMENT, + RULE_VM_MANAGEMENT, + TOKEN_INT_CONST, +} from './vmlConstants'; + +const sequenceEngineArgument: FswCommandArgumentInteger = { + arg_type: 'integer', + bit_length: 8, + default_value: null, + description: `Sequence Engine / Virtual Machine. -1 is used to specify next available engine`, + name: `Sequence Engine / Virtual Machine`, + range: null, + units: '', +}; + +export function vmlTooltip(commandDictionary: CommandDictionary | null): Extension { + return hoverTooltip((view: EditorView, pos: number, side: number): Tooltip | null => { + const { from, to } = getTokenPositionInLine(view, pos); + + // First handle the case where the token is out of bounds. + if ((from === pos && side < 0) || (to === pos && side > 0)) { + return null; + } + + const tree = syntaxTree(view.state); + const cursorNode = tree.cursorAt(from, 1).node; + + const timeTaggedNode = getNearestAncestorNodeOfType(cursorNode, [RULE_TIME_TAGGED_STATEMENT]); + + if (!timeTaggedNode) { + return null; + } + const statementSubNode = timeTaggedNode.getChild(RULE_STATEMENT)?.firstChild; + + if (!statementSubNode) { + return null; + } + + switch (statementSubNode.name) { + case RULE_VM_MANAGEMENT: + { + if ( + checkContainment(cursorNode, [ + RULE_VM_MANAGEMENT, + undefined, + RULE_SIMPLE_EXPR, + RULE_CONSTANT, + TOKEN_INT_CONST, + ]) + ) { + return argTooptip(sequenceEngineArgument, null, from, to); + } + } + break; + case RULE_ISSUE: { + const functionNameNode = statementSubNode.getChild(RULE_FUNCTION_NAME); + if (functionNameNode) { + const commandName = view.state.sliceDoc(functionNameNode.from, functionNameNode.to); + const command = commandDictionary?.fswCommandMap[commandName]; + if (command) { + const callParametersNode = getNearestAncestorNodeOfType(cursorNode, [RULE_CALL_PARAMETERS]); + if (callParametersNode) { + const thisCallParameterNode = + cursorNode.name === RULE_CALL_PARAMETER + ? cursorNode + : getNearestAncestorNodeOfType(cursorNode, [RULE_CALL_PARAMETER]); + + if (thisCallParameterNode) { + const parameterNodes = callParametersNode.getChildren(RULE_CALL_PARAMETER); + const argIndex = parameterNodes.findIndex( + callParameterNode => + callParameterNode.to === thisCallParameterNode.to && + callParameterNode.from === thisCallParameterNode.from, + ); + + const arg = command.arguments[argIndex]; + if (arg) { + return argTooptip(arg, commandDictionary, from, to); + } + } + } + + return cmdTooltip(command, from, to); + } + } + } + } + + return null; + }); +} + +function argTooptip( + arg: FswCommandArgument, + commandDictionary: CommandDictionary | null, + from: number, + to: number, +): Tooltip { + return { + above: true, + create() { + const dom = document.createElement('div'); + new ArgumentTooltip({ + props: { arg, commandDictionary }, + target: dom, + }); + return { dom }; + }, + end: to, + pos: from, + }; +} + +function cmdTooltip(command: FswCommand, from: number, to: number): Tooltip { + return { + above: true, + create() { + const dom = document.createElement('div'); + new CommandTooltip({ props: { command }, target: dom }); + return { dom }; + }, + end: to, + pos: from, + }; +} diff --git a/src/utilities/codemirror/vml/vmlTreeUtils.test.ts b/src/utilities/codemirror/vml/vmlTreeUtils.test.ts new file mode 100644 index 0000000000..14d9920fa6 --- /dev/null +++ b/src/utilities/codemirror/vml/vmlTreeUtils.test.ts @@ -0,0 +1,81 @@ +import type { SyntaxNode } from '@lezer/common'; +import { describe, expect, test } from 'vitest'; +import { filterNodes, nodeContents } from '../../sequence-editor/tree-utils'; +import { VmlLanguage } from './vml'; +import { RULE_CALL_PARAMETERS, RULE_FUNCTION_NAME, RULE_TIME_TAGGED_STATEMENT } from './vmlConstants'; +import { getArgumentPosition, VmlCommandInfoMapper } from './vmlTreeUtils'; + +describe('vml command info mapper', () => { + const input = `MODULE + +RELATIVE_SEQUENCE vnv +FLAGS AUTOEXECUTE AUTOUNLOAD +BODY +;initialize variables +R00:00:01.00 ISSUE CMD_001 "ENUM_B",FALSE_VM_CONST +R00:00:01.00 ISSUE CMD_002 1,2,3,4 + + +;TEST CASE 1 +R00:01:00 CALL pay_spawn "seis_pwr_on_r01_1" + +END_BODY +END_MODULE +`; + const parsed = VmlLanguage.parser.parse(input); + const vmlCommandInfoMapper = new VmlCommandInfoMapper(); + const timeTaggedNodes: SyntaxNode[] = Array.from( + filterNodes(parsed.cursor(), (node: SyntaxNode) => node.name === RULE_TIME_TAGGED_STATEMENT), + ); + + test('time tagged count', () => { + expect(timeTaggedNodes.length).toBe(3); + }); + + test.each([ + [0, 'CMD_001'], + [1, 'CMD_002'], + [2, null], + ])('command %i name %s', (statementIndex: number, expectedStem: string | null) => { + const nameNode = vmlCommandInfoMapper.getNameNode(timeTaggedNodes[statementIndex]); + if (expectedStem) { + expect(nameNode).toBeDefined(); + expect(nameNode!.name).toBe(RULE_FUNCTION_NAME); + expect(nodeContents(input, nameNode!)).toBe(expectedStem); + } + }); + + test.each([ + [0, 2], + [1, 4], + [2, 1], + ])('command %i argument count %s', (statementIndex: number, argCount: number) => { + const argContainer = vmlCommandInfoMapper.getArgumentNodeContainer(timeTaggedNodes[statementIndex]); + expect(argContainer).toBeDefined(); + expect(argContainer!.name).toBe(RULE_CALL_PARAMETERS); + expect(vmlCommandInfoMapper.getArgumentsFromContainer(argContainer!).length).toBe(argCount); + }); + + test.each([ + ['"ENUM_B"', 0], + ['FALSE_VM_CONST', 1], + ['1', 0], + ['2', 1], + ['3', 2], + ['4', 3], + ])("argument value '%s' index %i", (argumentValue: string, argIndexInCommand: number) => { + const argValueFilter = (node: SyntaxNode) => nodeContents(input, node) === argumentValue; + const argNode = filterNodes(parsed.cursor(), argValueFilter).next().value as SyntaxNode; + expect(argNode).toBeDefined(); + expect(getArgumentPosition(argNode)).toBe(argIndexInCommand); + }); + + test('arg insert position', () => { + const uniqArgValue = 'FALSE_VM_CONST'; + const inputPosition = input.indexOf(uniqArgValue) + uniqArgValue.length; + const argValueFilter = (node: SyntaxNode) => nodeContents(input, node) === uniqArgValue; + const argNode = filterNodes(parsed.cursor(), argValueFilter).next().value as SyntaxNode; + const cmdNode = vmlCommandInfoMapper.getContainingCommand(argNode); + expect(vmlCommandInfoMapper.getArgumentAppendPosition(cmdNode)).toBe(inputPosition); + }); +}); diff --git a/src/utilities/codemirror/vml/vmlTreeUtils.ts b/src/utilities/codemirror/vml/vmlTreeUtils.ts new file mode 100644 index 0000000000..231fdc878f --- /dev/null +++ b/src/utilities/codemirror/vml/vmlTreeUtils.ts @@ -0,0 +1,85 @@ +import type { SyntaxNode } from '@lezer/common'; +import { getChildrenNode, getNearestAncestorNodeOfType } from '../../sequence-editor/tree-utils'; +import type { CommandInfoMapper } from '../commandInfoMapper'; +import { + RULE_CALL_PARAMETER, + RULE_CALL_PARAMETERS, + RULE_CONSTANT, + RULE_FUNCTION_NAME, + RULE_ISSUE, + RULE_SIMPLE_EXPR, + RULE_STATEMENT, + RULE_TIME_TAGGED_STATEMENT, + TOKEN_COMMA, + TOKEN_INT_CONST, + TOKEN_STRING_CONST, +} from './vmlConstants'; + +export class VmlCommandInfoMapper implements CommandInfoMapper { + formatArgumentArray(values: string[], commandNode: SyntaxNode | null): string { + let prefix = ' '; + if (commandNode?.name === RULE_TIME_TAGGED_STATEMENT) { + const callParametersNode = commandNode.firstChild?.nextSibling?.firstChild?.getChild(RULE_CALL_PARAMETERS); + if (callParametersNode) { + const hasParametersSpecified = !!callParametersNode.getChild(RULE_CALL_PARAMETER); + if (hasParametersSpecified) { + const children = getChildrenNode(callParametersNode); + const hasTrailingComma = + children.findLastIndex(node => node.name === TOKEN_COMMA) > + children.findLastIndex(node => node.name === RULE_CALL_PARAMETER); + prefix = hasTrailingComma ? '' : ','; + } + } + } + return prefix + values.join(','); + } + + getArgumentAppendPosition(node: SyntaxNode | null): number | undefined { + if (node?.name === RULE_TIME_TAGGED_STATEMENT) { + return node.firstChild?.nextSibling?.firstChild?.getChild(RULE_CALL_PARAMETERS)?.to ?? undefined; + } + return node?.getChild(RULE_CALL_PARAMETERS)?.to ?? undefined; + } + + getArgumentNodeContainer(commandNode: SyntaxNode | null): SyntaxNode | null { + return commandNode?.getChild(RULE_STATEMENT)?.firstChild?.getChild(RULE_CALL_PARAMETERS) ?? null; + } + + getArgumentsFromContainer(containerNode: SyntaxNode): SyntaxNode[] { + return containerNode?.getChildren(RULE_CALL_PARAMETER) ?? []; + } + + getContainingCommand(node: SyntaxNode | null): SyntaxNode | null { + return getNearestAncestorNodeOfType(node, [RULE_TIME_TAGGED_STATEMENT]); + } + + getNameNode(statementNode: SyntaxNode | null): SyntaxNode | null { + const statementSubNode = statementNode?.getChild(RULE_STATEMENT)?.getChild(RULE_ISSUE); + if (statementSubNode?.name === RULE_ISSUE) { + return statementSubNode.getChild(RULE_FUNCTION_NAME); + } + // once block library is implemented allow spawn here too + return null; + } + + nodeTypeEnumCompatible(node: SyntaxNode | null): boolean { + return !!node?.getChild(RULE_SIMPLE_EXPR)?.getChild(RULE_CONSTANT)?.getChild(TOKEN_STRING_CONST); + } + + nodeTypeHasArguments(node: SyntaxNode | null): boolean { + return node?.name === RULE_TIME_TAGGED_STATEMENT; + } + + nodeTypeNumberCompatible(node: SyntaxNode | null): boolean { + return !!node?.getChild(RULE_SIMPLE_EXPR)?.getChild(RULE_CONSTANT)?.getChild(TOKEN_INT_CONST); + } +} + +export function getArgumentPosition(argNode: SyntaxNode): number { + return ( + getNearestAncestorNodeOfType(argNode, [RULE_STATEMENT]) + ?.firstChild?.getChild(RULE_CALL_PARAMETERS) + ?.getChildren(RULE_CALL_PARAMETER) + ?.findIndex(par => par.from === argNode.from && par.to === argNode.to) ?? -1 + ); +} diff --git a/src/utilities/effects.ts b/src/utilities/effects.ts index ad1610ec6a..318cd26adc 100644 --- a/src/utilities/effects.ts +++ b/src/utilities/effects.ts @@ -213,6 +213,7 @@ import type { import type { Row, Timeline } from '../types/timeline'; import type { View, ViewDefinition, ViewInsertInput, ViewSlim, ViewUpdateInput } from '../types/view'; import { ActivityDeletionAction } from './activities'; +import { parseCdlDictionary, toAmpcsXml } from './codemirror/cdlDictionary'; import { compare, convertToQuery, getSearchParameterNumber, setQueryParam } from './generic'; import gql, { convertToGQLArray } from './gql'; import { @@ -6367,6 +6368,12 @@ const effects = { throwPermissionError(`upload a dictionary`); } + if (dictionary.split('\n').find(line => /^PROJECT\s*:\s*"([^"]*)"/.test(line))) { + // convert cdl to ampcs format, consider moving to aerie backend after decision on XTCE + // eslint-disable-next-line no-control-regex + dictionary = toAmpcsXml(parseCdlDictionary(dictionary)).replaceAll(/[^\x00-\x7F]+/g, ''); + } + const data = await reqHasura<{ channel?: ChannelDictionary; command?: CommandDictionary; diff --git a/src/utilities/sequence-editor/extension-points.ts b/src/utilities/sequence-editor/extension-points.ts index 8d238f41d8..ac7700aec1 100644 --- a/src/utilities/sequence-editor/extension-points.ts +++ b/src/utilities/sequence-editor/extension-points.ts @@ -66,9 +66,14 @@ export async function toInputFormat( modifiedOutput = `${modifiedOutput}`; } - return await get(inputFormat)?.toInputFormat?.(modifiedOutput); + return (await get(inputFormat)?.toInputFormat?.(modifiedOutput)) ?? output; } else { - return await get(inputFormat)?.toInputFormat?.(output); + try { + return (await get(inputFormat)?.toInputFormat?.(output)) ?? output; + } catch (e) { + console.error(e); + return output; + } } } diff --git a/src/utilities/sequence-editor/sequence-autoindent.ts b/src/utilities/sequence-editor/sequence-autoindent.ts index d9210ff878..661d41d30c 100644 --- a/src/utilities/sequence-editor/sequence-autoindent.ts +++ b/src/utilities/sequence-editor/sequence-autoindent.ts @@ -1,4 +1,6 @@ +import { indentSelection } from '@codemirror/commands'; import { syntaxTree, type IndentContext } from '@codemirror/language'; +import { EditorView } from 'codemirror'; import { computeBlocks } from '../codemirror/custom-folder'; import { getNearestAncestorNodeOfType } from './tree-utils'; @@ -54,3 +56,22 @@ export function sequenceAutoIndent(): (context: IndentContext, pos: number) => n return 0; }; } + +export function seqNFormat(editorSequenceView: EditorView) { + // apply indentation + editorSequenceView.update([ + editorSequenceView.state.update({ + selection: { anchor: 0, head: editorSequenceView.state.doc.length }, + }), + ]); + indentSelection({ + dispatch: transaction => editorSequenceView.update([transaction]), + state: editorSequenceView.state, + }); + // clear selection + editorSequenceView.update([ + editorSequenceView.state.update({ + selection: { anchor: 0, head: 0 }, + }), + ]); +} diff --git a/src/utilities/sequence-editor/sequence-linter.ts b/src/utilities/sequence-editor/sequence-linter.ts index b165fb5879..6f00786a5d 100644 --- a/src/utilities/sequence-editor/sequence-linter.ts +++ b/src/utilities/sequence-editor/sequence-linter.ts @@ -21,6 +21,7 @@ import { getGlobals } from '../../stores/sequence-adaptation'; import { CustomErrorCodes } from '../../workers/customCodes'; import { addDefaultArgs, isHexValue, parseNumericArg, quoteEscape } from '../codemirror/codemirror-utils'; import { closeSuggestion, computeBlocks, openSuggestion } from '../codemirror/custom-folder'; +import { SeqNCommandInfoMapper } from '../codemirror/seq-n-tree-utils'; import { getBalancedDuration, getDoyTime, @@ -1025,7 +1026,13 @@ function validateAndLintArguments( { apply(view) { if (commandDictionary) { - addDefaultArgs(commandDictionary, view, command, dictArgs.slice(argNode.length)); + addDefaultArgs( + commandDictionary, + view, + command, + dictArgs.slice(argNode.length), + new SeqNCommandInfoMapper(), + ); } }, name: `Add default missing argument${pluralS}`, diff --git a/src/utilities/sequence-editor/sequence-tooltip.ts b/src/utilities/sequence-editor/sequence-tooltip.ts index 639b703d27..310e414bdc 100644 --- a/src/utilities/sequence-editor/sequence-tooltip.ts +++ b/src/utilities/sequence-editor/sequence-tooltip.ts @@ -33,7 +33,7 @@ function getParentNodeByName(view: EditorView, pos: number, name: string): Synta * Returns a text token range for a line in the view at a given position. * @see https://codemirror.net/examples/tooltip/#hover-tooltips */ -function getTokenPositionInLine(view: EditorView, pos: number) { +export function getTokenPositionInLine(view: EditorView, pos: number) { const { from, to, text } = view.state.doc.lineAt(pos); const tokenRegex = /[a-zA-Z0-9_".-]/; diff --git a/src/utilities/sequence-editor/to-seq-json.test.ts b/src/utilities/sequence-editor/to-seq-json.test.ts index 3e7b7723ba..fc77fc2a64 100644 --- a/src/utilities/sequence-editor/to-seq-json.test.ts +++ b/src/utilities/sequence-editor/to-seq-json.test.ts @@ -491,7 +491,8 @@ C ECHO SIZE `@INPUT_PARAMS L01STR L02STR`, `@LOCALS L01INT L02INT L01UINT L02UINT`, ]); - permutations.forEach(async (ordering: string[]) => { + + for (const ordering of permutations) { const input = ordering.join('\n\n'); const actual = JSON.parse(await sequenceToSeqJson(SeqLanguage.parser.parse(input), input, commandBanana, 'id')); const expected = { @@ -527,7 +528,7 @@ C ECHO SIZE ], }; expect(actual).toEqual(expected); - }); + } }); it('Convert quoted strings', async () => { diff --git a/src/utilities/sequence-editor/tree-utils.ts b/src/utilities/sequence-editor/tree-utils.ts index 6e33c08e14..c933c23870 100644 --- a/src/utilities/sequence-editor/tree-utils.ts +++ b/src/utilities/sequence-editor/tree-utils.ts @@ -1,4 +1,4 @@ -import type { SyntaxNode } from '@lezer/common'; +import type { SyntaxNode, TreeCursor } from '@lezer/common'; export function numberOfChildren(node: SyntaxNode): number { let count = 0; @@ -54,3 +54,38 @@ export function getNearestAncestorNodeOfType(node: SyntaxNode | null, ancestorTy } return ancestorNode; } + +/** + * + * @param node + * @param typesOfAncestorsAndSelf - array of node types to check containment [ great-grandparent, grandparent, undefined, selfType ] + * @returns if node type and container matches criteria + */ +export function checkContainment(node: SyntaxNode, typesOfAncestorsAndSelf: (string | undefined)[]): boolean { + if (typesOfAncestorsAndSelf.length === 0) { + return true; + } + + const comp = typesOfAncestorsAndSelf[typesOfAncestorsAndSelf.length - 1]; + if (comp === undefined || node.name === comp) { + return ( + !!node.parent && + checkContainment(node.parent, typesOfAncestorsAndSelf.slice(0, typesOfAncestorsAndSelf.length - 1)) + ); + } + + return false; +} + +export function* filterNodes(cursor: TreeCursor, filter?: (name: SyntaxNode) => boolean): Generator { + do { + const { node } = cursor; + if (!filter || filter(node)) { + yield node; + } + } while (cursor.next()); +} + +export function nodeContents(input: string, node: SyntaxNode): string { + return input.substring(node.from, node.to); +}