diff --git a/lib/interscript.rb b/lib/interscript.rb index b7d4e757..575287e3 100644 --- a/lib/interscript.rb +++ b/lib/interscript.rb @@ -50,9 +50,22 @@ def load(system_code, maps = {}, compiler: Interscript::Interpreter) end # Transliterates the string. - def transliterate(system_code, string, maps = {}, compiler: Interscript::Interpreter) + # + # ISC-WF-1 W3: `language:` selects one of the map's declared + # `languages`, activating the matching `when`-guarded rules. A + # selection outside the declaration (or no selection) leaves every + # guard inert (spec §5.1). + def transliterate(system_code, string, maps = {}, compiler: Interscript::Interpreter, language: nil) # The current best implementation is Interpreter - load(system_code, maps, compiler: compiler).call(string) + compiled = load(system_code, maps, compiler: compiler) + if language.nil? + compiled.call(string) + elsif compiler.equal?(Interscript::Interpreter) + compiled.call(string, language: language) + else + raise ArgumentError, + "language selection is only supported by Interscript::Interpreter, not #{compiler}" + end end # Gives each possible value of the transliteration. diff --git a/lib/interscript/interpreter.rb b/lib/interscript/interpreter.rb index 86882461..0e123587 100644 --- a/lib/interscript/interpreter.rb +++ b/lib/interscript/interpreter.rb @@ -5,7 +5,7 @@ def compile(map, _: nil) self end - def call(str, stage = :main, each: false, &block) + def call(str, stage = :main, each: false, language: nil, &block) stage = @map.stages[stage] if each e = Enumerator.new do |yielder| @@ -22,7 +22,7 @@ def call(str, stage = :main, each: false, &block) # standard:disable Style/GlobalVars (deliberate $DEBUG / -d-flag debug idiom) $select_nth_string = true # standard:enable Style/GlobalVars - result = Stage.new(@map, str).execute_rule(stage) + result = Stage.new(@map, str, language: language).execute_rule(stage) # standard:disable Style/GlobalVars (deliberate $DEBUG / -d-flag debug idiom) $select_nth_string = false # standard:enable Style/GlobalVars @@ -68,20 +68,72 @@ def call(str, stage = :main, each: false, &block) e end else - Stage.new(@map, str).execute_rule(stage) + Stage.new(@map, str, language: language).execute_rule(stage) end end + # ISC-WF-1 W3: effective language for a run. A selection outside the + # map's `languages` declaration (or any selection on a map without a + # declaration) means every `when` guard is inert (spec §5.1). + def self.select_language(map, language) + return nil if language.nil? + + declared = map.languages + return nil unless declared.is_a?(Interscript::Node::LanguagesDecl) && + declared.include?(language.to_sym) + + language.to_sym + end + class Stage - def initialize(map, str) + attr_reader :language + + def initialize(map, str, language: nil) @str = str @map = map + @language = Interscript::Interpreter.select_language(map, language) + end + + # ISC-WF-1 W3 guard applicability. Unguarded rules always apply; a + # guarded rule applies only under a selected language that its `when` + # list contains. Inert rules are absent from the pass (spec §5.1). + def guard_applies? rule + return true unless Interscript::Node::Rule::Sub === rule && rule.when_languages + return false unless @language + + rule.when_languages.include?(@language) + end + + # ISC-WF-1 W3: the effective rule set of a parallel pass = unguarded + # rules ∪ guard-applicable rules; where an unguarded rule and a + # guard-applicable rule share the same from-string, the + # guard-applicable rule wins (spec §5.2). Passes without any guard + # return the group untouched — zero change for pre-WF1 maps. Filtered + # variants are memoized per selected language on the group so the + # compiled-tree cache below stays per-language. + def resolve_parallel_guards r + return r unless r.children.any? { |c| Interscript::Node::Rule::Sub === c && c.when_languages } + + r.wf1_variants ||= {} + r.wf1_variants[@language] ||= begin + applicable = r.children.select { |c| guard_applies?(c) } + guarded_froms = applicable + .select { |c| Interscript::Node::Rule::Sub === c && c.when_languages } + .map { |c| c.from.inspect } + filtered = Interscript::Node::Group::Parallel.new + filtered.children = applicable.reject do |c| + Interscript::Node::Rule::Sub === c && !c.when_languages && + guarded_froms.include?(c.from.inspect) + end + filtered + end end def execute_rule r return if r.reverse_run == true case r when Interscript::Node::Group::Parallel + r = resolve_parallel_guards(r) if r.cached_tree @str = Interscript::Stdlib.parallel_replace_tree(@str, r.cached_tree) elsif r.subs_regexp && r.subs_replacements @@ -135,9 +187,12 @@ def execute_rule r end when Interscript::Node::Group r.children.each do |t| + # ISC-WF-1 W3: in sequence, an inert guarded rule is skipped. + next unless guard_applies?(t) execute_rule(t) end when Interscript::Node::Rule::Sub + return @str unless guard_applies?(r) @str = if r.to == :upcase @str.gsub(Regexp.new(build_regexp(r)), &:upcase) elsif r.to == :downcase @@ -151,10 +206,10 @@ def execute_rule r if r.stage.map doc = @map.dep_aliases[r.stage.map].document stage = doc.imported_stages[r.stage.name] - @str = Stage.new(doc, @str).execute_rule(stage) + @str = Stage.new(doc, @str, language: @language).execute_rule(stage) else stage = @map.imported_stages[r.stage.name] - @str = Stage.new(@map, @str).execute_rule(stage) + @str = Stage.new(@map, @str, language: @language).execute_rule(stage) end end diff --git a/lib/interscript/isc/document_builder.rb b/lib/interscript/isc/document_builder.rb index f711ece8..9d889aca 100644 --- a/lib/interscript/isc/document_builder.rb +++ b/lib/interscript/isc/document_builder.rb @@ -37,6 +37,8 @@ def build tests_arr = [] stages_arr = [] dependencies_arr = [] + rulesets_arr = [] + languages_arr = [] body.each do |item| if item[:metadata] @@ -45,6 +47,12 @@ def build aliases_arr.concat(extract_aliases(item[:aliases])) elsif item[:tests] tests_arr.concat(extract_tests(item[:tests])) + elsif item[:languages] + # ISC-WF-1 W3: map-declared languages. + languages_arr.concat(Array(item[:languages]).map { |l| ident(l) }) + elsif item[:ruleset] + # ISC-WF-1 W1: named ruleset declaration. + rulesets_arr << extract_ruleset(item) elsif item[:stage] stages_arr << extract_stage(item) elsif item[:target] @@ -52,7 +60,7 @@ def build end end - { + doc = { schemaVersion: SCHEMA_VERSION, filename: @filename, systemCode: code, @@ -63,6 +71,11 @@ def build stages: stages_arr, dependencies: dependencies_arr } + # ISC-WF-1 keys are present only when the map uses the constructs — + # documents of maps that don't keep their exact pre-WF1 shape. + doc[:languages] = languages_arr if languages_arr.any? + doc[:rulesets] = rulesets_arr if rulesets_arr.any? + doc end private @@ -254,6 +267,15 @@ def extract_stage(item) {name: name, body: body} end + # ISC-WF-1 W1: a named ruleset declaration — same body shape as a + # stage, addressed by name through `use` expansion. + def extract_ruleset(item) + node = item[:ruleset] + name = ident(item[:ruleset_name]) + body = Array(node).flat_map { |n| extract_stage_items(n) } + {name: name, body: body} + end + def extract_stage_items(n) return [] unless n.is_a?(Hash) if n[:sequence] @@ -279,6 +301,10 @@ def extract_stage_items(n) [{kind: :run, dependency: nil, stage: ident(n[:run_stage_only][:stage])}] elsif n[:bare_rule] [{kind: :bare_rule, rule: extract_rule(n[:bare_rule])}] + elsif n[:use] + # ISC-WF-1 W1/W2: `use ` or `use .` with an + # optional override block. + [extract_use(n[:use])] elsif n[:comment] [] elsif n[:noop] @@ -288,6 +314,19 @@ def extract_stage_items(n) end end + # `use ` (same-map ruleset) or `use .` (dependency- + # declared ruleset), plus the optional override rows (W2). + def extract_use(u) + dep = u[:qualified_name] ? ident(u[:use_alias]) : nil + name = ident(u[:qualified_name] || u[:use_alias]) + item = {kind: :use, ruleset: name, dependency: dep} + overrides = Array(u[:use_overrides]) + .select { |r| r.is_a?(Hash) && r.key?(:from) } + .map { |r| extract_rule(r) } + item[:overrides] = overrides if overrides.any? + item + end + # Filter out noop rules (from: None, to: None) created by empty # parallel/sequence blocks that contain only comments or whitespace. def filter_noop(rules) @@ -302,11 +341,17 @@ def extract_rule(r) from_val = r.is_a?(Hash) ? r[:from] : nil to_val = r.is_a?(Hash) ? r[:to] : nil constraints_val = r.is_a?(Hash) ? r[:constraints] : nil - { + rule = { from: from_val ? materialize(from_val) : Items::None.new, to: to_val ? materialize(to_val) : Items::None.new, constraints: extract_constraints(constraints_val) } + # ISC-WF-1 W3: a `when` guard folds to the list of language names. + # The rule hash never goes through Transform as a whole (from/to/ + # constraints are materialized individually), so the guard fragment + # gets its own apply. + rule[:when_languages] = @transform.apply(when: r[:when]) if r.is_a?(Hash) && r[:when] + rule end def extract_constraints(arr) diff --git a/lib/interscript/isc/grammar/concerns/items.rb b/lib/interscript/isc/grammar/concerns/items.rb index add11250..6dd2c6f4 100644 --- a/lib/interscript/isc/grammar/concerns/items.rb +++ b/lib/interscript/isc/grammar/concerns/items.rb @@ -125,7 +125,14 @@ module Items str("line_end") | str("word_boundary") | str("downcase") | str("upcase") | str("title_case") | str("capture") | str("maybe") | str("some") | str("ref") | - str("any_character") + str("any_character") | + # ISC-WF-1: ruleset / use / override / languages / when join + # the keyword table (spec §2). Prefix matching follows the + # existing table behavior; quoted-string forms remain the + # pass-through escape hatch. Verified against the 404-map + # corpus: no bare identifier starts with any of these words. + str("ruleset") | str("use") | str("override") | + str("languages") | str("when") end # Concatenation: one or more atoms. The continuation pattern diff --git a/lib/interscript/isc/grammar/concerns/stages.rb b/lib/interscript/isc/grammar/concerns/stages.rb index 32a90a3f..b0487fce 100644 --- a/lib/interscript/isc/grammar/concerns/stages.rb +++ b/lib/interscript/isc/grammar/concerns/stages.rb @@ -17,11 +17,44 @@ module Stages braced(stage_item.repeat(0)).as(:stage) end + # ISC-WF-1 W1: a named, reusable block of stage items. Declared at + # map top level anywhere a stage may appear; never executes on its + # own (only `use` expansion reaches the runtime). + rule(:ruleset_block) do + str("ruleset") >> whitespace >> + identifier.as(:ruleset_name) >> whitespace? >> + braced(stage_item.repeat(0)).as(:ruleset) + end + + # ISC-WF-1 W1/W2: `use ` (same-map ruleset) or + # `use .` (dependency-declared ruleset), with an + # optional override block of full replacement rows (child-wins on + # exact from-equality). + rule(:use_directive) do + (str("use") >> whitespace >> + identifier.as(:use_alias) >> + (str(".") >> identifier.as(:qualified_name)).maybe >> + (whitespace >> str("override") >> whitespace? >> + braced(rule_line.repeat(0)).as(:use_overrides)).maybe + ).as(:use) + end + + # ISC-WF-1 W3: `when (","|ws )*` — a language guard + # on a sub rule. Inert unless the caller selects a language the + # map declares (spec §5.1). Identifiers are keyword-guarded so the + # repeat stops at the next rule instead of swallowing it. + rule(:when_guard) do + (whitespace >> str("when") >> whitespace >> + keyword.absent? >> identifier.as(:when_lang) >> + ((comma | whitespace) >> (keyword.absent? >> identifier.as(:when_lang))).repeat + ).as(:when) + end + rule(:stage_item) do whitespace? >> (sequence_block | parallel_block | run_rule | separate_directive | string_case_directive | compose_directive | rababa_directive | - bare_rule | comment_item) >> + use_directive | bare_rule | comment_item) >> whitespace? end @@ -105,7 +138,8 @@ module Stages str("sub") >> whitespace >> item_atom.as(:from) >> whitespace >> item_atom.as(:to) >> - constraints + constraints >> + when_guard.maybe end rule(:block_rule) do @@ -114,6 +148,7 @@ module Stages str("from") >> whitespace >> item.as(:from) >> whitespace? >> str("to") >> whitespace >> item.as(:to) >> (whitespace >> constraint).repeat.as(:constraints) >> + when_guard.maybe >> whitespace? >> str("}") end diff --git a/lib/interscript/isc/grammar/concerns/system.rb b/lib/interscript/isc/grammar/concerns/system.rb index 7f51c9cb..177c11ed 100644 --- a/lib/interscript/isc/grammar/concerns/system.rb +++ b/lib/interscript/isc/grammar/concerns/system.rb @@ -10,12 +10,23 @@ module Concerns module System include Parsanol + # ISC-WF-1 W3: the languages a map serves, declared once per map + # inside the system block alongside metadata (spec §5.1). List + # identifiers are keyword-guarded so the repeat stops at the next + # block item (`stage`, `ruleset`, …) instead of swallowing it. + rule(:languages_decl) do + str("languages") >> whitespace >> + (keyword.absent? >> identifier.as(:language) >> + ((comma | whitespace) >> (keyword.absent? >> identifier.as(:language))).repeat + ).as(:languages) + end + # A block-item is any of the top-level constructs that may appear # inside `system "" { ... }`. rule(:block_item) do whitespace? >> - (metadata_block | aliases_block | tests_block | - stage_block | dependency_decl) >> + (metadata_block | aliases_block | tests_block | languages_decl | + ruleset_block | stage_block | dependency_decl) >> whitespace? end diff --git a/lib/interscript/isc/grammar/isc.parg b/lib/interscript/isc/grammar/isc.parg index b2130df7..cf790377 100644 --- a/lib/interscript/isc/grammar/isc.parg +++ b/lib/interscript/isc/grammar/isc.parg @@ -28,7 +28,7 @@ grammar InterscriptIsc version "0.1.0" { ident_rest = %x41-5A / %x61-7A / %x30-39 / %x5F identifier = (ident_first 0*ident_rest) as identifier - keyword = "parallel" / "sequence" / "stage" / "compose" / "separate" / "system" / "metadata" / "aliases" / "tests" / "notes" / "description" / "any_character" / "authority" / "dependency" / "run" / "sub" / "before" / "after" / "not_before" / "not_after" / "any" / "none" / "boundary" / "line_start" / "line_end" / "word_boundary" / "downcase" / "upcase" / "title_case" / "capture" / "maybe" / "some" / "ref" / "name" + keyword = "parallel" / "sequence" / "stage" / "compose" / "separate" / "system" / "metadata" / "aliases" / "tests" / "notes" / "description" / "any_character" / "authority" / "dependency" / "run" / "sub" / "before" / "after" / "not_before" / "not_after" / "any" / "none" / "boundary" / "line_start" / "line_end" / "word_boundary" / "downcase" / "upcase" / "title_case" / "capture" / "maybe" / "some" / "ref" / "name" / "ruleset" / "use" / "override" / "languages" / "when" # -- string literals ---------------------------------------------------- @@ -143,9 +143,12 @@ grammar InterscriptIsc version "0.1.0" { constraint = constraint_after / constraint_before / constraint_not_before / constraint_not_after constraints = *(whitespace constraint) - compact_rule = "sub" whitespace (item_atom as from) whitespace (item_atom as to) ((*(whitespace constraint)) as constraints) + compact_rule = "sub" whitespace (item_atom as from) whitespace (item_atom as to) ((*(whitespace constraint)) as constraints) [(when_guard)] - block_rule = "sub" [ws] "{" [ws] "from" whitespace (item as from) [ws] "to" whitespace (item as to) ((*(whitespace constraint)) as constraints) [ws] "}" + block_rule = "sub" [ws] "{" [ws] "from" whitespace (item as from) [ws] "to" whitespace (item as to) ((*(whitespace constraint)) as constraints) [(when_guard)] [ws] "}" + + when_lang = (!(keyword) identifier) as when_lang + when_guard = (whitespace "when" whitespace (when_lang) *(((comma_ws / whitespace) (when_lang)))) as when rule = block_rule / compact_rule @@ -172,12 +175,21 @@ grammar InterscriptIsc version "0.1.0" { run_rule = "run" whitespace ((run_dep) / run_stage_only) bare_rule = (rule) as bare_rule - stage_item = [ws] (sequence_block / parallel_block / run_rule / separate_directive / string_case_directive / compose_directive / rababa_directive / bare_rule / comment_item) [ws] + + use_alias = (identifier) as use_alias + use_qualified = "." (identifier as qualified_name) + use_overrides_part = whitespace "override" [ws] "{" [ws] ((*(rule_line)) as use_overrides) [ws] "}" + use_directive = (("use" whitespace (use_alias) [(use_qualified)] [(use_overrides_part)]) as use) + + stage_item = [ws] (sequence_block / parallel_block / run_rule / separate_directive / string_case_directive / compose_directive / rababa_directive / use_directive / bare_rule / comment_item) [ws] stage_items = *(stage_item) stage_name_paren = "(" (identifier as stage_name) ")" stage_name_ws = whitespace (identifier as stage_name) stage_block = "stage" (stage_name_paren / stage_name_ws) [ws] "{" [ws] (stage_items as stage) [ws] "}" + ruleset_name_ws = whitespace (identifier as ruleset_name) + ruleset_block = "ruleset" (ruleset_name_ws) [ws] "{" [ws] (stage_items as ruleset) [ws] "}" + # -- tests --------------------------------------------------------------- test_note = whitespace "note" whitespace (quoted_string as note) @@ -187,7 +199,10 @@ grammar InterscriptIsc version "0.1.0" { # -- system -------------------------------------------------------------- - block_item = [whitespace] (metadata_block / aliases_block / tests_block / stage_block / dependency_decl) [whitespace] + language_ident = (!(keyword) identifier) as language + languages_decl = ("languages" whitespace ((language_ident *(((comma_ws / whitespace) (language_ident))))) as languages) + + block_item = [whitespace] (metadata_block / aliases_block / tests_block / languages_decl / ruleset_block / stage_block / dependency_decl) [whitespace] body_items = *(block_item) system_block = "system" whitespace ((quoted_string) as system_code) [whitespace] "{" [ws] ((*(block_item)) as body) [ws] "}" [whitespace] isc_source = [whitespace] ((system_block) as system) [whitespace] diff --git a/lib/interscript/isc/model.rb b/lib/interscript/isc/model.rb index 47724820..102df383 100644 --- a/lib/interscript/isc/model.rb +++ b/lib/interscript/isc/model.rb @@ -10,6 +10,7 @@ module Model autoload :Rule, "interscript/isc/model/rule" autoload :StageItem, "interscript/isc/model/stage_item" autoload :Stage, "interscript/isc/model/stage" + autoload :Ruleset, "interscript/isc/model/ruleset" autoload :Alias, "interscript/isc/model/alias" autoload :Test, "interscript/isc/model/test" autoload :Dependency, "interscript/isc/model/dependency" diff --git a/lib/interscript/isc/model/document.rb b/lib/interscript/isc/model/document.rb index 9dbfa797..6db6cae3 100644 --- a/lib/interscript/isc/model/document.rb +++ b/lib/interscript/isc/model/document.rb @@ -4,6 +4,7 @@ require "interscript/isc/model/test" require "interscript/isc/model/alias" require "interscript/isc/model/stage" +require "interscript/isc/model/ruleset" require "interscript/isc/model/dependency" module Interscript @@ -15,6 +16,8 @@ class Document < Lutaml::Model::Serializable attribute :tests, Test, collection: true attribute :aliases, Alias, collection: true attribute :stages, Stage, collection: true + attribute :rulesets, Ruleset, collection: true + attribute :languages, :string, collection: true attribute :dependencies, Dependency, collection: true yaml do @@ -23,6 +26,8 @@ class Document < Lutaml::Model::Serializable map "tests", to: :tests map "aliases", to: :aliases map "stages", to: :stages + map "rulesets", to: :rulesets + map "languages", to: :languages map "dependencies", to: :dependencies end end diff --git a/lib/interscript/isc/model/rule.rb b/lib/interscript/isc/model/rule.rb index 3c0f7d8f..1d65e314 100644 --- a/lib/interscript/isc/model/rule.rb +++ b/lib/interscript/isc/model/rule.rb @@ -11,11 +11,13 @@ class Rule < Lutaml::Model::Serializable attribute :from, Item attribute :to, Item attribute :constraints, Constraint, collection: true + attribute :when_languages, :string, collection: true yaml do map "from", to: :from map "to", to: :to map "constraints", to: :constraints + map "when_languages", to: :when_languages end end end diff --git a/lib/interscript/isc/model/ruleset.rb b/lib/interscript/isc/model/ruleset.rb new file mode 100644 index 00000000..ebf100c7 --- /dev/null +++ b/lib/interscript/isc/model/ruleset.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "lutaml/model" +require "interscript/isc/model/stage_item" + +module Interscript + module Isc + module Model + # ISC-WF-1 W1: a named, reusable block of stage items (spec §3). + class Ruleset < Lutaml::Model::Serializable + attribute :name, :string + attribute :body, StageItem, collection: true + + yaml do + map "name", to: :name + map "body", to: :body + end + end + end + end +end diff --git a/lib/interscript/isc/model/stage_item.rb b/lib/interscript/isc/model/stage_item.rb index 2b7568ed..dd62373d 100644 --- a/lib/interscript/isc/model/stage_item.rb +++ b/lib/interscript/isc/model/stage_item.rb @@ -8,7 +8,8 @@ module Interscript module Isc module Model # A single item in a stage body. Discriminated by `kind`: - # parallel, sequence, bare_rule, run, separate, compose, string_case + # parallel, sequence, bare_rule, run, use, separate, compose, + # string_case class StageItem < Lutaml::Model::Serializable attribute :kind, :string attribute :rules, Rule, collection: true @@ -17,6 +18,11 @@ class StageItem < Lutaml::Model::Serializable attribute :stage, :string attribute :separator, Item attribute :op, :string + # ISC-WF-1 W1/W2: the referenced ruleset and the per-site + # override rows; `dependency` carries the alias for qualified + # references (`use alias.`). + attribute :ruleset, :string + attribute :overrides, Rule, collection: true yaml do map "kind", to: :kind @@ -26,6 +32,8 @@ class StageItem < Lutaml::Model::Serializable map "stage", to: :stage map "separator", to: :separator map "op", to: :op + map "ruleset", to: :ruleset + map "overrides", to: :overrides end end end diff --git a/lib/interscript/isc/node_adapter.rb b/lib/interscript/isc/node_adapter.rb index 804366af..fb4b10d3 100644 --- a/lib/interscript/isc/node_adapter.rb +++ b/lib/interscript/isc/node_adapter.rb @@ -26,10 +26,16 @@ def initialize(isc_doc) @local_raw = Array(isc_doc[:aliases]).to_h { |a| [a[:name].to_sym, a[:value]] } @charset_cache = {} @resolving = [] + # ISC-WF-1 W1: memoized fully-expanded rulesets, keyed + # [dependency alias or nil, ruleset name]; @resolving_rulesets + # guards `use` cycles (a load error, spec §6.4). + @ruleset_memo = {} + @resolving_rulesets = [] end def build Interscript::Node::Document.new.tap do |doc| + @doc = doc doc.metadata = build_metadata doc.tests = build_tests # Maps are addressed by their file-derived name (locate() and @@ -55,6 +61,10 @@ def build # (grek_latn_any = any([grek_any, …])) resolve too. @imported = doc.imported_aliases doc.aliases = build_aliases(doc.name) + # ISC-WF-1: declarations before stage building — `use` expansion + # resolves against the document's rulesets and its dependencies'. + doc.languages = build_languages + doc.rulesets = build_rulesets(doc.name) build_stages(doc.name).each { |name, stage| doc.stages[name] = stage } end end @@ -126,39 +136,189 @@ def build_stages(doc_name) end end + # ISC-WF-1 W1: build + memoize every declared ruleset, fully + # expanded (nested `use` resolved, cycles rejected). + def build_rulesets(doc_name) + Array(@isc_doc[:rulesets]).each_with_object({}) do |rs, h| + h[rs[:name].to_sym] = resolve_ruleset(nil, rs[:name], doc_name: doc_name) + end + end + + def build_languages + return nil unless @isc_doc[:languages]&.any? + + Interscript::Node::LanguagesDecl.new(@isc_doc[:languages].map(&:to_sym)) + end + def build_stage(stage_def) stage = Interscript::Node::Stage.new(stage_def[:name].to_sym) - stage_def[:body].each do |item| - case item[:kind] - when :parallel - group = Interscript::Node::Group::Parallel.new - item[:rules].each { |r| group.children << build_rule(r) } - stage.children << group - when :sequence - item[:rules].each { |r| stage.children << build_rule(r) } - when :bare_rule - stage.children << build_rule(item[:rule]) - when :run - stage.children << build_run_rule(item) - when :separate - kwargs = {} - kwargs[:separator] = item[:separator].value if item[:separator] - stage.children << Interscript::Node::Rule::Funcall.new(:separate, **kwargs) - when :string_case - stage.children << Interscript::Node::Rule::Funcall.new(item[:op].to_sym, - **(item[:kwargs] || {}).transform_keys(&:to_sym)) - when :compose - stage.children << Interscript::Node::Rule::Funcall.new(:compose) - when :decompose - stage.children << Interscript::Node::Rule::Funcall.new(:decompose) - when :funcall - stage.children << Interscript::Node::Rule::Funcall.new( - item[:name].to_sym, - **item[:kwargs].transform_keys(&:to_sym) - ) + expand_items(stage_def[:body]).each { |node| stage.children << node } + stage + end + + # Build the Nodes for one ISC stage-item def (no `use` — that goes + # through expand_use). Returns a list; sequence blocks contribute + # their rules individually, parallel blocks one group. + def build_stage_item_nodes(item) + case item[:kind] + when :parallel + group = Interscript::Node::Group::Parallel.new + item[:rules].each { |r| group.children << build_rule(r) } + [group] + when :sequence + item[:rules].map { |r| build_rule(r) } + when :bare_rule + [build_rule(item[:rule])] + when :run + [build_run_rule(item)] + when :separate + kwargs = {} + kwargs[:separator] = item[:separator].value if item[:separator] + [Interscript::Node::Rule::Funcall.new(:separate, **kwargs)] + when :string_case + [Interscript::Node::Rule::Funcall.new(item[:op].to_sym, + **(item[:kwargs] || {}).transform_keys(&:to_sym))] + when :compose + [Interscript::Node::Rule::Funcall.new(:compose)] + when :decompose + [Interscript::Node::Rule::Funcall.new(:decompose)] + when :funcall + [Interscript::Node::Rule::Funcall.new( + item[:name].to_sym, + **item[:kwargs].transform_keys(&:to_sym) + )] + else + [] + end + end + + # -- ISC-WF-1 W1/W2: ruleset expansion -------------------------------- + + # Expand a list of ISC stage-item defs into Node children, resolving + # `use` directives recursively. Used for both stage bodies and + # ruleset bodies (a ruleset body is a stage body, spec §3.1). + def expand_items(items) + Array(items).flat_map do |item| + if item.is_a?(Hash) && item[:kind] == :use + expand_use(item) + else + build_stage_item_nodes(item) end end - stage + end + + # Resolve + deep-copy + merge-override one `use` site (spec §3.2, §4). + def expand_use(item) + rs = resolve_ruleset(item[:dependency], item[:ruleset]) + apply_overrides(deep_copy_children(rs.children), item[:overrides]) + end + + # The fully-expanded Node::Ruleset for a reference, memoized. + # Same-map rulesets build here; dependency rulesets arrive already + # built through the recursive adapter of the dependency document. + def resolve_ruleset(dep_alias, name, doc_name: nil) + key = [dep_alias&.to_sym, name.to_sym] + return @ruleset_memo[key] if @ruleset_memo&.key?(key) + raise cycle_error(name) if @resolving_rulesets.include?(key) + + @resolving_rulesets << key + node = + if dep_alias + dep = @doc.dep_aliases[dep_alias.to_sym] || + raise(Interscript::MapLogicError, + "use #{dep_alias}.#{name}: no declared dependency aliased #{dep_alias}") + dep.document&.rulesets&.[](name.to_sym) || + raise(Interscript::MapLogicError, + "use #{dep_alias}.#{name}: ruleset not found in dependency #{dep.full_name}") + else + rs_def = Array(@isc_doc[:rulesets]).find { |r| r[:name] == name } || + raise(Interscript::MapLogicError, "use #{name}: ruleset not found") + Interscript::Node::Ruleset.new(rs_def[:name].to_sym).tap do |rs| + rs.doc_name = doc_name + expand_items(rs_def[:body]).each { |child| rs.children << child } + end + end + @ruleset_memo[key] = node + node + ensure + @resolving_rulesets.delete(key) + end + + def cycle_error(name) + Interscript::MapLogicError.new( + "use cycle: ruleset #{name} revisited through #{@resolving_rulesets.map { |_, n| n }.join(" -> ")}" + ) + end + + # Per-site deep copy: expansions must not share Group nodes (the + # Interpreter caches compiled pass trees on them) nor be mutated by + # another site's override merge. Rule/Item leaves are read-only at + # execution time and may be shared. + def deep_copy_children(children) + children.map { |node| deep_copy_node(node) } + end + + def deep_copy_node(node) + case node + when Interscript::Node::Group + copy = node.class.new(reverse_run: node.reverse_run) + if node.is_a?(Interscript::Node::Stage) + copy.name = node.name + copy.doc_name = node.doc_name + copy.dont_reverse = node.dont_reverse + end + copy.children = node.children.map { |c| deep_copy_node(c) } + copy + else + node.dup + end + end + + # ISC-WF-1 W2 merge (spec §4.2). R = the fully expanded ruleset rows + # (document order), O = the override rows. + # 1. A row whose from is byte-exact equal to an override from is + # REPLACED — removed from wherever it sits; all same-from + # duplicates go (the ruleset's internal last-wins winner is + # superseded too). From-equality is computed on the node's + # structural inspect — for plain strings this is exactly the + # byte-exact string of the corpus convention. + # 2. Kept rows keep their order and block structure (§4.2.2 with + # the §3.2 pass-structure inheritance). + # 3. The override rows land at the expansion tail: appended INTO + # the final parallel block when the expansion ends with one, so + # the child rows join that block's single longest-match pass + # (§4.2.5, §4.3 — replacement on the source-script pass, not + # post-pass re-substitution); otherwise appended as trailing + # rows. Unmatched overrides are simply added (§4.2.4). + def apply_overrides(children, overrides) + return children if overrides.nil? || overrides.empty? + + o_rules = overrides.map { |r| build_rule(r) } + o_keys = o_rules.map { |r| from_key(r) } + + kept = children.filter_map do |node| + case node + when Interscript::Node::Group::Parallel + node.children.reject! { |c| c.is_a?(Interscript::Node::Rule::Sub) && o_keys.include?(from_key(c)) } + node + when Interscript::Node::Rule::Sub + o_keys.include?(from_key(node)) ? nil : node + else + node + end + end + + tail = kept.last + if tail.is_a?(Interscript::Node::Group::Parallel) + tail.children.push(*o_rules) + else + kept.push(*o_rules) + end + kept + end + + def from_key(rule) + rule.from.inspect end def build_rule(rule_def) @@ -170,6 +330,9 @@ def build_rule(rule_def) constraint = rule_def[:constraints].find { |c| c[:kind] == k } opts[k] = convert_item(constraint[:item], :class) end + # ISC-WF-1 W3: the language guard rides on the rule; inert unless + # the interpreter selects a declared language. + opts[:when_languages] = rule_def[:when_languages] if rule_def[:when_languages]&.any? Interscript::Node::Rule::Sub.new(from, to, **opts) end diff --git a/lib/interscript/isc/normalizer.rb b/lib/interscript/isc/normalizer.rb index 7fb4bc8d..88a18aee 100644 --- a/lib/interscript/isc/normalizer.rb +++ b/lib/interscript/isc/normalizer.rb @@ -69,6 +69,16 @@ def normalize_block(block) {tests: merge_tests(items)} elsif block.key?(:stage) {stage_name: scalar(block[:stage_name]), stage: normalize_stage_items(block[:stage])} + elsif block.key?(:ruleset) + # ISC-WF-1 W1: same shape as a stage, under the ruleset key. + {ruleset_name: scalar(block[:ruleset_name]), ruleset: normalize_stage_items(block[:ruleset])} + elsif block.key?(:languages) + # ISC-WF-1 W3: `languages a, b` folds to a plain list of names. + # One language captures as a single {language:} hash, several as + # a list. + langs = block[:languages] + list = langs.is_a?(Hash) ? [langs] : Array(langs) + {languages: list.map { |l| l.is_a?(Hash) ? scalar(l[:language]) : scalar(l) }} elsif block.key?(:target) {target: scalar(block[:target]), alias: scalar(block[:alias])}.compact else @@ -85,6 +95,17 @@ def normalize_stage_items(items) %i[parallel sequence].each do |k| item = {k => []} if item.key?(k) && !item[k].is_a?(Array) end + # ISC-WF-1 W1/W2: `use` items get their reference identifiers + # folded to strings; the override rules keep their parslet + # shape (DocumentBuilder materializes them through Transform). + if item.key?(:use) && item[:use].is_a?(Hash) + use = item[:use] + item = {use: { + use_alias: use[:use_alias] && scalar(use[:use_alias]), + qualified_name: use[:qualified_name] && scalar(use[:qualified_name]), + use_overrides: use[:use_overrides] + }.compact} + end end item end diff --git a/lib/interscript/isc/serializer.rb b/lib/interscript/isc/serializer.rb index 84178011..dd8cd7bb 100644 --- a/lib/interscript/isc/serializer.rb +++ b/lib/interscript/isc/serializer.rb @@ -21,10 +21,12 @@ def initialize(doc_hash) def serialize emit_system_open + emit_languages if @doc[:languages]&.any? emit_metadata if @doc[:metadata]&.any? emit_tests if @doc[:tests]&.any? emit_aliases if @doc[:aliases]&.any? emit_dependencies if @doc[:dependencies]&.any? + emit_rulesets emit_stages emit_system_close @out @@ -40,6 +42,21 @@ def emit_system_close @out << "}\n" end + # ISC-WF-1 W3: the map's declared languages. + def emit_languages + @out << "\nlanguages #{@doc[:languages].join(" ")}\n" + end + + # ISC-WF-1 W1: named rulesets, emitted ahead of the stages that + # reference them. + def emit_rulesets + @doc[:rulesets]&.each do |rs| + @out << "\nruleset #{rs[:name]} {\n" + rs[:body].each { |item| emit_stage_item(item) } + @out << "}\n" + end + end + def emit_blank @out << "\n" end @@ -157,6 +174,19 @@ def emit_stage_item(item) @out << " }\n" when :bare_rule emit_rule(item[:rule], 2) + when :use + # ISC-WF-1 W1/W2: `use . [override { rows }]` — the + # override block is part of the use directive (one syntax, one + # site, spec §4.1). + ref = item[:dependency] ? "#{item[:dependency]}.#{item[:ruleset]}" : item[:ruleset] + overrides = item[:overrides] + if overrides&.any? + @out << " use #{ref} override {\n" + overrides.each { |r| emit_rule(r, 4) } + @out << " }\n" + else + @out << " use #{ref}\n" + end when :run dep = item[:dependency] stage = item[:stage] @@ -179,6 +209,8 @@ def emit_rule(rule, indent) pad = " " * indent from_str = emit_item(rule[:from]) to_str = emit_item(rule[:to]) + # ISC-WF-1 W3: the language guard trails the constraints. + when_str = rule[:when_languages]&.any? ? "when #{rule[:when_languages].join(" ")}" : nil needs_block = rule[:from].is_a?(Items::Concat) || rule[:to].is_a?(Items::Concat) @@ -188,11 +220,13 @@ def emit_rule(rule, indent) @out << "#{inner}from #{from_str}\n" @out << "#{inner}to #{to_str}\n" rule[:constraints]&.each { |c| @out << "#{inner}#{emit_constraint(c)}\n" } + @out << "#{inner}#{when_str}\n" if when_str @out << "#{pad}}\n" else constraints_str = rule[:constraints]&.map { |c| emit_constraint(c) }&.join(" ") || "" @out << "#{pad}sub #{from_str} #{to_str}" @out << " #{constraints_str}" unless constraints_str.empty? + @out << " #{when_str}" if when_str @out << "\n" end end diff --git a/lib/interscript/isc/transform.rb b/lib/interscript/isc/transform.rb index 757c99c6..ff275ecd 100644 --- a/lib/interscript/isc/transform.rb +++ b/lib/interscript/isc/transform.rb @@ -133,6 +133,21 @@ def materialize_item(fragment) rule(not_before: subtree(:x)) { {kind: :not_before, item: x} } rule(not_after: subtree(:x)) { {kind: :not_after, item: x} } rule(constraints: sequence(:c)) { c } + + # ISC-WF-1 W3: a `when` guard folds to the plain list of language + # names. A single language captures as one {when_lang:} hash, + # several as a list; identifier leaves may still be raw slices at + # this point, so decode them here. + rule(when: subtree(:langs)) do + list = langs.is_a?(Hash) ? [langs] : Array(langs) + list.map do |l| + lang = l.is_a?(Hash) ? l[:when_lang] : l + case lang + when Hash then lang[:identifier].to_s + else lang.to_s + end + end.compact + end end end end diff --git a/lib/interscript/isc/yaml_bridge.rb b/lib/interscript/isc/yaml_bridge.rb index 7ca6c119..60cfb868 100644 --- a/lib/interscript/isc/yaml_bridge.rb +++ b/lib/interscript/isc/yaml_bridge.rb @@ -31,6 +31,8 @@ def hash_to_model(doc_hash) tests: (doc_hash[:tests] || []).map { |t| Model::Test.new(t.transform_keys(&:to_s)) }, aliases: (doc_hash[:aliases] || []).map { |a| build_alias_model(a) }, stages: (doc_hash[:stages] || []).map { |s| build_stage_model(s) }, + rulesets: (doc_hash[:rulesets] || []).map { |r| build_ruleset_model(r) }, + languages: doc_hash[:languages], dependencies: (doc_hash[:dependencies] || []).map { |d| build_dependency_model(d) } ) end @@ -63,6 +65,13 @@ def build_stage_model(stage_hash) ) end + def build_ruleset_model(ruleset_hash) + Model::Ruleset.new( + name: ruleset_hash[:name], + body: (ruleset_hash[:body] || []).map { |item| stage_item_to_model(item) } + ) + end + def build_dependency_model(dep_hash) Model::Dependency.new( target: dep_hash[:target], @@ -80,6 +89,11 @@ def stage_item_to_model(item) when :run attrs[:dependency] = item[:dependency] attrs[:stage] = item[:stage] + when :use + # ISC-WF-1 W1/W2 + attrs[:dependency] = item[:dependency] + attrs[:ruleset] = item[:ruleset] + attrs[:overrides] = (item[:overrides] || []).map { |r| rule_to_model(r) } when :separate attrs[:separator] = item[:separator] ? item_to_model(item[:separator]) : nil when :string_case @@ -92,7 +106,8 @@ def rule_to_model(rule_hash) Model::Rule.new( from: item_to_model(rule_hash[:from]), to: item_to_model(rule_hash[:to]), - constraints: (rule_hash[:constraints] || []).map { |c| constraint_to_model(c) } + constraints: (rule_hash[:constraints] || []).map { |c| constraint_to_model(c) }, + when_languages: rule_hash[:when_languages] ) end @@ -140,7 +155,7 @@ def item_to_model(item) # --- Reverse direction: model → document hash --- def model_to_hash(model) - { + hash = { schemaVersion: 1, systemCode: model.system_code, metadata: model_metadata_to_hash(model.metadata), @@ -149,6 +164,11 @@ def model_to_hash(model) stages: (model.stages || []).map { |s| stage_model_to_hash(s) }, dependencies: (model.dependencies || []).map { |d| {target: d.target, alias: d.alias_name}.compact } } + # ISC-WF-1 keys round-trip only when present — pre-WF1 documents + # keep their exact hash shape. + hash[:languages] = model.languages if model.languages&.any? + hash[:rulesets] = (model.rulesets || []).map { |r| ruleset_model_to_hash(r) } if model.rulesets&.any? + hash end def model_metadata_to_hash(meta) @@ -171,6 +191,11 @@ def stage_item_model_to_hash(item) {kind: :bare_rule, rule: rule_model_to_hash(item.rule)} when "run" {kind: :run, dependency: item.dependency, stage: item.stage} + when "use" + # ISC-WF-1 W1/W2 + hash = {kind: :use, ruleset: item.ruleset, dependency: item.dependency} + hash[:overrides] = (item.overrides || []).map { |r| rule_model_to_hash(r) } if item.overrides&.any? + hash.compact when "separate" {kind: :separate, separator: item.separator ? model_to_item(item.separator) : nil} when "compose" @@ -182,12 +207,21 @@ def stage_item_model_to_hash(item) end end - def rule_model_to_hash(rule_model) + def ruleset_model_to_hash(ruleset_model) { + name: ruleset_model.name, + body: ruleset_model.body.map { |item| stage_item_model_to_hash(item) } + } + end + + def rule_model_to_hash(rule_model) + hash = { from: model_to_item(rule_model.from), to: model_to_item(rule_model.to), constraints: (rule_model.constraints || []).map { |c| constraint_model_to_hash(c) } } + hash[:when_languages] = rule_model.when_languages if rule_model.when_languages&.any? + hash end def constraint_model_to_hash(constraint_model) diff --git a/lib/interscript/node.rb b/lib/interscript/node.rb index faa9915d..881eb8e6 100644 --- a/lib/interscript/node.rb +++ b/lib/interscript/node.rb @@ -8,6 +8,8 @@ class Interscript::Node autoload :Dependency, "interscript/node/dependency" autoload :Tests, "interscript/node/tests" autoload :Stage, "interscript/node/stage" + autoload :Ruleset, "interscript/node/ruleset" + autoload :LanguagesDecl, "interscript/node/languages_decl" autoload :Rule, "interscript/node/rule" autoload :Item, "interscript/node/item" diff --git a/lib/interscript/node/document.rb b/lib/interscript/node/document.rb index ee8485a4..46e9ade5 100644 --- a/lib/interscript/node/document.rb +++ b/lib/interscript/node/document.rb @@ -2,6 +2,9 @@ class Interscript::Node::Document attr_accessor :metadata, :tests, :name attr_accessor :dependencies, :aliases, :stages, :dep_aliases attr_accessor :reversed_from + # ISC-WF-1: map-declared languages (LanguagesDecl or nil) and the named + # rulesets available to `use` expansion (name => Node::Ruleset). + attr_accessor :languages, :rulesets def initialize puts "Interscript::Node::Document.new " if $DEBUG @@ -11,6 +14,8 @@ def initialize @dep_aliases = {} @aliases = {} @stages = {} + @languages = nil + @rulesets = {} end def imported_aliases @@ -69,11 +74,18 @@ def ==(other) end def to_hash - {class: self.class.to_s, metadata: @metadata&.to_hash, - tests: @tests&.to_hash, - dependencies: @dependencies.map { |x| x.to_hash }, - dep_aliases: @dep_aliases.transform_values(&:to_hash), - aliases: @aliases.transform_values(&:to_hash), - stages: @stages.transform_values(&:to_hash)} + hash = { + class: self.class.to_s, metadata: @metadata&.to_hash, + tests: @tests&.to_hash, + dependencies: @dependencies.map { |x| x.to_hash }, + dep_aliases: @dep_aliases.transform_values(&:to_hash), + aliases: @aliases.transform_values(&:to_hash), + stages: @stages.transform_values(&:to_hash) + } + # ISC-WF-1 keys appear only on maps that use the constructs — the IR + # of every pre-WF1 map stays byte-identical. + hash[:languages] = @languages.to_hash if @languages + hash[:rulesets] = @rulesets.transform_values(&:to_hash) if @rulesets&.any? + hash end end diff --git a/lib/interscript/node/group/parallel.rb b/lib/interscript/node/group/parallel.rb index f510c5bd..209f5ac1 100644 --- a/lib/interscript/node/group/parallel.rb +++ b/lib/interscript/node/group/parallel.rb @@ -2,6 +2,10 @@ class Interscript::Node::Group::Parallel < Interscript::Node::Group # A place for Interpreter to store a compiled form of the tree attr_accessor :cached_tree attr_accessor :subs_regexp, :subs_replacements, :subs_array + # ISC-WF-1 W3: per-selected-language filtered variants of this pass + # (selected language => parallel group). Only populated for passes + # that contain `when`-guarded rules. + attr_accessor :wf1_variants def inspect "parallel {\n#{super}\n}" diff --git a/lib/interscript/node/languages_decl.rb b/lib/interscript/node/languages_decl.rb new file mode 100644 index 00000000..cf0e9a0e --- /dev/null +++ b/lib/interscript/node/languages_decl.rb @@ -0,0 +1,26 @@ +class Interscript::Node::LanguagesDecl < Interscript::Node + # ISC-WF-1 W3: the languages a map serves (spec §5.1). Attached to + # Node::Document#languages; nil means "no declaration" — every `when` + # guard is inert regardless of caller selection. + attr_accessor :languages + + def initialize(languages = []) + self.languages = languages + end + + def include?(language) + languages.include?(language) + end + + def to_hash + {class: self.class.to_s, languages: languages} + end + + def ==(other) + super && languages == other.languages + end + + def inspect + "languages #{languages.join(" ")}" + end +end diff --git a/lib/interscript/node/rule.rb b/lib/interscript/node/rule.rb index 539507e4..7af47719 100644 --- a/lib/interscript/node/rule.rb +++ b/lib/interscript/node/rule.rb @@ -3,6 +3,7 @@ class Interscript::Node::Rule < Interscript::Node autoload :Sub, "interscript/node/rule/sub" autoload :Run, "interscript/node/rule/run" autoload :Funcall, "interscript/node/rule/funcall" + autoload :Use, "interscript/node/rule/use" def ==(other) super && reverse_run == other.reverse_run diff --git a/lib/interscript/node/rule/sub.rb b/lib/interscript/node/rule/sub.rb index 3a1472c4..522db6e9 100644 --- a/lib/interscript/node/rule/sub.rb +++ b/lib/interscript/node/rule/sub.rb @@ -4,11 +4,14 @@ class Interscript::Node::Rule::Sub < Interscript::Node::Rule attr_accessor :reverse_before, :reverse_not_before, :reverse_after, :reverse_not_after attr_accessor :reverse_run attr_accessor :priority + # ISC-WF-1 W3: the language guard (`when kur, chv`). nil = unguarded. + attr_accessor :when_languages def initialize(from, to, before: nil, not_before: nil, after: nil, not_after: nil, - priority: nil, reverse_run: nil) + priority: nil, reverse_run: nil, + when_languages: nil) self.from = Interscript::Node::Item.try_convert from self.to = if to == :upcase :upcase @@ -19,6 +22,7 @@ def initialize(from, to, end self.priority = priority + self.when_languages = when_languages&.map(&:to_sym) # raise TypeError, "Can't supply both before and not_before" if before && not_before # raise TypeError, "Can't supply both after and not_after" if after && not_after @@ -59,6 +63,7 @@ def to_hash hash[:after] = after&.to_hash if after hash[:not_after] = not_after&.to_hash if not_after hash[:priority] = priority if priority + hash[:when_languages] = when_languages if when_languages hash end @@ -91,7 +96,8 @@ def reverse reverse_run: rrun, - priority: priority ? -priority : nil) + priority: priority ? -priority : nil, + when_languages: when_languages) end def has_assertions? @@ -202,7 +208,8 @@ def ==(other) after == other.after && not_before == other.not_before && not_after == other.not_after && - priority == other.priority + priority == other.priority && + when_languages == other.when_languages end def inspect @@ -222,6 +229,7 @@ def inspect params << "not_after: #{@not_after.inspect}" if @not_after params << "priority: #{@priority.inspect}" if @priority + params << "when: #{@when_languages.join(", ")}" if @when_languages out << params.join(", ") end # standard:disable Layout/TrailingEmptyLines (deliberate) diff --git a/lib/interscript/node/rule/use.rb b/lib/interscript/node/rule/use.rb new file mode 100644 index 00000000..064535a6 --- /dev/null +++ b/lib/interscript/node/rule/use.rb @@ -0,0 +1,31 @@ +class Interscript::Node::Rule::Use < Interscript::Node::Rule + # ISC-WF-1 W1/W2: a `use` directive before expansion. The NodeAdapter + # expands use directives at build time (macro expansion, spec §3.2), so + # this node only appears on documents built without expansion; it names + # the ruleset and, when present, the dependency alias and the per-site + # override rows. + attr_accessor :ruleset, :map, :overrides + + def initialize(ruleset, map: nil, overrides: []) + self.ruleset = ruleset + self.map = map + self.overrides = overrides + end + + def to_hash + {class: self.class.to_s, + ruleset: ruleset, + map: map, + overrides: overrides.map(&:to_hash)} + end + + def ==(other) + super && ruleset == other.ruleset && map == other.map && overrides == other.overrides + end + + def inspect + out = "use #{[map, ruleset].compact.map { |i| "@#{i}" }.join(".")}" + out += " override { #{overrides.map(&:inspect).join(", ")} }" unless overrides.empty? + out + end +end diff --git a/lib/interscript/node/ruleset.rb b/lib/interscript/node/ruleset.rb new file mode 100644 index 00000000..dd29ed9e --- /dev/null +++ b/lib/interscript/node/ruleset.rb @@ -0,0 +1,6 @@ +class Interscript::Node::Ruleset < Interscript::Node::Stage + # ISC-WF-1 W1: a named, reusable block of stage items. Lives on + # Node::Document#rulesets, never in #stages — a ruleset never executes + # on its own; it only reaches the runtime through `use` expansion + # (a macro expansion performed at adapter time). +end diff --git a/spec/interscript/fixtures/wf1/wf1-base.isc b/spec/interscript/fixtures/wf1/wf1-base.isc new file mode 100644 index 00000000..cf60a08d --- /dev/null +++ b/spec/interscript/fixtures/wf1/wf1-base.isc @@ -0,0 +1,27 @@ +system "WF1:base-thai-Thai:Latn:2026" { + metadata { + authority_id wf1-test + id 2026 + name "WF1 base shared machinery" + } + + # W1: the shared block the dependent map imports with an override. + ruleset leading_vowels { + parallel { + sub "ะ" "a" + sub "า" "ā" + sub "ิ" "i" + } + } + + tests { + "กะาิ" -> "kaāi" + } + + stage main { + use leading_vowels + parallel { + sub "ก" "k" + } + } +} diff --git a/spec/interscript/fixtures/wf1/wf1-dependent.isc b/spec/interscript/fixtures/wf1/wf1-dependent.isc new file mode 100644 index 00000000..98ab4d8b --- /dev/null +++ b/spec/interscript/fixtures/wf1/wf1-dependent.isc @@ -0,0 +1,27 @@ +system "WF1:dep-thai-Thai:Latn:2026" { + metadata { + authority_id wf1-test + id 2026 + name "WF1 dependent map" + } + + dependency "wf1-base" as base + + # W3: the languages this map serves. + languages tha + + tests { + "กะาิ" -> "gaāyy" + } + + stage main { + # W1/W2: dependency-declared ruleset, per-site override with a + # language guard (the guard applies to that row alone, spec §4.2.3). + use base.leading_vowels override { + sub "ิ" "yy" when tha + } + parallel { + sub "ก" "g" + } + } +} diff --git a/spec/interscript/isc/wf1_fixtures_spec.rb b/spec/interscript/isc/wf1_fixtures_spec.rb new file mode 100644 index 00000000..d0887f15 --- /dev/null +++ b/spec/interscript/isc/wf1_fixtures_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require "interscript" + +# ISC-WF-1 fixture specs: synthetic maps (a base ruleset map + a +# dependent map using use/override across the dependency boundary) run +# through the full Interscript.transliterate path, with and without +# language selection (spec docs/isc-wf-1.md §8.1-style composition). +RSpec.describe "ISC-WF-1 fixtures" do + let(:fixture_dir) { File.expand_path("../fixtures/wf1", __dir__) } + + before do + # Scoped to these examples: locate() resolves only inside the fixture + # directory while they run. The stub replaces the method, so any + # memoized load path of the host process is neither read nor written. + allow(Interscript).to receive(:load_path).and_return([fixture_dir]) + allow(Interscript).to receive(:map_aliases).and_return({}) + end + + describe "the base map" do + it "expands its own ruleset inline" do + expect(Interscript.transliterate("wf1-base", "กะาิ")).to eq("kaāi") + end + end + + describe "the dependent map" do + it "imports the base ruleset and overrides one row across the dependency boundary" do + # "ะ" → a, "า" → ā imported; "ก" → g own row. + expect(Interscript.transliterate("wf1-dependent", "กะา")).to eq("gaā") + end + + it "applies the guarded override under the declared language" do + expect(Interscript.transliterate("wf1-dependent", "กะาิ", language: "tha")).to eq("gaāyy") + end + + it "leaves the guarded override inert without a language selection" do + # The replacement happened structurally at load (spec §4.2.3: the + # guard applies to that row alone), so without a selection the + # imported "ิ" row is gone and the source character falls through. + expect(Interscript.transliterate("wf1-dependent", "กะาิ")).to eq("gaāิ") + end + + it "leaves the guarded override inert for an undeclared language" do + expect(Interscript.transliterate("wf1-dependent", "กิ", language: "ckv")).to eq("gิ") + end + + it "keeps the base map's own behavior untouched" do + expect(Interscript.transliterate("wf1-base", "กิ")).to eq("ki") + end + end + + describe "Interscript.transliterate language kwarg" do + it "passes the selection through to the Interpreter" do + expect(Interscript.transliterate("wf1-dependent", "กิ", language: :tha)).to eq("gyy") + end + + it "is rejected with a clear error on the non-Interpreter compilers" do + # Only the Interpreter implements selection; Ruby's positional-hash + # rules would otherwise silently swallow the kwarg in the compiled + # runtimes' call(str, stage). + expect { + Interscript.transliterate("wf1-dependent", "ก", compiler: Interscript::Compiler::Ruby, language: "tha") + }.to raise_error(ArgumentError, /only supported by Interscript::Interpreter/) + end + end +end diff --git a/spec/interscript/isc/wf1_grammar_spec.rb b/spec/interscript/isc/wf1_grammar_spec.rb new file mode 100644 index 00000000..99861b23 --- /dev/null +++ b/spec/interscript/isc/wf1_grammar_spec.rb @@ -0,0 +1,193 @@ +# frozen_string_literal: true + +require "interscript/isc" +require "interscript" + +# ISC-WF-1 unit specs: the four new constructs at the parser/builder +# layer (spec docs/isc-wf-1.md §2). +RSpec.describe "ISC-WF-1 grammar" do + def parse_doc(src, filename = "wf1.isc") + tree = Interscript::Isc::Parser.parse(src, filename: filename) + Interscript::Isc::DocumentBuilder.build(tree, filename: filename) + end + + describe "W3 languages declaration" do + it "parses space-separated languages in the system block" do + doc = parse_doc(<<~ISC) + system "T:e-L:Latn:1" { + languages rus bel ukr + stage main { sub "a" "b" } + } + ISC + expect(doc[:languages]).to eq(%w[rus bel ukr]) + end + + it "parses comma-separated languages" do + doc = parse_doc(<<~ISC) + system "T:e-L:Latn:1" { + languages rus, bel, ukr + stage main { sub "a" "b" } + } + ISC + expect(doc[:languages]).to eq(%w[rus bel ukr]) + end + + it "keeps a single language" do + doc = parse_doc(%(system "T:e-L:Latn:1" {\nlanguages kur\nstage main { sub "a" "b" }\n}\n)) + expect(doc[:languages]).to eq(%w[kur]) + end + + it "stops the language list at the next block item" do + doc = parse_doc(<<~ISC) + system "T:e-L:Latn:1" { + languages rus bel + stage main { + sub "a" "b" + } + } + ISC + expect(doc[:languages]).to eq(%w[rus bel]) + expect(doc[:stages].size).to eq(1) + end + + it "omits the languages key on maps without the declaration" do + doc = parse_doc(%(system "T:e-L:Latn:1" {\nstage main { sub "a" "b" }\n}\n)) + expect(doc).not_to have_key(:languages) + end + end + + describe "W1 ruleset declaration" do + it "parses a ruleset block at map top level" do + doc = parse_doc(<<~ISC) + system "T:e-L:Latn:1" { + ruleset leading { + parallel { + sub "a" "b" + } + sub "c" "d" + } + stage main { sub "e" "f" } + } + ISC + expect(doc[:rulesets].size).to eq(1) + rs = doc[:rulesets].first + expect(rs[:name]).to eq("leading") + expect(rs[:body].map { |i| i[:kind] }).to eq(%i[parallel bare_rule]) + expect(rs[:body].first[:rules].size).to eq(1) + end + + it "keeps rulesets out of the stages list" do + doc = parse_doc(<<~ISC) + system "T:e-L:Latn:1" { + ruleset r { sub "a" "b" } + stage main { sub "c" "d" } + } + ISC + expect(doc[:stages].map { |s| s[:name] }).to eq(%w[main]) + expect(doc[:rulesets].map { |r| r[:name] }).to eq(%w[r]) + end + + it "rejects stray identifiers in stage bodies as before" do + src = %(system "T:e-L:Latn:1" {\nstage main {\n bogus_token\n}\n}\n) + expect { Interscript::Isc::Parser.parse(src) }.to raise_error(Interscript::Isc::ParseError) + end + end + + describe "W1/W2 use directive" do + it "parses a plain same-map reference" do + doc = parse_doc(<<~ISC) + system "T:e-L:Latn:1" { + stage main { + use leading + sub "a" "b" + } + } + ISC + item = doc[:stages].first[:body].first + expect(item[:kind]).to eq(:use) + expect(item[:ruleset]).to eq("leading") + expect(item[:dependency]).to be_nil + expect(item).not_to have_key(:overrides) + end + + it "parses a dependency-qualified reference" do + doc = parse_doc(<<~ISC) + system "T:e-L:Latn:1" { + stage main { + use shared.leading + } + } + ISC + item = doc[:stages].first[:body].first + expect(item[:ruleset]).to eq("leading") + expect(item[:dependency]).to eq("shared") + end + + it "parses an override block with replacement rows" do + doc = parse_doc(<<~ISC) + system "T:e-L:Latn:1" { + stage main { + use leading override { + sub "ะ" "a" + sub "ิ" "yy" when tha + } + } + } + ISC + item = doc[:stages].first[:body].first + expect(item[:overrides].size).to eq(2) + expect(item[:overrides].first[:from]).to eq(Interscript::Isc::Items::StringValue.new("ะ")) + expect(item[:overrides].first[:when_languages]).to be_nil + expect(item[:overrides].last[:when_languages]).to eq(%w[tha]) + end + + it "keeps the override as part of the use directive (one site)" do + # An override block with no preceding use must not parse. + src = %(system "T:e-L:Latn:1" {\nstage main {\n override {\n sub "a" "b"\n }\n}\n}\n) + expect { Interscript::Isc::Parser.parse(src) }.to raise_error(Interscript::Isc::ParseError) + end + end + + describe "W3 when guard" do + it "parses a single-language guard on a compact rule" do + doc = parse_doc(%(system "T:e-L:Latn:1" {\nstage main {\nsub "ҫ" "ș" when chv\n}\n}\n)) + rule = doc[:stages].first[:body].first[:rule] + expect(rule[:when_languages]).to eq(%w[chv]) + end + + it "parses a multi-language guard" do + doc = parse_doc(%(system "T:e-L:Latn:1" {\nstage main {\nsub "a" "b" when rus, bel\n}\n}\n)) + rule = doc[:stages].first[:body].first[:rule] + expect(rule[:when_languages]).to eq(%w[rus bel]) + end + + it "parses a guard after constraints" do + doc = parse_doc(%(system "T:e-L:Latn:1" {\nstage main {\nsub "a" "b" not_before "x" when kur\n}\n}\n)) + rule = doc[:stages].first[:body].first[:rule] + expect(rule[:constraints].first[:kind]).to eq(:not_before) + expect(rule[:when_languages]).to eq(%w[kur]) + end + + it "parses a guard in block form" do + doc = parse_doc(<<~ISC) + system "T:e-L:Latn:1" { + stage main { + sub { + from "a" + to "b" + when kur + } + } + } + ISC + rule = doc[:stages].first[:body].first[:rule] + expect(rule[:when_languages]).to eq(%w[kur]) + end + + it "omits when_languages on unguarded rules" do + doc = parse_doc(%(system "T:e-L:Latn:1" {\nstage main {\nsub "a" "b"\n}\n}\n)) + rule = doc[:stages].first[:body].first[:rule] + expect(rule).not_to have_key(:when_languages) + end + end +end diff --git a/spec/interscript/isc/wf1_round_trip_spec.rb b/spec/interscript/isc/wf1_round_trip_spec.rb new file mode 100644 index 00000000..bb72e7d6 --- /dev/null +++ b/spec/interscript/isc/wf1_round_trip_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require "interscript/isc" +require "interscript" + +# ISC-WF-1 round-trip specs: the new constructs survive +# parse → builder → YAML bridge → serializer → re-parse (spec docs/ +# isc-wf-1.md §2; the existing round-trip suite stays green untouched). +RSpec.describe "ISC-WF-1 round trip" do + def parse_isc(src, filename = "wf1.isc") + tree = Interscript::Isc::Parser.parse(src, filename: filename) + Interscript::Isc::DocumentBuilder.build(tree, filename: filename) + end + + def round_trip(doc_hash) + yaml = Interscript::Isc::YamlBridge.to_yaml(doc_hash) + doc_back = Interscript::Isc::YamlBridge.from_yaml(yaml) + isc = Interscript::Isc::Serializer.serialize(doc_back) + parse_isc(isc, "round-trip.isc") + end + + it "round-trips languages, rulesets, use-with-override and when guards" do + src = <<~ISC + system "T:rus-Cyrl:Latn:9" { + languages kur ckv + ruleset leading { + parallel { + sub "қ" "ķ" + sub "ҫ" "ș" when ckv + } + } + stage main { + use leading override { + sub "қ" "k" when kur + } + parallel { + sub "б" "b" + } + } + } + ISC + doc1 = parse_isc(src) + doc2 = round_trip(doc1) + + expect(doc2[:languages]).to eq(%w[kur ckv]) + expect(doc2[:rulesets].size).to eq(1) + expect(doc2[:rulesets].first[:name]).to eq("leading") + expect(doc2[:stages].first[:body].first[:kind]).to eq(:use) + expect(doc2[:stages].first[:body].first[:ruleset]).to eq("leading") + expect(doc2[:stages].first[:body].first[:overrides].size).to eq(1) + expect(doc2[:stages].first[:body].first[:overrides].first[:when_languages]).to eq(%w[kur]) + ruleset_rule = doc2[:rulesets].first[:body].first[:rules].last + expect(ruleset_rule[:when_languages]).to eq(%w[ckv]) + expect(doc1[:languages]).to eq(doc2[:languages]) + expect(doc1[:rulesets]).to eq(doc2[:rulesets]) + expect(doc1[:stages]).to eq(doc2[:stages]) + end + + it "keeps pre-WF1 documents free of the new keys" do + src = %(system "T:e-L:Latn:1" {\nstage main {\nsub "a" "b"\n}\n}\n) + doc = round_trip(parse_isc(src)) + expect(doc).not_to have_key(:languages) + expect(doc).not_to have_key(:rulesets) + end +end diff --git a/spec/interscript/isc/wf1_semantics_spec.rb b/spec/interscript/isc/wf1_semantics_spec.rb new file mode 100644 index 00000000..0ba27dfc --- /dev/null +++ b/spec/interscript/isc/wf1_semantics_spec.rb @@ -0,0 +1,437 @@ +# frozen_string_literal: true + +require "interscript/isc" +require "interscript" + +# ISC-WF-1 semantics specs: W1 expansion, W2 merge, W3 guard selection — +# exercised through DocumentBuilder → NodeAdapter → Interpreter +# (spec docs/isc-wf-1.md §§3–5). +RSpec.describe "ISC-WF-1 semantics" do + def build_node_doc(src, filename = "wf1.isc") + tree = Interscript::Isc::Parser.parse(src, filename: filename) + doc = Interscript::Isc::DocumentBuilder.build(tree, filename: filename) + Interscript::Isc::NodeAdapter.to_interscript_node(doc) + end + + def interpreter_for(src) + Interscript::Interpreter.new.compile(build_node_doc(src)) + end + + describe "W1 expansion" do + let(:src) do + <<~ISC + system "T:e-L:Latn:1" { + ruleset double { + sub "x" "1" + parallel { + sub "y" "2" + sub "z" "3" + } + } + stage main { + sub "a" "A" + use double + sub "b" "B" + } + } + ISC + end + + it "expands the ruleset in place, in document order" do + doc = build_node_doc(src) + stage = doc.stages[:main] + kinds = stage.children.map { |c| c.class.name.split("::").last } + # textual-equivalent: as if the ruleset items were written at the + # use position (spec §3.2). + expect(kinds).to eq(%w[Sub Sub Parallel Sub]) + expect(stage.children[0].from.data).to eq("a") + expect(stage.children[1].from.data).to eq("x") + expect(stage.children[2].children.map { |c| c.from.data }).to eq(%w[y z]) + expect(stage.children[3].from.data).to eq("b") + end + + it "keeps the ruleset out of doc.stages — it never executes alone" do + doc = build_node_doc(src) + expect(doc.stages.keys).to eq([:main]) + expect(doc.rulesets.keys).to eq([:double]) + end + + it "inherits the pass structure: parallel items join one pass" do + doc = build_node_doc(src) + group = doc.stages[:main].children[2] + expect(group).to be_a(Interscript::Node::Group::Parallel) + end + + it "expands recursively" do + doc = build_node_doc(<<~ISC) + system "T:e-L:Latn:1" { + ruleset inner { + sub "1" "one" + } + ruleset outer { + sub "0" "zero" + use inner + } + stage main { + use outer + } + } + ISC + children = doc.stages[:main].children + expect(children.map { |c| c.from.data }).to eq(%w[0 1]) + end + + it "rejects use cycles at load time" do + expect { + build_node_doc(<<~ISC) + system "T:e-L:Latn:1" { + ruleset a { use b } + ruleset b { use a } + stage main { use a } + } + ISC + }.to raise_error(Interscript::MapLogicError, /use cycle/) + end + + it "rejects self-cycles at load time" do + expect { + build_node_doc(<<~ISC) + system "T:e-L:Latn:1" { + ruleset a { use a } + stage main { use a } + } + ISC + }.to raise_error(Interscript::MapLogicError, /use cycle/) + end + + it "rejects references to unknown rulesets at load time" do + expect { + build_node_doc(%(system "T:e-L:Latn:1" {\nstage main { use nope }\n}\n)) + }.to raise_error(Interscript::MapLogicError, /not found/) + end + + it "gives every use site its own node copies" do + doc = build_node_doc(<<~ISC) + system "T:e-L:Latn:1" { + ruleset r { parallel { sub "a" "1" } } + stage main { + use r + use r + } + } + ISC + g1, g2 = doc.stages[:main].children + expect(g1).not_to equal(g2) + expect(g1.children.first).not_to equal(g2.children.first) + end + + it "resolves dependency-declared rulesets through the alias" do + fixture_dir = File.expand_path("../fixtures/wf1", __dir__) + # Scoped to this example: locate() resolves only inside the fixture + # directory while it runs; the memoized load path of the host + # process is untouched (the stub replaces the method). + allow(Interscript).to receive(:load_path).and_return([fixture_dir]) + allow(Interscript).to receive(:map_aliases).and_return({}) + + doc = Interscript::Compiler.parse_isc(File.join(fixture_dir, "wf1-dependent.isc")) + children = doc.stages[:main].children + expect(children.map { |c| c.class.name.split("::").last }).to eq(%w[Parallel Parallel]) + # "ะ"→a, "า"→ā kept from the imported ruleset; the guarded override + # row replaces the imported "ิ" row inside the same pass; then the + # stage's own parallel block. + imported = children.first.children + expect(imported.map { |c| [c.from.data, c.to.data] }).to eq([["ะ", "a"], ["า", "ā"], ["ิ", "yy"]]) + expect(imported.last.when_languages).to eq([:tha]) + end + end + + describe "W2 use-with-override merge" do + # Leading parallel block, so overridden rows join the same + # longest-match pass (spec §4.3). + let(:src) do + <<~ISC + system "T:e-L:Latn:1" { + ruleset r { + parallel { + sub "ะ" "a" + sub "ั" "han" + sub "า" "aa" + } + } + stage main { + use r override { + sub "ะ" "q" + sub "zzz" "unmatched" + } + sub "k" "K" + } + } + ISC + end + + it "replaces matching rows and keeps the rest in order" do + group = build_node_doc(src).stages[:main].children.first + froms = group.children.map { |c| c.from.data } + # R_kept (ั, า) in R's order, then the override rows (child-wins on + # exact from-equality, spec §4.2.1–2, 4). + expect(froms).to eq(%w[ั า ะ zzz]) + end + + it "appends the override rows into the trailing parallel pass" do + doc = build_node_doc(src) + children = doc.stages[:main].children + # One parallel group (with overrides in it), then the stage's own + # trailing rule. + expect(children.size).to eq(2) + expect(children.first).to be_a(Interscript::Node::Group::Parallel) + expect(children.first.children.last.to).to eq(Interscript::Node::Item::String.new("unmatched")) + expect(children.last.from.data).to eq("k") + end + + it "replaces ALL same-from rows, including the last-wins winner" do + doc = build_node_doc(<<~ISC) + system "T:e-L:Latn:1" { + ruleset r { + parallel { + sub "ะ" "a" + sub "ะ" "a2" + sub "า" "aa" + } + } + stage main { + use r override { + sub "ะ" "q" + } + } + } + ISC + group = doc.stages[:main].children.first + expect(group.children.map { |c| [c.from.data, c.to.data] }) + .to eq([["า", "aa"], ["ะ", "q"]]) + end + + it "replaces the imported row's constraints with the override row's" do + doc = build_node_doc(<<~ISC) + system "T:e-L:Latn:1" { + ruleset r { + parallel { + sub "ะ" "a" not_before "x" + } + } + stage main { + use r override { + sub "ะ" "q" before "y" + } + } + } + ISC + rule = doc.stages[:main].children.first.children.first + expect(rule.not_before).to be_nil + expect(rule.before).to eq(Interscript::Node::Item::String.new("y")) + end + + it "keeps the override row's own when guard on that row alone" do + doc = build_node_doc(<<~ISC) + system "T:e-L:Latn:1" { + languages tha + ruleset r { + parallel { + sub "ิ" "i" + } + } + stage main { + use r override { + sub "ิ" "yy" when tha + } + } + } + ISC + rule = doc.stages[:main].children.first.children.first + expect(rule.when_languages).to eq([:tha]) + end + + it "transliterates with the override winning over the imported row" do + i = interpreter_for(src) + expect(i.call("ะัาk")).to eq("qhanaaK") + end + + it "appends trailing bare rows when the expansion does not end in a parallel block" do + doc = build_node_doc(<<~ISC) + system "T:e-L:Latn:1" { + ruleset r { + sub "a" "1" + sub "b" "2" + } + stage main { + use r override { + sub "a" "9" + sub "c" "3" + } + } + } + ISC + children = doc.stages[:main].children + expect(children.map { |c| [c.from.data, c.to.data] }) + .to eq([["b", "2"], ["a", "9"], ["c", "3"]]) + end + + it "behaves exactly like the written-out expansion without overrides" do + expanded = interpreter_for(<<~ISC) + system "T:e-L:Latn:1" { + ruleset r { + parallel { + sub "ะ" "a" + sub "ั" "han" + } + } + stage main { + use r + sub "k" "K" + } + } + ISC + written = interpreter_for(<<~ISC) + system "T:e-L:Latn:1" { + stage main { + parallel { + sub "ะ" "a" + sub "ั" "han" + } + sub "k" "K" + } + } + ISC + expect(expanded.call("ะัk")).to eq(written.call("ะัk")) + end + end + + describe "W3 language conditioning" do + let(:iso9) do + <<~ISC + system "ISO:rus-Cyrl:Latn:9-1995" { + languages kur ckv + stage main { + parallel { + sub "қ" "ķ" + sub "қ" "k̦" when kur + sub "ҫ" "ş" + sub "ҫ" "ș" when ckv + } + } + } + ISC + end + + def iso9_interp + interpreter_for(iso9) + end + + it "renders the generic value with no language selected" do + expect(iso9_interp.call("қҫ")).to eq("ķş") + end + + it "applies the matching guard (kur)" do + expect(iso9_interp.call("қҫ", language: :kur)).to eq("k̦ş") + end + + it "applies the matching guard (ckv) and leaves others inert" do + expect(iso9_interp.call("қҫ", language: "ckv")).to eq("ķș") + end + + it "leaves every guard inert for a selection outside the declaration" do + expect(iso9_interp.call("қҫ", language: :zzz)).to eq("ķş") + end + + it "leaves every guard inert on a map without a declaration" do + i = interpreter_for(<<~ISC) + system "T:e-L:Latn:1" { + stage main { + parallel { + sub "a" "generic" + sub "a" "guarded" when kur + } + } + } + ISC + expect(i.call("a")).to eq("generic") + expect(i.call("a", language: :kur)).to eq("generic") + end + + it "makes guard-applicable rules win over unguarded same-from rules" do + expect(iso9_interp.call("қ", language: :kur)).to eq("k̦") + end + + it "carries the selection through run directives" do + i = interpreter_for(<<~ISC) + system "T:e-L:Latn:1" { + languages kur + stage pre { + parallel { + sub "a" "generic" + sub "a" "guarded" when kur + } + } + stage main { + run stage.pre + } + } + ISC + expect(i.call("a", language: :kur)).to eq("guarded") + expect(i.call("a")).to eq("generic") + end + + it "skips inert guarded rules in sequence blocks" do + i = interpreter_for(<<~ISC) + system "T:e-L:Latn:1" { + languages kur + stage main { + sequence { + sub "a" "b" + sub "b" "guarded" when kur + sub "b" "unguarded2" + } + } + } + ISC + expect(i.call("a", language: :kur)).to eq("guarded") + expect(i.call("a")).to eq("unguarded2") + end + + it "memoizes filtered variants per language without cross-contamination" do + i = iso9_interp + expect(i.call("қ")).to eq("ķ") + expect(i.call("қ", language: :kur)).to eq("k̦") + expect(i.call("қ")).to eq("ķ") + expect(i.call("қ", language: :ckv)).to eq("ķ") + end + end + + describe "Node layer" do + it "carries when_languages on Sub and reports it in inspect" do + doc = build_node_doc(%(system "T:e-L:Latn:1" {\nstage main {\nsub "a" "b" when kur, ckv\n}\n}\n)) + rule = doc.stages[:main].children.first + expect(rule).to be_a(Interscript::Node::Rule::Sub) + expect(rule.when_languages).to eq(%i[kur ckv]) + expect(rule.inspect).to include("when: kur, ckv") + end + + it "excludes unguarded rules from the to_hash WF1 key" do + rule = Interscript::Node::Rule::Sub.new("a", "b") + expect(rule.to_hash).not_to have_key(:when_languages) + guarded = Interscript::Node::Rule::Sub.new("a", "b", when_languages: %i[kur]) + expect(guarded.to_hash[:when_languages]).to eq(%i[kur]) + end + + it "exposes the declaration via Node::LanguagesDecl" do + doc = build_node_doc(%(system "T:e-L:Latn:1" {\nlanguages kur ckv\nstage main { sub "a" "b" }\n}\n)) + expect(doc.languages).to be_a(Interscript::Node::LanguagesDecl) + expect(doc.languages.include?(:kur)).to be(true) + expect(doc.languages.include?(:rus)).to be(false) + end + + it "keeps reversal guard-free for unguarded corpus rules" do + rule = Interscript::Node::Rule::Sub.new("a", "b").reverse + expect(rule.when_languages).to be_nil + end + end +end diff --git a/tools/isc_wf1_golden_gate.rb b/tools/isc_wf1_golden_gate.rb new file mode 100644 index 00000000..66b80f98 --- /dev/null +++ b/tools/isc_wf1_golden_gate.rb @@ -0,0 +1,105 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# ISC-WF-1 additivity gate (spec §7). Re-runs the golden capture through +# the Ruby runtime and reports the delta — against a saved pre-change +# capture (--baseline) and against the interscript-rs golden out fields +# (--parity). Rows whose map adopts WF1 constructs are exempt from the +# gate (the non-adopting set must be byte-stable). +# +# ruby tools/isc_wf1_golden_gate.rb \ +# --golden /path/to/interscript-rs/tests/golden/isc-wf0.jsonl \ +# --maps-root /path/to/interscript/maps \ +# --baseline /path/to/pre-change-ruby.jsonl --parity +# +# Exit status is non-zero when a gated delta is found. + +require "json" # standard:disable Lint/RedundantRequireStatement (not preloaded outside bundler) +require "optparse" +require "yaml" +require "interscript" + +options = { + golden: "tests/golden/isc-wf0.jsonl", + maps_root: ENV["ISC_MAPS_ROOT"] || "../maps" +} +OptionParser.new do |o| + o.on("--golden PATH") { |v| options[:golden] = v } + o.on("--maps-root PATH") { |v| options[:maps_root] = v } + o.on("--baseline PATH", "compare against a saved pre-change capture") { |v| options[:baseline] = v } + o.on("--parity", "also compare against the golden 'out' fields") { |v| options[:parity] = true } +end.parse!(ARGV) + +# The rubygems index is not assumed to work here; wire the registry in +# process by reopening Interscript with plain methods (no +# instance_variable access) — the proven bundler bypass. +maps_root = File.expand_path(options[:maps_root]) +maps_yaml = File.join(maps_root, "interscript-maps.yaml") +module Interscript + class << self + def gate_map_gems(yaml_path) + @map_gems = {yaml_path => YAML.load_file(yaml_path)} + end + + def gate_map_locations + @map_locations = map_gems.flat_map do |i, v| + Array(v["paths"]).map { |j| File.expand_path(j, File.dirname(i)) } + end + end + + def gate_map_aliases + @map_aliases = {} + end + end +end +Interscript.gate_map_gems(maps_yaml) +Interscript.gate_map_locations +Interscript.gate_map_aliases + +# Adopting maps (spec §7): rows of maps whose source uses WF1 constructs +# are exempt from the zero-delta requirement. The corpus has none at +# adoption time; the list lives next to the golden capture +# (interscript-rs tests/golden/adopting-maps.txt) once the first pilot +# lands. +adopting_path = File.join(File.dirname(options[:golden]), "adopting-maps.txt") +adopting = if File.exist?(adopting_path) + File.readlines(adopting_path, encoding: "UTF-8") + .reject { |l| l.start_with?("#") || l.strip.empty? } + .map(&:strip).to_set +else + Set.new +end + +rows = File.readlines(options[:golden], encoding: "UTF-8").map { |l| JSON.parse(l) } +captured = rows.map do |row| + out = begin + Interscript.transliterate(row["map"], row["src"]) + rescue StandardError, NotImplementedError => e + "ERROR:#{e.class}:#{e.message.lines.first.to_s.strip[0, 160]}" + end + {map: row["map"], src: row["src"], out: out, golden: row["out"]} +end + +baseline = options[:baseline] && + File.readlines(options[:baseline], encoding: "UTF-8").map { |l| JSON.parse(l) } + +gated = captured.reject { |r| adopting.include?(r[:map]) } +delta_vs_baseline = baseline && gated.zip(baseline).reject { |(g, b)| g[:out] == b["out"] } +delta_vs_golden = gated.reject { |r| r[:out] == r[:golden] } + +puts "rows: #{captured.size} (gate: #{gated.size}, adopting-exempt: #{captured.size - gated.size})" +if baseline + puts "delta vs baseline: #{delta_vs_baseline.size}" + delta_vs_baseline.first(10).each do |(g, b)| + puts " #{g[:map]} #{g[:src].inspect}: baseline=#{b["out"].inspect} now=#{g[:out].inspect}" + end +end +if options[:parity] + puts "delta vs golden (cross-runtime): #{delta_vs_golden.size}" + delta_vs_golden.first(10).each do |r| + puts " #{r[:map]} #{r[:src].inspect}: golden=#{r[:golden].inspect} ruby=#{r[:out].inspect}" + end +end + +failed = (delta_vs_baseline || []).any? || (options[:parity] ? delta_vs_golden.any? : false) +exit(failed ? 1 : 0)