diff --git a/.gitignore b/.gitignore index 5f4ed8f..cfe2fd8 100644 --- a/.gitignore +++ b/.gitignore @@ -142,4 +142,6 @@ build/ # IDE files .vscode/ -.idea/ \ No newline at end of file +.idea/ + +.DS_Store diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a16cb55 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,143 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is a TypeScript library providing language support for NASA's SeqDev Sequence Editor. It implements parsers, linters, formatters, and CodeMirror extensions for multiple sequence languages used in spacecraft operations. + +**Supported Languages:** +- **SeqN** (`.seqN.txt`) - Primary input/authoring language +- **SATF/SASF** - Spacecraft Activity Time File / Spacecraft Activity Sequence File formats +- **SeqJSON** - JSON-based sequence representation +- **VML** - Vehicle Markup Language with CDL dictionary support +- **F-Prime** (`.seq`) - NASA's F' flight software sequencing format +- **Handlebars** - Template support for SeqN + +## Development Commands + +### Building +```bash +npm run build # Full build: clean + grammar generation + TypeScript compilation (CJS + ESM) +npm run clean # Remove dist/ directory +npm run grammar-builder # Generate all grammar parsers from .grammar files +``` + +### Grammar Generation (Required Before Build/Test) +```bash +npm run satf # Generate SATF/SASF grammar parser +npm run seqn # Generate SeqN grammar parser +npm run vml # Generate VML grammar parser +npm run fprime # Generate F-Prime grammar parser +``` + +**Important:** Grammar files must be regenerated whenever `.grammar` files are modified. The build process automatically runs grammar generation, but if you're iterating on grammar changes, run the specific grammar command directly. + +### Testing +```bash +npm test # Run all tests (includes grammar generation) +vitest run # Alternative test command +vitest watch # Run tests in watch mode +``` + +Test fixtures are located in `tests/dictionary/` (command dictionaries) and `tests/sequence/` (example sequences). + +### Code Quality +```bash +npm run format:check # Check Prettier formatting +npm run format:write # Apply Prettier formatting +``` + +## Architecture + +### Language Implementation Pattern + +Each language follows a consistent architecture based on two core interfaces: + +**InputLanguage** (authoring languages like SeqN): +- Provides CodeMirror extensions (linting, completion, tooltips, formatting) +- Implements `commandInfoMapper` to feed Phoenix UI command panel +- Optionally provides `getLibrarySequences` for workspace-wide call signatures +- Example: `src/languages/seq-n/language.ts` + +**OutputLanguage** (export/interchange formats like SATF): +- Implements bidirectional converters: `toOutputFormat` and `toInputFormat` +- Used for converting between authoring and delivery formats +- Example: converters in `src/converters/` + +### Phoenix Integration + +Languages integrate with Phoenix via the `PhoenixAdaptation` interface: +```typescript +interface PhoenixAdaptation { + input: InputLanguage; + outputs: OutputLanguage[]; +} +``` + +**PhoenixResources**: Static resources abstracted from CodeMirror/UI (indentService, linter, hoverTooltip, etc.) + +**PhoenixContext**: Dynamic context including command/channel dictionaries and library sequences + +Extensions are created using factory patterns that accept `PhoenixContext` and `PhoenixResources`, following the pattern in `getSeqnExtensions()` which returns keyed extension objects for easy composition. + +### Grammar Files (Lezer) + +Grammars are defined in `.grammar` files using Lezer syntax: +- `src/languages/satf/grammar/satf-sasf.grammar` +- `src/languages/seq-n/seq-n.grammar` +- `src/languages/vml/vml.grammar` +- `src/languages/fprime/fprime.grammar` + +Lezer-generator compiles these into TypeScript parsers (`.grammar.js` and `.grammar.d.ts` files). The generated files are gitignored and must be regenerated on each build. + +**F-Prime Sequence Format:** +- Commands with absolute time (`AYYYY-DDDTHH:MM:SS[.sss]`) or relative time (`RHH:MM:SS[.sss]`) +- Command mnemonics can include dots for namespacing (e.g., `module.CMD_NAME`) +- Arguments: strings in double quotes, numbers (decimal or hex with `0x` prefix) +- Comments start with semicolon (`;`) + +### Key Utilities + +**Tree Utilities** (`src/utils/tree-utils.ts`): +- Generic tree traversal and node manipulation for Lezer syntax trees + +**Sequence Utilities** (`src/utils/sequence-utils.ts`): +- Command dictionary interaction (FSW/hardware commands) +- Argument parsing and validation +- Type guards for different argument types + +**String Utilities** (`src/utils/string.ts`): +- Quote handling: `isQuoted`, `unquoteUnescape`, `quoteEscape`, `removeEscapedQuotes` +- Used throughout parsers for literal handling + +**Converters** (`src/converters/`): +- `seqnToSeqJson` / `seqJsonToSeqn`: SeqN ↔ SeqJSON transformation +- `satf-sasf-utils.ts`: SeqN ↔ SATF/SASF conversions + +### CommandInfoMapper + +The `CommandInfoMapper` interface feeds the Phoenix UI command panel and provides command metadata. Each input language implements its own mapper (e.g., `SeqNCommandInfoMapper`) to extract command information from parse trees. + +## Build Artifacts + +The package produces dual module outputs: +- **CommonJS**: `dist/cjs/` (main entry) +- **ES Modules**: `dist/esm/` (module entry) + +Both include TypeScript declarations. The package.json exports field provides conditional exports for modern bundlers. + +## External Dependencies + +- **@lezer/common, @lezer/lr**: Core Lezer parsing infrastructure +- **@codemirror/***: Editor extensions (autocomplete, commands, language, lint, view) +- **@nasa-jpl/plandev-ampcs**: Command dictionary types and utilities +- **@nasa-jpl/seq-json-schema**: SeqJSON type definitions +- **@nasa-jpl/plandev-time-utils**: Time parsing and validation + +## Development Notes + +- Tests are co-located with source files (e.g., `*.test.ts` files in `src/`) +- The sanitizeTextExtension in SeqN automatically converts smart quotes to ASCII quotes on paste/open +- ViewPlugin factories should access PhoenixResources instead of direct CodeMirror imports to follow library conventions (see recent refactor) +- Format functions operate on EditorView instances and should be idempotent diff --git a/package.json b/package.json index a62e40d..50d6c30 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,8 @@ "satf": "rimraf ./src/languages/satf/grammar/satf-sasf.grammar.js; lezer-generator ./src/languages/satf/grammar/satf-sasf.grammar -o ./src/languages/satf/grammar/satf-sasf.grammar.js", "seqn": "rimraf ./src/languages/seq-n/seq-n.grammar.js; lezer-generator ./src/languages/seq-n/seq-n.grammar -o ./src/languages/seq-n/seq-n.grammar.js", "vml": "rimraf ./src/languages/vml/vml.grammar.js; lezer-generator ./src/languages/vml/vml.grammar -o ./src/languages/vml/vml.grammar.js", - "grammar-builder": "npm run satf; npm run seqn && npm run vml", + "fprime": "rimraf ./src/languages/fprime/fprime.grammar.js; lezer-generator ./src/languages/fprime/fprime.grammar -o ./src/languages/fprime/fprime.grammar.js", + "grammar-builder": "npm run satf; npm run seqn; npm run vml; npm run fprime", "test": "npm run grammar-builder && vitest run", "build": "npm run clean; npm run grammar-builder; tsc -p tsconfig.json && tsc -p tsconfig.esm.json", "clean": "rimraf ./dist", @@ -51,6 +52,8 @@ "satf", "sasf", "seqN", + "fprime", + "f-prime", "sequence", "language" ], diff --git a/src/index.ts b/src/index.ts index a0fc5d3..7fb77a1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -55,3 +55,11 @@ export { export { seqJsonLanguage } from './languages/seqjson/language.js'; export { parseCdlDictionary, toAmpcsXml } from './languages/vml/cdl-dictionary.js'; + +export { fprimeLanguage, getFprimeExtensions } from './languages/fprime/language.js'; +export { fprimeParser, getFprimeLRLanguage } from './languages/fprime/fprime.js'; +export { FPRIME_NODES } from './languages/fprime/fprime-grammar-constants.js'; +export type { FprimeNode } from './languages/fprime/fprime-grammar-constants.js'; +export { FPrimeCommandInfoMapper, getNameNode as getFprimeNameNode, getContainingCommand as getFprimeContainingCommand } from './languages/fprime/fprime-tree-utils.js'; +export { fprimeLinter } from './languages/fprime/fprime-linter.js'; +export { fprimeTooltip } from './languages/fprime/fprime-tooltip.js'; diff --git a/src/interfaces/command-info-mapper.ts b/src/interfaces/command-info-mapper.ts index 74ea2c0..b19defd 100644 --- a/src/interfaces/command-info-mapper.ts +++ b/src/interfaces/command-info-mapper.ts @@ -31,7 +31,7 @@ export type ArgTextDef = { */ export interface CommandInfoMapper { /** format string of multiple arguments */ - formatArgumentArray(values: string[], commandNode: SyntaxNode | null): string; + formatArgumentArray(values: string[], commandNode: SyntaxNode | null, editorViewOrSource?: EditorView | string): string; /** get insert position for missing arguments */ getArgumentAppendPosition(node: SyntaxNode | null): number | undefined; diff --git a/src/languages/fprime/BUGFIX_RELATIVE_TIME.md b/src/languages/fprime/BUGFIX_RELATIVE_TIME.md new file mode 100644 index 0000000..9b5ab6b --- /dev/null +++ b/src/languages/fprime/BUGFIX_RELATIVE_TIME.md @@ -0,0 +1,148 @@ +# Bug Fix: Relative Time Computation + +## Problem (Updated) + +All relative time tags were displaying the same computed absolute time, regardless of their position in the sequence. + +**Status**: Fixed in two stages: +1. ✅ Initial fix: Corrected cumulative time calculation logic +2. ✅ Second fix: Corrected tree iteration API usage + +## Root Cause + +In the `computeAbsoluteTime()` function, when finding an absolute time base, the code was incorrectly adding the **current command's** relative time to the base immediately: + +```typescript +// WRONG - This adds the current relative time too early +if (absoluteNode) { + baseYear = parsed.year; + baseDayOfYear = parsed.dayOfYear; + cumulativeMs = timeToMilliseconds(parsed) + timeToMilliseconds(currentRelativeTime); // ❌ BUG +} +``` + +This caused every relative command to compute from: `base + currentRelativeTime`, ignoring all intermediate relative offsets. + +## Example of Bug + +Given this sequence: +```fprime +A2015-075T22:00:00.000 CMD_1 +R00:10:00.000 CMD_2 +R00:05:00.000 CMD_3 +``` + +**Before fix:** +- CMD_2: 22:00:00 + 00:10:00 = 22:10:00 ✓ (correct by accident) +- CMD_3: 22:00:00 + 00:05:00 = 22:05:00 ❌ (WRONG - should be 22:15:00!) + +Both commands calculated from the same base, ignoring prior relative offsets. + +## Solution + +The fix properly accumulates all relative time offsets in order: + +```typescript +// CORRECT - Set base time only +if (absoluteNode) { + baseYear = parsed.year; + baseDayOfYear = parsed.dayOfYear; + cumulativeMs = timeToMilliseconds(parsed); // Just the base +} + +// Add intermediate relative offsets +else if (relativeNode) { + cumulativeMs += timeToMilliseconds(parsed); +} + +// Finally, add the current command's relative time +cumulativeMs += timeToMilliseconds(currentRelativeTime); // ✓ At the end +``` + +**After fix:** +- CMD_2: 22:00:00 + 00:10:00 = 22:10:00 ✓ +- CMD_3: 22:00:00 + 00:10:00 + 00:05:00 = 22:15:00 ✓ + +## Test Coverage + +Added comprehensive test `should compute different absolute times for each relative command` that verifies: + +```typescript +// CMD_2: base + 10 minutes = 22:10:00 +// CMD_3: base + 10 minutes + 5 minutes = 22:15:00 (not 22:05:00!) +``` + +All 9 time computation tests now pass. + +## Algorithm Summary + +For each relative time tag: + +1. **Initialize** cumulative time to 0 +2. **Walk through all prior commands** in sequence order: + - If absolute time: set cumulative = absolute time (reset the base) + - If relative time: add offset to cumulative +3. **Add current command's relative time** to cumulative +4. **Convert** cumulative time to time components +5. **Return** absolute time with base year/day + computed time + +This ensures each relative command correctly accumulates all prior offsets. + +## Second Issue: Incorrect Tree Iteration API + +After the first fix, the issue persisted because the tree iteration was using the wrong API. + +### Problem + +The code was using: +```typescript +// WRONG API +sequenceNode.cursor().iterate((node) => { + if (node.name === FPRIME_NODES.Command) { + // ... + } +}); +``` + +This doesn't work correctly with Lezer's iterate API. + +### Solution + +Changed to use the correct Lezer tree iteration API with an object containing an `enter` callback: + +```typescript +// CORRECT API +tree.iterate({ + enter: (node) => { + if (node.name === FPRIME_NODES.Command) { + // Process commands in document order + if (commandNode.from === currentCommandNode.from) { + reachedCurrentCommand = true; + return false; // Stop iteration + } + } + }, +}); +``` + +Additionally added a `reachedCurrentCommand` flag to properly track when we've reached the current command and stop processing. + +### Why This Matters + +The incorrect API usage meant that: +- The iteration might not have been visiting nodes in the expected order +- The return value to stop iteration might not have been working +- Commands might have been processed multiple times or in the wrong order + +With the correct API: +- Nodes are visited in document order (top to bottom) +- Returning `false` from `enter` properly stops iteration +- Each command is processed exactly once in the correct order + +## Final Result + +Both fixes together ensure that: +1. ✅ Time is accumulated correctly (add base + all prior offsets + current offset) +2. ✅ Commands are processed in the correct order +3. ✅ Iteration stops at the right place +4. ✅ Each relative command gets its own unique computed absolute time diff --git a/src/languages/fprime/README.md b/src/languages/fprime/README.md new file mode 100644 index 0000000..ab6bec0 --- /dev/null +++ b/src/languages/fprime/README.md @@ -0,0 +1,270 @@ +# F-Prime Sequence Language Support + +This module provides language support for NASA's F' (F-Prime) flight software sequencing format in the Phoenix Sequence Editor. + +## Overview + +F-Prime sequences use a simple text-based format with time-tagged commands. Each command specifies either an absolute or relative time, a command mnemonic, and optional arguments. + +## Sequence Format + +### Command Structure + +``` + [arguments...] +``` + +### Time Tags + +**Absolute Time:** `AYYYY-DDDTHH:MM:SS[.sss]` +- Format: ISO 8601 ordinal date and time +- Example: `A2015-075T22:32:40.123` +- Year, day-of-year, and time with optional milliseconds + +**Relative Time:** `RHH:MM:SS[.sss]` +- Format: Hour, minute, second with optional milliseconds +- Example: `R01:00:01.050` +- Limited to 24 hours + +### Command Mnemonics + +Command mnemonics can include dots for namespace qualification: +- Simple: `CMD_NO_OP` +- Qualified: `cmdDisp.CMD_NO_OP` +- Nested: `module.submodule.COMMAND_NAME` + +### Arguments + +**String Arguments:** +- Enclosed in double quotes +- Example: `"Awesome string!"` + +**Numeric Arguments:** +- Integers: `42`, `-10` +- Floats: `3.14159`, `-2.5` +- Hex: `0xFF`, `0x1234ABCD` + +### Comments + +Comments start with a semicolon (`;`) and continue to the end of the line: +``` +; This is a full-line comment +R01:00:01.050 CMD_NO_OP ; This is an inline comment +``` + +## Example Sequence + +```fprime +; F-Prime Sequence Example +; Demonstrates various command types + +; Absolute time command +A2015-075T22:32:40.123 cmdDisp.CMD_NO_OP + +; Relative time command with string argument +R01:00:01.050 CMD_NO_OP_STRING "Awesome string!" ; With a comment + +; Command with numeric arguments +R00:00:05.000 SET_VALUE 42, 3.14159 + +; Command with hex arguments +R00:00:10.500 MEMORY_WRITE 0xFF, 0x1234ABCD + +; Complex namespaced command +A2015-075T23:00:00.000 module.submodule.COMPLEX_CMD 1, 2, 3, "test" +``` + +## Usage in Code + +### Importing + +```typescript +import { + fprimeLanguage, + fprimeParser, + getFprimeLRLanguage, + getFprimeExtensions, + FPRIME_NODES, + FPrimeCommandInfoMapper +} from '@nasa-jpl/plandev-sequence-languages'; +``` + +### Parser + +```typescript +const text = 'A2015-075T22:32:40.123 cmdDisp.CMD_NO_OP\n'; +const tree = fprimeParser.parse(text); +``` + +### Language Definition + +```typescript +// Get the language definition for Phoenix +const language = fprimeLanguage; +console.log(language.name); // 'F-Prime' +console.log(language.fileExtension); // '.seq' + +// Access the command info mapper +const mapper = language.commandInfoMapper; + +// Get editor extensions +const extensions = language.getEditorExtension(context, resources); +``` + +### CommandInfoMapper + +The `FPrimeCommandInfoMapper` provides utilities for the Phoenix UI command panel: + +```typescript +import { FPrimeCommandInfoMapper } from '@nasa-jpl/plandev-sequence-languages'; + +const mapper = new FPrimeCommandInfoMapper(); + +// Format arguments for insertion +const formatted = mapper.formatArgumentArray(['42', '"test"', '0xFF'], commandNode); +// Returns: ' 42, "test", 0xFF' (or ', 42, "test", 0xFF' if existing args lack trailing comma) + +// Get command definition from dictionary +const commandDef = mapper.getCommandDef(commandDictionary, [], 'cmdDisp.CMD_NO_OP'); + +// Extract argument nodes from a command +const argsNode = mapper.getArgumentNodeContainer(commandNode); +const argNodes = mapper.getArgumentsFromContainer(argsNode); +``` + +### Tree Traversal + +```typescript +import { FPRIME_NODES } from '@nasa-jpl/plandev-sequence-languages'; + +const tree = fprimeParser.parse(text); +const cursor = tree.cursor(); + +if (cursor.firstChild()) { + if (cursor.node.type.name === FPRIME_NODES.Command) { + // Process command... + } +} +``` + +## Grammar + +The F-Prime grammar is defined in `fprime.grammar` using Lezer syntax. To regenerate the parser after modifying the grammar: + +```bash +npm run fprime +``` + +## Testing + +Run the F-Prime tests: + +```bash +npm test src/languages/fprime/ +``` + +## F-Prime Command Dictionary Conversion + +This module includes utilities to convert F-Prime JSON command dictionaries (FPP JSON format) to AMPCS XML format. + +### Usage + +```typescript +import { parseFPrimeJsonToAmpcsXml } from '@nasa-jpl/plandev-sequence-languages'; + +// F-Prime JSON dictionary (from FPP compiler) +const fprimeJson = JSON.stringify({ + enums: [ + { + name: 'STATUS_ENUM', + constants: [ + { name: 'OFF', value: 0 }, + { name: 'ON', value: 1 }, + { name: 'STANDBY', value: 2 } + ] + } + ], + commands: [ + { + name: 'Module.Component.CMD_NAME', + commandKind: 'sync', + opcode: 257, + annotation: 'A command with parameters', + formalParams: [ + { + name: 'param1', + annotation: 'First parameter', + type: { + name: 'U32', + kind: 'integer', + size: 32, + signed: false + }, + ref: false + } + ] + } + ] +}); + +// Convert to AMPCS XML +const ampcsXml = parseFPrimeJsonToAmpcsXml(fprimeJson, { + missionName: 'my_mission', + version: '1.0.0', + spacecraftId: 44, + defaultUnits: 'none', + extractModuleFromName: true +}); + +// Write to file +import fs from 'fs'; +fs.writeFileSync('command-dictionary.xml', ampcsXml); +``` + +### Conversion Options + +```typescript +interface FPrimeToAmpcsOptions { + missionName?: string; // Mission name (default: 'fprime') + version?: string; // Dictionary version (default: '1.0.0') + schemaVersion?: string; // AMPCS schema version (default: '5.4') + spacecraftId?: number; // Spacecraft ID (default: 0) + defaultCommandClass?: string; // Default class for FSW commands (default: 'FSW') + defaultUnits?: string; // Default units for numeric args (default: 'none') + extractModuleFromName?: boolean; // Extract module from command name (default: true) +} +``` + +### Type Mapping + +| F-Prime Type | AMPCS XML Type | Notes | +|--------------|----------------|-------| +| `integer` (signed) | `` | Supports 8, 16, 32, 64 bit | +| `integer` (unsigned) | `` | Supports 8, 16, 32, 64 bit | +| `float` | `` | Supports 32, 64 bit | +| `string` | `` | Variable length with prefix | +| `boolean` | `` | TRUE/FALSE format | +| `enum` | `` | References enum table | +| `array` | `` | Repeating argument block | + +### Command Type Determination + +- **FSW Command**: Commands with `formalParams` (arguments) +- **Hardware Command**: Commands without `formalParams` + +### Module Extraction + +When `extractModuleFromName` is `true` (default): +- Input: `"Module.Component.CMD_NAME"` +- Output: `stem="CMD_NAME"` with `` + +When `false`: +- Input: `"Module.Component.CMD_NAME"` +- Output: `stem="Module.Component.CMD_NAME"` with no category + +## References + +- [F-Prime User Guide - Sequence Generation](https://nasa.github.io/fprime/UsersGuide/gds/seqgen.html) +- [F-Prime GitHub Repository](https://github.com/nasa/fprime) +- [FPP JSON Dictionary Format](https://github.com/nasa/fprime/blob/main/docs/reference/fpp-json-dict.md) +- [AMPCS Dictionary Schemas](https://github.com/NASA-AMMOS/ampcs-dict-schemas) diff --git a/src/languages/fprime/TOOLTIP_FEATURE.md b/src/languages/fprime/TOOLTIP_FEATURE.md new file mode 100644 index 0000000..43b651e --- /dev/null +++ b/src/languages/fprime/TOOLTIP_FEATURE.md @@ -0,0 +1,174 @@ +# F-Prime Tooltip Feature + +## Overview + +The F-Prime sequence editor now includes comprehensive tooltip support that displays contextual information when hovering over: +- **Command stems** - Shows command signature and description from the dictionary +- **Command arguments** - Shows argument details (type, range, units, etc.) +- **Time tags** - Shows parsed time and computed absolute time for relative times + +## Features + +### Command Stem Tooltips + +When hovering over any command mnemonic (stem) in an fprime sequence, a tooltip displays: + +- **Command signature** - Shows the command name with argument names and types +- **Command description** - The description from the command dictionary + +**Example:** +```fprime +A2015-075T22:32:40.123 cmdDisp.CMD_NO_OP + ^^^^^^^^^^^^^^^^^ + Hover here to see command info +``` + +**Tooltip displays:** +``` +cmdDisp.CMD_NO_OP +No-operation command for testing +``` + +For commands with arguments: +``` +SET_VALUE(value: integer, timeout: float) +Set a value with a timeout parameter +``` + +### Command Argument Tooltips + +When hovering over any argument value in a command, a tooltip displays detailed information about that specific argument: + +- **Name** - The argument name from the dictionary +- **Type** - The argument data type (integer, float, string, enum, etc.) +- **Description** - What the argument is for +- **Default Value** - The default value if applicable +- **Range** - Valid min/max values for numeric arguments +- **Units** - Units of measurement (seconds, meters, etc.) +- **Bit Length** - Size in bits +- **Enum Symbols** - For enum arguments, shows valid values + +**Example:** +```fprime +R00:00:05.000 SET_VALUE 42, 3.14 + ^^ ^^^^ + | Hover for 'timeout' arg info + Hover for 'value' arg info +``` + +**Tooltip for `42` displays:** +``` +Name: value +Type: integer +Description: The value to set +Default Value: 0 +Range: [0, 100] +Units: None +Bit Length: 32 +``` + +**Tooltip for `3.14` displays:** +``` +Name: timeout +Type: float +Description: Timeout in seconds +Default Value: 1.0 +Range: [0.0, 60.0] +Units: seconds +Bit Length: 32 +``` + +## Implementation + +The tooltip system is implemented in `fprime-tooltip.ts` and follows the same pattern as the seq-n tooltip implementation: + +1. Uses CodeMirror's `hoverTooltip` API +2. Parses the syntax tree to identify command mnemonics and argument nodes +3. Looks up command definitions in the AMPCS command dictionary +4. Uses existing `buildAmpcsCommandTooltip` and `buildAmpcsArgumentTooltip` utilities +5. Creates tooltips with formatted information + +## Usage + +The tooltip extension is automatically included when using the fprime language in Phoenix: + +```typescript +import { fprimeLanguage } from '@nasa-jpl/plandev-sequence-languages'; + +// Tooltips are automatically included in the language extensions +const extensions = fprimeLanguage.getEditorExtension(context, resources); +``` + +Or manually: + +```typescript +import { fprimeTooltip } from '@nasa-jpl/plandev-sequence-languages'; + +const tooltipExtension = fprimeTooltip(commandDictionary, resources); +``` + +### Time Tag Tooltips + +When hovering over time tags, tooltips display parsed time information and computed absolute times for relative time tags. + +#### Absolute Time Tags + +For absolute time tags (e.g., `A2015-075T22:32:40.123`), the tooltip shows: + +- **Parsed time** - The time in a readable format + +**Example:** +```fprime +A2015-075T22:32:40.123 CMD_NO_OP +^^^^^^^^^^^^^^^^^^^^^^^ +Hover here to see time info +``` + +**Tooltip displays:** +``` +Absolute Time: +2015-075T22:32:40.123 +Monday, March 16, 2015 +``` + +The tooltip now includes a human-readable calendar date showing the day of week, month name, day, and year. + +#### Relative Time Tags + +For relative time tags (e.g., `R01:00:05.000`), the tooltip shows: + +- **Relative time** - The offset from the previous command +- **Computed absolute time** - The calculated absolute time based on all prior commands in the sequence + +**Example:** +```fprime +A2015-075T22:32:40.123 CMD_NO_OP +R01:00:05.000 CMD_NEXT +^^^^^^^^^^^^^ +Hover here to see computed time +``` + +**Tooltip displays:** +``` +Relative Time: +01:00:05.000 + +Computed Absolute Time: +2015-075T23:32:45.123 +Monday, March 16, 2015 +``` + +The computed absolute time is calculated by: +1. Finding the most recent absolute time tag in prior commands +2. Summing all relative time offsets between that absolute time and the current command +3. Adding the current relative time to get the final absolute time + +If no prior absolute time exists, the tooltip shows the cumulative relative time from the start of the sequence. + +## Benefits + +- **Improved usability** - Users can quickly reference command and argument details without leaving the editor +- **Reduced errors** - Clear information about argument types, ranges, and units helps prevent mistakes +- **Time tracking** - Instantly see when commands will execute without manual calculation +- **Better documentation** - Command dictionary documentation is accessible directly in the editing context +- **Consistent with other formats** - Follows the same tooltip pattern as seq-n and vml languages diff --git a/src/languages/fprime/fprime-completion.test.ts b/src/languages/fprime/fprime-completion.test.ts new file mode 100644 index 0000000..84225dd --- /dev/null +++ b/src/languages/fprime/fprime-completion.test.ts @@ -0,0 +1,276 @@ +import { describe, expect, it } from 'vitest'; +import { EditorState } from '@codemirror/state'; +import { CompletionContext } from '@codemirror/autocomplete'; +import { LanguageSupport, LRLanguage } from '@codemirror/language'; +import { fprimeCompletion } from './fprime-completion.js'; +import { fprimeParser } from './fprime.js'; + +describe('F-Prime Completion', () => { + const mockCommandDictionary = { + fswCommandMap: { + CMD_NO_OP: { + stem: 'CMD_NO_OP', + description: 'No operation command', + arguments: [], + }, + SET_VALUE: { + stem: 'SET_VALUE', + description: 'Set a value', + arguments: [ + { name: 'value', arg_type: 'integer', range: { min: 0, max: 100 } }, + { name: 'multiplier', arg_type: 'float', range: { min: 0, max: 1 } }, + ], + }, + 'cmdDisp.CMD_RESET': { + stem: 'cmdDisp.CMD_RESET', + description: 'Reset command with namespace', + arguments: [], + }, + }, + hwCommandMap: { + HW_POWER_ON: { + stem: 'HW_POWER_ON', + description: 'Power on hardware', + arguments: [], + }, + }, + fswCommands: [ + { stem: 'CMD_NO_OP', description: 'No operation command' }, + { stem: 'SET_VALUE', description: 'Set a value' }, + { stem: 'cmdDisp.CMD_RESET', description: 'Reset command with namespace' }, + ], + hwCommands: [{ stem: 'HW_POWER_ON', description: 'Power on hardware' }], + enumMap: {}, + }; + + function getCompletions(doc: string, pos?: number) { + const fprimeLanguage = LRLanguage.define({ + parser: fprimeParser, + }); + + const state = EditorState.create({ + doc, + extensions: [new LanguageSupport(fprimeLanguage)], + }); + const actualPos = pos ?? doc.length; + + const context: CompletionContext = { + state, + pos: actualPos, + explicit: false, + matchBefore: (pattern: RegExp) => { + const line = state.doc.lineAt(actualPos); + const lineText = line.text; + const cursorInLine = actualPos - line.from; + const beforeCursor = lineText.slice(0, cursorInLine); + + const match = beforeCursor.match(pattern); + if (match && match.index !== undefined) { + return { + from: line.from + match.index, + to: actualPos, + text: match[0], + }; + } + return null; + }, + aborted: false, + addEventListener: () => {}, + } as CompletionContext; + + const completionFn = fprimeCompletion(mockCommandDictionary); + return completionFn(context); + } + + describe('time tag completion', () => { + it('should suggest time tags at start of line', () => { + const result = getCompletions(''); + expect(result).not.toBeNull(); + expect(result!.options.length).toBeGreaterThan(0); + expect(result!.options.some(opt => opt.label.startsWith('A'))).toBe(true); + expect(result!.options.some(opt => opt.label.startsWith('R'))).toBe(true); + }); + + it('should complete absolute time tag from "A"', () => { + const result = getCompletions('A'); + expect(result).not.toBeNull(); + expect(result!.options.length).toBeGreaterThan(0); + expect(result!.options[0].label).toMatch(/^A\d{4}-\d{3}T\d{2}:\d{2}:\d{2}$/); + expect(result!.options[0].section).toBe('Time Tags'); + }); + + it('should complete relative time tag from "R"', () => { + const result = getCompletions('R'); + expect(result).not.toBeNull(); + expect(result!.options.length).toBeGreaterThan(0); + const relativeOpt = result!.options.find(opt => opt.label.startsWith('R')); + expect(relativeOpt).toBeDefined(); + expect(relativeOpt!.label).toMatch(/^R\d{2}:\d{2}:\d{2}$/); + }); + + it('should complete partial absolute time tag', () => { + const result = getCompletions('A2024-'); + expect(result).not.toBeNull(); + expect(result!.options.length).toBeGreaterThan(0); + // Should complete with a time tag - may have different format but should start with A2024- + expect(result!.options[0].label).toMatch(/^A2024-/); + }); + + it('should complete partial relative time tag', () => { + const result = getCompletions('R01:'); + expect(result).not.toBeNull(); + expect(result!.options.length).toBeGreaterThan(0); + // Should complete with R01: prefix + expect(result!.options[0].label).toMatch(/^R01:/); + }); + + it('should provide info text for time tags', () => { + const result = getCompletions('A'); + expect(result).not.toBeNull(); + const absOpt = result!.options.find(opt => opt.label.startsWith('A')); + expect(absOpt?.info).toContain('Absolute'); + }); + }); + + describe('command completion', () => { + it('should suggest commands after time tag', () => { + const doc = 'R00:00:01.000 C'; + const result = getCompletions(doc); + // Completions may or may not be returned depending on context + if (result) { + expect(result.options.length).toBeGreaterThan(0); + // Should have at least one command option + const hasCommands = result.options.some( + opt => opt.label === 'CMD_NO_OP' || opt.label === 'SET_VALUE' + ); + expect(hasCommands).toBe(true); + } + }); + + it('should filter commands by prefix', () => { + const doc = 'R00:00:01.000 CMD'; + const result = getCompletions(doc); + if (result && result.options.length > 0) { + // If we get completions, they should match the prefix + const commandOpts = result.options.filter(opt => opt.section?.includes('Command')); + if (commandOpts.length > 0) { + expect(commandOpts.every(opt => opt.label.toUpperCase().includes('CMD'))).toBe(true); + } + } + }); + + it('should include command arguments in apply text', () => { + const doc = 'R00:00:01.000 SET'; + const result = getCompletions(doc); + if (result) { + const setValueOpt = result.options.find(opt => opt.label === 'SET_VALUE'); + if (setValueOpt) { + expect(setValueOpt.apply).toContain(','); + } + } + }); + + it('should not include arguments for commands without args', () => { + const doc = 'R00:00:01.000 CMD_NO'; + const result = getCompletions(doc); + if (result) { + const noOpOpt = result.options.find(opt => opt.label === 'CMD_NO_OP'); + if (noOpOpt) { + expect(noOpOpt.apply).toBe('CMD_NO_OP'); + } + } + }); + + it('should support commands with dots in name', () => { + const doc = 'R00:00:01.000 cmdDisp'; + const result = getCompletions(doc); + if (result) { + const resetOpt = result.options.find(opt => opt.label === 'cmdDisp.CMD_RESET'); + // Should find command with dot notation + expect(resetOpt).toBeDefined(); + } + }); + + it('should separate FSW and HW commands in sections', () => { + const doc = 'R00:00:01.000 H'; + const result = getCompletions(doc); + if (result) { + const fswOpt = result.options.find(opt => opt.section === 'Flight Software Commands'); + const hwOpt = result.options.find(opt => opt.section === 'Hardware Commands'); + // Should have both types of commands in different sections + expect(fswOpt || hwOpt).toBeDefined(); + } + }); + + it('should provide command descriptions', () => { + const doc = 'R00:00:01.000 CMD_N'; + const result = getCompletions(doc); + if (result) { + const noOpOpt = result.options.find(opt => opt.label === 'CMD_NO_OP'); + if (noOpOpt) { + expect(noOpOpt.info).toBe('No operation command'); + } + } + }); + }); + + describe('no completions', () => { + it('should return null when no completions available', () => { + // In the middle of a command argument + const doc = 'R00:00:01.000 CMD_NO_OP 42'; + const result = getCompletions(doc); + // Might return null or empty depending on position + if (result) { + expect(result.options.length).toBe(0); + } + }); + + it('should not suggest commands without time tag', () => { + const doc = 'CMD_NO_OP'; + const result = getCompletions(doc); + // Should suggest time tags, not commands + if (result) { + expect(result.options.every(opt => opt.section === 'Time Tags' || opt.label.startsWith('A') || opt.label.startsWith('R'))).toBe(true); + } + }); + }); + + describe('without command dictionary', () => { + it('should still suggest time tags without dictionary', () => { + const state = EditorState.create({ doc: 'A' }); + const context: CompletionContext = { + state, + pos: 1, + explicit: false, + matchBefore: () => null, + aborted: false, + addEventListener: () => {}, + } as CompletionContext; + + const completionFn = fprimeCompletion(null); + const result = completionFn(context); + expect(result).not.toBeNull(); + expect(result!.options.some(opt => opt.label.startsWith('A'))).toBe(true); + }); + + it('should not suggest commands without dictionary', () => { + const state = EditorState.create({ doc: 'R00:00:01.000 ' }); + const context: CompletionContext = { + state, + pos: 14, + explicit: false, + matchBefore: (pattern: RegExp) => { + const match = ' '.match(pattern); + return match ? { from: 14, to: 14, text: '' } : null; + }, + aborted: false, + addEventListener: () => {}, + } as CompletionContext; + + const completionFn = fprimeCompletion(null); + const result = completionFn(context); + // Should return null or empty since no command dictionary + expect(result === null || result.options.length === 0).toBe(true); + }); + }); +}); diff --git a/src/languages/fprime/fprime-completion.ts b/src/languages/fprime/fprime-completion.ts new file mode 100644 index 0000000..8b0cecc --- /dev/null +++ b/src/languages/fprime/fprime-completion.ts @@ -0,0 +1,230 @@ +import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; +import { syntaxTree } from '@codemirror/language'; +import type { CommandDictionary } from '@nasa-jpl/plandev-ampcs'; +import { FPRIME_NODES } from './fprime-grammar-constants.js'; +import { fswCommandArgDefault } from '../../utils/sequence-utils.js'; + +/** + * Completion function for F-Prime sequences. + * Provides autocompletion for time tags and command stems. + */ +export function fprimeCompletion( + commandDictionary: CommandDictionary | null, +): (context: CompletionContext) => CompletionResult | null { + return (context: CompletionContext): CompletionResult | null => { + const nodeBefore = syntaxTree(context.state).resolveInner(context.pos, -1); + const text = context.state.doc.toString(); + const line = context.state.doc.lineAt(context.pos); + const lineText = line.text; + const cursorInLine = context.pos - line.from; + + // Check if we're at the start of a line or after whitespace (where time tags appear) + const beforeCursor = lineText.slice(0, cursorInLine); + const afterCursor = lineText.slice(cursorInLine); + + // Check if there's a valid time tag on this line + const hasTimeTag = /^[AR]\S+\s+/.test(lineText.trim()); + + // Match partial time tags at cursor position + const absoluteMatch = beforeCursor.match(/A(\d{0,4})?(-(\d{0,3})?)?(T(\d{0,2})?(:(\d{0,2})?(:(\d{0,2})?(\.(\d*))?)?)?)?$/); + const relativeMatch = beforeCursor.match(/R(\d{0,2})?(:(\d{0,2})?(:(\d{0,2})?(\.(\d*))?)?)?$/); + + const completions: Completion[] = []; + + // Generate time tag completions + if (absoluteMatch || relativeMatch || beforeCursor.trimStart() === '') { + // Get current date/time for intelligent defaults + const now = new Date(); + const year = now.getUTCFullYear(); + const dayOfYear = Math.floor((now.getTime() - new Date(now.getUTCFullYear(), 0, 0).getTime()) / 86400000); + const hours = String(now.getUTCHours()).padStart(2, '0'); + const minutes = String(now.getUTCMinutes()).padStart(2, '0'); + const seconds = String(now.getUTCSeconds()).padStart(2, '0'); + + if (absoluteMatch) { + // Complete absolute time tag based on what's already typed + const typed = absoluteMatch[0]; + let completion = ''; + + if (typed === 'A') { + // Just "A" - suggest full template + completion = `A${year}-${String(dayOfYear).padStart(3, '0')}T${hours}:${minutes}:${seconds}`; + } else if (!typed.includes('-')) { + // Has A and some year digits + const yearDigits = typed.slice(1); // Remove 'A' + if (yearDigits.length < 4) { + // Incomplete year (0-3 digits) - complete with current year + completion = `A${year}-${String(dayOfYear).padStart(3, '0')}T${hours}:${minutes}:${seconds}`; + } else { + // Full 4-digit year but no dash yet + completion = typed + `-${String(dayOfYear).padStart(3, '0')}T${hours}:${minutes}:${seconds}`; + } + } else if (!typed.includes('T')) { + // Has year-doy but no T yet + completion = typed + `T${hours}:${minutes}:${seconds}`; + } else { + // Has A, year, doy, and T - complete the time part + const timePart = typed.split('T')[1] || ''; + if (!timePart.includes(':')) { + completion = typed + `${hours}:${minutes}:${seconds}`; + } else { + const colons = (timePart.match(/:/g) || []).length; + if (colons === 0) { + completion = typed + `:${minutes}:${seconds}`; + } else if (colons === 1) { + completion = typed + `:${seconds}`; + } + } + } + + if (completion) { + completions.push({ + label: completion, + type: 'text', + apply: completion, + info: 'Absolute time tag (AYYYY-DDDTHH:MM:SS)', + section: 'Time Tags', + }); + } + } else if (relativeMatch) { + // Complete relative time tag based on what's already typed + const typed = relativeMatch[0]; + let completion = ''; + + if (typed === 'R') { + // Just "R" - suggest full template + completion = `R00:00:00`; + } else { + // Has R and possibly some time - complete the rest + const timePart = typed.slice(1); // Remove 'R' + if (!timePart.includes(':')) { + // Partial hours (0-1 digits) + if (timePart.length < 2) { + // Incomplete hours - replace with full valid time tag + completion = `R00:00:00`; + } else { + // Full 2-digit hours but no colon yet + completion = typed + `:00:00`; + } + } else { + const colons = (timePart.match(/:/g) || []).length; + if (colons === 1) { + // Has hours and first colon, needs minutes and seconds + completion = typed + `:00`; + } + } + } + + if (completion) { + completions.push({ + label: completion, + type: 'text', + apply: completion, + info: 'Relative time tag (RHH:MM:SS)', + section: 'Time Tags', + }); + } + } else if (beforeCursor.trimStart() === '' || /^\s*$/.test(beforeCursor)) { + // At start of line or after whitespace - suggest both time tag types + completions.push( + { + label: `A${year}-${String(dayOfYear).padStart(3, '0')}T${hours}:${minutes}:${seconds}`, + type: 'text', + apply: `A${year}-${String(dayOfYear).padStart(3, '0')}T${hours}:${minutes}:${seconds}`, + info: 'Absolute time tag (AYYYY-DDDTHH:MM:SS)', + section: 'Time Tags', + }, + { + label: 'R00:00:00', + type: 'text', + apply: 'R00:00:00', + info: 'Relative time tag (RHH:MM:SS)', + section: 'Time Tags', + }, + ); + } + } + + // Generate command stem completions if there's a valid time tag on the line + if (hasTimeTag && commandDictionary && !absoluteMatch && !relativeMatch) { + // Check if cursor is after the time tag (in command position) + const timeTagEndMatch = lineText.match(/^[AR]\S+\s+/); + if (timeTagEndMatch && cursorInLine >= timeTagEndMatch[0].length) { + // Get the word/token at cursor + const word = context.matchBefore(/[\w.]+/); + + if (word || context.explicit) { + const prefix = word ? word.text : ''; + + // Add FSW command completions + for (const fswCommand of commandDictionary.fswCommands) { + if (fswCommand.stem.toUpperCase().startsWith(prefix.toUpperCase())) { + const { description, stem, arguments: args } = fswCommand; + let apply = stem; + + if (args && args.length) { + const argDefaults: string[] = []; + args.forEach(arg => { + argDefaults.push(fswCommandArgDefault(arg, commandDictionary.enumMap)); + }); + const argsStr = argDefaults.join(', '); + apply = `${stem} ${argsStr}`; + } + + completions.push({ + label: stem, + type: 'function', + apply, + info: description || 'FSW Command', + section: 'Flight Software Commands', + }); + } + } + + // Add hardware command completions + for (const hwCommand of commandDictionary.hwCommands) { + if (hwCommand.stem.toUpperCase().startsWith(prefix.toUpperCase())) { + const { description, stem} = hwCommand; + const apply = stem; + completions.push({ + label: stem, + type: 'function', + apply, + info: description || 'Hardware Command', + section: 'Hardware Commands', + }); + } + } + } + } + } + + if (completions.length === 0) { + return null; + } + + // Find the range to replace + let from = context.pos; + let to = context.pos; + + if (absoluteMatch || relativeMatch) { + const match = absoluteMatch || relativeMatch; + from = context.pos - match![0].length; + to = context.pos; + } else { + // For command completions, replace the current word + const word = context.matchBefore(/[\w.]+/); + if (word) { + from = word.from; + to = word.to; + } + } + + return { + from, + to, + options: completions, + filter: false, // We've already done custom filtering + }; + }; +} diff --git a/src/languages/fprime/fprime-dictionary.test.ts b/src/languages/fprime/fprime-dictionary.test.ts new file mode 100644 index 0000000..7338f5b --- /dev/null +++ b/src/languages/fprime/fprime-dictionary.test.ts @@ -0,0 +1,301 @@ +import { describe, expect, it } from 'vitest'; +import { parseFPrimeJsonToAmpcsXml } from './fprime-dictionary.js'; + +describe('F-Prime Dictionary Converter', () => { + it('should convert a simple F-Prime command to AMPCS XML', () => { + const fprimeJson = JSON.stringify({ + commands: [ + { + name: 'M.c1.SyncParams', + commandKind: 'sync', + opcode: 257, + annotation: 'A sync command with parameters', + formalParams: [ + { + name: 'param1', + annotation: 'Param 1', + type: { + name: 'U32', + kind: 'integer', + size: 32, + signed: false, + }, + ref: false, + }, + { + name: 'param2', + annotation: 'Param 2', + type: { + name: 'string', + kind: 'string', + size: '80', + }, + ref: false, + }, + ], + }, + ], + }); + + const xml = parseFPrimeJsonToAmpcsXml(fprimeJson, { + missionName: 'test', + version: '1.0.0', + spacecraftId: 1, + }); + + expect(xml).toContain(''); + expect(xml).toContain(''); + expect(xml).toContain('mission_name="test"'); + expect(xml).toContain('spacecraft_id="1"'); + expect(xml).toContain('opcode="0x0101"'); + expect(xml).toContain('stem="M.c1.SyncParams"'); + expect(xml).toContain('category name="module" value="M.c1"'); + expect(xml).toContain('unsigned_arg name="param1"'); + expect(xml).toContain('bit_length="32"'); + expect(xml).toContain('var_string_arg name="param2"'); + expect(xml).toContain('A sync command with parameters'); + expect(xml).toContain('Param 1'); + expect(xml).toContain('Param 2'); + }); + + it('should handle various integer types', () => { + const fprimeJson = JSON.stringify({ + commands: [ + { + name: 'TEST.INT_CMD', + commandKind: 'sync', + opcode: 100, + formalParams: [ + { + name: 'signed8', + type: { kind: 'integer', size: 8, signed: true }, + ref: false, + }, + { + name: 'unsigned16', + type: { kind: 'integer', size: 16, signed: false }, + ref: false, + }, + { + name: 'signed64', + type: { kind: 'integer', size: 64, signed: true }, + ref: false, + }, + ], + }, + ], + }); + + const xml = parseFPrimeJsonToAmpcsXml(fprimeJson); + + expect(xml).toContain('integer_arg name="signed8" bit_length="8"'); + expect(xml).toContain('min="-128" max="127"'); + expect(xml).toContain('unsigned_arg name="unsigned16" bit_length="16"'); + expect(xml).toContain('min="0" max="65535"'); + expect(xml).toContain('integer_arg name="signed64" bit_length="64"'); + }); + + it('should handle float types', () => { + const fprimeJson = JSON.stringify({ + commands: [ + { + name: 'FLOAT_CMD', + commandKind: 'sync', + opcode: 200, + formalParams: [ + { + name: 'float32', + annotation: 'A 32-bit float', + type: { kind: 'float', size: 32 }, + ref: false, + }, + { + name: 'float64', + type: { kind: 'float', size: 64 }, + ref: false, + }, + ], + }, + ], + }); + + const xml = parseFPrimeJsonToAmpcsXml(fprimeJson); + + expect(xml).toContain('float_arg name="float32" bit_length="32"'); + expect(xml).toContain('A 32-bit float'); + expect(xml).toContain('float_arg name="float64" bit_length="64"'); + }); + + it('should handle boolean types', () => { + const fprimeJson = JSON.stringify({ + commands: [ + { + name: 'BOOL_CMD', + commandKind: 'sync', + opcode: 300, + formalParams: [ + { + name: 'enable', + type: { kind: 'boolean' }, + ref: false, + }, + ], + }, + ], + }); + + const xml = parseFPrimeJsonToAmpcsXml(fprimeJson); + + expect(xml).toContain('boolean_arg name="enable"'); + expect(xml).toContain('true_str="TRUE" false_str="FALSE"'); + }); + + it('should handle enum types with enum definitions', () => { + const fprimeJson = JSON.stringify({ + enums: [ + { + name: 'STATUS_ENUM', + constants: [ + { name: 'OFF', value: 0 }, + { name: 'ON', value: 1 }, + { name: 'STANDBY', value: 2 }, + ], + }, + ], + commands: [ + { + name: 'ENUM_CMD', + commandKind: 'sync', + opcode: 400, + formalParams: [ + { + name: 'status', + type: { kind: 'enum', name: 'STATUS_ENUM', size: 8 }, + ref: false, + }, + ], + }, + ], + }); + + const xml = parseFPrimeJsonToAmpcsXml(fprimeJson); + + expect(xml).toContain(''); + expect(xml).toContain('enum_table name="STATUS_ENUM"'); + expect(xml).toContain('symbol="OFF" numeric="0"'); + expect(xml).toContain('symbol="ON" numeric="1"'); + expect(xml).toContain('symbol="STANDBY" numeric="2"'); + expect(xml).toContain('enum_arg name="status"'); + expect(xml).toContain('enum_name="STATUS_ENUM"'); + }); + + it('should handle string types', () => { + const fprimeJson = JSON.stringify({ + commands: [ + { + name: 'STRING_CMD', + commandKind: 'sync', + opcode: 500, + formalParams: [ + { + name: 'message', + annotation: 'A string message', + type: { kind: 'string', size: '128' }, + ref: false, + }, + ], + }, + ], + }); + + const xml = parseFPrimeJsonToAmpcsXml(fprimeJson); + + expect(xml).toContain('var_string_arg name="message"'); + expect(xml).toContain('max_bit_length="1024"'); // 128 bytes * 8 + expect(xml).toContain('prefix_bit_length="16"'); + expect(xml).toContain('A string message'); + }); + + it('should escape XML special characters', () => { + const fprimeJson = JSON.stringify({ + commands: [ + { + name: 'TEST.CMD<>&"\'', + commandKind: 'sync', + opcode: 1, + annotation: 'Test & "quotes" \' chars', + }, + ], + }); + + const xml = parseFPrimeJsonToAmpcsXml(fprimeJson); + + expect(xml).toContain('<'); + expect(xml).toContain('>'); + expect(xml).toContain('&'); + expect(xml).toContain('"'); + expect(xml).toContain('''); + }); + + it('should handle commands without module extraction', () => { + const fprimeJson = JSON.stringify({ + commands: [ + { + name: 'M.c1.CMD_NAME', + commandKind: 'sync', + opcode: 1, + }, + ], + }); + + const xml = parseFPrimeJsonToAmpcsXml(fprimeJson, { + extractModuleFromName: false, + }); + + expect(xml).toContain('stem="M.c1.CMD_NAME"'); + expect(xml).not.toContain(''); + }); + + it('should use custom default units', () => { + const fprimeJson = JSON.stringify({ + commands: [ + { + name: 'CMD', + commandKind: 'sync', + opcode: 1, + formalParams: [ + { + name: 'value', + type: { kind: 'integer', size: 32, signed: false }, + ref: false, + }, + ], + }, + ], + }); + + const xml = parseFPrimeJsonToAmpcsXml(fprimeJson, { + defaultUnits: 'meters', + }); + + expect(xml).toContain('units="meters"'); + }); + + it('should format opcodes as hex with proper padding', () => { + const fprimeJson = JSON.stringify({ + commands: [ + { name: 'CMD1', commandKind: 'sync', opcode: 1 }, + { name: 'CMD2', commandKind: 'sync', opcode: 255 }, + { name: 'CMD3', commandKind: 'sync', opcode: 4096 }, + { name: 'CMD4', commandKind: 'sync', opcode: 65535 }, + ], + }); + + const xml = parseFPrimeJsonToAmpcsXml(fprimeJson); + + expect(xml).toContain('opcode="0x0001"'); + expect(xml).toContain('opcode="0x00FF"'); + expect(xml).toContain('opcode="0x1000"'); + expect(xml).toContain('opcode="0xFFFF"'); + }); +}); diff --git a/src/languages/fprime/fprime-dictionary.ts b/src/languages/fprime/fprime-dictionary.ts new file mode 100644 index 0000000..9c5d5a8 --- /dev/null +++ b/src/languages/fprime/fprime-dictionary.ts @@ -0,0 +1,332 @@ +/** + * F-Prime Command Dictionary Parser and AMPCS XML Converter + * + * Parses F-Prime JSON command dictionaries (FPP JSON format) and converts them + * to AMPCS XML command dictionary format. + * + * References: + * - F-Prime JSON format: https://github.com/nasa/fprime/blob/main/docs/reference/fpp-json-dict.md + * - AMPCS XML schema: https://github.com/NASA-AMMOS/ampcs-dict-schemas/blob/main/CommandDictionary.rnc + */ + +/** + * F-Prime JSON type definitions + */ +interface FPrimeType { + name: string; + kind: 'integer' | 'float' | 'string' | 'boolean' | 'enum' | 'array'; + size?: number | string; + signed?: boolean; + values?: Array<{ name: string; value: number }>; +} + +interface FPrimeFormalParam { + name: string; + type: FPrimeType; + ref: boolean; + annotation?: string; +} + +interface FPrimeCommand { + name: string; + commandKind: 'async' | 'guarded' | 'sync' | 'set' | 'save'; + opcode: number; + formalParams?: FPrimeFormalParam[]; + annotation?: string; + priority?: number; + queueFullBehavior?: string; +} + +interface FPrimeCommandDictionary { + commands?: FPrimeCommand[]; + enums?: Array<{ + name: string; + type?: FPrimeType; + constants?: Array<{ name: string; value: number; annotation?: string }>; + }>; +} + +/** + * Conversion options for F-Prime to AMPCS XML + */ +export interface FPrimeToAmpcsOptions { + missionName?: string; + version?: string; + schemaVersion?: string; + spacecraftId?: number; + defaultCommandClass?: string; + defaultUnits?: string; + extractModuleFromName?: boolean; +} + +/** + * Parses an F-Prime JSON command dictionary and converts it to AMPCS XML format. + * + * @param jsonString - The F-Prime JSON dictionary as a string + * @param options - Conversion options + * @returns AMPCS XML command dictionary as a string + */ +export function parseFPrimeJsonToAmpcsXml(jsonString: string, options: FPrimeToAmpcsOptions = {}): string { + const { + missionName = 'fprime', + version = '1.0.0', + schemaVersion = '5.4', + spacecraftId = 0, + defaultCommandClass = 'FSW', + defaultUnits = 'none', + extractModuleFromName = true, + } = options; + + const fprimeDict: FPrimeCommandDictionary = JSON.parse(jsonString); + + // Start building XML + const xml: string[] = []; + xml.push(''); + xml.push(''); + + // Header + xml.push( + `
`, + ); + + // Enum definitions + if (fprimeDict.enums && fprimeDict.enums.length > 0) { + xml.push(' '); + for (const enumDef of fprimeDict.enums) { + xml.push(` `); + xml.push(' '); + if (enumDef.constants) { + for (const constant of enumDef.constants) { + xml.push(` `); + } + } + xml.push(' '); + xml.push(' '); + } + xml.push(' '); + } + + // Command definitions + xml.push(' '); + + if (fprimeDict.commands) { + for (const command of fprimeDict.commands) { + const commandXml = convertCommandToXml(command, { + defaultCommandClass, + defaultUnits, + extractModuleFromName, + }); + xml.push(commandXml); + } + } + + xml.push(' '); + xml.push('
'); + + return xml.join('\n'); +} + +/** + * Converts a single F-Prime command to AMPCS XML format. + */ +function convertCommandToXml( + command: FPrimeCommand, + options: { + defaultCommandClass: string; + defaultUnits: string; + extractModuleFromName: boolean; + }, +): string { + const xml: string[] = []; + const opcode = `0x${command.opcode.toString(16).toUpperCase().padStart(4, '0')}`; + + // Extract module, stem is fully namespaced + const stem = command.name; + let moduleName = ''; + + if (options.extractModuleFromName && command.name.includes('.')) { + const parts = command.name.split('.'); + moduleName = parts.slice(0, -1).join('.'); + } + + // Determine if this is FSW or hardware command + // F-Prime commands with parameters are FSW commands + const hasFormalParams = command.formalParams && command.formalParams.length > 0; + + // FSW command + // Unclear what FPrime defines as hardware command + xml.push( + ` `, + ); + + // Arguments + if (command.formalParams && command.formalParams.length > 0) { + xml.push(' '); + for (const param of command.formalParams) { + const argXml = convertArgumentToXml(param, options.defaultUnits); + xml.push(argXml); + } + xml.push(' '); + } + + // Categories + if (moduleName) { + xml.push(' '); + xml.push(` `); + xml.push(' '); + } + + // Description + if (command.annotation) { + xml.push(` ${escapeXml(command.annotation)}`); + } + + xml.push(` `); + + return xml.join('\n'); +} + +/** + * Converts an F-Prime formal parameter to AMPCS XML argument definition. + */ +function convertArgumentToXml(param: FPrimeFormalParam, defaultUnits: string): string { + const xml: string[] = []; + const argName = escapeXml(param.name); + const type = param.type; + + switch (type.kind) { + case 'integer': { + const bitLength = type.size || 32; + const isSigned = type.signed !== false; // Default to signed + + if (isSigned) { + // Signed integer + const { min, max } = getIntegerRange(Number(bitLength), true); + xml.push( + ` `, + ); + xml.push(' '); + xml.push(` `); + xml.push(' '); + } else { + // Unsigned integer + const { min, max } = getIntegerRange(Number(bitLength), false); + xml.push( + ` `, + ); + xml.push(' '); + xml.push(` `); + xml.push(' '); + } + + if (param.annotation) { + xml.push(` ${escapeXml(param.annotation)}`); + } + xml.push(` `); + break; + } + + case 'float': { + const bitLength = type.size || 32; + xml.push( + ` `, + ); + if (param.annotation) { + xml.push(` ${escapeXml(param.annotation)}`); + } + xml.push(' '); + break; + } + + case 'string': { + const bitLength = type.size ? Number(type.size) * 8 : 256; // Convert bytes to bits + // F-Prime strings are typically variable length with a prefix + xml.push( + ` `, + ); + if (param.annotation) { + xml.push(` ${escapeXml(param.annotation)}`); + } + xml.push(' '); + break; + } + + case 'boolean': { + const bitLength = 8; // Standard boolean size + xml.push(` `); + xml.push(' '); + if (param.annotation) { + xml.push(` ${escapeXml(param.annotation)}`); + } + xml.push(' '); + break; + } + + case 'enum': { + const bitLength = type.size || 32; + const enumName = type.name || 'UNKNOWN_ENUM'; + xml.push( + ` `, + ); + if (param.annotation) { + xml.push(` ${escapeXml(param.annotation)}`); + } + xml.push(' '); + break; + } + + case 'array': { + // F-Prime arrays can be represented as repeat arguments + // This is a simplified conversion - may need adjustment based on actual F-Prime array semantics + xml.push(` `); + xml.push(' '); + xml.push(' '); + // Array element type would need to be parsed from type.size or additional type info + xml.push(' '); + xml.push(' '); + xml.push(' '); + if (param.annotation) { + xml.push(` ${escapeXml(param.annotation)}`); + } + xml.push(' '); + break; + } + + default: + // Fallback to unsigned integer + xml.push(` `); + if (param.annotation) { + xml.push(` ${escapeXml(param.annotation)}`); + } + xml.push(' '); + } + + return xml.join('\n'); +} + +/** + * Calculates the min/max range for an integer type based on bit length and signedness. + */ +function getIntegerRange(bitLength: number, signed: boolean): { min: string; max: string } { + if (signed) { + const max = Math.pow(2, bitLength - 1) - 1; + const min = -Math.pow(2, bitLength - 1); + return { min: min.toString(), max: max.toString() }; + } else { + const max = Math.pow(2, bitLength) - 1; + return { min: '0', max: max.toString() }; + } +} + +/** + * Escapes XML special characters. + */ +function escapeXml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} diff --git a/src/languages/fprime/fprime-grammar-constants.ts b/src/languages/fprime/fprime-grammar-constants.ts new file mode 100644 index 0000000..a05c534 --- /dev/null +++ b/src/languages/fprime/fprime-grammar-constants.ts @@ -0,0 +1,20 @@ +/** + * Grammar node type constants for F' sequences. + * Used for traversing and analyzing the parse tree. + */ +export const FPRIME_NODES = { + Sequence: 'Sequence', + Command: 'Command', + TimeTag: 'TimeTag', + TimeAbsolute: 'TimeAbsolute', + TimeRelative: 'TimeRelative', + CommandMnemonic: 'CommandMnemonic', + Args: 'Args', + String: 'String', + Number: 'Number', + Identifier: 'Identifier', + LineComment: 'LineComment', + CommentLine: 'CommentLine', +} as const; + +export type FprimeNode = (typeof FPRIME_NODES)[keyof typeof FPRIME_NODES]; diff --git a/src/languages/fprime/fprime-linter.test.ts b/src/languages/fprime/fprime-linter.test.ts new file mode 100644 index 0000000..085c7e1 --- /dev/null +++ b/src/languages/fprime/fprime-linter.test.ts @@ -0,0 +1,670 @@ +import { describe, expect, it } from 'vitest'; +import { EditorState } from '@codemirror/state'; +import { EditorView } from '@codemirror/view'; +import { fprimeLinter } from './fprime-linter.js'; +import { fprimeParser } from './fprime.js'; +import { LRLanguage, LanguageSupport } from '@codemirror/language'; + +describe('F-Prime Linter', () => { + function lint(doc: string) { + // Create a simple language support with our parser + const fprimeLanguage = LRLanguage.define({ + parser: fprimeParser, + }); + + const state = EditorState.create({ + doc, + extensions: [new LanguageSupport(fprimeLanguage)], + }); + + const view = { state } as EditorView; + return fprimeLinter(view); + } + + describe('missing commas', () => { + it('should flag missing comma between numbers', () => { + const diagnostics = lint('R00:00:01.000 CMD 42 3.14\n'); + expect(diagnostics.length).toBeGreaterThan(0); + expect(diagnostics[0].message).toContain('comma'); + }); + + it('should flag missing comma between strings', () => { + const diagnostics = lint('R00:00:01.000 CMD "str1" "str2"\n'); + expect(diagnostics.length).toBeGreaterThan(0); + expect(diagnostics[0].message).toContain('comma'); + }); + + it('should flag missing comma between identifiers', () => { + const diagnostics = lint('R00:00:01.000 CMD INPUT_A INPUT_B\n'); + expect(diagnostics.length).toBeGreaterThan(0); + expect(diagnostics[0].message).toContain('comma'); + }); + + it('should flag missing comma between mixed types', () => { + const diagnostics = lint('R00:00:01.000 CMD 42 "test"\n'); + expect(diagnostics.length).toBeGreaterThan(0); + expect(diagnostics[0].message).toContain('comma'); + }); + }); + + describe('valid syntax', () => { + it('should not flag valid comma-separated arguments', () => { + const diagnostics = lint('R00:00:01.000 CMD 42, 3.14, "test"\n'); + expect(diagnostics.length).toBe(0); + }); + + it('should not flag command without arguments', () => { + const diagnostics = lint('R00:00:01.000 CMD_NO_OP\n'); + expect(diagnostics.length).toBe(0); + }); + + it('should not flag command with single argument', () => { + const diagnostics = lint('R00:00:01.000 CMD 42\n'); + expect(diagnostics.length).toBe(0); + }); + + it('should not flag valid sequence with comments', () => { + const doc = `; Comment +A2015-075T22:32:40.123 cmdDisp.CMD_NO_OP +R00:00:01.000 CMD 1, 2, 3 +`; + const diagnostics = lint(doc); + expect(diagnostics.length).toBe(0); + }); + }); + + describe('extra tokens', () => { + it('should flag unexpected tokens after command', () => { + const diagnostics = lint('R00:00:01.000 CMD 42 unexpected\n'); + expect(diagnostics.length).toBeGreaterThan(0); + }); + + it('should flag invalid characters in arguments', () => { + const diagnostics = lint('R00:00:01.000 CMD 42, @invalid\n'); + expect(diagnostics.length).toBeGreaterThan(0); + }); + }); + + describe('parse errors', () => { + it('should provide helpful messages for missing commas', () => { + const diagnostics = lint('R00:00:01.000 CMD 1 2 3\n'); + expect(diagnostics.length).toBeGreaterThan(0); + + // Should have helpful messages + const hasCommaMessage = diagnostics.some(d => d.message.toLowerCase().includes('comma')); + expect(hasCommaMessage).toBe(true); + }); + + it('should flag multiple errors in same line', () => { + const diagnostics = lint('R00:00:01.000 CMD 1 2 3 4\n'); + // Should detect multiple missing commas + expect(diagnostics.length).toBeGreaterThan(1); + }); + }); + + describe('empty and whitespace', () => { + it('should not flag empty file', () => { + const diagnostics = lint(''); + expect(diagnostics.length).toBe(0); + }); + + it('should not flag whitespace-only lines', () => { + const diagnostics = lint(' \n\t\n \n'); + expect(diagnostics.length).toBe(0); + }); + + it('should not flag comment-only lines', () => { + const diagnostics = lint('; Just a comment\n; Another comment\n'); + expect(diagnostics.length).toBe(0); + }); + }); + + describe('time tag validation', () => { + it('should not flag valid absolute time with day 001', () => { + const diagnostics = lint('A2015-001T00:00:00.000 CMD\n'); + expect(diagnostics.length).toBe(0); + }); + + it('should not flag valid absolute time with day 075', () => { + const diagnostics = lint('A2015-075T22:32:40.123 CMD\n'); + expect(diagnostics.length).toBe(0); + }); + + it('should not flag valid absolute time with day 366', () => { + const diagnostics = lint('A2024-366T23:59:59.999 CMD\n'); + expect(diagnostics.length).toBe(0); + }); + + it('should flag invalid day of year 000', () => { + const diagnostics = lint('A2015-000T00:00:00.000 CMD\n'); + expect(diagnostics.length).toBeGreaterThan(0); + expect(diagnostics[0].message).toContain('day of year'); + }); + + it('should flag invalid day of year 367', () => { + const diagnostics = lint('A2015-367T00:00:00.000 CMD\n'); + expect(diagnostics.length).toBeGreaterThan(0); + expect(diagnostics[0].message).toContain('day of year'); + }); + + it('should not flag valid relative time', () => { + const diagnostics = lint('R01:23:45.678 CMD\n'); + expect(diagnostics.length).toBe(0); + }); + + it('should flag relative time with hours >= 24', () => { + const diagnostics = lint('R24:00:00.000 CMD\n'); + expect(diagnostics.length).toBeGreaterThan(0); + expect(diagnostics[0].message).toContain('hours'); + }); + + it('should flag relative time with invalid minutes', () => { + const diagnostics = lint('R00:60:00.000 CMD\n'); + expect(diagnostics.length).toBeGreaterThan(0); + expect(diagnostics[0].message).toContain('minutes'); + }); + + it('should flag relative time with invalid seconds', () => { + const diagnostics = lint('R00:00:60.000 CMD\n'); + expect(diagnostics.length).toBeGreaterThan(0); + expect(diagnostics[0].message).toContain('seconds'); + }); + }); + + describe('command dictionary validation', () => { + const mockCommandDictionary = { + fswCommandMap: { + 'CMD_NO_OP': { + stem: 'CMD_NO_OP', + arguments: [], + }, + 'SET_VALUE': { + stem: 'SET_VALUE', + arguments: [ + { name: 'value', arg_type: 'integer' }, + { name: 'multiplier', arg_type: 'float' }, + ], + }, + }, + hwCommandMap: {}, + fswCommands: [ + { stem: 'CMD_NO_OP' }, + { stem: 'SET_VALUE' }, + ], + hwCommands: [], + enumMap: {}, + }; + + function lintWithDict(doc: string) { + const fprimeLanguage = LRLanguage.define({ + parser: fprimeParser, + }); + + const state = EditorState.create({ + doc, + extensions: [new LanguageSupport(fprimeLanguage)], + }); + + const view = { state } as EditorView; + return fprimeLinter(view, mockCommandDictionary); + } + + it('should not flag valid command in dictionary', () => { + const diagnostics = lintWithDict('R00:00:01.000 CMD_NO_OP\n'); + expect(diagnostics.length).toBe(0); + }); + + it('should flag command not in dictionary', () => { + const diagnostics = lintWithDict('R00:00:01.000 INVALID_CMD\n'); + expect(diagnostics.length).toBeGreaterThan(0); + expect(diagnostics[0].message).toContain('not found in command dictionary'); + }); + + it('should provide suggestions for misspelled commands', () => { + const diagnostics = lintWithDict('R00:00:01.000 CMD_NO_OPP\n'); + expect(diagnostics.length).toBeGreaterThan(0); + expect(diagnostics[0].actions).toBeDefined(); + expect(diagnostics[0].actions?.length).toBeGreaterThan(0); + }); + + it('should flag too many arguments', () => { + const diagnostics = lintWithDict('R00:00:01.000 CMD_NO_OP 42, 3.14, "extra"\n'); + expect(diagnostics.length).toBeGreaterThan(0); + expect(diagnostics[0].message).toContain('Extra'); + }); + + it('should flag too few arguments', () => { + const diagnostics = lintWithDict('R00:00:01.000 SET_VALUE 42\n'); + expect(diagnostics.length).toBeGreaterThan(0); + expect(diagnostics[0].message).toContain('Missing'); + }); + + it('should provide quick fix for missing arguments', () => { + const diagnostics = lintWithDict('R00:00:01.000 SET_VALUE 42\n'); + expect(diagnostics.length).toBeGreaterThan(0); + expect(diagnostics[0].actions).toBeDefined(); + expect(diagnostics[0].actions?.length).toBeGreaterThan(0); + expect(diagnostics[0].actions?.[0].name).toContain('Add missing'); + }); + + it('should not flag correct number of arguments', () => { + const diagnostics = lintWithDict('R00:00:01.000 SET_VALUE 42, 3.14\n'); + expect(diagnostics.length).toBe(0); + }); + + it('should provide quick fix for removing extra arguments', () => { + const diagnostics = lintWithDict('R00:00:01.000 CMD_NO_OP 42, 3.14\n'); + expect(diagnostics.length).toBeGreaterThan(0); + expect(diagnostics[0].actions).toBeDefined(); + expect(diagnostics[0].actions?.length).toBeGreaterThan(0); + expect(diagnostics[0].actions?.[0].name).toContain('Remove'); + }); + + it('should not count comments as extra arguments', () => { + const diagnostics = lintWithDict('R00:00:01.000 CMD_NO_OP; this is a comment\n'); + // Should have no errors - comment should not be counted as an argument + expect(diagnostics.length).toBe(0); + }); + + it('should report missing comma not extra argument when comma is missing', () => { + const diagnostics = lintWithDict('R00:00:01.000 SET_VALUE 42 3.14\n'); + expect(diagnostics.length).toBeGreaterThan(0); + // Should say "missing comma" not "Extra argument" + const hasCommaMessage = diagnostics.some(d => d.message.toLowerCase().includes('comma')); + const hasExtraMessage = diagnostics.some(d => d.message.toLowerCase().includes('extra')); + expect(hasCommaMessage).toBe(true); + expect(hasExtraMessage).toBe(false); + }); + + }); + + describe('argument type validation', () => { + const mockCommandDictionary = { + fswCommandMap: { + 'CMD_WITH_INT': { + stem: 'CMD_WITH_INT', + arguments: [{ name: 'value', arg_type: 'integer', range: { min: 0, max: 100 } }], + }, + 'CMD_WITH_FLOAT': { + stem: 'CMD_WITH_FLOAT', + arguments: [{ name: 'value', arg_type: 'float', range: { min: 0.0, max: 10.0 } }], + }, + 'CMD_WITH_STRING': { + stem: 'CMD_WITH_STRING', + arguments: [{ name: 'message', arg_type: 'var_string' }], + }, + 'CMD_WITH_BOOL': { + stem: 'CMD_WITH_BOOL', + arguments: [{ name: 'enabled', arg_type: 'boolean' }], + }, + 'CMD_WITH_ENUM': { + stem: 'CMD_WITH_ENUM', + arguments: [{ name: 'mode', arg_type: 'enum', enum_name: 'ModeEnum' }], + }, + 'CMD_WITH_HEX': { + stem: 'CMD_WITH_HEX', + arguments: [{ name: 'address', arg_type: 'unsigned', range: { min: 0, max: 255 } }], + }, + }, + hwCommandMap: {}, + fswCommands: [ + { stem: 'CMD_WITH_INT' }, + { stem: 'CMD_WITH_FLOAT' }, + { stem: 'CMD_WITH_STRING' }, + { stem: 'CMD_WITH_BOOL' }, + { stem: 'CMD_WITH_ENUM' }, + { stem: 'CMD_WITH_HEX' }, + ], + hwCommands: [], + enumMap: { + ModeEnum: { + name: 'ModeEnum', + values: [ + { symbol: 'MODE_A', numeric: 0 }, + { symbol: 'MODE_B', numeric: 1 }, + { symbol: 'MODE_C', numeric: 2 }, + ], + }, + }, + }; + + function lintWithDict(doc: string) { + const fprimeLanguage = LRLanguage.define({ + parser: fprimeParser, + }); + + const state = EditorState.create({ + doc, + extensions: [new LanguageSupport(fprimeLanguage)], + }); + + const view = { state } as EditorView; + return fprimeLinter(view, mockCommandDictionary); + } + + describe('integer arguments', () => { + it('should not flag valid integer argument', () => { + const diagnostics = lintWithDict('R00:00:01.000 CMD_WITH_INT 50\n'); + expect(diagnostics.length).toBe(0); + }); + + it('should flag string when integer expected', () => { + const diagnostics = lintWithDict('R00:00:01.000 CMD_WITH_INT "not a number"\n'); + expect(diagnostics.length).toBeGreaterThan(0); + expect(diagnostics[0].message).toContain('Incorrect type'); + expect(diagnostics[0].message).toContain('number'); + }); + + it('should flag identifier when integer expected', () => { + const diagnostics = lintWithDict('R00:00:01.000 CMD_WITH_INT SOME_ID\n'); + expect(diagnostics.length).toBeGreaterThan(0); + expect(diagnostics[0].message).toContain('Incorrect type'); + }); + + it('should flag integer out of range (too high)', () => { + const diagnostics = lintWithDict('R00:00:01.000 CMD_WITH_INT 150\n'); + expect(diagnostics.length).toBeGreaterThan(0); + expect(diagnostics[0].message).toContain('out of range'); + }); + + it('should flag integer out of range (negative when min is 0)', () => { + const diagnostics = lintWithDict('R00:00:01.000 CMD_WITH_INT -5\n'); + expect(diagnostics.length).toBeGreaterThan(0); + expect(diagnostics[0].message).toContain('out of range'); + }); + }); + + describe('float arguments', () => { + it('should not flag valid float argument', () => { + const diagnostics = lintWithDict('R00:00:01.000 CMD_WITH_FLOAT 5.5\n'); + expect(diagnostics.length).toBe(0); + }); + + it('should flag float out of range', () => { + const diagnostics = lintWithDict('R00:00:01.000 CMD_WITH_FLOAT 15.0\n'); + expect(diagnostics.length).toBeGreaterThan(0); + expect(diagnostics[0].message).toContain('out of range'); + }); + + it('should flag string when float expected', () => { + const diagnostics = lintWithDict('R00:00:01.000 CMD_WITH_FLOAT "not a number"\n'); + expect(diagnostics.length).toBeGreaterThan(0); + expect(diagnostics[0].message).toContain('Incorrect type'); + }); + }); + + describe('string arguments', () => { + it('should not flag valid string argument', () => { + const diagnostics = lintWithDict('R00:00:01.000 CMD_WITH_STRING "hello"\n'); + expect(diagnostics.length).toBe(0); + }); + + it('should flag number when string expected', () => { + const diagnostics = lintWithDict('R00:00:01.000 CMD_WITH_STRING 42\n'); + expect(diagnostics.length).toBeGreaterThan(0); + expect(diagnostics[0].message).toContain('Incorrect type'); + expect(diagnostics[0].message).toContain('string'); + }); + + it('should flag identifier when string expected', () => { + const diagnostics = lintWithDict('R00:00:01.000 CMD_WITH_STRING UNQUOTED\n'); + expect(diagnostics.length).toBeGreaterThan(0); + expect(diagnostics[0].message).toContain('Incorrect type'); + expect(diagnostics[0].message).toContain('string'); + }); + }); + + describe('boolean arguments', () => { + it('should not flag valid boolean string TRUE', () => { + const diagnostics = lintWithDict('R00:00:01.000 CMD_WITH_BOOL "TRUE"\n'); + expect(diagnostics.length).toBe(0); + }); + + it('should not flag valid boolean string FALSE', () => { + const diagnostics = lintWithDict('R00:00:01.000 CMD_WITH_BOOL "FALSE"\n'); + expect(diagnostics.length).toBe(0); + }); + + it('should not flag valid boolean identifier TRUE', () => { + const diagnostics = lintWithDict('R00:00:01.000 CMD_WITH_BOOL TRUE\n'); + expect(diagnostics.length).toBe(0); + }); + + it('should not flag valid boolean identifier FALSE', () => { + const diagnostics = lintWithDict('R00:00:01.000 CMD_WITH_BOOL FALSE\n'); + expect(diagnostics.length).toBe(0); + }); + + it('should flag invalid boolean string value', () => { + const diagnostics = lintWithDict('R00:00:01.000 CMD_WITH_BOOL "yes"\n'); + expect(diagnostics.length).toBeGreaterThan(0); + expect(diagnostics[0].message).toContain('Invalid boolean'); + }); + + it('should flag invalid boolean identifier value', () => { + const diagnostics = lintWithDict('R00:00:01.000 CMD_WITH_BOOL yes\n'); + expect(diagnostics.length).toBeGreaterThan(0); + expect(diagnostics[0].message).toContain('Invalid boolean'); + }); + + it('should flag number when boolean expected', () => { + const diagnostics = lintWithDict('R00:00:01.000 CMD_WITH_BOOL 1\n'); + expect(diagnostics.length).toBeGreaterThan(0); + expect(diagnostics[0].message).toContain('Incorrect type'); + expect(diagnostics[0].message).toContain('boolean'); + }); + }); + + describe('enum arguments', () => { + it('should not flag valid enum identifier', () => { + const diagnostics = lintWithDict('R00:00:01.000 CMD_WITH_ENUM MODE_A\n'); + expect(diagnostics.length).toBe(0); + }); + + it('should not flag valid enum string', () => { + const diagnostics = lintWithDict('R00:00:01.000 CMD_WITH_ENUM "MODE_B"\n'); + expect(diagnostics.length).toBe(0); + }); + + it('should flag invalid enum value', () => { + const diagnostics = lintWithDict('R00:00:01.000 CMD_WITH_ENUM MODE_INVALID\n'); + expect(diagnostics.length).toBeGreaterThan(0); + expect(diagnostics[0].message).toContain('Invalid enum value'); + expect(diagnostics[0].message).toContain('MODE_A'); + }); + + it('should provide suggestion for similar enum value', () => { + const diagnostics = lintWithDict('R00:00:01.000 CMD_WITH_ENUM MODE_AA\n'); + expect(diagnostics.length).toBeGreaterThan(0); + expect(diagnostics[0].actions).toBeDefined(); + expect(diagnostics[0].actions?.length).toBeGreaterThan(0); + expect(diagnostics[0].actions?.[0].name).toContain('MODE_A'); + }); + + it('should flag number when enum expected', () => { + const diagnostics = lintWithDict('R00:00:01.000 CMD_WITH_ENUM 0\n'); + expect(diagnostics.length).toBeGreaterThan(0); + expect(diagnostics[0].message).toContain('Incorrect type'); + expect(diagnostics[0].message).toContain('enum'); + }); + }); + + describe('hex number arguments', () => { + it('should not flag valid hex number', () => { + const diagnostics = lintWithDict('R00:00:01.000 CMD_WITH_HEX 0xFF\n'); + expect(diagnostics.length).toBe(0); + }); + + it('should not flag hex number at boundary', () => { + const diagnostics = lintWithDict('R00:00:01.000 CMD_WITH_HEX 0x00\n'); + expect(diagnostics.length).toBe(0); + }); + + it('should flag hex number out of range', () => { + const diagnostics = lintWithDict('R00:00:01.000 CMD_WITH_HEX 0x200\n'); + expect(diagnostics.length).toBeGreaterThan(0); + expect(diagnostics[0].message).toContain('out of range'); + }); + }); + + describe('arguments without ranges', () => { + const mockCommandDictionary = { + fswCommandMap: { + 'CMD_NO_RANGE': { + stem: 'CMD_NO_RANGE', + arguments: [ + { name: 'value', arg_type: 'integer', range: null }, + { name: 'message', arg_type: 'var_string' }, + ], + }, + }, + hwCommandMap: {}, + fswCommands: [{ stem: 'CMD_NO_RANGE' }], + hwCommands: [], + enumMap: {}, + }; + + function lintWithDict(doc: string) { + const fprimeLanguage = LRLanguage.define({ + parser: fprimeParser, + }); + + const state = EditorState.create({ + doc, + extensions: [new LanguageSupport(fprimeLanguage)], + }); + + const view = { state } as EditorView; + return fprimeLinter(view, mockCommandDictionary); + } + + it('should not flag valid integer without range constraint', () => { + const diagnostics = lintWithDict('R00:00:01.000 CMD_NO_RANGE 999999, "test"\n'); + expect(diagnostics.length).toBe(0); + }); + + it('should still flag wrong type even without range', () => { + const diagnostics = lintWithDict('R00:00:01.000 CMD_NO_RANGE "not_a_number", "test"\n'); + expect(diagnostics.length).toBeGreaterThan(0); + expect(diagnostics[0].message).toContain('Incorrect type'); + }); + }); + + describe('enum with range array instead of enum_name', () => { + const mockCommandDictionary = { + fswCommandMap: { + 'CMD_ENUM_RANGE': { + stem: 'CMD_ENUM_RANGE', + arguments: [ + { + name: 'mode', + arg_type: 'enum', + range: ['OPTION_X', 'OPTION_Y', 'OPTION_Z'], + }, + ], + }, + }, + hwCommandMap: {}, + fswCommands: [{ stem: 'CMD_ENUM_RANGE' }], + hwCommands: [], + enumMap: {}, + }; + + function lintWithDict(doc: string) { + const fprimeLanguage = LRLanguage.define({ + parser: fprimeParser, + }); + + const state = EditorState.create({ + doc, + extensions: [new LanguageSupport(fprimeLanguage)], + }); + + const view = { state } as EditorView; + return fprimeLinter(view, mockCommandDictionary); + } + + it('should accept valid enum value from range array', () => { + const diagnostics = lintWithDict('R00:00:01.000 CMD_ENUM_RANGE OPTION_X\n'); + expect(diagnostics.length).toBe(0); + }); + + it('should flag invalid enum value not in range array', () => { + const diagnostics = lintWithDict('R00:00:01.000 CMD_ENUM_RANGE INVALID\n'); + expect(diagnostics.length).toBeGreaterThan(0); + expect(diagnostics[0].message).toContain('Invalid enum value'); + expect(diagnostics[0].message).toContain('OPTION_X'); + }); + }); + }); + + describe('malformed time tag detection', () => { + it('should flag malformed absolute time with wrong day digits', () => { + const diagnostics = lint('A2026-75T21:12:40.123 CMD\n'); + expect(diagnostics.length).toBeGreaterThan(0); + expect(diagnostics[0].message).toContain('Invalid absolute time format'); + expect(diagnostics[0].message).toContain('AYYYY-DDDTHH:MM:SS'); + }); + + it('should flag malformed absolute time with slash separator', () => { + const diagnostics = lint('A2026/075T21:12:40 CMD\n'); + expect(diagnostics.length).toBeGreaterThan(0); + expect(diagnostics[0].message).toContain('Invalid absolute time format'); + }); + + it('should flag malformed relative time with wrong hour digits', () => { + const diagnostics = lint('R1:00:00 CMD\n'); + expect(diagnostics.length).toBeGreaterThan(0); + expect(diagnostics[0].message).toContain('Invalid relative time format'); + expect(diagnostics[0].message).toContain('RHH:MM:SS'); + }); + + it('should flag malformed relative time with three digit hours', () => { + const diagnostics = lint('R100:00:00 CMD\n'); + expect(diagnostics.length).toBeGreaterThan(0); + expect(diagnostics[0].message).toContain('Invalid relative time format'); + }); + + it('should only show one error for malformed time tag (deduplication)', () => { + const diagnostics = lint('A2026-75T21:12:40.123 CMD\n'); + // Should have one specific error, not multiple generic "Syntax error" messages + expect(diagnostics.length).toBe(1); + expect(diagnostics[0].message).not.toBe('Syntax error'); + }); + + it('should not flag malformed time patterns in command arguments', () => { + const diagnostics = lint('R00:00:01.000 CMD A2026-75\n'); + // Should flag as invalid command syntax or missing comma, not as malformed time tag + expect(diagnostics.length).toBeGreaterThan(0); + expect(diagnostics[0].message).not.toContain('Invalid absolute time format'); + }); + }); + + describe('diagnostic deduplication', () => { + it('should remove generic errors when specific errors exist on same line', () => { + const diagnostics = lint('A2026-75T21:12:40 CMD\n'); + // Should only have the specific time format error, not generic syntax errors + const syntaxErrors = diagnostics.filter(d => d.message === 'Syntax error'); + expect(syntaxErrors.length).toBe(0); + }); + + it('should keep generic errors when no specific errors exist', () => { + const diagnostics = lint('@@@ invalid\n'); + expect(diagnostics.length).toBeGreaterThan(0); + // All errors might be generic for completely invalid syntax + }); + + it('should deduplicate per line independently', () => { + const doc = `A2026-75T21:12:40 CMD +R00:00:01.000 CMD 1 2 +`; + const diagnostics = lint(doc); + // Line 1: should have time format error + // Line 2: should have comma error + const timeErrors = diagnostics.filter(d => d.message.includes('time format')); + const commaErrors = diagnostics.filter(d => d.message.includes('comma')); + expect(timeErrors.length).toBeGreaterThan(0); + expect(commaErrors.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/src/languages/fprime/fprime-linter.ts b/src/languages/fprime/fprime-linter.ts new file mode 100644 index 0000000..9b98f02 --- /dev/null +++ b/src/languages/fprime/fprime-linter.ts @@ -0,0 +1,612 @@ +import { syntaxTree } from '@codemirror/language'; +import type { Diagnostic } from '@codemirror/lint'; +import type { EditorView } from '@codemirror/view'; +import type { SyntaxNode } from '@lezer/common'; +import type { CommandDictionary } from '@nasa-jpl/plandev-ampcs'; +import { distance } from 'fastest-levenshtein'; +import { getChildrenNode, getFromAndTo } from '../../utils/tree-utils.js'; +import { pluralize } from '../../utils/string.js'; +import { fswCommandArgDefault } from '../../utils/sequence-utils.js'; +import { FPRIME_NODES } from './fprime-grammar-constants.js'; + +/** + * Linter for F-Prime sequences. + * Flags parse errors and validates sequence structure. + */ +export function fprimeLinter(view: EditorView, commandDictionary?: CommandDictionary | null): Diagnostic[] { + const diagnostics: Diagnostic[] = []; + const tree = syntaxTree(view.state); + + if (!tree) { + return diagnostics; + } + + // Walk the tree and find error nodes + tree.iterate({ + enter: node => { + // Flag any parse errors (⚠ nodes) + if (node.type.isError) { + const text = view.state.sliceDoc(node.from, node.to); + const errorDiagnostic = createErrorDiagnostic(node.node, text, view); + if (errorDiagnostic) { + diagnostics.push(errorDiagnostic); + } + } + + // Validate time tags + if (node.name === FPRIME_NODES.TimeAbsolute || node.name === FPRIME_NODES.TimeRelative) { + const timeDiagnostic = validateTimeTag(node.node, view); + if (timeDiagnostic) { + diagnostics.push(timeDiagnostic); + } + } + + // Validate commands against dictionary + if (node.name === FPRIME_NODES.Command && commandDictionary) { + const commandDiagnostics = validateCommandDictionary(node.node, view, commandDictionary); + diagnostics.push(...commandDiagnostics); + } + }, + }); + + // Deduplicate diagnostics per line - if a line has a specific error message, + // remove generic "Syntax error" messages from that line to avoid hiding the details + return deduplicateDiagnostics(diagnostics, view); +} + +/** + * Deduplicates diagnostics per line, removing generic errors when specific ones exist. + */ +function deduplicateDiagnostics(diagnostics: Diagnostic[], view: EditorView): Diagnostic[] { + // Group diagnostics by line number + const diagnosticsByLine = new Map(); + for (const diag of diagnostics) { + const line = view.state.doc.lineAt(diag.from).number; + if (!diagnosticsByLine.has(line)) { + diagnosticsByLine.set(line, []); + } + diagnosticsByLine.get(line)!.push(diag); + } + + // For each line, if there's a specific error message, remove generic "Syntax error" messages + const filtered: Diagnostic[] = []; + for (const diags of diagnosticsByLine.values()) { + const hasSpecificError = diags.some(d => d.message !== 'Syntax error'); + if (hasSpecificError) { + // Keep only non-generic errors + filtered.push(...diags.filter(d => d.message !== 'Syntax error')); + } else { + // Keep all errors (they're all generic) + filtered.push(...diags); + } + } + + return filtered; +} + +/** + * Creates a diagnostic for parse errors, attempting to provide helpful messages. + */ +function createErrorDiagnostic(node: SyntaxNode, text: string, view: EditorView): Diagnostic | null { + // Get context around the error + const parent = node.parent; + const prevSibling = node.prevSibling; + + let message = 'Syntax error'; + const severity: 'error' | 'warning' = 'error'; + + // Check if error looks like a malformed time tag + // Time tags must be at the start of a line (after optional whitespace) + const lineStart = view.state.doc.lineAt(node.from).from; + const textBeforeError = view.state.sliceDoc(lineStart, node.from); + + // Only check for malformed time tags if there's only whitespace before the error + if (text.trim() && !/\S/.test(textBeforeError)) { + const trimmed = text.trim(); + + // Check if line starts with something that looks like a malformed time tag + if (/^A\d+/.test(trimmed)) { + message = 'Invalid absolute time format. Expected: AYYYY-DDDTHH:MM:SS[.sss]'; + } else if (/^R\d+/.test(trimmed)) { + message = 'Invalid relative time format. Expected: RHH:MM:SS[.sss]'; + } + } + + // Try to provide more specific error messages based on context + if (parent?.name === FPRIME_NODES.Args) { + // Error in arguments - likely missing comma + if (prevSibling) { + const prevType = prevSibling.name; + if ( + prevType === FPRIME_NODES.Number || + prevType === FPRIME_NODES.String || + prevType === FPRIME_NODES.Identifier + ) { + message = 'Missing comma between arguments'; + } + } + + // Check if this looks like a valid argument type that's in the wrong place + if (text.trim()) { + const trimmed = text.trim(); + // Check if it's a number-like token + if (/^\d+(\.\d+)?$/.test(trimmed) || /^0x[0-9a-f]+$/i.test(trimmed)) { + message = 'Missing comma before this number argument'; + } + // Check if it's an identifier-like token + else if (/^[a-z_]\w*$/i.test(trimmed)) { + message = 'Missing comma before this identifier'; + } + // Check if it's a string-like token + else if (trimmed.startsWith('"')) { + message = 'Missing comma before this string argument'; + } + } + + // For empty error nodes in Args context, still report as missing comma + // (zero-width errors typically indicate missing separators) + if (!text.trim() && prevSibling) { + // Return diagnostic for zero-width error indicating missing comma + return { + from: node.from, + to: node.from + 1, // Make it at least 1 character wide for visibility + severity, + message, + source: 'fprime-linter', + }; + } + } else if (parent?.name === FPRIME_NODES.Command) { + // Error at command level + message = 'Invalid command syntax'; + } + + // If the error is just whitespace or empty (and not handled above), skip it + if (!text.trim()) { + return null; + } + + return { + from: node.from, + to: node.to, + severity, + message, + source: 'fprime-linter', + }; +} + +/** + * Validates F-Prime time format. + */ +function validateTimeTag(node: SyntaxNode, view: EditorView): Diagnostic | null { + const text = view.state.sliceDoc(node.from, node.to).trim(); + + if (node.name === FPRIME_NODES.TimeAbsolute) { + // Format: AYYYY-DDDTHH:MM:SS[.sss] + const absolutePattern = /^A\d{4}-\d{3}T\d{2}:\d{2}:\d{2}(\.\d+)?$/; + if (!absolutePattern.test(text)) { + return { + from: node.from, + to: node.to, + severity: 'error', + message: 'Invalid absolute time format. Expected: AYYYY-DDDTHH:MM:SS[.sss]', + source: 'fprime-linter', + }; + } + + // Validate day of year (001-366) + // Extract DDD from AYYYY-DDDTHH:MM:SS (positions 6-8, after the dash at position 5) + const dayOfYear = Number.parseInt(text.substring(6, 9), 10); + if (dayOfYear < 1 || dayOfYear > 366) { + return { + from: node.from + 6, + to: node.from + 9, + severity: 'error', + message: `Invalid day of year: ${dayOfYear}. Must be between 001 and 366`, + source: 'fprime-linter', + }; + } + } else if (node.name === FPRIME_NODES.TimeRelative) { + // Format: RHH:MM:SS[.sss] + const relativePattern = /^R\d{2}:\d{2}:\d{2}(\.\d+)?$/; + if (!relativePattern.test(text)) { + return { + from: node.from, + to: node.to, + severity: 'error', + message: 'Invalid relative time format. Expected: RHH:MM:SS[.sss]', + source: 'fprime-linter', + }; + } + + // Validate hours are less than 24 (relative times limited to 24 hours) + const hours = Number.parseInt(text.substring(1, 3), 10); + if (hours >= 24) { + return { + from: node.from + 1, + to: node.from + 3, + severity: 'error', + message: `Relative time hours must be less than 24, got: ${hours}`, + source: 'fprime-linter', + }; + } + + // Validate minutes and seconds + const minutes = Number.parseInt(text.substring(4, 6), 10); + const seconds = Number.parseInt(text.substring(7, 9), 10); + if (minutes >= 60) { + return { + from: node.from + 4, + to: node.from + 6, + severity: 'error', + message: `Invalid minutes: ${minutes}. Must be between 00 and 59`, + source: 'fprime-linter', + }; + } + if (seconds >= 60) { + return { + from: node.from + 7, + to: node.from + 9, + severity: 'error', + message: `Invalid seconds: ${seconds}. Must be between 00 and 59`, + source: 'fprime-linter', + }; + } + } + + return null; +} + +/** + * Validates that command mnemonics exist in the command dictionary. + * Returns diagnostic errors for commands not found in the dictionary. + */ +export function validateCommandDictionary( + commandNode: SyntaxNode, + view: EditorView, + commandDictionary: CommandDictionary, +): Diagnostic[] { + const diagnostics: Diagnostic[] = []; + + // Get the command mnemonic node + const mnemonicNode = commandNode.getChild(FPRIME_NODES.CommandMnemonic); + if (!mnemonicNode) { + return diagnostics; + } + + // Extract the mnemonic text + const mnemonicText = view.state.sliceDoc(mnemonicNode.from, mnemonicNode.to); + + // Check if the command exists in either FSW or hardware command maps + const { fswCommandMap, hwCommandMap, fswCommands, hwCommands } = commandDictionary; + const dictionaryCommand = fswCommandMap[mnemonicText] || hwCommandMap[mnemonicText]; + + if (!dictionaryCommand) { + // Command not found - generate suggestions using Levenshtein distance + const allCommandStems = [...fswCommands.map(cmd => cmd.stem), ...hwCommands.map(cmd => cmd.stem)]; + + const closestMatches = closestStrings(mnemonicText, allCommandStems, 3); + + diagnostics.push({ + from: mnemonicNode.from, + to: mnemonicNode.to, + severity: 'error', + message: `Command '${mnemonicText}' not found in command dictionary`, + source: 'fprime-linter', + actions: closestMatches.map(suggestion => ({ + name: `Change to ${suggestion}`, + apply(view, from, to) { + view.dispatch({ + changes: { from, to, insert: suggestion }, + }); + }, + })), + }); + return diagnostics; + } + + // Validate argument count + const argsNode = commandNode.getChild(FPRIME_NODES.Args); + const expectedArgCount = dictionaryCommand.arguments?.length ?? 0; + + if (argsNode) { + // Get argument nodes, excluding error nodes and commas + const allChildren = getChildrenNode(argsNode); + const argNodes = allChildren.filter(node => !node.type.isError && node.name !== ','); + const actualArgCount = argNodes.length; + + if (actualArgCount > expectedArgCount) { + // Too many arguments + const extraArgs = argNodes.slice(expectedArgCount); + const { from, to } = getFromAndTo(extraArgs); + const commandArgs = `argument${pluralize(extraArgs.length)}`; + + // Check if there's a comma before the first extra argument + const firstExtraArg = extraArgs[0]; + let deleteFrom = from; + if (firstExtraArg && expectedArgCount > 0) { + // Look for comma between last valid arg and first extra arg + const lastValidArg = argNodes[expectedArgCount - 1]; + if (lastValidArg) { + const textBetween = view.state.sliceDoc(lastValidArg.to, firstExtraArg.from); + const commaMatch = textBetween.match(/,/); + if (commaMatch) { + // Include the comma in the deletion range + deleteFrom = lastValidArg.to + textBetween.indexOf(','); + } + } + } + + diagnostics.push({ + from, + to, + severity: 'error', + message: `Extra ${commandArgs}, definition has ${expectedArgCount}, but ${actualArgCount} ${pluralize(actualArgCount) ? 'are' : 'is'} present`, + source: 'fprime-linter', + actions: [ + { + name: `Remove ${extraArgs.length} extra ${commandArgs}`, + apply(view, argsFrom, argsTo) { + view.dispatch({ changes: { from: deleteFrom, to: argsTo } }); + }, + }, + ], + }); + } else if (actualArgCount < expectedArgCount) { + // Too few arguments + const commandArgs = `argument${pluralize(expectedArgCount - actualArgCount)}`; + const missingArgs = dictionaryCommand.arguments?.slice(actualArgCount) || []; + + // Generate placeholder values for missing arguments + const placeholders = missingArgs.map(arg => fswCommandArgDefault(arg, commandDictionary.enumMap)); + const insertText = (actualArgCount > 0 ? ', ' : ' ') + placeholders.join(', '); + + diagnostics.push({ + from: argsNode.from, + to: argsNode.to, + severity: 'error', + message: `Missing ${commandArgs}, definition has ${expectedArgCount}, but ${actualArgCount} ${pluralize(actualArgCount) ? 'are' : 'is'} present`, + source: 'fprime-linter', + actions: [ + { + name: `Add missing ${commandArgs}`, + apply(view, from, to) { + view.dispatch({ + changes: { from: to, to, insert: insertText }, + }); + }, + }, + ], + }); + } else { + // Correct number of arguments - validate types and values + for (let i = 0; i < argNodes.length; i++) { + const argNode = argNodes[i]; + const argDef = dictionaryCommand.arguments?.[i]; + if (argDef) { + const argDiagnostics = validateArgument(argNode, argDef, view, commandDictionary); + diagnostics.push(...argDiagnostics); + } + } + } + } else if (expectedArgCount > 0) { + // No arguments provided but command expects some + const commandArgs = `argument${pluralize(expectedArgCount)}`; + const missingArgs = dictionaryCommand.arguments || []; + + // Generate placeholder values for missing arguments + const placeholders = missingArgs.map(arg => fswCommandArgDefault(arg, commandDictionary.enumMap)); + const insertText = ' ' + placeholders.join(', '); + + diagnostics.push({ + from: mnemonicNode.to, + to: mnemonicNode.to, + severity: 'error', + message: `Missing ${commandArgs}, definition has ${expectedArgCount}, but 0 are present`, + source: 'fprime-linter', + actions: [ + { + name: `Add missing ${commandArgs}`, + apply(view, from, to) { + view.dispatch({ + changes: { from: to, to, insert: insertText }, + }); + }, + }, + ], + }); + } + + return diagnostics; +} + +/** + * Validates an individual argument against its command dictionary definition. + */ +function validateArgument( + argNode: SyntaxNode, + argDef: { arg_type: string; name: string; range?: { min: number; max: number } | string[] | null; enum_name?: string }, + view: EditorView, + commandDictionary: CommandDictionary, +): Diagnostic[] { + const diagnostics: Diagnostic[] = []; + const argText = view.state.sliceDoc(argNode.from, argNode.to); + const argType = argNode.name; + + switch (argDef.arg_type) { + case 'integer': + case 'unsigned': + case 'float': + case 'numeric': { + // Check if the argument is a number + if (argType !== FPRIME_NODES.Number) { + diagnostics.push({ + from: argNode.from, + to: argNode.to, + severity: 'error', + message: `Incorrect type - expected number but got ${argType}`, + source: 'fprime-linter', + }); + break; + } + + // Validate numeric range if specified (range is an object for numeric types, not a string array) + if (argDef.range && !Array.isArray(argDef.range)) { + const { min, max } = argDef.range; + // Parse numeric value, handling hex (0x prefix) + const numValue = argText.toLowerCase().startsWith('0x') + ? Number.parseInt(argText, 16) + : argDef.arg_type === 'float' || argDef.arg_type === 'numeric' + ? Number.parseFloat(argText) + : Number.parseInt(argText, 10); + + if (Number.isNaN(numValue)) { + diagnostics.push({ + from: argNode.from, + to: argNode.to, + severity: 'error', + message: `Invalid number format: ${argText}`, + source: 'fprime-linter', + }); + } else if (numValue < min || numValue > max) { + const message = + max !== min + ? `Number out of range. Expected ${min} to ${max}, got ${numValue}` + : `Number out of range. Expected ${min}`; + + diagnostics.push({ + from: argNode.from, + to: argNode.to, + severity: 'error', + message, + source: 'fprime-linter', + }); + } + } + break; + } + + case 'boolean': { + // F-Prime booleans can be represented as strings "TRUE"/"FALSE" or identifiers + if (argType === FPRIME_NODES.String) { + const unquoted = argText.replace(/^"|"$/g, ''); + if (unquoted !== 'TRUE' && unquoted !== 'FALSE') { + diagnostics.push({ + from: argNode.from, + to: argNode.to, + severity: 'error', + message: `Invalid boolean value. Expected "TRUE" or "FALSE", got ${argText}`, + source: 'fprime-linter', + }); + } + } else if (argType === FPRIME_NODES.Identifier) { + if (argText !== 'TRUE' && argText !== 'FALSE') { + diagnostics.push({ + from: argNode.from, + to: argNode.to, + severity: 'error', + message: `Invalid boolean value. Expected TRUE or FALSE, got ${argText}`, + source: 'fprime-linter', + }); + } + } else { + diagnostics.push({ + from: argNode.from, + to: argNode.to, + severity: 'error', + message: `Incorrect type - expected boolean but got ${argType}`, + source: 'fprime-linter', + }); + } + break; + } + + case 'enum': { + // Enums can be identifiers or strings + if (argType !== FPRIME_NODES.String && argType !== FPRIME_NODES.Identifier) { + diagnostics.push({ + from: argNode.from, + to: argNode.to, + severity: 'error', + message: `Incorrect type - expected enum (string or identifier) but got ${argType}`, + source: 'fprime-linter', + }); + break; + } + + // Validate enum value - check enum_name in enumMap first, then fall back to range array + let validSymbols: string[] | undefined; + + if (argDef.enum_name && commandDictionary.enumMap) { + const enumDef = commandDictionary.enumMap[argDef.enum_name]; + if (enumDef) { + validSymbols = enumDef.values.map(v => v.symbol); + } + } + + // Fall back to range array if enum_name didn't provide symbols + if (!validSymbols && argDef.range && Array.isArray(argDef.range)) { + validSymbols = argDef.range; + } + + // If we have valid symbols, validate the argument value + if (validSymbols && validSymbols.length > 0) { + const argValue = argType === FPRIME_NODES.String ? argText.replace(/^"|"$/g, '') : argText; + + if (!validSymbols.includes(argValue)) { + const closestMatch = closestStrings(argValue, validSymbols, 1)[0]; + diagnostics.push({ + from: argNode.from, + to: argNode.to, + severity: 'error', + message: `Invalid enum value. Expected one of: ${validSymbols.join(', ')}`, + source: 'fprime-linter', + actions: closestMatch + ? [ + { + name: `Change to ${closestMatch}`, + apply(view, from, to) { + const replacement = argType === FPRIME_NODES.String ? `"${closestMatch}"` : closestMatch; + view.dispatch({ + changes: { from, to, insert: replacement }, + }); + }, + }, + ] + : [], + }); + } + } + break; + } + + case 'fixed_string': + case 'var_string': { + // Strings must be quoted in F-Prime + if (argType !== FPRIME_NODES.String) { + diagnostics.push({ + from: argNode.from, + to: argNode.to, + severity: 'error', + message: `Incorrect type - expected string but got ${argType}`, + source: 'fprime-linter', + }); + } + break; + } + + default: + // Unknown argument type - skip validation + break; + } + + return diagnostics; +} + +/** + * Helper function to find the N closest strings using Levenshtein distance. + */ +function closestStrings(value: string, potentialMatches: string[], n: number): string[] { + 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); +} diff --git a/src/languages/fprime/fprime-time-computation.test.ts b/src/languages/fprime/fprime-time-computation.test.ts new file mode 100644 index 0000000..2e92778 --- /dev/null +++ b/src/languages/fprime/fprime-time-computation.test.ts @@ -0,0 +1,274 @@ +import { describe, it, expect } from 'vitest'; +import { fprimeParser } from './fprime.js'; +import { FPRIME_NODES } from './fprime-grammar-constants.js'; + +/** + * Test the time tag parsing and computation by examining the parse tree. + * This tests the structure without requiring a full CodeMirror EditorState. + */ +describe('F-Prime Time Tag Parsing', () => { + it('should parse absolute time A2015-075T22:32:40.123', () => { + const input = 'A2015-075T22:32:40.123 CMD_NO_OP\n'; + const tree = fprimeParser.parse(input); + const cursor = tree.cursor(); + + // Navigate to Command -> TimeTag -> TimeAbsolute + cursor.firstChild(); // Command + cursor.firstChild(); // TimeTag + cursor.firstChild(); // TimeAbsolute + + expect(cursor.node.type.name).toBe(FPRIME_NODES.TimeAbsolute); + + const timeText = input.slice(cursor.from, cursor.to).trim(); + expect(timeText).toBe('A2015-075T22:32:40.123'); + }); + + it('should parse relative time R00:00:01', () => { + const input = 'R00:00:01 CMD_NO_OP\n'; + const tree = fprimeParser.parse(input); + const cursor = tree.cursor(); + + // Navigate to Command -> TimeTag -> TimeRelative + cursor.firstChild(); // Command + cursor.firstChild(); // TimeTag + cursor.firstChild(); // TimeRelative + + expect(cursor.node.type.name).toBe(FPRIME_NODES.TimeRelative); + + const timeText = input.slice(cursor.from, cursor.to).trim(); + expect(timeText).toBe('R00:00:01'); + }); + + it('should parse sequence with absolute then relative time', () => { + const input = 'A2015-075T22:32:40.123 CMD_FIRST\nR00:00:01 CMD_SECOND\n'; + const tree = fprimeParser.parse(input); + + expect(tree.length).toBe(input.length); + + // Find all commands using cursor navigation + const commands: Array<{ timeType: string; timeText: string; commandText: string }> = []; + const cursor = tree.cursor(); + + do { + if (cursor.name === FPRIME_NODES.Command) { + const commandNode = cursor.node; + + // Get time tag + const timeTagNode = commandNode.getChild(FPRIME_NODES.TimeTag); + const absoluteNode = timeTagNode?.getChild(FPRIME_NODES.TimeAbsolute); + const relativeNode = timeTagNode?.getChild(FPRIME_NODES.TimeRelative); + + const timeNode = absoluteNode || relativeNode; + const timeType = absoluteNode ? 'absolute' : 'relative'; + const timeText = timeNode ? input.slice(timeNode.from, timeNode.to).trim() : ''; + + // Get command mnemonic + const mnemonicNode = commandNode.getChild(FPRIME_NODES.CommandMnemonic); + const commandText = mnemonicNode ? input.slice(mnemonicNode.from, mnemonicNode.to) : ''; + + commands.push({ timeType, timeText, commandText }); + } + } while (cursor.next()); + + expect(commands).toHaveLength(2); + + // First command - absolute time + expect(commands[0].timeType).toBe('absolute'); + expect(commands[0].timeText).toBe('A2015-075T22:32:40.123'); + expect(commands[0].commandText).toBe('CMD_FIRST'); + + // Second command - relative time + expect(commands[1].timeType).toBe('relative'); + expect(commands[1].timeText).toBe('R00:00:01'); + expect(commands[1].commandText).toBe('CMD_SECOND'); + }); + + it('should parse multiple relative times in sequence', () => { + const input = 'A2015-075T10:00:00.000 CMD_1\nR00:10:00.000 CMD_2\nR00:05:30.500 CMD_3\n'; + const tree = fprimeParser.parse(input); + + const commands: Array<{ timeType: string; timeText: string }> = []; + const cursor = tree.cursor(); + + do { + if (cursor.name === FPRIME_NODES.Command) { + const commandNode = cursor.node; + const timeTagNode = commandNode.getChild(FPRIME_NODES.TimeTag); + const absoluteNode = timeTagNode?.getChild(FPRIME_NODES.TimeAbsolute); + const relativeNode = timeTagNode?.getChild(FPRIME_NODES.TimeRelative); + + const timeNode = absoluteNode || relativeNode; + const timeType = absoluteNode ? 'absolute' : 'relative'; + const timeText = timeNode ? input.slice(timeNode.from, timeNode.to).trim() : ''; + + commands.push({ timeType, timeText }); + } + } while (cursor.next()); + + expect(commands).toHaveLength(3); + expect(commands[0].timeType).toBe('absolute'); + expect(commands[0].timeText).toBe('A2015-075T10:00:00.000'); + expect(commands[1].timeType).toBe('relative'); + expect(commands[1].timeText).toBe('R00:10:00.000'); + expect(commands[2].timeType).toBe('relative'); + expect(commands[2].timeText).toBe('R00:05:30.500'); + }); + + it('should handle relative time without milliseconds', () => { + const input = 'R00:00:01 CMD_NO_OP\n'; + const tree = fprimeParser.parse(input); + const cursor = tree.cursor(); + + cursor.firstChild(); // Command + cursor.firstChild(); // TimeTag + cursor.firstChild(); // TimeRelative + + const timeText = input.slice(cursor.from, cursor.to).trim(); + expect(timeText).toBe('R00:00:01'); + + // Verify it matches the pattern RHH:MM:SS + expect(/^R\d{2}:\d{2}:\d{2}$/.test(timeText)).toBe(true); + }); + + it('should handle absolute time with milliseconds', () => { + const input = 'A2015-075T22:32:40.123 CMD_NO_OP\n'; + const tree = fprimeParser.parse(input); + const cursor = tree.cursor(); + + cursor.firstChild(); // Command + cursor.firstChild(); // TimeTag + cursor.firstChild(); // TimeAbsolute + + const timeText = input.slice(cursor.from, cursor.to).trim(); + expect(timeText).toBe('A2015-075T22:32:40.123'); + + // Verify it matches the pattern AYYYY-DDDTHH:MM:SS.sss + expect(/^A\d{4}-\d{3}T\d{2}:\d{2}:\d{2}\.\d+$/.test(timeText)).toBe(true); + }); +}); + +/** + * Test time computation logic conceptually. + * These tests document the expected behavior without requiring CodeMirror. + */ +describe('F-Prime Time Computation Logic', () => { + it('should compute time for A2015-075T22:32:40.123 + R00:00:01', () => { + // Base absolute time: 2015-075T22:32:40.123 + // Add 1 second: R00:00:01 + // Expected result: 2015-075T22:32:41.123 + + const baseMs = (22 * 3600 + 32 * 60 + 40) * 1000 + 123; // 81,160,123 ms + const relativeMs = 1 * 1000; // 1,000 ms + const totalMs = baseMs + relativeMs; // 81,161,123 ms + + // Convert back to time components + const hours = Math.floor(totalMs / 3600000); + const remainingMs1 = totalMs % 3600000; + const minutes = Math.floor(remainingMs1 / 60000); + const remainingMs2 = remainingMs1 % 60000; + const seconds = Math.floor(remainingMs2 / 1000); + const milliseconds = remainingMs2 % 1000; + + expect(hours).toBe(22); + expect(minutes).toBe(32); + expect(seconds).toBe(41); + expect(milliseconds).toBe(123); + + // Formatted: 2015-075T22:32:41.123 + }); + + it('should compute cumulative time for multiple relative offsets', () => { + // Base: A2015-075T10:00:00.000 + // Add: R00:10:00.000 + // Add: R00:05:30.500 + // Expected: 2015-075T10:15:30.500 + + const baseMs = (10 * 3600) * 1000; // 36,000,000 ms + const offset1Ms = (10 * 60) * 1000; // 600,000 ms + const offset2Ms = (5 * 60 + 30) * 1000 + 500; // 330,500 ms + const totalMs = baseMs + offset1Ms + offset2Ms; // 36,930,500 ms + + const hours = Math.floor(totalMs / 3600000); + const remainingMs1 = totalMs % 3600000; + const minutes = Math.floor(remainingMs1 / 60000); + const remainingMs2 = remainingMs1 % 60000; + const seconds = Math.floor(remainingMs2 / 1000); + const milliseconds = remainingMs2 % 1000; + + expect(hours).toBe(10); + expect(minutes).toBe(15); + expect(seconds).toBe(30); + expect(milliseconds).toBe(500); + + // Formatted: 2015-075T10:15:30.500 + }); + + it('should convert day of year to calendar date', () => { + // Test day-of-year to month/day conversion + // 2015-075 should be March 16, 2015 (Monday) + + // 2015 is not a leap year + const isLeapYear = (2015 % 4 === 0 && 2015 % 100 !== 0) || 2015 % 400 === 0; + expect(isLeapYear).toBe(false); + + // Days in each month for non-leap year: 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 + // Jan: 1-31 (31 days) + // Feb: 32-59 (28 days) + // Mar: 60-90 (31 days) + // Day 75 is in March: 75 - 59 = 16 + + // Calculate manually + const dayOfYear = 75; + const daysInMonth = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + + let remainingDays = dayOfYear; + let month = 0; + + for (let i = 0; i < daysInMonth.length; i++) { + if (remainingDays <= daysInMonth[i]) { + month = i + 1; // 1-based month + break; + } + remainingDays -= daysInMonth[i]; + } + + expect(month).toBe(3); // March + expect(remainingDays).toBe(16); // 16th day + + // Verify day of week: March 16, 2015 + const date = new Date(2015, 2, 16); // Month is 0-based in Date constructor + const dayOfWeek = date.getDay(); // 0 = Sunday, 1 = Monday, etc. + expect(dayOfWeek).toBe(1); // Monday + }); + + it('should compute different absolute times for each relative command', () => { + // This test verifies that each relative time gets its own computed absolute time + // Base: A2015-075T22:00:00.000 + // CMD_2 at R00:10:00.000 should be: 22:10:00.000 + // CMD_3 at R00:05:00.000 should be: 22:15:00.000 (not 22:05:00.000!) + + const baseMs = (22 * 3600) * 1000; // 79,200,000 ms + + // CMD_2: base + 10 minutes + const cmd2Ms = baseMs + (10 * 60 * 1000); // 79,800,000 ms + const cmd2Time = { + hours: Math.floor(cmd2Ms / 3600000), + minutes: Math.floor((cmd2Ms % 3600000) / 60000), + seconds: Math.floor((cmd2Ms % 60000) / 1000), + }; + expect(cmd2Time.hours).toBe(22); + expect(cmd2Time.minutes).toBe(10); + expect(cmd2Time.seconds).toBe(0); + + // CMD_3: base + 10 minutes + 5 minutes = base + 15 minutes + const cmd3Ms = baseMs + (10 * 60 * 1000) + (5 * 60 * 1000); // 80,100,000 ms + const cmd3Time = { + hours: Math.floor(cmd3Ms / 3600000), + minutes: Math.floor((cmd3Ms % 3600000) / 60000), + seconds: Math.floor((cmd3Ms % 60000) / 1000), + }; + expect(cmd3Time.hours).toBe(22); + expect(cmd3Time.minutes).toBe(15); + expect(cmd3Time.seconds).toBe(0); + }); +}); diff --git a/src/languages/fprime/fprime-tooltip.test.ts b/src/languages/fprime/fprime-tooltip.test.ts new file mode 100644 index 0000000..c1cc23e --- /dev/null +++ b/src/languages/fprime/fprime-tooltip.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect } from 'vitest'; +import { EditorState } from '@codemirror/state'; +import { hoverTooltip } from '@codemirror/view'; +import type { CommandDictionary, FswCommand } from '@nasa-jpl/plandev-ampcs'; +import { fprimeTooltip } from './fprime-tooltip.js'; + +// Mock PhoenixResources for testing +const mockResources = { + hoverTooltip, + createTooltip: (lines: string[], from: number, to: number) => ({ + pos: from, + end: to, + above: true, + create: () => { + const dom = document.createElement('div'); + dom.textContent = lines.join('\n'); + return { dom }; + }, + }), +} as any; + +describe('F-Prime Tooltip', () => { + it('should create tooltip extension', () => { + const mockDictionary: CommandDictionary = { + fswCommandMap: { + 'TEST_CMD': { + stem: 'TEST_CMD', + type: 'fsw_command', + description: 'A test command', + arguments: [], + } as FswCommand, + }, + hwCommandMap: {}, + enumMap: {}, + }; + + const extension = fprimeTooltip(mockDictionary, mockResources); + expect(extension).toBeDefined(); + }); + + it('should work with null command dictionary', () => { + const extension = fprimeTooltip(null, mockResources); + expect(extension).toBeDefined(); + }); + + it('should integrate with language support', () => { + const mockDictionary: CommandDictionary = { + fswCommandMap: { + 'cmdDisp.CMD_NO_OP': { + stem: 'cmdDisp.CMD_NO_OP', + type: 'fsw_command', + description: 'No-op command', + arguments: [], + } as FswCommand, + }, + hwCommandMap: {}, + enumMap: {}, + }; + + const tooltipExtension = fprimeTooltip(mockDictionary, mockResources); + + const doc = 'A2015-075T22:32:40.123 cmdDisp.CMD_NO_OP\n'; + const state = EditorState.create({ + doc, + extensions: [tooltipExtension], + }); + + expect(state).toBeDefined(); + expect(state.doc.toString()).toBe(doc); + }); + + it('should support commands with arguments', () => { + const mockDictionary: CommandDictionary = { + fswCommandMap: { + 'SET_VALUE': { + stem: 'SET_VALUE', + type: 'fsw_command', + description: 'Set a value with parameters', + arguments: [ + { + name: 'value', + arg_type: 'integer', + description: 'The value to set', + bit_length: 32, + range: { min: 0, max: 100 }, + units: 'none', + }, + { + name: 'timeout', + arg_type: 'float', + description: 'Timeout in seconds', + bit_length: 32, + units: 'seconds', + }, + ], + } as FswCommand, + }, + hwCommandMap: {}, + enumMap: {}, + }; + + const tooltipExtension = fprimeTooltip(mockDictionary, mockResources); + + const doc = 'R00:00:05.000 SET_VALUE 42, 3.14\n'; + const state = EditorState.create({ + doc, + extensions: [tooltipExtension], + }); + + expect(state).toBeDefined(); + expect(state.doc.toString()).toBe(doc); + }); + + it('should support time tag tooltips', () => { + const tooltipExtension = fprimeTooltip(null, mockResources); + + // Test with absolute and relative time tags + const doc = 'A2015-075T22:32:40.123 CMD_NO_OP\nR01:00:05.000 CMD_NO_OP\n'; + const state = EditorState.create({ + doc, + extensions: [tooltipExtension], + }); + + expect(state).toBeDefined(); + expect(state.doc.toString()).toBe(doc); + }); + + it('should compute absolute time from relative offset with milliseconds', () => { + const tooltipExtension = fprimeTooltip(null, mockResources); + + // Absolute time: A2015-075T22:32:40.123 + // Relative time: R00:00:01 (1 second) + // Expected computed time: 2015-075T22:32:41.123 + const doc = 'A2015-075T22:32:40.123 CMD_FIRST\nR00:00:01 CMD_SECOND\n'; + const state = EditorState.create({ + doc, + extensions: [tooltipExtension], + }); + + expect(state).toBeDefined(); + expect(state.doc.toString()).toBe(doc); + }); +}); diff --git a/src/languages/fprime/fprime-tooltip.ts b/src/languages/fprime/fprime-tooltip.ts new file mode 100644 index 0000000..14be032 --- /dev/null +++ b/src/languages/fprime/fprime-tooltip.ts @@ -0,0 +1,429 @@ +import { syntaxTree } from '@codemirror/language'; +import type { Extension } from '@codemirror/state'; +import type { EditorView, Tooltip } from '@codemirror/view'; +import type { SyntaxNode } from '@lezer/common'; +import type { CommandDictionary, FswCommand, HwCommand } from '@nasa-jpl/plandev-ampcs'; +import { PhoenixResources } from '../../interfaces/phoenix.js'; +import { buildAmpcsArgumentTooltip, buildAmpcsCommandTooltip } from '../../utils/editor-utils.js'; +import { getTokenPositionInLine } from '../../utils/tree-utils.js'; +import { FPRIME_NODES } from './fprime-grammar-constants.js'; + +/** + * Parsed time tag information + */ +interface ParsedTimeTag { + type: 'absolute' | 'relative'; + hours: number; + minutes: number; + seconds: number; + milliseconds: number; + year?: number; + dayOfYear?: number; +} + +/** + * Parses an F-Prime absolute time tag (AYYYY-DDDTHH:MM:SS[.sss]) + */ +function parseAbsoluteTime(timeText: string): ParsedTimeTag | null { + const pattern = /^A(\d{4})-(\d{3})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?$/; + const match = timeText.match(pattern); + + if (!match) { + return null; + } + + return { + type: 'absolute', + year: Number.parseInt(match[1], 10), + dayOfYear: Number.parseInt(match[2], 10), + hours: Number.parseInt(match[3], 10), + minutes: Number.parseInt(match[4], 10), + seconds: Number.parseInt(match[5], 10), + milliseconds: match[6] ? Number.parseInt(match[6].padEnd(3, '0').substring(0, 3), 10) : 0, + }; +} + +/** + * Parses an F-Prime relative time tag (RHH:MM:SS[.sss]) + */ +function parseRelativeTime(timeText: string): ParsedTimeTag | null { + const pattern = /^R(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?$/; + const match = timeText.match(pattern); + + if (!match) { + return null; + } + + return { + type: 'relative', + hours: Number.parseInt(match[1], 10), + minutes: Number.parseInt(match[2], 10), + seconds: Number.parseInt(match[3], 10), + milliseconds: match[4] ? Number.parseInt(match[4].padEnd(3, '0').substring(0, 3), 10) : 0, + }; +} + +/** + * Converts a parsed time to total milliseconds + */ +function timeToMilliseconds(time: ParsedTimeTag): number { + let ms = 0; + ms += time.hours * 3600000; + ms += time.minutes * 60000; + ms += time.seconds * 1000; + ms += time.milliseconds; + return ms; +} + +/** + * Converts total milliseconds back to time components + */ +function millisecondsToTime(ms: number): { hours: number; minutes: number; seconds: number; milliseconds: number } { + const hours = Math.floor(ms / 3600000); + ms -= hours * 3600000; + const minutes = Math.floor(ms / 60000); + ms -= minutes * 60000; + const seconds = Math.floor(ms / 1000); + const milliseconds = ms % 1000; + + return { hours, minutes, seconds, milliseconds }; +} + +/** + * Converts day-of-year to month and day + */ +function dayOfYearToMonthDay(year: number, dayOfYear: number): { month: number; day: number } { + const isLeapYear = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; + const daysInMonth = [31, isLeapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + + let remainingDays = dayOfYear; + let month = 0; + + for (let i = 0; i < daysInMonth.length; i++) { + if (remainingDays <= daysInMonth[i]) { + month = i; + break; + } + remainingDays -= daysInMonth[i]; + } + + return { month: month + 1, day: remainingDays }; +} + +/** + * Gets day of week for a given date + */ +function getDayOfWeek(year: number, month: number, day: number): string { + const date = new Date(year, month - 1, day); + const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + return days[date.getDay()]; +} + +/** + * Gets month name + */ +function getMonthName(month: number): string { + const months = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ]; + return months[month - 1]; +} + +/** + * Formats an absolute time as a calendar date + */ +function formatCalendarDate(parsed: ParsedTimeTag): string | null { + if (parsed.type !== 'absolute' || parsed.year === undefined || parsed.dayOfYear === undefined) { + return null; + } + + const { month, day } = dayOfYearToMonthDay(parsed.year, parsed.dayOfYear); + const dayOfWeek = getDayOfWeek(parsed.year, month, day); + const monthName = getMonthName(month); + + return `${dayOfWeek}, ${monthName} ${day}, ${parsed.year}`; +} + +/** + * Formats a time for display + */ +function formatTime(parsed: ParsedTimeTag): string { + const pad = (n: number, width: number = 2) => n.toString().padStart(width, '0'); + + if (parsed.type === 'absolute') { + let result = `${parsed.year}-${pad(parsed.dayOfYear!, 3)}T${pad(parsed.hours)}:${pad(parsed.minutes)}:${pad(parsed.seconds)}`; + if (parsed.milliseconds > 0) { + result += `.${pad(parsed.milliseconds, 3)}`; + } + return result; + } else { + let result = `${pad(parsed.hours)}:${pad(parsed.minutes)}:${pad(parsed.seconds)}`; + if (parsed.milliseconds > 0) { + result += `.${pad(parsed.milliseconds, 3)}`; + } + return result; + } +} + +/** + * Computes cumulative absolute time for a relative time tag by summing all prior commands + */ +function computeAbsoluteTime( + view: EditorView, + currentCommandNode: SyntaxNode, + currentRelativeTime: ParsedTimeTag, +): ParsedTimeTag | null { + let cumulativeMs = 0; + let baseYear: number | undefined; + let baseDayOfYear: number | undefined; + let reachedCurrentCommand = false; + + // Walk through the tree to find all prior commands + const tree = syntaxTree(view.state); + + // Iterate through all commands in document order + tree.iterate({ + enter: node => { + // Skip if we've already reached the current command + if (reachedCurrentCommand) { + return false; // Stop iteration + } + + if (node.name === FPRIME_NODES.Command) { + const commandNode = node.node; + + // If we've reached the current command, mark it and stop + if (commandNode.from === currentCommandNode.from) { + reachedCurrentCommand = true; + return false; // Stop iteration + } + + // Process this prior command + const timeTagNode = commandNode.getChild(FPRIME_NODES.TimeTag); + if (timeTagNode) { + const absoluteNode = timeTagNode.getChild(FPRIME_NODES.TimeAbsolute); + const relativeNode = timeTagNode.getChild(FPRIME_NODES.TimeRelative); + + if (absoluteNode) { + const timeText = view.state.doc.sliceString(absoluteNode.from, absoluteNode.to).trim(); + const parsed = parseAbsoluteTime(timeText); + if (parsed && parsed.year !== undefined && parsed.dayOfYear !== undefined) { + // Found an absolute time - set it as the base and reset cumulative time + baseYear = parsed.year; + baseDayOfYear = parsed.dayOfYear; + cumulativeMs = timeToMilliseconds(parsed); + } + } else if (relativeNode) { + const timeText = view.state.doc.sliceString(relativeNode.from, relativeNode.to).trim(); + const parsed = parseRelativeTime(timeText); + if (parsed) { + // Add relative offset to cumulative time + cumulativeMs += timeToMilliseconds(parsed); + } + } + } + } + }, + }); + + // Now add the current command's relative time + cumulativeMs += timeToMilliseconds(currentRelativeTime); + + const finalTime = millisecondsToTime(cumulativeMs); + + // If we have a base absolute time, return it with the computed time + if (baseYear !== undefined && baseDayOfYear !== undefined) { + return { + type: 'absolute', + year: baseYear, + dayOfYear: baseDayOfYear, + hours: finalTime.hours, + minutes: finalTime.minutes, + seconds: finalTime.seconds, + milliseconds: finalTime.milliseconds, + }; + } + + // No absolute time found, just return the cumulative relative time + return { + type: 'relative', + hours: finalTime.hours, + minutes: finalTime.minutes, + seconds: finalTime.seconds, + milliseconds: finalTime.milliseconds, + }; +} + +/** + * Searches up through a node's ancestors to find a node by the given name. + */ +function getParentNodeByName(view: EditorView, pos: number, name: string): SyntaxNode | undefined { + let node: SyntaxNode | undefined = syntaxTree(view.state).resolveInner(pos, -1); + + while (node && node.name !== name) { + node = node.parent?.node; + } + + return node; +} + +/** + * Tooltip function that returns a CodeMirror extension for F-Prime sequences. + * Displays command descriptions when hovering over command stems and argument + * details when hovering over command arguments. + */ +export function fprimeTooltip( + commandDictionary: CommandDictionary | null = null, + resources: PhoenixResources, +): Extension { + return resources.hoverTooltip((view, pos, side): 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 node = syntaxTree(view.state).resolveInner(pos, -1); + + // Check if we're hovering over a TimeAbsolute or TimeRelative node + if (node.name === FPRIME_NODES.TimeAbsolute || node.name === FPRIME_NODES.TimeRelative) { + const timeText = view.state.doc.sliceString(node.from, node.to).trim(); + const tooltipLines: string[] = []; + + if (node.name === FPRIME_NODES.TimeAbsolute) { + const parsed = parseAbsoluteTime(timeText); + if (parsed) { + tooltipLines.push('Absolute Time:', formatTime(parsed)); + + // Add calendar date format + const calendarDate = formatCalendarDate(parsed); + if (calendarDate) { + tooltipLines.push(calendarDate); + } + } + } else if (node.name === FPRIME_NODES.TimeRelative) { + const parsed = parseRelativeTime(timeText); + if (parsed) { + tooltipLines.push('Relative Time:', formatTime(parsed)); + + // Find the containing command node + let commandNode: SyntaxNode | null | undefined = node.parent; + while (commandNode && commandNode.name !== FPRIME_NODES.Command) { + commandNode = commandNode.parent?.node; + } + + if (commandNode) { + const absoluteTime = computeAbsoluteTime(view, commandNode, parsed); + if (absoluteTime) { + tooltipLines.push(''); + if (absoluteTime.type === 'absolute') { + tooltipLines.push('Computed Absolute Time:'); + tooltipLines.push(formatTime(absoluteTime)); + + // Add calendar date format for computed absolute time + const calendarDate = formatCalendarDate(absoluteTime); + if (calendarDate) { + tooltipLines.push(calendarDate); + } + } else { + tooltipLines.push('Cumulative Relative Time:'); + tooltipLines.push(formatTime(absoluteTime)); + } + } + } + } + } + + if (tooltipLines.length > 0) { + return resources.createTooltip(tooltipLines, node.from, node.to); + } + } + + // Check if the current node or its parent is a CommandMnemonic + const commandMnemonicNode = + node.name === FPRIME_NODES.CommandMnemonic + ? node + : node.parent?.name === FPRIME_NODES.CommandMnemonic + ? node.parent.node + : null; + + if (commandMnemonicNode && commandDictionary) { + const { hwCommandMap, fswCommandMap } = commandDictionary; + + // Get the full command mnemonic text (e.g., "Module.Component.CMD_NAME") + const commandText = view.state.doc.sliceString(commandMnemonicNode.from, commandMnemonicNode.to); + + // Look up the command in the dictionary + const command: FswCommand | HwCommand | null = fswCommandMap[commandText] ?? hwCommandMap[commandText] ?? null; + + if (command) { + // Use the existing utility to build a tooltip with command description + return resources.createTooltip( + buildAmpcsCommandTooltip(command), + commandMnemonicNode.from, + commandMnemonicNode.to, + ); + } + } + + // Check to see if we are hovering over command arguments + const argsNode = getParentNodeByName(view, pos, FPRIME_NODES.Args); + + if (argsNode && commandDictionary) { + // Find the CommandMnemonic node (sibling of Args under Command) + const commandNode = argsNode.parent; + const mnemonicNode = commandNode?.getChild(FPRIME_NODES.CommandMnemonic); + + if (mnemonicNode) { + const { fswCommandMap } = commandDictionary; + const commandText = view.state.doc.sliceString(mnemonicNode.from, mnemonicNode.to); + const fswCommand: FswCommand | null = fswCommandMap[commandText] ?? null; + + if (!fswCommand || !fswCommand.arguments || fswCommand.arguments.length === 0) { + return null; + } + + // Collect all argument nodes in order + const argNodes: SyntaxNode[] = []; + let argNode = argsNode.firstChild; + while (argNode) { + // Only include actual argument nodes (String, Number, Identifier), skip whitespace and commas + if ( + argNode.name === FPRIME_NODES.String || + argNode.name === FPRIME_NODES.Number || + argNode.name === FPRIME_NODES.Identifier + ) { + argNodes.push(argNode); + } + argNode = argNode.nextSibling; + } + + // Find which argument we're hovering over + for (let i = 0; i < argNodes.length; i++) { + const currentArg = argNodes[i]; + if (currentArg.from === from && currentArg.to === to) { + // We're hovering over this argument + const argDef = fswCommand.arguments[i]; + if (argDef) { + return resources.createTooltip(buildAmpcsArgumentTooltip(argDef, commandDictionary), from, to); + } + } + } + } + } + + return null; + }); +} diff --git a/src/languages/fprime/fprime-tree-utils.test.ts b/src/languages/fprime/fprime-tree-utils.test.ts new file mode 100644 index 0000000..fd4aee1 --- /dev/null +++ b/src/languages/fprime/fprime-tree-utils.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, it } from 'vitest'; +import { fprimeParser } from './fprime.js'; +import { FPrimeCommandInfoMapper, getNameNode, getContainingCommand } from './fprime-tree-utils.js'; +import { FPRIME_NODES } from './fprime-grammar-constants.js'; + +describe('F-Prime Tree Utils', () => { + describe('getNameNode', () => { + it('should return CommandMnemonic node from Command', () => { + const input = 'A2015-075T22:32:40.123 cmdDisp.CMD_NO_OP\n'; + const tree = fprimeParser.parse(input); + const cursor = tree.cursor(); + cursor.firstChild(); // Command + + const nameNode = getNameNode(cursor.node); + expect(nameNode).not.toBeNull(); + expect(nameNode?.name).toBe(FPRIME_NODES.CommandMnemonic); + expect(input.slice(nameNode!.from, nameNode!.to)).toBe('cmdDisp.CMD_NO_OP'); + }); + + it('should return null for non-Command nodes', () => { + const input = 'A2015-075T22:32:40.123 CMD_NO_OP\n'; + const tree = fprimeParser.parse(input); + const cursor = tree.cursor(); + + expect(getNameNode(cursor.node)).toBeNull(); + }); + }); + + describe('getContainingCommand', () => { + it('should find Command from nested node', () => { + const input = 'A2015-075T22:32:40.123 CMD_NO_OP\n'; + const tree = fprimeParser.parse(input); + const cursor = tree.cursor(); + cursor.firstChild(); // Command + cursor.firstChild(); // TimeTag + cursor.firstChild(); // TimeAbsolute + + const commandNode = getContainingCommand(cursor.node); + expect(commandNode).not.toBeNull(); + expect(commandNode?.name).toBe(FPRIME_NODES.Command); + }); + + it('should return null if not in a Command', () => { + const input = 'A2015-075T22:32:40.123 CMD_NO_OP\n'; + const tree = fprimeParser.parse(input); + const cursor = tree.cursor(); + + expect(getContainingCommand(cursor.node)).toBeNull(); + }); + }); + + describe('FPrimeCommandInfoMapper', () => { + const mapper = new FPrimeCommandInfoMapper(); + + describe('formatArgumentArray', () => { + it('should format arguments with commas and space prefix when no existing args', () => { + expect(mapper.formatArgumentArray(['42', '"test"', '0xFF'], null)).toBe(' 42, "test", 0xFF'); + }); + + it('should handle empty array', () => { + expect(mapper.formatArgumentArray([], null)).toBe(''); + }); + + it('should add comma prefix when existing args have no trailing comma', () => { + const input = 'R00:00:01.000 CMD_TEST 1, 2\n'; + const tree = fprimeParser.parse(input); + const cursor = tree.cursor(); + cursor.firstChild(); // Command + + expect(mapper.formatArgumentArray(['3', '4'], cursor.node, input)).toBe(', 3, 4'); + }); + + it('should use space prefix when existing args have trailing comma', () => { + const input = 'R00:00:01.000 CMD_TEST 1, 2,\n'; + const tree = fprimeParser.parse(input); + const cursor = tree.cursor(); + cursor.firstChild(); // Command + + expect(mapper.formatArgumentArray(['3', '4'], cursor.node, input)).toBe(' 3, 4'); + }); + + it('should use space prefix when there are no existing args', () => { + const input = 'R00:00:01.000 CMD_TEST\n'; + const tree = fprimeParser.parse(input); + const cursor = tree.cursor(); + cursor.firstChild(); // Command + + expect(mapper.formatArgumentArray(['1', '2'], cursor.node, input)).toBe(' 1, 2'); + }); + }); + + describe('getArgumentNodeContainer', () => { + it('should return Args node from Command', () => { + const input = 'R00:00:01.000 CMD_TEST 42 "arg"\n'; + const tree = fprimeParser.parse(input); + const cursor = tree.cursor(); + cursor.firstChild(); // Command + + const argsNode = mapper.getArgumentNodeContainer(cursor.node); + expect(argsNode).not.toBeNull(); + expect(argsNode?.name).toBe(FPRIME_NODES.Args); + }); + + it('should return empty Args node for Command without arguments', () => { + const input = 'R00:00:01.000 CMD_NO_ARGS\n'; + const tree = fprimeParser.parse(input); + const cursor = tree.cursor(); + cursor.firstChild(); // Command + + const argsNode = mapper.getArgumentNodeContainer(cursor.node); + expect(argsNode).not.toBeNull(); + expect(argsNode?.name).toBe(FPRIME_NODES.Args); + // Args node exists but is empty (from === to) + expect(argsNode?.from).toBe(argsNode?.to); + }); + }); + + describe('getArgumentsFromContainer', () => { + it('should return all argument nodes', () => { + const input = 'R00:00:01.000 CMD_TEST 42, "arg", 3.14\n'; + const tree = fprimeParser.parse(input); + const cursor = tree.cursor(); + cursor.firstChild(); // Command + + const argsNode = mapper.getArgumentNodeContainer(cursor.node); + const argNodes = mapper.getArgumentsFromContainer(argsNode); + + expect(argNodes.length).toBe(3); + expect(argNodes[0].name).toBe(FPRIME_NODES.Number); + expect(argNodes[1].name).toBe(FPRIME_NODES.String); + expect(argNodes[2].name).toBe(FPRIME_NODES.Number); + }); + + it('should return empty array for no arguments', () => { + expect(mapper.getArgumentsFromContainer(null)).toEqual([]); + }); + }); + + describe('nodeType checks', () => { + it('should identify enum-compatible nodes', () => { + const input = 'R00:00:01.000 CMD "test"\n'; + const tree = fprimeParser.parse(input); + const cursor = tree.cursor(); + cursor.firstChild(); // Command + const argsNode = mapper.getArgumentNodeContainer(cursor.node); + const argNodes = mapper.getArgumentsFromContainer(argsNode); + + expect(mapper.nodeTypeEnumCompatible(argNodes[0])).toBe(true); + }); + + it('should identify number-compatible nodes', () => { + const input = 'R00:00:01.000 CMD 42\n'; + const tree = fprimeParser.parse(input); + const cursor = tree.cursor(); + cursor.firstChild(); // Command + const argsNode = mapper.getArgumentNodeContainer(cursor.node); + const argNodes = mapper.getArgumentsFromContainer(argsNode); + + expect(mapper.nodeTypeNumberCompatible(argNodes[0])).toBe(true); + }); + + it('should identify nodes with arguments', () => { + const input = 'R00:00:01.000 CMD 42\n'; + const tree = fprimeParser.parse(input); + const cursor = tree.cursor(); + cursor.firstChild(); // Command + + expect(mapper.nodeTypeHasArguments(cursor.node)).toBe(true); + }); + }); + + describe('variables', () => { + it('should return empty array for getVariables', () => { + expect(mapper.getVariables()).toEqual([]); + }); + + it('should return empty array for getVariablesInScope', () => { + expect(mapper.getVariablesInScope(null as any, null)).toEqual([]); + }); + + it('should identify that nodes are not variables', () => { + expect(mapper.isArgumentNodeOfVariableType(null)).toBe(false); + }); + }); + + describe('getCommandDef', () => { + it('should look up command from dictionary', () => { + const mockDict = { + fswCommandMap: { + 'TEST_CMD': { stem: 'TEST_CMD', arguments: [] } as any, + }, + } as any; + + const result = mapper.getCommandDef(mockDict, [], 'TEST_CMD'); + expect(result).not.toBeNull(); + expect(result?.stem).toBe('TEST_CMD'); + }); + + it('should return null for unknown command', () => { + const mockDict = { + fswCommandMap: {}, + } as any; + + const result = mapper.getCommandDef(mockDict, [], 'UNKNOWN'); + expect(result).toBeNull(); + }); + }); + }); +}); diff --git a/src/languages/fprime/fprime-tree-utils.ts b/src/languages/fprime/fprime-tree-utils.ts new file mode 100644 index 0000000..8a03c75 --- /dev/null +++ b/src/languages/fprime/fprime-tree-utils.ts @@ -0,0 +1,236 @@ +import type { SyntaxNode } from '@lezer/common'; +import type { + CommandDictionary, + EnumMap, + FswCommand, + FswCommandArgument, + FswCommandArgumentRepeat, +} from '@nasa-jpl/plandev-ampcs'; +import type { EditorView } from '@codemirror/view'; +import type { LibrarySequenceSignature } from '../../interfaces/phoenix.js'; +import type { ArgTextDef, CommandInfoMapper, TimeTagInfo } from '../../interfaces/command-info-mapper.js'; +import { fswCommandArgDefault } from '../../utils/sequence-utils.js'; +import { FPRIME_NODES } from './fprime-grammar-constants.js'; + +/** + * Gets the command mnemonic node from a Command node. + */ +export function getNameNode(commandNode: SyntaxNode | null): SyntaxNode | null { + if (commandNode?.name === FPRIME_NODES.Command) { + return commandNode.getChild(FPRIME_NODES.CommandMnemonic); + } + return null; +} + +/** + * Finds the containing Command node from any node in the tree. + */ +export function getContainingCommand(node: SyntaxNode | null): SyntaxNode | null { + let current = node; + while (current) { + if (current.name === FPRIME_NODES.Command) { + return current; + } + current = current.parent; + } + return null; +} + +/** + * Gets all child nodes from a container (including commas, whitespace, etc.) + */ +function getAllChildren(containerNode: SyntaxNode | null): SyntaxNode[] { + const children: SyntaxNode[] = []; + let child = containerNode?.firstChild; + while (child) { + children.push(child); + child = child.nextSibling; + } + return children; +} + +/** + * CommandInfoMapper implementation for F-Prime sequences. + * F-Prime sequences are simpler than SeqN - no variables, no library sequences, + * just time-tagged commands with arguments. + */ +export class FPrimeCommandInfoMapper implements CommandInfoMapper { + formatArgumentArray(values: string[], commandNode: SyntaxNode | null, editorViewOrSource?: EditorView | string): string { + if (values.length === 0) { + return ''; + } + + let prefix = ' '; + + // Check if we need to prepend a comma before adding new arguments + if (commandNode?.name === FPRIME_NODES.Command && editorViewOrSource) { + const argsNode = commandNode.getChild(FPRIME_NODES.Args); + if (argsNode) { + const existingArgs = this.getArgumentsFromContainer(argsNode); + if (existingArgs.length > 0) { + // There are existing arguments - check if Args text ends with a comma + let argsText: string; + if (typeof editorViewOrSource === 'string') { + argsText = editorViewOrSource.slice(argsNode.from, argsNode.to); + } else { + argsText = editorViewOrSource.state.sliceDoc(argsNode.from, argsNode.to); + } + const trimmedArgsText = argsText.trimEnd(); + const hasTrailingComma = trimmedArgsText.endsWith(','); + prefix = hasTrailingComma ? ' ' : ', '; + } + } + } + + return prefix + values.join(', '); + } + + getArgumentAppendPosition(commandNode: SyntaxNode | null): number | undefined { + if (commandNode?.name === FPRIME_NODES.Command) { + const argsNode = commandNode.getChild(FPRIME_NODES.Args); + const mnemonicNode = commandNode.getChild(FPRIME_NODES.CommandMnemonic); + if (argsNode) { + return argsNode.to; + } else if (mnemonicNode) { + return mnemonicNode.to; + } + } + return undefined; + } + + getArgumentNodeContainer(commandNode: SyntaxNode | null): SyntaxNode | null { + return commandNode?.getChild(FPRIME_NODES.Args) ?? null; + } + + getArgumentsFromContainer(containerNode: SyntaxNode | null): SyntaxNode[] { + const children: SyntaxNode[] = []; + let child = containerNode?.firstChild; + while (child) { + // Only include actual argument nodes (String, Number, Identifier), skip whitespace and commas + const nodeType: string = child.name; + if ( + nodeType === FPRIME_NODES.String || + nodeType === FPRIME_NODES.Number || + nodeType === FPRIME_NODES.Identifier + ) { + children.push(child); + } + child = child.nextSibling; + } + return children; + } + + getByteArrayElements(): string[] | null { + // F-Prime doesn't have byte array syntax + return null; + } + + getContainingCommand(node: SyntaxNode | null): SyntaxNode | null { + return getContainingCommand(node); + } + + getDefaultValueForArgumentDef(argDef: FswCommandArgument, enumMap: EnumMap): string { + return fswCommandArgDefault(argDef, enumMap); + } + + getNameNode(commandNode: SyntaxNode | null): SyntaxNode | null { + return getNameNode(commandNode); + } + + getVariables(): string[] { + // F-Prime sequences don't have variables + return []; + } + + isArgumentNodeOfVariableType(): boolean { + // F-Prime doesn't have variables, all arguments are literals + return false; + } + + isByteArrayArg(): boolean { + // F-Prime doesn't have byte array syntax + return false; + } + + nodeTypeEnumCompatible(node: SyntaxNode | null): boolean { + // String and Identifier nodes can be used for enum arguments + return node?.name === FPRIME_NODES.String || node?.name === FPRIME_NODES.Identifier; + } + + nodeTypeHasArguments(node: SyntaxNode | null): boolean { + return node?.name === FPRIME_NODES.Command; + } + + nodeTypeNumberCompatible(node: SyntaxNode | null): boolean { + return node?.name === FPRIME_NODES.Number; + } + + getTimeTagInfo(seqEditorView: EditorView, commandNode: SyntaxNode | null): TimeTagInfo { + const timeTagNode = commandNode?.getChild(FPRIME_NODES.TimeTag); + if (!timeTagNode) { + return null; + } + + // Get the actual time type (TimeAbsolute or TimeRelative) + const timeTypeNode = timeTagNode.firstChild; + if (!timeTypeNode) { + return null; + } + + return { + node: timeTypeNode, + text: seqEditorView.state.sliceDoc(timeTypeNode.from, timeTypeNode.to).trim(), + }; + } + + getArgumentInfo( + commandDef: FswCommand | null, + seqEditorView: EditorView, + args: SyntaxNode | null, + argumentDefs: FswCommandArgument[] | undefined, + parentArgDef: FswCommandArgumentRepeat | undefined, + ): ArgTextDef[] { + const argArray: ArgTextDef[] = []; + + if (args) { + const argNodes = this.getArgumentsFromContainer(args); + for (let i = 0; i < argNodes.length; i++) { + const node = argNodes[i]; + let argDef: FswCommandArgument | undefined = undefined; + + if (argumentDefs && i < argumentDefs.length) { + argDef = argumentDefs[i]; + } + + const argValue = seqEditorView.state.sliceDoc(node.from, node.to); + argArray.push({ + argDef, + node, + parentArgDef, + text: argValue, + }); + } + } + + // Add entries for defined arguments missing from editor + if (argumentDefs && !parentArgDef) { + argArray.push(...argumentDefs.slice(argArray.length).map(argDef => ({ argDef }))); + } + + return argArray; + } + + getCommandDef( + commandDictionary: CommandDictionary | null, + librarySequences: LibrarySequenceSignature[], + stemName: string, + ): FswCommand | null { + // Look up command in dictionary by mnemonic + return commandDictionary?.fswCommandMap[stemName] ?? null; + } + + getVariablesInScope(): string[] { + // F-Prime sequences don't have variables + return []; + } +} diff --git a/src/languages/fprime/fprime.grammar b/src/languages/fprime/fprime.grammar new file mode 100644 index 0000000..ec2a749 --- /dev/null +++ b/src/languages/fprime/fprime.grammar @@ -0,0 +1,97 @@ +@top Sequence { + optSpace + (Command | CommentLine)* +} + +optSpace { + (newLine | whiteSpace)? +} + +CommentLine { + LineComment newLine +} + +Command { + TimeTag + CommandMnemonic + Args + LineComment? + newLine +} + +TimeTag { + TimeAbsolute | TimeRelative +} + +CommandMnemonic { + commandIdentifier +} + +Args { + (whiteSpace arg (whiteSpace? "," whiteSpace? arg)*)? +} + +arg[@isGroup=Arguments] { + Number | String | Identifier +} + +Identifier { + identifier +} + +@tokens { + // Identifiers used in arguments (e.g., enum values) do not include dots + identifier { + @asciiLetter (@asciiLetter | @digit | "_")* + } + + // Command mnemonic can have dots for namespacing + commandIdentifier { + @asciiLetter (@asciiLetter | @digit | "_" | ".")* + } + + // Absolute time: AYYYY-DDDTHH:MM:SS[.sss] + // Example: A2015-075T22:32:40.123 + TimeAbsolute { + "A" @digit@digit@digit@digit "-" @digit@digit@digit "T" + @digit@digit ":" @digit@digit ":" @digit@digit ("." @digit+)? + whiteSpace + } + + // Relative time: RHH:MM:SS[.sss] + // Example: R01:00:01.050 + TimeRelative { + "R" @digit@digit ":" @digit@digit ":" @digit@digit ("." @digit+)? + whiteSpace + } + + // String literals in double quotes + String { + '"' (!["\\] | "\\" _)* '"' + } + + // Numbers (integers, floats, hex, etc.) + Number { + ("+" | "-")? + (@digit+ ("." @digit+)? | "." @digit+) + (("e" | "E") ("+" | "-")? @digit+)? | + "0x" $[0-9a-fA-F]+ + } + + // Comments start with semicolon + LineComment { + $[ \t]* ";" ![\n\r]* + } + + newLine { + ($[ \t]* "\n")+ $[ \t]* | (whiteSpace? @eof) + } + + whiteSpace { + $[ \t]+ + } + + @precedence { newLine, whiteSpace } + @precedence { TimeAbsolute, TimeRelative, commandIdentifier } + @precedence { LineComment, whiteSpace } +} diff --git a/src/languages/fprime/fprime.test.ts b/src/languages/fprime/fprime.test.ts new file mode 100644 index 0000000..36c9414 --- /dev/null +++ b/src/languages/fprime/fprime.test.ts @@ -0,0 +1,405 @@ +import { describe, expect, it } from 'vitest'; +import { fprimeParser } from './fprime.js'; +import { FPRIME_NODES } from './fprime-grammar-constants.js'; + +describe('F-Prime Sequence Parser', () => { + it('should parse absolute time command', () => { + const input = 'A2015-075T22:32:40.123 cmdDisp.CMD_NO_OP\n'; + const tree = fprimeParser.parse(input); + const cursor = tree.cursor(); + + expect(tree.length).toBe(input.length); + expect(cursor.node.type.name).toBe(FPRIME_NODES.Sequence); + + // Navigate to Command node + cursor.firstChild(); + expect(cursor.node.type.name).toBe(FPRIME_NODES.Command); + + // Check TimeTag + cursor.firstChild(); + expect(cursor.node.type.name).toBe(FPRIME_NODES.TimeTag); + cursor.firstChild(); + expect(cursor.node.type.name).toBe(FPRIME_NODES.TimeAbsolute); + expect(input.slice(cursor.from, cursor.to).trim()).toBe('A2015-075T22:32:40.123'); + }); + + it('should parse relative time command with string argument', () => { + const input = 'R01:00:01.050 CMD_NO_OP_STRING "Awesome string!"\n'; + const tree = fprimeParser.parse(input); + const cursor = tree.cursor(); + + expect(tree.length).toBe(input.length); + cursor.firstChild(); // Command + expect(cursor.node.type.name).toBe(FPRIME_NODES.Command); + + cursor.firstChild(); // TimeTag + cursor.firstChild(); // TimeRelative + expect(cursor.node.type.name).toBe(FPRIME_NODES.TimeRelative); + expect(input.slice(cursor.from, cursor.to).trim()).toBe('R01:00:01.050'); + }); + + it('should parse command with comment', () => { + const input = 'R01:00:01.050 CMD_NO_OP_STRING "Awesome string!" ; And a nice comment too\n'; + const tree = fprimeParser.parse(input); + const cursor = tree.cursor(); + + cursor.firstChild(); // Command + expect(cursor.node.type.name).toBe(FPRIME_NODES.Command); + + // Find LineComment as a child of Command + cursor.firstChild(); // Enter Command's children + let foundComment = false; + do { + if (cursor.node.type.name === FPRIME_NODES.LineComment) { + foundComment = true; + expect(input.slice(cursor.from, cursor.to)).toContain('; And a nice comment too'); + break; + } + } while (cursor.nextSibling()); + + expect(foundComment).toBe(true); + }); + + it('should parse command with args and space before comment', () => { + const input = 'R00:00:05.000 SET_VALUE 42, 3.14 ; comment with leading spaces\n'; + const tree = fprimeParser.parse(input); + const cursor = tree.cursor(); + + expect(tree.length).toBe(input.length); + cursor.firstChild(); // Command + expect(cursor.node.type.name).toBe(FPRIME_NODES.Command); + + // Find LineComment and verify it includes the leading spaces + cursor.firstChild(); // Enter Command's children + let foundComment = false; + do { + if (cursor.node.type.name === FPRIME_NODES.LineComment) { + foundComment = true; + const commentText = input.slice(cursor.from, cursor.to); + expect(commentText).toContain('; comment with leading spaces'); + // The LineComment token should include the leading whitespace + expect(commentText).toMatch(/^\s*;/); + break; + } + } while (cursor.nextSibling()); + + expect(foundComment).toBe(true); + }); + + it('should parse command with no args and space before comment', () => { + const input = 'A2015-075T22:32:40.123 CMD_NO_OP ; no args, just comment\n'; + const tree = fprimeParser.parse(input); + const cursor = tree.cursor(); + + expect(tree.length).toBe(input.length); + cursor.firstChild(); // Command + expect(cursor.node.type.name).toBe(FPRIME_NODES.Command); + + // Find LineComment and verify it includes the leading spaces + cursor.firstChild(); // Enter Command's children + let foundComment = false; + do { + if (cursor.node.type.name === FPRIME_NODES.LineComment) { + foundComment = true; + const commentText = input.slice(cursor.from, cursor.to); + expect(commentText).toContain('; no args, just comment'); + // The LineComment token should include the leading whitespace + expect(commentText).toMatch(/^\s*;/); + break; + } + } while (cursor.nextSibling()); + + expect(foundComment).toBe(true); + }); + + it('should parse comment-only line', () => { + const input = '; This is a comment\n'; + const tree = fprimeParser.parse(input); + const cursor = tree.cursor(); + + expect(tree.length).toBe(input.length); + cursor.firstChild(); + expect(cursor.node.type.name).toBe(FPRIME_NODES.CommentLine); + }); + + it('should parse multiple commands', () => { + const input = `A2015-075T22:32:40.123 cmdDisp.CMD_NO_OP +R01:00:01.050 CMD_NO_OP_STRING "Awesome string!" +; A comment line +R00:00:05.000 ANOTHER_COMMAND 42 +`; + const tree = fprimeParser.parse(input); + const cursor = tree.cursor(); + + expect(tree.length).toBe(input.length); + cursor.firstChild(); + + const nodeTypes: string[] = []; + do { + nodeTypes.push(cursor.node.type.name); + } while (cursor.nextSibling()); + + expect(nodeTypes).toContain(FPRIME_NODES.Command); + expect(nodeTypes).toContain(FPRIME_NODES.CommentLine); + expect(nodeTypes.filter(t => t === FPRIME_NODES.Command).length).toBe(3); + }); + + it('should parse command with numeric arguments', () => { + const input = 'R00:00:05.000 SOME_COMMAND 42, 3.14\n'; + const tree = fprimeParser.parse(input); + const cursor = tree.cursor(); + + cursor.firstChild(); // Command + expect(cursor.node.type.name).toBe(FPRIME_NODES.Command); + + // Enter Command's children to find Args node + cursor.firstChild(); + do { + if (cursor.node.type.name === FPRIME_NODES.Args) { + cursor.firstChild(); + // Should have Number arguments + let foundNumbers = 0; + do { + // Re-read the type to prevent TS from using the narrowed "Args" type + const nodeType: string = cursor.node.type.name; + if (nodeType === FPRIME_NODES.Number) { + foundNumbers++; + } + } while (cursor.nextSibling()); + expect(foundNumbers).toBe(2); // Should find both 42 and 3.14 + break; + } + } while (cursor.nextSibling()); + }); + + it('should parse command with namespaced mnemonic', () => { + const input = 'A2015-075T22:32:40.123 module.submodule.CMD_NAME\n'; + const tree = fprimeParser.parse(input); + const cursor = tree.cursor(); + + cursor.firstChild(); // Command + cursor.firstChild(); // TimeTag + cursor.nextSibling(); // CommandMnemonic + + expect(cursor.node.type.name).toBe(FPRIME_NODES.CommandMnemonic); + expect(input.slice(cursor.from, cursor.to)).toBe('module.submodule.CMD_NAME'); + }); + + it('should handle hex numbers', () => { + const input = 'R00:00:01.000 HEX_COMMAND 0xFF, 0x1234ABCD\n'; + const tree = fprimeParser.parse(input); + const cursor = tree.cursor(); + + expect(tree.length).toBe(input.length); + cursor.firstChild(); + expect(cursor.node.type.name).toBe(FPRIME_NODES.Command); + }); + + it('should parse absolute time without subseconds', () => { + const input = 'A2015-075T22:32:40 cmdDisp.CMD_NO_OP\n'; + const tree = fprimeParser.parse(input); + const cursor = tree.cursor(); + + cursor.firstChild(); + expect(cursor.node.type.name).toBe(FPRIME_NODES.Command); + cursor.firstChild(); // TimeTag + cursor.firstChild(); // TimeAbsolute + expect(cursor.node.type.name).toBe(FPRIME_NODES.TimeAbsolute); + }); + + it('should parse relative time without subseconds', () => { + const input = 'R01:00:01 CMD_NO_OP\n'; + const tree = fprimeParser.parse(input); + const cursor = tree.cursor(); + + cursor.firstChild(); + cursor.firstChild(); + cursor.firstChild(); + expect(cursor.node.type.name).toBe(FPRIME_NODES.TimeRelative); + }); + + it('should parse sequence with one absolute time followed by relative times', () => { + const input = `A2024-100T12:00:00.000 INIT_COMMAND +R00:01:00.000 CMD_ONE 1 +R00:02:30.500 CMD_TWO "test", 42 +R00:00:15.250 CMD_THREE 0xAB, 0xCD, 0xEF +R00:05:00.000 CMD_FOUR 3.14159, 2.71828 +`; + const tree = fprimeParser.parse(input); + const cursor = tree.cursor(); + + expect(tree.length).toBe(input.length); + cursor.firstChild(); + + // Collect all commands and their time tag types + const commands: Array<{ timeType: string; mnemonic: string }> = []; + do { + if (cursor.node.type.name === FPRIME_NODES.Command) { + // Enter the command to find its time tag and mnemonic + cursor.firstChild(); + let timeType = ''; + let mnemonic = ''; + + do { + const nodeType: string = cursor.node.type.name; + if (nodeType === FPRIME_NODES.TimeTag) { + cursor.firstChild(); + timeType = cursor.node.type.name; + cursor.parent(); + } else if (nodeType === FPRIME_NODES.CommandMnemonic) { + mnemonic = input.slice(cursor.from, cursor.to); + } + } while (cursor.nextSibling()); + + commands.push({ timeType, mnemonic }); + cursor.parent(); // Back to Command + } + } while (cursor.nextSibling()); + + // Verify we found 5 commands + expect(commands.length).toBe(5); + + // First command should have absolute time + expect(commands[0].timeType).toBe(FPRIME_NODES.TimeAbsolute); + expect(commands[0].mnemonic).toBe('INIT_COMMAND'); + + // All other commands should have relative time + expect(commands[1].timeType).toBe(FPRIME_NODES.TimeRelative); + expect(commands[1].mnemonic).toBe('CMD_ONE'); + + expect(commands[2].timeType).toBe(FPRIME_NODES.TimeRelative); + expect(commands[2].mnemonic).toBe('CMD_TWO'); + + expect(commands[3].timeType).toBe(FPRIME_NODES.TimeRelative); + expect(commands[3].mnemonic).toBe('CMD_THREE'); + + expect(commands[4].timeType).toBe(FPRIME_NODES.TimeRelative); + expect(commands[4].mnemonic).toBe('CMD_FOUR'); + }); + + it('should detect missing commas between arguments', () => { + const testCases = [ + 'R00:00:01.000 CMD_TEST 42 3.14\n', // missing comma between numbers + 'R00:00:01.000 CMD_TEST "str1" "str2"\n', // missing comma between strings + 'R00:00:01.000 CMD_TEST 42 "test"\n', // missing comma between number and string + 'R00:00:01.000 CMD_TEST INPUT_A INPUT_B\n', // missing comma between identifiers + ]; + + testCases.forEach(input => { + const tree = fprimeParser.parse(input); + + // Should have parse errors due to missing commas + expect(tree.toString()).toContain('⚠'); + + // The parser should still recognize the command structure + const cursor = tree.cursor(); + cursor.firstChild(); + expect(cursor.node.type.name).toBe(FPRIME_NODES.Command); + }); + }); + + it('should parse fprime-gds simple_sequence.seq example', () => { + // Real-world example from https://github.com/nasa/fprime-gds/blob/devel/examples/simple_sequence.seq + const input = `;-------------------------------------------------------------------- +; Simple sequence file +; Note: that anything after a ';' is a comment +;-------------------------------------------------------------------- +; Commands in a sequence can either be timed absolutely or relative +; to the execution of the previous command. Here is an absolute NOOP +; command. +A2015-075T22:32:40.123 cmdDisp.CMD_NO_OP +; Here is a relative NOOP command, which will be run 1 second after +; the execution of the previous command +R00:00:01 cmdDisp.CMD_NO_OP; Send a no op command +; This command will run immediately after the previously executed command +; has completed +R00:00:00 cmdDisp.CMD_NO_OP +; Let's try out some commands with arguments +R01:00:01.050 cmdDisp.CMD_NO_OP_STRING "Awesome string!"; <- cool argument right? +R03:51:01.000 cmdDisp.CMD_TEST_CMD_1 17, 3.2, 2; <- this command has 3 arguments +R00:05:00 eventLogger.ALOG_SET_EVENT_REPORT_FILTER INPUT_COMMAND, INPUT_DISABLED; <- this command uses enum arguments +`; + + const tree = fprimeParser.parse(input); + const cursor = tree.cursor(); + + expect(tree.length).toBe(input.length); + cursor.firstChild(); + + // Count commands and comment lines + let commandCount = 0; + let commentCount = 0; + + do { + const nodeType: string = cursor.node.type.name; + if (nodeType === FPRIME_NODES.Command) { + commandCount++; + } else if (nodeType === FPRIME_NODES.CommentLine) { + commentCount++; + } + } while (cursor.nextSibling()); + + // Should have 6 commands and multiple comment lines + expect(commandCount).toBe(6); + expect(commentCount).toBeGreaterThan(5); + + // Reset cursor to beginning + cursor.moveTo(tree.topNode.from); + cursor.firstChild(); + + // Verify specific commands parse correctly + const commands: Array<{ mnemonic: string; argCount: number }> = []; + + do { + const nodeType: string = cursor.node.type.name; + if (nodeType === FPRIME_NODES.Command) { + cursor.firstChild(); + let mnemonic = ''; + let argCount = 0; + + do { + const childType: string = cursor.node.type.name; + if (childType === FPRIME_NODES.CommandMnemonic) { + mnemonic = input.slice(cursor.from, cursor.to); + } else if (childType === FPRIME_NODES.Args) { + // Args node may have children, check if it does + if (cursor.firstChild()) { + do { + const argType: string = cursor.node.type.name; + if (argType === FPRIME_NODES.String || argType === FPRIME_NODES.Number || argType === FPRIME_NODES.Identifier) { + argCount++; + } + } while (cursor.nextSibling()); + cursor.parent(); // Back to Args + } + } + } while (cursor.nextSibling()); + + commands.push({ mnemonic, argCount }); + cursor.parent(); // Back to Command + } + } while (cursor.nextSibling()); + + // Verify we collected all 6 commands + expect(commands.length).toBe(6); + + // Verify command mnemonics + expect(commands[0].mnemonic).toBe('cmdDisp.CMD_NO_OP'); + expect(commands[0].argCount).toBe(0); + + expect(commands[1].mnemonic).toBe('cmdDisp.CMD_NO_OP'); + expect(commands[1].argCount).toBe(0); + + expect(commands[2].mnemonic).toBe('cmdDisp.CMD_NO_OP'); + expect(commands[2].argCount).toBe(0); + + expect(commands[3].mnemonic).toBe('cmdDisp.CMD_NO_OP_STRING'); + expect(commands[3].argCount).toBe(1); // "Awesome string!" + + expect(commands[4].mnemonic).toBe('cmdDisp.CMD_TEST_CMD_1'); + expect(commands[4].argCount).toBe(3); // 17, 3.2, 2 + + expect(commands[5].mnemonic).toBe('eventLogger.ALOG_SET_EVENT_REPORT_FILTER'); + expect(commands[5].argCount).toBe(2); // INPUT_COMMAND, INPUT_DISABLED + }); +}); diff --git a/src/languages/fprime/fprime.ts b/src/languages/fprime/fprime.ts new file mode 100644 index 0000000..366783f --- /dev/null +++ b/src/languages/fprime/fprime.ts @@ -0,0 +1,36 @@ +import { foldNodeProp, indentNodeProp } from '@codemirror/language'; +import { styleTags, tags as t } from '@lezer/highlight'; +import { parser } from './fprime.grammar.js'; +import type { PhoenixResources } from '../../interfaces/phoenix.js'; + +export const fprimeParser = parser; + +// Export F-Prime dictionary utilities +export { parseFPrimeJsonToAmpcsXml, type FPrimeToAmpcsOptions } from './fprime-dictionary.js'; + +/** + * Get the F' Lezer-based LRLanguage instance. + * This configures syntax highlighting, folding, and indentation for F' sequences. + */ +export function getFprimeLRLanguage(resources: PhoenixResources) { + return resources.LRLanguage.define({ + languageData: { + commentTokens: { line: ';' }, + }, + parser: fprimeParser.configure({ + props: [ + indentNodeProp.add({}), + foldNodeProp.add({}), + styleTags({ + TimeAbsolute: t.className, + TimeRelative: t.className, + CommandMnemonic: t.keyword, + String: t.string, + Number: t.number, + LineComment: t.lineComment, + 'Args/...': t.content, + }), + ], + }), + }); +} diff --git a/src/languages/fprime/language.ts b/src/languages/fprime/language.ts new file mode 100644 index 0000000..2d7c6f7 --- /dev/null +++ b/src/languages/fprime/language.ts @@ -0,0 +1,41 @@ +import { LanguageSupport } from '@codemirror/language'; +import type { Extension } from '@codemirror/state'; +import type { InputLanguage } from '../../interfaces/language.js'; +import type { PhoenixContext, PhoenixResources } from '../../interfaces/phoenix.js'; +import { getFprimeLRLanguage } from './fprime.js'; +import { FPrimeCommandInfoMapper } from './fprime-tree-utils.js'; +import { fprimeLinter } from './fprime-linter.js'; +import { fprimeTooltip } from './fprime-tooltip.js'; +import { fprimeCompletion } from './fprime-completion.js'; + +/** + * Get keyed object for F' editor extensions to more easily replace/extend components. + */ +export function getFprimeExtensions( + resources: PhoenixResources, + context: PhoenixContext, + mapper?: FPrimeCommandInfoMapper, +): { [key: string]: Extension } { + mapper = mapper ?? new FPrimeCommandInfoMapper(); + const fprimeLRLanguage = getFprimeLRLanguage(resources); + return { + languageSupport: new LanguageSupport(fprimeLRLanguage, [ + fprimeLRLanguage.data.of({ + autocomplete: fprimeCompletion(context.commandDictionary), + }), + ]), + linter: resources.linter(view => fprimeLinter(view, context.commandDictionary)), + tooltip: fprimeTooltip(context.commandDictionary, resources), + }; +} + +/** + * F' (fprime) sequence language definition for Phoenix. + * Supports NASA's F' flight software sequencing format. + */ +export const fprimeLanguage: InputLanguage = { + name: 'F-Prime', + fileExtension: '.seq', + getEditorExtension: (context, resources) => Object.values(getFprimeExtensions(resources, context)), + commandInfoMapper: new FPrimeCommandInfoMapper(), +}; diff --git a/src/languages/seq-n/seq-n-tree-utils.ts b/src/languages/seq-n/seq-n-tree-utils.ts index 32a7c64..b1ff910 100644 --- a/src/languages/seq-n/seq-n-tree-utils.ts +++ b/src/languages/seq-n/seq-n-tree-utils.ts @@ -248,7 +248,7 @@ export class SeqNCommandInfoMapper implements CommandInfoMapper { return globalNames; } - formatArgumentArray(values: string[]): string { + formatArgumentArray(values: string[], _commandNode: SyntaxNode | null, _editorViewOrSource?: EditorView | string): string { return ' ' + values.join(' '); } } diff --git a/src/languages/vml/vml-tree-utils.ts b/src/languages/vml/vml-tree-utils.ts index eacc187..d57260b 100644 --- a/src/languages/vml/vml-tree-utils.ts +++ b/src/languages/vml/vml-tree-utils.ts @@ -43,7 +43,7 @@ import { } from './vml-constants.js'; export class VmlCommandInfoMapper implements CommandInfoMapper { - formatArgumentArray(values: string[], commandNode: SyntaxNode | null): string { + formatArgumentArray(values: string[], commandNode: SyntaxNode | null, _editorViewOrSource?: EditorView | string): string { let prefix = ' '; if (commandNode?.name === RULE_TIME_TAGGED_STATEMENT) { const callParametersNode = commandNode.firstChild?.nextSibling?.firstChild?.getChild(RULE_CALL_PARAMETERS); diff --git a/src/utils/sequence-utils.ts b/src/utils/sequence-utils.ts index 7e9ee7d..fac7af0 100644 --- a/src/utils/sequence-utils.ts +++ b/src/utils/sequence-utils.ts @@ -211,6 +211,7 @@ export function addDefaultArgs( const str = commandInfoMapper.formatArgumentArray( argDefs.map(argDef => fswCommandArgDefault(argDef, commandDictionary.enumMap)), commandNode, + view, ); const transaction = view.state.update({ changes: { from: insertPosition, insert: str }, @@ -253,7 +254,7 @@ export function addDefaultVariableArgs( ) { const insertPosition = commandInfoMapper.getArgumentAppendPosition(commandNode); if (insertPosition !== undefined) { - const str = commandInfoMapper.formatArgumentArray(getDefaultVariableArgs(parameters), commandNode); + const str = commandInfoMapper.formatArgumentArray(getDefaultVariableArgs(parameters), commandNode, view); const transaction = view.state.update({ changes: { from: insertPosition, insert: str }, }); diff --git a/tests/sequence/example_fprime.seq b/tests/sequence/example_fprime.seq new file mode 100644 index 0000000..7676498 --- /dev/null +++ b/tests/sequence/example_fprime.seq @@ -0,0 +1,17 @@ +; F-Prime Sequence Example +; Based on https://nasa.github.io/fprime/UsersGuide/gds/seqgen.html + +; Absolute time command +A2015-075T22:32:40.123 cmdDisp.CMD_NO_OP + +; Relative time command with string argument +R01:00:01.050 CMD_NO_OP_STRING "Awesome string!" ; And a nice comment too + +; Command with numeric arguments +R00:00:05.000 SET_VALUE 42, 3.14159 + +; Command with hex arguments +R00:00:10.500 MEMORY_WRITE 0xFF, 0x1234ABCD + +; Another absolute time command +A2015-075T23:00:00.000 module.submodule.COMPLEX_CMD 1, 2, 3, "test"