Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions vscode-extension/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,17 @@ pane. Keywords that are valid in every section (e.g. `INCLUDE`, `ECHO`) list
them all. Completions are triggered when typing uppercase letters at the start
of a line.

Accepting a completion inserts the keyword together with a **boilerplate data
record** as a tab-navigable snippet: each parameter becomes a placeholder filled
with its documented default, or a type-appropriate dummy value when there is no
default (`1` for integers, `0.0` for reals, `'STRING'` for strings). The record
is terminated to match the keyword's shape — a single `/` for fixed and array
keywords, plus an extra standalone `/` line for record-list keywords like
`WELSPECS` and `COMPDAT`. Activation keywords (e.g. `OIL`, `UNIFOUT`) insert the
name alone. Press `Tab` to jump between placeholders and overwrite the dummy
values. To insert just the keyword name with no record, set
`opm-flow.completion.keywordInsert` to `keyword` (see [Settings](#settings)).

### Parameter Value Completion

Inside a record, when the parameter at the current column has a known set of
Expand Down Expand Up @@ -96,6 +107,22 @@ Keywords whose record bodies don't fit the generic model can be silenced
wholesale via the `opm-flow.diagnostics.excludedKeywords` setting — see
[Settings](#settings) below.

### Quick Fixes

Diagnostics that have an unambiguous correction offer a lightbulb **Quick Fix**
(`Ctrl+.`, or `Cmd+.` on macOS). Place the cursor on the squiggle, open the
lightbulb, and apply:

- **Convert to uppercase** — a lowercase keyword like `welspecs` → `WELSPECS`.
- **Move keyword to column 1** — strip the leading whitespace from an indented
keyword.
- **Add terminating `/`** — append the missing per-record `/`.
- **Add `/` to close the record list / value array** — insert the missing
standalone `/` line that closes a `WELSPECS`/`COMPDAT` block or a
`PORO`/`PERMX` array.
- **Replace with `<nearest>`** — for an unrecognised keyword that is a close typo
of a known one (e.g. `EQLDIM` → `EQLDIMS`), substitute the suggested keyword.

### Docs Panel (Sidebar)

Open the **Explorer** sidebar (`Ctrl+Shift+E`) and scroll down to the **OPM Keyword Reference** panel.
Expand Down Expand Up @@ -256,6 +283,7 @@ you can override them per-workspace or per-folder.

| Setting | Default | Description |
| --- | --- | --- |
| `opm-flow.completion.keywordInsert` | `"template"` | What accepting a keyword completion inserts. `"template"` adds the keyword plus a boilerplate data record (typed placeholders / documented defaults) as a tab-navigable snippet; `"keyword"` inserts just the keyword name. |
| `opm-flow.completion.stringValueStyle` | `"quoted"` | How STRING-typed parameter values appear in the suggestion list. `"quoted"` shows only `'OPEN'`; `"unquoted"` shows only `OPEN`; `"both"` shows each option twice (e.g. `OPEN` and `'OPEN'`). Inside an existing quoted token only the quoted form is offered regardless of this setting. |

### Docs layout
Expand Down Expand Up @@ -308,6 +336,18 @@ The language is registered as `opm-flow`.

## Release Notes

### Unreleased

- **Boilerplate keyword completion** — accepting a keyword completion now inserts
a sample data record as a tab-navigable snippet (documented defaults or
type-appropriate dummy values, terminated to match the keyword's shape) instead
of just the keyword name. The new `opm-flow.completion.keywordInsert` setting
switches back to name-only insertion.
- **Diagnostic quick fixes** — lightbulb fixes for common deck mistakes:
uppercase a lowercase keyword, move an indented keyword to column 1, add a
missing record / list / array terminator `/`, and replace an unrecognised
keyword with its nearest known match.

### 0.8.0

- **Keyword outline tree view** (Issue #41) — a new **OPM Flow Outline** panel in
Expand Down
11 changes: 11 additions & 0 deletions vscode-extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,17 @@
"default": true,
"description": "Show the Default column in the keyword docs sidebar and hover tooltips."
},
"opm-flow.completion.keywordInsert": {
"type": "string",
"enum": ["template", "keyword"],
"enumDescriptions": [
"Insert the keyword together with a sample data record — typed placeholders or defaults, terminated to match the keyword's shape — as a tab-navigable snippet.",
"Insert only the keyword name, with no data record."
],
"default": "template",
"description": "What accepting a keyword completion inserts: the keyword plus a boilerplate data record (template), or just the keyword name.",
"scope": "resource"
},
"opm-flow.completion.stringValueStyle": {
"type": "string",
"enum": ["both", "quoted", "unquoted"],
Expand Down
56 changes: 56 additions & 0 deletions vscode-extension/src/analysis.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1226,3 +1226,59 @@ describe('computeDiagnostics — TITLE accepts a bare title line', () => {
expect(computeDiagnostics(lines, titleIndex)).toEqual([]);
});
});

// ---------------------------------------------------------------------------
// Quick-fix discriminator codes
// ---------------------------------------------------------------------------

describe('computeDiagnostics — quick-fix codes', () => {
it('tags a lowercase keyword', () => {
const diags = computeDiagnostics(['SCHEDULE', 'welspecs', '/'], index);
expect(diags[0].code).toBe('lowercase-keyword');
});

it('tags an indented keyword', () => {
const diags = computeDiagnostics(['SCHEDULE', '\tWELSPECS', '/'], index);
expect(diags[0].code).toBe('indented-keyword');
});

it('tags an indented section keyword', () => {
const diags = computeDiagnostics([' RUNSPEC'], index);
expect(diags[0].code).toBe('indented-keyword');
});

it('tags a record missing its terminating /', () => {
const diags = computeDiagnostics(['RUNSPEC', 'DIMENS', '10 10 10'], index);
const d = diags.find(x => x.code === 'missing-record-terminator');
expect(d).toBeDefined();
});

it('tags a list block missing its closing /', () => {
const diags = computeDiagnostics(
['SCHEDULE', 'WELSPECS', "'W1' 'G' 1 1 /", 'INCLUDE'],
index,
);
const d = diags.find(x => x.code === 'missing-list-terminator');
expect(d).toBeDefined();
});

it('tags an array block missing its closing /', () => {
const diags = computeDiagnostics(['GRID', 'PORO', '0.1 0.2 0.3', 'NTG'], index);
const d = diags.find(x => x.code === 'missing-array-terminator');
expect(d).toBeDefined();
});

it('tags an unknown keyword and suggests the nearest match', () => {
const diags = computeDiagnostics(['RUNSPEC', 'DIMNES'], index);
const d = diags.find(x => x.code === 'unknown-keyword');
expect(d).toBeDefined();
expect(d!.suggestion).toBe('DIMENS');
});

it('leaves suggestion undefined for an unknown keyword with no close match', () => {
const diags = computeDiagnostics(['RUNSPEC', 'ZZZQQQ'], index);
const d = diags.find(x => x.code === 'unknown-keyword');
expect(d).toBeDefined();
expect(d!.suggestion).toBeUndefined();
});
});
70 changes: 70 additions & 0 deletions vscode-extension/src/analysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,16 @@ export interface AnalysisEntry {

export type AnalysisIndex = Record<string, AnalysisEntry>;

/** Stable identifier for diagnostics that have an unambiguous quick fix.
* Diagnostics without a fix leave this undefined. */
export type DiagnosticCode =
| 'lowercase-keyword'
| 'indented-keyword'
| 'missing-record-terminator'
| 'missing-list-terminator'
| 'missing-array-terminator'
| 'unknown-keyword';

export interface LineDiagnostic {
/** Zero-based document line. */
line: number;
Expand All @@ -90,6 +100,57 @@ export interface LineDiagnostic {
endChar: number;
/** Human-readable message ready for VS Code. */
message: string;
/** Set when a one-click quick fix is available for this diagnostic. */
code?: DiagnosticCode;
/** For `unknown-keyword`: the nearest known keyword to offer as a
* replacement, when one is close enough. */
suggestion?: string;
}

/** Levenshtein edit distance, capped early once it exceeds `max`. */
function editDistance(a: string, b: string, max: number): number {
const la = a.length;
const lb = b.length;
if (Math.abs(la - lb) > max) return max + 1;
let prev = Array.from({ length: lb + 1 }, (_, j) => j);
let curr = new Array<number>(lb + 1);
for (let i = 1; i <= la; i++) {
curr[0] = i;
let rowMin = curr[0];
for (let j = 1; j <= lb; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
if (curr[j] < rowMin) rowMin = curr[j];
}
if (rowMin > max) return max + 1;
[prev, curr] = [curr, prev];
}
return prev[lb];
}

/** The recognised keyword closest to `kw` (typo candidate), or undefined when
* nothing is within a small edit distance. Searches the index plus the
* section keywords. */
function nearestKeyword(
kw: string,
index: AnalysisIndex,
sectionKeywords: ReadonlySet<string>,
): string | undefined {
// Allow more slack for longer names; never more than 2 edits.
const max = kw.length >= 6 ? 2 : 1;
let best: string | undefined;
let bestDist = max + 1;
const consider = (cand: string) => {
if (cand === kw) return;
const d = editDistance(kw, cand, max);
if (d < bestDist) {
bestDist = d;
best = cand;
}
};
for (const cand of sectionKeywords) consider(cand);
for (const cand of Object.keys(index)) consider(cand);
return bestDist <= max ? best : undefined;
}

/** True when the line, after leading whitespace, is just '/' (optionally
Expand Down Expand Up @@ -244,6 +305,9 @@ export function computeDiagnostics(
startChar: sc,
endChar: ec,
message: `${activeKw.name}: missing terminating '/' to ${what}.`,
code: activeKw.size_kind === 'array'
? 'missing-array-terminator'
: 'missing-list-terminator',
});
}
activeKw = null;
Expand Down Expand Up @@ -281,6 +345,7 @@ export function computeDiagnostics(
startChar: section.indent,
endChar: section.indent + section.name.length,
message: `${section.name}: keywords must start in column 1; indented keywords are not recognised by OPM Flow.`,
code: 'indented-keyword',
});
}
closeKw();
Expand All @@ -306,6 +371,7 @@ export function computeDiagnostics(
startChar: indent,
endChar: indent + tok.length,
message: `${upper}: keywords must be in capital case; lowercase keywords are not recognised by OPM Flow.`,
code: 'lowercase-keyword',
});
closeKw();
continue;
Expand Down Expand Up @@ -344,6 +410,7 @@ export function computeDiagnostics(
startChar: indent,
endChar: indent + kw.length,
message: `${kw}: keywords must start in column 1; indented keywords are not recognised by OPM Flow.`,
code: 'indented-keyword',
});
}

Expand All @@ -368,6 +435,8 @@ export function computeDiagnostics(
startChar: activeKwIndent,
endChar: activeKwIndent + kw.length,
message: `${kw} is not a recognised OPM Flow keyword.`,
code: 'unknown-keyword',
suggestion: nearestKeyword(kw, index, SECTION_KEYWORD_SET),
});
continue;
}
Expand Down Expand Up @@ -444,6 +513,7 @@ export function computeDiagnostics(
startChar: lastTok.start,
endChar: lastTok.end,
message: `${activeKw.name}: record is missing the terminating '/'.`,
code: 'missing-record-terminator',
});
}

Expand Down
77 changes: 77 additions & 0 deletions vscode-extension/src/boilerplate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { buildKeywordSnippet, SnippetKeyword } from './boilerplate';

describe('buildKeywordSnippet', () => {
it('emits keyword + a terminated record with defaults for a fixed keyword', () => {
const entry: SnippetKeyword = {
name: 'EQLDIMS',
size_kind: 'fixed',
parameters: [
{ index: 1, value_type: 'INT', default: '1' },
{ index: 2, value_type: 'INT', default: '100' },
],
};
expect(buildKeywordSnippet(entry)).toBe('EQLDIMS\n ${1:1} ${2:100} /\n$0');
});

it('falls back to typed dummies when a parameter has no default', () => {
const entry: SnippetKeyword = {
name: 'FOO',
size_kind: 'fixed',
parameters: [
{ index: 1, value_type: 'INT', default: 'None' },
{ index: 2, value_type: 'DOUBLE' },
{ index: 3, value_type: 'STRING', default: 'None' },
],
};
expect(buildKeywordSnippet(entry)).toBe("FOO\n ${1:1} ${2:0.0} ${3:'STRING'} /\n$0");
});

it('honours the unquoted string style for STRING dummies', () => {
const entry: SnippetKeyword = {
name: 'FOO',
size_kind: 'fixed',
parameters: [{ index: 1, value_type: 'STRING' }],
};
expect(buildKeywordSnippet(entry, 'unquoted')).toBe('FOO\n ${1:STRING} /\n$0');
});

it('adds a standalone terminator line for a list keyword', () => {
const entry: SnippetKeyword = {
name: 'WELSPECS',
size_kind: 'list',
parameters: [{ index: 1, value_type: 'STRING', default: 'None' }],
};
expect(buildKeywordSnippet(entry)).toBe("WELSPECS\n ${1:'STRING'} /\n/\n$0");
});

it('emits a single value line for an array keyword', () => {
const entry: SnippetKeyword = {
name: 'PERMX',
size_kind: 'array',
parameters: [{ index: 1 }],
};
expect(buildKeywordSnippet(entry)).toBe('PERMX\n ${1:1*} /\n$0');
});

it('emits the bare keyword for an activation (none) keyword', () => {
const entry: SnippetKeyword = { name: 'UNIFOUT', size_kind: 'none', parameters: [] };
expect(buildKeywordSnippet(entry)).toBe('UNIFOUT\n$0');
});

it('emits the bare keyword when no parameter data is available', () => {
const entry: SnippetKeyword = { name: 'BARE', size_kind: 'fixed' };
expect(buildKeywordSnippet(entry)).toBe('BARE\n$0');
});

it('only emits the first record for a multi-record keyword', () => {
const entry: SnippetKeyword = {
name: 'MULTI',
size_kind: 'list',
parameters: [
{ index: 1, value_type: 'INT', default: '1', record: 1 },
{ index: 2, value_type: 'INT', default: '2', record: 2 },
],
};
expect(buildKeywordSnippet(entry)).toBe('MULTI\n ${1:1} /\n/\n$0');
});
});
Loading