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
1 change: 1 addition & 0 deletions lib/interscript/isc/grammar/isc.artifact.json

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion lib/interscript/isc/normalizer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,11 @@ def normalize_block(block)
end
}
elsif block.key?(:tests)
{tests: merge_tests(Array(block[:tests]))}
# A comment-only or empty tests block captures as its raw
# text through the artifact; the DSL grammar folds it. Both
# normalize to no tests.
items = block[:tests].is_a?(Array) ? block[:tests] : []
{tests: merge_tests(items)}
elsif block.key?(:stage)
{stage_name: scalar(block[:stage_name]), stage: normalize_stage_items(block[:stage])}
elsif block.key?(:target)
Expand Down
50 changes: 43 additions & 7 deletions lib/interscript/isc/parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,50 @@ class Parser < Parsanol::Parser

root :isc_source

def self.parse(source, filename: nil)
new.parse_with_callbacks(source, filename: filename)
end
# The compiled grammar, shipped alongside the .parg source; the
# runtime parses through it when parsanol carries PARG (>= 1.3.61)
# and falls back to the Ruby-DSL grammar otherwise. Trees are
# byte-identical by the corpus gate in the parg_diff spec.
ARTIFACT_PATH = File.expand_path("grammar/isc.artifact.json", __dir__)

class ArtifactUnavailable < StandardError; end

class << self
def parse(source, filename: nil)
parse_with_callbacks(source, filename: filename)
end

def parse_with_callbacks(source, filename: nil)
tree = begin
parse_with_artifact(source)
rescue ArtifactUnavailable
parse_with_dsl(source)
end
Normalizer.normalize(tree)
rescue Parsanol::ParseFailed => e
raise ParseError.new(e.message, filename: filename, source: source, cause: e)
end

def parse_with_artifact(source)
artifact.parse("isc", source)
end

def parse_with_dsl(source)
new.parse(source)
end

def artifact
@artifact ||= begin
require "parsanol/parg"
Parsanol::PARG::Artifact.load(ARTIFACT_PATH)
rescue LoadError, StandardError => e
raise ArtifactUnavailable, "parg artifact unavailable: #{e.class}: #{e.message}"
end
end

def parse_with_callbacks(source, filename: nil)
Normalizer.normalize(parse(source))
rescue Parsanol::ParseFailed => e
raise ParseError.new(e.message, filename: filename, source: source, cause: e)
def reset_artifact
@artifact = nil
end
end
end

Expand Down
35 changes: 35 additions & 0 deletions spec/interscript/isc/parg_artifact_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# frozen_string_literal: true

# Differential gate: every corpus map parses byte-identically through
# the shipped .parg artifact and the Ruby-DSL grammar. The runtime
# prefers the artifact; this spec is the proof the two agree.
require "support/parg_diff"

RSpec.describe Interscript::Isc::Parser, :aggregate_failures do
maps_dir = ENV.fetch("INTERSCRIPT_MAPS_PATH", "../maps/maps")
maps = Dir[File.expand_path("*.isc", maps_dir)].sort

skip "maps checkout not present" if maps.empty?
skip "parsanol lacks PARG (needs >= 1.3.61)" unless Interscript::Isc::Parser.respond_to?(:artifact)

it "parses every corpus map identically via artifact and DSL" do
failures = []

maps.each do |path|
source = File.read(path, encoding: "utf-8")
artifact_tree = PargDiff.normalize(
Interscript::Isc::Normalizer.normalize(described_class.parse_with_artifact(source))
)
dsl_tree = PargDiff.normalize(
Interscript::Isc::Normalizer.normalize(described_class.parse_with_dsl(source))
)
diffs = PargDiff.diff(dsl_tree, artifact_tree)
failures << "#{File.basename(path)}: #{diffs.first(3).join("; ")}" unless diffs.empty?
rescue Parsanol::ParseFailed => e
failures << "#{File.basename(path)}: parse failed: #{e.message[0, 120]}"
end

expect(failures).to be_empty,
"#{failures.size}/#{maps.size} maps diverge; first: #{failures.first(5).join(" | ")}"
end
end
44 changes: 44 additions & 0 deletions spec/interscript/isc/parser_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,48 @@
expect { described_class.parse(src, filename: "test.isc") }.not_to raise_error
end
end

describe "artifact runtime" do
let(:src) do
<<~ISC
system "s" {
metadata { authority_id t }
stage main {
parallel {
sub "a" "b"
}
}
}
ISC
end

it "parses through the shipped .parg artifact when parsanol has PARG" do
skip "parsanol lacks PARG (needs >= 1.3.61)" unless Interscript::Isc::Parser.respond_to?(:artifact)

tree = described_class.parse(src)
doc = Interscript::Isc::DocumentBuilder.build(tree, filename: "test.isc")
expect(doc[:systemCode]).to eq("s")
expect(doc[:tests]).to be_empty
end

it "produces byte-identical trees via artifact and DSL paths" do
skip "parsanol lacks PARG (needs >= 1.3.61)" unless Interscript::Isc::Parser.respond_to?(:artifact)

artifact_tree = described_class.parse_with_artifact(src)
dsl_tree = described_class.parse_with_dsl(src)
expect(artifact_tree).to eq(dsl_tree)
end

it "falls back to the DSL grammar when the artifact cannot load" do
skip "parsanol lacks PARG (needs >= 1.3.61)" unless Interscript::Isc::Parser.respond_to?(:artifact)

allow(File).to receive(:read).and_call_original
allow(File).to receive(:read).with(%r{isc\.artifact\.json}).and_raise(Errno::ENOENT)
described_class.reset_artifact
tree = described_class.parse(src)
doc = Interscript::Isc::DocumentBuilder.build(tree, filename: "test.isc")
expect(doc[:systemCode]).to eq("s")
described_class.reset_artifact
end
end
end
Loading