Add koral:rewrite to query and corpus transformations
Change-Id: I97e3050d39b936256616bdf46203a784de6a3414
diff --git a/MAPPING.md b/MAPPING.md
index 3815431..205c452 100644
--- a/MAPPING.md
+++ b/MAPPING.md
@@ -12,11 +12,16 @@
layerA: source-layer
foundryB: target-foundry
layerB: target-layer
+rewrites: false # Optional: attach koral:rewrite annotations (default: false)
mappings:
- "[pattern1] <> [replacement1]"
- "[pattern2] <> [replacement2]"
```
+### `rewrites`
+
+When `rewrites` is set to `true`, each applied mapping rule produces a `koral:rewrite` annotation on the replacement node, recording what the original structure looked like before the transformation. This is off by default and can be activated per mapping list in the YAML configuration. Each mapping list can have a different default. The value can be overridden globally for all lists in a request via the `rewrites` query parameter (`true` or `false`). When used on composite endpoints (`/query/:cfg` or `/response/:cfg`), the `rewrites` query parameter applies uniformly to all mapping lists in the cascade, overriding each list's individual default.
+
Mapping files can also be embedded inside a main configuration file under the `lists:` key (see [README.md](README.md) for configuration file format).
Koral-Mapper supports two mapping types: **annotation** (the default) and **corpus**.
diff --git a/README.md b/README.md
index 46232ee..af43b62 100644
--- a/README.md
+++ b/README.md
@@ -69,6 +69,7 @@
layerA: source-layer
foundryB: target-foundry
layerB: target-layer
+ rewrites: false # Optional: attach koral:rewrite annotations (default: false)
mappings:
- "[pattern1] <> [replacement1]"
- "[pattern2] <> [replacement2]"
@@ -82,6 +83,7 @@
layerA: source-layer
foundryB: target-foundry
layerB: target-layer
+rewrites: false # Optional: attach koral:rewrite annotations (default: false)
mappings:
- "[pattern1] <> [replacement1]"
- "[pattern2] <> [replacement2]"
@@ -189,6 +191,7 @@
- `foundryB` (query): Override default foundryB from mapping list
- `layerA` (query): Override default layerA from mapping list
- `layerB` (query): Override default layerB from mapping list
+- `rewrites` (query): Override the mapping list's `rewrites` setting (`true` or `false`)
Request body: JSON object to transform
@@ -251,6 +254,7 @@
- `foundryB` (query): Override default foundryB from mapping list
- `layerA` (query): Override default layerA from mapping list
- `layerB` (query): Override default layerB from mapping list
+- `rewrites` (query): Override the mapping list's `rewrites` setting (`true` or `false`)
Request body: JSON object containing a `snippet` field with HTML markup
diff --git a/ast/ast.go b/ast/ast.go
index 6461a7b..918a8cd 100644
--- a/ast/ast.go
+++ b/ast/ast.go
@@ -450,3 +450,47 @@
// For unknown node types, return as is
return node
}
+
+// Rewriteable is implemented by AST nodes that carry a rewrites slice.
+type Rewriteable interface {
+ GetRewrites() []Rewrite
+ SetRewrites([]Rewrite)
+}
+
+func (t *Term) GetRewrites() []Rewrite { return t.Rewrites }
+func (t *Term) SetRewrites(r []Rewrite) { t.Rewrites = r }
+func (tg *TermGroup) GetRewrites() []Rewrite { return tg.Rewrites }
+func (tg *TermGroup) SetRewrites(r []Rewrite) { tg.Rewrites = r }
+func (t *Token) GetRewrites() []Rewrite { return t.Rewrites }
+func (t *Token) SetRewrites(r []Rewrite) { t.Rewrites = r }
+
+// AppendRewrite appends a rewrite to any Rewriteable node.
+// Non-Rewriteable nodes (e.g. CatchallNode) are silently ignored.
+func AppendRewrite(node Node, rw Rewrite) {
+ if r, ok := node.(Rewriteable); ok {
+ r.SetRewrites(append(r.GetRewrites(), rw))
+ }
+}
+
+// StripRewrites recursively removes all rewrites from an AST tree.
+func StripRewrites(node Node) {
+ if node == nil {
+ return
+ }
+ if r, ok := node.(Rewriteable); ok {
+ r.SetRewrites(nil)
+ }
+ switch n := node.(type) {
+ case *Token:
+ StripRewrites(n.Wrap)
+ case *TermGroup:
+ for _, op := range n.Operands {
+ StripRewrites(op)
+ }
+ case *CatchallNode:
+ StripRewrites(n.Wrap)
+ for _, op := range n.Operands {
+ StripRewrites(op)
+ }
+ }
+}
diff --git a/ast/rewrite_test.go b/ast/rewrite_test.go
index 63f5e53..4fe0ffb 100644
--- a/ast/rewrite_test.go
+++ b/ast/rewrite_test.go
@@ -197,6 +197,176 @@
assert.Equal(t, "legacy-origin", rewrites[1].Src)
}
+func TestRewriteableInterface(t *testing.T) {
+ t.Run("Term implements Rewriteable", func(t *testing.T) {
+ term := &Term{Foundry: "opennlp", Key: "DET", Layer: "p", Match: MatchEqual}
+
+ var r Rewriteable = term
+ assert.Nil(t, r.GetRewrites())
+
+ rewrites := []Rewrite{{Editor: "test", Scope: "foundry"}}
+ r.SetRewrites(rewrites)
+ assert.Equal(t, rewrites, r.GetRewrites())
+ assert.Equal(t, rewrites, term.Rewrites)
+ })
+
+ t.Run("TermGroup implements Rewriteable", func(t *testing.T) {
+ tg := &TermGroup{
+ Operands: []Node{&Term{Key: "A", Match: MatchEqual}},
+ Relation: AndRelation,
+ }
+
+ var r Rewriteable = tg
+ assert.Nil(t, r.GetRewrites())
+
+ rewrites := []Rewrite{{Editor: "editor", Scope: "layer", Original: "old"}}
+ r.SetRewrites(rewrites)
+ assert.Equal(t, rewrites, r.GetRewrites())
+ assert.Equal(t, rewrites, tg.Rewrites)
+ })
+
+ t.Run("Token implements Rewriteable", func(t *testing.T) {
+ tok := &Token{Wrap: &Term{Key: "X", Match: MatchEqual}}
+
+ var r Rewriteable = tok
+ assert.Nil(t, r.GetRewrites())
+
+ rewrites := []Rewrite{{Editor: "mapper", Operation: "op"}}
+ r.SetRewrites(rewrites)
+ assert.Equal(t, rewrites, r.GetRewrites())
+ assert.Equal(t, rewrites, tok.Rewrites)
+ })
+
+ t.Run("SetRewrites to nil clears slice", func(t *testing.T) {
+ term := &Term{
+ Key: "DET",
+ Match: MatchEqual,
+ Rewrites: []Rewrite{{Editor: "x"}},
+ }
+ term.SetRewrites(nil)
+ assert.Nil(t, term.GetRewrites())
+ })
+}
+
+func TestAppendRewrite(t *testing.T) {
+ t.Run("Append to Term", func(t *testing.T) {
+ term := &Term{Key: "DET", Match: MatchEqual}
+ rw := Rewrite{Editor: "Koral-Mapper", Scope: "foundry", Original: "opennlp"}
+
+ AppendRewrite(term, rw)
+ assert.Equal(t, []Rewrite{rw}, term.Rewrites)
+
+ rw2 := Rewrite{Editor: "Koral-Mapper", Scope: "key", Original: "PIDAT"}
+ AppendRewrite(term, rw2)
+ assert.Equal(t, []Rewrite{rw, rw2}, term.Rewrites)
+ })
+
+ t.Run("Append to TermGroup", func(t *testing.T) {
+ tg := &TermGroup{
+ Operands: []Node{&Term{Key: "A", Match: MatchEqual}},
+ Relation: AndRelation,
+ }
+ rw := Rewrite{Editor: "editor", Original: "orig"}
+ AppendRewrite(tg, rw)
+ assert.Equal(t, []Rewrite{rw}, tg.Rewrites)
+ })
+
+ t.Run("Append to Token", func(t *testing.T) {
+ tok := &Token{Wrap: &Term{Key: "X", Match: MatchEqual}}
+ rw := Rewrite{Editor: "ed"}
+ AppendRewrite(tok, rw)
+ assert.Equal(t, []Rewrite{rw}, tok.Rewrites)
+ })
+
+ t.Run("Append to non-Rewriteable is no-op", func(t *testing.T) {
+ catchall := &CatchallNode{NodeType: "koral:span"}
+ rw := Rewrite{Editor: "test"}
+ AppendRewrite(catchall, rw)
+ // CatchallNode doesn't implement Rewriteable, so nothing happens
+ })
+
+ t.Run("Append to nil is no-op", func(t *testing.T) {
+ assert.NotPanics(t, func() {
+ AppendRewrite(nil, Rewrite{Editor: "x"})
+ })
+ })
+}
+
+func TestStripRewrites(t *testing.T) {
+ t.Run("Strips from Term", func(t *testing.T) {
+ term := &Term{
+ Key: "DET",
+ Match: MatchEqual,
+ Rewrites: []Rewrite{{Editor: "a"}, {Editor: "b"}},
+ }
+ StripRewrites(term)
+ assert.Nil(t, term.Rewrites)
+ })
+
+ t.Run("Strips from Token and its Wrap", func(t *testing.T) {
+ tok := &Token{
+ Wrap: &Term{
+ Key: "DET",
+ Match: MatchEqual,
+ Rewrites: []Rewrite{{Editor: "inner"}},
+ },
+ Rewrites: []Rewrite{{Editor: "outer"}},
+ }
+ StripRewrites(tok)
+ assert.Nil(t, tok.Rewrites)
+ assert.Nil(t, tok.Wrap.(*Term).Rewrites)
+ })
+
+ t.Run("Strips from TermGroup and all operands", func(t *testing.T) {
+ tg := &TermGroup{
+ Operands: []Node{
+ &Term{Key: "A", Match: MatchEqual, Rewrites: []Rewrite{{Editor: "e1"}}},
+ &Term{Key: "B", Match: MatchEqual, Rewrites: []Rewrite{{Editor: "e2"}}},
+ },
+ Relation: AndRelation,
+ Rewrites: []Rewrite{{Editor: "group"}},
+ }
+ StripRewrites(tg)
+ assert.Nil(t, tg.Rewrites)
+ assert.Nil(t, tg.Operands[0].(*Term).Rewrites)
+ assert.Nil(t, tg.Operands[1].(*Term).Rewrites)
+ })
+
+ t.Run("Strips recursively from CatchallNode", func(t *testing.T) {
+ catchall := &CatchallNode{
+ NodeType: "koral:group",
+ Wrap: &Term{
+ Key: "W",
+ Match: MatchEqual,
+ Rewrites: []Rewrite{{Editor: "wrap-ed"}},
+ },
+ Operands: []Node{
+ &Token{
+ Wrap: &Term{Key: "X", Match: MatchEqual, Rewrites: []Rewrite{{Editor: "deep"}}},
+ Rewrites: []Rewrite{{Editor: "tok"}},
+ },
+ },
+ }
+ StripRewrites(catchall)
+ assert.Nil(t, catchall.Wrap.(*Term).Rewrites)
+ tok := catchall.Operands[0].(*Token)
+ assert.Nil(t, tok.Rewrites)
+ assert.Nil(t, tok.Wrap.(*Term).Rewrites)
+ })
+
+ t.Run("Nil node does not panic", func(t *testing.T) {
+ assert.NotPanics(t, func() {
+ StripRewrites(nil)
+ })
+ })
+
+ t.Run("Already empty rewrites stays nil", func(t *testing.T) {
+ term := &Term{Key: "DET", Match: MatchEqual}
+ StripRewrites(term)
+ assert.Nil(t, term.Rewrites)
+ })
+}
+
func TestRewriteMarshalJSON(t *testing.T) {
// Test that marshaling works correctly and maintains the modern field names
rewrite := Rewrite{
diff --git a/cmd/koralmapper/main.go b/cmd/koralmapper/main.go
index d8e51a6..75a1d9c 100644
--- a/cmd/koralmapper/main.go
+++ b/cmd/koralmapper/main.go
@@ -77,6 +77,7 @@
FoundryB string
LayerA string
LayerB string
+ Rewrites *bool // nil = use mapping list default; non-nil = override
}
// MappingSectionData contains per-section UI metadata so request and response
@@ -187,6 +188,11 @@
LayerB: c.Query("layerB", ""),
}
+ if rewrites := c.Query("rewrites", ""); rewrites != "" {
+ v := rewrites == "true"
+ params.Rewrites = &v
+ }
+
// Validate input parameters
if err := validateInput(params.MapID, params.Dir, params.FoundryA, params.FoundryB, params.LayerA, params.LayerB, c.Body()); err != nil {
return nil, err
@@ -317,10 +323,10 @@
app.Post("/response/:cfg", handleCompositeResponseTransform(m, yamlConfig.Lists))
// Transformation endpoint
- app.Post("/:map/query", handleTransform(m))
+ app.Post("/:map/query", handleTransform(m, yamlConfig.Lists))
// Response transformation endpoint
- app.Post("/:map/response", handleResponseTransform(m))
+ app.Post("/:map/response", handleResponseTransform(m, yamlConfig.Lists))
// Kalamar plugin endpoint
app.Get("/", handleKalamarPlugin(yamlConfig, configTmpl, pluginTmpl))
@@ -405,6 +411,11 @@
}
func handleCompositeQueryTransform(m *mapper.Mapper, lists []config.MappingList) fiber.Handler {
+ listsByID := make(map[string]*config.MappingList, len(lists))
+ for i := range lists {
+ listsByID[lists[i].ID] = &lists[i]
+ }
+
return func(c *fiber.Ctx) error {
cfgRaw := c.Params("cfg")
if len(cfgRaw) > maxParamLength {
@@ -431,6 +442,13 @@
return c.JSON(jsonData)
}
+ rewrites := c.Query("rewrites", "")
+ var rewritesOverride *bool
+ if rewrites != "" {
+ v := rewrites == "true"
+ rewritesOverride = &v
+ }
+
orderedIDs := make([]string, 0, len(entries))
opts := make([]mapper.MappingOptions, 0, len(entries))
for _, entry := range entries {
@@ -439,15 +457,24 @@
dir = mapper.BtoA
}
+ addRewrites := false
+ if list, ok := listsByID[entry.ID]; ok {
+ addRewrites = list.Rewrites
+ }
+ if rewritesOverride != nil {
+ addRewrites = *rewritesOverride
+ }
+
orderedIDs = append(orderedIDs, entry.ID)
opts = append(opts, mapper.MappingOptions{
- Direction: dir,
- FoundryA: entry.FoundryA,
- LayerA: entry.LayerA,
- FoundryB: entry.FoundryB,
- LayerB: entry.LayerB,
- FieldA: entry.FieldA,
- FieldB: entry.FieldB,
+ Direction: dir,
+ FoundryA: entry.FoundryA,
+ LayerA: entry.LayerA,
+ FoundryB: entry.FoundryB,
+ LayerB: entry.LayerB,
+ FieldA: entry.FieldA,
+ FieldB: entry.FieldB,
+ AddRewrites: addRewrites,
})
}
@@ -464,6 +491,11 @@
}
func handleCompositeResponseTransform(m *mapper.Mapper, lists []config.MappingList) fiber.Handler {
+ listsByID := make(map[string]*config.MappingList, len(lists))
+ for i := range lists {
+ listsByID[lists[i].ID] = &lists[i]
+ }
+
return func(c *fiber.Ctx) error {
cfgRaw := c.Params("cfg")
if len(cfgRaw) > maxParamLength {
@@ -490,6 +522,13 @@
return c.JSON(jsonData)
}
+ rewrites := c.Query("rewrites", "")
+ var rewritesOverride *bool
+ if rewrites != "" {
+ v := rewrites == "true"
+ rewritesOverride = &v
+ }
+
orderedIDs := make([]string, 0, len(entries))
opts := make([]mapper.MappingOptions, 0, len(entries))
for _, entry := range entries {
@@ -498,15 +537,24 @@
dir = mapper.BtoA
}
+ addRewrites := false
+ if list, ok := listsByID[entry.ID]; ok {
+ addRewrites = list.Rewrites
+ }
+ if rewritesOverride != nil {
+ addRewrites = *rewritesOverride
+ }
+
orderedIDs = append(orderedIDs, entry.ID)
opts = append(opts, mapper.MappingOptions{
- Direction: dir,
- FoundryA: entry.FoundryA,
- LayerA: entry.LayerA,
- FoundryB: entry.FoundryB,
- LayerB: entry.LayerB,
- FieldA: entry.FieldA,
- FieldB: entry.FieldB,
+ Direction: dir,
+ FoundryA: entry.FoundryA,
+ LayerA: entry.LayerA,
+ FoundryB: entry.FoundryB,
+ LayerB: entry.LayerB,
+ FieldA: entry.FieldA,
+ FieldB: entry.FieldB,
+ AddRewrites: addRewrites,
})
}
@@ -522,7 +570,12 @@
}
}
-func handleTransform(m *mapper.Mapper) fiber.Handler {
+func handleTransform(m *mapper.Mapper, lists []config.MappingList) fiber.Handler {
+ listsByID := make(map[string]*config.MappingList, len(lists))
+ for i := range lists {
+ listsByID[lists[i].ID] = &lists[i]
+ }
+
return func(c *fiber.Ctx) error {
// Extract and validate parameters
params, err := extractRequestParams(c)
@@ -540,13 +593,23 @@
})
}
+ // Determine rewrites: query param overrides YAML default
+ addRewrites := false
+ if list, ok := listsByID[params.MapID]; ok {
+ addRewrites = list.Rewrites
+ }
+ if params.Rewrites != nil {
+ addRewrites = *params.Rewrites
+ }
+
// Apply mappings
result, err := m.ApplyQueryMappings(params.MapID, mapper.MappingOptions{
- Direction: direction,
- FoundryA: params.FoundryA,
- FoundryB: params.FoundryB,
- LayerA: params.LayerA,
- LayerB: params.LayerB,
+ Direction: direction,
+ FoundryA: params.FoundryA,
+ FoundryB: params.FoundryB,
+ LayerA: params.LayerA,
+ LayerB: params.LayerB,
+ AddRewrites: addRewrites,
}, jsonData)
if err != nil {
@@ -564,7 +627,12 @@
}
}
-func handleResponseTransform(m *mapper.Mapper) fiber.Handler {
+func handleResponseTransform(m *mapper.Mapper, lists []config.MappingList) fiber.Handler {
+ listsByID := make(map[string]*config.MappingList, len(lists))
+ for i := range lists {
+ listsByID[lists[i].ID] = &lists[i]
+ }
+
return func(c *fiber.Ctx) error {
// Extract and validate parameters
params, err := extractRequestParams(c)
@@ -582,13 +650,23 @@
})
}
+ // Determine rewrites: query param overrides YAML default
+ addRewrites := false
+ if list, ok := listsByID[params.MapID]; ok {
+ addRewrites = list.Rewrites
+ }
+ if params.Rewrites != nil {
+ addRewrites = *params.Rewrites
+ }
+
// Apply response mappings
result, err := m.ApplyResponseMappings(params.MapID, mapper.MappingOptions{
- Direction: direction,
- FoundryA: params.FoundryA,
- FoundryB: params.FoundryB,
- LayerA: params.LayerA,
- LayerB: params.LayerB,
+ Direction: direction,
+ FoundryA: params.FoundryA,
+ FoundryB: params.FoundryB,
+ LayerA: params.LayerA,
+ LayerB: params.LayerB,
+ AddRewrites: addRewrites,
}, jsonData)
if err != nil {
diff --git a/cmd/koralmapper/main_test.go b/cmd/koralmapper/main_test.go
index 64ea8be..a9bb530 100644
--- a/cmd/koralmapper/main_test.go
+++ b/cmd/koralmapper/main_test.go
@@ -59,6 +59,7 @@
layerA: p
foundryB: upos
layerB: p
+ rewrites: true
mappings:
- "[PIDAT] <> [opennlp/p=PIDAT & opennlp/p=AdjType:Pdt]"
- "[DET] <> [opennlp/p=DET]"
@@ -121,7 +122,20 @@
"value": "Pdt"
}
],
- "relation": "relation:and"
+ "relation": "relation:and",
+ "rewrites": [
+ {
+ "@type": "koral:rewrite",
+ "editor": "Koral-Mapper",
+ "original": {
+ "@type": "koral:term",
+ "foundry": "opennlp",
+ "key": "PIDAT",
+ "layer": "p",
+ "match": "match:eq"
+ }
+ }
+ ]
}
}`,
},
@@ -161,7 +175,34 @@
"foundry": "opennlp",
"key": "PIDAT",
"layer": "p",
- "match": "match:eq"
+ "match": "match:eq",
+ "rewrites": [
+ {
+ "@type": "koral:rewrite",
+ "editor": "Koral-Mapper",
+ "original": {
+ "@type": "koral:termGroup",
+ "operands": [
+ {
+ "@type": "koral:term",
+ "foundry": "opennlp",
+ "key": "PIDAT",
+ "layer": "p",
+ "match": "match:eq"
+ },
+ {
+ "@type": "koral:term",
+ "foundry": "opennlp",
+ "key": "AdjType",
+ "layer": "p",
+ "match": "match:eq",
+ "value": "Pdt"
+ }
+ ],
+ "relation": "relation:and"
+ }
+ }
+ ]
}
}`,
},
@@ -202,7 +243,20 @@
"value": "Pdt"
}
],
- "relation": "relation:and"
+ "relation": "relation:and",
+ "rewrites": [
+ {
+ "@type": "koral:rewrite",
+ "editor": "Koral-Mapper",
+ "original": {
+ "@type": "koral:term",
+ "foundry": "opennlp",
+ "key": "PIDAT",
+ "layer": "p",
+ "match": "match:eq"
+ }
+ }
+ ]
}
}`,
},
@@ -1570,6 +1624,7 @@
layerA: p
foundryB: opennlp
layerB: p
+ rewrites: true
mappings:
- "[PIDAT] <> [DET]"
- id: step2
@@ -1577,6 +1632,7 @@
layerA: p
foundryB: upos
layerB: p
+ rewrites: true
mappings:
- "[DET] <> [PRON]"
`)
@@ -1615,6 +1671,14 @@
"key": "PRON",
"layer": "p",
"match": "match:eq",
+ "rewrites": []any{
+ map[string]any{
+ "@type": "koral:rewrite",
+ "editor": "Koral-Mapper",
+ "scope": "foundry",
+ "original": "opennlp",
+ },
+ },
},
},
},
@@ -2212,6 +2276,189 @@
assert.Contains(t, htmlContent, `type="button"`)
}
+func TestAddRewritesDefaultOff(t *testing.T) {
+ cfg := loadConfigFromYAML(t, `
+lists:
+ - id: test-mapper
+ foundryA: opennlp
+ layerA: p
+ foundryB: upos
+ layerB: p
+ mappings:
+ - "[PIDAT] <> [DET]"
+`)
+ m, err := mapper.NewMapper(cfg.Lists)
+ require.NoError(t, err)
+
+ app := fiber.New()
+ setupRoutes(app, m, cfg)
+
+ input := `{
+ "@type": "koral:token",
+ "wrap": {
+ "@type": "koral:term",
+ "foundry": "opennlp",
+ "key": "PIDAT",
+ "layer": "p",
+ "match": "match:eq"
+ }
+ }`
+
+ req := httptest.NewRequest(http.MethodPost, "/test-mapper/query?dir=atob", bytes.NewBufferString(input))
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := app.Test(req)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+
+ var result map[string]any
+ err = json.NewDecoder(resp.Body).Decode(&result)
+ require.NoError(t, err)
+
+ wrap := result["wrap"].(map[string]any)
+ assert.Equal(t, "DET", wrap["key"])
+ assert.Nil(t, wrap["rewrites"], "rewrites should not be present by default")
+}
+
+func TestAddRewritesEnabledViaYAML(t *testing.T) {
+ cfg := loadConfigFromYAML(t, `
+lists:
+ - id: test-mapper
+ foundryA: opennlp
+ layerA: p
+ foundryB: upos
+ layerB: p
+ rewrites: true
+ mappings:
+ - "[PIDAT] <> [DET]"
+`)
+ m, err := mapper.NewMapper(cfg.Lists)
+ require.NoError(t, err)
+
+ app := fiber.New()
+ setupRoutes(app, m, cfg)
+
+ input := `{
+ "@type": "koral:token",
+ "wrap": {
+ "@type": "koral:term",
+ "foundry": "opennlp",
+ "key": "PIDAT",
+ "layer": "p",
+ "match": "match:eq"
+ }
+ }`
+
+ req := httptest.NewRequest(http.MethodPost, "/test-mapper/query?dir=atob", bytes.NewBufferString(input))
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := app.Test(req)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+
+ var result map[string]any
+ err = json.NewDecoder(resp.Body).Decode(&result)
+ require.NoError(t, err)
+
+ wrap := result["wrap"].(map[string]any)
+ assert.Equal(t, "DET", wrap["key"])
+ assert.NotNil(t, wrap["rewrites"], "rewrites should be present when rewrites are enabled in YAML")
+}
+
+func TestAddRewritesQueryParamOverridesYAML(t *testing.T) {
+ cfg := loadConfigFromYAML(t, `
+lists:
+ - id: test-mapper
+ foundryA: opennlp
+ layerA: p
+ foundryB: upos
+ layerB: p
+ rewrites: true
+ mappings:
+ - "[PIDAT] <> [DET]"
+`)
+ m, err := mapper.NewMapper(cfg.Lists)
+ require.NoError(t, err)
+
+ app := fiber.New()
+ setupRoutes(app, m, cfg)
+
+ input := `{
+ "@type": "koral:token",
+ "wrap": {
+ "@type": "koral:term",
+ "foundry": "opennlp",
+ "key": "PIDAT",
+ "layer": "p",
+ "match": "match:eq"
+ }
+ }`
+
+ // Override YAML rewrites=true with query param rewrites=false
+ req := httptest.NewRequest(http.MethodPost, "/test-mapper/query?dir=atob&rewrites=false", bytes.NewBufferString(input))
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := app.Test(req)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+
+ var result map[string]any
+ err = json.NewDecoder(resp.Body).Decode(&result)
+ require.NoError(t, err)
+
+ wrap := result["wrap"].(map[string]any)
+ assert.Equal(t, "DET", wrap["key"])
+ assert.Nil(t, wrap["rewrites"], "rewrites should be suppressed by query param override")
+}
+
+func TestAddRewritesQueryParamEnablesWhenYAMLOff(t *testing.T) {
+ cfg := loadConfigFromYAML(t, `
+lists:
+ - id: test-mapper
+ foundryA: opennlp
+ layerA: p
+ foundryB: upos
+ layerB: p
+ mappings:
+ - "[PIDAT] <> [DET]"
+`)
+ m, err := mapper.NewMapper(cfg.Lists)
+ require.NoError(t, err)
+
+ app := fiber.New()
+ setupRoutes(app, m, cfg)
+
+ input := `{
+ "@type": "koral:token",
+ "wrap": {
+ "@type": "koral:term",
+ "foundry": "opennlp",
+ "key": "PIDAT",
+ "layer": "p",
+ "match": "match:eq"
+ }
+ }`
+
+ req := httptest.NewRequest(http.MethodPost, "/test-mapper/query?dir=atob&rewrites=true", bytes.NewBufferString(input))
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := app.Test(req)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+
+ var result map[string]any
+ err = json.NewDecoder(resp.Body).Decode(&result)
+ require.NoError(t, err)
+
+ wrap := result["wrap"].(map[string]any)
+ assert.Equal(t, "DET", wrap["key"])
+ assert.NotNil(t, wrap["rewrites"], "rewrites should be present when enabled by query param")
+}
+
func TestConfigPagePreservesOrderOfMappings(t *testing.T) {
lists := []tmconfig.MappingList{
{
diff --git a/config/config.go b/config/config.go
index cebf10d..b7d2a16 100644
--- a/config/config.go
+++ b/config/config.go
@@ -35,6 +35,7 @@
LayerB string `yaml:"layerB,omitempty"`
FieldA string `yaml:"fieldA,omitempty"`
FieldB string `yaml:"fieldB,omitempty"`
+ Rewrites bool `yaml:"rewrites,omitempty"`
Mappings []MappingRule `yaml:"mappings"`
}
diff --git a/config/config_test.go b/config/config_test.go
index 0046c97..599191d 100644
--- a/config/config_test.go
+++ b/config/config_test.go
@@ -1116,6 +1116,38 @@
assert.Equal(t, defaultLogLevel, cfg.LogLevel)
}
+func TestRewritesYAMLField(t *testing.T) {
+ content := `
+lists:
+ - id: rewrite-on
+ rewrites: true
+ mappings:
+ - "[A] <> [B]"
+ - id: rewrite-off
+ rewrites: false
+ mappings:
+ - "[C] <> [D]"
+ - id: rewrite-default
+ mappings:
+ - "[E] <> [F]"
+`
+ tmpfile, err := os.CreateTemp("", "config-rewrites-*.yaml")
+ require.NoError(t, err)
+ defer os.Remove(tmpfile.Name())
+
+ _, err = tmpfile.WriteString(content)
+ require.NoError(t, err)
+ require.NoError(t, tmpfile.Close())
+
+ cfg, err := LoadFromSources(tmpfile.Name(), nil)
+ require.NoError(t, err)
+ require.Len(t, cfg.Lists, 3)
+
+ assert.True(t, cfg.Lists[0].Rewrites, "rewrites should be true when set to true")
+ assert.False(t, cfg.Lists[1].Rewrites, "rewrites should be false when set to false")
+ assert.False(t, cfg.Lists[2].Rewrites, "rewrites should default to false")
+}
+
func TestParseCorpusMappingsWithFieldAFieldB(t *testing.T) {
list := &MappingList{
ID: "test-keyed",
diff --git a/mapper/corpus_test.go b/mapper/corpus_test.go
index 02964fc..a65c7ff 100644
--- a/mapper/corpus_test.go
+++ b/mapper/corpus_test.go
@@ -412,6 +412,110 @@
assert.Equal(t, "Koral-Mapper", rewrite["editor"])
}
+func TestCorpusQueryRewriteWithCollectionKey(t *testing.T) {
+ m := newCorpusMapper(t, "textClass=novel <> genre=fiction")
+
+ input := map[string]any{
+ "collection": map[string]any{
+ "@type": "koral:doc",
+ "key": "textClass",
+ "value": "novel",
+ "match": "match:eq",
+ },
+ }
+ result, err := m.ApplyQueryMappings("corpus-test", MappingOptions{Direction: AtoB, AddRewrites: true}, input)
+ require.NoError(t, err)
+
+ // Result must use "collection" key (not "corpus")
+ resultMap := result.(map[string]any)
+ assert.Nil(t, resultMap["corpus"], "should not introduce 'corpus' key")
+ collection := resultMap["collection"].(map[string]any)
+ assert.Equal(t, "genre", collection["key"])
+ assert.Equal(t, "fiction", collection["value"])
+
+ rewrites, ok := collection["rewrites"].([]any)
+ require.True(t, ok)
+ require.Len(t, rewrites, 1)
+
+ rewrite := rewrites[0].(map[string]any)
+ assert.Equal(t, "koral:rewrite", rewrite["@type"])
+ assert.Equal(t, "Koral-Mapper", rewrite["editor"])
+ assert.Equal(t, "key", rewrite["scope"])
+ assert.Equal(t, "textClass", rewrite["original"])
+}
+
+func TestCorpusQueryNoRewriteWhenDisabled(t *testing.T) {
+ m := newCorpusMapper(t, "textClass=novel <> genre=fiction")
+
+ input := map[string]any{
+ "corpus": map[string]any{
+ "@type": "koral:doc",
+ "key": "textClass",
+ "value": "novel",
+ "match": "match:eq",
+ },
+ }
+ result, err := m.ApplyQueryMappings("corpus-test", MappingOptions{Direction: AtoB, AddRewrites: false}, input)
+ require.NoError(t, err)
+
+ corpus := result.(map[string]any)["corpus"].(map[string]any)
+ assert.Equal(t, "genre", corpus["key"])
+ assert.Nil(t, corpus["rewrites"], "rewrites should not be present when disabled")
+}
+
+func TestCorpusQueryNoRewriteWhenDisabledCollection(t *testing.T) {
+ m := newCorpusMapper(t, "textClass=novel <> genre=fiction")
+
+ input := map[string]any{
+ "collection": map[string]any{
+ "@type": "koral:doc",
+ "key": "textClass",
+ "value": "novel",
+ "match": "match:eq",
+ },
+ }
+ result, err := m.ApplyQueryMappings("corpus-test", MappingOptions{Direction: AtoB, AddRewrites: false}, input)
+ require.NoError(t, err)
+
+ collection := result.(map[string]any)["collection"].(map[string]any)
+ assert.Equal(t, "genre", collection["key"])
+ assert.Nil(t, collection["rewrites"], "rewrites should not be present when disabled")
+}
+
+func TestCorpusQueryRewriteGroupSubsetWithCollectionKey(t *testing.T) {
+ m := newCorpusMapper(t, "genre=fiction <> (textClass=kultur & textClass=musik)")
+
+ input := map[string]any{
+ "collection": map[string]any{
+ "@type": "koral:docGroup",
+ "operation": "operation:and",
+ "operands": []any{
+ map[string]any{"@type": "koral:doc", "key": "textClass", "value": "kultur"},
+ map[string]any{"@type": "koral:doc", "key": "textClass", "value": "musik"},
+ },
+ },
+ }
+ result, err := m.ApplyQueryMappings("corpus-test", MappingOptions{Direction: BtoA, AddRewrites: true}, input)
+ require.NoError(t, err)
+
+ resultMap := result.(map[string]any)
+ assert.Nil(t, resultMap["corpus"], "should not introduce 'corpus' key")
+
+ collection := resultMap["collection"].(map[string]any)
+ assert.Equal(t, "genre", collection["key"])
+ assert.Equal(t, "fiction", collection["value"])
+
+ rewrites, ok := collection["rewrites"].([]any)
+ require.True(t, ok)
+ require.Len(t, rewrites, 1)
+
+ rewrite := rewrites[0].(map[string]any)
+ assert.Equal(t, "koral:rewrite", rewrite["@type"])
+ original, ok := rewrite["original"].(map[string]any)
+ require.True(t, ok)
+ assert.Equal(t, "koral:docGroup", original["@type"])
+}
+
func TestCorpusQueryPreservesMatchTypeFromOriginal(t *testing.T) {
m := newCorpusMapper(t, "textClass=novel <> genre=fiction")
diff --git a/mapper/query.go b/mapper/query.go
index 4980f85..9aed2db 100644
--- a/mapper/query.go
+++ b/mapper/query.go
@@ -1,4 +1,4 @@
-package mapper // ApplyQueryMappings applies the specified mapping rules to a JSON object
+package mapper
import (
"encoding/json"
@@ -9,9 +9,10 @@
"github.com/KorAP/Koral-Mapper/parser"
)
-// ApplyQueryMappings applies the specified mapping rules to a JSON object
+// ApplyQueryMappings transforms a JSON query object using the mapping rules
+// identified by mappingID. The input may be a bare query node or a wrapper
+// object containing a "query" field; both forms are accepted.
func (m *Mapper) ApplyQueryMappings(mappingID string, opts MappingOptions, jsonData any) (any, error) {
- // Validate mapping ID
if _, exists := m.mappingLists[mappingID]; !exists {
return nil, fmt.Errorf("mapping list with ID %s not found", mappingID)
}
@@ -20,10 +21,9 @@
return m.applyCorpusQueryMappings(mappingID, opts, jsonData)
}
- // Get the parsed rules
rules := m.parsedQueryRules[mappingID]
- // Check if we have a wrapper object with a "query" field
+ // Detect wrapper: input may be {"query": ...} or a bare koral:token
var queryData any
var hasQueryWrapper bool
@@ -34,20 +34,17 @@
}
}
- // If no query wrapper was found, use the entire input
if !hasQueryWrapper {
- // If the input itself is not a valid query object, return it as is
if !isValidQueryObject(jsonData) {
return jsonData, nil
}
queryData = jsonData
} else if queryData == nil || !isValidQueryObject(queryData) {
- // If we have a query wrapper but the query is nil or not a valid object,
- // return the original data
return jsonData, nil
}
- // Store rewrites if they exist
+ // Strip pre-existing rewrites before AST conversion so they do not
+ // interfere with matching. They are restored after transformation.
var oldRewrites any
if queryMap, ok := queryData.(map[string]any); ok {
if rewrites, exists := queryMap["rewrites"]; exists {
@@ -56,7 +53,6 @@
}
}
- // Convert input JSON to AST
jsonBytes, err := json.Marshal(queryData)
if err != nil {
return nil, fmt.Errorf("failed to marshal input JSON: %w", err)
@@ -67,7 +63,7 @@
return nil, fmt.Errorf("failed to parse JSON into AST: %w", err)
}
- // Store whether the input was a Token
+ // Unwrap Token so matching operates on the inner node; re-wrapped later.
isToken := false
var tokenWrap ast.Node
if token, ok := node.(*ast.Token); ok {
@@ -76,15 +72,9 @@
node = tokenWrap
}
- // Store original node for rewrite if needed
- var originalNode ast.Node
- if opts.AddRewrites {
- originalNode = node.Clone()
- }
-
- // Pre-check foundry/layer overrides to optimize processing
+ // Resolve foundry/layer overrides per direction once, before the rule loop.
var patternFoundry, patternLayer, replacementFoundry, replacementLayer string
- if opts.Direction { // true means AtoB
+ if opts.Direction {
patternFoundry, patternLayer = opts.FoundryA, opts.LayerA
replacementFoundry, replacementLayer = opts.FoundryB, opts.LayerB
} else {
@@ -92,7 +82,8 @@
replacementFoundry, replacementLayer = opts.FoundryA, opts.LayerA
}
- // Create a pattern cache key for memoization
+ // patternCache avoids redundant Clone+Override for the same rule index
+ // and foundry/layer combination across repeated calls.
type patternCacheKey struct {
ruleIndex int
foundry string
@@ -101,11 +92,9 @@
}
patternCache := make(map[patternCacheKey]ast.Node)
- // Apply each rule to the AST
for i, rule := range rules {
- // Create pattern and replacement based on direction
var pattern, replacement ast.Node
- if opts.Direction { // true means AtoB
+ if opts.Direction {
pattern = rule.Upper
replacement = rule.Lower
} else {
@@ -113,7 +102,6 @@
replacement = rule.Upper
}
- // Extract the inner nodes from the pattern and replacement tokens
if token, ok := pattern.(*ast.Token); ok {
pattern = token.Wrap
}
@@ -121,52 +109,51 @@
replacement = token.Wrap
}
- // Get or create pattern with overrides
patternKey := patternCacheKey{ruleIndex: i, foundry: patternFoundry, layer: patternLayer, isReplacement: false}
processedPattern, exists := patternCache[patternKey]
if !exists {
- // Clone pattern only when needed
processedPattern = pattern.Clone()
- // Apply foundry and layer overrides only if they're non-empty
if patternFoundry != "" || patternLayer != "" {
ast.ApplyFoundryAndLayerOverrides(processedPattern, patternFoundry, patternLayer)
}
patternCache[patternKey] = processedPattern
}
- // Create a temporary matcher to check for actual matches
+ // Probe for a match before cloning the replacement (lazy evaluation)
tempMatcher, err := matcher.NewMatcher(ast.Pattern{Root: processedPattern}, ast.Replacement{Root: &ast.Term{}})
if err != nil {
return nil, fmt.Errorf("failed to create temporary matcher: %w", err)
}
-
- // Only proceed if there's an actual match
if !tempMatcher.Match(node) {
continue
}
- // Get or create replacement with overrides (lazy evaluation)
replacementKey := patternCacheKey{ruleIndex: i, foundry: replacementFoundry, layer: replacementLayer, isReplacement: true}
processedReplacement, exists := patternCache[replacementKey]
if !exists {
- // Clone replacement only when we have a match
processedReplacement = replacement.Clone()
- // Apply foundry and layer overrides only if they're non-empty
if replacementFoundry != "" || replacementLayer != "" {
ast.ApplyFoundryAndLayerOverrides(processedReplacement, replacementFoundry, replacementLayer)
}
patternCache[replacementKey] = processedReplacement
}
- // Create the actual matcher and apply replacement
+ var beforeNode ast.Node
+ if opts.AddRewrites {
+ beforeNode = node.Clone()
+ }
+
actualMatcher, err := matcher.NewMatcher(ast.Pattern{Root: processedPattern}, ast.Replacement{Root: processedReplacement})
if err != nil {
return nil, fmt.Errorf("failed to create matcher: %w", err)
}
node = actualMatcher.Replace(node)
+
+ if opts.AddRewrites {
+ recordRewrites(node, beforeNode)
+ }
}
- // Wrap the result in a token if the input was a token
var result ast.Node
if isToken {
result = &ast.Token{Wrap: node}
@@ -174,45 +161,23 @@
result = node
}
- // Convert AST back to JSON
resultBytes, err := parser.SerializeToJSON(result)
if err != nil {
return nil, fmt.Errorf("failed to serialize AST to JSON: %w", err)
}
- // Parse the JSON string back into
var resultData any
if err := json.Unmarshal(resultBytes, &resultData); err != nil {
return nil, fmt.Errorf("failed to parse result JSON: %w", err)
}
- // Add rewrites if enabled and node was changed
- if opts.AddRewrites && !ast.NodesEqual(node, originalNode) {
- rewrite := buildQueryRewrite(originalNode, node)
-
- // Add rewrite to the node
- if resultMap, ok := resultData.(map[string]any); ok {
- if wrapMap, ok := resultMap["wrap"].(map[string]any); ok {
- rewrites, exists := wrapMap["rewrites"]
- if !exists {
- rewrites = []any{}
- }
- if rewritesList, ok := rewrites.([]any); ok {
- wrapMap["rewrites"] = append(rewritesList, rewrite)
- } else {
- wrapMap["rewrites"] = []any{rewrite}
- }
- }
- }
- }
-
- // Restore rewrites if they existed
+ // Restore pre-existing rewrites. The round-trip through ast.Rewrite
+ // normalizes legacy field names (e.g. "source" -> "editor") so the
+ // output always uses the modern schema.
if oldRewrites != nil {
- // Process old rewrites through AST to ensure backward compatibility
if rewritesList, ok := oldRewrites.([]any); ok {
processedRewrites := make([]any, len(rewritesList))
for i, rewriteData := range rewritesList {
- // Marshal and unmarshal each rewrite to apply backward compatibility
rewriteBytes, err := json.Marshal(rewriteData)
if err != nil {
return nil, fmt.Errorf("failed to marshal old rewrite %d: %w", i, err)
@@ -221,7 +186,6 @@
if err := json.Unmarshal(rewriteBytes, &rewrite); err != nil {
return nil, fmt.Errorf("failed to unmarshal old rewrite %d: %w", i, err)
}
- // Marshal back to get the transformed version
transformedBytes, err := json.Marshal(&rewrite)
if err != nil {
return nil, fmt.Errorf("failed to marshal transformed rewrite %d: %w", i, err)
@@ -236,14 +200,12 @@
resultMap["rewrites"] = processedRewrites
}
} else {
- // If it's not a list, restore as-is
if resultMap, ok := resultData.(map[string]any); ok {
resultMap["rewrites"] = oldRewrites
}
}
}
- // If we had a query wrapper, put the transformed data back in it
if hasQueryWrapper {
if wrapper, ok := jsonData.(map[string]any); ok {
wrapper["query"] = resultData
@@ -254,48 +216,102 @@
return resultData, nil
}
-// buildQueryRewrite creates a rewrite entry for a query-level transformation
-// by comparing the original and new AST nodes.
-func buildQueryRewrite(originalNode, newNode ast.Node) map[string]any {
+// recordRewrites compares the new node against the before-snapshot and
+// attaches rewrite entries to any changed nodes. It handles both simple
+// nodes (Term, TermGroup) and container nodes (CatchallNode with operands).
+func recordRewrites(newNode, beforeNode ast.Node) {
+ if ast.NodesEqual(newNode, beforeNode) {
+ return
+ }
+
+ // For CatchallNodes with operands (e.g. token sequences), attach
+ // per-operand rewrites so each changed token gets its own annotation.
+ if newCatchall, ok := newNode.(*ast.CatchallNode); ok {
+ if oldCatchall, ok := beforeNode.(*ast.CatchallNode); ok && len(newCatchall.Operands) > 0 {
+ for i, newOp := range newCatchall.Operands {
+ if i >= len(oldCatchall.Operands) {
+ break
+ }
+ oldOp := oldCatchall.Operands[i]
+ recordRewritesForOperand(newOp, oldOp)
+ }
+ return
+ }
+ }
+
+ addRewriteToNode(newNode, beforeNode)
+}
+
+// recordRewritesForOperand handles rewrite recording for a single operand,
+// unwrapping Token nodes so the rewrite attaches to the inner term/termGroup
+// rather than the token wrapper.
+func recordRewritesForOperand(newOp, oldOp ast.Node) {
+ if ast.NodesEqual(newOp, oldOp) {
+ return
+ }
+
+ newInner := newOp
+ oldInner := oldOp
+ if tok, ok := newOp.(*ast.Token); ok {
+ newInner = tok.Wrap
+ }
+ if tok, ok := oldOp.(*ast.Token); ok {
+ oldInner = tok.Wrap
+ }
+
+ if newInner == nil || ast.NodesEqual(newInner, oldInner) {
+ return
+ }
+
+ addRewriteToNode(newInner, oldInner)
+}
+
+// addRewriteToNode creates and attaches a rewrite entry to a node,
+// recording what the node looked like before the change.
+func addRewriteToNode(newNode, originalNode ast.Node) {
+ rw := buildRewrite(originalNode, newNode)
+ ast.AppendRewrite(newNode, rw)
+}
+
+// buildRewrite creates a Rewrite describing what changed between
+// originalNode and newNode. For simple term-level changes (just foundry,
+// layer, key, or value), it uses a scoped rewrite. For structural changes,
+// it stores the full original as an object.
+func buildRewrite(originalNode, newNode ast.Node) ast.Rewrite {
if term, ok := originalNode.(*ast.Term); ok && ast.IsTermNode(newNode) && originalNode.Type() == newNode.Type() {
newTerm := newNode.(*ast.Term)
if term.Foundry != newTerm.Foundry {
- return newRewriteEntry("foundry", term.Foundry)
+ return ast.Rewrite{Editor: RewriteEditor, Scope: "foundry", Original: term.Foundry}
}
if term.Layer != newTerm.Layer {
- return newRewriteEntry("layer", term.Layer)
+ return ast.Rewrite{Editor: RewriteEditor, Scope: "layer", Original: term.Layer}
}
if term.Key != newTerm.Key {
- return newRewriteEntry("key", term.Key)
+ return ast.Rewrite{Editor: RewriteEditor, Scope: "key", Original: term.Key}
}
if term.Value != newTerm.Value {
- return newRewriteEntry("value", term.Value)
+ return ast.Rewrite{Editor: RewriteEditor, Scope: "value", Original: term.Value}
}
}
+ // Structural change: serialize the original as the rewrite value
originalBytes, err := parser.SerializeToJSON(originalNode)
if err != nil {
- return newRewriteEntry("", nil)
+ return ast.Rewrite{Editor: RewriteEditor}
}
var originalJSON any
if err := json.Unmarshal(originalBytes, &originalJSON); err != nil {
- return newRewriteEntry("", nil)
+ return ast.Rewrite{Editor: RewriteEditor}
}
- return newRewriteEntry("", originalJSON)
+ return ast.Rewrite{Editor: RewriteEditor, Original: originalJSON}
}
-// isValidQueryObject checks if the query data is a valid object that can be processed
+// isValidQueryObject returns true if data is a JSON object with an @type field.
func isValidQueryObject(data any) bool {
- // Check if it's a map
queryMap, ok := data.(map[string]any)
if !ok {
return false
}
-
- // Check if it has the required @type field
- if _, ok := queryMap["@type"]; !ok {
- return false
- }
-
- return true
+ _, ok = queryMap["@type"]
+ return ok
}
diff --git a/parser/parser.go b/parser/parser.go
index a6f29f9..d19b19f 100644
--- a/parser/parser.go
+++ b/parser/parser.go
@@ -277,12 +277,14 @@
case *ast.Token:
if n.Wrap == nil {
return rawNode{
- Type: "koral:token",
+ Type: "koral:token",
+ Rewrites: n.Rewrites,
}
}
return rawNode{
- Type: "koral:token",
- Wrap: json.RawMessage(nodeToRaw(n.Wrap).toJSON()),
+ Type: "koral:token",
+ Wrap: json.RawMessage(nodeToRaw(n.Wrap).toJSON()),
+ Rewrites: n.Rewrites,
}
case *ast.TermGroup:
@@ -294,13 +296,15 @@
Type: "koral:termGroup",
Operands: operands,
Relation: "relation:" + string(n.Relation),
+ Rewrites: n.Rewrites,
}
case *ast.Term:
raw := rawNode{
- Type: "koral:term",
- Key: n.Key,
- Match: "match:" + string(n.Match),
+ Type: "koral:term",
+ Key: n.Key,
+ Match: "match:" + string(n.Match),
+ Rewrites: n.Rewrites,
}
if n.Foundry != "" {
raw.Foundry = n.Foundry