Skip to content

Commit ea27ece

Browse files
committed
feat(fsharp): route .fsi files through the dedicated signature grammar
The tree-sitter-fsharp package ships two distinct grammars: LANGUAGE_FSHARP for .fs / .fsx source files and LANGUAGE_SIGNATURE for .fsi signature files. Both engines previously routed all three extensions through the source grammar, so bare `val` declarations in .fsi files surfaced as ERROR nodes and yielded no symbols. This change adds a separate `fsharp-signature` language for .fsi: * native: new `FSharpSignature` LanguageKind wired to LANGUAGE_SIGNATURE * WASM: new `fsharp-signature` registry entry using tree-sitter-fsharp_signature.wasm (build script now produces it) * shared F# extractor handles `value_definition` only when its first child is the `val` keyword, distinguishing signature `val foo : type` from source `let foo = ...` * function vs variable kind is inferred from the type shape; supports both `function_type` (WASM npm 0.1.0) and `curried_spec` (cargo 0.3.0) node shapes for engine parity docs check acknowledged: README's F# row already covers .fs/.fsx/.fsi and the user-facing language count is unchanged; fsharp-signature is an internal id that mirrors how ocaml-interface backs .mli files. Closes #1114
1 parent 46e7a42 commit ea27ece

10 files changed

Lines changed: 311 additions & 7 deletions

File tree

crates/codegraph-core/src/extractors/fsharp.rs

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ fn match_fsharp_node(node: &Node, source: &[u8], symbols: &mut FileSymbols, _dep
2424
"import_decl" => handle_import_decl(node, source, symbols),
2525
"application_expression" => handle_application(node, source, symbols),
2626
"dot_expression" => handle_dot_expression(node, source, symbols),
27+
"value_definition" => handle_value_definition(node, source, symbols),
2728
_ => {}
2829
}
2930
}
@@ -300,3 +301,154 @@ fn handle_dot_expression(node: &Node, source: &[u8], symbols: &mut FileSymbols)
300301
});
301302
}
302303
}
304+
305+
/// Handle `val name : type` declarations in `.fsi` signature files.
306+
///
307+
/// The signature grammar reuses the `value_definition` node kind for `val`
308+
/// declarations, distinguished from the source grammar's `let` bindings by
309+
/// the first child being the literal `val` keyword. Source-file
310+
/// `value_definition` nodes (which start with `let`) are intentionally
311+
/// ignored here to preserve `.fs` extractor parity.
312+
fn handle_value_definition(node: &Node, source: &[u8], symbols: &mut FileSymbols) {
313+
let first = match node.child(0) {
314+
Some(c) => c,
315+
None => return,
316+
};
317+
if first.kind() != "val" {
318+
return;
319+
}
320+
321+
let decl_left = match find_child(node, "value_declaration_left") {
322+
Some(n) => n,
323+
None => return,
324+
};
325+
let name = match extract_value_name(&decl_left, source) {
326+
Some(n) => n,
327+
None => return,
328+
};
329+
330+
let kind = if has_function_type(node) { "function" } else { "variable" };
331+
let module_name = enclosing_module_name(node, source);
332+
let qualified = match module_name {
333+
Some(m) => format!("{}.{}", m, name),
334+
None => name,
335+
};
336+
337+
symbols.definitions.push(Definition {
338+
name: qualified,
339+
kind: kind.to_string(),
340+
line: start_line(node),
341+
end_line: Some(end_line(node)),
342+
decorators: None,
343+
complexity: None,
344+
cfg: None,
345+
children: None,
346+
});
347+
}
348+
349+
fn extract_value_name(decl_left: &Node, source: &[u8]) -> Option<String> {
350+
let pattern = find_child(decl_left, "identifier_pattern")?;
351+
let ident = find_child(&pattern, "long_identifier_or_op")
352+
.and_then(|n| find_child(&n, "identifier"))
353+
.or_else(|| find_child(&pattern, "identifier"))?;
354+
Some(node_text(&ident, source).to_string())
355+
}
356+
357+
fn has_function_type(node: &Node) -> bool {
358+
// The two grammar versions use different node shapes for type signatures:
359+
//
360+
// • WASM (tree-sitter-fsharp npm 0.1.0): `function_type` is the explicit
361+
// function-type kind, only present for `a -> b` types.
362+
// • Native (tree-sitter-fsharp 0.3.0): every type signature is wrapped
363+
// in `curried_spec`. For a function it contains `arguments_spec`
364+
// children; for a plain value (e.g. `val pi : float`) it wraps a
365+
// single `simple_type`.
366+
//
367+
// Treat both engines consistently by classifying as a function whenever
368+
// a function_type node appears OR a curried_spec contains `arguments_spec`.
369+
for i in 0..node.child_count() {
370+
let Some(child) = node.child(i) else { continue };
371+
match child.kind() {
372+
"function_type" => return true,
373+
"curried_spec" => {
374+
for j in 0..child.child_count() {
375+
if let Some(g) = child.child(j) {
376+
if g.kind() == "arguments_spec" {
377+
return true;
378+
}
379+
}
380+
}
381+
}
382+
_ => {}
383+
}
384+
}
385+
false
386+
}
387+
388+
#[cfg(test)]
389+
mod tests {
390+
use super::*;
391+
use crate::extractors::SymbolExtractor;
392+
use tree_sitter::Parser;
393+
394+
fn parse_source(code: &str) -> FileSymbols {
395+
let mut parser = Parser::new();
396+
parser
397+
.set_language(&tree_sitter_fsharp::LANGUAGE_FSHARP.into())
398+
.unwrap();
399+
let tree = parser.parse(code.as_bytes(), None).unwrap();
400+
FSharpExtractor.extract(&tree, code.as_bytes(), "test.fs")
401+
}
402+
403+
fn parse_signature(code: &str) -> FileSymbols {
404+
let mut parser = Parser::new();
405+
parser
406+
.set_language(&tree_sitter_fsharp::LANGUAGE_SIGNATURE.into())
407+
.unwrap();
408+
let tree = parser.parse(code.as_bytes(), None).unwrap();
409+
FSharpExtractor.extract(&tree, code.as_bytes(), "test.fsi")
410+
}
411+
412+
#[test]
413+
fn signature_extracts_val_declarations() {
414+
let s = parse_signature("namespace MyApp.Domain\n\nval add : int -> int -> int\nval pi : float\n");
415+
let add = s
416+
.definitions
417+
.iter()
418+
.find(|d| d.name == "add")
419+
.expect("val add should be extracted");
420+
assert_eq!(add.kind, "function");
421+
let pi = s
422+
.definitions
423+
.iter()
424+
.find(|d| d.name == "pi")
425+
.expect("val pi should be extracted");
426+
assert_eq!(pi.kind, "variable");
427+
}
428+
429+
#[test]
430+
fn signature_extracts_bare_val_declarations() {
431+
let s = parse_signature("val negate : int -> int\nval count : int\n");
432+
assert!(s
433+
.definitions
434+
.iter()
435+
.any(|d| d.name == "negate" && d.kind == "function"));
436+
assert!(s
437+
.definitions
438+
.iter()
439+
.any(|d| d.name == "count" && d.kind == "variable"));
440+
}
441+
442+
#[test]
443+
fn source_grammar_does_not_extract_let_bindings_as_val() {
444+
// `let x = 5` is a value_definition in the source grammar but its
445+
// first child is `let`, not `val`. Our handler must not extract it
446+
// (preserves prior `.fs` extraction parity — only function_declaration_left
447+
// produces definitions in source files).
448+
let s = parse_source("module M\n\nlet x = 5\n");
449+
assert!(
450+
s.definitions.iter().all(|d| d.name != "x"),
451+
"let bindings in .fs files must not be extracted as val definitions"
452+
);
453+
}
454+
}

crates/codegraph-core/src/extractors/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ pub fn extract_symbols_with_opts(
140140
LanguageKind::Ocaml | LanguageKind::OcamlInterface => {
141141
ocaml::OcamlExtractor.extract_with_opts(tree, source, file_path, include_ast_nodes)
142142
}
143-
LanguageKind::FSharp => {
143+
LanguageKind::FSharp | LanguageKind::FSharpSignature => {
144144
fsharp::FSharpExtractor.extract_with_opts(tree, source, file_path, include_ast_nodes)
145145
}
146146
LanguageKind::ObjC => {

crates/codegraph-core/src/parser_registry.rs

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ pub enum LanguageKind {
2828
Ocaml,
2929
OcamlInterface,
3030
FSharp,
31+
FSharpSignature,
3132
ObjC,
3233
Gleam,
3334
Julia,
@@ -70,6 +71,7 @@ impl LanguageKind {
7071
Self::Ocaml => "ocaml",
7172
Self::OcamlInterface => "ocaml-interface",
7273
Self::FSharp => "fsharp",
74+
Self::FSharpSignature => "fsharp-signature",
7375
Self::ObjC => "objc",
7476
Self::Gleam => "gleam",
7577
Self::Julia => "julia",
@@ -120,7 +122,8 @@ impl LanguageKind {
120122
"hs" => Some(Self::Haskell),
121123
"ml" => Some(Self::Ocaml),
122124
"mli" => Some(Self::OcamlInterface),
123-
"fs" | "fsx" | "fsi" => Some(Self::FSharp),
125+
"fs" | "fsx" => Some(Self::FSharp),
126+
"fsi" => Some(Self::FSharpSignature),
124127
"m" => Some(Self::ObjC),
125128
"gleam" => Some(Self::Gleam),
126129
"jl" => Some(Self::Julia),
@@ -165,6 +168,7 @@ impl LanguageKind {
165168
"ocaml" => Some(Self::Ocaml),
166169
"ocaml-interface" => Some(Self::OcamlInterface),
167170
"fsharp" => Some(Self::FSharp),
171+
"fsharp-signature" => Some(Self::FSharpSignature),
168172
"objc" => Some(Self::ObjC),
169173
"gleam" => Some(Self::Gleam),
170174
"julia" => Some(Self::Julia),
@@ -207,6 +211,7 @@ impl LanguageKind {
207211
Self::Ocaml => tree_sitter_ocaml::LANGUAGE_OCAML.into(),
208212
Self::OcamlInterface => tree_sitter_ocaml::LANGUAGE_OCAML_INTERFACE.into(),
209213
Self::FSharp => tree_sitter_fsharp::LANGUAGE_FSHARP.into(),
214+
Self::FSharpSignature => tree_sitter_fsharp::LANGUAGE_SIGNATURE.into(),
210215
Self::ObjC => tree_sitter_objc::LANGUAGE.into(),
211216
Self::Gleam => tree_sitter_gleam::LANGUAGE.into(),
212217
Self::Julia => tree_sitter_julia::LANGUAGE.into(),
@@ -232,8 +237,8 @@ impl LanguageKind {
232237
&[
233238
JavaScript, TypeScript, Tsx, Python, Go, Rust, Java, CSharp, Ruby, Php, Hcl, C,
234239
Cpp, Kotlin, Swift, Scala, Bash, Elixir, Lua, Dart, Zig, Haskell, Ocaml,
235-
OcamlInterface, FSharp, ObjC, Gleam, Julia, Cuda, Clojure, Erlang, Groovy, R, Solidity,
236-
Verilog,
240+
OcamlInterface, FSharp, FSharpSignature, ObjC, Gleam, Julia, Cuda, Clojure, Erlang,
241+
Groovy, R, Solidity, Verilog,
237242
]
238243
}
239244
}
@@ -304,6 +309,7 @@ mod tests {
304309
| LanguageKind::Ocaml
305310
| LanguageKind::OcamlInterface
306311
| LanguageKind::FSharp
312+
| LanguageKind::FSharpSignature
307313
| LanguageKind::ObjC
308314
| LanguageKind::Gleam
309315
| LanguageKind::Julia
@@ -320,7 +326,7 @@ mod tests {
320326
// Because both checks require the same manual update, they reinforce
321327
// each other: a developer who updates the match is reminded to also
322328
// update `all()` and this count.
323-
const EXPECTED_LEN: usize = 35;
329+
const EXPECTED_LEN: usize = 36;
324330
assert_eq!(
325331
LanguageKind::all().len(),
326332
EXPECTED_LEN,

scripts/build-wasm.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@ const grammars = [
206206
{ name: 'tree-sitter-ocaml', pkg: 'tree-sitter-ocaml', sub: 'grammars/ocaml' },
207207
{ name: 'tree-sitter-ocaml_interface', pkg: 'tree-sitter-ocaml', sub: 'grammars/interface' },
208208
{ name: 'tree-sitter-fsharp', pkg: 'tree-sitter-fsharp', sub: 'fsharp' },
209+
{ name: 'tree-sitter-fsharp_signature', pkg: 'tree-sitter-fsharp', sub: 'fsharp_signature' },
209210
{ name: 'tree-sitter-gleam', pkg: 'tree-sitter-gleam', sub: null },
210211
{ name: 'tree-sitter-clojure', pkg: 'tree-sitter-clojure', sub: null },
211212
{ name: 'tree-sitter-julia', pkg: 'tree-sitter-julia', sub: null },

src/ast-analysis/rules/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@ export const AST_TYPE_MAPS: Map<string, Record<string, string>> = new Map([
233233
['ocaml', OCAML_AST_TYPES],
234234
['ocaml-interface', OCAML_AST_TYPES],
235235
['fsharp', FSHARP_AST_TYPES],
236+
['fsharp-signature', FSHARP_AST_TYPES],
236237
['objc', OBJC_AST_TYPES],
237238
['gleam', GLEAM_AST_TYPES],
238239
['julia', JULIA_AST_TYPES],
@@ -313,6 +314,7 @@ export const AST_STRING_CONFIGS: Map<string, AstStringConfig> = new Map([
313314
['ocaml', OCAML_STRING_CONFIG],
314315
['ocaml-interface', OCAML_STRING_CONFIG],
315316
['fsharp', FSHARP_STRING_CONFIG],
317+
['fsharp-signature', FSHARP_STRING_CONFIG],
316318
['objc', OBJC_STRING_CONFIG],
317319
['gleam', GLEAM_STRING_CONFIG],
318320
['julia', JULIA_STRING_CONFIG],

src/domain/parser.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -831,11 +831,18 @@ export const LANGUAGE_REGISTRY: LanguageRegistryEntry[] = [
831831
},
832832
{
833833
id: 'fsharp',
834-
extensions: ['.fs', '.fsx', '.fsi'],
834+
extensions: ['.fs', '.fsx'],
835835
grammarFile: 'tree-sitter-fsharp.wasm',
836836
extractor: extractFSharpSymbols,
837837
required: false,
838838
},
839+
{
840+
id: 'fsharp-signature',
841+
extensions: ['.fsi'],
842+
grammarFile: 'tree-sitter-fsharp_signature.wasm',
843+
extractor: extractFSharpSymbols,
844+
required: false,
845+
},
839846
{
840847
id: 'gleam',
841848
extensions: ['.gleam'],

src/domain/wasm-worker-entry.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -306,11 +306,18 @@ const LANGUAGE_REGISTRY: LanguageRegistryEntry[] = [
306306
},
307307
{
308308
id: 'fsharp',
309-
extensions: ['.fs', '.fsx', '.fsi'],
309+
extensions: ['.fs', '.fsx'],
310310
grammarFile: 'tree-sitter-fsharp.wasm',
311311
extractor: extractFSharpSymbols,
312312
required: false,
313313
},
314+
{
315+
id: 'fsharp-signature',
316+
extensions: ['.fsi'],
317+
grammarFile: 'tree-sitter-fsharp_signature.wasm',
318+
extractor: extractFSharpSymbols,
319+
required: false,
320+
},
314321
{
315322
id: 'gleam',
316323
extensions: ['.gleam'],

src/extractors/fsharp.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ function walkFSharpNode(
5757
case 'dot_expression':
5858
handleDotExpression(node, ctx);
5959
break;
60+
case 'value_definition':
61+
handleValueDefinition(node, ctx, currentModule);
62+
break;
6063
}
6164

6265
for (let i = 0; i < node.childCount; i++) {
@@ -251,3 +254,65 @@ function handleDotExpression(node: TreeSitterNode, ctx: ExtractorOutput): void {
251254
ctx.calls.push(call);
252255
}
253256
}
257+
258+
// Handle `val name : type` declarations in `.fsi` signature files.
259+
// The signature grammar reuses `value_definition` for `val` bindings,
260+
// distinguished from the source grammar's `let` bindings by the first
261+
// child being the literal `val` keyword. Source-file `value_definition`
262+
// nodes (which start with `let`) are intentionally ignored to preserve
263+
// `.fs` extractor parity.
264+
function handleValueDefinition(
265+
node: TreeSitterNode,
266+
ctx: ExtractorOutput,
267+
currentModule: string | null,
268+
): void {
269+
const first = node.child(0);
270+
if (!first || first.type !== 'val') return;
271+
272+
const declLeft = findChild(node, 'value_declaration_left');
273+
if (!declLeft) return;
274+
275+
const pattern = findChild(declLeft, 'identifier_pattern');
276+
if (!pattern) return;
277+
278+
const ident =
279+
findChild(findChild(pattern, 'long_identifier_or_op') ?? pattern, 'identifier') ??
280+
findChild(pattern, 'identifier');
281+
if (!ident) return;
282+
283+
// The two grammar versions use different shapes for type signatures:
284+
// • WASM (npm tree-sitter-fsharp 0.1.0): `function_type` is the explicit
285+
// function-type kind, only present for `a -> b` types.
286+
// • Native (cargo tree-sitter-fsharp 0.3.0): every type signature is
287+
// wrapped in `curried_spec`. For a function it contains `arguments_spec`
288+
// children; for a plain value (e.g. `val pi : float`) it wraps a single
289+
// `simple_type`.
290+
// Treat both engines consistently by classifying as a function whenever
291+
// function_type appears OR a curried_spec contains an arguments_spec child.
292+
let hasFunctionType = false;
293+
for (let i = 0; i < node.childCount; i++) {
294+
const c = node.child(i);
295+
if (!c) continue;
296+
if (c.type === 'function_type') {
297+
hasFunctionType = true;
298+
break;
299+
}
300+
if (c.type === 'curried_spec') {
301+
for (let j = 0; j < c.childCount; j++) {
302+
if (c.child(j)?.type === 'arguments_spec') {
303+
hasFunctionType = true;
304+
break;
305+
}
306+
}
307+
if (hasFunctionType) break;
308+
}
309+
}
310+
311+
const name = currentModule ? `${currentModule}.${ident.text}` : ident.text;
312+
ctx.definitions.push({
313+
name,
314+
kind: hasFunctionType ? 'function' : 'variable',
315+
line: node.startPosition.row + 1,
316+
endLine: nodeEndLine(node),
317+
});
318+
}

src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ export type LanguageId =
9999
| 'ocaml'
100100
| 'ocaml-interface'
101101
| 'fsharp'
102+
| 'fsharp-signature'
102103
| 'gleam'
103104
| 'clojure'
104105
| 'julia'

0 commit comments

Comments
 (0)