diff --git a/codemirror-lang-sequence/package.json b/codemirror-lang-sequence/package.json index a8ed18267a..9a49d3a3ea 100644 --- a/codemirror-lang-sequence/package.json +++ b/codemirror-lang-sequence/package.json @@ -15,7 +15,7 @@ "build": "npm run clean && rollup -c", "clean": "rm -rf dist", "postinstall": "npm run build", - "test": "npm run build && mocha test/test.js" + "test": "npm run build && mocha test/*.js" }, "dependencies": { "@codemirror/autocomplete": "^6.15.0", diff --git a/codemirror-lang-sequence/src/index.ts b/codemirror-lang-sequence/src/index.ts index 9a7c2f6c0d..55b63d1ac6 100644 --- a/codemirror-lang-sequence/src/index.ts +++ b/codemirror-lang-sequence/src/index.ts @@ -26,6 +26,7 @@ export const SeqLanguage = LRLanguage.define({ }), styleTags({ Boolean: t.bool, + GenericDirective: t.namespace, Global: t.namespace, HardwareCommands: t.namespace, IdDeclaration: t.namespace, diff --git a/codemirror-lang-sequence/src/sequence.grammar b/codemirror-lang-sequence/src/sequence.grammar index 98937e21ff..7471a3210a 100644 --- a/codemirror-lang-sequence/src/sequence.grammar +++ b/codemirror-lang-sequence/src/sequence.grammar @@ -1,11 +1,9 @@ @top Sequence { (newLine | whiteSpace)? - commentLine* ~maybeComments - IdDeclaration? - commentLine* ~maybeComments - ParameterDeclaration? - commentLine* ~maybeComments - LocalDeclaration? + ( + commentLine* ~maybeComments + (IdDeclaration | ParameterDeclaration | LocalDeclaration | GenericDirective) + )* commentLine* ~maybeComments Metadata? Commands? @@ -14,14 +12,17 @@ } // Potential Improvements -// flexibility - allow indentation of directives // maintainability - use @specialize on directives -// extensibility - add generic Directive { "@"identifier } +// expressiveness - add activate, load and ground syntax @precedence { stemStart @cut } +GenericDirective { + genericDirective (whiteSpace String)* newLine +} + IdDeclaration { idDirective whiteSpace String newLine } @@ -148,10 +149,9 @@ Stem { !stemStart identifier } parameterDirective { "@INPUT_PARAMS" } metadataDirective { "@METADATA" } modelDirective { "@MODEL" } + genericDirective { "@"identifier } - @precedence { - newLine, whiteSpace - } + @precedence { newLine, whiteSpace } @precedence{ TimeAbsolute, TimeRelative, TimeEpoch, TimeComplete, identifier } @@ -164,6 +164,7 @@ Stem { !stemStart identifier } localsDirective, parameterDirective, LoadAndGoDirective, + genericDirective, identifier } } diff --git a/codemirror-lang-sequence/test/cases/errors.txt b/codemirror-lang-sequence/test/cases/errors.txt index 6b7e78d245..a09b086e94 100644 --- a/codemirror-lang-sequence/test/cases/errors.txt +++ b/codemirror-lang-sequence/test/cases/errors.txt @@ -39,3 +39,11 @@ Sequence(Commands( Command(Stem,Args(RepeatArg(⚠))), Command(Stem,Args(RepeatArg(⚠,Enum))) )) + +# locals with wrong value types + +@LOCALS "string_not_enum" + +==> + +Sequence(LocalDeclaration(⚠(String))) diff --git a/codemirror-lang-sequence/test/cases/parse_tree.txt b/codemirror-lang-sequence/test/cases/parse_tree.txt index 7e0f0f336b..5e71462d9c 100644 --- a/codemirror-lang-sequence/test/cases/parse_tree.txt +++ b/codemirror-lang-sequence/test/cases/parse_tree.txt @@ -23,6 +23,22 @@ Sequence( ) ) +# Generic directive + +@WRONG_LOAD_AND_GO + +C CMD_1 + +==> + +Sequence( + GenericDirective, + Commands( + Command(TimeTag(TimeComplete),Stem,Args) + ) +) + + # Command with two string args FSW_CMD "hello" "world" diff --git a/codemirror-lang-sequence/test/token.js b/codemirror-lang-sequence/test/token.js index d505696cf1..970b5bb053 100644 --- a/codemirror-lang-sequence/test/token.js +++ b/codemirror-lang-sequence/test/token.js @@ -35,7 +35,7 @@ describe('error positions', () => { testname: 'bad number arg', }, { - first_error: 20, + first_error: 24, input: `COM 12345 COM "dsa" @UNKNOWN DIRECTIVE`, @@ -58,7 +58,9 @@ describe('error positions', () => { describe('seqfiles', () => { const seqDir = path.dirname(fileURLToPath(import.meta.url)) + '/sequences'; for (const file of readdirSync(seqDir)) { - if (!/\.txt$/.test(file)) {continue}; + if (!/\.txt$/.test(file)) { + continue; + } const name = /^[^.]*/.exec(file)[0]; it(name, () => { @@ -91,7 +93,6 @@ CMD0 return { from, to: from + nodeText.length, - }; }; const expectedCommentLocations = { diff --git a/package-lock.json b/package-lock.json index 0ba9fed158..9fe797b480 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "d3-selection": "^3.0.0", "d3-shape": "^3.2.0", "d3-time": "^3.1.0", + "fastest-levenshtein": "^1.0.16", "graphql-ws": "^5.14.0", "json-source-map": "^0.6.1", "jszip": "^3.10.1", @@ -4133,7 +4134,6 @@ "version": "1.0.16", "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", - "dev": true, "engines": { "node": ">= 4.9.1" } diff --git a/package.json b/package.json index 51b450249e..5319489514 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "d3-selection": "^3.0.0", "d3-shape": "^3.2.0", "d3-time": "^3.1.0", + "fastest-levenshtein": "^1.0.16", "graphql-ws": "^5.14.0", "json-source-map": "^0.6.1", "jszip": "^3.10.1", diff --git a/src/components/sequencing/CommandTooltip.svelte b/src/components/sequencing/CommandTooltip.svelte index be18db8d64..02e3283391 100644 --- a/src/components/sequencing/CommandTooltip.svelte +++ b/src/components/sequencing/CommandTooltip.svelte @@ -5,7 +5,7 @@ export let command: FswCommand | HwCommand; - let commandExample: string; + $: commandExample = command.stem; $: if (command.type === 'hw_command') { commandExample = command.stem; diff --git a/src/components/sequencing/SequenceEditor.svelte b/src/components/sequencing/SequenceEditor.svelte index 575c097383..e6945266af 100644 --- a/src/components/sequencing/SequenceEditor.svelte +++ b/src/components/sequencing/SequenceEditor.svelte @@ -6,12 +6,13 @@ import { lintGutter } from '@codemirror/lint'; import { Compartment, EditorState } from '@codemirror/state'; import type { ViewUpdate } from '@codemirror/view'; + import type { SyntaxNode } from '@lezer/common'; import type { CommandDictionary } from '@nasa-jpl/aerie-ampcs'; import { EditorView, basicSetup } from 'codemirror'; import { seq } from 'codemirror-lang-sequence'; import { debounce } from 'lodash-es'; import { createEventDispatcher, onMount } from 'svelte'; - import { commandDictionaries, userSequencesRows } from '../../stores/sequencing'; + import { commandDictionaries, userSequenceEditorColumns, userSequencesRows } from '../../stores/sequencing'; import type { User } from '../../types/app'; import effects from '../../utilities/effects'; import { seqJsonLinter } from '../../utilities/new-sequence-editor/seq-json-linter'; @@ -23,6 +24,7 @@ import CssGridGutter from '../ui/CssGridGutter.svelte'; import Panel from '../ui/Panel.svelte'; import SectionTitle from '../ui/SectionTitle.svelte'; + import SelectedCommand from './form/selected-command.svelte'; export let readOnly: boolean = false; export let sequenceCommandDictionaryId: number | null = null; @@ -48,6 +50,7 @@ let editorSeqJsonView: EditorView; let editorSequenceDiv: HTMLDivElement; let editorSequenceView: EditorView; + let selectedNode: SyntaxNode | null; $: { if (editorSequenceView) { @@ -122,8 +125,15 @@ editorSeqJsonView.dispatch({ changes: { from: 0, insert: seqJsonStr, to: editorSeqJsonView.state.doc.length } }); dispatch('sequence', sequence); + + const updatedSelectionNode = tree.resolveInner(viewUpdate.state.selection.asSingle().main.from, -1); + // minimize triggering selected command view + if (selectedNode !== updatedSelectionNode) { + selectedNode = updatedSelectionNode; + } } + // eslint-disable-next-line @typescript-eslint/no-unused-vars function downloadSeqJson() { const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([sequenceDefinition], { type: 'application/json' })); @@ -132,34 +142,44 @@ } - - - - {title} - -
- -
-
- - -
- - - - - - - - Seq JSON (Read-only) - -
- -
-
- - -
- - + + + + + {title} + +
+ +
+
+ + +
+ + + + + + + + Seq JSON (Read-only) + + + + + +
+ + + + + + + {#if !!commandDictionary && !!selectedNode} + + {:else} +
Selected Command
+ {/if} diff --git a/src/components/sequencing/SequenceForm.svelte b/src/components/sequencing/SequenceForm.svelte index d172a46292..446c836a38 100644 --- a/src/components/sequencing/SequenceForm.svelte +++ b/src/components/sequencing/SequenceForm.svelte @@ -80,6 +80,7 @@ loadAdaptation(adaptation); }); + // eslint-disable-next-line @typescript-eslint/no-unused-vars async function getUserSequenceFromSeqJson() { const file: File = seqJsonFiles[0]; const text = await file.text(); @@ -256,7 +257,7 @@ }} /> - + +
+ + diff --git a/src/components/sequencing/form/selected-command.svelte b/src/components/sequencing/form/selected-command.svelte new file mode 100644 index 0000000000..0a82a9ac58 --- /dev/null +++ b/src/components/sequencing/form/selected-command.svelte @@ -0,0 +1,182 @@ + + + + +
+ {#if !!commandNode} +
Selected Command
+ {#if !!commandDef} + {#if !!timeTagNode} +
Time Tag: {timeTagNode.text.trim()}
+ {/if} +
{commandDef.stem}
+
+
+ {#each editorArgInfoArray as argInfo} + + addDefaultArgs(commandDictionary, editorSequenceView, commandNode, missingArgDefArray)} + /> + {/each} + {#if missingArgDefArray.length} + { + if (commandNode) { + addDefaultArgs(commandDictionary, editorSequenceView, commandNode, missingArgDefArray); + } + }} + /> + {/if} +
+ {/if} + {/if} +
+ + diff --git a/src/components/sequencing/form/string-editor.svelte b/src/components/sequencing/form/string-editor.svelte new file mode 100644 index 0000000000..aebc9481f5 --- /dev/null +++ b/src/components/sequencing/form/string-editor.svelte @@ -0,0 +1,34 @@ + + + + +
+ +
+ + diff --git a/src/components/sequencing/form/utils.ts b/src/components/sequencing/form/utils.ts new file mode 100644 index 0000000000..debda51e19 --- /dev/null +++ b/src/components/sequencing/form/utils.ts @@ -0,0 +1,127 @@ +import type { SyntaxNode } from '@lezer/common'; +import type { + CommandDictionary, + FswCommandArgument, + FswCommandArgumentEnum, + FswCommandArgumentFixedString, + FswCommandArgumentFloat, + FswCommandArgumentInteger, + FswCommandArgumentNumeric, + FswCommandArgumentRepeat, + FswCommandArgumentUnsigned, + FswCommandArgumentVarString, +} from '@nasa-jpl/aerie-ampcs'; +import type { EditorView } from 'codemirror'; +import { fswCommandArgDefault } from '../../../utilities/new-sequence-editor/command-dictionary'; +import { TOKEN_REPEAT_ARG } from '../../../utilities/new-sequence-editor/sequencer-grammar-constants'; + +export function isFswCommandArgumentEnum(arg: FswCommandArgument): arg is FswCommandArgumentEnum { + return arg.arg_type === 'enum'; +} + +export function isFswCommandArgumentInteger(arg: FswCommandArgument): arg is FswCommandArgumentInteger { + return arg.arg_type === 'integer'; +} + +export function isFswCommandArgumentFloat(arg: FswCommandArgument): arg is FswCommandArgumentFloat { + return arg.arg_type === 'float'; +} + +export function isFswCommandArgumentNumeric(arg: FswCommandArgument): arg is FswCommandArgumentNumeric { + return arg.arg_type === 'numeric'; +} + +export function isFswCommandArgumentUnsigned(arg: FswCommandArgument): arg is FswCommandArgumentUnsigned { + return arg.arg_type === 'unsigned'; +} + +export function isFswCommandArgumentRepeat(arg: FswCommandArgument): arg is FswCommandArgumentRepeat { + return arg.arg_type === 'repeat'; +} + +export function isFswCommandArgumentVarString(arg: FswCommandArgument): arg is FswCommandArgumentVarString { + return arg.arg_type === 'var_string'; +} + +export function isFswCommandArgumentFixedString(arg: FswCommandArgument): arg is FswCommandArgumentFixedString { + return arg.arg_type === 'fixed_string'; +} + +export function isNumberArg(arg: FswCommandArgument): arg is NumberArg { + return ( + isFswCommandArgumentFloat(arg) || + isFswCommandArgumentInteger(arg) || + isFswCommandArgumentNumeric(arg) || + isFswCommandArgumentUnsigned(arg) + ); +} + +export function isStringArg(arg: FswCommandArgument): arg is StringArg { + return isFswCommandArgumentVarString(arg) || isFswCommandArgumentFixedString(arg); +} + +export type StringArg = FswCommandArgumentVarString | FswCommandArgumentFixedString; + +export type NumberArg = + | FswCommandArgumentFloat + | FswCommandArgumentInteger + | FswCommandArgumentNumeric + | FswCommandArgumentUnsigned; + +export type ArgTextDef = { + argDef?: FswCommandArgument; + children?: ArgTextDef[]; + node?: SyntaxNode; + parentArgDef?: FswCommandArgumentRepeat; + text?: string; +}; + +export function addDefaultArgs( + commandDictionary: CommandDictionary, + view: EditorView, + commandNode: SyntaxNode, + argDefs: FswCommandArgument[], +) { + 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); + } + } +} + +export function getMissingArgDefs(argInfoArray: ArgTextDef[]) { + return argInfoArray + .filter((argInfo): argInfo is { argDef: FswCommandArgument } => !argInfo.node && !!argInfo.argDef) + .map(argInfo => argInfo.argDef); +} + +export function isQuoted(s: string) { + return s.startsWith('"') && s.endsWith('"'); +} + +export function unquoteUnescape(s: string) { + if (isQuoted(s)) { + return s.slice(1, -1).replaceAll('\\"', '"'); + } + return s; +} + +export function quoteEscape(s: string) { + return `"${s.replaceAll('"', '\\"')}"`; +} diff --git a/src/stores/sequencing.ts b/src/stores/sequencing.ts index 7e87fb7897..9b962131e6 100644 --- a/src/stores/sequencing.ts +++ b/src/stores/sequencing.ts @@ -16,3 +16,5 @@ export const userSequencesColumns: Writable = writable('1.5fr 3px 1fr'); export const userSequenceFormColumns: Writable = writable('1fr 3px 2fr'); export const userSequencesRows: Writable = writable('1fr 3px 1fr'); + +export const userSequenceEditorColumns: Writable = writable('3fr 3px 1fr'); diff --git a/src/utilities/new-sequence-editor/from-seq-json.ts b/src/utilities/new-sequence-editor/from-seq-json.ts index 18f2e0bc4c..1b854feead 100644 --- a/src/utilities/new-sequence-editor/from-seq-json.ts +++ b/src/utilities/new-sequence-editor/from-seq-json.ts @@ -2,6 +2,8 @@ import type { Args, BooleanArgument, HexArgument, + Metadata, + Model, NumberArgument, SeqJson, StringArgument, @@ -9,6 +11,7 @@ import type { Time, } from '@nasa-jpl/seq-json-schema/types'; import { isArray } from 'lodash-es'; +import { quoteEscape } from '../../components/sequencing/form/utils'; import { logError } from './logger'; /** @@ -102,6 +105,35 @@ export function seqJsonArgsToSequence(args: Args): string { return argsStr; } +export function seqJsonModelsToSequence(models: Model[]) { + // MODEL directives are one per line, the last new line is to start the next token + return ( + models + .map(model => { + let formattedValue: Model['value'] = model.value; + if (typeof model.value === 'string') { + formattedValue = quoteEscape(model.value); + } else if (typeof model.value === 'boolean') { + formattedValue = model.value.toString().toUpperCase(); + } + return `@MODEL ${quoteEscape(model.variable)} ${formattedValue} ${quoteEscape(model.offset)}`; + }) + .join('\n') + '\n' + ); +} + +export function seqJsonMetadataToSequence(metadata: Metadata) { + // METADATA directives are one per line, the last new line is to start the next token + return ( + Object.entries(metadata) + .map( + ([key, value]: [key: string, value: unknown]) => + `@METADATA ${quoteEscape(key)} ${quoteEscape(value as string)}`, + ) + .join('\n') + '\n' + ); +} + /** * Transforms a sequence JSON to a sequence string. */ @@ -118,7 +150,9 @@ export function seqJsonToSequence(seqJson: SeqJson | null): string { if (step.type === 'command') { const time = seqJsonTimeToSequence(step.time); const args = seqJsonArgsToSequence(step.args); - sequence.push(`${time} ${step.stem}${args}\n`); + const metadata = step.metadata ? seqJsonMetadataToSequence(step.metadata) : ''; + const models = step.models ? seqJsonModelsToSequence(step.models) : ''; + sequence.push(`${time} ${step.stem}${args}\n${models}${metadata}`); } } } diff --git a/src/utilities/new-sequence-editor/sequence-linter.ts b/src/utilities/new-sequence-editor/sequence-linter.ts index 909cd4e023..87441e2100 100644 --- a/src/utilities/new-sequence-editor/sequence-linter.ts +++ b/src/utilities/new-sequence-editor/sequence-linter.ts @@ -3,7 +3,11 @@ import { linter, type Diagnostic } from '@codemirror/lint'; import type { Extension } from '@codemirror/state'; import type { SyntaxNode } from '@lezer/common'; import type { CommandDictionary, EnumMap, FswCommand, FswCommandArgument, HwCommand } from '@nasa-jpl/aerie-ampcs'; -import { TOKEN_REPEAT_ARG } from './sequencer-grammar-constants'; +import { closest, distance } from 'fastest-levenshtein'; +import { addDefaultArgs } from '../../components/sequencing/form/utils'; + +import type { EditorView } from 'codemirror'; +import { TOKEN_COMMAND, TOKEN_ERROR, TOKEN_REPEAT_ARG } from './sequencer-grammar-constants'; import { ABSOLUTE_TIME, EPOCH_SIMPLE, @@ -15,7 +19,16 @@ import { } from './time-utils'; import { getChildrenNode, getDeepestNode, getFromAndTo } from './tree-utils'; -const ERROR = '⚠'; +const KNOWN_DIRECTIVES = [ + 'LOAD_AND_GO', + 'ID', + 'IMMEDIATE', + 'HARDWARE', + 'LOCALS', + 'INPUT_PARAMS', + 'MODEL', + 'METADATA', +].map(name => `@${name}`); export function getAllEnumSymbols(enumMap: EnumMap, enumName: string) { const enumSymbols = enumMap[enumName].values.map(({ symbol }) => symbol); @@ -23,6 +36,12 @@ export function getAllEnumSymbols(enumMap: EnumMap, enumName: string) { return { enumSymbols, enumSymbolsDisplayStr }; } +function closestStrings(value: string, potentialMatches: string[], n: number) { + const distances = potentialMatches.map(s => ({ distance: distance(s, value), s })); + distances.sort((a, b) => a.distance - b.distance); + return distances.slice(0, n).map(pair => pair.s); +} + /** * Linter function that returns a Code Mirror extension function. * Can be optionally called with a command dictionary so it's available during linting. @@ -35,25 +54,25 @@ export function sequenceLinter(commandDictionary: CommandDictionary | null = nul // Validate top level metadata diagnostics.push(...validateMetadata(treeNode)); + diagnostics.push(...validateLocals(treeNode.getChildren('LocalDeclaration'))); + + diagnostics.push(...validateParameters(treeNode.getChildren('ParameterDeclaration'))); + // Validate command type mixing diagnostics.push(...validateCommandTypeMixing(treeNode)); - diagnostics.push( - ...commandLinter(treeNode.getChild('Commands')?.getChildren('Command') || [], view.state.doc.toString()), - ); + const docText = view.state.doc.toString(); + + diagnostics.push(...validateCustomDirectives(treeNode, docText)); + + diagnostics.push(...commandLinter(treeNode.getChild('Commands')?.getChildren(TOKEN_COMMAND) || [], docText)); diagnostics.push( - ...immediateCommandLinter( - treeNode.getChild('ImmediateCommands')?.getChildren('Command') || [], - view.state.doc.toString(), - ), + ...immediateCommandLinter(treeNode.getChild('ImmediateCommands')?.getChildren(TOKEN_COMMAND) || [], docText), ); diagnostics.push( - ...hardwareCommandLinter( - treeNode.getChild('HardwareCommands')?.getChildren('Command') || [], - view.state.doc.toString(), - ), + ...hardwareCommandLinter(treeNode.getChild('HardwareCommands')?.getChildren(TOKEN_COMMAND) || [], docText), ); return diagnostics; @@ -73,7 +92,7 @@ export function sequenceLinter(commandDictionary: CommandDictionary | null = nul const lgo = commands?.getChild('LoadAndGoDirective') ?? null; // Check if each command type exists and has at least one child node. - const hasCommands = commands !== null && (commands?.getChildren('Command').length > 0 || lgo !== null); + const hasCommands = commands !== null && (commands?.getChildren(TOKEN_COMMAND).length > 0 || lgo !== null); const hasImmediateCommands = immediateCommands !== null; const hasHardwareCommands = hardwareCommands !== null; @@ -89,22 +108,118 @@ export function sequenceLinter(commandDictionary: CommandDictionary | null = nul ) { if (lgo) { diagnostics.push({ - from, - message: `Directive 'LOAD_AND_GO' cannot be used with 'Immediate Commands' or 'Hardware Commands'.`, - severity: 'error', - to, - }); - } - diagnostics.push({ from, - message: 'Cannot mix different command types in one Sequence.', + message: `Directive 'LOAD_AND_GO' cannot be used with 'Immediate Commands' or 'Hardware Commands'.`, severity: 'error', to, + }); + } + diagnostics.push({ + from, + message: 'Cannot mix different command types in one Sequence.', + severity: 'error', + to, }); } return diagnostics; } + function validateLocals(locals: SyntaxNode[]) { + const diagnostics: Diagnostic[] = []; + diagnostics.push( + ...locals.slice(1).map( + local => + ({ + ...getFromAndTo([local]), + message: 'There is a maximum of @LOCALS directive per sequence', + severity: 'error', + }) as Diagnostic, + ), + ); + locals.forEach(local => { + let child = local.firstChild; + while (child) { + if (child.name !== 'Enum') { + diagnostics.push({ + from: child.from, + message: `@LOCALS values are required to be Enums`, + severity: 'error', + to: child.to, + }); + } + child = child.nextSibling; + } + }); + // TODO - hook to check mission specific nomenclature + return diagnostics; + } + + function validateParameters(inputParams: SyntaxNode[]) { + const diagnostics: Diagnostic[] = []; + diagnostics.push( + ...inputParams.slice(1).map( + inputParam => + ({ + ...getFromAndTo([inputParam]), + message: 'There is a maximum of @INPUT_PARAMS directive per sequence', + severity: 'error', + }) as Diagnostic, + ), + ); + inputParams.forEach(inputParam => { + let child = inputParam.firstChild; + while (child) { + if (child.name !== 'Enum') { + diagnostics.push({ + from: child.from, + message: `@INPUT_PARAMS values are required to be Enums`, + severity: 'error', + to: child.to, + }); + } + child = child.nextSibling; + } + }); + // TODO - hook to check mission specific nomenclature + return diagnostics; + } + + function validateCustomDirectives(node: SyntaxNode, text: string): Diagnostic[] { + const diagnostics: Diagnostic[] = []; + node.getChildren('GenericDirective').forEach(directiveNode => { + const child = directiveNode.firstChild; + // use first token as directive, preserve remainder of line + const { from, to } = { ...getFromAndTo([directiveNode]), ...(child ? { to: child.from } : {}) }; + const custom = text.slice(from, to).trim(); + const guess = closest(custom, KNOWN_DIRECTIVES); + const insert = guess + (child ? ' ' : '\n'); + diagnostics.push({ + actions: [ + { + apply(view, from, to) { + view.dispatch({ changes: { from, insert, to } }); + }, + name: `Change to ${guess}`, + }, + ], + from, + message: `Unknown Directive ${custom}, did you mean ${guess}`, + severity: 'error', + to, + }); + }); + return diagnostics; + } + + function insertAction(name: string, insert: string) { + return { + apply(view: EditorView, from: number, _to: number) { + view.dispatch({ changes: { from, insert } }); + }, + name, + }; + } + /** * Function to generate diagnostics based on Commands section in the parse tree. * @@ -129,7 +244,10 @@ export function sequenceLinter(commandDictionary: CommandDictionary | null = nul // If the TimeTag node is missing, create a diagnostic if (!timeTagNode) { diagnostics.push({ - actions: [], + actions: [ + insertAction(`Insert 'C' (command complete)`, 'C '), + insertAction(`Insert 'R1' (relative 1)`, 'R '), + ], from: command.from, message: "Missing 'Time Tag' for command", severity: 'error', @@ -298,7 +416,6 @@ export function sequenceLinter(commandDictionary: CommandDictionary | null = nul const modelsNode = command.getChild('Models'); if (modelsNode) { diagnostics.push({ - actions: [], from: modelsNode.from, message: "Immediate commands can't have models", severity: 'error', @@ -432,21 +549,27 @@ export function sequenceLinter(commandDictionary: CommandDictionary | null = nul if (commandDictionary === null) { return null; } - const { fswCommandMap, hwCommandMap } = commandDictionary; + const { fswCommandMap, fswCommands, hwCommandMap, hwCommands } = commandDictionary; const stemText = text.slice(stem.from, stem.to); - const dictionaryCommand : FswCommand | HwCommand | null = fswCommandMap[stemText] + const dictionaryCommand: FswCommand | HwCommand | null = fswCommandMap[stemText] ? fswCommandMap[stemText] : hwCommandMap[stemText] - ? hwCommandMap[stemText] - : null; + ? hwCommandMap[stemText] + : null; if (!dictionaryCommand) { + const ALL_STEMS = [...fswCommands.map(cmd => cmd.stem), ...hwCommands.map(cmd => cmd.stem)]; return { - actions: [], + actions: closestStrings(stemText.toUpperCase(), ALL_STEMS, 3).map(guess => ({ + apply(view, from, to) { + view.dispatch({ changes: { from, insert: guess, to } }); + }, + name: `Change to ${guess}`, + })), from: stem.from, - message: 'Command not found', + message: `Command '${stemText}' not found`, severity: 'error', to: stem.to, }; @@ -457,7 +580,6 @@ export function sequenceLinter(commandDictionary: CommandDictionary | null = nul case 'immediate': if (!fswCommandMap[stemText]) { return { - actions: [], from: stem.from, message: 'Command must be a fsw command', severity: 'error', @@ -468,7 +590,6 @@ export function sequenceLinter(commandDictionary: CommandDictionary | null = nul case 'hardware': if (!hwCommandMap[stemText]) { return { - actions: [], from: stem.from, message: 'Command must be a hardware command', severity: 'error', @@ -511,29 +632,65 @@ export function sequenceLinter(commandDictionary: CommandDictionary | null = nul return diagnostics; } - if (argNode.length !== dictArgs.length) { + if (argNode.length > dictArgs.length) { + const extraArgs = argNode.slice(dictArgs.length); + const { from, to } = getFromAndTo(extraArgs); + diagnostics.push({ + actions: [ + { + apply(view, from, to) { + view.dispatch({ changes: { from, to } }); + }, + name: `Remove ${extraArgs.length} extra argument${extraArgs.length > 1 ? 's' : ''}`, + }, + ], + from, + message: `Extra arguments, definition has ${dictArgs.length}, but ${argNode.length} are present`, + severity: 'error', + to, + }); + return diagnostics; + } else if (argNode.length < dictArgs.length) { const { from, to } = getFromAndTo(argNode); + const pluralS = dictArgs.length > argNode.length + 1 ? 's' : ''; diagnostics.push({ - actions: [], + actions: [ + { + apply(view, _from, _to) { + if (commandDictionary) { + addDefaultArgs(commandDictionary, view, command, dictArgs.slice(argNode.length)); + } + }, + name: `Add default missing argument${pluralS}`, + }, + ], from, - message: `Should only have ${dictArgs.length} arguments`, + message: `Missing argument${pluralS}, definition has ${argNode.length}, but ${dictArgs.length} are present`, severity: 'error', to, }); return diagnostics; } } else if (argNode && argNode.length > 0) { + const { from, to } = getFromAndTo(argNode); diagnostics.push({ - actions: [], - from: command.from, + actions: [ + { + apply(view, from, to) { + view.dispatch({ changes: { from, to } }); + }, + name: `Remove argument${argNode.length > 1 ? 's' : ''}`, + }, + ], + from: from, message: 'The command should not have arguments', severity: 'error', - to: command.to, + to: to, }); return diagnostics; } - // don't check any further as there is no arguments in the command dictionary + // don't check any further as there are no arguments in the command dictionary if (dictArgs.length === 0) { return diagnostics; } @@ -604,9 +761,18 @@ export function sequenceLinter(commandDictionary: CommandDictionary | null = nul commandDictionary?.enumMap, dictArg.enum_name, ); - if (!symbols.includes(argText.replace(/^"|"$/g, ''))) { + const unquotedArgText = argText.replace(/^"|"$/g, ''); + if (!symbols.includes(unquotedArgText)) { + const guess = closest(unquotedArgText.toUpperCase(), symbols); diagnostics.push({ - actions: [], + actions: [ + { + apply(view, from, to) { + view.dispatch({ changes: { from, insert: `"${guess}"`, to } }); + }, + name: `Change to ${guess}`, + }, + ], from: argNode.from, message: `Enum should be "${availableSymbols}"`, severity: 'error', @@ -617,10 +783,17 @@ export function sequenceLinter(commandDictionary: CommandDictionary | null = nul } if (argType === 'Enum') { diagnostics.push({ - actions: [], + actions: [ + { + apply(view, from, to) { + view.dispatch({ changes: { from, insert: `"${argText}"`, to } }); + }, + name: `Add quotes around ${argText}`, + }, + ], from: argNode.from, message: `Enum should be a "string"`, - severity: 'warning', + severity: 'error', to: argNode.to, }); } @@ -640,7 +813,6 @@ export function sequenceLinter(commandDictionary: CommandDictionary | null = nul if (nodeTextAsNumber < min || nodeTextAsNumber > max) { const message = `Number out of range. Make sure this number is between ${min} and ${max} inclusive.`; diagnostics.push({ - actions: [], from: argNode.from, message, severity: 'error', @@ -649,7 +821,6 @@ export function sequenceLinter(commandDictionary: CommandDictionary | null = nul } } else { diagnostics.push({ - actions: [], from: argNode.from, message: `Incorrect type - expected 'Number' but got ${argType}`, severity: 'error', @@ -661,7 +832,6 @@ export function sequenceLinter(commandDictionary: CommandDictionary | null = nul case 'var_string': if (argType !== 'String') { diagnostics.push({ - actions: [], from: argNode.from, message: `Incorrect type - expected 'String' but got ${argType}`, severity: 'error', @@ -672,7 +842,6 @@ export function sequenceLinter(commandDictionary: CommandDictionary | null = nul case 'repeat': if (argType !== TOKEN_REPEAT_ARG) { diagnostics.push({ - actions: [], from: argNode.from, message: `Incorrect type - expected '${TOKEN_REPEAT_ARG}' but got ${argType}`, severity: 'error', @@ -825,7 +994,6 @@ export function sequenceLinter(commandDictionary: CommandDictionary | null = nul case 'Enum': case 'Boolean': diagnostics.push({ - actions: [], from: metadataNode.from, message: `Incorrect type - expected 'String' but got ${deepestNodeName}`, severity: 'error', @@ -834,7 +1002,6 @@ export function sequenceLinter(commandDictionary: CommandDictionary | null = nul break; default: diagnostics.push({ - actions: [], from: entry.from, message: `Missing ${templateName}`, severity: 'error', @@ -850,11 +1017,7 @@ export function sequenceLinter(commandDictionary: CommandDictionary | null = nul } function validateModel(commandNode: SyntaxNode): Diagnostic[] { - const modelConstainerNode = commandNode.getChild('Models'); - if (!modelConstainerNode) { - return []; - } - const models = modelConstainerNode.getChildren('Model'); + const models = commandNode.getChild('Models')?.getChildren('Model'); if (!models) { return []; } @@ -865,7 +1028,6 @@ export function sequenceLinter(commandDictionary: CommandDictionary | null = nul const modelChildren = getChildrenNode(model); if (modelChildren.length > 3) { diagnostics.push({ - actions: [], from: model.from, message: `Should only have 'Variable', 'value', and 'Offset'`, severity: 'error', @@ -878,7 +1040,6 @@ export function sequenceLinter(commandDictionary: CommandDictionary | null = nul const modelNode = modelChildren[i]; if (!modelNode) { diagnostics.push({ - actions: [], from: model.from, message: `Missing ${templateName}`, severity: 'error', @@ -888,9 +1049,8 @@ export function sequenceLinter(commandDictionary: CommandDictionary | null = nul if (modelNode.name !== templateName) { const deepestNodeName = getDeepestNode(modelNode).name; - if (deepestNodeName === ERROR) { + if (deepestNodeName === TOKEN_ERROR) { diagnostics.push({ - actions: [], from: model.from, message: `Missing ${templateName}`, severity: 'error', @@ -901,7 +1061,6 @@ export function sequenceLinter(commandDictionary: CommandDictionary | null = nul if (templateName === 'Variable' || templateName === 'Offset') { if (deepestNodeName !== 'String') { diagnostics.push({ - actions: [], from: modelNode.from, message: `Incorrect type - expected 'String' but got ${deepestNodeName}`, severity: 'error', @@ -913,7 +1072,6 @@ export function sequenceLinter(commandDictionary: CommandDictionary | null = nul // Value if (deepestNodeName !== 'Number' && deepestNodeName !== 'String' && deepestNodeName !== 'Boolean') { diagnostics.push({ - actions: [], from: modelNode.from, message: `Incorrect type - expected 'Number', 'String', or 'Boolean' but got ${deepestNodeName}`, severity: 'error', diff --git a/src/utilities/new-sequence-editor/sequencer-grammar-constants.ts b/src/utilities/new-sequence-editor/sequencer-grammar-constants.ts index b420079c30..77eff640d7 100644 --- a/src/utilities/new-sequence-editor/sequencer-grammar-constants.ts +++ b/src/utilities/new-sequence-editor/sequencer-grammar-constants.ts @@ -1 +1,3 @@ +export const TOKEN_COMMAND = 'Command'; export const TOKEN_REPEAT_ARG = 'RepeatArg'; +export const TOKEN_ERROR = '⚠'; diff --git a/src/utilities/new-sequence-editor/to-seq-json.ts b/src/utilities/new-sequence-editor/to-seq-json.ts index 9eff829530..ff79febe32 100644 --- a/src/utilities/new-sequence-editor/to-seq-json.ts +++ b/src/utilities/new-sequence-editor/to-seq-json.ts @@ -40,7 +40,7 @@ export function sequenceToSeqJson(node: Tree, text: string, commandDictionary: C variableList = []; seqJson.id = parseId(baseNode, text); - seqJson.metadata = { ...parseLGO(baseNode), ...parseMetatdata(baseNode, text) } ?? {}; + seqJson.metadata = { ...parseLGO(baseNode), ...parseMetadata(baseNode, text) }; seqJson.locals = parseVariables(baseNode, text, 'LocalDeclaration') ?? undefined; seqJson.parameters = parseVariables(baseNode, text, 'ParameterDeclaration') ?? undefined; seqJson.steps = @@ -369,10 +369,25 @@ function parseModel(node: SyntaxNode, text: string): Model[] | undefined { const variable = variableNode ? (removeQuotes(text.slice(variableNode.from, variableNode.to)) as string) : 'UNKNOWN'; - const value = valueNode ? removeQuotes(text.slice(valueNode.from, valueNode.to)) : 0; + + // Value can be string, number or boolean + let value: Model['value'] = 0; + if (valueNode) { + const valueChild = valueNode.firstChild; + if (valueChild) { + const valueText = text.slice(valueChild.from, valueChild.to); + if (valueChild.name === 'String') { + value = removeQuotes(valueText); + } else if (valueChild.name === 'Boolean') { + value = !/^FALSE$/i.test(valueText); + } else if (valueChild.name === 'Number') { + value = Number(valueText); + } + } + } const offset = offsetNode ? (removeQuotes(text.slice(offsetNode.from, offsetNode.to)) as string) : 'UNKNOWN'; - models.push({ offset, value, variable}); + models.push({ offset, value, variable }); } return models; @@ -389,7 +404,7 @@ function parseDescription(node: SyntaxNode, text: string): string | undefined { function removeQuotes(text: string | number | boolean): string | number | boolean { if (typeof text === 'string') { - return text.replace(/^"|"$/g, ''); + return text.replace(/^"|"$/g, '').replaceAll('\\"', '"'); } return text; } @@ -408,7 +423,7 @@ export function parseCommand( const args = argsNode ? parseArgs(argsNode, text, commandDictionary, stem) : []; const description = parseDescription(commandNode, text); - const metadata: Metadata | undefined = parseMetatdata(commandNode, text); + const metadata: Metadata | undefined = parseMetadata(commandNode, text); const models: Model[] | undefined = parseModel(commandNode, text); return { @@ -434,7 +449,7 @@ export function parseImmediateCommand( const args = argsNode ? parseArgs(argsNode, text, commandDictionary, stem) : []; const description = parseDescription(commandNode, text); - const metadata: Metadata | undefined = parseMetatdata(commandNode, text); + const metadata: Metadata | undefined = parseMetadata(commandNode, text); return { args, @@ -448,7 +463,7 @@ export function parseHardwareCommand(commandNode: SyntaxNode, text: string): Har const stemNode = commandNode.getChild('Stem'); const stem = stemNode ? text.slice(stemNode.from, stemNode.to) : 'UNKNOWN'; const description = parseDescription(commandNode, text); - const metadata: Metadata | undefined = parseMetatdata(commandNode, text); + const metadata: Metadata | undefined = parseMetadata(commandNode, text); return { stem, @@ -472,7 +487,7 @@ export function parseId(node: SyntaxNode, text: string): string { return id; } -export function parseMetatdata(node: SyntaxNode, text: string): Metadata | undefined { +function parseMetadata(node: SyntaxNode, text: string): Metadata | undefined { const metadataNode = node.getChild('Metadata'); if (!metadataNode) { return undefined; @@ -492,8 +507,8 @@ export function parseMetatdata(node: SyntaxNode, text: string): Metadata | undef return; // Skip this entry if either the key or value is missing } - const keyText = text.slice(keyNode.from, keyNode.to); - const valueText = text.slice(valueNode.from, valueNode.to); + const keyText = removeQuotes(text.slice(keyNode.from, keyNode.to)) as string; + const valueText = removeQuotes(text.slice(valueNode.from, valueNode.to)); obj[keyText] = valueText; }); diff --git a/src/utilities/new-sequence-editor/tree-utils.ts b/src/utilities/new-sequence-editor/tree-utils.ts index 2fab3c398c..0e9864763f 100644 --- a/src/utilities/new-sequence-editor/tree-utils.ts +++ b/src/utilities/new-sequence-editor/tree-utils.ts @@ -45,3 +45,11 @@ export function getFromAndTo(nodes: (SyntaxNode | null)[]): { from: number; to: { from: Number.MAX_VALUE, to: Number.MIN_VALUE }, ); } + +export function getAncestorNode(node: SyntaxNode | null, name: string) { + let commandNode: SyntaxNode | null = node; + while (commandNode && commandNode.name !== name) { + commandNode = commandNode.parent; + } + return commandNode; +}