Skip to content

Commit 5c0a319

Browse files
committed
Add section/keyword outline tree view for navigation
Closes #41. Adds an explorer tree view that mirrors the deck structure (section -> keyword), letting users jump to any keyword with a click. - outline.ts: pure buildOutline() helper, unit-tested under jest. Reuses the column-1 keyword discipline from the folding provider (comments skipped, END closes a section, pre-section keywords attach to root). - OpmFlowOutlineProvider: TreeDataProvider with section/keyword icons and index summaries as tooltips; tree selection follows the cursor. - opm-flow.revealKeyword command jumps the editor to a keyword line. - package.json: contributes the opm-flow.outlineView tree to the explorer.
1 parent c0502ef commit 5c0a319

4 files changed

Lines changed: 250 additions & 0 deletions

File tree

vscode-extension/package.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,19 @@
3232
"activationEvents": [
3333
"onLanguage:opm-flow",
3434
"onView:opm-flow.docsView",
35+
"onView:opm-flow.outlineView",
3536
"onStartupFinished"
3637
],
3738
"main": "./out/extension",
3839
"contributes": {
3940
"views": {
4041
"explorer": [
42+
{
43+
"type": "tree",
44+
"id": "opm-flow.outlineView",
45+
"name": "OPM Flow Outline",
46+
"when": "resourceLangId == opm-flow"
47+
},
4148
{
4249
"type": "webview",
4350
"id": "opm-flow.docsView",

vscode-extension/src/extension.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
toggleLineComments,
2121
} from './formatting';
2222
import { computeDiagnostics } from './analysis';
23+
import { buildOutline, OutlineNode } from './outline';
2324
import { findFileReferences } from './links';
2425
import { parsePathsAliases, resolvePathAlias, prtCandidatePaths } from './paths';
2526
import { DEFAULT_DIAGNOSTICS_EXCLUDED_KEYWORDS } from './diagnostics-exclusions';
@@ -729,6 +730,81 @@ class OpmFlowFoldingRangeProvider implements vscode.FoldingRangeProvider {
729730
}
730731
}
731732

733+
// ---------------------------------------------------------------------------
734+
// Outline tree view — section -> keyword navigation
735+
// ---------------------------------------------------------------------------
736+
737+
class OpmFlowOutlineProvider implements vscode.TreeDataProvider<OutlineNode> {
738+
private readonly _onDidChangeTreeData = new vscode.EventEmitter<void>();
739+
readonly onDidChangeTreeData = this._onDidChangeTreeData.event;
740+
741+
private roots: OutlineNode[] = [];
742+
/** Source document of the current outline, for the reveal command. */
743+
private docUri?: vscode.Uri;
744+
745+
constructor(private readonly index: KeywordIndex) {}
746+
747+
/** Rebuild the outline from `doc` (or clear it for non-opm-flow docs). */
748+
refresh(doc?: vscode.TextDocument): void {
749+
if (doc?.languageId === 'opm-flow') {
750+
this.roots = buildOutline(doc.getText().split(/\r?\n/));
751+
this.docUri = doc.uri;
752+
} else {
753+
this.roots = [];
754+
this.docUri = undefined;
755+
}
756+
this._onDidChangeTreeData.fire();
757+
}
758+
759+
getChildren(node?: OutlineNode): OutlineNode[] {
760+
return node ? node.children : this.roots;
761+
}
762+
763+
/** Required for `TreeView.reveal` to locate a node. Sections are roots;
764+
* a keyword's parent is the section that contains it (undefined for
765+
* pre-section keywords attached directly to the root). */
766+
getParent(node: OutlineNode): OutlineNode | undefined {
767+
if (node.kind === 'section') return undefined;
768+
return this.roots.find(s => s.children.includes(node));
769+
}
770+
771+
getTreeItem(node: OutlineNode): vscode.TreeItem {
772+
const item = new vscode.TreeItem(
773+
node.name,
774+
node.kind === 'section'
775+
? vscode.TreeItemCollapsibleState.Expanded
776+
: vscode.TreeItemCollapsibleState.None,
777+
);
778+
item.iconPath = new vscode.ThemeIcon(
779+
node.kind === 'section' ? 'symbol-namespace' : 'symbol-keyword',
780+
);
781+
const summary = resolveKeyword(this.index, node.name)?.summary;
782+
if (summary) item.tooltip = summary;
783+
if (node.kind === 'keyword' && this.docUri) {
784+
item.command = {
785+
command: 'opm-flow.revealKeyword',
786+
title: 'Go to keyword',
787+
arguments: [this.docUri, node.line],
788+
};
789+
}
790+
return item;
791+
}
792+
793+
/** Find the deepest node whose declaration line is at or before `line`. */
794+
nodeAtLine(line: number): OutlineNode | undefined {
795+
let match: OutlineNode | undefined;
796+
for (const section of this.roots) {
797+
if (section.line > line) break;
798+
match = section;
799+
for (const kw of section.children) {
800+
if (kw.line > line) break;
801+
match = kw;
802+
}
803+
}
804+
return match;
805+
}
806+
}
807+
732808
// ---------------------------------------------------------------------------
733809
// File-reference link provider — INCLUDE / IMPORT / RESTART / GDFILE
734810
// ---------------------------------------------------------------------------
@@ -1258,6 +1334,57 @@ export function activate(context: vscode.ExtensionContext): void {
12581334
new OpmFlowFoldingRangeProvider()
12591335
);
12601336

1337+
// --- Outline tree view: section -> keyword navigation ---
1338+
const outlineProvider = new OpmFlowOutlineProvider(index);
1339+
const outlineView = vscode.window.createTreeView('opm-flow.outlineView', {
1340+
treeDataProvider: outlineProvider,
1341+
});
1342+
outlineProvider.refresh(vscode.window.activeTextEditor?.document);
1343+
1344+
const revealKeywordCommand = vscode.commands.registerCommand(
1345+
'opm-flow.revealKeyword',
1346+
async (uri: vscode.Uri, line: number) => {
1347+
const editor = await vscode.window.showTextDocument(uri);
1348+
const pos = new vscode.Position(line, 0);
1349+
editor.selection = new vscode.Selection(pos, pos);
1350+
editor.revealRange(
1351+
new vscode.Range(pos, pos),
1352+
vscode.TextEditorRevealType.InCenter,
1353+
);
1354+
},
1355+
);
1356+
1357+
const refreshOutline = debounce((doc: vscode.TextDocument) => {
1358+
outlineProvider.refresh(doc);
1359+
}, 250);
1360+
1361+
// Keep the tree's selection in sync with the cursor's active keyword.
1362+
let lastRevealedLine = -1;
1363+
const syncOutlineSelection = (editor: vscode.TextEditor): void => {
1364+
if (editor.document.languageId !== 'opm-flow' || !outlineView.visible) return;
1365+
const node = outlineProvider.nodeAtLine(editor.selection.active.line);
1366+
if (!node || node.line === lastRevealedLine) return;
1367+
lastRevealedLine = node.line;
1368+
void outlineView.reveal(node, { select: true, focus: false });
1369+
};
1370+
1371+
context.subscriptions.push(
1372+
outlineView,
1373+
revealKeywordCommand,
1374+
vscode.window.onDidChangeActiveTextEditor(editor => {
1375+
outlineProvider.refresh(editor?.document);
1376+
lastRevealedLine = -1;
1377+
}),
1378+
vscode.workspace.onDidChangeTextDocument(e => {
1379+
if (e.document === vscode.window.activeTextEditor?.document) {
1380+
refreshOutline(e.document);
1381+
}
1382+
}),
1383+
vscode.window.onDidChangeTextEditorSelection(e => {
1384+
syncOutlineSelection(e.textEditor);
1385+
}),
1386+
);
1387+
12611388
// --- Diagnostics: over-arity records and wrong-section keywords ---
12621389
const diagnostics = vscode.languages.createDiagnosticCollection('opm-flow');
12631390
const refreshDiags = debounce((doc: vscode.TextDocument) => {
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { buildOutline } from './outline';
2+
3+
const lines = (s: string): string[] => s.split('\n');
4+
5+
describe('buildOutline', () => {
6+
it('nests keywords under their section', () => {
7+
const roots = buildOutline(lines(
8+
'RUNSPEC\n' +
9+
'DIMENS\n' +
10+
'10 10 3 /\n' +
11+
'OIL\n' +
12+
'GRID\n' +
13+
'PERMX\n' +
14+
'300*100 /\n',
15+
));
16+
expect(roots.map(r => r.name)).toEqual(['RUNSPEC', 'GRID']);
17+
expect(roots[0].kind).toBe('section');
18+
expect(roots[0].children.map(c => c.name)).toEqual(['DIMENS', 'OIL']);
19+
expect(roots[1].children.map(c => c.name)).toEqual(['PERMX']);
20+
});
21+
22+
it('records the zero-based line of each node', () => {
23+
const roots = buildOutline(lines('RUNSPEC\nDIMENS\n10 10 3 /\n'));
24+
expect(roots[0].line).toBe(0);
25+
expect(roots[0].children[0].line).toBe(1);
26+
});
27+
28+
it('skips comment lines and record/value lines', () => {
29+
const roots = buildOutline(lines(
30+
'-- a comment\n' +
31+
'RUNSPEC\n' +
32+
'-- another\n' +
33+
'DIMENS\n' +
34+
'10 10 3 /\n',
35+
));
36+
expect(roots[0].children.map(c => c.name)).toEqual(['DIMENS']);
37+
});
38+
39+
it('does not treat indented uppercase tokens as keywords', () => {
40+
const roots = buildOutline(lines(
41+
'RUNSPEC\n' +
42+
'EQLOPTS\n' +
43+
' THPRES /\n',
44+
));
45+
expect(roots[0].children.map(c => c.name)).toEqual(['EQLOPTS']);
46+
});
47+
48+
it('attaches pre-section keywords to the root', () => {
49+
const roots = buildOutline(lines('INCLUDE\n \'grid.inc\' /\nGRID\nPERMX\n'));
50+
expect(roots[0]).toMatchObject({ name: 'INCLUDE', kind: 'keyword' });
51+
expect(roots[1].name).toBe('GRID');
52+
});
53+
54+
it('closes the active section on END', () => {
55+
const roots = buildOutline(lines('SCHEDULE\nTSTEP\n10 /\nEND\nFOO\n'));
56+
expect(roots[0].children.map(c => c.name)).toEqual(['TSTEP']);
57+
// FOO after END falls back to a root-level keyword node.
58+
expect(roots.map(r => r.name)).toEqual(['SCHEDULE', 'FOO']);
59+
});
60+
});

vscode-extension/src/outline.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// ---------------------------------------------------------------------------
2+
// Pure document-outline model for the keyword navigation tree.
3+
// Kept free of vscode imports so it can be unit-tested under jest, mirroring
4+
// the section -> keyword structure used by the folding range provider.
5+
// ---------------------------------------------------------------------------
6+
7+
import { KEYWORD_LINE_COL1_RE, SECTION_KEYWORD_SET } from './formatting';
8+
9+
export interface OutlineNode {
10+
/** Keyword token as written in the deck (always column-1, uppercase). */
11+
name: string;
12+
/** Zero-based document line where the keyword starts — used for reveal. */
13+
line: number;
14+
kind: 'section' | 'keyword';
15+
/** Populated for section nodes; always empty for keyword leaves. */
16+
children: OutlineNode[];
17+
}
18+
19+
/**
20+
* Build a two-level outline from deck lines:
21+
*
22+
* section (RUNSPEC, GRID, …) -> keyword leaves between section headers
23+
*
24+
* The walk mirrors `OpmFlowFoldingRangeProvider`: only column-1 declarations
25+
* (`KEYWORD_LINE_COL1_RE`) count, comment lines are skipped, and `END` closes
26+
* the current section so any trailing keywords fall back to the synthetic
27+
* root. Keywords appearing before the first section header (e.g. in an
28+
* include file with no section marker) are attached directly to the root so
29+
* nothing is dropped.
30+
*/
31+
export function buildOutline(lines: string[]): OutlineNode[] {
32+
const roots: OutlineNode[] = [];
33+
let current: OutlineNode | null = null;
34+
35+
for (let i = 0; i < lines.length; i++) {
36+
const text = lines[i];
37+
if (text.trim().startsWith('--')) continue;
38+
39+
const m = text.match(KEYWORD_LINE_COL1_RE);
40+
if (!m) continue;
41+
const kw = m[1];
42+
43+
if (SECTION_KEYWORD_SET.has(kw)) {
44+
current = { name: kw, line: i, kind: 'section', children: [] };
45+
roots.push(current);
46+
} else if (kw === 'END') {
47+
current = null;
48+
} else {
49+
const node: OutlineNode = { name: kw, line: i, kind: 'keyword', children: [] };
50+
if (current) current.children.push(node);
51+
else roots.push(node);
52+
}
53+
}
54+
55+
return roots;
56+
}

0 commit comments

Comments
 (0)