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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,21 @@ Add a directive to some file in your module (assumes install with `go tool`):
//go:generate go tool typefox.dev/fastbelt/cmd/fastbelt generate ./grammar.fb -o ./
```

## Railroad diagrams

Pass `--railroad` to `generate` to also produce railroad syntax diagrams for your grammar:

```sh
fastbelt generate ./grammar.fb -o ./ --railroad
```

This writes one `<RuleName>.svg` per parser, composite, and infix rule to a `railroad/`
subdirectory, plus an `index.md` linking all of them in grammar order. Diagrams of
renamed or deleted rules are not removed from `railroad/`.

The Fastbelt VS Code extension shows the same diagram when hovering a rule name in a
`.fb` file.

## Scaffolding

To bootstrap a **new Go module** for a language, run:
Expand Down
21 changes: 21 additions & 0 deletions cmd/fastbelt/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ type generateOptions struct {
outputPath string
packageName string
atn bool
railroad bool
verbose bool
}

Expand Down Expand Up @@ -165,10 +166,30 @@ func runGenerateCLI(opts generateOptions) error {
return err
}
}
if opts.railroad {
if err := writeRailroadDiagrams(grammar, packageName, outputPath, writeFile); err != nil {
return err
}
}

return nil
}

func writeRailroadDiagrams(g grammarPkg.Grammar, packageName, outputPath string, writeFile func(name, path, content string) error) error {
railroadDir := filepath.Join(outputPath, "railroad")
if err := os.MkdirAll(railroadDir, 0755); err != nil {
return err
}
diagrams := generator.GenerateRailroadDiagrams(g)
for _, d := range diagrams {
if err := writeFile("railroad-"+d.Name, filepath.Join(railroadDir, d.Name+".svg"), d.SVG); err != nil {
return err
}
}
return writeFile("railroad-index", filepath.Join(railroadDir, "index.md"),
generator.GenerateRailroadIndexMarkdown(packageName, diagrams))
}

func validateEntryRule(g grammarPkg.Grammar) (grammarPkg.ParserRule, error) {
var entries []grammarPkg.ParserRule
for _, rule := range g.Rules() {
Expand Down
4 changes: 3 additions & 1 deletion cmd/fastbelt/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ each step.`

const generateExamples = ` fastbelt generate ./grammar.fb
fastbelt generate ./lang.fb -o ./internal/lang -p lang
fastbelt generate ./mylanguage.fb --atn -v`
fastbelt generate ./mylanguage.fb --atn -v
fastbelt generate ./mylanguage.fb --railroad`

const scaffoldLongHelp = `Scaffold a new language project from templates.

Expand Down Expand Up @@ -83,6 +84,7 @@ func newGenerateCmd() *cobra.Command {
cmd.Flags().StringVarP(&opts.outputPath, "output", "o", "./", "Path to the output directory")
cmd.Flags().StringVarP(&opts.packageName, "package", "p", "", "Package name for generated code (defaults to the last segment of the output path)")
cmd.Flags().BoolVar(&opts.atn, "atn", false, "Enable markdown output about ATN construction")
cmd.Flags().BoolVar(&opts.railroad, "railroad", false, "Generate railroad syntax diagrams (SVG per rule)")
cmd.Flags().BoolVarP(&opts.verbose, "verbose", "v", false, "Enable verbose output about written files")
return cmd
}
Expand Down
62 changes: 62 additions & 0 deletions internal/generator/railroad_generator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Copyright 2026 TypeFox GmbH
// This program and the accompanying materials are made available under the
// terms of the MIT License, which is available in the project root.

package generator

import (
"cmp"
"context"
"fmt"
"slices"
"strings"

"typefox.dev/fastbelt/internal/grammar"
)

// RailroadDiagram is the rendered SVG railroad diagram of one grammar rule.
type RailroadDiagram struct {
Name string
SVG string
}

// GenerateRailroadDiagrams renders a standalone SVG railroad diagram for
// every parser, composite, and infix rule of grammr, in source order.
// Rule kinds with no meaningful concrete-syntax diagram (terminal/token
// rules) are omitted.
func GenerateRailroadDiagrams(grammr grammar.Grammar) []RailroadDiagram {
var rules []grammar.AbstractRule
for _, rule := range grammr.Rules() {
rules = append(rules, rule)
}
for _, rule := range grammr.Composites() {
rules = append(rules, rule)
}
for _, rule := range grammr.InfixRules() {
rules = append(rules, rule)
}
slices.SortFunc(rules, func(a, b grammar.AbstractRule) int {
return cmp.Compare(a.TextRange().Start, b.TextRange().Start)
})

ctx := context.Background()
out := make([]RailroadDiagram, 0, len(rules))
for _, rule := range rules {
if diagram, ok := grammar.BuildRuleDiagram(ctx, rule); ok {
out = append(out, RailroadDiagram{Name: rule.Name(), SVG: diagram.SVG()})
}
}
return out
}

// GenerateRailroadIndexMarkdown renders a Markdown page linking every
// diagram (by relative <name>.svg file path) for convenient one-page
// browsing.
func GenerateRailroadIndexMarkdown(packageName string, diagrams []RailroadDiagram) string {
var b strings.Builder
fmt.Fprintf(&b, "# Railroad diagrams for %s\n\n", packageName)
for _, d := range diagrams {
fmt.Fprintf(&b, "## %s\n\n![%s](%s.svg)\n\n", d.Name, d.Name, d.Name)
}
return b.String()
}
104 changes: 104 additions & 0 deletions internal/generator/railroad_generator_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// Copyright 2026 TypeFox GmbH
// This program and the accompanying materials are made available under the
// terms of the MIT License, which is available in the project root.

package generator

import (
"encoding/xml"
"slices"
"strings"
"testing"

"typefox.dev/fastbelt/internal/grammar"
"typefox.dev/fastbelt/test"
)

// Rule kinds are interleaved so source order differs from both alphabetical
// and grouped-by-kind order.
const railroadGeneratorFixtureGrammar = `grammar Test

interface Expr {}

interface BinaryExpression extends Expr {
Left Expr
Operator string
Right Expr
}

interface Foo {
Name string
Items []Item
}

interface Item {
Name string
}

entry Foo returns Foo:
"begin" Name=ID Items+=Item*

composite Combo: "prefix" Item;

infix BinaryExpression on Primary:
"+" | "-"

Item returns Item:
Name=ID

Primary returns Expr:
Value=NUMBER

token ID: /[a-zA-Z_][a-zA-Z0-9_]*/
token NUMBER: /[0-9]+/
`

func TestGenerateRailroadDiagrams(t *testing.T) {
sc := grammar.CreateServices()
f := test.New(t, sc)
doc := f.Parse(railroadGeneratorFixtureGrammar)
doc.AssertNoParseErrors()

g, ok := doc.Root().(grammar.Grammar)
if !ok {
t.Fatalf("document root = %T, want grammar.Grammar", doc.Root())
}
// Mirror the CLI, which desugars infix rules before running generators.
if err := grammar.ExpandInfixRules(g); err != nil {
t.Fatalf("ExpandInfixRules: %v", err)
}

diagrams := GenerateRailroadDiagrams(g)

names := make([]string, 0, len(diagrams))
for _, d := range diagrams {
names = append(names, d.Name)
}
want := []string{"Foo", "Combo", "BinaryExpression", "Item", "Primary"}
if !slices.Equal(names, want) {
t.Fatalf("diagram names = %v, want %v (source order, no token rules)", names, want)
}

for _, d := range diagrams {
var root struct{ XMLName xml.Name }
if err := xml.Unmarshal([]byte(d.SVG), &root); err != nil {
t.Errorf("diagram for %q is not well-formed XML: %v\n%s", d.Name, err, d.SVG)
continue
}
if root.XMLName.Local != "svg" {
t.Errorf("diagram for %q has root element %q, want svg", d.Name, root.XMLName.Local)
}
}
}

func TestGenerateRailroadIndexMarkdown(t *testing.T) {
md := GenerateRailroadIndexMarkdown("mylang", []RailroadDiagram{{Name: "Foo"}, {Name: "Item"}})
for _, want := range []string{"# Railroad diagrams for mylang", "## Foo", "![Foo](Foo.svg)", "## Item", "![Item](Item.svg)"} {
if !strings.Contains(md, want) {
t.Errorf("index markdown missing %q, got:\n%s", want, md)
}
}
if strings.Index(md, "## Foo") > strings.Index(md, "## Item") {
t.Errorf("index markdown should keep the given diagram order, got:\n%s", md)
}
}
73 changes: 73 additions & 0 deletions internal/grammar/hover_provider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Copyright 2026 TypeFox GmbH
// This program and the accompanying materials are made available under the
// terms of the MIT License, which is available in the project root.

package grammar

import (
"context"
"encoding/base64"
"fmt"

core "typefox.dev/fastbelt"
"typefox.dev/fastbelt/server"
"typefox.dev/fastbelt/util/service"
"typefox.dev/lsp"
)

type hoverProviderImpl struct {
sc *service.Container
}

// NewHoverProvider returns the grammar language's [server.HoverProvider]:
// the same documentation-comment content as [server.DefaultHoverProvider],
// plus - for grammar rule nodes - a railroad syntax diagram of the rule's
// concrete syntax.
func NewHoverProvider(sc *service.Container) server.HoverProvider {
return &hoverProviderImpl{sc: sc}
}

func (p *hoverProviderImpl) HandleHoverRequest(ctx context.Context, params *lsp.HoverParams) (*lsp.Hover, error) {
target, sourceRange := server.TargetAtCursor(ctx, p.sc, &params.TextDocumentPositionParams)
if target == nil {
return nil, nil
}

content := ""
if docProvider, err := service.Get[server.DocumentationProvider](p.sc); err == nil {
content = docProvider.Documentation(target)
}
if diagram := diagramMarkdown(ctx, target); diagram != "" {
if content != "" {
content += "\n\n---\n\n"
}
content += diagram
}
if content == "" {
return nil, nil
}

return &lsp.Hover{
Contents: lsp.MarkupContent{
Kind: lsp.Markdown,
Value: content,
},
Range: sourceRange,
}, nil
}

// diagramMarkdown returns a Markdown image embedding a base64-encoded SVG
// railroad diagram for node, or "" if node isn't a grammar rule with a
// meaningful concrete-syntax diagram (e.g. a terminal/token rule).
func diagramMarkdown(ctx context.Context, node core.AstNode) string {
rule, ok := node.(AbstractRule)
if !ok {
return ""
}
diagram, ok := BuildRuleDiagram(ctx, rule)
if !ok {
return ""
}
encoded := base64.StdEncoding.EncodeToString([]byte(diagram.SVG()))
return fmt.Sprintf("![Railroad diagram](data:image/svg+xml;base64,%s)", encoded)
Comment thread
ssmifi marked this conversation as resolved.
}
Loading
Loading