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
3 changes: 2 additions & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ gemspec
gem "memory_profiler"
gem "pry"
gem "racc", "1.8.1"
gem "railroad_diagrams", "0.3.0"
gem "railroad_diagrams", ">= 1.0.0"
gem "rake"
gem "rdoc"
gem "rexml", require: false
gem "rspec"
gem "simplecov", require: false
gem "stackprof", platforms: [:ruby] # stackprof doesn't support Windows
Expand Down
14 changes: 14 additions & 0 deletions doc/Index.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,20 @@ Enter the formula:
=> 9
```

### Syntax diagrams

Install `railroad_diagrams` 1.0.0 or later to generate SVG diagrams with links to
referenced rules and links back to referencing rules:

```shell
$ lrama --diagram=diagram.html -o calc.c sample/calc.y
```

Open `diagram.html` in a browser. Click a nonterminal symbol to visit its rule
or follow the referencing-rule links to navigate back.

Midrule action symbols are omitted from diagrams. User-defined empty rules remain visible.

## Supported Ruby version

Lrama is executed with BASERUBY when building ruby from source code. Therefore Lrama needs to support BASERUBY, currently 3.1, or later version.
Expand Down
33 changes: 12 additions & 21 deletions lib/lrama/diagram.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ def initialize(out:, grammar:, template_name: 'diagram/diagram.html')

# @rbs () -> void
def render
RailroadDiagrams::TextDiagram.set_formatting(RailroadDiagrams::TextDiagram::PARTS_UNICODE)
@out << ERB.render(template_file, output: self)
end

Expand All @@ -38,21 +37,19 @@ def default_style
RailroadDiagrams::Style::default_style
end

# @rbs () -> string
def diagrams
result = +''
@grammar.unique_rule_s_values.each do |s_value|
diagrams =
@grammar.select_rules_by_s_value(s_value).map { |r| r.to_diagrams }
add_diagram(
s_value,
RailroadDiagrams::Diagram.new(
RailroadDiagrams::Choice.new(0, *diagrams),
),
result
)
# @rbs () -> Array[Hash[Symbol, (String | Array[String])]]
def linked_sections
document = RailroadDiagrams::Document.new(title: "Lrama syntax diagrams", theme: :default)
action_symbols = @grammar.rules.select(&:original_rule).map(&:lhs)
@grammar.rules.reject(&:original_rule).group_by { |rule| rule.lhs.id.s_value }.each do |name, rules|
alternatives = rules.map do |rule|
diagram_rule = rule.dup
diagram_rule.rhs = rule.rhs - action_symbols
diagram_rule.to_diagrams
end
document.add_rule(name, RailroadDiagrams::Choice.new(0, *alternatives))
end
result
document.rule_sections
end

private
Expand All @@ -67,11 +64,5 @@ def template_file
File.join(template_dir, @template_name)
end

# @rbs (String name, RailroadDiagrams::Diagram diagram, String result) -> void
def add_diagram(name, diagram, result)
result << "\n<h2 class=\"diagram-header\">#{RailroadDiagrams.escape_html(name)}</h2>"
diagram.write_svg(result.method(:<<))
result << "\n"
end
end
end
5,785 changes: 3,530 additions & 2,255 deletions sample/diagram.html

Large diffs are not rendered by default.

7 changes: 2 additions & 5 deletions sig/generated/lrama/diagram.rbs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions spec/lrama/command_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,21 @@
end
end

context "when `--diagram` is specified" do
it "generates links and reverse references without text diagrams" do
diagram_file = File.join(Dir.tmpdir, "diagram.html")
command = Lrama::Command.new(o_option + ["--diagram=#{diagram_file}", fixture_path("command/basic.y")])
command.run

html = File.read(diagram_file)
expect(html).to include('<section id="rule-expr">')
expect(html).to include('href="#rule-expr"')
expect(html).to include("Referenced by:")
expect(html).not_to include("<details", "<pre", "Text diagram")
expect(html).not_to include("<script")
end
end

context "when STDIN mode and a grammar file is specified" do
it "ends successfully" do
File.open(fixture_path("command/basic.y")) do |f|
Expand Down
102 changes: 94 additions & 8 deletions spec/lrama/diagram_spec.rb
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
# frozen_string_literal: true

require "rexml/document"
require "railroad_diagrams"

RSpec.describe Lrama::Diagram do
let(:diagram) {
Lrama::Diagram.new(
out: out,
grammar: grammar,
template_name: "diagram/diagram.html",
)
}
let(:out) { StringIO.new }
Expand All @@ -24,6 +26,9 @@
expect(out.string).to include('<h2 class="diagram-header">$accept</h2>')
expect(out.string).to include('<h2 class="diagram-header">unused</h2>')
expect(out.string).to include("<svg")
expect(out.string).to include('<section id="rule-program">')
expect(out.string).to include('href="#rule-program"')
expect(out.string).to include("Referenced by:")
end
end

Expand Down Expand Up @@ -68,15 +73,96 @@
end
end

describe "#diagrams" do
before do
Lrama::Diagram.require_railroad_diagrams
describe "linked diagrams" do
it "preserves rule order and includes references" do
sections = diagram.linked_sections

expect(sections.map { |section| section[:name] }).to eq(%w[$accept program class strings_1 strings_2 string_1 string_2 string unused])
expect(sections.map { |section| section[:id] }.uniq.size).to eq(sections.size)
expect(sections.find { |section| section[:name] == "program" }[:referenced_by]).to eq(["$accept"])
expect(sections.find { |section| section[:name] == "string_1" }[:referenced_by]).to eq(["strings_1", "strings_2"])
expect(sections.find { |section| section[:name] == "unused" }[:referenced_by]).to eq([])
sections.each do |section|
expect(REXML::Document.new(section[:svg]).root.name).to eq("svg")
end
end

context "with midrule actions" do
let(:grammar_file_path) { "midrule-actions.y" }
let(:text) do
<<~GRAMMAR
%union { int i; }
%token <i> NUMBER
%type <i> start value
%%
start: { setup(); } value { checkpoint(); } empty
| value { $<i>$ = $1; } empty { $$ = $<i>2; }
| actions;
value: NUMBER;
empty: %empty;
actions: { setup(); } { finish(); };
GRAMMAR
end

it "omits action rules and references while preserving empty grammar rules" do
original_rules = grammar.rules.map(&:display_name)
action_names = grammar.rules.select(&:original_rule).map { |rule| rule.lhs.id.s_value }
expect(action_names).to include("$@1", "@3")

sections = diagram.linked_sections
expect(sections.map { |section| section[:name] }).to eq(%w[$accept start value empty actions])
start = sections.find { |section| section[:name] == "start" }
svg = REXML::Document.new(start[:svg])
expect(REXML::XPath.match(svg, "//text").map(&:text)).to eq(%w[value empty value empty actions])
expect(sections.find { |section| section[:name] == "empty" }[:referenced_by]).to eq(["start"])
actions = sections.find { |section| section[:name] == "actions" }
expect(REXML::XPath.match(REXML::Document.new(actions[:svg]), "//text")).to eq([])

diagram.render
action_names.each { |name| expect(out.string).not_to include(name) }
ids = out.string.scan(/\bid="([^"]+)"/).flatten
targets = out.string.scan(/\bhref="#([^"]+)"/).flatten
expect(targets - ids).to eq([])
expect(out.string).to include('href="#rule-empty"')
expect(grammar.rules.map(&:display_name)).to eq(original_rules)
end
end

it "returns diagrams" do
expect(diagram.diagrams).to include('<h2 class="diagram-header">$accept</h2>')
expect(diagram.diagrams).to include('<h2 class="diagram-header">unused</h2>')
expect(diagram.diagrams).to include("<svg")
it "renders native links and reverse references without text diagrams" do
sections = diagram.linked_sections
expect(diagram).to receive(:linked_sections).once.and_return(sections)
diagram.render

ids = out.string.scan(/\bid="([^"]+)"/).flatten
targets = out.string.scan(/\bhref="#([^"]+)"/).flatten
expect(ids.uniq).to eq(ids)
expect(targets).to include("rule-program", "rule-accept")
expect(targets - ids).to eq([])
expect(out.string).to include('<section id="rule-unused">')
expect(out.string).to include("Referenced by:")
expect(out.string).not_to include("<details", "<pre", "Text diagram")
expect(out.string).not_to include("<script")
end

it "escapes rule names and leaves undefined references unlinked" do
name = '<rule & "name">'
symbols = [name, "missing", "rule name"].map do |label|
Lrama::Grammar::Symbol.new(id: Lrama::Lexer::Token::Ident.new(s_value: label), term: false)
end
rules = [
Lrama::Grammar::Rule.new(lhs: symbols[0], rhs: symbols[0..1]),
Lrama::Grammar::Rule.new(lhs: symbols[2], rhs: []),
]
custom_grammar = double(:grammar, rules: rules)
linked = Lrama::Diagram.new(out: out, grammar: custom_grammar)
sections = linked.linked_sections
expect(sections.map { |section| section[:id] }).to eq(["rule-rule-name", "rule-rule-name-2"])
svg = REXML::Document.new(sections.first[:svg])
hrefs = REXML::XPath.match(svg, "//*[@href]").map { |element| element.attributes["href"] }
expect(hrefs).to eq(["#rule-rule-name"])
linked.render
expect(out.string).to include("<h2 class=\"diagram-header\">#{RailroadDiagrams.escape_html(name)}</h2>")
expect(out.string).not_to include(name)
end
end
end
120 changes: 27 additions & 93 deletions template/diagram/diagram.html
Original file line number Diff line number Diff line change
@@ -1,102 +1,36 @@
<% sections = output.linked_sections -%>
<% rule_ids = sections.each_with_object({}) { |section, ids| ids[section[:name]] = section[:id] } -%>
<!DOCTYPE html>
<html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Lrama syntax diagrams</title>

<style>
<%= output.default_style %>
.diagram-header {
display: inline-block;
font-weight: bold;
font-size: 18px;
margin-bottom: -8px;
text-align: center;
}

svg {
width: 100%;
}

svg.railroad-diagram g.non-terminal text {
cursor: pointer;
}

h2.hover-header {
background-color: #90ee90;
}

svg.railroad-diagram g.non-terminal.hover-g rect {
fill: #eded91;
stroke: 5;
}

svg.railroad-diagram g.terminal.hover-g rect {
fill: #eded91;
stroke: 5;
}
body { margin: 2rem; font-family: sans-serif; }
section { margin-bottom: 3rem; scroll-margin-top: 1rem; }
.diagram-header { font-size: 18px; }
.diagram { overflow-x: auto; }
svg { max-width: 100%; height: auto; }
svg a { cursor: pointer; }
svg a:hover rect { fill: hsl(60, 100%, 83%); }
</style>
</head>

<body align="center">
<%= output.diagrams %>
<script>
document.addEventListener("DOMContentLoaded", () => {
function addHoverEffect(selector, hoverClass, relatedSelector, relatedHoverClass, getTextElements) {
document.querySelectorAll(selector).forEach(element => {
element.addEventListener("mouseenter", () => {
element.classList.add(hoverClass);
getTextElements(element).forEach(textEl => {
if (!relatedSelector) return;
getElementsByText(relatedSelector, textEl.textContent).forEach(related => {
related.classList.add(relatedHoverClass);
});
});
});

element.addEventListener("mouseleave", () => {
element.classList.remove(hoverClass);
if (!relatedSelector) return;
getTextElements(element).forEach(textEl => {
getElementsByText(relatedSelector, textEl.textContent).forEach(related => {
related.classList.remove(relatedHoverClass);
});
});
});
});
}

function getElementsByText(selector, text) {
return [...document.querySelectorAll(selector)].filter(el => el.textContent.trim() === text.trim());
}

function getParentElementsByText(selector, text) {
return [...document.querySelectorAll(selector)].filter(el =>
[...el.querySelectorAll("text")].some(textEl => textEl.textContent.trim() === text.trim())
);
}

function scrollToMatchingHeader() {
document.querySelectorAll("g.non-terminal").forEach(element => {
element.addEventListener("click", () => {
const textElements = [...element.querySelectorAll("text")];
for (const textEl of textElements) {
const targetHeader = getElementsByText("h2", textEl.textContent)[0];
if (targetHeader) {
targetHeader.scrollIntoView({ behavior: "smooth", block: "start" });
break;
}
}
});
});
}

addHoverEffect("h2", "hover-header", "g.non-terminal", "hover-g", element => [element]);
addHoverEffect("g.non-terminal", "hover-g", "h2", "hover-header",
element => [...element.querySelectorAll("text")]
);
addHoverEffect("g.terminal", "hover-g", "", "", element => [element]);
scrollToMatchingHeader();
});
</script>
<body>
<h1>Lrama syntax diagrams</h1>
<% sections.each do |section| -%>
<section id="<%= RailroadDiagrams.escape_html(section[:id]) %>">
<h2 class="diagram-header"><%= RailroadDiagrams.escape_html(section[:name]) %></h2>
<div class="diagram"><%= section[:svg] %></div>
<% unless section[:referenced_by].empty? -%>
<p>Referenced by:
<% section[:referenced_by].each do |name| -%>
<a href="#<%= RailroadDiagrams.escape_html(rule_ids.fetch(name)) %>"><%= RailroadDiagrams.escape_html(name) %></a>
<% end -%>
</p>
<% end -%>
</section>
<% end -%>
</body>
</html>
Loading