From d1fa32b4cfac59bda3cd685bd12a87c3701d443b Mon Sep 17 00:00:00 2001 From: Marten Hildell Date: Sat, 17 Oct 2026 18:23:32 +0200 Subject: [PATCH 1/5] values prints every value a path glob wants as path = "text" lines `arxmodel values PATTERN ...` merges the files as get does and prints every value whose AUTOSAR path a path glob matches, or lies below a path it matches, one a line, `path = "text"` (the text a TOML basic string), in the order of the merged view. - A value the merge names by its definition (an item of the policy's item_identity roles, or of the built-in rules' by_definition: ECUC parameter and reference values) is its container's path and the definition's last name (`/Pkg/Cfg/Dim = "10"`), its label a key. Of its parts, the one no fragment writes another part after is the value (of several, the last in the merged view; of copies from several files, the first file's, the others not printed: check reports them); its other parts follow a `#`. - Any other value is named as findings name elements (`/Pkg/Lamp#WATTS = "55"`). A referrable element's SHORT-NAME is its path, not a value; attributes are not values; structured comments, not compared, are left out. - The pattern is the policy filters' path glob; `/` is the root. The walk writes each path into one builder and matches on its bytes: a string is made only for a value printed, and the command prints each as found. Checked.values is the library's. - Flat.identified keeps the built-in identity rules a Flat was made of, so values names their items by definition too. On a large real-world model, a value query takes the load and merge plus up to 0.6 s at the same peak memory. Co-authored-by: Claude Opus 5.5 --- README.md | 30 ++ arxmodel/identity.v | 1 + arxmodel/merge.v | 3 + arxmodel/values.v | 548 +++++++++++++++++++++++++++++++++++++ arxmodel/values_test.v | 149 ++++++++++ cmd/arxmodel/main.v | 49 +++- docs/plan.md | 3 + scripts/values_cli_test.sh | 34 +++ 8 files changed, 812 insertions(+), 5 deletions(-) create mode 100644 arxmodel/values.v create mode 100644 arxmodel/values_test.v create mode 100755 scripts/values_cli_test.sh diff --git a/README.md b/README.md index c37b6a3..18890af 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ arxmodel check --rules autosar.rules.txt ... # arxmodel check --schema-dir DIR --format json ... > check.json # or junit: for machines arxmodel get --schema-dir DIR /Pkg/Name ... # the merged element at an AUTOSAR path arxmodel list --schema-dir DIR ECUC-MODULE-DEF ... # the paths of every element of a name +arxmodel values --schema-dir DIR '/Pkg/**/Name' ... # every value there, `path = "text"` arxmodel merge --schema-dir DIR [--force] -o model.arxml ... # the merged model, as one ARXML arxmodel diff A B # what changed, by AUTOSAR path (no schema needed) arxmodel merge3 BASE OURS THEIRS -o OUT # a three-way merge (no schema needed) @@ -351,6 +352,35 @@ scoped rule beside its unscoped counterpart, and a child element no role names, has rules, gets a rule of the kind no rule gives (each its own) for its place. A merged model is then written in the order its schema requires. +## Values (`values`) + +`values PATTERN ...` merges the files as `get` does and prints every value whose path +the pattern matches, or lies below a path it matches, one a line, `path = "text"` (the text a TOML +basic string), in the order of the merged view: + +```text +/Pkg/LampCfg/Dim = "10" +/Pkg/LampCfg/Signal = "/Sig/A" +/Pkg/Head#WATTS = "55" +``` + +A value is an element holding text and no element. One the merge names by its definition (an +item of the policy's `item_identity` roles, or of the built-in rules' `by_definition`: ECUC +parameter and reference values) is named by it: its container's path, `/`, and the definition's +last name, with its variation point's label as a key where it has one (`Dim[v1]`). Of such an +item, its last element, when that holds text only, is the value itself (the value comes last in +such an item); its other parts follow a `#` (`/Pkg/LampCfg/Dim#IS-AUTO-VALUE`). Any other value +is named as findings name elements: its nearest referrable ancestor's path, `#`, and the elements +below it with their keys. A referrable element's SHORT-NAME is its path, not a value; attributes +are not values; what the merge does not compare (structured comments) is left out. Where fragments give a value differently, the first file's is printed, as `merge` writes it +(`check` reports the difference). + +The pattern is a path glob, as the policy's filters take them: `*` one segment or part of one, +`**` any segments, `#` matched only by itself or `**`, a keyed segment matched by its name unless +the pattern gives the key. `/Pkg/**/Dim` finds a definition's value wherever it is; `/Pkg/LampCfg` +gives every value of that container and its subcontainers; `/**` every value. A pattern that is a +file or directory is refused (the pattern comes first). Exit 1 when no value matches. + ## Export (`merge`) `merge` merges and checks the files as `check` does, with the same options, the summary going to diff --git a/arxmodel/identity.v b/arxmodel/identity.v index 8e153e4..ad56a42 100644 --- a/arxmodel/identity.v +++ b/arxmodel/identity.v @@ -318,5 +318,6 @@ pub fn (x &Identified) flat() Flat { } described: ['built-in identity'] unkeyed_by_design: true + identified: x } } diff --git a/arxmodel/merge.v b/arxmodel/merge.v index e9a2810..f3be4c1 100644 --- a/arxmodel/merge.v +++ b/arxmodel/merge.v @@ -59,6 +59,9 @@ pub mut: repeat_rules []int // elements whose content a merge does not compare (structured comments, by the policy) ignored_content []string + // the built-in identity rules the Flat was made of, which name values by their definition + // (values); nil for a schema's rules + identified &Identified = unsafe { nil } } // Conflict is an element pair under a parent path that roles of different holders still give diff --git a/arxmodel/values.v b/arxmodel/values.v new file mode 100644 index 0000000..1150cda --- /dev/null +++ b/arxmodel/values.v @@ -0,0 +1,548 @@ +module arxmodel + +import flatxml +import strings + +// Values: the values of a merged model, one a line, `path = "text"`, by AUTOSAR path. +// +// A value is an element holding text and no element: its text. Its path is the AUTOSAR path of +// its nearest referrable ancestor, then `#` and the elements below that one down to it, as +// findings and diffs name elements (`/Pkg/Lamp#WATTS`). A value the merge identifies by its +// definition (an item of an identity role, or of the built-in rules' `by_definition`, such as an +// ECUC parameter or reference value) is named by its definition instead: its container's path, +// `/`, and the definition's last name (`/Pkg/LampCfg/Dim`), with its label as a key where it has +// one (`Dim[v1]`). The item's last element, when that holds text only, is the value itself and +// takes that path (the value comes last in such an item); its other parts follow a `#` +// (`/Pkg/LampCfg/Dim#IS-AUTO-VALUE`). A referrable element's SHORT-NAME is its path, not a value; +// attributes are not values; content the merge does not compare (structured comments) is left +// out. The text is the merged node's (its +// first fragment's), without surrounding white space. +// +// A pattern is a path glob, as the policy's filters take them (`*` one segment or part of one, +// `**` any segments, `#` matched only by `#` or `**`, a segment's key matched only by a pattern +// that gives one). A value is wanted when its path matches, or when the path of an element +// above it does: `/Pkg/LampCfg` gives every value of that container and its subcontainers. + +// Value is one value of a merged model. +pub struct Value { +pub: + path string + text string + node u32 // the merged node whose text it is +} + +// line is the value as one line: `path = "text"`, the text a TOML basic string. +pub fn (v Value) line() string { + return '${v.path} = ${quoted(v.text)}' +} + +const hex_digits = '0123456789ABCDEF' + +// quoted is s as a TOML basic string: `"`, `\` and control characters escaped. +fn quoted(s string) string { + mut sb := strings.new_builder(s.len + 2) + sb.write_u8(`"`) + for c in s { + match c { + `"` { + sb.write_string('\\"') + } + `\\` { + sb.write_string('\\\\') + } + `\n` { + sb.write_string('\\n') + } + `\r` { + sb.write_string('\\r') + } + `\t` { + sb.write_string('\\t') + } + else { + if c < 0x20 || c == 0x7f { + sb.write_string('\\u00') + sb.write_u8(hex_digits[c >> 4]) + sb.write_u8(hex_digits[c & 15]) + } else { + sb.write_u8(c) + } + } + } + } + sb.write_u8(`"`) + return sb.str() +} + +// any_parent is an identity's parent that is any element (the built-in rules). +const any_parent = u32(0xFFFF_FFFF) + +// ItemIdentity is how items of one kind are named by a definition: the element holding them +// (any_parent for any), and the paths below an item of its definition and its label, as name ids +// of the set's table. +struct ItemIdentity { + parent u32 + def []u32 + label []u32 +} + +// ValueWalk is one pass over a merged view collecting the values a pattern wants. The path of +// the node being visited is written into one builder, each segment's bounds beside it, and +// matched there: no string is made for a value unless it is wanted. A referrable element, or an +// item named by its definition, starts its path anew after the others (its container's path +// copied, then its name), so the segments of the wrappers above it stay as they were for their +// other children. +struct ValueWalk { + ix &PathIndex + pat []string // the pattern's segments + // per name id of the set's table: the item identities of elements of that name (several + // roles may share an item name, under different parents) + item_of [][]int + idents []ItemIdentity + skip []bool // per name id: content the merge does not compare +mut: + sb strings.Builder + bounds []int // per segment written: its start and its end in sb + // the path of the node being visited: sb[start..], its segments bounds[2 * seg0..] + start int + seg0 int + // the nearest referrable element's (or named item's) path, likewise + ref_lo int + ref_hi int + ref_s0 int + ref_s1 int + out []Value + // terminal's scratch, kept between items: an item's parts, where a fragment writes each, and + // whether a fragment writes another part after it + parts []u32 + at []u32 + followed []bool +} + +// values are the values a path pattern wants, in the order of the merged view (see above). +pub fn (c &Checked) values(pattern string) []Value { + v := c.view + if v == unsafe { nil } || !v.root().ok() { + return []Value{} + } + tab := v.set.tab + n := tab.len() + mut skip := []bool{len: n} + for name in c.flat.ignored_content { + if id := tab.id(name) { + skip[id] = true + } + } + mut item_of := [][]int{len: n} + mut idents := []ItemIdentity{} + for r in c.flat.identity_roles { + parent := tab.id(r.parent) or { continue } + idents << ItemIdentity{ + parent: parent + def: name_ids(tab, r.definition) + label: name_ids(tab, r.label) + } + for item in r.items { + if id := tab.id(item) { + item_of[id] << idents.len - 1 + } + } + } + if c.flat.identified != unsafe { nil } { + id := c.flat.identified.id + role := id.value_role() + idents << ItemIdentity{ + parent: any_parent + def: name_ids(tab, role.definition) + label: name_ids(tab, role.label) + } + for k in 2 .. n { + name := tab.name(u32(k)) + if !name.contains_u8(`:`) && !name.starts_with('{') + && id.by_definition.any(glob(it, name)) { + item_of[k] << idents.len - 1 + } + } + } + mut pat := segments(pattern) + if pat.len == 0 { + pat = ['**'] // `/`: the root, so every value + } + mut w := ValueWalk{ + ix: c.index + pat: pat + item_of: item_of + idents: idents + skip: skip + sb: strings.new_builder(256) + } + w.visit(v.root(), false, false) + return w.out +} + +// name_ids is a path's names as ids of a table; none where the table lacks a name. +fn name_ids(tab &flatxml.Names, path string) []u32 { + if path == '' { + return []u32{} + } + mut out := []u32{} + for name in path.split('/') { + out << tab.id(name) or { return []u32{} } + } + return out +} + +// visit collects the values of n and below: inner is whether the path has its `#` part, below +// whether the pattern wants every value below. +fn (mut w ValueWalk) visit(n flatxml.MNode, inner bool, below bool) { + tag := n.tag() + if tag == flatxml.text_tag || (int(tag) < w.skip.len && w.skip[tag]) { + return + } + mark, nb := w.sb.len, w.bounds.len + start, seg0 := w.start, w.seg0 + ref_lo, ref_hi, ref_s0, ref_s1 := w.ref_lo, w.ref_hi, w.ref_s0, w.ref_s1 + defer { + w.sb.go_back_to(mark) + w.bounds.trim(nb) + w.start, w.seg0 = start, seg0 + w.ref_lo, w.ref_hi, w.ref_s0, w.ref_s1 = ref_lo, ref_hi, ref_s0, ref_s1 + } + entry := if int(n.i) < w.ix.at.len { w.ix.at[n.i] } else { u32(0) } + // an element with a path of its own (a container named by its short name too) is no item + ident := if entry != 0 { -1 } else { w.identity_of(n) } + mut now_inner := inner + if entry != 0 { + w.from_ref() + w.write_name(w.ix.name_of(entry - 1)) + w.as_ref() + now_inner = false + } else if ident >= 0 { + w.from_ref() + w.write_item_name(n, w.idents[ident]) + w.as_ref() + now_inner = false + } else { + if !inner { + w.write_hash() + } + w.write_inner(n) + now_inner = true + } + nseg := w.bounds.len / 2 + all := below || w.glob_at(0, w.seg0, nseg, false) + // what lies below extends this path, or (a referrable element, a named item) its nearest + // referrable's + if !all && !w.glob_at(0, w.seg0, nseg, true) && !w.glob_at(0, w.ref_s0, w.ref_s1, true) { + return + } + if n.child_count() == 0 { + if all { + w.emit(n) + } + return + } + if ident >= 0 { + w.item_parts(n, w.idents[ident], all) + return + } + for c in n.children() { + if entry != 0 && c.tag() == w.ix.sn { + continue // its path + } + w.visit(c, now_inner, all) + } +} + +// identity_of is the item identity n is named by, or -1: none where n gives no definition, as +// the merge then does not name it by one. +fn (w &ValueWalk) identity_of(n flatxml.MNode) int { + tag := n.tag() + if int(tag) >= w.item_of.len { + return -1 + } + for k in w.item_of[tag] { + id := w.idents[k] + if (id.parent == any_parent || n.parent().tag() == id.parent) + && path_text(n, id.def) != '' { + return k + } + } + return -1 +} + +// item_parts collects an item's values: its value (terminal) at the item's path when it holds +// text only and is compared; the rest after `#`. The definition is its name, not a value. +fn (mut w ValueWalk) item_parts(n flatxml.MNode, id ItemIdentity, all bool) { + last := w.terminal(n, id) + last_tag := if last == u32(0xFFFF_FFFF) { flatxml.text_tag } else { n.m.node(last).tag() } + for c in n.children() { + if !w.is_part(c, id) { + continue + } + if c.i != last && last_tag != flatxml.text_tag && c.tag() == last_tag { + continue // another file's copy of the value: the first file's is the value + } + if c.i == last && c.child_count() == 0 { + if all { + w.emit(c) + } + continue + } + w.visit(c, false, all) + } +} + +// terminal is the merged child that is an item's value: of its parts (every element but its +// definition, ignored ones too), one that no fragment writes another part after (the value comes +// last in such an item); of several, the last in the merged view (in its rules' order), and of +// several copies of that element (from different files) the first, the first file's. So neither +// the merge's order, nor an ignored value, nor a fragment holding only some of the parts makes +// another part the value. No node where it has no part. +fn (mut w ValueWalk) terminal(n flatxml.MNode, id ItemIdentity) u32 { + none_ := u32(0xFFFF_FFFF) + w.parts.clear() + w.followed.clear() + for c in n.children() { + tag := c.tag() + if tag != flatxml.text_tag && !(id.def.len > 0 && tag == id.def[0]) { + w.parts << c.i + w.followed << false + } + } + if w.parts.len == 0 { + return none_ + } + v := n.m + for k in 0 .. n.fragment_count() { + f := n.fragment(k) + w.at.clear() + for p in w.parts { + c := v.node(p) + mut at := none_ + for j in 0 .. c.fragment_count() { + cf := c.fragment(j) + if voidptr(cf.doc) == voidptr(f.doc) && cf.parent().i == f.i { + at = cf.i + break + } + } + w.at << at + } + for a in 0 .. w.parts.len { + if w.at[a] == none_ { + continue + } + for b in 0 .. w.parts.len { + if w.at[b] != none_ && w.at[b] > w.at[a] { + w.followed[a] = true + break + } + } + } + } + // the last part no fragment writes another after; of several of its name (copies of the + // value, from different files), the first: the first file's, as the merge keeps a value + for a := w.parts.len - 1; a >= 0; a-- { + if !w.followed[a] { + tag := v.node(w.parts[a]).tag() + for b in 0 .. a + 1 { + if !w.followed[b] && v.node(w.parts[b]).tag() == tag { + return w.parts[b] + } + } + } + } + return none_ +} + +// is_part reports whether a child of an item is one of its values' parts: an element, not its +// definition, and not one whose content the merge does not compare. +fn (w &ValueWalk) is_part(c flatxml.MNode, id ItemIdentity) bool { + tag := c.tag() + return tag != flatxml.text_tag && !(int(tag) < w.skip.len && w.skip[tag]) + && !(id.def.len > 0 && tag == id.def[0]) +} + +// from_ref starts the path of a node anew, after what is written: its nearest referrable +// element's path, copied, segments and all. +fn (mut w ValueWalk) from_ref() { + at := w.sb.len + for i in w.ref_lo .. w.ref_hi { + w.sb.write_u8(w.sb[i]) + } + s0 := w.bounds.len / 2 + for k := 2 * w.ref_s0; k < 2 * w.ref_s1; k++ { + w.bounds << w.bounds[k] - w.ref_lo + at + } + w.start, w.seg0 = at, s0 +} + +// as_ref makes the path written the nearest referrable's, for what lies below. +fn (mut w ValueWalk) as_ref() { + w.ref_lo, w.ref_hi = w.start, w.sb.len + w.ref_s0, w.ref_s1 = w.seg0, w.bounds.len / 2 +} + +// write_name writes `/name` as one segment. +fn (mut w ValueWalk) write_name(name string) { + w.sb.write_u8(`/`) + lo := w.sb.len + w.sb.write_string(name) + w.bounds << lo + w.bounds << w.sb.len +} + +// write_item_name writes an item's name: `/` and its definition's last name, and its label as +// a key where it has one, escaped as paths escape keys. +fn (mut w ValueWalk) write_item_name(n flatxml.MNode, id ItemIdentity) { + def := path_text(n, id.def) + cut := def.last_index_u8(`/`) + w.sb.write_u8(`/`) + lo := w.sb.len + unsafe { w.sb.write_ptr(def.str + cut + 1, def.len - cut - 1) } + label := path_text(n, id.label) + if label != '' { + w.sb.write_u8(`[`) + for c in label { + if c == `\\` || c == `[` || c == `]` { + w.sb.write_u8(`\\`) + } + w.sb.write_u8(c) + } + w.sb.write_u8(`]`) + } + w.bounds << lo + w.bounds << w.sb.len +} + +// path_text is the text at a path of name ids below a merged node, trimmed; '' where there is none. +fn path_text(n flatxml.MNode, ids []u32) string { + if ids.len == 0 { + return '' + } + mut cur := n + for id in ids { + mut found := false + for c in cur.children() { + if c.tag() == id { + cur = c + found = true + break + } + } + if !found { + return '' + } + } + return trimmed(cur.text()) +} + +// write_hash writes the `#` that starts a path's inner part, a segment of its own. +fn (mut w ValueWalk) write_hash() { + lo := w.sb.len + w.sb.write_u8(`#`) + w.bounds << lo + w.bounds << w.sb.len +} + +// write_inner writes an element below the nearest referrable one as a path shows it, after `#` +// or `/`. +fn (mut w ValueWalk) write_inner(n flatxml.MNode) { + nb := w.bounds.len + if !(nb >= 2 && w.bounds[nb - 1] - w.bounds[nb - 2] == 1 && w.sb[w.bounds[nb - 2]] == `#`) { + w.sb.write_u8(`/`) + } + lo := w.sb.len + write_segment(mut w.sb, n) + w.bounds << lo + w.bounds << w.sb.len +} + +// emit adds n's text as a value at the path written. +fn (mut w ValueWalk) emit(n flatxml.MNode) { + w.out << Value{ + path: w.sb[w.start..].bytestr() + text: trimmed(n.text()).clone() + node: n.i + } +} + +// glob_at reports whether the pattern's segments from pi match the path's segments si .. end: +// all of them, or, with prefix, as far as the path goes (a longer path could match). +fn (w &ValueWalk) glob_at(pi int, si int, end int, prefix bool) bool { + if pi == w.pat.len { + return si == end + } + if w.pat[pi] == '**' { + for k in si .. end + 1 { + if w.glob_at(pi + 1, k, end, prefix) { + return true + } + } + return false + } + if si == end { + return prefix + } + return w.segment_matches(w.pat[pi], si) && w.glob_at(pi + 1, si + 1, end, prefix) +} + +// segment_matches is glob_segment on segment si of the path written. +fn (w &ValueWalk) segment_matches(pat string, si int) bool { + lo, mut hi := w.bounds[2 * si], w.bounds[2 * si + 1] + hash := hi - lo == 1 && w.sb[lo] == `#` + if hash || pat == '#' { + return hash && pat == '#' + } + if !pat.contains_u8(`[`) { + for i in lo .. hi { + if w.sb[i] == `[` { + hi = i + break + } + } + } + return glob_bytes(pat, w.sb, lo, hi) +} + +// glob_bytes is glob_name on b[lo..hi]. +fn glob_bytes(pat string, b []u8, lo int, hi int) bool { + if pat == '*' { + return true + } + n := hi - lo + if !pat.contains_any('*?') { + if pat.len != n { + return false + } + for i in 0 .. n { + if pat[i] != b[lo + i] { + return false + } + } + return true + } + mut p, mut i := 0, 0 + mut star, mut mark := -1, 0 + for i < n { + if p < pat.len && (pat[p] == `?` || pat[p] == b[lo + i]) { + p++ + i++ + } else if p < pat.len && pat[p] == `*` { + star = p + mark = i + p++ + } else if star >= 0 { + p = star + 1 + mark++ + i = mark + } else { + return false + } + } + for p < pat.len && pat[p] == `*` { + p++ + } + return p == pat.len +} diff --git a/arxmodel/values_test.v b/arxmodel/values_test.v new file mode 100644 index 0000000..ca8628c --- /dev/null +++ b/arxmodel/values_test.v @@ -0,0 +1,149 @@ +module arxmodel + +import os + +fn vdata(p ...string) string { + mut parts := ['testdata'] + parts << p + return os.join_path(@VMODROOT, ...parts) +} + +// values_by reads a model by the built-in identity rules and gives the values a pattern wants, +// as lines. +fn values_by(m &Model, pattern string) []string { + p := automat_policy() + c := check_with(m, identity_flat(builtin_identity(), [m]), 'built-in identity rules', p.settings(), + p, CheckOptions{ + skip_xml: true + skip_multiplicity: true + skip_references: true + }) or { panic(err) } + return c.values(pattern).map(it.line()) +} + +// values_model writes one document and loads it. +fn values_model(name string, text string) &Model { + dir := os.join_path(os.temp_dir(), 'arxmodel_values_${os.getpid()}') + os.mkdir_all(dir) or { panic(err) } + path := os.join_path(dir, name + '.arxml') + os.write_file(path, text) or { panic(err) } + return load([path]) or { panic(err) } +} + +fn test_values_by_path() { + m := load([vdata('compare')]) or { panic(err) } + assert values_by(m, '/Body/**') == [ + '/Body/Head#WATTS = "55"', + '/Body/Head#NOTES/NOTE = "first"', + '/Body/Head#NOTES/NOTE = "second"', + '/Body/LampCfg/Count = "2"', + '/Body/LampCfg/Dim = "10"', + '/Body/LampCfg/Signal = "/Sig/A"', + '/Body/Tail#WATTS = "5"', + '/Body/Tail#TARGET-REF = "/Body/Head"', + ] + // a value named by its definition, wherever it is + assert values_by(m, '/**/Dim') == ['/Body/LampCfg/Dim = "10"'] + // an element's path: every value below it + assert values_by(m, '/Body/LampCfg') == ['/Body/LampCfg/Count = "2"', '/Body/LampCfg/Dim = "10"', + '/Body/LampCfg/Signal = "/Sig/A"'] + // `*` one segment or part of one; `#` only by itself + assert values_by(m, '/Body/*#WATTS') == ['/Body/Head#WATTS = "55"', '/Body/Tail#WATTS = "5"'] + assert values_by(m, '/Body/*/WATTS') == [] + assert values_by(m, '/Body/LampCfg/D*') == ['/Body/LampCfg/Dim = "10"'] + assert values_by(m, '/Nowhere/**') == [] +} + +fn test_an_items_parts_its_label_and_quoting() { + m := values_model('parts', 'P +Cfg/Defs/Cfg +n/Defs/Cfg/Leveltrue 3 +/Defs/Cfg/Modev1a "b" \\ c +') + // the item's last element is its value; its other parts follow `#`; its label is a key; in + // the merged view's order (its lists, keyed, before what is matched by place) + assert values_by(m, '/P/**') == [ + '/P/Cfg/Level#NOTES/NOTE = "n"', + '/P/Cfg/Level#IS-AUTO = "true"', + '/P/Cfg/Level = "3"', + '/P/Cfg/Mode[v1]#VARIATION-POINT/SHORT-LABEL = "v1"', + '/P/Cfg/Mode[v1] = "a \\"b\\" \\\\ c"', + '/P/Cfg#DEFINITION-REF = "/Defs/Cfg"', + ] + // a pattern without a key matches a keyed segment by its name; with one, by both + assert values_by(m, '/P/Cfg/Mode').len == 2 + assert values_by(m, '/P/Cfg/Mode[v1]').len == 2 + assert values_by(m, '/P/Cfg/Mode[v2]') == [] +} + +fn test_a_value_is_one_line() { + assert Value{ + path: '/P/X' + text: 'a"b\\c\nd\te\x01' + }.line() == '/P/X = "a\\"b\\\\c\\nd\\te\\u0001"' +} + +fn test_a_container_is_no_item_and_an_item_needs_its_definition() { + // by the built-in rules a container is named by its definition and by its short name: its + // short name is its path, not a value; an item giving no definition is not named by one + m := values_model('container', 'P +Cfg +C/Defs/C +/Defs/C/Level1 +5 + +') + vals := values_by(m, '/P/**') + assert '/P/Cfg/C/Level = "1"' in vals + assert '/P/Cfg/C#DEFINITION-REF = "/Defs/C"' in vals + assert !vals.any(it.contains('SHORT-NAME')) + assert !vals.any(it.contains('/ = ') || it.contains('C/#')) + assert vals.any(it.starts_with('/P/Cfg/C#PARAMETER-VALUES/NUMBER-PARAM-VALUE') + && it.ends_with('/VALUE = "5"')) +} + +fn test_the_root_pattern() { + m := load([vdata('compare')]) or { panic(err) } + // `/` is the root: every value + assert values_by(m, '/') == values_by(m, '/**') + assert values_by(m, '/').len == 8 +} + +fn test_the_value_of_an_item_split_over_files() { + // one file gives the item a part, another its value: the value is the value + dir := os.join_path(os.temp_dir(), 'arxmodel_values_${os.getpid()}', 'split_item') + os.mkdir_all(dir) or { panic(err) } + for name, part in { + 'a': 'true' + 'b': '3' + } { + os.write_file(os.join_path(dir, '${name}.arxml'), 'P +Cfg +/Defs/Cfg/Level${part} +') or { + panic(err) + } + } + m := load([dir]) or { panic(err) } + assert values_by(m, '/P/**') == ['/P/Cfg/Level#IS-AUTO = "true"', '/P/Cfg/Level = "3"'] +} + +fn test_two_files_giving_an_item_its_value() { + // by the built-in rules each file's value is a child of its own: the first file's is the value + dir := os.join_path(os.temp_dir(), 'arxmodel_values_${os.getpid()}', 'two_values') + os.mkdir_all(dir) or { panic(err) } + for name, value in { + 'a': '2' + 'b': '3' + } { + os.write_file(os.join_path(dir, '${name}.arxml'), 'P +Cfg +/Defs/Cfg/Level${value} +') or { + panic(err) + } + } + m := load([dir]) or { panic(err) } + // the other file's copy is no value of its own (check reports that the files disagree) + assert values_by(m, '/P/**') == ['/P/Cfg/Level = "2"'] +} diff --git a/cmd/arxmodel/main.v b/cmd/arxmodel/main.v index d52fc76..c55e182 100644 --- a/cmd/arxmodel/main.v +++ b/cmd/arxmodel/main.v @@ -20,6 +20,9 @@ // arxmodel list (--schema-dir DIR | --rules FILE) ... // the merged element at an AUTOSAR path; the paths // of every referrable element of a name +// arxmodel values (--schema-dir DIR | --rules FILE) ... +// every value a path pattern wants, one a line: +// `path = "text"` // arxmodel merge (--schema-dir DIR | --rules FILE) [the options of check] [--force] // -o OUT|- ... merge and check them as check does (the summary // on standard error), and write the merged model @@ -91,6 +94,13 @@ const usage = 'usage: every element holding it, where the merge keeps two apart arxmodel list (--schema-dir DIR | --rules FILE) [--jobs N] ... the AUTOSAR path of every referrable element of that name + arxmodel values (--schema-dir DIR | --rules FILE) [--jobs N] ... + every value whose path a path pattern matches, or lies below a path it matches (`*` one + segment or part of one, `**` any: /Pkg/**/Name), one a line: `path = "text"`. A value the + merge names by its definition (an ECUC parameter or reference value) is the path of its + container and the last name of the definition (/Pkg/Cfg/Param = "3"); any other value is + the path of its referrable element, `#` and the elements below it (/Pkg/Elem#LENGTH = "8"). + Exit 1 when no value matches arxmodel merge (--schema-dir DIR | --rules FILE) [the options of check] [--force] -o OUT|- ... merge and check the files as check does, the summary going to standard error, and write @@ -121,7 +131,7 @@ const usage = 'usage: every setting of the policy with the values it takes, its value and the specification item it concerns, then the severities; --diff, how another policy differs; --schema, the JSON Schema of a policy file (policy.schema.json) - --policy NAME|FILE (rules, check, merge, get, list, diff, merge3, policy): the merge policy: + --policy NAME|FILE (rules, check, merge, get, list, values, diff, merge3, policy): the merge policy: spec (policy.toml, as the AUTOSAR specification has it, the default), automat (automat.policy.toml), or a file ...: the input set, for every command reading models: a file; a directory (every @@ -232,7 +242,7 @@ fn main() { '--case-sensitive', '--ignore-attr', '--check-attr', '--policy', '--skip-xml', '--skip-multiplicity', '--skip-references', '--refs-from', '--format'] } - 'get', 'list' { ['--jobs', '--schema-dir', '--rules', '--no-schema', '--policy'] } + 'get', 'list', 'values' { ['--jobs', '--schema-dir', '--rules', '--no-schema', '--policy'] } 'merge' { ['--jobs', '--schema-dir', '--rules', '--no-schema', '--all', '--problems', '--case-sensitive', '--ignore-attr', '--check-attr', '--policy', '--skip-xml', @@ -247,7 +257,7 @@ fn main() { else { []string{} } } // the input set: every command reading models takes excludes, and lists its files - if args[0] in ['load', 'rules', 'check', 'get', 'list', 'merge', 'diff', 'merge3'] { + if args[0] in ['load', 'rules', 'check', 'get', 'list', 'values', 'merge', 'diff', 'merge3'] { allowed << ['--exclude', '--verbose'] } if opts.paths.len == 0 && args[0] !in ['git-setup', 'policy'] { @@ -265,13 +275,13 @@ fn main() { exit(2) } } - if args[0] in ['check', 'merge', 'get', 'list', 'diff', 'merge3', 'git-diff', 'git-setup'] + if args[0] in ['check', 'merge', 'get', 'list', 'values', 'diff', 'merge3', 'git-diff', 'git-setup'] && opts.schema_dir != '' && opts.rules_file != '' { eprintln('${args[0]} takes --schema-dir or --rules, not both') exit(2) } // git-setup records only what it is given: the drivers resolve the rest when they run - if args[0] in ['check', 'merge', 'get', 'list', 'diff', 'merge3', 'git-diff', 'rules'] { + if args[0] in ['check', 'merge', 'get', 'list', 'values', 'diff', 'merge3', 'git-diff', 'rules'] { resolve_rules(mut opts) or { // git's diff driver always answers, with a text diff where it cannot read models if args[0] != 'git-diff' { @@ -291,6 +301,9 @@ fn main() { 'list' { cmd_list(opts) } + 'values' { + cmd_values(opts) + } 'check' { if opts.format != 'text' && opts.format !in arxmodel.report_formats { eprintln('--format is text, json or junit, not ${opts.format}') @@ -813,6 +826,32 @@ fn cmd_get(o Opts) { } } +// cmd_values prints every value a path pattern wants, one a line: `path = "text"` (Checked.values), +// each as it is found. +fn cmd_values(o Opts) { + if o.paths.len < 2 { + eprintln('values needs a path pattern and the files') + exit(2) + } + pattern := o.paths[0] + if (!pattern.starts_with('/') && !pattern.starts_with('#') && !pattern.starts_with('**')) + || (pattern != '/' && os.exists(pattern)) { + eprintln('values: ${pattern} is no path pattern (/Pkg/Name, /Pkg/**/Name, ...): the pattern comes before the files') + exit(2) + } + mut rest := o + rest.paths = o.paths[1..] + _, _, c, _ := merged(rest, Out{ + err: true + }, all_skipped()) + vals := c.values(pattern) + for v in vals { + println(v.line()) + } + eprintln(plural(vals.len, 'value')) + exit(if vals.len == 0 { 1 } else { 0 }) +} + // cmd_list prints the AUTOSAR path of every referrable element of one element name, a path held // by two such elements once for each. fn cmd_list(o Opts) { diff --git a/docs/plan.md b/docs/plan.md index d8b85d3..2283da4 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -75,6 +75,9 @@ Each step needs the matching part of flatxml first (its `docs/documents.md`): not compared, the union of item types' keys, absent keys kept apart); automat.policy.toml is the alternative (`--policy automat`). policy.schema.json is generated from the code's one list of settings; `arxmodel policy` shows a policy and `--diff` how two differ. + - **Done: values.** `values PATTERN` prints every value a path glob wants as `path = "text"` + lines, a value named by its definition at its container's path and the definition's last + name (the form an edit script would take, step 3 below). - **Next, in this order** (arxmodel is used in ordinary flows elsewhere too: merging .arxml files in CI and in git merges): 1. *CI and git:* diff --git a/scripts/values_cli_test.sh b/scripts/values_cli_test.sh new file mode 100755 index 0000000..181bdf1 --- /dev/null +++ b/scripts/values_cli_test.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# values through the command, on the synthetic compare model, no schema needed: values prints +# `path = "text"` lines and exits 1 when nothing matches, 2 without a pattern. +# +# scripts/values_cli_test.sh [path to the arxmodel binary] +set -euo pipefail +root=$(cd "$(dirname "$0")/.." && pwd) +bin=$(realpath "${1:-$root/bin/arxmodel}") +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +unset ARXMODEL_SCHEMA_DIR +export XDG_CONFIG_HOME="$work/config" +fail() { echo "FAIL: $*" >&2; exit 1; } +cd "$work" +model="$root/testdata/compare/base.arxml" + +# values: a definition's value by its last name, anywhere +"$bin" values '/**/Dim' "$model" > dim.txt 2> dim.log || { cat dim.log; fail 'values /**/Dim'; } +[[ "$(cat dim.txt)" == '/Body/LampCfg/Dim = "10"' ]] || { cat dim.txt; fail 'values /**/Dim printed something else'; } +grep -q '^1 value$' dim.log || { cat dim.log; fail 'values does not count what it printed'; } + +# an element's path: every value below it +"$bin" values /Body/Head "$model" > head.txt 2> /dev/null +[[ $(wc -l < head.txt) == 3 ]] || { cat head.txt; fail 'values /Body/Head'; } + +# nothing matching: exit 1; no path pattern: exit 2 +set +e +"$bin" values '/Nowhere/**' "$model" > /dev/null 2>&1 +[[ $? == 1 ]] || fail 'values matching nothing does not exit 1' +"$bin" values "$model" "$model" > /dev/null 2>&1 +[[ $? == 2 ]] || fail 'values without a path pattern does not exit 2' +set -e + +echo 'values_cli_test: ok' From 2c6a5b28d0f9ae31dbc8877da4822a23391908c5 Mon Sep 17 00:00:00 2001 From: Marten Hildell Date: Sat, 17 Oct 2026 18:36:32 +0200 Subject: [PATCH 2/5] values: the review's findings - An item's label is no candidate for its value: a label written after the value (a variation point after VALUE) no longer makes the value a `#VALUE` part; the label stays a part. - Roles the policy identifies whose items have no wrapper are named by their definition too: the rules record them (RuleSet.direct_identity_roles, Flat.direct_identities), and an item is one when its merge rule keys it by that definition. - Checked.each_value gives each value to a function as it is found (path and text views valid during the call), and the command prints through it: nothing is held, a broad query's memory is the model's. Checked.values collects through the same walk. On a large real-world model, the output is the same as before, with its schema and with the built-in rules; a query of every value peaks at the model's memory. Co-authored-by: Claude Opus 5.5 --- arxmodel/merge.v | 9 ++- arxmodel/rules.v | 22 +++++++ arxmodel/valuekeys_test.v | 30 ++++++++++ arxmodel/values.v | 83 +++++++++++++++++++++++--- cmd/arxmodel/main.v | 9 ++- testdata/schemas/DIRECT_1/DIRECT_1.xsd | 59 ++++++++++++++++++ 6 files changed, 195 insertions(+), 17 deletions(-) create mode 100644 testdata/schemas/DIRECT_1/DIRECT_1.xsd diff --git a/arxmodel/merge.v b/arxmodel/merge.v index f3be4c1..7ab7a07 100644 --- a/arxmodel/merge.v +++ b/arxmodel/merge.v @@ -55,6 +55,8 @@ pub mut: unkeyed_by_design bool // roles whose items are identified by a definition (the policy's item_identity) identity_roles []IdentityRole + // such roles whose items have no wrapper: keyed by the identity as their split key (values) + direct_identities []IdentityRole // rules of non-splittable roles allowed in several fragments only as identical repetitions repeat_rules []int // elements whose content a merge does not compare (structured comments, by the policy) @@ -471,9 +473,10 @@ fn (rs &RuleSet) describe(c Cands, ks []int) []string { pub fn (rs &RuleSet) to_flat(opts FlatOptions) Flat { mut c := rs.candidates() mut f := Flat{ - flat_wrappers: c.flats - lost_keys: c.lost_keys - identity_roles: rs.identity_roles() + flat_wrappers: c.flats + lost_keys: c.lost_keys + identity_roles: rs.identity_roles() + direct_identities: rs.direct_identity_roles() } if rs.policy.ignore_structured { f.ignored_content = rs.structured_elements() diff --git a/arxmodel/rules.v b/arxmodel/rules.v index a126ca8..82e4f4a 100644 --- a/arxmodel/rules.v +++ b/arxmodel/rules.v @@ -881,6 +881,28 @@ fn (mut rs RuleSet) check_naming(mut r Rule) { } } +// direct_identity_roles are the roles the policy identifies by one role of their items whose items +// are written in their holder with no wrapper: merged by that identity as their split key +// (resolve_key), named by it by values as a wrapper role's items are. Their parent is left empty. +pub fn (rs &RuleSet) direct_identity_roles() []IdentityRole { + mut out := []IdentityRole{} + for r in rs.rules { + if r.name !in rs.policy.item_identity || r.wrapper || r.flat || r.items.len == 0 { + continue + } + key := r.items[0].key + if key.len == 0 { + continue + } + out << IdentityRole{ + items: r.items.map(it.element) + definition: key[0].steps.join('/') + label: if key.len > 1 { key[1].steps.join('/') } else { '' } + } + } + return out +} + // identity_roles are the wrapper roles the policy identifies by one role of their items: the // wrapper, its items, and the resolved paths of their identity and of the rest of the key. pub fn (rs &RuleSet) identity_roles() []IdentityRole { diff --git a/arxmodel/valuekeys_test.v b/arxmodel/valuekeys_test.v index af048db..af0e11d 100644 --- a/arxmodel/valuekeys_test.v +++ b/arxmodel/valuekeys_test.v @@ -103,3 +103,33 @@ fn test_a_rules_file_keeps_the_identity_roles() { back, _, _ := parse_rules_file(text)! assert back.identity_roles == flat.identity_roles } + +fn test_values_by_the_schemas_identity_roles() { + p := vk_policy('{ "Shelf.setting" = "definition" }') + // the label after the value (SETTING writes VARIATION-POINT last): the value stays the value + labelled := '/d/x1v1' + m := vk_model('labelled', labelled) + c := check(m, vk_schemas(m, p), skip_xml: true, skip_references: true)! + vals := c.values('/Fiction/**').map(it.line()) + assert vals == ['/Fiction/x[v1] = "1"', '/Fiction/x[v1]#VARIATION-POINT/SHORT-LABEL = "v1"'], vals.str() + // each_value gives what values gives, one at a time + mut seen := &[]string{} + n := c.each_value('/Fiction/**', fn [mut seen] (v Value) { + seen << v.line() + }) + assert n == 2 && *seen == vals +} + +fn test_values_of_a_role_with_no_wrapper() { + // the policy identifies a role written in its holder, with no wrapper, by its definition + dir := os.join_path(os.temp_dir(), 'arxmodel_vk_${os.getpid()}', 'direct') + os.mkdir_all(dir)! + os.write_file(os.join_path(dir, 'a.arxml'), ' + +Main +')! + m := load([dir])! + p := vk_policy('{ "Store.option" = "definition" }') + c := check(m, vk_schemas(m, p), skip_xml: true, skip_references: true)! + assert c.values('/Main/**').map(it.line()) == ['/Main/x = "1"', '/Main/y = "2"'] +} diff --git a/arxmodel/values.v b/arxmodel/values.v index 1150cda..65e472c 100644 --- a/arxmodel/values.v +++ b/arxmodel/values.v @@ -84,6 +84,8 @@ struct ItemIdentity { parent u32 def []u32 label []u32 + // a role with no wrapper: its items are those whose merge rule keys by the definition first + by_key string } // ValueWalk is one pass over a merged view collecting the values a pattern wants. The path of @@ -93,8 +95,9 @@ struct ItemIdentity { // copied, then its name), so the segments of the wrappers above it stay as they were for their // other children. struct ValueWalk { - ix &PathIndex - pat []string // the pattern's segments + view &flatxml.Merged = unsafe { nil } + ix &PathIndex + pat []string // the pattern's segments // per name id of the set's table: the item identities of elements of that name (several // roles may share an item name, under different parents) item_of [][]int @@ -112,6 +115,8 @@ mut: ref_s0 int ref_s1 int out []Value + each ?fn (Value) // each value as found, where the caller asks so (each_value) + count int // terminal's scratch, kept between items: an item's parts, where a fragment writes each, and // whether a fragment writes another part after it parts []u32 @@ -121,9 +126,35 @@ mut: // values are the values a path pattern wants, in the order of the merged view (see above). pub fn (c &Checked) values(pattern string) []Value { + mut w := c.value_walk(pattern) + w.walk() + return w.out +} + +// each_value gives f every value a pattern wants, in the order of the merged view, as it is +// found, and returns how many: nothing is kept, so a value's path and text are views valid during +// the call (clone them to keep them). +pub fn (c &Checked) each_value(pattern string, f fn (Value)) int { + mut w := c.value_walk(pattern) + w.each = f + w.walk() + return w.count +} + +// walk visits the merged view from its root. +fn (mut w ValueWalk) walk() { + if w.view != unsafe { nil } && w.view.root().ok() { + w.visit(w.view.root(), false, false) + } +} + +// value_walk is a walk for a pattern over the merged view, its tables made. +fn (c &Checked) value_walk(pattern string) ValueWalk { v := c.view if v == unsafe { nil } || !v.root().ok() { - return []Value{} + return ValueWalk{ + ix: c.index + } } tab := v.set.tab n := tab.len() @@ -148,6 +179,19 @@ pub fn (c &Checked) values(pattern string) []Value { } } } + for r in c.flat.direct_identities { + idents << ItemIdentity{ + parent: any_parent + def: name_ids(tab, r.definition) + label: name_ids(tab, r.label) + by_key: r.definition + } + for item in r.items { + if id := tab.id(item) { + item_of[id] << idents.len - 1 + } + } + } if c.flat.identified != unsafe { nil } { id := c.flat.identified.id role := id.value_role() @@ -168,7 +212,8 @@ pub fn (c &Checked) values(pattern string) []Value { if pat.len == 0 { pat = ['**'] // `/`: the root, so every value } - mut w := ValueWalk{ + return ValueWalk{ + view: v ix: c.index pat: pat item_of: item_of @@ -176,8 +221,6 @@ pub fn (c &Checked) values(pattern string) []Value { skip: skip sb: strings.new_builder(256) } - w.visit(v.root(), false, false) - return w.out } // name_ids is a path's names as ids of a table; none where the table lacks a name. @@ -264,13 +307,23 @@ fn (w &ValueWalk) identity_of(n flatxml.MNode) int { for k in w.item_of[tag] { id := w.idents[k] if (id.parent == any_parent || n.parent().tag() == id.parent) - && path_text(n, id.def) != '' { + && path_text(n, id.def) != '' && keyed_by(n, id.by_key) { return k } } return -1 } +// keyed_by reports whether a merged node's rule keys it by a definition path first; true where +// none is asked (a wrapper role's items, the built-in rules'). +fn keyed_by(n flatxml.MNode, def string) bool { + if def == '' { + return true + } + r := n.rule() or { return false } + return r.key.len > 0 && r.key[0] == def +} + // item_parts collects an item's values: its value (terminal) at the item's path when it holds // text only and is compared; the rest after `#`. The definition is its name, not a value. fn (mut w ValueWalk) item_parts(n flatxml.MNode, id ItemIdentity, all bool) { @@ -305,7 +358,9 @@ fn (mut w ValueWalk) terminal(n flatxml.MNode, id ItemIdentity) u32 { w.followed.clear() for c in n.children() { tag := c.tag() - if tag != flatxml.text_tag && !(id.def.len > 0 && tag == id.def[0]) { + // neither the definition nor the label (the item's name, the label a part of its own) + if tag != flatxml.text_tag && !(id.def.len > 0 && tag == id.def[0]) + && !(id.label.len > 0 && tag == id.label[0]) { w.parts << c.i w.followed << false } @@ -459,8 +514,18 @@ fn (mut w ValueWalk) write_inner(n flatxml.MNode) { w.bounds << w.sb.len } -// emit adds n's text as a value at the path written. +// emit gives n's text as a value at the path written: to the caller's function, as views valid +// during the call, or kept. fn (mut w ValueWalk) emit(n flatxml.MNode) { + w.count++ + if f := w.each { + f(Value{ + path: unsafe { tos(&w.sb[w.start], w.sb.len - w.start) } + text: trimmed(n.text()) + node: n.i + }) + return + } w.out << Value{ path: w.sb[w.start..].bytestr() text: trimmed(n.text()).clone() diff --git a/cmd/arxmodel/main.v b/cmd/arxmodel/main.v index c55e182..f1f5118 100644 --- a/cmd/arxmodel/main.v +++ b/cmd/arxmodel/main.v @@ -844,12 +844,11 @@ fn cmd_values(o Opts) { _, _, c, _ := merged(rest, Out{ err: true }, all_skipped()) - vals := c.values(pattern) - for v in vals { + n := c.each_value(pattern, fn (v arxmodel.Value) { println(v.line()) - } - eprintln(plural(vals.len, 'value')) - exit(if vals.len == 0 { 1 } else { 0 }) + }) + eprintln(plural(n, 'value')) + exit(if n == 0 { 1 } else { 0 }) } // cmd_list prints the AUTOSAR path of every referrable element of one element name, a path held diff --git a/testdata/schemas/DIRECT_1/DIRECT_1.xsd b/testdata/schemas/DIRECT_1/DIRECT_1.xsd new file mode 100644 index 0000000..8b8738b --- /dev/null +++ b/testdata/schemas/DIRECT_1/DIRECT_1.xsd @@ -0,0 +1,59 @@ + + + + + + + mmt.qualifiedName="Named" + + + + mmt.qualifiedName="Named.shortName";pureMM.maxOccurs="1";pureMM.minOccurs="1" + atpIdentityContributor + + + + + + + + + + atp.Splitkey="store.shortName";mmt.qualifiedName="Depot.store";pureMM.maxOccurs="-1";pureMM.minOccurs="0" + atpSplitable + + + + + + + + + + + + + + + + + atp.Splitkey="option";mmt.qualifiedName="Store.option";pureMM.maxOccurs="-1";pureMM.minOccurs="0" + atpSplitable + + + + + + + + + mmt.qualifiedName="Option.definition";pureMM.maxOccurs="1";pureMM.minOccurs="0" + + + mmt.qualifiedName="Option.value";pureMM.maxOccurs="1";pureMM.minOccurs="0" + + + + From 969460e1b7759f31717df2a391ac0410ce7e988b Mon Sep 17 00:00:00 2001 From: Marten Hildell Date: Sat, 17 Oct 2026 18:46:10 +0200 Subject: [PATCH 3/5] Rules files record the direct identity roles (format 4) A rules file now writes `# direct-identity:` for each item_identity role without a wrapper, and reads it back, so a model merged by a rules file names those items by their definition in values as one merged by the schema does. The format is 4: a file of format 3 has no such lines, and is refused, to be made again. Co-authored-by: Claude Opus 5.5 --- arxmodel/ruletext.v | 17 ++++++++++++++++- arxmodel/valuekeys_test.v | 9 +++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/arxmodel/ruletext.v b/arxmodel/ruletext.v index 221e832..a041fa9 100644 --- a/arxmodel/ruletext.v +++ b/arxmodel/ruletext.v @@ -11,7 +11,7 @@ import os // rules_format is the version of the rules file's format this arxmodel writes and reads. A file // of another format is refused: its rules are not in the order this one merges and writes by. -pub const rules_format = 3 +pub const rules_format = 4 // version is arxmodel's version, from its own v.mod (builtin.v: @VMOD_FILE would be the v.mod of a // program that vendors arxmodel). @@ -90,6 +90,11 @@ pub fn format_rules(f Flat, pv Provenance, st Settings) !string { for r in f.identity_roles { out << '# identity: ${r.parent} ${r.items.join(',')} ${r.definition} ${r.label}'.trim_space() } + // such roles without a wrapper: their rules key by the definition already; values names their + // items by it + for r in f.direct_identities { + out << '# direct-identity: ${r.items.join(',')} ${r.definition} ${r.label}'.trim_space() + } // the rules (by their number, from 0) of non-splittable roles that must repeat identically, // and the elements a merge does not compare if f.repeat_rules.len > 0 { @@ -148,6 +153,16 @@ pub fn parse_rules_file(text string) !(Flat, Provenance, Settings) { } } } + 'direct-identity' { + w := value.fields() + if w.len >= 2 { + f.direct_identities << IdentityRole{ + items: w[0].split(',') + definition: w[1] + label: if w.len > 2 { w[2] } else { '' } + } + } + } 'conflict' { pair := value.all_before(' = ') f.conflicts << Conflict{ diff --git a/arxmodel/valuekeys_test.v b/arxmodel/valuekeys_test.v index af0e11d..4a25de7 100644 --- a/arxmodel/valuekeys_test.v +++ b/arxmodel/valuekeys_test.v @@ -132,4 +132,13 @@ fn test_values_of_a_role_with_no_wrapper() { p := vk_policy('{ "Store.option" = "definition" }') c := check(m, vk_schemas(m, p), skip_xml: true, skip_references: true)! assert c.values('/Main/**').map(it.line()) == ['/Main/x = "1"', '/Main/y = "2"'] + // through a rules file, the same: the file records the role + sc := vk_schemas(m, p) + flat, _ := governing_flat(sc)! + text := format_rules(flat, provenance(governing_use(sc)!, p), p.settings())! + assert text.contains('\n# direct-identity: OPTION DEFINITION-REF') + g, _, st := parse_rules_file(text)! + assert g.direct_identities.len == 1 + d := check_with(m, g, 'rules file', st, p, skip_xml: true, skip_references: true)! + assert d.values('/Main/**').map(it.line()) == ['/Main/x = "1"', '/Main/y = "2"'] } From 0b1ba0818ea7a7b2763661bf971f6d85a27d5807 Mon Sep 17 00:00:00 2001 From: Marten Hildell Date: Sat, 17 Oct 2026 19:00:02 +0200 Subject: [PATCH 4/5] values: several values of one definition by place; copies only from other fragments - One definition given several values under one container (a set) is printed with each value's place among them, `Level[#2]`, as a path shows an item keyed by its content, so each can be asked for; the merge orders them side by side, so two neighbours tell, no list is made. - Of an item's parts with the value's element name, only copies from other fragments (other files) are left out: two written side by side in one fragment are both printed. - The identity of an item_identity role is found in its key wherever it stands (a split key may give another part first), for direct roles and wrapper roles alike, and an item of a direct role is one when its rule keys by it anywhere. On a large real-world model, the lines of definitions with several values gain their place; every other line is as before, at the same time and peak memory. Co-authored-by: Claude Opus 5.5 --- arxmodel/rules.v | 27 +++++++++++++---- arxmodel/valuekeys_test.v | 16 ++++++++++ arxmodel/values.v | 41 ++++++++++++++++++++++++-- arxmodel/values_test.v | 15 ++++++++++ testdata/schemas/DIRECT_1/DIRECT_1.xsd | 21 +++++++++++++ 5 files changed, 112 insertions(+), 8 deletions(-) diff --git a/arxmodel/rules.v b/arxmodel/rules.v index 82e4f4a..9050694 100644 --- a/arxmodel/rules.v +++ b/arxmodel/rules.v @@ -890,19 +890,34 @@ pub fn (rs &RuleSet) direct_identity_roles() []IdentityRole { if r.name !in rs.policy.item_identity || r.wrapper || r.flat || r.items.len == 0 { continue } - key := r.items[0].key - if key.len == 0 { - continue - } + def, label := rs.identity_parts(r) or { continue } out << IdentityRole{ items: r.items.map(it.element) - definition: key[0].steps.join('/') - label: if key.len > 1 { key[1].steps.join('/') } else { '' } + definition: def + label: label } } return out } +// identity_parts are the paths of an item_identity role's identity and label in its items' key: +// the part the policy's identity gave (resolve_key), wherever it stands, and the part after it. +fn (rs &RuleSet) identity_parts(r Rule) ?(string, string) { + by := rs.policy.item_identity[r.name] or { return none } + if r.items.len == 0 { + return none + } + w := rs.walk_roles(rs.schema.elements[r.items[0].decl].typ, [by]) or { return none } + def := w.steps.join('/') + key := r.items[0].key + for j, k in key { + if k.steps.join('/') == def { + return def, if j + 1 < key.len { key[j + 1].steps.join('/') } else { '' } + } + } + return none +} + // identity_roles are the wrapper roles the policy identifies by one role of their items: the // wrapper, its items, and the resolved paths of their identity and of the rest of the key. pub fn (rs &RuleSet) identity_roles() []IdentityRole { diff --git a/arxmodel/valuekeys_test.v b/arxmodel/valuekeys_test.v index 4a25de7..67514e8 100644 --- a/arxmodel/valuekeys_test.v +++ b/arxmodel/valuekeys_test.v @@ -142,3 +142,19 @@ fn test_values_of_a_role_with_no_wrapper() { d := check_with(m, g, 'rules file', st, p, skip_xml: true, skip_references: true)! assert d.values('/Main/**').map(it.line()) == ['/Main/x = "1"', '/Main/y = "2"'] } + +fn test_values_of_a_role_keyed_by_another_part_first() { + // the split key gives a kind before the item itself: the identity is the definition still + dir := os.join_path(os.temp_dir(), 'arxmodel_vk_${os.getpid()}', 'direct_kind') + os.mkdir_all(dir)! + os.write_file(os.join_path(dir, 'a.arxml'), ' + +Maink/d/z3 +')! + m := load([dir])! + p := vk_policy('{ "Store.choice" = "definition" }') + c := check(m, vk_schemas(m, p), skip_xml: true, skip_references: true)! + vals := c.values('/Main/**').map(it.line()) + assert '/Main/z = "3"' in vals, vals.str() + assert !vals.any(it.starts_with('/Main/k')), vals.str() +} diff --git a/arxmodel/values.v b/arxmodel/values.v index 65e472c..f9abcc9 100644 --- a/arxmodel/values.v +++ b/arxmodel/values.v @@ -321,7 +321,7 @@ fn keyed_by(n flatxml.MNode, def string) bool { return true } r := n.rule() or { return false } - return r.key.len > 0 && r.key[0] == def + return def in r.key } // item_parts collects an item's values: its value (terminal) at the item's path when it holds @@ -333,7 +333,8 @@ fn (mut w ValueWalk) item_parts(n flatxml.MNode, id ItemIdentity, all bool) { if !w.is_part(c, id) { continue } - if c.i != last && last_tag != flatxml.text_tag && c.tag() == last_tag { + if c.i != last && last_tag != flatxml.text_tag && c.tag() == last_tag + && !same_fragment(c, n.m.node(last)) { continue // another file's copy of the value: the first file's is the value } if c.i == last && c.child_count() == 0 { @@ -346,6 +347,21 @@ fn (mut w ValueWalk) item_parts(n flatxml.MNode, id ItemIdentity, all bool) { } } +// same_fragment reports whether two merged children have an element in one fragment of their +// parent: written side by side, not copies from different files. +fn same_fragment(a flatxml.MNode, b flatxml.MNode) bool { + for i in 0 .. a.fragment_count() { + fa := a.fragment(i) + for j in 0 .. b.fragment_count() { + fb := b.fragment(j) + if voidptr(fa.doc) == voidptr(fb.doc) && fa.parent().i == fb.parent().i { + return true + } + } + } + return false +} + // terminal is the merged child that is an item's value: of its parts (every element but its // definition, ignored ones too), one that no fragment writes another part after (the value comes // last in such an item); of several, the last in the merged view (in its rules' order), and of @@ -467,10 +483,31 @@ fn (mut w ValueWalk) write_item_name(n flatxml.MNode, id ItemIdentity) { } w.sb.write_u8(`]`) } + // one definition given several values under one container (a set): each by its place among + // them, as a path shows an item keyed by its content. The merge orders children by key, so + // they are neighbours: two siblings are looked at, no list made. + mut place := 1 + mut k := n.i + for k > 0 && same_definition(n.m.node(k - 1), n, id, def, label) { + place++ + k-- + } + if place > 1 || (int(n.i) + 1 < n.m.len() && same_definition(n.m.node(n.i + 1), n, id, def, label)) { + w.sb.write_string('[#') + w.sb.write_decimal(place) + w.sb.write_u8(`]`) + } w.bounds << lo w.bounds << w.sb.len } +// same_definition reports whether a merged node is a sibling of n giving the same definition and +// label. +fn same_definition(s flatxml.MNode, n flatxml.MNode, id ItemIdentity, def string, label string) bool { + return s.ok() && s.tag() == n.tag() && s.parent().i == n.parent().i + && path_text(s, id.def) == def && path_text(s, id.label) == label +} + // path_text is the text at a path of name ids below a merged node, trimmed; '' where there is none. fn path_text(n flatxml.MNode, ids []u32) string { if ids.len == 0 { diff --git a/arxmodel/values_test.v b/arxmodel/values_test.v index ca8628c..adaf140 100644 --- a/arxmodel/values_test.v +++ b/arxmodel/values_test.v @@ -147,3 +147,18 @@ fn test_two_files_giving_an_item_its_value() { // the other file's copy is no value of its own (check reports that the files disagree) assert values_by(m, '/P/**') == ['/P/Cfg/Level = "2"'] } + +fn test_values_written_side_by_side_and_several_of_one_definition() { + m := values_model('side_by_side', 'P +Cfg +/Defs/Cfg/Level12 +/Defs/Cfg/Modea +/Defs/Cfg/Modeb +') + vals := values_by(m, '/P/**') + // two values one item writes side by side: both printed, no copy of another file's + assert '/P/Cfg/Level#VALUE = "1"' in vals && '/P/Cfg/Level = "2"' in vals, vals.str() + // one definition given two values: each by its place + assert '/P/Cfg/Mode[#1] = "a"' in vals && '/P/Cfg/Mode[#2] = "b"' in vals, vals.str() + assert values_by(m, '/P/Cfg/Mode[#2]') == ['/P/Cfg/Mode[#2] = "b"'] +} diff --git a/testdata/schemas/DIRECT_1/DIRECT_1.xsd b/testdata/schemas/DIRECT_1/DIRECT_1.xsd index 8b8738b..dce06fd 100644 --- a/testdata/schemas/DIRECT_1/DIRECT_1.xsd +++ b/testdata/schemas/DIRECT_1/DIRECT_1.xsd @@ -43,6 +43,13 @@ atpSplitable + + + + atp.Splitkey="choice.kind, choice";mmt.qualifiedName="Store.choice";pureMM.maxOccurs="-1";pureMM.minOccurs="0" + atpSplitable + + @@ -56,4 +63,18 @@ + + + + + mmt.qualifiedName="Choice.kind";pureMM.maxOccurs="1";pureMM.minOccurs="0" + + + mmt.qualifiedName="Choice.definition";pureMM.maxOccurs="1";pureMM.minOccurs="0" + + + mmt.qualifiedName="Choice.value";pureMM.maxOccurs="1";pureMM.minOccurs="0" + + + From 83d95f9d379ad61567e1d47e9f76877b92108288 Mon Sep 17 00:00:00 2001 From: Marten Hildell Date: Sat, 17 Oct 2026 19:18:28 +0200 Subject: [PATCH 5/5] values: identities read from attributes; wrapper roles find their identity in the key - An identity or label the schema writes as an attribute (`@NAME` in the key) is read from the attribute: identity paths are element steps and, at their end, an attribute. - Wrapper item_identity roles find the policy's identity in their key wherever it stands, as direct roles do (identity_parts), so a split key giving another part first no longer names the values by that part. On a large real-world model the output is as before, with its schema and with the built-in rules. Co-authored-by: Claude Opus 5.5 --- arxmodel/rules.v | 10 ++-- arxmodel/valuekeys_test.v | 17 ++++++ arxmodel/values.v | 71 ++++++++++++++++++-------- testdata/schemas/DIRECT_1/DIRECT_1.xsd | 32 ++++++++++++ 4 files changed, 102 insertions(+), 28 deletions(-) diff --git a/arxmodel/rules.v b/arxmodel/rules.v index 9050694..8e00d2a 100644 --- a/arxmodel/rules.v +++ b/arxmodel/rules.v @@ -926,15 +926,13 @@ pub fn (rs &RuleSet) identity_roles() []IdentityRole { if r.name !in rs.policy.item_identity || !r.wrapper || r.flat || r.items.len == 0 { continue } - key := r.items[0].key - if key.len == 0 { - continue - } + // the policy's identity wherever it stands in the key, and the part after it + def, label := rs.identity_parts(r) or { continue } out << IdentityRole{ parent: r.element items: r.items.map(it.element) - definition: key[0].steps.join('/') - label: if key.len > 1 { key[1].steps.join('/') } else { '' } + definition: def + label: label } } return out diff --git a/arxmodel/valuekeys_test.v b/arxmodel/valuekeys_test.v index 67514e8..5b912d5 100644 --- a/arxmodel/valuekeys_test.v +++ b/arxmodel/valuekeys_test.v @@ -158,3 +158,20 @@ fn test_values_of_a_role_keyed_by_another_part_first() { assert '/Main/z = "3"' in vals, vals.str() assert !vals.any(it.starts_with('/Main/k')), vals.str() } + +fn test_values_of_an_attribute_identity_and_a_wrapper_keyed_by_another_part_first() { + dir := os.join_path(os.temp_dir(), 'arxmodel_vk_${os.getpid()}', 'direct_attr') + os.mkdir_all(dir)! + os.write_file(os.join_path(dir, 'a.arxml'), ' + +Mainyesk/d/w4 +')! + m := load([dir])! + p := vk_policy('{ "Store.flag" = "name", "Store.entry" = "definition" }') + c := check(m, vk_schemas(m, p), skip_xml: true, skip_references: true)! + vals := c.values('/Main/**').map(it.line()) + // an identity read from an attribute; a wrapper role named by its definition, not its kind + assert '/Main/on = "yes"' in vals, vals.str() + assert '/Main/w = "4"' in vals, vals.str() + assert !vals.any(it.starts_with('/Main/k')), vals.str() +} diff --git a/arxmodel/values.v b/arxmodel/values.v index f9abcc9..59b833e 100644 --- a/arxmodel/values.v +++ b/arxmodel/values.v @@ -82,8 +82,8 @@ const any_parent = u32(0xFFFF_FFFF) // of the set's table. struct ItemIdentity { parent u32 - def []u32 - label []u32 + def IdPath + label IdPath // a role with no wrapper: its items are those whose merge rule keys by the definition first by_key string } @@ -170,8 +170,8 @@ fn (c &Checked) value_walk(pattern string) ValueWalk { parent := tab.id(r.parent) or { continue } idents << ItemIdentity{ parent: parent - def: name_ids(tab, r.definition) - label: name_ids(tab, r.label) + def: id_path(tab, r.definition) + label: id_path(tab, r.label) } for item in r.items { if id := tab.id(item) { @@ -182,8 +182,8 @@ fn (c &Checked) value_walk(pattern string) ValueWalk { for r in c.flat.direct_identities { idents << ItemIdentity{ parent: any_parent - def: name_ids(tab, r.definition) - label: name_ids(tab, r.label) + def: id_path(tab, r.definition) + label: id_path(tab, r.label) by_key: r.definition } for item in r.items { @@ -197,8 +197,8 @@ fn (c &Checked) value_walk(pattern string) ValueWalk { role := id.value_role() idents << ItemIdentity{ parent: any_parent - def: name_ids(tab, role.definition) - label: name_ids(tab, role.label) + def: id_path(tab, role.definition) + label: id_path(tab, role.label) } for k in 2 .. n { name := tab.name(u32(k)) @@ -223,16 +223,40 @@ fn (c &Checked) value_walk(pattern string) ValueWalk { } } -// name_ids is a path's names as ids of a table; none where the table lacks a name. -fn name_ids(tab &flatxml.Names, path string) []u32 { +// IdPath is a key path below an item, as name ids of the set's table, ending at an element's +// text or, where its last step is `@NAME`, at that attribute. +struct IdPath { + ids []u32 + attr string + ok bool // the table has every name (and the path is not empty) +} + +// id_path is a key path as an IdPath; not ok where the table lacks a name. +fn id_path(tab &flatxml.Names, path string) IdPath { if path == '' { - return []u32{} + return IdPath{} + } + mut steps := path.split('/') + mut attr := '' + if steps.last().starts_with('@') { + attr = steps.last()[1..] + steps = steps[..steps.len - 1].clone() + } + mut ids := []u32{} + for name in steps { + ids << tab.id(name) or { return IdPath{} } } - mut out := []u32{} - for name in path.split('/') { - out << tab.id(name) or { return []u32{} } + return IdPath{ + ids: ids + attr: attr + ok: true } - return out +} + +// starts_at reports whether a child element of an item is where the path starts: the item's +// name, not a part of it (an attribute of the item itself starts at none). +fn (p IdPath) starts_at(tag u32) bool { + return p.ok && p.ids.len > 0 && p.ids[0] == tag } // visit collects the values of n and below: inner is whether the path has its `#` part, below @@ -375,8 +399,7 @@ fn (mut w ValueWalk) terminal(n flatxml.MNode, id ItemIdentity) u32 { for c in n.children() { tag := c.tag() // neither the definition nor the label (the item's name, the label a part of its own) - if tag != flatxml.text_tag && !(id.def.len > 0 && tag == id.def[0]) - && !(id.label.len > 0 && tag == id.label[0]) { + if tag != flatxml.text_tag && !id.def.starts_at(tag) && !id.label.starts_at(tag) { w.parts << c.i w.followed << false } @@ -432,7 +455,7 @@ fn (mut w ValueWalk) terminal(n flatxml.MNode, id ItemIdentity) u32 { fn (w &ValueWalk) is_part(c flatxml.MNode, id ItemIdentity) bool { tag := c.tag() return tag != flatxml.text_tag && !(int(tag) < w.skip.len && w.skip[tag]) - && !(id.def.len > 0 && tag == id.def[0]) + && !id.def.starts_at(tag) } // from_ref starts the path of a node anew, after what is written: its nearest referrable @@ -508,13 +531,14 @@ fn same_definition(s flatxml.MNode, n flatxml.MNode, id ItemIdentity, def string && path_text(s, id.def) == def && path_text(s, id.label) == label } -// path_text is the text at a path of name ids below a merged node, trimmed; '' where there is none. -fn path_text(n flatxml.MNode, ids []u32) string { - if ids.len == 0 { +// path_text is the text at a key path below a merged node, or the attribute it ends at, trimmed; +// '' where there is none. +fn path_text(n flatxml.MNode, p IdPath) string { + if !p.ok { return '' } mut cur := n - for id in ids { + for id in p.ids { mut found := false for c in cur.children() { if c.tag() == id { @@ -527,6 +551,9 @@ fn path_text(n flatxml.MNode, ids []u32) string { return '' } } + if p.attr != '' { + return trimmed(cur.attr(p.attr)) + } return trimmed(cur.text()) } diff --git a/testdata/schemas/DIRECT_1/DIRECT_1.xsd b/testdata/schemas/DIRECT_1/DIRECT_1.xsd index dce06fd..25f41cc 100644 --- a/testdata/schemas/DIRECT_1/DIRECT_1.xsd +++ b/testdata/schemas/DIRECT_1/DIRECT_1.xsd @@ -43,6 +43,25 @@ atpSplitable + + + + atp.Splitkey="flag";mmt.qualifiedName="Store.flag";pureMM.maxOccurs="-1";pureMM.minOccurs="0" + atpSplitable + + + + + + atp.Splitkey="entry.kind, entry";mmt.qualifiedName="Store.entry";pureMM.maxOccurs="-1";pureMM.minOccurs="0" + atpSplitable + + + + + + + @@ -64,6 +83,19 @@ + + + + mmt.qualifiedName="Flag.value";pureMM.maxOccurs="1";pureMM.minOccurs="0" + + + + + mmt.qualifiedName="Flag.name";pureMM.maxOccurs="1";pureMM.minOccurs="0";xml.attribute="true" + + + +