Skip to content

Commit 725275e

Browse files
authored
Merge pull request #29 from theodevelop/dev
feat: #line-based navigation between source and generated files #27
2 parents e0c6aa4 + 42f2791 commit 725275e

5 files changed

Lines changed: 332 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,17 @@
22

33
All notable changes to the **Bison/Flex Language Support** extension will be documented in this file.
44

5+
## [1.5.0] - 2026-04-01
6+
7+
### Added
8+
9+
- **`#line`-based navigation** (#27): two new commands to jump between Bison/Flex grammar sources and their generated C files:
10+
- **`Bison/Flex: Show in Source`** — from a generated `.tab.c` / `lex.yy.c` file, reads the nearest `#line N "file.y"` directive above the cursor and opens the grammar source at the correct line. Appears in the context menu only when a generated file is detected.
11+
- **`Bison/Flex: Show in Generated File`** — from a `.y` / `.l` source, locates the generated file (using `bisonFlex.buildDirectory` setting, CMake detection, Makefile detection, same-directory fallback, then workspace-wide search) and navigates to the matching line. A QuickPick is shown when multiple candidates are found.
12+
- New setting `bisonFlex.buildDirectory`: optional path to the build output directory, used by **Show in Generated File** to locate generated files when they are not in the same directory as the source.
13+
14+
--
15+
516
## [1.4.1] - 2026-03-31
617

718
### Fixed

client/src/extension.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as path from 'path';
22
import * as cp from 'child_process';
33
import * as fs from 'fs';
4+
import { isGeneratedFile, showInSource, showInGenerated } from './lineDirectiveNavigation';
45
import {
56
ExtensionContext,
67
workspace,
@@ -462,6 +463,29 @@ export function activate(context: ExtensionContext): void {
462463
commands.registerCommand('bisonFlex.noOp', () => { /* intentionally empty */ })
463464
);
464465

466+
// ── Commands: #line navigation ────────────────────────────────────────────
467+
context.subscriptions.push(
468+
commands.registerCommand('bisonFlex.showInSource', () => void showInSource())
469+
);
470+
471+
context.subscriptions.push(
472+
commands.registerCommand('bisonFlex.showInGenerated', () => void showInGenerated())
473+
);
474+
475+
// Set context variable so the "Show in Source" menu entry only appears in generated files
476+
function updateGeneratedFileContext(): void {
477+
const editor = window.activeTextEditor;
478+
if (!editor) {
479+
void commands.executeCommand('setContext', 'bisonFlexIsGeneratedFile', false);
480+
return;
481+
}
482+
const text = editor.document.getText();
483+
void commands.executeCommand('setContext', 'bisonFlexIsGeneratedFile', isGeneratedFile(text));
484+
}
485+
486+
context.subscriptions.push(window.onDidChangeActiveTextEditor(updateGeneratedFileContext));
487+
updateGeneratedFileContext();
488+
465489
// ── Command: Show References (triggered by "N references" Code Lenses) ────
466490
// Args: [uriString, { line, character }]
467491
context.subscriptions.push(
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
import * as fs from 'fs';
2+
import * as path from 'path';
3+
import { window, workspace, Uri, Position, Range, ViewColumn } from 'vscode';
4+
5+
// ── Detection ─────────────────────────────────────────────────────────────────
6+
7+
/** Returns true if the file header looks like a Bison or Flex generated C file. */
8+
export function isGeneratedFile(text: string): boolean {
9+
const header = text.slice(0, 600);
10+
return (
11+
header.includes('/* A Bison parser, made by GNU Bison') ||
12+
header.includes('/* Generated by GNU Bison') ||
13+
header.includes('/* Generated by flex') ||
14+
header.includes('/* A lexical scanner generated by flex')
15+
);
16+
}
17+
18+
// ── Direction 1: generated → source ──────────────────────────────────────────
19+
20+
interface LineDirective {
21+
sourceLine: number; // 1-based line number in the source file
22+
sourceFile: string;
23+
}
24+
25+
/** Scan backwards from cursorLine to find the nearest #line N "file" directive. */
26+
function findNearestLineDirective(lines: string[], cursorLine: number): LineDirective | null {
27+
for (let i = cursorLine; i >= 0; i--) {
28+
const m = lines[i].match(/^#line\s+(\d+)\s+"([^"]+)"/);
29+
if (m) {
30+
return { sourceLine: parseInt(m[1], 10), sourceFile: m[2] };
31+
}
32+
}
33+
return null;
34+
}
35+
36+
/** Navigate from the generated C file at cursorLine to the original grammar source. */
37+
export async function showInSource(): Promise<void> {
38+
const editor = window.activeTextEditor;
39+
if (!editor) return;
40+
41+
const text = editor.document.getText();
42+
43+
if (!isGeneratedFile(text)) {
44+
window.showWarningMessage('This command is only available inside Bison or Flex generated files.');
45+
return;
46+
}
47+
48+
const lines = text.split('\n');
49+
const cursorLine = editor.selection.active.line;
50+
const directive = findNearestLineDirective(lines, cursorLine);
51+
52+
if (!directive) {
53+
window.showWarningMessage('No #line directive found above the cursor.');
54+
return;
55+
}
56+
57+
// Resolve path: may be absolute or relative to the generated file's directory
58+
let sourcePath = directive.sourceFile.replace(/\\\\/g, '/').replace(/\\/g, '/');
59+
if (!path.isAbsolute(sourcePath)) {
60+
sourcePath = path.resolve(path.dirname(editor.document.uri.fsPath), sourcePath);
61+
}
62+
63+
if (!fs.existsSync(sourcePath)) {
64+
const baseName = path.basename(sourcePath);
65+
const found = await findInWorkspace(baseName);
66+
if (!found) {
67+
window.showErrorMessage(`Source file not found: ${sourcePath}`);
68+
return;
69+
}
70+
sourcePath = found;
71+
}
72+
73+
const targetLine = Math.max(0, directive.sourceLine - 1); // convert to 0-based
74+
const doc = await workspace.openTextDocument(Uri.file(sourcePath));
75+
const pos = new Position(targetLine, 0);
76+
await window.showTextDocument(doc, { selection: new Range(pos, pos), viewColumn: ViewColumn.Active });
77+
}
78+
79+
// ── Direction 2: source → generated ──────────────────────────────────────────
80+
81+
const BISON_CANDIDATES = (base: string, dir: string): string[] => [
82+
path.join(dir, base + '.tab.c'),
83+
path.join(dir, base + '.tab.cpp'),
84+
path.join(dir, base + '.tab.cc'),
85+
];
86+
87+
const FLEX_CANDIDATES = (base: string, dir: string): string[] => [
88+
path.join(dir, 'lex.yy.c'),
89+
path.join(dir, 'lex.yy.cc'),
90+
path.join(dir, base + '.yy.c'),
91+
path.join(dir, base + '.yy.cpp'),
92+
];
93+
94+
/** Scan CMakeLists.txt up the directory tree and return a build directory hint. */
95+
function findCmakeBuildDir(sourceFilePath: string): string | undefined {
96+
const fileName = path.basename(sourceFilePath);
97+
let dir = path.dirname(sourceFilePath);
98+
99+
for (let depth = 0; depth < 6; depth++) {
100+
const cmakePath = path.join(dir, 'CMakeLists.txt');
101+
if (fs.existsSync(cmakePath)) {
102+
try {
103+
const content = fs.readFileSync(cmakePath, 'utf-8').replace(/#[^\n]*/g, '');
104+
// Only proceed if this CMakeLists references our file
105+
if (!content.includes(fileName)) break;
106+
const re = /(?:BISON_TARGET|FLEX_TARGET)\s*\(\s*\w+\s+\S+\s+(\S+)/gi;
107+
let m: RegExpExecArray | null;
108+
while ((m = re.exec(content)) !== null) {
109+
const outputArg = m[1].replace(/^["']|["']$/g, '');
110+
// Strip CMake variables (e.g. ${CMAKE_CURRENT_BINARY_DIR})
111+
const stripped = outputArg.replace(/\$\{[^}]+\}\/?/g, '').trim();
112+
if (stripped) {
113+
return path.dirname(path.join(dir, stripped));
114+
}
115+
// If the output arg is purely a CMake variable (e.g. ${CMAKE_CURRENT_BINARY_DIR}/foo.tab.c),
116+
// try the standard build subdirectory convention
117+
return path.join(dir, 'build');
118+
}
119+
} catch {
120+
// ignore unreadable files
121+
}
122+
}
123+
const parent = path.dirname(dir);
124+
if (parent === dir) break;
125+
dir = parent;
126+
}
127+
return undefined;
128+
}
129+
130+
/** Scan Makefile for generated output paths. */
131+
function findMakefileBuildDir(sourceFilePath: string): string | undefined {
132+
const dir = path.dirname(sourceFilePath);
133+
const base = path.basename(sourceFilePath, path.extname(sourceFilePath));
134+
135+
for (const name of ['Makefile', 'makefile', 'GNUmakefile']) {
136+
const mkPath = path.join(dir, name);
137+
if (!fs.existsSync(mkPath)) continue;
138+
try {
139+
const content = fs.readFileSync(mkPath, 'utf-8');
140+
// Look for lines like: build/parser.tab.c or obj/lex.yy.c
141+
const re = new RegExp(`([^\\s:]+[\\/\\\\])?${base}\\.tab\\.[ch]|lex\\.yy\\.c`, 'g');
142+
const m = re.exec(content);
143+
if (m && m[1]) {
144+
return path.resolve(dir, m[1].replace(/[\\/]$/, ''));
145+
}
146+
} catch {
147+
// ignore
148+
}
149+
}
150+
return undefined;
151+
}
152+
153+
/** Locate the generated file corresponding to a grammar source file. */
154+
async function findGeneratedFile(sourceFilePath: string): Promise<string | null> {
155+
const config = workspace.getConfiguration('bisonFlex');
156+
const settingBuildDir = config.get<string>('buildDirectory', '').trim() || undefined;
157+
const sourceDir = path.dirname(sourceFilePath);
158+
const base = path.basename(sourceFilePath, path.extname(sourceFilePath));
159+
const ext = path.extname(sourceFilePath).toLowerCase();
160+
const isBison = ['.y', '.yy', '.ypp', '.bison'].includes(ext);
161+
const candidates = isBison ? BISON_CANDIDATES : FLEX_CANDIDATES;
162+
163+
const dirsToTry: string[] = [];
164+
if (settingBuildDir) dirsToTry.push(settingBuildDir);
165+
const cmakeDir = findCmakeBuildDir(sourceFilePath);
166+
if (cmakeDir) dirsToTry.push(cmakeDir);
167+
const makeDir = findMakefileBuildDir(sourceFilePath);
168+
if (makeDir) dirsToTry.push(makeDir);
169+
dirsToTry.push(sourceDir);
170+
171+
for (const dir of dirsToTry) {
172+
for (const c of candidates(base, dir)) {
173+
if (fs.existsSync(c)) return c;
174+
}
175+
}
176+
177+
// Workspace-wide search as last resort — let user pick if ambiguous
178+
const pattern = isBison ? `**/${base}.tab.{c,cpp,cc}` : `**/lex.yy.{c,cc}`;
179+
const found = await workspace.findFiles(pattern, '**/node_modules/**', 10);
180+
if (found.length === 0) return null;
181+
if (found.length === 1) return found[0].fsPath;
182+
183+
const pick = await window.showQuickPick(
184+
found.map(u => ({ label: workspace.asRelativePath(u), fsPath: u.fsPath })),
185+
{ placeHolder: 'Multiple generated files found — select one' }
186+
);
187+
return pick ? pick.fsPath : null;
188+
}
189+
190+
/**
191+
* In the generated file, find the output line that corresponds to `sourceLine` (1-based)
192+
* in `sourceFilePath`. Returns the 0-based line index in the generated file.
193+
*/
194+
function findLineInGenerated(generatedLines: string[], sourceFilePath: string, sourceLine: number): number | null {
195+
const sourceBase = path.basename(sourceFilePath).toLowerCase();
196+
let bestGenLine = -1;
197+
let bestSrcLine = -1;
198+
199+
for (let i = 0; i < generatedLines.length; i++) {
200+
const m = generatedLines[i].match(/^#line\s+(\d+)\s+"([^"]+)"/);
201+
if (!m) continue;
202+
const dirSrcLine = parseInt(m[1], 10);
203+
const dirFile = path.basename(m[2]).toLowerCase();
204+
if (dirFile !== sourceBase) continue;
205+
if (dirSrcLine <= sourceLine) {
206+
bestGenLine = i;
207+
bestSrcLine = dirSrcLine;
208+
} else {
209+
break;
210+
}
211+
}
212+
213+
if (bestGenLine === -1) return null;
214+
return bestGenLine + (sourceLine - bestSrcLine);
215+
}
216+
217+
/** Navigate from a .y/.l source line to the corresponding line in the generated C file. */
218+
export async function showInGenerated(): Promise<void> {
219+
const editor = window.activeTextEditor;
220+
if (!editor) return;
221+
222+
const sourceFilePath = editor.document.uri.fsPath;
223+
const sourceLine = editor.selection.active.line + 1; // 1-based
224+
225+
const generatedPath = await findGeneratedFile(sourceFilePath);
226+
if (!generatedPath) {
227+
window.showErrorMessage(
228+
'Generated file not found. Compile the grammar first, or set bisonFlex.buildDirectory in settings.'
229+
);
230+
return;
231+
}
232+
233+
let generatedText: string;
234+
try {
235+
generatedText = fs.readFileSync(generatedPath, 'utf-8');
236+
} catch {
237+
window.showErrorMessage(`Cannot read generated file: ${generatedPath}`);
238+
return;
239+
}
240+
241+
const generatedLines = generatedText.split('\n');
242+
const targetLine = findLineInGenerated(generatedLines, sourceFilePath, sourceLine);
243+
const pos = new Position(targetLine !== null ? targetLine : 0, 0);
244+
const doc = await workspace.openTextDocument(Uri.file(generatedPath));
245+
await window.showTextDocument(doc, { selection: new Range(pos, pos), viewColumn: ViewColumn.Beside });
246+
247+
if (targetLine === null) {
248+
window.showWarningMessage(
249+
`Opened ${path.basename(generatedPath)}, but could not locate the exact line for source line ${sourceLine}.`
250+
);
251+
}
252+
}
253+
254+
// ── Helpers ───────────────────────────────────────────────────────────────────
255+
256+
async function findInWorkspace(baseName: string): Promise<string | null> {
257+
const found = await workspace.findFiles(`**/${baseName}`, '**/node_modules/**', 5);
258+
return found.length > 0 ? found[0].fsPath : null;
259+
}

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "bison-flex-lang",
33
"displayName": "Bison/Flex Language Support",
44
"description": "Full-featured language support for GNU Bison (.y, .yy) and Flex/RE-flex (.l, .ll) — syntax highlighting with embedded C/C++, real-time diagnostics, intelligent autocompletion, and hover documentation for all directives.",
5-
"version": "1.4.1",
5+
"version": "1.5.0",
66
"publisher": "theodevelop",
77
"license": "MIT",
88
"repository": {
@@ -159,9 +159,31 @@
159159
{
160160
"command": "bisonFlex.noOp",
161161
"title": "Bison/Flex: No-Op (internal)"
162+
},
163+
{
164+
"command": "bisonFlex.showInSource",
165+
"title": "Bison/Flex: Show in Source",
166+
"icon": "$(go-to-file)"
167+
},
168+
{
169+
"command": "bisonFlex.showInGenerated",
170+
"title": "Bison/Flex: Show in Generated File",
171+
"icon": "$(go-to-file)"
162172
}
163173
],
164174
"menus": {
175+
"editor/context": [
176+
{
177+
"command": "bisonFlex.showInSource",
178+
"when": "bisonFlexIsGeneratedFile",
179+
"group": "navigation@100"
180+
},
181+
{
182+
"command": "bisonFlex.showInGenerated",
183+
"when": "editorLangId == bison || editorLangId == flex",
184+
"group": "navigation@100"
185+
}
186+
],
165187
"commandPalette": [
166188
{
167189
"command": "bisonFlex.compileBison",
@@ -206,6 +228,14 @@
206228
{
207229
"command": "bisonFlex.noOp",
208230
"when": "false"
231+
},
232+
{
233+
"command": "bisonFlex.showInSource",
234+
"when": "bisonFlexIsGeneratedFile"
235+
},
236+
{
237+
"command": "bisonFlex.showInGenerated",
238+
"when": "editorLangId == bison || editorLangId == flex"
209239
}
210240
]
211241
},
@@ -265,6 +295,12 @@
265295
"default": [],
266296
"scope": "resource",
267297
"markdownDescription": "Diagnostic codes to silence entirely, regardless of version settings. Example: `[\"bison/shift-reduce\", \"flex/missing-yywrap\"]`.\n\nSee all available codes in the [Diagnostic Codes reference](https://github.com/theodevelop/bison-flex-lang/wiki/Diagnostic-Codes)."
298+
},
299+
"bisonFlex.buildDirectory": {
300+
"type": "string",
301+
"default": "",
302+
"scope": "resource",
303+
"markdownDescription": "Path to the directory where Bison/Flex generated files are placed (e.g. `\"build\"` or `\"${workspaceFolder}/out\"`). Used by **Bison/Flex: Show in Generated File** to locate `.tab.c` / `lex.yy.c`. If empty, the extension tries CMake detection, Makefile detection, and the same directory as the source file."
268304
}
269305
}
270306
}

0 commit comments

Comments
 (0)