Skip to content

Commit 716f5c9

Browse files
committed
Add symbol providers and inlay hints
Introduce new language feature providers and supporting utilities for Bison/Flex files. Adds definition, references, rename (prepare+apply), and inlay hint providers, plus a shared getWordAtPosition util and updates hover to use it. Server refactored to ensure parsed models via ensureModel and wire up the new LSP handlers. Also includes example lexer/parser (examples/calc.l, examples/calc.y) to illustrate usage and tests. Inlay hints resolve types for $$/$N using %type/%token and rule symbol information.
1 parent a82556f commit 716f5c9

9 files changed

Lines changed: 641 additions & 47 deletions

File tree

examples/calc.l

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
%{
2+
#include <stdio.h>
3+
#include "calc.tab.h"
4+
%}
5+
6+
%option noyywrap nounput noinput
7+
8+
%x COMMENT
9+
%s MATH
10+
11+
DIGIT [0-9]
12+
INTEGER {DIGIT}+
13+
FLOAT {DIGIT}+"."{DIGIT}*
14+
WS [ \t]+
15+
ID [a-zA-Z_][a-zA-Z0-9_]*
16+
17+
%%
18+
19+
"/*" { BEGIN(COMMENT); }
20+
<COMMENT>"*/" { BEGIN(INITIAL); }
21+
<COMMENT>. { /* skip */ }
22+
<COMMENT>\n { /* skip */ }
23+
24+
<MATH>{INTEGER} { return NUMBER; }
25+
<MATH>{FLOAT} { return NUMBER; }
26+
{INTEGER} { return NUMBER; }
27+
{FLOAT} { return NUMBER; }
28+
{ID} { return IDENTIFIER; }
29+
30+
"+" { return PLUS; }
31+
"-" { return MINUS; }
32+
"*" { return TIMES; }
33+
"/" { return DIVIDE; }
34+
"(" { return LPAREN; }
35+
")" { return RPAREN; }
36+
"=" { return ASSIGN; }
37+
";" { return SEMICOLON; }
38+
39+
{WS} { /* skip whitespace */ }
40+
\n { /* skip newlines */ }
41+
42+
. { fprintf(stderr, "Unknown char: %s\n", yytext); }
43+
44+
%%

examples/calc.y

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
%{
2+
#include <stdio.h>
3+
#include <math.h>
4+
%}
5+
6+
%token <double> NUMBER
7+
%token <std::string> IDENTIFIER
8+
%token PLUS "+"
9+
%token MINUS "-"
10+
%token TIMES "*"
11+
%token DIVIDE "/"
12+
%token LPAREN "("
13+
%token RPAREN ")"
14+
%token ASSIGN "="
15+
%token SEMICOLON ";"
16+
17+
%type <double> expr term factor program stmt
18+
19+
%left PLUS MINUS
20+
%left TIMES DIVIDE
21+
22+
%start program
23+
24+
%%
25+
26+
program : stmt SEMICOLON { $$ = $1; }
27+
| program stmt SEMICOLON { $$ = $2; }
28+
;
29+
30+
stmt : IDENTIFIER ASSIGN expr { $$ = $3; }
31+
| expr { $$ = $1; }
32+
;
33+
34+
expr : expr PLUS term { $$ = $1 + $3; }
35+
| expr MINUS term { $$ = $1 - $3; }
36+
| term { $$ = $1; }
37+
;
38+
39+
term : term TIMES factor { $$ = $1 * $3; }
40+
| term DIVIDE factor { $$ = $1 / $3; }
41+
| factor { $$ = $1; }
42+
;
43+
44+
factor : NUMBER { $$ = $1; }
45+
| LPAREN expr RPAREN { $$ = $2; }
46+
| MINUS factor { $$ = -$2; }
47+
;
48+
49+
%%
50+
51+
void yyerror(const char *s) {
52+
fprintf(stderr, "Error: %s\n", s);
53+
}

server/src/providers/definition.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { Location, Position } from 'vscode-languageserver';
2+
import { TextDocument } from 'vscode-languageserver-textdocument';
3+
import { DocumentModel, BisonDocument, FlexDocument, isBisonDocument } from '../parser/types';
4+
import { getWordAtPosition } from './utils';
5+
6+
export function getDefinition(
7+
doc: DocumentModel,
8+
textDoc: TextDocument,
9+
position: Position
10+
): Location | null {
11+
const lines = textDoc.getText().split(/\r?\n/);
12+
const line = lines[position.line] || '';
13+
14+
const wordInfo = getWordAtPosition(line, position.line, position.character);
15+
if (!wordInfo) return null;
16+
17+
const word = wordInfo.word;
18+
19+
// Skip directives and semantic values — no "definition" for those
20+
if (word.startsWith('%') || word.startsWith('$') || word.startsWith('@')) return null;
21+
22+
if (isBisonDocument(doc)) {
23+
return getBisonDefinition(doc, textDoc.uri, word);
24+
} else {
25+
return getFlexDefinition(doc, textDoc.uri, word);
26+
}
27+
}
28+
29+
function getBisonDefinition(doc: BisonDocument, uri: string, word: string): Location | null {
30+
// Token declaration (%token)
31+
const token = doc.tokens.get(word);
32+
if (token) return Location.create(uri, token.location);
33+
34+
// Non-terminal declaration (%type / %nterm)
35+
const nt = doc.nonTerminals.get(word);
36+
if (nt) return Location.create(uri, nt.location);
37+
38+
// Rule definition (name : ...)
39+
const rule = doc.rules.get(word);
40+
if (rule) return Location.create(uri, rule.location);
41+
42+
return null;
43+
}
44+
45+
function getFlexDefinition(doc: FlexDocument, uri: string, word: string): Location | null {
46+
// Start condition declaration (%x / %s)
47+
const sc = doc.startConditions.get(word);
48+
if (sc) return Location.create(uri, sc.location);
49+
50+
// Abbreviation definition (name pattern)
51+
const abbr = doc.abbreviations.get(word);
52+
if (abbr) return Location.create(uri, abbr.location);
53+
54+
return null;
55+
}

server/src/providers/hover.ts

Lines changed: 4 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
flexOptionDocs,
1010
flexBuiltinDocs,
1111
} from './documentation';
12+
import { getWordAtPosition as getWordUtil } from './utils';
1213

1314
export function getHover(
1415
doc: DocumentModel,
@@ -20,8 +21,9 @@ export function getHover(
2021
const line = lines[position.line] || '';
2122

2223
// Get the word under cursor (extended to handle %, $, @, dots, hyphens)
23-
const word = getWordAtPosition(line, position.character);
24-
if (!word) return null;
24+
const wordInfo = getWordUtil(line, position.line, position.character);
25+
if (!wordInfo) return null;
26+
const word = wordInfo.word;
2527

2628
if (isBisonDocument(doc)) {
2729
return getBisonHover(doc, word, line, position);
@@ -182,30 +184,3 @@ function makeHover(signature: string, description: string, example?: string): Ho
182184
return { contents: content };
183185
}
184186

185-
function getWordAtPosition(line: string, character: number): string | null {
186-
// Extended word pattern: allows %, $, @, dots, hyphens in identifiers
187-
// Try to match a %directive first
188-
let start = character;
189-
let end = character;
190-
191-
// Expand left
192-
while (start > 0 && isWordChar(line[start - 1])) {
193-
start--;
194-
}
195-
// Check for leading %, $, @
196-
if (start > 0 && (line[start - 1] === '%' || line[start - 1] === '$' || line[start - 1] === '@')) {
197-
start--;
198-
}
199-
200-
// Expand right
201-
while (end < line.length && isWordChar(line[end])) {
202-
end++;
203-
}
204-
205-
if (start === end) return null;
206-
return line.substring(start, end);
207-
}
208-
209-
function isWordChar(ch: string): boolean {
210-
return /[a-zA-Z0-9_.\-]/.test(ch);
211-
}

server/src/providers/inlayHints.ts

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import { InlayHint, InlayHintKind, Position, Range } from 'vscode-languageserver';
2+
import { TextDocument } from 'vscode-languageserver-textdocument';
3+
import { DocumentModel, BisonDocument, isBisonDocument } from '../parser/types';
4+
5+
/**
6+
* Provide inlay hints showing the inferred type of $1, $2, $$, etc.
7+
* Only applies to Bison files where %type / %token declare types.
8+
*/
9+
export function getInlayHints(
10+
doc: DocumentModel,
11+
textDoc: TextDocument,
12+
range: Range
13+
): InlayHint[] {
14+
// Inlay hints only make sense for Bison (typed semantic values $N)
15+
if (!isBisonDocument(doc)) return [];
16+
17+
return getBisonInlayHints(doc, textDoc, range);
18+
}
19+
20+
interface LineContext {
21+
ruleName: string;
22+
symbols: string[];
23+
}
24+
25+
function getBisonInlayHints(doc: BisonDocument, textDoc: TextDocument, range: Range): InlayHint[] {
26+
const hints: InlayHint[] = [];
27+
const text = textDoc.getText();
28+
const lines = text.split(/\r?\n/);
29+
30+
const rulesStart = doc.separators.length > 0 ? doc.separators[0] + 1 : lines.length;
31+
const rulesEnd = doc.separators.length > 1 ? doc.separators[1] : lines.length;
32+
33+
// Phase 1: Build a map of line → { ruleName, ordered symbols } for the rules section.
34+
// We walk through rule definitions and track which alternative each line belongs to.
35+
let currentRuleName: string | undefined;
36+
let currentSymbols: string[] = [];
37+
let braceDepth = 0;
38+
const lineContext = new Map<number, LineContext>();
39+
40+
for (let i = rulesStart; i < rulesEnd; i++) {
41+
const line = lines[i];
42+
const trimmed = line.trim();
43+
44+
if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('/*')) continue;
45+
46+
if (braceDepth === 0) {
47+
// Rule definition: name :
48+
const ruleDefMatch = trimmed.match(/^([a-zA-Z_][a-zA-Z0-9_.]*)\s*:/);
49+
if (ruleDefMatch) {
50+
currentRuleName = ruleDefMatch[1];
51+
const rest = trimmed.substring(ruleDefMatch[0].length);
52+
currentSymbols = extractOrderedSymbols(rest);
53+
} else if (trimmed.startsWith('|') && currentRuleName) {
54+
// New alternative
55+
currentSymbols = extractOrderedSymbols(trimmed.slice(1));
56+
} else if (currentRuleName && trimmed !== ';') {
57+
// Continuation line — may add more symbols before the action block
58+
const moreSymbols = extractOrderedSymbols(trimmed);
59+
if (moreSymbols.length > 0) {
60+
currentSymbols = [...currentSymbols, ...moreSymbols];
61+
}
62+
}
63+
64+
if (trimmed === ';') {
65+
currentRuleName = undefined;
66+
currentSymbols = [];
67+
}
68+
}
69+
70+
// Record context for this line (even inside action blocks, so $N can be resolved)
71+
if (currentRuleName) {
72+
lineContext.set(i, { ruleName: currentRuleName, symbols: [...currentSymbols] });
73+
}
74+
75+
// Track brace depth
76+
for (const ch of line) {
77+
if (ch === '{') braceDepth++;
78+
if (ch === '}') braceDepth = Math.max(0, braceDepth - 1);
79+
}
80+
81+
if (trimmed === ';' && braceDepth === 0) {
82+
currentRuleName = undefined;
83+
currentSymbols = [];
84+
}
85+
}
86+
87+
// Phase 2: Scan the requested range for $N / $$ patterns and resolve types.
88+
const startLine = Math.max(range.start.line, rulesStart);
89+
const endLine = Math.min(range.end.line, rulesEnd - 1);
90+
91+
for (let i = startLine; i <= endLine; i++) {
92+
const line = lines[i];
93+
if (!line) continue;
94+
95+
const ctx = lineContext.get(i);
96+
if (!ctx) continue;
97+
98+
// Find all $N and $$ occurrences
99+
const dollarMatches = line.matchAll(/\$(\$|\d+)/g);
100+
for (const m of dollarMatches) {
101+
const value = m[1];
102+
const col = m.index!;
103+
104+
let type: string | undefined;
105+
106+
if (value === '$') {
107+
// $$ → type of the rule's own non-terminal (LHS)
108+
type = resolveSymbolType(doc, ctx.ruleName);
109+
} else {
110+
const n = parseInt(value);
111+
if (n >= 1 && n <= ctx.symbols.length) {
112+
const symbol = ctx.symbols[n - 1];
113+
type = resolveSymbolType(doc, symbol);
114+
}
115+
}
116+
117+
if (type) {
118+
hints.push({
119+
position: Position.create(i, col + m[0].length),
120+
label: `/* <${type}> */`,
121+
kind: InlayHintKind.Type,
122+
paddingLeft: true,
123+
});
124+
}
125+
}
126+
}
127+
128+
return hints;
129+
}
130+
131+
/**
132+
* Extract the ordered list of grammar symbols from a production body,
133+
* stopping at the first action block `{` or semicolon `;`.
134+
*/
135+
function extractOrderedSymbols(text: string): string[] {
136+
let cleaned = text
137+
.replace(/"(?:[^"\\]|\\.)*"/g, ' ') // remove double-quoted strings
138+
.replace(/'(?:[^'\\]|\\.)*'/g, ' ') // remove single-quoted char literals
139+
.replace(/%prec\s+\S+/g, ' ') // remove %prec TOKEN
140+
.replace(/%empty/g, ' ') // remove %empty
141+
.replace(/\/\/.*$/g, ' '); // remove line comments
142+
143+
// Stop at first action block
144+
const braceIdx = cleaned.indexOf('{');
145+
if (braceIdx >= 0) cleaned = cleaned.substring(0, braceIdx);
146+
147+
// Stop at semicolon
148+
const semiIdx = cleaned.indexOf(';');
149+
if (semiIdx >= 0) cleaned = cleaned.substring(0, semiIdx);
150+
151+
const symbols: string[] = [];
152+
const matches = cleaned.matchAll(/\b([a-zA-Z_][a-zA-Z0-9_.]*)\b/g);
153+
for (const m of matches) {
154+
symbols.push(m[1]);
155+
}
156+
return symbols;
157+
}
158+
159+
/**
160+
* Resolve a symbol name to its declared type (from %token or %type/%nterm).
161+
*/
162+
function resolveSymbolType(doc: BisonDocument, symbol: string): string | undefined {
163+
const token = doc.tokens.get(symbol);
164+
if (token?.type) return token.type;
165+
166+
const nt = doc.nonTerminals.get(symbol);
167+
if (nt?.type) return nt.type;
168+
169+
return undefined;
170+
}

0 commit comments

Comments
 (0)