From cd59718a00a998ff5968024df709a2e5d4e09e32 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 04:23:51 +0000 Subject: [PATCH 01/25] feat(view): read an edited draw.io file back as DiagramLayout edits Add the draw.io reader (uncompressed and compressed pages, page selection by view, container-relative geometry resolved to absolute coordinates) and the matcher that diffs an edited file against the writer's own export of the same rendering: nodes and ports pair by qualified name and ordinal, edges by their ends, kind and ordinal, notes and pictures by position, and only cells that differ within half a pixel become Layout, Route, Style, Canvas, Note or Picture edits. Pictures now carry their declaration's Origin so a moved picture can be written back. Co-Authored-By: jason.han --- internal/ir/view/drawio.go | 22 +- internal/ir/view/drawio_import.go | 489 +++++++++++++++++++++++ internal/ir/view/drawio_import_diff.go | 518 +++++++++++++++++++++++++ internal/ir/view/drawio_import_test.go | 296 ++++++++++++++ internal/ir/view/geometry.go | 4 +- internal/ir/view/picture_test.go | 23 +- 6 files changed, 1342 insertions(+), 10 deletions(-) create mode 100644 internal/ir/view/drawio_import.go create mode 100644 internal/ir/view/drawio_import_diff.go create mode 100644 internal/ir/view/drawio_import_test.go diff --git a/internal/ir/view/drawio.go b/internal/ir/view/drawio.go index f5273c73f0..859d06d84e 100644 --- a/internal/ir/view/drawio.go +++ b/internal/ir/view/drawio.go @@ -18,17 +18,29 @@ func (r *Rendering) Drawio() (string, error) { return r.DrawioWith(Options{}) } // DrawioWith writes the rendering as a draw.io file under options; every semantic // cell is an carrying the element's qualified name, kind, view and declaration. func (r *Rendering) DrawioWith(options Options) (string, error) { + r, w, err := r.drawioPrepare(options) + if err != nil { + return "", err + } + return w.document(r, options), nil +} + +// drawioPrepare checks options and normalizes the rendering the way the file +// shows it — shown elements, folded compartments, stand-ins dropped, partial +// placement omitted — and settles every box, so that the rendering returned is +// the one whose node ids and edge indices the file's cell ids name. +func (r *Rendering) drawioPrepare(options Options) (*Rendering, *drawioWriter, error) { if !r.supportsForm(FormDrawio) { - return "", r.wrongFormError(FormDrawio) + return nil, nil, r.wrongFormError(FormDrawio) } if err := options.Palette.check(); err != nil { - return "", err + return nil, nil, err } if err := options.Unplaced.check(); err != nil { - return "", err + return nil, nil, err } if err := options.Ports.check(); err != nil { - return "", err + return nil, nil, err } r = r.showing(options.Show) if options.Unplaced != UnplacedStrip || options.Compartments.automatic() { @@ -55,7 +67,7 @@ func (r *Rendering) DrawioWith(options Options) (string, error) { } w.layout(r) w.edgeIDs = drawioEdgeIDs(r.Edges) - return w.document(r, options), nil + return r, w, nil } // drawioWriter composes one draw.io file from a rendering. diff --git a/internal/ir/view/drawio_import.go b/internal/ir/view/drawio_import.go new file mode 100644 index 0000000000..05f1711bc5 --- /dev/null +++ b/internal/ir/view/drawio_import.go @@ -0,0 +1,489 @@ +package view + +import ( + "bytes" + "compress/flate" + "encoding/base64" + "encoding/xml" + "errors" + "fmt" + "io" + "net/url" + "sort" + "strconv" + "strings" +) + +// DrawioFile is a draw.io file read back for one view: the page drawn for it, +// its size and unit, and every cell of the page with its geometry made absolute. +type DrawioFile struct { + // View is the view the page's cells were rendered from, the writer's `view` attribute. + View string + // Page is the diagram's name, Pages every diagram name of the file in order. + Page string + Pages []string + // PageWidth and PageHeight are the model's page size; Unit its unit when stated. + PageWidth, PageHeight float64 + Unit string + // Cells are the page's cells in document order, the root cells 0 and 1 left out. + Cells []DrawioCell + byID map[string]*DrawioCell +} + +// DrawioCell is one cell of a draw.io page: the writer's handle when the cell is +// one of its semantic objects, and what draw.io keeps of it. +type DrawioCell struct { + ID, Parent string + // FQN, Kind, View, Link and Label are the `` attributes a semantic + // cell wraps; all empty on a plain mxCell, whose Value is its text. + FQN, Kind, View, Link, Label string + Value string + Vertex, Edge bool + Collapsed bool + // Style is the style string split at its semicolons; a bare word maps to "". + Style map[string]string + StyleText string + // Geometry is the cell's box in absolute coordinates, every container's + // offset added, when HasGeometry; Relative marks an edge or label geometry, + // which is left as written. + Geometry DrawioBox + HasGeometry bool + Relative bool + // Alternate is the full box a collapsed cell expands to, when stated. + Alternate *DrawioBox + // Points are an edge's waypoints, Source and Target the cells it joins. + Points []Point + Source, Target string +} + +// DrawioBox is a cell's box, as drawioBox is for the writer. +type DrawioBox struct { + X, Y, Width, Height float64 +} + +// Cell is the cell with the given id, nil for none. +func (f *DrawioFile) Cell(id string) *DrawioCell { + return f.byID[id] +} + +// DrawioFileError reports a file that is not a draw.io file this reader reads. +type DrawioFileError struct { + Message string + Err error +} + +func (e *DrawioFileError) Error() string { + if e.Err != nil { + return fmt.Sprintf("not a draw.io file: %s: %v", e.Message, e.Err) + } + return "not a draw.io file: " + e.Message +} + +func (e *DrawioFileError) Unwrap() error { return e.Err } + +// DrawioViewError reports that no page of the file, or more than one, was +// rendered from the requested view; Pages names the views the pages carry. +type DrawioViewError struct { + View string + Pages []string + Found []string +} + +func (e *DrawioViewError) Error() string { + switch { + case len(e.Found) > 1 && e.View == "": + return fmt.Sprintf("the file has %d pages (%s); name the view to import", len(e.Found), strings.Join(e.Found, ", ")) + case len(e.Found) > 1: + return fmt.Sprintf("%d pages of the file were rendered from %s; the import reads one", len(e.Found), e.View) + case e.View == "": + return "no page of the file was rendered from a view; the cells carry no view attribute" + case len(e.Pages) == 0: + return fmt.Sprintf("no page of the file was rendered from %s; the cells carry no view attribute", e.View) + } + return fmt.Sprintf("no page of the file was rendered from %s; the pages were rendered from %s", e.View, strings.Join(e.Pages, ", ")) +} + +// drawioPageData is one as parsed: its name and its model's XML. +type drawioPageData struct { + name string + model []byte +} + +// ReadDrawio reads a draw.io file as the drawio form writes it, or as draw.io +// desktop saves it (each diagram's model deflated, base64 and URL encoded), and +// returns the page rendered from view; with view empty, the file's one page. +func ReadDrawio(data []byte, view string) (*DrawioFile, error) { + pages, err := drawioPages(data) + if err != nil { + return nil, err + } + files := make([]*DrawioFile, 0, len(pages)) + var names []string + for _, page := range pages { + f, err := readDrawioModel(page.model) + if err != nil { + return nil, &DrawioFileError{Message: "page " + strconv.Quote(page.name), Err: err} + } + f.Page = page.name + files = append(files, f) + names = append(names, page.name) + } + var found []*DrawioFile + var views []string + for _, f := range files { + if f.View != "" { + views = append(views, f.View) + } + if f.View != "" && (view == "" || f.View == view) { + found = append(found, f) + } + } + if len(found) != 1 { + var foundViews []string + for _, f := range found { + foundViews = append(foundViews, f.View) + } + return nil, &DrawioViewError{View: view, Pages: views, Found: foundViews} + } + found[0].Pages = names + return found[0], nil +} + +// drawioPages splits a file into its diagrams, each with its model's XML: +// inline for an uncompressed file, decoded for a compressed one. +func drawioPages(data []byte) ([]drawioPageData, error) { + decoder := xml.NewDecoder(bytes.NewReader(data)) + var pages []drawioPageData + var ( + root string + inFile bool + current *drawioPageData + depth int + text strings.Builder + model bytes.Buffer + enc *xml.Encoder + ) + for { + token, err := decoder.Token() + if err == io.EOF { + break + } + if err != nil { + return nil, &DrawioFileError{Message: "malformed XML", Err: err} + } + switch tok := token.(type) { + case xml.StartElement: + if root == "" { + root = tok.Name.Local + if root != "mxfile" { + return nil, &DrawioFileError{Message: fmt.Sprintf("the root element is <%s>, not ", root)} + } + inFile = true + continue + } + if current == nil { + if !inFile || tok.Name.Local != "diagram" { + return nil, &DrawioFileError{Message: fmt.Sprintf("<%s> under ; a file holds pages", tok.Name.Local)} + } + current = &drawioPageData{name: attrValue(tok.Attr, "name")} + text.Reset() + model.Reset() + enc = nil + continue + } + if depth == 0 { + if tok.Name.Local != "mxGraphModel" { + return nil, &DrawioFileError{Message: fmt.Sprintf("<%s> under ; a page holds one ", tok.Name.Local)} + } + enc = xml.NewEncoder(&model) + } + depth++ + if err := enc.EncodeToken(tok); err != nil { + return nil, &DrawioFileError{Message: "malformed XML", Err: err} + } + case xml.EndElement: + switch { + case current != nil && depth > 0: + depth-- + if err := enc.EncodeToken(tok); err != nil { + return nil, &DrawioFileError{Message: "malformed XML", Err: err} + } + if depth == 0 { + if err := enc.Flush(); err != nil { + return nil, &DrawioFileError{Message: "malformed XML", Err: err} + } + current.model = append([]byte(nil), model.Bytes()...) + } + case current != nil: + if current.model == nil { + decoded, err := decodeDrawioDiagram(text.String()) + if err != nil { + return nil, &DrawioFileError{Message: "page " + strconv.Quote(current.name) + " holds no model", Err: err} + } + current.model = decoded + } + pages = append(pages, *current) + current = nil + default: + inFile = false + } + case xml.CharData: + if current != nil && depth == 0 { + text.Write(tok) + } else if current != nil { + if err := enc.EncodeToken(tok); err != nil { + return nil, &DrawioFileError{Message: "malformed XML", Err: err} + } + } + } + } + if root == "" { + return nil, &DrawioFileError{Message: "the file is empty"} + } + if len(pages) == 0 { + return nil, &DrawioFileError{Message: "the file has no page"} + } + return pages, nil +} + +// decodeDrawioDiagram undoes draw.io's compressed page encoding: base64, raw +// deflate, then URL percent-encoding of the model's XML. +func decodeDrawioDiagram(text string) ([]byte, error) { + text = strings.TrimSpace(text) + if text == "" { + return nil, errors.New("the page is empty") + } + raw, err := base64.StdEncoding.DecodeString(text) + if err != nil { + return nil, fmt.Errorf("base64: %w", err) + } + inflated, err := io.ReadAll(flate.NewReader(bytes.NewReader(raw))) + if err != nil { + return nil, fmt.Errorf("deflate: %w", err) + } + unescaped, err := url.PathUnescape(string(inflated)) + if err != nil { + return nil, fmt.Errorf("percent-decoding: %w", err) + } + return []byte(unescaped), nil +} + +// readDrawioModel reads one into its cells, the object wrapper's +// handle on each semantic cell and every vertex geometry made absolute. +func readDrawioModel(model []byte) (*DrawioFile, error) { + decoder := xml.NewDecoder(bytes.NewReader(model)) + f := &DrawioFile{byID: map[string]*DrawioCell{}} + var ( + objects []map[string]string + open []*DrawioCell + points bool + seen bool + ) + for { + token, err := decoder.Token() + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + switch tok := token.(type) { + case xml.StartElement: + attrs := map[string]string{} + for _, a := range tok.Attr { + attrs[a.Name.Local] = a.Value + } + switch tok.Name.Local { + case "mxGraphModel": + seen = true + f.PageWidth, _ = strconv.ParseFloat(attrs["pageWidth"], 64) + f.PageHeight, _ = strconv.ParseFloat(attrs["pageHeight"], 64) + f.Unit = attrs["unit"] + case "object", "UserObject": + objects = append(objects, attrs) + case "mxCell": + cell := &DrawioCell{ID: attrs["id"], Parent: attrs["parent"], Value: attrs["value"], + Vertex: attrs["vertex"] == "1", Edge: attrs["edge"] == "1", Collapsed: attrs["collapsed"] == "1", + Source: attrs["source"], Target: attrs["target"], StyleText: attrs["style"], Style: drawioStyleMap(attrs["style"])} + if len(objects) > 0 { + o := objects[len(objects)-1] + cell.ID, cell.FQN, cell.Kind, cell.View, cell.Link, cell.Label = o["id"], o["fqn"], o["kind"], o["view"], o["link"], o["label"] + objects[len(objects)-1] = nil + } + if cell.ID == "" { + return nil, errors.New("an mxCell has no id") + } + if _, dup := f.byID[cell.ID]; dup { + return nil, fmt.Errorf("cell id %q written twice", cell.ID) + } + open = append(open, cell) + case "mxGeometry": + if len(open) == 0 { + return nil, errors.New("an mxGeometry outside every mxCell") + } + cell := open[len(open)-1] + cell.HasGeometry = true + cell.Relative = attrs["relative"] == "1" + cell.Geometry, err = drawioBoxOf(attrs) + if err != nil { + return nil, fmt.Errorf("cell %s: geometry: %w", cell.ID, err) + } + case "mxRectangle": + if len(open) > 0 && attrs["as"] == "alternateBounds" { + box, err := drawioBoxOf(attrs) + if err != nil { + return nil, fmt.Errorf("cell %s: alternate bounds: %w", open[len(open)-1].ID, err) + } + open[len(open)-1].Alternate = &box + } + case "Array": + points = len(open) > 0 && attrs["as"] == "points" + case "mxPoint": + if points && len(open) > 0 { + box, err := drawioBoxOf(attrs) + if err != nil { + return nil, fmt.Errorf("cell %s: waypoint: %w", open[len(open)-1].ID, err) + } + cell := open[len(open)-1] + cell.Points = append(cell.Points, Point{X: box.X, Y: box.Y}) + } + } + case xml.EndElement: + switch tok.Name.Local { + case "object", "UserObject": + if len(objects) > 0 { + if objects[len(objects)-1] != nil { + return nil, fmt.Errorf("object %q wraps no mxCell", objects[len(objects)-1]["id"]) + } + objects = objects[:len(objects)-1] + } + case "mxCell": + if len(open) > 0 { + cell := open[len(open)-1] + open = open[:len(open)-1] + if cell.ID != "0" && cell.ID != "1" { + f.Cells = append(f.Cells, *cell) + } + f.byID[cell.ID] = nil + } + case "Array": + points = false + } + } + } + if !seen { + return nil, errors.New("no ") + } + for i := range f.Cells { + f.byID[f.Cells[i].ID] = &f.Cells[i] + } + f.byID["0"], f.byID["1"] = nil, nil + if err := f.resolveGeometry(); err != nil { + return nil, err + } + f.View = f.pageView() + return f, nil +} + +// pageView is the view the page's semantic cells name; the one they agree on. +func (f *DrawioFile) pageView() string { + views := map[string]bool{} + for _, cell := range f.Cells { + if cell.View != "" { + views[cell.View] = true + } + } + if len(views) != 1 { + return "" + } + for v := range views { + return v + } + return "" +} + +// resolveGeometry adds every container's offset to its children's boxes, so a +// nested cell's Geometry is absolute like a Layout's coordinates. +func (f *DrawioFile) resolveGeometry() error { + resolved := map[string]bool{} + var resolve func(cell *DrawioCell, chain int) error + resolve = func(cell *DrawioCell, chain int) error { + if resolved[cell.ID] || !cell.Vertex || !cell.HasGeometry || cell.Relative { + return nil + } + if chain > len(f.Cells) { + return fmt.Errorf("cell %s: its parents form a cycle", cell.ID) + } + parent := f.byID[cell.Parent] + if parent == nil { + if cell.Parent != "1" && cell.Parent != "0" { + if _, known := f.byID[cell.Parent]; !known { + return fmt.Errorf("cell %s has parent %q, which is no cell", cell.ID, cell.Parent) + } + } + resolved[cell.ID] = true + return nil + } + if err := resolve(parent, chain+1); err != nil { + return err + } + if parent.Vertex && parent.HasGeometry && !parent.Relative { + cell.Geometry.X += parent.Geometry.X + cell.Geometry.Y += parent.Geometry.Y + } + resolved[cell.ID] = true + return nil + } + ids := make([]string, 0, len(f.Cells)) + for i := range f.Cells { + ids = append(ids, f.Cells[i].ID) + } + sort.Strings(ids) + for _, id := range ids { + if err := resolve(f.byID[id], 0); err != nil { + return err + } + } + return nil +} + +// drawioBoxOf reads x, y, width and height attributes, each 0 when absent. +func drawioBoxOf(attrs map[string]string) (DrawioBox, error) { + var box DrawioBox + for name, field := range map[string]*float64{"x": &box.X, "y": &box.Y, "width": &box.Width, "height": &box.Height} { + text, ok := attrs[name] + if !ok || text == "" { + continue + } + v, err := strconv.ParseFloat(text, 64) + if err != nil { + return DrawioBox{}, fmt.Errorf("%s=%q is no number", name, text) + } + *field = v + } + return box, nil +} + +// drawioStyleMap splits a style string into its key=value pairs, keeping a bare +// word (shape names like `swimlane`) under its own name with an empty value. +func drawioStyleMap(style string) map[string]string { + out := map[string]string{} + for _, part := range strings.Split(style, ";") { + if part == "" { + continue + } + key, value, _ := strings.Cut(part, "=") + out[key] = value + } + return out +} + +// attrValue is the named attribute's value, "" when absent. +func attrValue(attrs []xml.Attr, name string) string { + for _, a := range attrs { + if a.Name.Local == name { + return a.Value + } + } + return "" +} diff --git a/internal/ir/view/drawio_import_diff.go b/internal/ir/view/drawio_import_diff.go new file mode 100644 index 0000000000..d28dca0323 --- /dev/null +++ b/internal/ir/view/drawio_import_diff.go @@ -0,0 +1,518 @@ +package view + +import ( + "fmt" + "math" + "sort" + "strconv" + "strings" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" +) + +// DrawioTolerance is how far a coordinate may move in draw.io and still count +// as unchanged: half a pixel, below what the editor's grid lets a hand place. +const DrawioTolerance = 0.5 + +// DrawioEditKind is which DiagramLayout annotation a DrawioEdit writes. +type DrawioEditKind int + +const ( + DrawioEditLayout DrawioEditKind = iota + DrawioEditRoute + DrawioEditStyle + DrawioEditCanvas + DrawioEditNote + DrawioEditPicture +) + +// String names the annotation the kind writes. +func (k DrawioEditKind) String() string { + switch k { + case DrawioEditLayout: + return "Layout" + case DrawioEditRoute: + return "Route" + case DrawioEditStyle: + return "Style" + case DrawioEditCanvas: + return "Canvas" + case DrawioEditNote: + return "Note" + case DrawioEditPicture: + return "Picture" + } + return "Layout" +} + +// DrawioEdit is one change read back from an edited draw.io file: the +// annotation to write for an element, located by its qualified name and +// declaration, with the value the file states. Exactly one of Layout, Route, +// Style, Canvas, Note and Picture applies to Kind; a nil Route or Style clears. +type DrawioEdit struct { + Kind DrawioEditKind + // Cell is the id of the file cell the edit was read from, Label its label. + Cell, Label string + // FQN is the element's qualified name as the writer stamped it, "" for an + // edge or a note; Origin is where the element — or the annotation, for a + // Note or Picture — was declared. + FQN string + Origin Origin + // Edge is set for an edit on an edge: the ends it joins and its kind. + Edge *DrawioEdgeKey + // Index is a note's or picture's position in the rendering's list. + Index int + Layout *semantics.Layout + Route *semantics.Route + Style *semantics.Style + Canvas *semantics.Canvas + Note *semantics.Note + Picture *semantics.Picture +} + +// DrawioEdgeKey identifies an edge across exports: the qualified names of its +// ends, its kind, and its ordinal among the edges sharing them in file order. +type DrawioEdgeKey struct { + From, To string + Kind string + Ordinal int +} + +func (k DrawioEdgeKey) String() string { + s := fmt.Sprintf("%s %s → %s", k.Kind, k.From, k.To) + if k.Ordinal > 0 { + s += fmt.Sprintf(" (%d)", k.Ordinal+1) + } + return s +} + +// DrawioImport is what an edited draw.io file says about a rendering: the edits +// that make the model state what the file draws, and what the file holds that +// no edit carries. +type DrawioImport struct { + View string + // Edits are in file order, one per annotation changed. + Edits []DrawioEdit + // Unmatched are semantic cells of the file matching no element of the + // rendering: elements gone or renamed since the export. + Unmatched []string + // Unknown are cells the file holds that the export did not write and that + // carry no element: shapes drawn in draw.io, which no edit creates. + Unknown []DrawioCell + // Deleted are elements of the rendering whose cells the file no longer + // holds; nothing deletes them from the model. + Deleted []string + // Notices say what else the file changed that has no annotation to land in. + Notices []string +} + +// drawioMatch pairs a file cell with the baseline cell it stands for. +type drawioMatch struct { + file, base *DrawioCell +} + +// ImportDrawio reads an edited draw.io file of this rendering back as edits: +// the file the writer would emit now under options is the baseline, and only +// cells that differ from it are imported, so the writer's own geometry — grids, +// fitted sizes, computed routes — never becomes an annotation. +func (r *Rendering) ImportDrawio(file *DrawioFile, options Options) (*DrawioImport, error) { + norm, w, err := r.drawioPrepare(options) + if err != nil { + return nil, err + } + if file.View != "" && file.View != norm.View { + return nil, &DrawioViewError{View: norm.View, Pages: []string{file.View}} + } + pages, err := drawioPages([]byte(w.document(norm, options))) + if err != nil { + return nil, err + } + base, err := readDrawioModel(pages[0].model) + if err != nil { + return nil, err + } + out := &DrawioImport{View: norm.View} + m := &drawioMatcher{norm: norm, base: base, file: file, out: out, nodes: map[string]*Node{}, ports: map[string]Port{}} + for _, node := range drawioNodes(norm.Roots) { + m.nodes[node.ID] = node + for _, port := range node.Ports { + m.ports[node.ID+":"+port.ID] = port + } + } + m.match() + m.canvas() + return out, nil +} + +// drawioNodes lists every node of the trees rooted at roots, parents first. +func drawioNodes(roots []*Node) []*Node { + var out []*Node + for _, node := range roots { + out = append(out, node) + out = append(out, drawioNodes(node.Children)...) + } + return out +} + +// drawioMatcher pairs the cells of the edited file with the baseline's and +// turns each difference into an edit. +type drawioMatcher struct { + norm *Rendering + base, file *DrawioFile + out *DrawioImport + nodes map[string]*Node + ports map[string]Port + matched map[string]bool +} + +// match pairs nodes and ports by qualified name — and by ordinal when a view +// draws the same element twice, as a tree does an inherited member —, edges by +// their ends, kind and ordinal, notes and pictures by position, and diffs each pair. +func (m *drawioMatcher) match() { + m.matched = map[string]bool{} + byFQN := map[string][]*DrawioCell{} + for i := range m.base.Cells { + if c := &m.base.Cells[i]; c.Vertex && c.FQN != "" { + byFQN[c.FQN] = append(byFQN[c.FQN], c) + } + } + seen := map[string]int{} + baseEdges := m.baseIndex() + fileEdges := m.edgesByKey(m.file) + for i := range m.file.Cells { + cell := &m.file.Cells[i] + switch { + case cell.Vertex && cell.FQN != "": + ordinal := seen[cell.FQN] + seen[cell.FQN]++ + if ordinal >= len(byFQN[cell.FQN]) { + m.out.Unmatched = append(m.out.Unmatched, cell.FQN) + continue + } + base := byFQN[cell.FQN][ordinal] + m.matched[base.ID] = true + m.diffVertex(cell, base) + case cell.Edge && cell.Kind != "": + key, ok := fileEdges[cell.ID] + if !ok { + m.out.Unmatched = append(m.out.Unmatched, m.edgeLabel(cell, m.file)) + continue + } + base, ok := baseEdges.byKey[key] + if !ok { + m.out.Unmatched = append(m.out.Unmatched, key.String()) + continue + } + m.matched[base.ID] = true + m.diffEdge(cell, base, key) + default: + m.plainCell(cell) + } + } + m.deleted() +} + +// drawioEdgeIndex is a file's semantic edges keyed both ways. +type drawioEdgeIndex struct { + byKey map[DrawioEdgeKey]*DrawioCell +} + +// edgesByKey keys every semantic edge of f by the names of its ends, its kind +// and its ordinal among those sharing them, in document order; an edge whose +// end is no cell of the file is left out. +func (m *drawioMatcher) edgesByKey(f *DrawioFile) map[string]DrawioEdgeKey { + keys := map[string]DrawioEdgeKey{} + ordinals := map[DrawioEdgeKey]int{} + for i := range f.Cells { + cell := &f.Cells[i] + if !cell.Edge || cell.Kind == "" { + continue + } + from, to := f.Cell(cell.Source), f.Cell(cell.Target) + if from == nil || to == nil { + continue + } + key := DrawioEdgeKey{From: drawioEndName(from), To: drawioEndName(to), Kind: cell.Kind} + key.Ordinal = ordinals[key] + ordinals[key]++ + keys[cell.ID] = key + } + return keys +} + +// drawioEndName names an edge's end across exports: the element's qualified +// name, or for a cell without one — a pseudostate, an objective — its label. +func drawioEndName(cell *DrawioCell) string { + if cell.FQN != "" { + return cell.FQN + } + return strconv.Quote(cell.Label) +} + +// edgesByKey over the baseline also indexes the cells by key. +func (m *drawioMatcher) baseIndex() drawioEdgeIndex { + idx := drawioEdgeIndex{byKey: map[DrawioEdgeKey]*DrawioCell{}} + for id, key := range m.edgesByKey(m.base) { + idx.byKey[key] = m.base.Cell(id) + } + return idx +} + +// edgeLabel describes an edge the matcher cannot key. +func (m *drawioMatcher) edgeLabel(cell *DrawioCell, f *DrawioFile) string { + name := func(id string) string { + if c := f.Cell(id); c != nil { + return drawioEndName(c) + } + return "cell " + strconv.Quote(id) + } + return fmt.Sprintf("%s %s → %s", cell.Kind, name(cell.Source), name(cell.Target)) +} + +// diffVertex imports a moved, resized, folded or restyled node; a port's +// position is drawn on its owner's border, so only its style is read. +func (m *drawioMatcher) diffVertex(cell, base *DrawioCell) { + origin, isPort := m.originOf(base.ID) + edit := DrawioEdit{Cell: cell.ID, Label: cell.Label, FQN: cell.FQN, Origin: origin} + if isPort { + if !sameBox(boxOf(cell), boxOf(base)) { + m.out.Notices = append(m.out.Notices, fmt.Sprintf("%s: a port is drawn on its owner's border; its position in the file is not imported", cell.FQN)) + } + } else if layout := m.layoutDiff(cell, base); layout != nil { + edit.Kind, edit.Layout = DrawioEditLayout, layout + m.out.Edits = append(m.out.Edits, edit) + } + var current *Style + if node := m.nodes[base.ID]; node != nil { + current = node.Style + } + if style, changed := styleDiff(cell.Style, base.Style, current, drawioVertexStyleKeys); changed { + edit.Kind, edit.Style, edit.Layout = DrawioEditStyle, style, nil + m.out.Edits = append(m.out.Edits, edit) + } +} + +// originOf is the declaration behind a baseline cell id: a port's or a node's. +func (m *drawioMatcher) originOf(id string) (Origin, bool) { + if port, ok := m.ports[id]; ok { + return port.Origin, true + } + if node := m.nodes[id]; node != nil { + return node.Origin, false + } + return Origin{}, false +} + +// layoutDiff is the Layout a node needs for the file's box, nil when the box +// is the baseline's within tolerance and the fold is the same. +func (m *drawioMatcher) layoutDiff(cell, base *DrawioCell) *semantics.Layout { + fileBox, baseBox := fullBox(cell), fullBox(base) + moved := !near(fileBox.X, baseBox.X) || !near(fileBox.Y, baseBox.Y) + resized := !near(fileBox.Width, baseBox.Width) || !near(fileBox.Height, baseBox.Height) + folded := cell.Collapsed != base.Collapsed + if !moved && !resized && !folded { + return nil + } + layout := &semantics.Layout{X: fileBox.X, Y: fileBox.Y, Collapsed: cell.Collapsed} + node := m.nodes[base.ID] + if resized || (node != nil && node.Geometry != nil && node.Geometry.HasSize) { + layout.Width, layout.Height, layout.HasSize = fileBox.Width, fileBox.Height, true + } + if node != nil && node.Geometry != nil { + layout.Hidden = node.Geometry.Hidden + } + return layout +} + +// diffEdge imports an edge's moved waypoints and changed colours or font. +func (m *drawioMatcher) diffEdge(cell, base *DrawioCell, key DrawioEdgeKey) { + i, err := strconv.Atoi(strings.TrimPrefix(base.ID, "e")) + if err != nil || i < 0 || i >= len(m.norm.Edges) { + return + } + edge := m.norm.Edges[i] + out := DrawioEdit{Cell: cell.ID, Label: cell.Label, Origin: edge.Origin, Edge: &key} + if !sameWaypoints(cell.Points, base.Points) { + out.Kind = DrawioEditRoute + if len(cell.Points) > 0 { + out.Route = &semantics.Route{} + for _, p := range cell.Points { + out.Route.Points = append(out.Route.Points, semantics.Waypoint{X: p.X, Y: p.Y}) + } + } + m.out.Edits = append(m.out.Edits, out) + } + if style, changed := styleDiff(cell.Style, base.Style, edge.Style, drawioEdgeStyleKeys); changed { + out.Kind, out.Route, out.Style = DrawioEditStyle, nil, style + m.out.Edits = append(m.out.Edits, out) + } +} + +// plainCell handles a cell without an element: a note, a picture, an end label +// or a compartment row the export wrote, or a shape drawn in draw.io. +func (m *drawioMatcher) plainCell(cell *DrawioCell) { + base := m.base.Cell(cell.ID) + if base == nil { + m.out.Unknown = append(m.out.Unknown, *cell) + return + } + m.matched[cell.ID] = true + switch { + case strings.HasPrefix(cell.ID, "note") && cell.Vertex: + m.diffNote(cell, base) + case strings.HasPrefix(cell.ID, "picture") && cell.Vertex: + m.diffPicture(cell, base) + case cell.Vertex && cell.Relative && base.Relative: + if !near(cell.Geometry.X, base.Geometry.X) || !near(cell.Geometry.Y, base.Geometry.Y) { + m.out.Notices = append(m.out.Notices, fmt.Sprintf("label %s moved: a Route places a line, not its label; label positions have no annotation yet and are not imported", strconv.Quote(cell.Value))) + } + } +} + +// diffNote imports a moved, resized or reworded note box. +func (m *drawioMatcher) diffNote(cell, base *DrawioCell) { + i, err := strconv.Atoi(strings.TrimPrefix(cell.ID, "note")) + if err != nil || i < 0 || i >= len(m.norm.Notes) { + return + } + if sameBox(boxOf(cell), boxOf(base)) && cell.Value == base.Value { + return + } + note := m.norm.Notes[i] + box := boxOf(cell) + m.out.Edits = append(m.out.Edits, DrawioEdit{Kind: DrawioEditNote, Cell: cell.ID, Label: cell.Value, Origin: note.Origin, Index: i, + Note: &semantics.Note{Text: cell.Value, X: box.X, Y: box.Y, Width: box.Width, Height: box.Height, HasSize: true}}) +} + +// diffPicture imports a moved or resized picture; its bytes stay the file's. +func (m *drawioMatcher) diffPicture(cell, base *DrawioCell) { + i, err := strconv.Atoi(strings.TrimPrefix(cell.ID, "picture")) + if err != nil || i < 0 || i >= len(m.norm.Pictures) { + return + } + if sameBox(boxOf(cell), boxOf(base)) && cell.Value == base.Value { + return + } + picture := m.norm.Pictures[i] + box := boxOf(cell) + m.out.Edits = append(m.out.Edits, DrawioEdit{Kind: DrawioEditPicture, Cell: cell.ID, Label: cell.Value, Origin: picture.Origin, Index: i, + Picture: &semantics.Picture{Location: picture.Location, X: box.X, Y: box.Y, Width: box.Width, Height: box.Height, Alt: cell.Value, Above: picture.Above}}) +} + +// canvas imports a changed page size as the view's Canvas. +func (m *drawioMatcher) canvas() { + if near(m.file.PageWidth, m.base.PageWidth) && near(m.file.PageHeight, m.base.PageHeight) { + return + } + unit := m.file.Unit + if unit == "" && m.norm.Canvas != nil { + unit = m.norm.Canvas.Unit + } + m.out.Edits = append(m.out.Edits, DrawioEdit{Kind: DrawioEditCanvas, Cell: "page", FQN: m.norm.View, + Canvas: &semantics.Canvas{Unit: unit, Width: m.file.PageWidth, Height: m.file.PageHeight, HasSize: true}}) +} + +// deleted lists the baseline's semantic cells the file no longer holds. +func (m *drawioMatcher) deleted() { + var gone []string + for i := range m.base.Cells { + cell := &m.base.Cells[i] + if m.matched[cell.ID] { + continue + } + switch { + case cell.FQN != "": + gone = append(gone, cell.FQN) + case cell.Edge && cell.Kind != "": + gone = append(gone, m.edgeLabel(cell, m.base)) + } + } + sort.Strings(gone) + m.out.Deleted = gone +} + +// The draw.io style keys a Style annotation states, for a node and for an edge. +var ( + drawioVertexStyleKeys = []string{"fillColor", "strokeColor", "fontColor", "fontFamily", "fontSize", "fontStyle"} + drawioEdgeStyleKeys = []string{"strokeColor", "fontColor", "fontFamily", "fontSize", "fontStyle"} +) + +// styleDiff is the Style the element needs for the file's colours and font: +// the current one, each key the file changed overriding it. The second result +// is false when nothing changed; a nil Style with true clears the current one. +func styleDiff(file, base map[string]string, current *Style, keys []string) (*semantics.Style, bool) { + out := semantics.Style{} + if current != nil { + out = semantics.Style{Fill: current.Fill, Line: current.Line, Text: current.Text, Font: current.Font, FontSize: current.FontSize, Bold: current.Bold, Italic: current.Italic} + } + changed := false + for _, key := range keys { + fv, bv := file[key], base[key] + if fv == bv { + continue + } + changed = true + switch key { + case "fillColor": + out.Fill = drawioColour(fv) + case "strokeColor": + out.Line = drawioColour(fv) + case "fontColor": + out.Text = drawioColour(fv) + case "fontFamily": + out.Font = fv + case "fontSize": + out.FontSize, _ = strconv.ParseFloat(fv, 64) + case "fontStyle": + bits, _ := strconv.Atoi(fv) + out.Bold, out.Italic = bits&1 != 0, bits&2 != 0 + } + } + if !changed { + return nil, false + } + if out.Empty() { + return nil, current != nil + } + return &out, true +} + +// drawioColour is a draw.io colour as a Style states it: `#RRGGBB`, or "" +// for `none`, `default` and anything that is no hexadecimal colour. +func drawioColour(v string) string { + if len(v) == 7 && v[0] == '#' { + if _, err := strconv.ParseUint(v[1:], 16, 32); err == nil { + return strings.ToUpper(v) + } + } + return "" +} + +// boxOf is a cell's box; fullBox the box it expands to when collapsed. +func boxOf(cell *DrawioCell) DrawioBox { return cell.Geometry } + +func fullBox(cell *DrawioCell) DrawioBox { + box := cell.Geometry + if cell.Collapsed && cell.Alternate != nil { + box.Width, box.Height = cell.Alternate.Width, cell.Alternate.Height + } + return box +} + +func near(a, b float64) bool { return math.Abs(a-b) <= DrawioTolerance } + +func sameBox(a, b DrawioBox) bool { + return near(a.X, b.X) && near(a.Y, b.Y) && near(a.Width, b.Width) && near(a.Height, b.Height) +} + +func sameWaypoints(a, b []Point) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if !near(a[i].X, b[i].X) || !near(a[i].Y, b[i].Y) { + return false + } + } + return true +} diff --git a/internal/ir/view/drawio_import_test.go b/internal/ir/view/drawio_import_test.go new file mode 100644 index 0000000000..3775b1b9f1 --- /dev/null +++ b/internal/ir/view/drawio_import_test.go @@ -0,0 +1,296 @@ +package view + +import ( + "bytes" + "image" + "image/png" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +// drawioGoldenImport imports a draw.io document back against the rendering +// its golden case was exported from. +func drawioGoldenImport(t *testing.T, c struct { + name string + file string + view string + kind Kind +}, document string) *DrawioImport { + t.Helper() + rendering, options := renderWithSites(t, c.file, c.view) + file, err := ReadDrawio([]byte(document), c.view) + if err != nil { + t.Fatalf("read %s: %v", c.name, err) + } + imp, err := rendering.ImportDrawio(file, options) + if err != nil { + t.Fatalf("import %s: %v", c.name, err) + } + return imp +} + +// TestDrawioImportUntouchedGoldenIsNoEdit is the round trip's contract: a file +// the writer emitted, imported as is, changes nothing and reports nothing. +func TestDrawioImportUntouchedGoldenIsNoEdit(t *testing.T) { + for _, c := range plantumlGoldenCases { + if !c.kind.SupportsForm(FormDrawio) { + continue + } + t.Run(c.name, func(t *testing.T) { + golden, err := os.ReadFile(filepath.Join("testdata", c.name+".drawio.golden")) + if err != nil { + t.Fatal(err) + } + imp := drawioGoldenImport(t, c, string(golden)) + if len(imp.Edits) != 0 { + t.Errorf("edits = %+v, want none", imp.Edits) + } + if len(imp.Unmatched)+len(imp.Unknown)+len(imp.Deleted)+len(imp.Notices) != 0 { + t.Errorf("unmatched %v unknown %d deleted %v notices %v, want nothing reported", imp.Unmatched, len(imp.Unknown), imp.Deleted, imp.Notices) + } + }) + } +} + +// layoutCase is the placed interconnection view with a Layout, a Route, a +// Canvas, a Style, a note and a picture: every annotation the import writes. +var layoutCase = plantumlGoldenCases[7] + +// drawioCellXML is the of the cell id, with its geometry. +func drawioCellXML(t *testing.T, document, id string) (whole, inner string) { + t.Helper() + re := regexp.MustCompile(`(?s)]*>\s*()\s*||/>)`) + m := re.FindStringSubmatch(document) + if m == nil { + t.Fatalf("no cell %s in the document", id) + } + if m[1] != "" { + return m[0], m[1] + } + return m[0], m[0] +} + +// mutateCell rewrites the cell id's through edit. +func mutateCell(t *testing.T, document, id string, edit func(cell string) string) string { + t.Helper() + whole, inner := drawioCellXML(t, document, id) + edited := strings.Replace(whole, inner, edit(inner), 1) + if edited == whole { + t.Fatalf("mutation of %s changed nothing", id) + } + return strings.Replace(document, whole, edited, 1) +} + +// setGeometryAttr sets one attribute of the cell's own mxGeometry. +func setGeometryAttr(t *testing.T, cell, name, value string) string { + t.Helper() + re := regexp.MustCompile(`]*`) + loc := re.FindStringIndex(cell) + if loc == nil { + t.Fatalf("no geometry in %s", cell) + } + geometry := cell[loc[0]:loc[1]] + attr := regexp.MustCompile(` ` + name + `="[^"]*"`) + if attr.MatchString(geometry) { + geometry = attr.ReplaceAllString(geometry, ` `+name+`="`+value+`"`) + } else { + geometry = strings.Replace(geometry, ".*?`).ReplaceAllString(cell, + ``) + }) + edit := onlyEdit(t, drawioGoldenImport(t, layoutCase, document), DrawioEditRoute) + want := []semantics.Waypoint{{X: 300, Y: 40}, {X: 420, Y: 40}} + if edit.Route == nil || len(edit.Route.Points) != 2 || edit.Route.Points[0] != want[0] || edit.Route.Points[1] != want[1] { + t.Errorf("route = %+v, want %v", edit.Route, want) + } + if edit.Edge == nil || edit.Edge.From != "Plant::Pump::outlet" || edit.Edge.To != "Plant::Tank::inlet" { + t.Errorf("edge key = %+v", edit.Edge) + } +} + +func TestDrawioImportStraightenedEdgeClearsItsRoute(t *testing.T) { + golden, _ := os.ReadFile(filepath.Join("testdata", layoutCase.name+".drawio.golden")) + document := mutateCell(t, string(golden), "e0", func(cell string) string { + return regexp.MustCompile(`(?s).*?`).ReplaceAllString(cell, "") + }) + if edit := onlyEdit(t, drawioGoldenImport(t, layoutCase, document), DrawioEditRoute); edit.Route != nil { + t.Errorf("route = %+v, want nil to clear", edit.Route) + } +} + +func TestDrawioImportRecolouredNodeIsAStyle(t *testing.T) { + golden, _ := os.ReadFile(filepath.Join("testdata", layoutCase.name+".drawio.golden")) + document := mutateCell(t, string(golden), "n2", func(cell string) string { + return strings.Replace(cell, `style="rounded=1;`, `style="rounded=1;fillColor=#ffcc00;`, 1) + }) + edit := onlyEdit(t, drawioGoldenImport(t, layoutCase, document), DrawioEditStyle) + if edit.Style == nil || edit.Style.Fill != "#FFCC00" { + t.Errorf("style = %+v, want fill #FFCC00", edit.Style) + } +} + +func TestDrawioImportResizedPageIsACanvas(t *testing.T) { + golden, _ := os.ReadFile(filepath.Join("testdata", layoutCase.name+".drawio.golden")) + document := strings.Replace(string(golden), `pageWidth="1200" pageHeight="800"`, `pageWidth="1400" pageHeight="900"`, 1) + edit := onlyEdit(t, drawioGoldenImport(t, layoutCase, document), DrawioEditCanvas) + if edit.Canvas == nil || edit.Canvas.Width != 1400 || edit.Canvas.Height != 900 || edit.Canvas.Unit != "px" { + t.Errorf("canvas = %+v", edit.Canvas) + } +} + +func TestDrawioImportMovedNoteIsANote(t *testing.T) { + golden, _ := os.ReadFile(filepath.Join("testdata", layoutCase.name+".drawio.golden")) + document := mutateCell(t, string(golden), "note0", func(cell string) string { + return setGeometryAttr(t, cell, "y", "333") + }) + edit := onlyEdit(t, drawioGoldenImport(t, layoutCase, document), DrawioEditNote) + if edit.Note == nil || edit.Note.Y != 333 || edit.Note.Text == "" || edit.Origin.Doc == "" { + t.Errorf("note = %+v at %+v", edit.Note, edit.Origin) + } +} + +func TestDrawioImportMovedPictureIsAPicture(t *testing.T) { + var imageData bytes.Buffer + if err := png.Encode(&imageData, image.NewRGBA(image.Rect(0, 0, 2, 2))); err != nil { + t.Fatal(err) + } + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "local.png"), imageData.Bytes(), 0o600); err != nil { + t.Fatal(err) + } + origin := Origin{Doc: "pictured.sysml", Span: source.Span{Offset: 7, Len: 40}} + rendering := &Rendering{View: "Views::pictured", Kind: KindTree, Roots: []*Node{{ID: "n", Kind: "part", Name: "part"}}, + Pictures: []Picture{ + {Location: "local.png", Dir: dir, X: 10, Y: 20, Width: 40, Height: 30}, + {Location: "local.png", Dir: dir, X: 60, Y: 20, Width: 40, Height: 30, Above: true, Origin: origin}, + }} + exported, err := rendering.Drawio() + if err != nil { + t.Fatal(err) + } + document := mutateCell(t, exported, "picture1", func(cell string) string { + return setGeometryAttr(t, cell, "x", "715") + }) + file, err := ReadDrawio([]byte(document), "") + if err != nil { + t.Fatal(err) + } + imp, err := rendering.ImportDrawio(file, Options{}) + if err != nil { + t.Fatal(err) + } + edit := onlyEdit(t, imp, DrawioEditPicture) + if edit.Picture == nil || edit.Picture.X != 715 || edit.Picture.Y != 20 || edit.Picture.Location != "local.png" || !edit.Picture.Above || edit.Origin != origin || edit.Index != 1 { + t.Errorf("picture = %+v at %+v", edit.Picture, edit.Origin) + } +} + +func TestDrawioImportReportsWhatItCannotImport(t *testing.T) { + golden, _ := os.ReadFile(filepath.Join("testdata", layoutCase.name+".drawio.golden")) + document := strings.Replace(string(golden), `fqn="Plant::Loop::tank"`, `fqn="Plant::Loop::reservoir"`, 1) + document = strings.Replace(document, "", ``, 1) + imp := drawioGoldenImport(t, layoutCase, document) + if len(imp.Unmatched) != 1 || imp.Unmatched[0] != "Plant::Loop::reservoir" { + t.Errorf("unmatched = %v", imp.Unmatched) + } + if len(imp.Deleted) != 1 || imp.Deleted[0] != "Plant::Loop::tank" { + t.Errorf("deleted = %v", imp.Deleted) + } + if len(imp.Unknown) != 1 || imp.Unknown[0].ID != "hand" { + t.Errorf("unknown = %+v", imp.Unknown) + } + if len(imp.Edits) != 0 { + t.Errorf("edits = %+v, want none", imp.Edits) + } +} diff --git a/internal/ir/view/geometry.go b/internal/ir/view/geometry.go index 53372fb99e..afb7fc2efa 100644 --- a/internal/ir/view/geometry.go +++ b/internal/ir/view/geometry.go @@ -94,6 +94,8 @@ type Picture struct { Width, Height float64 Alt string Above bool + // Origin is where the Picture annotation was declared. + Origin Origin } // Path is the picture's file as a path from the working directory: Location @@ -130,7 +132,7 @@ func (r *Renderer) statedPictures(view *symbols.Symbol, out *Rendering) []Pictur } p := site.Picture dir := source.Dir(r.model.SourceFileOf(site.Origin())) - pictures = append(pictures, Picture{Location: p.Location, Dir: dir, X: p.X, Y: p.Y, Width: p.Width, Height: p.Height, Alt: p.Alt, Above: p.Above}) + pictures = append(pictures, Picture{Location: p.Location, Dir: dir, X: p.X, Y: p.Y, Width: p.Width, Height: p.Height, Alt: p.Alt, Above: p.Above, Origin: site.Origin()}) } return pictures } diff --git a/internal/ir/view/picture_test.go b/internal/ir/view/picture_test.go index f8447f1e56..38ca79c22d 100644 --- a/internal/ir/view/picture_test.go +++ b/internal/ir/view/picture_test.go @@ -241,8 +241,8 @@ func TestPicturesReachTheRenderingAndTheDOTForm(t *testing.T) { {Location: "images/bench.png", Dir: ".", X: 0, Y: 0, Width: 823, Height: 577, Alt: "the bench, from above"}, {Location: "images/logo.png", Dir: ".", X: 700, Y: 20, Width: 80, Height: 40, Above: true}, } - if !reflect.DeepEqual(rendering.Pictures, want) { - t.Errorf("pictures = %+v, want %+v", rendering.Pictures, want) + if got := picturesWithoutOrigins(t, rendering.Pictures, "pictures.sysml", "pictures.sysml"); !reflect.DeepEqual(got, want) { + t.Errorf("pictures = %+v, want %+v", got, want) } if rendering.Empty() || !rendering.Positioned() { t.Errorf("a view of placed parts and pictures is empty=%v positioned=%v", rendering.Empty(), rendering.Positioned()) @@ -345,8 +345,8 @@ func TestPictureStatedElsewhereIsLocatedFromItsOwnFile(t *testing.T) { {Location: "images/bench.png", Dir: "model", X: 0, Y: 0, Width: 400, Height: 300}, {Location: "images/logo.png", Dir: filepath.Join("docs", "overlay"), X: 10, Y: 10, Width: 80, Height: 40, Above: true}, } - if !reflect.DeepEqual(rendering.Pictures, want) { - t.Errorf("pictures = %+v, want %+v", rendering.Pictures, want) + if got := picturesWithoutOrigins(t, rendering.Pictures, filepath.Join("model", "site.sysml"), filepath.Join("docs", "overlay", "overlay.sysml")); !reflect.DeepEqual(got, want) { + t.Errorf("pictures = %+v, want %+v", got, want) } dot, err := rendering.DOT() if err != nil { @@ -540,3 +540,18 @@ func TestPicturePath(t *testing.T) { } } } + +// picturesWithoutOrigins checks that each picture's Origin names the file it was +// declared in, then returns the pictures with their Origins cleared. +func picturesWithoutOrigins(t *testing.T, pictures []Picture, docs ...string) []Picture { + t.Helper() + out := make([]Picture, len(pictures)) + for i, p := range pictures { + if i < len(docs) && p.Origin.Doc != docs[i] { + t.Errorf("picture %d declared in %q, want %q", i, p.Origin.Doc, docs[i]) + } + p.Origin = Origin{} + out[i] = p + } + return out +} From 732216af55147e8eca1d28c891b4c5c9a8103532 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 04:27:29 +0000 Subject: [PATCH 02/25] test(view): read the compressed draw.io desktop save and malformed files The layout golden as draw.io desktop 32.6.1 saved it compressed after a drag of the tank, which it re-parented to the page; malformed files yield typed errors; multi-page files select the page by view; cells without an element stay apart from the import. Co-Authored-By: jason.han --- internal/ir/view/drawio_read_test.go | 128 ++++++++++++++++++ .../ir/view/testdata/layout-desktop.drawio | 1 + 2 files changed, 129 insertions(+) create mode 100644 internal/ir/view/drawio_read_test.go create mode 100644 internal/ir/view/testdata/layout-desktop.drawio diff --git a/internal/ir/view/drawio_read_test.go b/internal/ir/view/drawio_read_test.go new file mode 100644 index 0000000000..8fc2fa4a65 --- /dev/null +++ b/internal/ir/view/drawio_read_test.go @@ -0,0 +1,128 @@ +package view + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +// The compressed fixture is the layout golden as draw.io desktop 32.6.1 saved +// it after File › Properties › Compressed and a drag of the tank to the right. +func TestReadDrawioCompressedDesktopSave(t *testing.T) { + data, err := os.ReadFile(filepath.Join("testdata", "layout-desktop.drawio")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), `compressed="true"`) || strings.Contains(string(data), "`, + "not an mxfile": ``, + "no page": ``, + "bad compressed": `not base64 at all!`, + "bad geometry": ``, + "duplicate id": ``, + "parent cycle": ``, + "parent not cell": ``, + } + for name, data := range cases { + t.Run(name, func(t *testing.T) { + _, err := ReadDrawio([]byte(data), "") + var fileErr *DrawioFileError + if !errors.As(err, &fileErr) { + t.Fatalf("err = %v, want a *DrawioFileError", err) + } + }) + } +} + +// drawioTestPage is one whose cells carry the view attribute given. +func drawioTestPage(name, view string) string { + attr := "" + if view != "" { + attr = ` view="` + view + `"` + } + return `` + + `` + + `` +} + +func TestReadDrawioSelectsThePageOfTheView(t *testing.T) { + two := `` + drawioTestPage("first", "V::one") + drawioTestPage("second", "V::two") + `` + file, err := ReadDrawio([]byte(two), "V::two") + if err != nil { + t.Fatal(err) + } + if file.Page != "second" || file.View != "V::two" || len(file.Pages) != 2 { + t.Errorf("page %q view %q pages %v", file.Page, file.View, file.Pages) + } + var viewErr *DrawioViewError + if _, err := ReadDrawio([]byte(two), ""); !errors.As(err, &viewErr) || !strings.Contains(err.Error(), "name the view") { + t.Errorf("two pages without a view: %v", err) + } + if _, err := ReadDrawio([]byte(two), "V::three"); !errors.As(err, &viewErr) || !strings.Contains(err.Error(), "V::one, V::two") { + t.Errorf("a view no page was rendered from: %v", err) + } + twice := `` + drawioTestPage("first", "V::one") + drawioTestPage("again", "V::one") + `` + if _, err := ReadDrawio([]byte(twice), "V::one"); !errors.As(err, &viewErr) || !strings.Contains(err.Error(), "2 pages") { + t.Errorf("two pages of one view: %v", err) + } + unviewed := `` + drawioTestPage("only", "") + `` + if _, err := ReadDrawio([]byte(unviewed), "V::one"); !errors.As(err, &viewErr) || !strings.Contains(err.Error(), "no view attribute") { + t.Errorf("cells without a view: %v", err) + } + if _, err := ReadDrawio([]byte(unviewed), ""); !errors.As(err, &viewErr) { + t.Errorf("cells without a view and no view asked: %v", err) + } +} + +func TestReadDrawioKeepsUnknownCellsApart(t *testing.T) { + data := `` + strings.Replace(drawioTestPage("p", "V::one"), "", + ``, 1) + `` + file, err := ReadDrawio([]byte(data), "V::one") + if err != nil { + t.Fatal(err) + } + hand := file.Cell("hand") + if hand == nil || hand.FQN != "" || hand.Value != "todo" || hand.Style["fillColor"] != "#FFF" || hand.Style["rounded"] != "1" { + t.Fatalf("hand = %+v", hand) + } + if hand.Geometry.X != 11 || hand.Geometry.Y != 12 { + t.Errorf("hand at %+v, want its parent's (1, 2) added", hand.Geometry) + } + if file.Cell("0") != nil || file.Cell("1") != nil { + t.Errorf("the root cells are listed") + } +} diff --git a/internal/ir/view/testdata/layout-desktop.drawio b/internal/ir/view/testdata/layout-desktop.drawio new file mode 100644 index 0000000000..66c886a399 --- /dev/null +++ b/internal/ir/view/testdata/layout-desktop.drawio @@ -0,0 +1 @@ +7ZlLc6M4EIB/DcdNATK2r7YTzxxmq1Kbrd2zArLRWkiMkEO8v34lJEAg8CtkKodNpVKoab26P3W3iAc22fs3DvP0d5Yg4oV+8u6BRy8MF9FM/lWCkxbMlwst2HOcaFHQCl7wv8gIfSM94gQVHUXBGBE47wpjRimKRUcGOWdlV23HSHfWHO6RI3iJIXGlf+NEpEYa+n774jvC+9RMvaxfZLBWNoIihQkrLRF48sCGMyb0U/a+QUTZrraL7rcdedssjCMqBjqw13+UPUKfwFfpkkrB24Teai27KHmCdqq9Xnvh3AuBNDhY/2AsN4b6SXWfZwLV+Cv52749YGqWYQ1WvXnDqLQ6/iWbhe6dExijRAm0JsH0oDUJPLGjeChORSYXugoirVDvlLbWaqxgb9tYohCn2mlFiTM5u2ytU6HGfFSbK1Ms0EsuVyEFpaRVynaMihfTMZBtSPBebTyWoyMuBW+ICyxxWJkXgqluhZDbNrDO5NrWMSME5gV+bUYy5pDd0bu1TrONb4hlSPCTVEktegJgjkvZsgaiUMvMMDPDkzlQwdy0oSF93wzdwiAfjOnqpsHjGlT6kOTH7BIk2uGNXh+XqUBZ9EAJXFCMY9D4qaG+ww9nR5pUXSomeGxcXW3/AlEXCcpwkqiJ1jtMyIYRxqtJwXb7tFw/VnBxdkDWG/1jaN3CDBPl+xXHkNQIm/UFA0jfBSKIHA7rSF4PMu9gGC3HKTRT/SEhg3QvF9abaz46Vz0aJNKWFAq0Vp4pHLSbXdxLe/QkwSJIjHP9rGiunmxNi2w2IdlLB2ywosHDhUBI3UjYkjzGq4WzTZJqVwZ6ZgUWmA0i/aOnoIPjxRPwyoRg2d0x0oRDjggU+K2bp0s7R3d49R+ii4Q+M0xF2+W3LuJ1sx6A7XaF4WBSFntgmcpGWXgqvELQ5QsNgIUSpzByUy478hgNMNphUI1UByTKqqzcxdJCENFkpaq2VlVHw7r2AkPx0Zc/223Dk8zNe1QfiVCtKuycHIurQYZGAZErgydLIVe4FOP8zPxuql70Krq+etTL7P0KsKcfnR1ePuj13kemU29SJpA/ToJVfKUw164WVxRghYk3MytwELQT46WX7I/pXhVebQSB5IjqTFHCU3FfznPz0HzRzXmga/EwOJP1RmqvEcOCFaRxyriZ4ObT13rn/Mmz3JHAIm2KHPfgDRymESNefYpuMcmZfX8Z1iqPSRPeRZvvZqz6QJtR5r2AMLu90h8x7QS0uc6YmrbwF9IWfn3a4hTFB7VGjoriyNFUMa65cw7fLwGYTWXiCagLP5s69NFK4RaTgK9P3Y6jyUjr3SDDYPZh0m78dCHvnwfvik8Xrd4nfbqoy/yGBjvYXfWN69d8o7jL9fOB1BZ2UxtYfLySuu5ej+nZa/2fytPVk6X4Sbf6wHW6czkZuta7Qe//a/2XvtbLZvtdX/du/zkCnv4D \ No newline at end of file From 3530143594a84f8e6dc5a812dba65b215e244b84 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 04:47:17 +0000 Subject: [PATCH 03/25] fix(view): pair draw.io cells by id before name, place a start by its member name A cell keeps its exported id while it stands for the same element, so a reordered tree of inherited members or a bundle of parallel edges keeps its identity; the name, or the ends and kind, pair what the id no longer does. A start or done no declaration names is written as the member its owner's body knows (Machines::VehicleStates::start), which a Layout, Style or Note may now be stated about through the inheriting owner. A recoloured port and a re-anchored note are reported, a page inflating past 64 MiB refused, and a page emptied of its cells is of the view its name states. Co-Authored-By: jason.han --- docs/project/view-rendering-forms.md | 3 +- internal/check/edit/layout.go | 132 ++++++++++---- internal/check/edit/layout_test.go | 50 ++++++ internal/ir/view/behavior.go | 6 +- internal/ir/view/drawio.go | 6 +- internal/ir/view/drawio_import.go | 16 +- internal/ir/view/drawio_import_diff.go | 137 ++++++++++---- internal/ir/view/drawio_import_match_test.go | 170 ++++++++++++++++++ internal/ir/view/drawio_read_test.go | 38 +++- .../ir/view/testdata/action.drawio.golden | 2 +- internal/ir/view/testdata/mixed.drawio.golden | 6 +- .../view/testdata/state-entry.drawio.golden | 8 +- internal/ir/view/testdata/state.drawio.golden | 4 +- .../view/testdata/typed-action.drawio.golden | 2 +- .../view/testdata/typed-state.drawio.golden | 2 +- internal/ir/view/view.go | 3 + 16 files changed, 493 insertions(+), 92 deletions(-) create mode 100644 internal/ir/view/drawio_import_match_test.go diff --git a/docs/project/view-rendering-forms.md b/docs/project/view-rendering-forms.md index 9688c04e83..139d48ec21 100644 --- a/docs/project/view-rendering-forms.md +++ b/docs/project/view-rendering-forms.md @@ -1420,7 +1420,8 @@ Every semantic cell — a node, a port, an edge — is wrapped in an `` element's handle beside draw.io's own `label`: `kind`, the node's keyword (`part def`, `state`, `initial`, …) or the edge's kind; `view`, the view rendered; `link`, the declaration's `file:line` when the element has one; and, on a node or a port, `fqn`, the declaration's -qualified name. An edge carries no `fqn` — a relationship has no name of its own in most +qualified name — for a `start` or `done` the model did not declare, the owner's member a +Layout names it as (`Machines::VehicleStates::start`). An edge carries no `fqn` — a relationship has no name of its own in most models — and is matched by its `link` and the `source` and `target` cells it joins. Compartment rows and edge end labels are plain `mxCell`s and carry none of these. The `fqn` is exact and the same for every cell of one element, so a tool reading an edited file back can diff --git a/internal/check/edit/layout.go b/internal/check/edit/layout.go index 08c5d4feaa..576991af10 100644 --- a/internal/check/edit/layout.go +++ b/internal/check/edit/layout.go @@ -1,6 +1,7 @@ package edit import ( + "errors" "fmt" "strconv" "strings" @@ -36,20 +37,20 @@ func (m Model) layoutSplices(i int, op Operation) ([]splice, error) { if err != nil { return nil, err } - sym, viewSym, err := m.layoutTargets(i, op) + r, sem := m.resolver() + target, err := m.layoutTargets(i, op, sem) if err != nil { return nil, err } - in, err := m.layoutDocument(i, op, sym, viewSym) + in, err := m.layoutDocument(i, op, target.sym, target.view) if err != nil { return nil, err } - r, sem := m.resolver() renderer := view.NewRenderer(sem, r, nil) - if err := m.checkPlaceable(i, op, renderer, sem, sym, viewSym); err != nil { + if err := m.checkPlaceable(i, op, renderer, sem, target); err != nil { return nil, err } - site := in.layoutSite(sem, sym, viewSym, op.Annotation) + site := in.layoutSite(sem, target, op.Annotation) var splices []splice switch { case bindings == nil: @@ -58,15 +59,24 @@ func (m Model) layoutSplices(i int, op Operation) ([]splice, error) { Message: fmt.Sprintf("%s carries no %s%s to clear", m.label(op), layoutTypeName(op.Annotation), inView(op.View))} } - splices = []splice{in.clearAnnotation(site, sym)} + splices = []splice{in.clearAnnotation(site, target.sym)} case site == nil: - splices = []splice{in.insertAnnotation(op.Annotation, bindings, sym, viewSym)} + splices = []splice{in.insertAnnotation(op.Annotation, bindings, target)} default: splices = in.updateAnnotation(site, bindings, features) } return m.addressed(splices, i, op, in), nil } +// layoutTarget is what a layout operation annotates: the element, the view whose +// body states the annotation (nil inline), and, for a member reached through an +// owner inheriting it (`Machines::VehicleStates::start`), that owner and the +// name as the operation wrote it, which the `about` clause repeats. +type layoutTarget struct { + sym, view, owner *symbols.Symbol + about string +} + // addressed labels splices as operation i's, addressed to in's document. func (m Model) addressed(splices []splice, i int, op Operation, in Model) []splice { label := m.label(op) @@ -229,41 +239,81 @@ func stringLiteral(s string) string { // body states the layout, wherever in the workspace each is declared. A view // body states an annotation `about` a qualified name, so an element reached by // none — its own or an owner's name missing — is placed inline or not at all. -func (m Model) layoutTargets(i int, op Operation) (sym, viewSym *symbols.Symbol, err error) { +func (m Model) layoutTargets(i int, op Operation, sem *semantics.Model) (layoutTarget, error) { + var target layoutTarget + var err error if op.Annotation == semantics.CanvasFQN && (op.Target != "" || op.Declaration.Len == 0) { - sym, err = m.viewNamed(i, op.Target, op.DeclarationDoc, + target.sym, err = m.viewNamed(i, op.Target, op.DeclarationDoc, fmt.Sprintf("%s is no view; a Canvas sizes the drawing surface of a view", op.Target)) } else { - sym, err = m.element(i, op) + target, err = m.layoutElement(i, op, sem) } if err != nil { - return nil, nil, err + return layoutTarget{}, err } - if op.Annotation != semantics.CanvasFQN && op.View != "" && !qualified(sym) { - return nil, nil, &Error{Failure: FailureNotNamed, OperationIndex: i, + if op.Annotation != semantics.CanvasFQN && op.View != "" && target.owner == nil && !qualified(target.sym) { + return layoutTarget{}, &Error{Failure: FailureNotNamed, OperationIndex: i, Message: fmt.Sprintf("%s has no qualified name for the body of %s to state a %s about; write it inline, without a view", m.label(op), op.View, layoutTypeName(op.Annotation))} } if op.Annotation == semantics.CanvasFQN { if op.View != "" { - return nil, nil, &Error{Failure: FailureInvalidValue, OperationIndex: i, + return layoutTarget{}, &Error{Failure: FailureInvalidValue, OperationIndex: i, Message: "a Canvas is stated in the body of the view it sizes; name that view as the target and no other"} } - if !semantics.IsView(sym) { - return nil, nil, &Error{Failure: FailureNotAView, OperationIndex: i, + if !semantics.IsView(target.sym) { + return layoutTarget{}, &Error{Failure: FailureNotAView, OperationIndex: i, Message: fmt.Sprintf("%s is no view; a Canvas sizes the drawing surface of a view", m.label(op))} } - return sym, nil, nil + return target, nil } if op.View == "" { - return sym, nil, nil + if target.owner != nil { + return layoutTarget{}, &Error{Failure: FailureReferencedElsewhere, OperationIndex: i, + Message: fmt.Sprintf("%s is a member %s inherits, declared elsewhere; state the %s about it in the body of a view", + op.Target, notationName(target.owner), layoutTypeName(op.Annotation))} + } + return target, nil } - viewSym, err = m.viewNamed(i, op.View, "", + target.view, err = m.viewNamed(i, op.View, "", fmt.Sprintf("%s is no view; a %s applies in the view whose body states it", op.View, layoutTypeName(op.Annotation))) if err != nil { - return nil, nil, err + return layoutTarget{}, err } - return sym, viewSym, nil + return target, nil +} + +// layoutElement is the element a layout operation names or declares; a name no +// declaration answers to may still reach an inherited member through the owner +// a rendering draws it under, as `Machines::VehicleStates::start` does its start. +func (m Model) layoutElement(i int, op Operation, sem *semantics.Model) (layoutTarget, error) { + sym, err := m.element(i, op) + if err == nil { + return layoutTarget{sym: sym}, nil + } + var refusal *Error + if !errors.As(err, &refusal) || refusal.Failure != FailureUnknownTarget || op.Target == "" || !memberAnnotation(op.Annotation) { + return layoutTarget{}, err + } + names, ok := source.QualifiedNameSegments(op.Target) + if !ok || len(names) < 2 { + return layoutTarget{}, err + } + owner, ownerErr := m.declaredOnceIn(i, source.QualifiedNameOf(names[:len(names)-1]), op.DeclarationDoc) + if ownerErr != nil { + return layoutTarget{}, err + } + member, ok := sem.LookupMember(owner, names[len(names)-1]) + if !ok { + return layoutTarget{}, err + } + return layoutTarget{sym: member, owner: owner, about: op.Target}, nil +} + +// memberAnnotation reports whether an annotation of type fqn may be stated about +// a member through an owner inheriting it: those a node carries, not a Route. +func memberAnnotation(fqn string) bool { + return fqn == semantics.LayoutFQN || fqn == semantics.StyleFQN || fqn == semantics.NoteFQN } // viewNamed is the view name names, among the views so named: the one of doc @@ -294,10 +344,11 @@ func (m Model) viewNamed(i int, name, doc, noView string) (*symbols.Symbol, erro // checkPlaceable refuses a Layout, Route or Style of an element the rendering // it applies in does not draw as a node or an edge: the view's rendering for a // view-local one, any rendering for an inline one. A Style colours either. -func (m Model) checkPlaceable(i int, op Operation, renderer *view.Renderer, sem *semantics.Model, sym, viewSym *symbols.Symbol) error { +func (m Model) checkPlaceable(i int, op Operation, renderer *view.Renderer, sem *semantics.Model, target layoutTarget) error { if op.Annotation == semantics.CanvasFQN { return nil } + sym, viewSym := target.sym, target.view wantNode := op.Annotation != semantics.RouteFQN wantEdge := op.Annotation != semantics.LayoutFQN role := "an edge a Route steers" @@ -315,7 +366,11 @@ func (m Model) checkPlaceable(i int, op Operation, renderer *view.Renderer, sem } return nil } - if !m.exposes(sem, viewSym, sym) { + exposed := sym + if target.owner != nil { + exposed = target.owner + } + if !m.exposes(sem, viewSym, exposed) { return &Error{Failure: FailureNotExposed, OperationIndex: i, Message: fmt.Sprintf("%s does not expose %s, so a %s in its body would place nothing", op.View, m.label(op), layoutTypeName(op.Annotation))} @@ -325,6 +380,13 @@ func (m Model) checkPlaceable(i int, op Operation, renderer *view.Renderer, sem return &Error{Failure: FailureNotDrawn, OperationIndex: i, Message: fmt.Sprintf("%s does not render: %v", op.View, err)} } + if target.owner != nil { + if !drawn.MemberNode(target.owner, sym) { + return &Error{Failure: FailureNotDrawn, OperationIndex: i, + Message: fmt.Sprintf("the rendering of %s does not draw %s as %s", op.View, m.label(op), role)} + } + return nil + } if !(wantNode && drawn.Node(sym)) && !(wantEdge && drawn.Edge(sym)) { return &Error{Failure: FailureNotDrawn, OperationIndex: i, Message: fmt.Sprintf("the rendering of %s does not draw %s as %s", op.View, m.label(op), role)} @@ -352,11 +414,17 @@ func (m Model) exposes(sem *semantics.Model, viewSym, sym *symbols.Symbol) bool // stated in viewSym's body about sym, or, for an inline operation, the one // applying in every view; nil when none is stated in m's document. A Route // naming a general steers a specialization edge, which no operation edits. -func (m Model) layoutSite(sem *semantics.Model, sym, viewSym *symbols.Symbol, typeFQN string) *semantics.LayoutSite { +func (m Model) layoutSite(sem *semantics.Model, target layoutTarget, typeFQN string) *semantics.LayoutSite { + sym, viewSym := target.sym, target.view for _, site := range sem.LayoutSitesOf(sym) { if site.TypeFQN != typeFQN || site.General != nil || symbols.DocNameOf(site.Scope) != m.Source.Name() { continue } + if target.owner != nil { + if via := sem.MemberLayoutOwner(site, sym); via == nil || via.Decl != target.owner.Decl { + continue + } + } if viewSym == nil && site.View == nil { return site } @@ -368,13 +436,17 @@ func (m Model) layoutSite(sem *semantics.Model, sym, viewSym *symbols.Symbol, ty } // insertAnnotation is the insertion of a new annotation of type typeFQN: inline -// in the body of sym, or stated about it in the body of viewSym. -func (m Model) insertAnnotation(typeFQN string, bindings []MetadataValue, sym, viewSym *symbols.Symbol) splice { - owner := sym +// in the body of the element, or stated about it in the body of the view. +func (m Model) insertAnnotation(typeFQN string, bindings []MetadataValue, target layoutTarget) splice { + owner := target.sym text := "@" + typeFQN - if viewSym != nil { - owner = viewSym - text = "metadata " + typeFQN + " about " + notationName(sym) + if target.view != nil { + owner = target.view + about := target.about + if about == "" { + about = notationName(target.sym) + } + text = "metadata " + typeFQN + " about " + about } ins := m.memberInsertion(owner.Decl, text+" "+writeBindings(bindings)) return splice{span: ins.span, text: ins.text} diff --git a/internal/check/edit/layout_test.go b/internal/check/edit/layout_test.go index 5ea8feb154..bd9c7c0a12 100644 --- a/internal/check/edit/layout_test.go +++ b/internal/check/edit/layout_test.go @@ -893,3 +893,53 @@ func TestSetLayoutFoldOnlyWritesCollapsedAlone(t *testing.T) { t.Fatalf("unplaced layout with a size: %+v", e) } } + +// motorModel draws a start for Motor's entry, which no declaration names: a +// Layout reaches it as the member Motor inherits, through Motor's name. +const motorModel = `package Plant { + state def Motor { + entry; then off; + state off; + state on; + transition t first off then on; + } +} +package PlantViews { + private import Views::*; + private import StandardViewDefinitions::*; + view motorView : StateTransitionView { + expose Plant::Motor; + } +} +` + +func TestSetLayoutAboutAnInheritedStart(t *testing.T) { + got := applyLayout(t, motorModel, SetLayout("Plant::Motor::start", "PlantViews::motorView", at(10, 20))) + requireReplaced(t, motorModel, " expose Plant::Motor;\n }\n", + " expose Plant::Motor;\n metadata DiagramLayout::Layout about Plant::Motor::start { x = 10; y = 20; }\n }\n", got) + placed := got + got = applyLayout(t, placed, SetLayout("Plant::Motor::start", "PlantViews::motorView", at(30, 40))) + requireReplaced(t, placed, "{ x = 10; y = 20; }", "{ x = 30; y = 40; }", got) + got = applyLayout(t, placed, SetStyle("Plant::Motor::start", "PlantViews::motorView", &semantics.Style{Fill: "#C00000"})) + requireReplaced(t, placed, "{ x = 10; y = 20; }\n", + "{ x = 10; y = 20; }\n metadata DiagramLayout::Style about Plant::Motor::start { fill = \"#C00000\"; }\n", got) + got = applyLayout(t, placed, SetLayout("Plant::Motor::start", "PlantViews::motorView", nil)) + if got != motorModel { + t.Fatalf("clearing the start's Layout did not restore the model:\n%s", got) + } + for _, tc := range []struct { + op Operation + failure Failure + message string + }{ + {SetLayout("Plant::Motor::start", "", at(1, 2)), FailureReferencedElsewhere, "a member Plant::Motor inherits"}, + {SetLayout("Plant::Motor::finish", "PlantViews::motorView", at(1, 2)), FailureUnknownTarget, "no element named"}, + {SetRoute("Plant::Motor::start", "PlantViews::motorView", &semantics.Route{Points: []semantics.Waypoint{{X: 1, Y: 2}}}), FailureUnknownTarget, "no element named"}, + } { + m := loadContent(t, "plant.sysml", motorModel) + _, err := Apply(m, []Operation{tc.op}) + if e := editError(t, err); e.Failure != tc.failure || !strings.Contains(e.Message, tc.message) { + t.Errorf("%s: err = %v, want %s containing %q", tc.op.Target, err, tc.failure, tc.message) + } + } +} diff --git a/internal/ir/view/behavior.go b/internal/ir/view/behavior.go index 6367e89cd8..e068ca22fb 100644 --- a/internal/ir/view/behavior.go +++ b/internal/ir/view/behavior.go @@ -141,6 +141,7 @@ func (r *Renderer) stateMachineNode(view, machine *symbols.Symbol, graph *lower. } if graph.Completes(state) { body := r.bodySymbol(machine, graph, bodyOwning(graph, state)) + node.Member = ast.DoneFeature node.Geometry = r.memberGeometryOf(view, body, ast.DoneFeature, out) r.memberDress(view, body, ast.DoneFeature, node, out) } @@ -220,7 +221,7 @@ func (r *Renderer) entryEdges(view, machine *symbols.Symbol, graph *lower.StateG return } bodySym := r.bodySymbol(machine, graph, body.owner) - start := &Node{ID: ids.take(), Kind: startKind, Geometry: r.memberGeometryOf(view, bodySym, ast.StartFeature, out)} + start := &Node{ID: ids.take(), Kind: startKind, Member: ast.StartFeature, Geometry: r.memberGeometryOf(view, bodySym, ast.StartFeature, out)} r.memberDress(view, bodySym, ast.StartFeature, start, out) body.node.Children = append([]*Node{start}, body.node.Children...) for _, entry := range entries { @@ -741,7 +742,7 @@ func (r *Renderer) actionNode(subject actionSubject, ids *nodeIDs, out *Renderin // the view's Layout positions it, first among the body's nodes. if r.placedStart(subject.view, owner, graph, out) { start := &Node{ID: ids.take(), Kind: actionNodeKind(&ast.InitialNode{}, nil), - Name: nameText("start"), NameSynthesized: true} + Name: nameText("start"), NameSynthesized: true, Member: "start"} start.Geometry = r.memberGeometryOf(subject.view, owner, "start", out) r.memberDress(subject.view, owner, "start", start, out) if member, ok := r.model.LookupMember(owner, "start"); ok { @@ -756,6 +757,7 @@ func (r *Renderer) actionNode(subject actionSubject, ids *nodeIDs, out *Renderin Type: nodeType(node), Origin: nodeOrigin(nodeDoc, node)} child.Inherited = inheritedMember(r.declaredSymbol(subject.elem, node), owner) if languageNamed(node) { + child.Member = behaviorNodeName(node) child.Geometry = r.memberGeometryOf(subject.view, owner, behaviorNodeName(node), out) r.memberDress(subject.view, owner, behaviorNodeName(node), child, out) } else { diff --git a/internal/ir/view/drawio.go b/internal/ir/view/drawio.go index 859d06d84e..1326c4a5a6 100644 --- a/internal/ir/view/drawio.go +++ b/internal/ir/view/drawio.go @@ -528,8 +528,12 @@ func (w *drawioWriter) siteName(origin Origin) string { } // fqnOf is a node's qualified name from its label: a root's name as the view -// exposes it, a nested node's under its container's; a synthesized name is none. +// exposes it, a nested node's under its container's; a synthesized name is none, +// but a start or done is the owner's member a Layout names it as. func fqnOf(node *Node, parent string) string { + if node.Member != "" && parent != "" { + return parent + "::" + node.Member + } if node.Name == "" || node.NameSynthesized { return "" } diff --git a/internal/ir/view/drawio_import.go b/internal/ir/view/drawio_import.go index 05f1711bc5..e425d07cf0 100644 --- a/internal/ir/view/drawio_import.go +++ b/internal/ir/view/drawio_import.go @@ -111,7 +111,9 @@ type drawioPageData struct { // ReadDrawio reads a draw.io file as the drawio form writes it, or as draw.io // desktop saves it (each diagram's model deflated, base64 and URL encoded), and -// returns the page rendered from view; with view empty, the file's one page. +// returns the page rendered from view; with view empty, the file's one page. A +// page is of the view its semantic cells name; one with none left is of the +// view its name states, the writer naming each page after its view. func ReadDrawio(data []byte, view string) (*DrawioFile, error) { pages, err := drawioPages(data) if err != nil { @@ -125,6 +127,9 @@ func ReadDrawio(data []byte, view string) (*DrawioFile, error) { return nil, &DrawioFileError{Message: "page " + strconv.Quote(page.name), Err: err} } f.Page = page.name + if f.View == "" { + f.View = page.name + } files = append(files, f) names = append(names, page.name) } @@ -246,6 +251,10 @@ func drawioPages(data []byte) ([]drawioPageData, error) { return pages, nil } +// drawioInflateLimit bounds the XML a compressed page may inflate to, so a +// small file cannot exhaust memory. +const drawioInflateLimit = 64 << 20 + // decodeDrawioDiagram undoes draw.io's compressed page encoding: base64, raw // deflate, then URL percent-encoding of the model's XML. func decodeDrawioDiagram(text string) ([]byte, error) { @@ -257,10 +266,13 @@ func decodeDrawioDiagram(text string) ([]byte, error) { if err != nil { return nil, fmt.Errorf("base64: %w", err) } - inflated, err := io.ReadAll(flate.NewReader(bytes.NewReader(raw))) + inflated, err := io.ReadAll(io.LimitReader(flate.NewReader(bytes.NewReader(raw)), drawioInflateLimit+1)) if err != nil { return nil, fmt.Errorf("deflate: %w", err) } + if len(inflated) > drawioInflateLimit { + return nil, fmt.Errorf("the page inflates past %d MiB", drawioInflateLimit>>20) + } unescaped, err := url.PathUnescape(string(inflated)) if err != nil { return nil, fmt.Errorf("percent-decoding: %w", err) diff --git a/internal/ir/view/drawio_import_diff.go b/internal/ir/view/drawio_import_diff.go index d28dca0323..920b00d3f1 100644 --- a/internal/ir/view/drawio_import_diff.go +++ b/internal/ir/view/drawio_import_diff.go @@ -165,56 +165,106 @@ type drawioMatcher struct { matched map[string]bool } -// match pairs nodes and ports by qualified name — and by ordinal when a view -// draws the same element twice, as a tree does an inherited member —, edges by -// their ends, kind and ordinal, notes and pictures by position, and diffs each pair. +// match pairs the file's semantic cells with the baseline's — by the exported +// id while it still denotes the same element, then by qualified name, or by ends +// and kind for an edge, among those left, in order —, notes and pictures by +// position, and diffs each pair. func (m *drawioMatcher) match() { m.matched = map[string]bool{} byFQN := map[string][]*DrawioCell{} + byKey := map[DrawioEdgeKey][]*DrawioCell{} + baseKeys, fileKeys := m.edgesByKey(m.base), m.edgesByKey(m.file) for i := range m.base.Cells { - if c := &m.base.Cells[i]; c.Vertex && c.FQN != "" { + c := &m.base.Cells[i] + switch { + case c.Vertex && c.FQN != "": byFQN[c.FQN] = append(byFQN[c.FQN], c) + case c.Edge && c.Kind != "": + if key, ok := baseKeys[c.ID]; ok { + byKey[key.unordered()] = append(byKey[key.unordered()], c) + } } } - seen := map[string]int{} - baseEdges := m.baseIndex() - fileEdges := m.edgesByKey(m.file) + var pending []*DrawioCell for i := range m.file.Cells { cell := &m.file.Cells[i] + if cell.Kind == "" { + m.plainCell(cell) + continue + } + if base := m.base.Cell(cell.ID); base != nil && sameDrawioElement(cell, base, fileKeys, baseKeys) { + m.pair(cell, base, baseKeys) + continue + } + pending = append(pending, cell) + } + for _, cell := range pending { switch { case cell.Vertex && cell.FQN != "": - ordinal := seen[cell.FQN] - seen[cell.FQN]++ - if ordinal >= len(byFQN[cell.FQN]) { + if base := m.firstUnmatched(byFQN[cell.FQN]); base != nil { + m.pair(cell, base, baseKeys) + } else { m.out.Unmatched = append(m.out.Unmatched, cell.FQN) - continue } - base := byFQN[cell.FQN][ordinal] - m.matched[base.ID] = true - m.diffVertex(cell, base) - case cell.Edge && cell.Kind != "": - key, ok := fileEdges[cell.ID] + case cell.Vertex: + m.out.Unmatched = append(m.out.Unmatched, cell.Kind+" "+strconv.Quote(cell.Label)) + default: + key, ok := fileKeys[cell.ID] if !ok { m.out.Unmatched = append(m.out.Unmatched, m.edgeLabel(cell, m.file)) continue } - base, ok := baseEdges.byKey[key] - if !ok { + if base := m.firstUnmatched(byKey[key.unordered()]); base != nil { + m.pair(cell, base, baseKeys) + } else { m.out.Unmatched = append(m.out.Unmatched, key.String()) - continue } - m.matched[base.ID] = true - m.diffEdge(cell, base, key) - default: - m.plainCell(cell) } } m.deleted() } -// drawioEdgeIndex is a file's semantic edges keyed both ways. -type drawioEdgeIndex struct { - byKey map[DrawioEdgeKey]*DrawioCell +// sameDrawioElement reports whether the file's cell and the baseline's of the +// same id still stand for one element: the same qualified name and kind — the +// same kind and label for a node no name reaches —, or the same ends and kind. +func sameDrawioElement(cell, base *DrawioCell, fileKeys, baseKeys map[string]DrawioEdgeKey) bool { + if cell.Vertex != base.Vertex || cell.Edge != base.Edge || cell.Kind != base.Kind { + return false + } + if cell.Vertex { + return cell.FQN == base.FQN && (cell.FQN != "" || cell.Label == base.Label) + } + fileKey, ok := fileKeys[cell.ID] + baseKey, okBase := baseKeys[base.ID] + return ok && okBase && fileKey.unordered() == baseKey.unordered() +} + +// firstUnmatched is the first baseline cell of a group not yet paired, nil +// when every one is. +func (m *drawioMatcher) firstUnmatched(cells []*DrawioCell) *DrawioCell { + for _, c := range cells { + if !m.matched[c.ID] { + return c + } + } + return nil +} + +// pair diffs the file's cell against the baseline's it stands for. +func (m *drawioMatcher) pair(cell, base *DrawioCell, baseKeys map[string]DrawioEdgeKey) { + m.matched[base.ID] = true + if cell.Vertex { + m.diffVertex(cell, base) + return + } + m.diffEdge(cell, base, baseKeys[base.ID]) +} + +// unordered is the key without its ordinal: what every edge between the same +// ends of one kind shares. +func (k DrawioEdgeKey) unordered() DrawioEdgeKey { + k.Ordinal = 0 + return k } // edgesByKey keys every semantic edge of f by the names of its ends, its kind @@ -249,15 +299,6 @@ func drawioEndName(cell *DrawioCell) string { return strconv.Quote(cell.Label) } -// edgesByKey over the baseline also indexes the cells by key. -func (m *drawioMatcher) baseIndex() drawioEdgeIndex { - idx := drawioEdgeIndex{byKey: map[DrawioEdgeKey]*DrawioCell{}} - for id, key := range m.edgesByKey(m.base) { - idx.byKey[key] = m.base.Cell(id) - } - return idx -} - // edgeLabel describes an edge the matcher cannot key. func (m *drawioMatcher) edgeLabel(cell *DrawioCell, f *DrawioFile) string { name := func(id string) string { @@ -286,10 +327,16 @@ func (m *drawioMatcher) diffVertex(cell, base *DrawioCell) { if node := m.nodes[base.ID]; node != nil { current = node.Style } - if style, changed := styleDiff(cell.Style, base.Style, current, drawioVertexStyleKeys); changed { - edit.Kind, edit.Style, edit.Layout = DrawioEditStyle, style, nil - m.out.Edits = append(m.out.Edits, edit) + style, changed := styleDiff(cell.Style, base.Style, current, drawioVertexStyleKeys) + if !changed { + return + } + if isPort { + m.out.Notices = append(m.out.Notices, fmt.Sprintf("%s: a port is drawn by its direction; its colours in the file are not imported", cell.FQN)) + return } + edit.Kind, edit.Style, edit.Layout = DrawioEditStyle, style, nil + m.out.Edits = append(m.out.Edits, edit) } // originOf is the declaration behind a baseline cell id: a port's or a node's. @@ -352,12 +399,16 @@ func (m *drawioMatcher) diffEdge(cell, base *DrawioCell, key DrawioEdgeKey) { // or a compartment row the export wrote, or a shape drawn in draw.io. func (m *drawioMatcher) plainCell(cell *DrawioCell) { base := m.base.Cell(cell.ID) - if base == nil { + if base == nil || base.Kind != "" { m.out.Unknown = append(m.out.Unknown, *cell) return } m.matched[cell.ID] = true switch { + case strings.HasPrefix(cell.ID, "note") && cell.Edge: + if cell.Source != base.Source || cell.Target != base.Target { + m.out.Notices = append(m.out.Notices, fmt.Sprintf("note %s re-anchored: a Note is about the element its annotation names; the attachment in the file is not imported", strconv.Quote(m.noteText(cell)))) + } case strings.HasPrefix(cell.ID, "note") && cell.Vertex: m.diffNote(cell, base) case strings.HasPrefix(cell.ID, "picture") && cell.Vertex: @@ -369,6 +420,14 @@ func (m *drawioMatcher) plainCell(cell *DrawioCell) { } } +// noteText is the text of the note an anchor edge joins. +func (m *drawioMatcher) noteText(anchor *DrawioCell) string { + if note := m.file.Cell(anchor.Source); note != nil { + return note.Value + } + return anchor.Source +} + // diffNote imports a moved, resized or reworded note box. func (m *drawioMatcher) diffNote(cell, base *DrawioCell) { i, err := strconv.Atoi(strings.TrimPrefix(cell.ID, "note")) diff --git a/internal/ir/view/drawio_import_match_test.go b/internal/ir/view/drawio_import_match_test.go new file mode 100644 index 0000000000..47e96cc2f8 --- /dev/null +++ b/internal/ir/view/drawio_import_match_test.go @@ -0,0 +1,170 @@ +package view + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// drawioGolden is the golden case of the fixture name and its document. +func drawioGolden(t *testing.T, name string) (struct { + name string + file string + view string + kind Kind +}, string) { + t.Helper() + for _, c := range plantumlGoldenCases { + if c.name != name { + continue + } + golden, err := os.ReadFile(filepath.Join("testdata", name+".drawio.golden")) + if err != nil { + t.Fatal(err) + } + return c, string(golden) + } + t.Fatalf("no golden case %s", name) + panic("unreachable") +} + +// drawioObjectXML is the whole wrapper of the semantic cell id. +func drawioObjectXML(t *testing.T, document, id string) string { + t.Helper() + m := regexp.MustCompile(`(?s)]*>.*?`).FindString(document) + if m == "" { + t.Fatalf("no object %s in the document", id) + } + return m +} + +// swapObjects brings the object b before a, as draw.io does when a cell is +// brought to front: the file order changes, the elements do not. +func swapObjects(t *testing.T, document, a, b string) string { + t.Helper() + objA, objB := drawioObjectXML(t, document, a), drawioObjectXML(t, document, b) + if strings.Index(document, objA) > strings.Index(document, objB) { + t.Fatalf("%s is not before %s", a, b) + } + document = strings.Replace(document, objB, "", 1) + return strings.Replace(document, objA, objB+objA, 1) +} + +func drawioCount(t *testing.T, document, fqn string) int { + t.Helper() + return strings.Count(document, `fqn="`+fqn+`"`) +} + +// The tree draws Vehicle::mass once declared and three times inherited; the +// cells share one fqn, so the order of the file must not decide which is which. +func TestDrawioImportReorderedDuplicatesKeepTheirIdentity(t *testing.T) { + c, golden := drawioGolden(t, "abstract-tree") + const mass = "AbstractViews::Vehicle::mass" + if n := drawioCount(t, golden, mass); n < 2 { + t.Fatalf("%s is drawn %d times, the test needs a repeated element", mass, n) + } + reordered := swapObjects(t, golden, "n1", "n7") + if imp := drawioGoldenImport(t, c, reordered); len(imp.Edits)+len(imp.Unmatched)+len(imp.Deleted) != 0 { + t.Fatalf("reordering alone: edits %+v unmatched %v deleted %v", imp.Edits, imp.Unmatched, imp.Deleted) + } + moved := mutateCell(t, reordered, "n7", func(cell string) string { return setGeometryAttr(t, cell, "x", "900") }) + imp := drawioGoldenImport(t, c, moved) + edit := onlyEdit(t, imp, DrawioEditLayout) + rendering, _ := renderWithSites(t, c.file, c.view) + var n7 *Node + for _, node := range drawioNodes(rendering.Roots) { + if node.ID == "n7" { + n7 = node + } + } + if n7 == nil { + t.Fatal("no node n7 in the rendering") + } + if edit.Cell != "n7" || edit.FQN != mass || edit.Origin != n7.Origin || !strings.Contains(edit.Label, "^mass") { + t.Errorf("edit = %+v, want the inherited mass at n7 (origin %+v)", edit, n7.Origin) + } + if len(imp.Unmatched)+len(imp.Deleted) != 0 { + t.Errorf("unmatched %v deleted %v", imp.Unmatched, imp.Deleted) + } +} + +// A start pseudostate is declared nowhere; the writer names it as the member +// its owner's body knows, so moving it is a Layout about Owner::start. +func TestDrawioImportMovedStartIsALayoutAboutTheMember(t *testing.T) { + c, golden := drawioGolden(t, "state") + const start = "Machines::VehicleStates::start" + if !strings.Contains(drawioObjectXML(t, golden, "n5"), `label="start" fqn="`+start+`" kind="start"`) { + t.Fatalf("n5 is not the start of the machine:\n%s", drawioObjectXML(t, golden, "n5")) + } + moved := mutateCell(t, golden, "n5", func(cell string) string { return setGeometryAttr(t, cell, "y", "333") }) + file, err := ReadDrawio([]byte(moved), c.view) + if err != nil { + t.Fatal(err) + } + imp := drawioGoldenImport(t, c, moved) + edit := onlyEdit(t, imp, DrawioEditLayout) + if edit.Cell != "n5" || edit.FQN != start || edit.Label != "start" || edit.Origin.Located() || edit.Layout.Y != file.Cell("n5").Geometry.Y { + t.Errorf("edit = %+v, want the start by its member name at the absolute y %v", edit, file.Cell("n5").Geometry.Y) + } + if len(imp.Unmatched)+len(imp.Deleted)+len(imp.Notices) != 0 { + t.Errorf("unmatched %v deleted %v notices %v", imp.Unmatched, imp.Deleted, imp.Notices) + } + // Under another id the cell is still the start: nothing else carries its name. + renumbered := strings.ReplaceAll(strings.Replace(golden, ``).ReplaceAllString(golden, "") + if strings.Contains(emptied, "fqn=") { + t.Fatal("objects left") + } + imp := drawioGoldenImport(t, c, emptied) + if len(imp.Edits) != 0 || len(imp.Deleted) < 3 || len(imp.Unmatched) != 0 { + t.Errorf("edits %+v deleted %v unmatched %v, want every element deleted", imp.Edits, imp.Deleted, imp.Unmatched) + } + for _, fqn := range []string{"Plant::Loop::pump", "Plant::Loop::tank", "Plant::Pump::outlet"} { + found := false + for _, d := range imp.Deleted { + found = found || d == fqn + } + if !found { + t.Errorf("deleted %v lacks %s", imp.Deleted, fqn) + } + } +} diff --git a/internal/ir/view/drawio_read_test.go b/internal/ir/view/drawio_read_test.go index 8fc2fa4a65..406dfd24c2 100644 --- a/internal/ir/view/drawio_read_test.go +++ b/internal/ir/view/drawio_read_test.go @@ -1,6 +1,9 @@ package view import ( + "bytes" + "compress/flate" + "encoding/base64" "errors" "os" "path/filepath" @@ -99,12 +102,37 @@ func TestReadDrawioSelectsThePageOfTheView(t *testing.T) { if _, err := ReadDrawio([]byte(twice), "V::one"); !errors.As(err, &viewErr) || !strings.Contains(err.Error(), "2 pages") { t.Errorf("two pages of one view: %v", err) } - unviewed := `` + drawioTestPage("only", "") + `` - if _, err := ReadDrawio([]byte(unviewed), "V::one"); !errors.As(err, &viewErr) || !strings.Contains(err.Error(), "no view attribute") { - t.Errorf("cells without a view: %v", err) + // A page without semantic cells is of the view its name states. + unviewed := `` + drawioTestPage("V::one", "") + `` + if file, err := ReadDrawio([]byte(unviewed), "V::one"); err != nil || file.View != "V::one" { + t.Errorf("cells without a view on a page named after it: %v %v", file, err) } - if _, err := ReadDrawio([]byte(unviewed), ""); !errors.As(err, &viewErr) { - t.Errorf("cells without a view and no view asked: %v", err) + if _, err := ReadDrawio([]byte(unviewed), "V::two"); !errors.As(err, &viewErr) || !strings.Contains(err.Error(), "V::one") { + t.Errorf("cells without a view on a page named after another: %v", err) + } + unnamed := `` + strings.Replace(drawioTestPage("", ""), ` name=""`, "", 1) + `` + if _, err := ReadDrawio([]byte(unnamed), "V::one"); !errors.As(err, &viewErr) || !strings.Contains(err.Error(), "no view attribute") { + t.Errorf("cells without a view on an unnamed page: %v", err) + } +} + +func TestReadDrawioRefusesPagesInflatingPastTheLimit(t *testing.T) { + var deflated bytes.Buffer + w, err := flate.NewWriter(&deflated, flate.BestCompression) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write(bytes.Repeat([]byte("<"), drawioInflateLimit+1)); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + page := `` + base64.StdEncoding.EncodeToString(deflated.Bytes()) + `` + _, err = ReadDrawio([]byte(page), "") + var fileErr *DrawioFileError + if !errors.As(err, &fileErr) || !strings.Contains(err.Error(), "inflates past 64 MiB") { + t.Fatalf("err = %v, want the inflate limit", err) } } diff --git a/internal/ir/view/testdata/action.drawio.golden b/internal/ir/view/testdata/action.drawio.golden index fa79d36973..6763a04b77 100644 --- a/internal/ir/view/testdata/action.drawio.golden +++ b/internal/ir/view/testdata/action.drawio.golden @@ -73,7 +73,7 @@ - + diff --git a/internal/ir/view/testdata/mixed.drawio.golden b/internal/ir/view/testdata/mixed.drawio.golden index 081b846e7b..5ea01575f7 100644 --- a/internal/ir/view/testdata/mixed.drawio.golden +++ b/internal/ir/view/testdata/mixed.drawio.golden @@ -96,7 +96,7 @@ - + @@ -121,7 +121,7 @@ - + @@ -156,7 +156,7 @@ - + diff --git a/internal/ir/view/testdata/state-entry.drawio.golden b/internal/ir/view/testdata/state-entry.drawio.golden index f338202de6..096ec3e42e 100644 --- a/internal/ir/view/testdata/state-entry.drawio.golden +++ b/internal/ir/view/testdata/state-entry.drawio.golden @@ -12,7 +12,7 @@ - + @@ -27,7 +27,7 @@ - + @@ -52,7 +52,7 @@ - + @@ -72,7 +72,7 @@ - + diff --git a/internal/ir/view/testdata/state.drawio.golden b/internal/ir/view/testdata/state.drawio.golden index 9319f5e2a5..e5de536fa9 100644 --- a/internal/ir/view/testdata/state.drawio.golden +++ b/internal/ir/view/testdata/state.drawio.golden @@ -12,7 +12,7 @@ - + @@ -27,7 +27,7 @@ - + diff --git a/internal/ir/view/testdata/typed-action.drawio.golden b/internal/ir/view/testdata/typed-action.drawio.golden index a97b3ed756..6659fb2673 100644 --- a/internal/ir/view/testdata/typed-action.drawio.golden +++ b/internal/ir/view/testdata/typed-action.drawio.golden @@ -22,7 +22,7 @@ - + diff --git a/internal/ir/view/testdata/typed-state.drawio.golden b/internal/ir/view/testdata/typed-state.drawio.golden index a4e2862577..5b1167fbb2 100644 --- a/internal/ir/view/testdata/typed-state.drawio.golden +++ b/internal/ir/view/testdata/typed-state.drawio.golden @@ -12,7 +12,7 @@ - + diff --git a/internal/ir/view/view.go b/internal/ir/view/view.go index 3c0e268d8b..c9d47ab4ab 100644 --- a/internal/ir/view/view.go +++ b/internal/ir/view/view.go @@ -348,6 +348,9 @@ type Node struct { // NameSynthesized marks a name the model did not give: a migration made it up, or // it is the language's `start`/`done`. A name to key by, not one a picture shows. NameSynthesized bool + // Member is the name the owner's body knows an unnamed node by — the + // language's `start` or `done` —, which a Layout about Owner::Member places. + Member string // Referential marks a structural usage that refers to its value rather // than owning it (`ref part`): a row of a tool's references compartment. Referential bool From bc3611b62cabb9a56df62c4385d88c380ff47824 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 05:06:45 +0000 Subject: [PATCH 04/25] feat(edit): write Note and Picture annotations, and the edits a draw.io import found SetNote, SetNoteAt, SetPicture and SetPictureAt join the layout operations, reaching an annotation stated many times by its own declaration; the LSP exposes them as setNote and setPicture and the MCP tool describes them. DrawioOperations turns an import's edits into these operations, naming each element or declaring it where no name reaches, inline then. The import reads a node's Layout in the frame the writer drew it in, pinning a container the model does not place when a child inside it moves. Co-Authored-By: jason.han --- docs/project/diagram-layout-annotations.md | 6 +- docs/reference/lsp.md | 5 +- docs/reference/sysml-mcp.md | 2 +- internal/check/edit/drawio.go | 48 ++++++ internal/check/edit/edit.go | 46 +++++- internal/check/edit/layout.go | 153 +++++++++++++++--- internal/check/edit/layout_note_test.go | 118 ++++++++++++++ internal/frontend/lsp/modeledit.go | 90 ++++++++++- .../lsp/modeledit_cross_document_test.go | 4 +- internal/frontend/lsp/modeledit_note_test.go | 52 ++++++ internal/frontend/lsp/modeledit_test.go | 2 +- internal/frontend/mcp/lsptools/edits.go | 10 +- internal/ir/view/drawio_import_diff.go | 77 ++++++++- internal/ir/view/drawio_import_match_test.go | 4 +- internal/ir/view/drawio_import_test.go | 27 +++- internal/ir/view/drawio_read_test.go | 2 +- internal/semantic/semantics/layout.go | 17 ++ 17 files changed, 608 insertions(+), 55 deletions(-) create mode 100644 internal/check/edit/drawio.go create mode 100644 internal/check/edit/layout_note_test.go create mode 100644 internal/frontend/lsp/modeledit_note_test.go diff --git a/docs/project/diagram-layout-annotations.md b/docs/project/diagram-layout-annotations.md index e67088be53..550988fb84 100644 --- a/docs/project/diagram-layout-annotations.md +++ b/docs/project/diagram-layout-annotations.md @@ -5,7 +5,7 @@ side table that resolves a position per view, the geometry the rendering tree ca what the Mermaid, text and Graphviz DOT writers make of it, the LSP fields, the validation pass, and the write-back: `internal/check/edit` sets, updates and clears the annotations source-preservingly, `opensysml/applyModelEdit` exposes that as `setLayout`, -`setRoute` and `setCanvas`, and the VS Code diagram panel writes a `Layout` when a node +`setRoute`, `setCanvas`, `setStyle`, `setNote` and `setPicture`, and the VS Code diagram panel writes a `Layout` when a node is dragged and a `Route` when an edge is. Open: the OMG proposal. This note records the design agreed for carrying diagram geometry in textual notation and how that geometry reaches every rendering OpenSysML produces. @@ -375,8 +375,8 @@ exactly the bytes it produced before; the existing rendering goldens pin that. - **Editors.** The LSP fields are what a graphical client (the VS Code diagram panel, a SysON-style editor) reads to place nodes where the model says. Writing positions back - is `opensysml/applyModelEdit` with `setLayout`, `setRoute`, `setCanvas` and `setStyle` - ([the LSP reference](../reference/lsp.md)): `metadata Layout about … { x = …; y = …; + is `opensysml/applyModelEdit` with `setLayout`, `setRoute`, `setCanvas`, `setStyle`, + `setNote` and `setPicture` ([the LSP reference](../reference/lsp.md)): `metadata Layout about … { x = …; y = …; }` into the view body when the operation names a view, inline into the element's own body when it does not (`{ collapsed = true; }` alone for a layout given no `x` and `y`), updated in place when one is already stated and removed with its line when cleared. Each annotation goes into the workspace document that declares diff --git a/docs/reference/lsp.md b/docs/reference/lsp.md index 970762c5cd..35979b817a 100644 --- a/docs/reference/lsp.md +++ b/docs/reference/lsp.md @@ -552,6 +552,8 @@ operation and its fields beside it: | `setRoute` | `target` or `declaration`, `declaredIn?`, `digest?`, `view?`, `route?` | The `DiagramLayout::Route` annotation of a connection, transition, succession or flow, `route` being its waypoints as an array of `{"x", "y"}`; `view`, the document written and an omitted or empty `route` mean what they do for `setLayout`. | | `setCanvas` | `target`, `canvas?` | The `DiagramLayout::Canvas` annotation of the view `target` names, written into the view's body in the document declaring it: `{ "unit"?, "width"?, "height"? }`, the sizes together or not at all; omitted, the annotation is removed. Refused as `setLayout` is when that document cannot be rewritten. | | `setStyle` | `target` or `declaration`, `declaredIn?`, `digest?`, `view?`, `style?` | The `DiagramLayout::Style` annotation of the node or edge the target is drawn as: `style` is `{ "fill"?, "line"?, "text"?, "font"?, "fontSize"?, "bold"?, "italic"? }`, each a colour as `#RRGGBB`, a font family, a size in points or a flag; omitted, the annotation is removed. Written, located and refused as `setLayout` is: into the view `view` names, inline into the element's own body when none is. | +| `setNote` | `target` or `declaration`, `declaredIn?`, `digest?`, `view?`, `note?` | The `DiagramLayout::Note` annotation about the node or edge `target` is drawn as, in the body of `view` or inline on the target without one; `target` naming the view itself writes a note about nothing in particular into its body. `note` is `{ "text", "x", "y", "width"?, "height"? }`, the sizes together or not at all; omitted, the annotation is removed. An element may carry several notes, so `declaration` here is the range of the annotation itself — as a rendering reports a note's `origin` — and rewrites or removes that one wherever it is stated, with no `view`. | +| `setPicture` | `target` or `declaration`, `declaredIn?`, `digest?`, `picture?` | The `DiagramLayout::Picture` annotation in the body of the view `target` names, as a `setCanvas` finds its view: `picture` is `{ "location", "x", "y", "width", "height", "alt"?, "above"? }`, the file relative to the view's document; omitted, the annotation is removed. `declaration` is the range of the annotation itself, as for `setNote`. | `target` and `owner` are qualified names, as `nodes[].fqn` and `nodes[].owners[].fqn` in a rendering give them, each name quoted on its own where the notation requires it; the empty @@ -571,7 +573,8 @@ declare — else the one view of the workspace so named; a namesake that is no v passed over, and a name naming no view is refused as `not-a-view`. A `declaredIn` naming a document the server does not hold, given on an operation other than these two, or naming another document without a `digest`, is an invalid-params error, as an operation giving -both `target` and `declaration` is. No other operation takes a `declaration`. Every target +both `target` and `declaration` is. A `setNote` or `setPicture` takes a `declaration` too, the +annotation's own range rather than an element's. No other operation takes a `declaration`. Every target of a request is read in the text the server holds of its document — the request's `version` for the requested one, the text `digest` fingerprints for another, which the answer is `stale` for when that document's text has changed since the rendering, whether diff --git a/docs/reference/sysml-mcp.md b/docs/reference/sysml-mcp.md index d327eaf628..28a870ba2e 100644 --- a/docs/reference/sysml-mcp.md +++ b/docs/reference/sysml-mcp.md @@ -251,7 +251,7 @@ without this page. | Tool | Does | Writes | |---|---|---| -| `apply_model_edit` | Edit a model by operations on its elements rather than by text: setValue, rename, addMember, addConnection, addSequence (action-body items), delete, move, and the diagram-layout annotations setLayout, setRoute, setCanvas, setStyle. | yes | +| `apply_model_edit` | Edit a model by operations on its elements rather than by text: setValue, rename, addMember, addConnection, addSequence (action-body items), delete, move, and the diagram-layout annotations setLayout, setRoute, setCanvas, setStyle, setNote, setPicture. | yes | | `close_document` | Release a document held open by open_document: the server goes back to the file on disk (or forgets the document when there is no file). | yes | | `code_actions` | List the quick fixes and refactorings available for a range of a document, each with the edits it would make. | no | | `completion` | Propose completions at a position: keywords, names in scope and members of the type being accessed. | no | diff --git a/internal/check/edit/drawio.go b/internal/check/edit/drawio.go new file mode 100644 index 0000000000..9e689948e6 --- /dev/null +++ b/internal/check/edit/drawio.go @@ -0,0 +1,48 @@ +package edit + +import ( + "github.com/Open-MBEE/OpenSysML/internal/ir/view" +) + +// DrawioOperations turns the edits an import of a draw.io file found into the +// operations writing them: each annotation stated in the body of the imported +// view — inline on the element with inline set — and, whatever the scope, inline +// on an element no qualified name reaches, as a view's body cannot name it. A +// moved note or picture rewrites the annotation that drew it. +func DrawioOperations(imp *view.DrawioImport, inline bool) []Operation { + ops := make([]Operation, 0, len(imp.Edits)) + for _, e := range imp.Edits { + viewName := imp.View + if inline || e.FQN == "" { + viewName = "" + } + var op Operation + switch e.Kind { + case view.DrawioEditLayout: + op = drawioTargeted(e, SetLayout(e.FQN, viewName, e.Layout), SetLayoutAt(e.Origin.Span, viewName, e.Layout)) + case view.DrawioEditRoute: + op = drawioTargeted(e, SetRoute(e.FQN, viewName, e.Route), SetRouteAt(e.Origin.Span, viewName, e.Route)) + case view.DrawioEditStyle: + op = drawioTargeted(e, SetStyle(e.FQN, viewName, e.Style), SetStyleAt(e.Origin.Span, viewName, e.Style)) + case view.DrawioEditCanvas: + op = SetCanvas(imp.View, e.Canvas) + case view.DrawioEditNote: + op = SetNoteAt(e.Origin.Span, e.Note).DeclaredIn(e.Origin.Doc) + case view.DrawioEditPicture: + op = SetPictureAt(e.Origin.Span, e.Picture).DeclaredIn(e.Origin.Doc) + default: + continue + } + ops = append(ops, op) + } + return ops +} + +// drawioTargeted is the operation naming the element when the import knows its +// qualified name, else the one declaring it where the rendering found it. +func drawioTargeted(e view.DrawioEdit, named, declared Operation) Operation { + if e.FQN != "" { + return named + } + return declared.DeclaredIn(e.Origin.Doc) +} diff --git a/internal/check/edit/edit.go b/internal/check/edit/edit.go index 5eec83761f..5e7227824f 100644 --- a/internal/check/edit/edit.go +++ b/internal/check/edit/edit.go @@ -169,17 +169,21 @@ type Operation struct { // NewOwner is the namespace an OpMove moves Target into; empty means the root. NewOwner string // Annotation is the DiagramLayout metadata an OpSetLayout writes, by FQN - // (semantics.LayoutFQN, RouteFQN, CanvasFQN or StyleFQN). View names the view whose - // body states it about Target; empty, the annotation is inline on Target - // and applies in every view. + // (semantics.LayoutFQN, RouteFQN, CanvasFQN, StyleFQN, NoteFQN or PictureFQN). + // View names the view whose body states it about Target; empty, the + // annotation is inline on Target and applies in every view. A Note or a + // Picture may be stated many times over one element, so Declaration names + // the annotation itself, not the element, when one of those is rewritten. Annotation string View string // The geometry to write, the one Annotation names; all nil clears the - // annotation. - Layout *semantics.Layout - Route *semantics.Route - Canvas *semantics.Canvas - Style *semantics.Style + // annotation. NoteBox is the Note annotation, Note the text an OpAddNote adds. + Layout *semantics.Layout + Route *semantics.Route + Canvas *semantics.Canvas + Style *semantics.Style + NoteBox *semantics.Note + Picture *semantics.Picture } // SetValue is an operation setting target's value to the expression value. @@ -421,6 +425,32 @@ func SetStyleAt(decl source.Span, view string, style *semantics.Style) Operation return Operation{Kind: OpSetLayout, Declaration: decl, View: view, Annotation: semantics.StyleFQN, Style: style} } +// SetNote is an operation adding a Note to target — in the body of view, or +// inline on target when view is empty; target may be the view itself, for a +// note about nothing in particular — or rewriting the first one it carries; +// a nil note clears that one. +func SetNote(target, view string, note *semantics.Note) Operation { + return Operation{Kind: OpSetLayout, Target: target, View: view, Annotation: semantics.NoteFQN, NoteBox: note} +} + +// SetNoteAt rewrites the Note annotation declared at annotation in the +// document, wherever it is stated, or clears it when note is nil. +func SetNoteAt(annotation source.Span, note *semantics.Note) Operation { + return Operation{Kind: OpSetLayout, Declaration: annotation, Annotation: semantics.NoteFQN, NoteBox: note} +} + +// SetPicture is an operation adding a Picture to the body of view, or +// rewriting the first one it states; a nil picture clears that one. +func SetPicture(view string, picture *semantics.Picture) Operation { + return Operation{Kind: OpSetLayout, Target: view, Annotation: semantics.PictureFQN, Picture: picture} +} + +// SetPictureAt rewrites the Picture annotation declared at annotation in the +// document, or clears it when picture is nil. +func SetPictureAt(annotation source.Span, picture *semantics.Picture) Operation { + return Operation{Kind: OpSetLayout, Declaration: annotation, Annotation: semantics.PictureFQN, Picture: picture} +} + // Model is a parsed model to edit: the source that was read, its parse, and the // index it was analyzed in. type Model struct { diff --git a/internal/check/edit/layout.go b/internal/check/edit/layout.go index 576991af10..e029bb4ca8 100644 --- a/internal/check/edit/layout.go +++ b/internal/check/edit/layout.go @@ -17,10 +17,12 @@ import ( // layoutFeatures are the features each DiagramLayout metadata definition // declares, in the order the writer binds them. var layoutFeatures = map[string][]string{ - semantics.LayoutFQN: {"x", "y", "width", "height", "collapsed"}, - semantics.RouteFQN: {"points"}, - semantics.CanvasFQN: {"unit", "width", "height"}, - semantics.StyleFQN: {"fill", "line", "text", "font", "fontSize", "bold", "italic"}, + semantics.LayoutFQN: {"x", "y", "width", "height", "collapsed"}, + semantics.RouteFQN: {"points"}, + semantics.CanvasFQN: {"unit", "width", "height"}, + semantics.StyleFQN: {"fill", "line", "text", "font", "fontSize", "bold", "italic"}, + semantics.NoteFQN: {"text", "x", "y", "width", "height"}, + semantics.PictureFQN: {"location", "x", "y", "width", "height", "alt", "above"}, } // layoutSplices turns a set-layout operation into the bytes it rewrites: the @@ -30,8 +32,8 @@ func (m Model) layoutSplices(i int, op Operation) ([]splice, error) { features, known := layoutFeatures[op.Annotation] if !known { return nil, &Error{Failure: FailureInvalidValue, OperationIndex: i, - Message: fmt.Sprintf("%q is no DiagramLayout annotation this edit writes; the annotations are %s, %s, %s and %s", - op.Annotation, semantics.LayoutFQN, semantics.RouteFQN, semantics.CanvasFQN, semantics.StyleFQN)} + Message: fmt.Sprintf("%q is no DiagramLayout annotation this edit writes; the annotations are %s", + op.Annotation, strings.Join(semantics.LayoutFQNs, ", "))} } bindings, err := m.layoutBindings(i, op) if err != nil { @@ -42,15 +44,18 @@ func (m Model) layoutSplices(i int, op Operation) ([]splice, error) { if err != nil { return nil, err } - in, err := m.layoutDocument(i, op, target.sym, target.view) + in, err := m.layoutDocument(i, op, target) if err != nil { return nil, err } - renderer := view.NewRenderer(sem, r, nil) - if err := m.checkPlaceable(i, op, renderer, sem, target); err != nil { - return nil, err + site := target.site + if site == nil { + renderer := view.NewRenderer(sem, r, nil) + if err := m.checkPlaceable(i, op, renderer, sem, target); err != nil { + return nil, err + } + site = in.layoutSite(sem, target, op.Annotation) } - site := in.layoutSite(sem, target, op.Annotation) var splices []splice switch { case bindings == nil: @@ -75,6 +80,9 @@ func (m Model) layoutSplices(i int, op Operation) ([]splice, error) { type layoutTarget struct { sym, view, owner *symbols.Symbol about string + // site is the annotation itself when the operation declares it, as a + // setNote or setPicture may: it is rewritten whatever else the element carries. + site *semantics.LayoutSite } // addressed labels splices as operation i's, addressed to in's document. @@ -92,15 +100,19 @@ func (m Model) addressed(splices []splice, i int, op Operation, in Model) []spli // layoutDocument is the model of the document written: the view's when one is // named, else the element's own. A document without writable source refuses. -func (m Model) layoutDocument(i int, op Operation, sym, viewSym *symbols.Symbol) (Model, error) { - owner, declares := sym, "it" - if viewSym != nil { - owner, declares = viewSym, op.View +func (m Model) layoutDocument(i int, op Operation, target layoutTarget) (Model, error) { + owner, declares := target.sym, "it" + if target.view != nil { + owner, declares = target.view, op.View } - in, ok := m.inDocument(owner.DocName) + docName := owner.DocName + if target.site != nil { + docName = symbols.DocNameOf(target.site.Scope) + } + in, ok := m.inDocument(docName) if !ok { - where := docLabel(owner.DocName) - if m.Index.IsLibraryDocument(owner.DocName) { + where := docLabel(docName) + if m.Index.IsLibraryDocument(docName) { where = "the bundled library file " + where } return Model{}, &Error{Failure: FailureReferencedElsewhere, OperationIndex: i, @@ -122,10 +134,54 @@ func (m Model) layoutBindings(i int, op Operation) ([]MetadataValue, error) { return canvasValues(i, op) case semantics.StyleFQN: return m.styleValues(i, op) + case semantics.NoteFQN: + return m.noteValues(i, op) + case semantics.PictureFQN: + return pictureValues(i, op) } return nil, nil } +func (m Model) noteValues(i int, op Operation) ([]MetadataValue, error) { + n := op.NoteBox + if n == nil { + return nil, nil + } + if strings.TrimSpace(n.Text) == "" { + return nil, &Error{Failure: FailureInvalidValue, OperationIndex: i, + Message: fmt.Sprintf("a Note of %s shows no text; clear the Note to drop it", m.label(op))} + } + out := []MetadataValue{{"text", stringLiteral(n.Text)}, {"x", realLiteral(n.X)}, {"y", realLiteral(n.Y)}} + if n.HasSize { + out = append(out, MetadataValue{"width", realLiteral(n.Width)}, MetadataValue{"height", realLiteral(n.Height)}) + } + return out, nil +} + +func pictureValues(i int, op Operation) ([]MetadataValue, error) { + p := op.Picture + if p == nil { + return nil, nil + } + if p.Location == "" { + return nil, &Error{Failure: FailureInvalidValue, OperationIndex: i, + Message: fmt.Sprintf("a Picture of %s names no file to show; clear the Picture to drop it", op.Target)} + } + if p.Width <= 0 || p.Height <= 0 { + return nil, &Error{Failure: FailureInvalidValue, OperationIndex: i, + Message: fmt.Sprintf("a Picture of %s needs a positive width and height", op.Target)} + } + out := []MetadataValue{{"location", stringLiteral(p.Location)}, {"x", realLiteral(p.X)}, {"y", realLiteral(p.Y)}, + {"width", realLiteral(p.Width)}, {"height", realLiteral(p.Height)}} + if p.Alt != "" { + out = append(out, MetadataValue{"alt", stringLiteral(p.Alt)}) + } + if p.Above { + out = append(out, MetadataValue{"above", "true"}) + } + return out, nil +} + func (m Model) layoutValues(i int, op Operation) ([]MetadataValue, error) { l := op.Layout if l == nil { @@ -242,15 +298,32 @@ func stringLiteral(s string) string { func (m Model) layoutTargets(i int, op Operation, sem *semantics.Model) (layoutTarget, error) { var target layoutTarget var err error - if op.Annotation == semantics.CanvasFQN && (op.Target != "" || op.Declaration.Len == 0) { + switch { + case annotationDeclared(op): + return m.declaredAnnotation(i, op, sem) + case op.Annotation == semantics.CanvasFQN && (op.Target != "" || op.Declaration.Len == 0): target.sym, err = m.viewNamed(i, op.Target, op.DeclarationDoc, fmt.Sprintf("%s is no view; a Canvas sizes the drawing surface of a view", op.Target)) - } else { + case op.Annotation == semantics.PictureFQN: + target.sym, err = m.viewNamed(i, op.Target, op.DeclarationDoc, + fmt.Sprintf("%s is no view; a Picture is drawn on the canvas of a view", op.Target)) + default: target, err = m.layoutElement(i, op, sem) } if err != nil { return layoutTarget{}, err } + if op.Annotation == semantics.PictureFQN { + if op.View != "" { + return layoutTarget{}, &Error{Failure: FailureInvalidValue, OperationIndex: i, + Message: "a Picture is stated in the body of the view it is drawn on; name that view as the target and no other"} + } + if !semantics.IsView(target.sym) { + return layoutTarget{}, &Error{Failure: FailureNotAView, OperationIndex: i, + Message: fmt.Sprintf("%s is no view; a Picture is drawn on the canvas of a view", m.label(op))} + } + return target, nil + } if op.Annotation != semantics.CanvasFQN && op.View != "" && target.owner == nil && !qualified(target.sym) { return layoutTarget{}, &Error{Failure: FailureNotNamed, OperationIndex: i, Message: fmt.Sprintf("%s has no qualified name for the body of %s to state a %s about; write it inline, without a view", @@ -283,6 +356,37 @@ func (m Model) layoutTargets(i int, op Operation, sem *semantics.Model) (layoutT return target, nil } +// annotationDeclared reports whether the operation reaches a Note or Picture +// annotation by its own declaration rather than by the element it annotates. +func annotationDeclared(op Operation) bool { + return (op.Annotation == semantics.NoteFQN || op.Annotation == semantics.PictureFQN) && op.Target == "" && op.Declaration.Len > 0 +} + +// declaredAnnotation is the Note or Picture annotation declared at the +// operation's span, as the site of the element it annotates, in the view it +// is stated in. +func (m Model) declaredAnnotation(i int, op Operation, sem *semantics.Model) (layoutTarget, error) { + if op.View != "" { + return layoutTarget{}, &Error{Failure: FailureInvalidValue, OperationIndex: i, + Message: fmt.Sprintf("a %s reached by its declaration is stated where it is; name no view", layoutTypeName(op.Annotation))} + } + root, err := m.declarationRoot(i, op) + if err != nil { + return layoutTarget{}, err + } + sym := root.DeclaredAt(op.Declaration) + if sym == nil { + return layoutTarget{}, &Error{Failure: FailureUnknownTarget, OperationIndex: i, + Message: fmt.Sprintf("nothing is declared at %s of this model", m.at(op))} + } + site, elem := sem.LayoutSiteDeclaredBy(sym) + if site == nil || site.TypeFQN != op.Annotation { + return layoutTarget{}, &Error{Failure: FailureNotAnnotated, OperationIndex: i, + Message: fmt.Sprintf("what is declared at %s is no %s annotation", m.at(op), layoutTypeName(op.Annotation))} + } + return layoutTarget{sym: elem, view: site.View, site: site}, nil +} + // layoutElement is the element a layout operation names or declares; a name no // declaration answers to may still reach an inherited member through the owner // a rendering draws it under, as `Machines::VehicleStates::start` does its start. @@ -345,10 +449,11 @@ func (m Model) viewNamed(i int, name, doc, noView string) (*symbols.Symbol, erro // it applies in does not draw as a node or an edge: the view's rendering for a // view-local one, any rendering for an inline one. A Style colours either. func (m Model) checkPlaceable(i int, op Operation, renderer *view.Renderer, sem *semantics.Model, target layoutTarget) error { - if op.Annotation == semantics.CanvasFQN { + sym, viewSym := target.sym, target.view + if op.Annotation == semantics.CanvasFQN || op.Annotation == semantics.PictureFQN || + op.Annotation == semantics.NoteFQN && viewSym == nil && semantics.IsView(sym) { return nil } - sym, viewSym := target.sym, target.view wantNode := op.Annotation != semantics.RouteFQN wantEdge := op.Annotation != semantics.LayoutFQN role := "an edge a Route steers" @@ -357,6 +462,8 @@ func (m Model) checkPlaceable(i int, op Operation, renderer *view.Renderer, sem role = "a node a Layout positions" case semantics.StyleFQN: role = "a node or an edge a Style colours" + case semantics.NoteFQN: + role = "a node or an edge a Note annotates" } if viewSym == nil { node, edge := renderer.DrawsAnywhere(sym) @@ -425,7 +532,7 @@ func (m Model) layoutSite(sem *semantics.Model, target layoutTarget, typeFQN str continue } } - if viewSym == nil && site.View == nil { + if viewSym == nil && (site.View == nil || typeFQN == semantics.NoteFQN && site.View.Decl == sym.Decl) { return site } if viewSym != nil && site.View != nil && site.View.Decl == viewSym.Decl { diff --git a/internal/check/edit/layout_note_test.go b/internal/check/edit/layout_note_test.go new file mode 100644 index 0000000000..764578d204 --- /dev/null +++ b/internal/check/edit/layout_note_test.go @@ -0,0 +1,118 @@ +package edit + +import ( + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +// annotationSpan is the span of the first annotation declared with text in content. +func annotationSpan(t *testing.T, content, text string) source.Span { + t.Helper() + at := strings.Index(content, text) + if at < 0 { + t.Fatalf("no %q in the model", text) + } + return source.Span{Offset: at, Len: len(text)} +} + +func TestSetNote(t *testing.T) { + note := &semantics.Note{Text: "feeds the tank", X: 120, Y: 40} + got := applyLayout(t, plantModel, SetNote("Plant::Loop::pump", "PlantViews::loopView", note)) + requireReplaced(t, plantModel, " render asInterconnectionDiagram;\n }\n", + " render asInterconnectionDiagram;\n metadata DiagramLayout::Note about Plant::Loop::pump { text = \"feeds the tank\"; x = 120; y = 40; }\n }\n", got) + noted := got + sized := &semantics.Note{Text: "feeds the tank", X: 130, Y: 50, Width: 100, Height: 30, HasSize: true} + got = applyLayout(t, noted, SetNote("Plant::Loop::pump", "PlantViews::loopView", sized)) + requireReplaced(t, noted, `{ text = "feeds the tank"; x = 120; y = 40; }`, `{ text = "feeds the tank"; x = 130; y = 50; width = 100; height = 30; }`, got) + got = applyLayout(t, noted, SetNote("Plant::Loop::pump", "PlantViews::loopView", nil)) + if got != plantModel { + t.Fatalf("clearing the note did not restore the model:\n%s", got) + } + got = applyLayout(t, plantModel, SetNote("Plant::Loop::pump", "", note)) + requireReplaced(t, plantModel, " part pump : Pump;\n", + " part pump : Pump {\n @DiagramLayout::Note { text = \"feeds the tank\"; x = 120; y = 40; }\n }\n", got) +} + +// A note about nothing in particular is stated inline in the view's own body. +func TestSetNoteOnTheView(t *testing.T) { + note := &semantics.Note{Text: "draft", X: 10, Y: 10} + got := applyLayout(t, plantModel, SetNote("PlantViews::loopView", "", note)) + requireReplaced(t, plantModel, " render asInterconnectionDiagram;\n }\n", + " render asInterconnectionDiagram;\n @DiagramLayout::Note { text = \"draft\"; x = 10; y = 10; }\n }\n", got) + free := got + got = applyLayout(t, free, SetNote("PlantViews::loopView", "", &semantics.Note{Text: "final", X: 20, Y: 30})) + requireReplaced(t, free, `{ text = "draft"; x = 10; y = 10; }`, `{ text = "final"; x = 20; y = 30; }`, got) + got = applyLayout(t, free, SetNote("PlantViews::loopView", "", nil)) + if got != plantModel { + t.Fatalf("clearing the free note did not restore the model:\n%s", got) + } +} + +// Two notes about one element are told apart by their declarations. +func TestSetNoteAtRewritesTheDeclaredNote(t *testing.T) { + const first = `metadata DiagramLayout::Note about Plant::Loop::pump { text = "first"; x = 1; y = 2; }` + const second = `metadata DiagramLayout::Note about Plant::Loop::pump { text = "second"; x = 3; y = 4; }` + twice := strings.Replace(plantModel, " render asInterconnectionDiagram;\n", + " render asInterconnectionDiagram;\n "+first+"\n "+second+"\n", 1) + got := applyLayout(t, twice, SetNoteAt(annotationSpan(t, twice, second), &semantics.Note{Text: "second", X: 30, Y: 40})) + requireReplaced(t, twice, second, `metadata DiagramLayout::Note about Plant::Loop::pump { text = "second"; x = 30; y = 40; }`, got) + got = applyLayout(t, twice, SetNoteAt(annotationSpan(t, twice, first), nil)) + requireReplaced(t, twice, " "+first+"\n", "", got) + inline := strings.Replace(plantModel, " part pump : Pump;\n", + " part pump : Pump {\n @DiagramLayout::Note { text = \"inline\"; x = 5; y = 6; }\n }\n", 1) + got = applyLayout(t, inline, SetNoteAt(annotationSpan(t, inline, `@DiagramLayout::Note { text = "inline"; x = 5; y = 6; }`), &semantics.Note{Text: "moved", X: 7, Y: 8})) + requireReplaced(t, inline, `{ text = "inline"; x = 5; y = 6; }`, `{ text = "moved"; x = 7; y = 8; }`, got) +} + +func TestSetPicture(t *testing.T) { + picture := &semantics.Picture{Location: "plant.png", X: 10, Y: 20, Width: 300, Height: 200, Alt: "the plant"} + got := applyLayout(t, plantModel, SetPicture("PlantViews::loopView", picture)) + requireReplaced(t, plantModel, " render asInterconnectionDiagram;\n }\n", + " render asInterconnectionDiagram;\n @DiagramLayout::Picture { location = \"plant.png\"; x = 10; y = 20; width = 300; height = 200; alt = \"the plant\"; }\n }\n", got) + pictured := got + got = applyLayout(t, pictured, SetPicture("PlantViews::loopView", &semantics.Picture{Location: "plant.png", X: 15, Y: 25, Width: 300, Height: 200, Above: true})) + requireReplaced(t, pictured, `{ location = "plant.png"; x = 10; y = 20; width = 300; height = 200; alt = "the plant"; }`, + `{ location = "plant.png"; x = 15; y = 25; width = 300; height = 200; above = true; }`, got) + const annotation = `@DiagramLayout::Picture { location = "plant.png"; x = 10; y = 20; width = 300; height = 200; alt = "the plant"; }` + got = applyLayout(t, pictured, SetPictureAt(annotationSpan(t, pictured, annotation), &semantics.Picture{Location: "site.png", X: 10, Y: 20, Width: 30, Height: 20})) + requireReplaced(t, pictured, annotation, `@DiagramLayout::Picture { location = "site.png"; x = 10; y = 20; width = 30; height = 20; }`, got) + got = applyLayout(t, pictured, SetPicture("PlantViews::loopView", nil)) + if got != plantModel { + t.Fatalf("clearing the picture did not restore the model:\n%s", got) + } +} + +func TestSetNoteAndPictureRefusals(t *testing.T) { + pictured := strings.Replace(plantModel, " render asInterconnectionDiagram;\n", + " render asInterconnectionDiagram;\n @DiagramLayout::Picture { location = \"plant.png\"; x = 1; y = 2; width = 3; height = 4; }\n", 1) + pictureSpan := annotationSpan(t, pictured, `@DiagramLayout::Picture { location = "plant.png"; x = 1; y = 2; width = 3; height = 4; }`) + cases := []struct { + name string + op Operation + failure Failure + message string + }{ + {"empty note", SetNote("Plant::Loop::pump", "", &semantics.Note{Text: " ", X: 1, Y: 2}), FailureInvalidValue, "shows no text"}, + {"note on nothing drawn", SetNote("Plant::Pump", "PlantViews::loopView", &semantics.Note{Text: "x", X: 1, Y: 2}), FailureNotExposed, "does not expose"}, + {"picture without a file", SetPicture("PlantViews::loopView", &semantics.Picture{X: 1, Y: 2, Width: 3, Height: 4}), FailureInvalidValue, "names no file"}, + {"flat picture", SetPicture("PlantViews::loopView", &semantics.Picture{Location: "p.png", X: 1, Y: 2}), FailureInvalidValue, "positive width and height"}, + {"picture on a part", SetPicture("Plant::Loop::pump", &semantics.Picture{Location: "p.png", X: 1, Y: 2, Width: 3, Height: 4}), FailureNotAView, "is no view"}, + {"nothing to clear", SetNote("Plant::Loop::pump", "", nil), FailureNotAnnotated, "no Note"}, + {"note at a part", SetNoteAt(annotationSpan(t, pictured, "part pump : Pump;"), &semantics.Note{Text: "x", X: 1, Y: 2}), FailureNotAnnotated, "no Note annotation"}, + {"picture at a note", SetNoteAt(pictureSpan, &semantics.Note{Text: "x", X: 1, Y: 2}), FailureNotAnnotated, "no Note annotation"}, + {"declared with a view", Operation{Kind: OpSetLayout, Declaration: pictureSpan, View: "PlantViews::loopView", Annotation: semantics.PictureFQN}, FailureInvalidValue, "name no view"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m := loadContent(t, "plant.sysml", pictured) + _, err := Apply(m, []Operation{tc.op}) + e := editError(t, err) + if e.Failure != tc.failure || !strings.Contains(e.Message, tc.message) { + t.Fatalf("err = %v, want %s containing %q", err, tc.failure, tc.message) + } + }) + } +} diff --git a/internal/frontend/lsp/modeledit.go b/internal/frontend/lsp/modeledit.go index 4344cd9d3e..fc21ec3449 100644 --- a/internal/frontend/lsp/modeledit.go +++ b/internal/frontend/lsp/modeledit.go @@ -38,6 +38,8 @@ const ( EditSetRoute = "setRoute" EditSetCanvas = "setCanvas" EditSetStyle = "setStyle" + EditSetNote = "setNote" + EditSetPicture = "setPicture" ) // ApplyModelEditParams asks for the operations to be applied to the document as @@ -65,7 +67,12 @@ type ApplyModelEditParams struct { // Layout or Route, so it applies in that view alone; left empty, the annotation // goes inline into Target's declaration and applies in every view. A setLayout // with no Layout, a setRoute with no or an empty Route, a setCanvas with no -// Canvas and a setStyle with no Style clear the annotation. +// Canvas and a setStyle with no Style clear the annotation. setNote writes a +// Note about the node or edge Target in View (inline on Target without one, and +// a note about nothing when Target is the view itself), setPicture a Picture +// into the body of the view Target; both may be stated many times, so for these +// two Declaration is the range of the annotation itself, as a rendering reports +// a note's or picture's origin, and rewrites or clears that one wherever it is. type ModelEditOperation struct { Kind string `json:"kind"` Target string `json:"target,omitempty"` @@ -98,6 +105,32 @@ type ModelEditOperation struct { Route []RenderPoint `json:"route,omitempty"` Canvas *RenderCanvas `json:"canvas,omitempty"` Style *RenderStyle `json:"style,omitempty"` + Note *ModelEditNote `json:"note,omitempty"` + Picture *ModelEditPicture `json:"picture,omitempty"` +} + +// ModelEditNote is a note as setNote writes it: its text and the top-left +// corner of its box in the units opensysml/render reports; Width and Height +// are written both or neither. +type ModelEditNote struct { + Text string `json:"text"` + X float64 `json:"x"` + Y float64 `json:"y"` + Width *float64 `json:"width,omitempty"` + Height *float64 `json:"height,omitempty"` +} + +// ModelEditPicture is a picture as setPicture writes it: the file, relative to +// the view's document, the box it fills, its alt text and whether it is drawn +// over the elements. +type ModelEditPicture struct { + Location string `json:"location"` + X float64 `json:"x"` + Y float64 `json:"y"` + Width float64 `json:"width"` + Height float64 `json:"height"` + Alt string `json:"alt,omitempty"` + Above bool `json:"above,omitempty"` } // ModelEditLayout is a node's geometry as setLayout writes it, in the units @@ -335,15 +368,15 @@ func (s *Server) operation(doc *model.Document, op ModelEditOperation) (modeledi // operation reads the wire operation as the edit operation it names; content // is the document a Declaration range is a range of. func (op ModelEditOperation) operation(content []byte) (modeledit.Operation, error) { - byDeclaration := op.Kind == EditSetLayout || op.Kind == EditSetRoute || op.Kind == EditSetStyle + byDeclaration := op.Kind == EditSetLayout || op.Kind == EditSetRoute || op.Kind == EditSetStyle || op.Kind == EditSetNote || op.Kind == EditSetPicture if op.Declaration != nil && !byDeclaration { - return modeledit.Operation{}, fmt.Errorf("a declaration stands in for the target of a %s, %s or %s alone", EditSetLayout, EditSetRoute, EditSetStyle) + return modeledit.Operation{}, fmt.Errorf("a declaration stands in for the target of a %s, %s, %s, %s or %s alone", EditSetLayout, EditSetRoute, EditSetStyle, EditSetNote, EditSetPicture) } if op.Declaration != nil && op.Target != "" { return modeledit.Operation{}, errors.New("an operation targets its element by name or by declaration, not both") } if op.DeclaredIn != "" && !byDeclaration { - return modeledit.Operation{}, fmt.Errorf("declaredIn names the document declaring the target of a %s, %s or %s alone", EditSetLayout, EditSetRoute, EditSetStyle) + return modeledit.Operation{}, fmt.Errorf("declaredIn names the document declaring the target of a %s, %s, %s, %s or %s alone", EditSetLayout, EditSetRoute, EditSetStyle, EditSetNote, EditSetPicture) } switch op.Kind { case EditSetValue: @@ -427,9 +460,56 @@ func (op ModelEditOperation) operation(content []byte) (modeledit.Operation, err return modeledit.SetStyleAt(rangeToSpan(content, *op.Declaration), op.View, style), nil } return modeledit.SetStyle(op.Target, op.View, style), nil + case EditSetNote: + note, err := op.Note.note() + if err != nil { + return modeledit.Operation{}, err + } + if op.Declaration != nil { + if op.View != "" { + return modeledit.Operation{}, fmt.Errorf("a %s given the annotation's declaration names no view", EditSetNote) + } + return modeledit.SetNoteAt(rangeToSpan(content, *op.Declaration), note), nil + } + return modeledit.SetNote(op.Target, op.View, note), nil + case EditSetPicture: + picture := op.Picture.picture() + if op.Declaration != nil { + if op.View != "" { + return modeledit.Operation{}, fmt.Errorf("a %s given the annotation's declaration names no view", EditSetPicture) + } + return modeledit.SetPictureAt(rangeToSpan(content, *op.Declaration), picture), nil + } + if op.View != "" { + return modeledit.Operation{}, fmt.Errorf("a %s names the view as its target and no other", EditSetPicture) + } + return modeledit.SetPicture(op.Target, picture), nil } return modeledit.Operation{}, fmt.Errorf("kind %q is none of %s", op.Kind, - strings.Join([]string{EditSetValue, EditRename, EditAddMember, EditAddConnection, EditAddSequence, EditDelete, EditMove, EditSetLayout, EditSetRoute, EditSetCanvas, EditSetStyle}, ", ")) + strings.Join([]string{EditSetValue, EditRename, EditAddMember, EditAddConnection, EditAddSequence, EditDelete, EditMove, EditSetLayout, EditSetRoute, EditSetCanvas, EditSetStyle, EditSetNote, EditSetPicture}, ", ")) +} + +// note reads the wire note as the edit layer writes it; nil clears. +func (n *ModelEditNote) note() (*semantics.Note, error) { + if n == nil { + return nil, nil + } + if (n.Width == nil) != (n.Height == nil) { + return nil, errors.New("a note's width and height are given together or not at all") + } + note := &semantics.Note{Text: n.Text, X: n.X, Y: n.Y} + if n.Width != nil { + note.Width, note.Height, note.HasSize = *n.Width, *n.Height, true + } + return note, nil +} + +// picture reads the wire picture as the edit layer writes it; nil clears. +func (p *ModelEditPicture) picture() *semantics.Picture { + if p == nil { + return nil + } + return &semantics.Picture{Location: p.Location, X: p.X, Y: p.Y, Width: p.Width, Height: p.Height, Alt: p.Alt, Above: p.Above} } // style reads the wire style as the edit layer writes it; nil clears. diff --git a/internal/frontend/lsp/modeledit_cross_document_test.go b/internal/frontend/lsp/modeledit_cross_document_test.go index 97483a9322..778ed0c3d7 100644 --- a/internal/frontend/lsp/modeledit_cross_document_test.go +++ b/internal/frontend/lsp/modeledit_cross_document_test.go @@ -450,8 +450,8 @@ func TestApplyModelEditRejectsMisplacedDeclaredIn(t *testing.T) { }{ {ModelEditOperation{Kind: EditSetRoute, Declaration: decl, DeclaredIn: protocol.DocumentURI(uri.File("elsewhere.sysml")), Digest: digest, Route: []RenderPoint{{X: 1, Y: 2}}}, "no document the server holds"}, {ModelEditOperation{Kind: EditSetLayout, Target: "Machinery::Engine::rotor", DeclaredIn: protocol.DocumentURI(uri.File("elsewhere.sysml")), Digest: digest, Layout: &ModelEditLayout{X: float(1), Y: float(2)}}, "no document the server holds"}, - {ModelEditOperation{Kind: EditRename, Target: "Machinery::Engine", DeclaredIn: protocol.DocumentURI(partsURI), Digest: digest, NewName: "Motor"}, "setLayout, setRoute or setStyle alone"}, - {ModelEditOperation{Kind: EditSetCanvas, Target: "EngineViews::engineView", DeclaredIn: protocol.DocumentURI(viewsURI), Canvas: &RenderCanvas{Unit: "px"}}, "setLayout, setRoute or setStyle alone"}, + {ModelEditOperation{Kind: EditRename, Target: "Machinery::Engine", DeclaredIn: protocol.DocumentURI(partsURI), Digest: digest, NewName: "Motor"}, "setLayout, setRoute, setStyle, setNote or setPicture alone"}, + {ModelEditOperation{Kind: EditSetCanvas, Target: "EngineViews::engineView", DeclaredIn: protocol.DocumentURI(viewsURI), Canvas: &RenderCanvas{Unit: "px"}}, "setLayout, setRoute, setStyle, setNote or setPicture alone"}, {ModelEditOperation{Kind: EditSetRoute, Declaration: decl, DeclaredIn: protocol.DocumentURI(partsURI), Route: []RenderPoint{{X: 1, Y: 2}}}, "needs the digest"}, {ModelEditOperation{Kind: EditSetLayout, Target: "Machinery::Engine::rotor", DeclaredIn: protocol.DocumentURI(partsURI), Layout: &ModelEditLayout{X: float(1), Y: float(2)}}, "needs the digest"}, } { diff --git a/internal/frontend/lsp/modeledit_note_test.go b/internal/frontend/lsp/modeledit_note_test.go new file mode 100644 index 0000000000..2c79271649 --- /dev/null +++ b/internal/frontend/lsp/modeledit_note_test.go @@ -0,0 +1,52 @@ +package lsp + +import ( + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +// A setNote writes a Note about a node into the view's body; the note's own +// range then reaches that one annotation to move or remove it, as a setPicture +// does the Picture it wrote into the view. +func TestApplyModelEditSetNoteAndPicture(t *testing.T) { + s, docURI := renderServer(t, "plant.sysml", plantModel) + drawn := render(t, s, docURI, "PlantViews::loopView") + width, height := 100.0, 30.0 + out := applyModelEdit(t, s, docURI, drawn.Version, + ModelEditOperation{Kind: EditSetNote, Target: "Plant::Loop::pump", View: drawn.View, Note: &ModelEditNote{Text: "feeds the tank", X: 120, Y: 40, Width: &width, Height: &height}}, + ModelEditOperation{Kind: EditSetPicture, Target: drawn.View, Picture: &ModelEditPicture{Location: "plant.png", X: 0, Y: 0, Width: 400, Height: 300, Alt: "the plant"}}, + ) + const note = `metadata DiagramLayout::Note about Plant::Loop::pump { text = "feeds the tank"; x = 120; y = 40; width = 100; height = 30; }` + const picture = `@DiagramLayout::Picture { location = "plant.png"; x = 0; y = 0; width = 400; height = 300; alt = "the plant"; }` + applied, _ := redraw(t, s, docURI, plantModel, out, 2, drawn.View) + if !strings.Contains(applied, note) || !strings.Contains(applied, picture) { + t.Fatalf("note and picture not written:\n%s", applied) + } + content := []byte(applied) + noteRange := spanToRange(content, source.Span{Offset: strings.Index(applied, note), Len: len(note)}) + pictureRange := spanToRange(content, source.Span{Offset: strings.Index(applied, picture), Len: len(picture)}) + out = applyModelEdit(t, s, docURI, 2, + ModelEditOperation{Kind: EditSetNote, Declaration: ¬eRange, Note: &ModelEditNote{Text: "feeds the tank", X: 130, Y: 50}}, + ModelEditOperation{Kind: EditSetPicture, Declaration: &pictureRange}, + ) + moved, _ := redraw(t, s, docURI, applied, out, 3, drawn.View) + if !strings.Contains(moved, `{ text = "feeds the tank"; x = 130; y = 50; }`) || strings.Contains(moved, "Picture") { + t.Errorf("note not moved or picture not removed:\n%s", moved) + } + + for _, tc := range []struct { + name string + op ModelEditOperation + want string + }{ + {"note sized by half", ModelEditOperation{Kind: EditSetNote, Target: "Plant::Loop::pump", Note: &ModelEditNote{Text: "x", Width: &width}}, "together or not at all"}, + {"picture in a view", ModelEditOperation{Kind: EditSetPicture, Target: drawn.View, View: drawn.View, Picture: &ModelEditPicture{Location: "p.png", Width: 1, Height: 1}}, "names the view as its target"}, + {"declared note in a view", ModelEditOperation{Kind: EditSetNote, Declaration: ¬eRange, View: drawn.View}, "names no view"}, + } { + if _, err := tc.op.operation(content); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Errorf("%s: err = %v, want %q", tc.name, err, tc.want) + } + } +} diff --git a/internal/frontend/lsp/modeledit_test.go b/internal/frontend/lsp/modeledit_test.go index 500ab8314a..63bbf579df 100644 --- a/internal/frontend/lsp/modeledit_test.go +++ b/internal/frontend/lsp/modeledit_test.go @@ -1163,7 +1163,7 @@ package PlantViews { t.Errorf("target and declaration together: err = %v", err) } rename := ModelEditOperation{Kind: EditRename, Declaration: &decl, NewName: "x"} - if _, err := rename.operation(nil); err == nil || !strings.Contains(err.Error(), "setLayout, setRoute or setStyle") { + if _, err := rename.operation(nil); err == nil || !strings.Contains(err.Error(), "setLayout, setRoute, setStyle, setNote or setPicture") { t.Errorf("rename by declaration: err = %v", err) } } diff --git a/internal/frontend/mcp/lsptools/edits.go b/internal/frontend/mcp/lsptools/edits.go index 844db3fe1f..fd7365cbe4 100644 --- a/internal/frontend/mcp/lsptools/edits.go +++ b/internal/frontend/mcp/lsptools/edits.go @@ -141,7 +141,7 @@ func registerEdits(s *mcp.Server, h *Host) { mcp.AddTool(s, &mcp.Tool{ Name: "apply_model_edit", Description: "Edit a model by operations on its elements rather than by text: setValue, rename, addMember, addConnection, addSequence (action-body items), delete, move, " + - "and the diagram-layout annotations setLayout, setRoute, setCanvas, setStyle. Targets and owners are qualified names as render_view reports them in nodes[].fqn. " + + "and the diagram-layout annotations setLayout, setRoute, setCanvas, setStyle, setNote, setPicture. Targets and owners are qualified names as render_view reports them in nodes[].fqn. " + "Returns the text edits the operations amount to, per document, and the operations refused with why; writes the files only with apply=true.", Annotations: toolkit.Mutating("Apply model edit", false), InputSchema: applyModelEditSchema(), @@ -288,9 +288,9 @@ func describe(s *jsonschema.Schema, descriptions map[string]string) { } var operationFieldDescriptions = map[string]string{ - "kind": "setValue, rename, addMember, addConnection, addSequence, delete, move, setLayout, setRoute, setCanvas or setStyle", - "target": "qualified name of the element the operation is about (setValue, rename, delete, move, setCanvas, and the layout operations when no declaration is given)", - "declaration": "for setLayout, setRoute, setStyle: the LSP range (0-based) of an unnamed element's declaration, as render_view reports it, in place of target", + "kind": "setValue, rename, addMember, addConnection, addSequence, delete, move, setLayout, setRoute, setCanvas, setStyle, setNote or setPicture", + "target": "qualified name of the element the operation is about (setValue, rename, delete, move, setCanvas, setPicture, and the layout operations when no declaration is given)", + "declaration": "for setLayout, setRoute, setStyle: the LSP range (0-based) of an unnamed element's declaration, as render_view reports it, in place of target; for setNote, setPicture: the range of the annotation itself, to rewrite or clear that one", "declaredIn": "the document URI declaring the target, as render_view reports in origin.uri; omitted, the requested document", "digest": "the origin.digest render_view reported for declaredIn, so a changed document is detected", "value": "setValue: the new value expression; addMember: an initial value; addSequence: the value assigned, sent or iterated over", @@ -319,4 +319,6 @@ var operationFieldDescriptions = map[string]string{ "route": "setRoute: the waypoints [{x, y}, ...]", "canvas": "setCanvas: {unit?, width?, height?}", "style": "setStyle: {fill?, line?, text?, font?, fontSize?, bold?, italic?}, colors as #RRGGBB", + "note": "setNote: {text, x, y, width?, height?} in diagram coordinates; omitted, the Note is removed", + "picture": "setPicture: {location, x, y, width, height, alt?, above?}, the file relative to the view's document; omitted, the Picture is removed", } diff --git a/internal/ir/view/drawio_import_diff.go b/internal/ir/view/drawio_import_diff.go index 920b00d3f1..e8572e0145 100644 --- a/internal/ir/view/drawio_import_diff.go +++ b/internal/ir/view/drawio_import_diff.go @@ -132,7 +132,7 @@ func (r *Rendering) ImportDrawio(file *DrawioFile, options Options) (*DrawioImpo return nil, err } out := &DrawioImport{View: norm.View} - m := &drawioMatcher{norm: norm, base: base, file: file, out: out, nodes: map[string]*Node{}, ports: map[string]Port{}} + m := &drawioMatcher{norm: norm, w: w, base: base, file: file, out: out, nodes: map[string]*Node{}, ports: map[string]Port{}, framed: map[string]bool{}} for _, node := range drawioNodes(norm.Roots) { m.nodes[node.ID] = node for _, port := range node.Ports { @@ -158,11 +158,14 @@ func drawioNodes(roots []*Node) []*Node { // turns each difference into an edit. type drawioMatcher struct { norm *Rendering + w *drawioWriter base, file *DrawioFile out *DrawioImport nodes map[string]*Node ports map[string]Port matched map[string]bool + // framed are the cells whose Layout pinning a container already wrote. + framed map[string]bool } // match pairs the file's semantic cells with the baseline's — by the exported @@ -353,6 +356,9 @@ func (m *drawioMatcher) originOf(id string) (Origin, bool) { // layoutDiff is the Layout a node needs for the file's box, nil when the box // is the baseline's within tolerance and the fold is the same. func (m *drawioMatcher) layoutDiff(cell, base *DrawioCell) *semantics.Layout { + if m.framed[base.ID] { + return nil + } fileBox, baseBox := fullBox(cell), fullBox(base) moved := !near(fileBox.X, baseBox.X) || !near(fileBox.Y, baseBox.Y) resized := !near(fileBox.Width, baseBox.Width) || !near(fileBox.Height, baseBox.Height) @@ -360,7 +366,11 @@ func (m *drawioMatcher) layoutDiff(cell, base *DrawioCell) *semantics.Layout { if !moved && !resized && !folded { return nil } - layout := &semantics.Layout{X: fileBox.X, Y: fileBox.Y, Collapsed: cell.Collapsed} + shift := m.frameShift(base) + if m.framed[base.ID] { + return nil + } + layout := &semantics.Layout{X: fileBox.X - shift.X, Y: fileBox.Y - shift.Y, Collapsed: cell.Collapsed} node := m.nodes[base.ID] if resized || (node != nil && node.Geometry != nil && node.Geometry.HasSize) { layout.Width, layout.Height, layout.HasSize = fileBox.Width, fileBox.Height, true @@ -371,6 +381,69 @@ func (m *drawioMatcher) layoutDiff(cell, base *DrawioCell) *semantics.Layout { return layout } +// frameShift is what the writer added to a node's Layout to draw it on the page: +// nothing for a root or a child of a placed container; for a child of one the +// model does not place, the writer hugs the placed children to the container's +// corner, so the container is pinned first and the page frame becomes the model's. +func (m *drawioMatcher) frameShift(base *DrawioCell) DrawioBox { + parent := m.nodes[base.Parent] + if parent == nil { + return DrawioBox{} + } + if !parent.Geometry.placed() { + m.pin(base.Parent) + return DrawioBox{} + } + shift := m.w.childShift(parent, m.w.boxes[base.Parent]) + return DrawioBox{X: shift.X, Y: shift.Y} +} + +// pin places a container the model does not place at its box on the page, and +// its placed children at theirs, so that a child moved inside it lands where +// the file has it: the Layouts turn absolute, as the notation reads them. +func (m *drawioMatcher) pin(id string) { + if m.framed[id] { + return + } + m.framed[id] = true + container, base := m.nodes[id], m.base.Cell(id) + if container == nil || base == nil { + return + } + cell := m.file.Cell(id) + if cell == nil { + cell = base + } + m.framePlace(cell, base, container, false) + for i := range m.base.Cells { + child := &m.base.Cells[i] + node := m.nodes[child.ID] + if child.Parent != id || !child.Vertex || child.Relative || node == nil || !node.Geometry.placed() || m.framed[child.ID] { + continue + } + m.framed[child.ID] = true + edited := m.file.Cell(child.ID) + if edited == nil { + edited = child + } + m.framePlace(edited, child, node, node.Geometry.HasSize) + } +} + +// framePlace writes a Layout putting node at its page box, as the file has it. +func (m *drawioMatcher) framePlace(cell, base *DrawioCell, node *Node, sized bool) { + box := fullBox(cell) + layout := &semantics.Layout{X: box.X - m.frameShift(base).X, Y: box.Y - m.frameShift(base).Y, Collapsed: cell.Collapsed} + if sized { + layout.Width, layout.Height, layout.HasSize = box.Width, box.Height, true + } + if node.Geometry != nil { + layout.Hidden = node.Geometry.Hidden + } + origin, _ := m.originOf(base.ID) + m.out.Edits = append(m.out.Edits, DrawioEdit{Kind: DrawioEditLayout, Cell: cell.ID, Label: cell.Label, FQN: cell.FQN, Origin: origin, Layout: layout}) +} + // diffEdge imports an edge's moved waypoints and changed colours or font. func (m *drawioMatcher) diffEdge(cell, base *DrawioCell, key DrawioEdgeKey) { i, err := strconv.Atoi(strings.TrimPrefix(base.ID, "e")) diff --git a/internal/ir/view/drawio_import_match_test.go b/internal/ir/view/drawio_import_match_test.go index 47e96cc2f8..5cf3df08a1 100644 --- a/internal/ir/view/drawio_import_match_test.go +++ b/internal/ir/view/drawio_import_match_test.go @@ -71,7 +71,7 @@ func TestDrawioImportReorderedDuplicatesKeepTheirIdentity(t *testing.T) { } moved := mutateCell(t, reordered, "n7", func(cell string) string { return setGeometryAttr(t, cell, "x", "900") }) imp := drawioGoldenImport(t, c, moved) - edit := onlyEdit(t, imp, DrawioEditLayout) + edit := editOf(t, imp, DrawioEditLayout, "n7") rendering, _ := renderWithSites(t, c.file, c.view) var n7 *Node for _, node := range drawioNodes(rendering.Roots) { @@ -104,7 +104,7 @@ func TestDrawioImportMovedStartIsALayoutAboutTheMember(t *testing.T) { t.Fatal(err) } imp := drawioGoldenImport(t, c, moved) - edit := onlyEdit(t, imp, DrawioEditLayout) + edit := editOf(t, imp, DrawioEditLayout, "n5") if edit.Cell != "n5" || edit.FQN != start || edit.Label != "start" || edit.Origin.Located() || edit.Layout.Y != file.Cell("n5").Geometry.Y { t.Errorf("edit = %+v, want the start by its member name at the absolute y %v", edit, file.Cell("n5").Geometry.Y) } diff --git a/internal/ir/view/drawio_import_test.go b/internal/ir/view/drawio_import_test.go index 3775b1b9f1..cc7f838b85 100644 --- a/internal/ir/view/drawio_import_test.go +++ b/internal/ir/view/drawio_import_test.go @@ -106,6 +106,29 @@ func setGeometryAttr(t *testing.T, cell, name, value string) string { return cell[:loc[0]] + geometry + cell[loc[1]:] } +// editOf is the one edit of kind on the cell; a container the model does not +// place is pinned beside a moved child, so other Layouts may accompany it. +func editOf(t *testing.T, imp *DrawioImport, kind DrawioEditKind, cell string) DrawioEdit { + t.Helper() + var found *DrawioEdit + for i := range imp.Edits { + e := &imp.Edits[i] + if e.Kind != kind { + t.Fatalf("edit %+v, want only %s edits", *e, kind) + } + if e.Cell == cell { + if found != nil { + t.Fatalf("two %s edits of %s", kind, cell) + } + found = e + } + } + if found == nil { + t.Fatalf("edits = %+v, want a %s of %s", imp.Edits, kind, cell) + } + return *found +} + func onlyEdit(t *testing.T, imp *DrawioImport, kind DrawioEditKind) DrawioEdit { t.Helper() if len(imp.Edits) != 1 { @@ -122,7 +145,7 @@ func TestDrawioImportMovedNodeIsALayout(t *testing.T) { document := mutateCell(t, string(golden), "n2", func(cell string) string { return setGeometryAttr(t, setGeometryAttr(t, cell, "x", "533"), "y", "71.5") }) - edit := onlyEdit(t, drawioGoldenImport(t, layoutCase, document), DrawioEditLayout) + edit := editOf(t, drawioGoldenImport(t, layoutCase, document), DrawioEditLayout, "n2") // The tank sits in the loop drawn at (40, 160); a Layout states absolute coordinates. if edit.FQN != "Plant::Loop::tank" || edit.Layout.X != 573 || edit.Layout.Y != 231.5 || edit.Layout.Collapsed { t.Errorf("layout = %s %+v", edit.FQN, edit.Layout) @@ -152,7 +175,7 @@ func TestDrawioImportResizedNodeKeepsItsSize(t *testing.T) { document := mutateCell(t, string(golden), "n2", func(cell string) string { return setGeometryAttr(t, cell, "width", "260") }) - edit := onlyEdit(t, drawioGoldenImport(t, layoutCase, document), DrawioEditLayout) + edit := editOf(t, drawioGoldenImport(t, layoutCase, document), DrawioEditLayout, "n2") if !edit.Layout.HasSize || edit.Layout.Width != 260 || edit.Layout.Height == 0 { t.Errorf("layout = %+v, want width 260 with the height kept", edit.Layout) } diff --git a/internal/ir/view/drawio_read_test.go b/internal/ir/view/drawio_read_test.go index 406dfd24c2..bd09f17f5b 100644 --- a/internal/ir/view/drawio_read_test.go +++ b/internal/ir/view/drawio_read_test.go @@ -39,7 +39,7 @@ func TestReadDrawioCompressedDesktopSave(t *testing.T) { if err != nil { t.Fatal(err) } - edit := onlyEdit(t, imp, DrawioEditLayout) + edit := editOf(t, imp, DrawioEditLayout, "n2") if edit.FQN != "Plant::Loop::tank" || edit.Layout.X <= 256 || edit.Layout.Y != 218 { t.Errorf("layout = %s %+v, want the tank moved right from (256, 218)", edit.FQN, edit.Layout) } diff --git a/internal/semantic/semantics/layout.go b/internal/semantic/semantics/layout.go index b209e67615..1a6cd0dff9 100644 --- a/internal/semantic/semantics/layout.go +++ b/internal/semantic/semantics/layout.go @@ -295,6 +295,23 @@ func (m *Model) annotationTypeFQN(sym *symbols.Symbol) string { return m.fqnOf(a.typ) } +// LayoutSiteDeclaredBy is the DiagramLayout annotation sym declares — a metadata +// usage, inline or with an `about` clause — as the site of the element it +// annotates, with that element; nil for any other symbol. +func (m *Model) LayoutSiteDeclaredBy(sym *symbols.Symbol) (*LayoutSite, *symbols.Symbol) { + if !m.IsLayoutAnnotation(sym) { + return nil, nil + } + for _, elem := range m.annotatedElementsOf(sym) { + for _, site := range m.LayoutSitesOf(elem) { + if site.Node == sym.Decl { + return site, elem + } + } + } + return nil, nil +} + // LayoutOf resolves the Layout of elem as drawn in view: the first Layout // annotation stated in the view's body, else the first one applying in every // view. With no view — a rendering of elements outside any view — only the From f8bd8a3d46bde161701bb8e2639a2ab223dc7cbb Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 05:14:44 +0000 Subject: [PATCH 05/25] fix(semantics): keep no annotations read while the about index is being built A view exposing through a filter reads each candidate's annotations while the about usages are still being indexed, and cached what it read: a Layout stated about such an element in the view's body was never found. Answers given during the build are no longer kept. The draw.io import pairs every cell before diffing any, so pinning a container reads a child's edited cell whatever id the file gives it, and a pinned container keeps the size the file shows. Co-Authored-By: jason.han --- internal/check/edit/layout_test.go | 39 ++++++++++++++ internal/ir/view/drawio_import_diff.go | 59 ++++++++++++++-------- internal/semantic/semantics/annotations.go | 9 ++++ internal/semantic/semantics/layout.go | 3 ++ internal/semantic/semantics/model.go | 2 + 5 files changed, 92 insertions(+), 20 deletions(-) diff --git a/internal/check/edit/layout_test.go b/internal/check/edit/layout_test.go index bd9c7c0a12..de24f3d254 100644 --- a/internal/check/edit/layout_test.go +++ b/internal/check/edit/layout_test.go @@ -4,6 +4,7 @@ import ( "strings" "testing" + "github.com/Open-MBEE/OpenSysML/internal/ir/view" "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" "github.com/Open-MBEE/OpenSysML/internal/syntax/source" @@ -943,3 +944,41 @@ func TestSetLayoutAboutAnInheritedStart(t *testing.T) { } } } + +// A view exposing through a filter reads each candidate's annotations while +// the `about` index is being built; the Layout it states about one of them +// must still place it. +func TestSetLayoutOnAnElementExposedThroughAFilterIsDrawn(t *testing.T) { + const filtered = `package Plant { + part def Pump; + part def Tank; +} +package PlantViews { + private import Views::*; + private import StandardViewDefinitions::*; + view defs : GeneralView { + expose Plant::**[@SysML::Definition]; + } +} +` + got := applyLayout(t, filtered, SetLayout("Plant::Tank", "PlantViews::defs", at(117, 40))) + m := loadContent(t, "plant.sysml", got) + r, sem := m.resolver() + viewSym, err := m.viewNamed(0, "PlantViews::defs", "", "no view") + if err != nil { + t.Fatal(err) + } + rendering, err := view.NewRenderer(sem, r, nil).Render(viewSym) + if err != nil { + t.Fatal(err) + } + for _, node := range rendering.Roots { + if node.Name == "Plant::Tank" { + if node.Geometry == nil || node.Geometry.X != 117 || node.Geometry.Y != 40 { + t.Fatalf("tank geometry = %+v, want the Layout stated in the view", node.Geometry) + } + return + } + } + t.Fatalf("no Plant::Tank among %d roots", len(rendering.Roots)) +} diff --git a/internal/ir/view/drawio_import_diff.go b/internal/ir/view/drawio_import_diff.go index e8572e0145..07fae3d9ec 100644 --- a/internal/ir/view/drawio_import_diff.go +++ b/internal/ir/view/drawio_import_diff.go @@ -132,7 +132,7 @@ func (r *Rendering) ImportDrawio(file *DrawioFile, options Options) (*DrawioImpo return nil, err } out := &DrawioImport{View: norm.View} - m := &drawioMatcher{norm: norm, w: w, base: base, file: file, out: out, nodes: map[string]*Node{}, ports: map[string]Port{}, framed: map[string]bool{}} + m := &drawioMatcher{norm: norm, w: w, base: base, file: file, out: out, nodes: map[string]*Node{}, ports: map[string]Port{}, framed: map[string]bool{}, paired: map[string]*DrawioCell{}} for _, node := range drawioNodes(norm.Roots) { m.nodes[node.ID] = node for _, port := range node.Ports { @@ -164,8 +164,10 @@ type drawioMatcher struct { nodes map[string]*Node ports map[string]Port matched map[string]bool - // framed are the cells whose Layout pinning a container already wrote. + // framed are the cells whose Layout pinning a container already wrote; + // paired is the file's cell for each baseline cell matched so far. framed map[string]bool + paired map[string]*DrawioCell } // match pairs the file's semantic cells with the baseline's — by the exported @@ -188,15 +190,22 @@ func (m *drawioMatcher) match() { } } } - var pending []*DrawioCell + var pending, plain []*DrawioCell + type pairing struct{ cell, base *DrawioCell } + var pairs []pairing + pair := func(cell, base *DrawioCell) { + m.matched[base.ID] = true + m.paired[base.ID] = cell + pairs = append(pairs, pairing{cell, base}) + } for i := range m.file.Cells { cell := &m.file.Cells[i] if cell.Kind == "" { - m.plainCell(cell) + plain = append(plain, cell) continue } if base := m.base.Cell(cell.ID); base != nil && sameDrawioElement(cell, base, fileKeys, baseKeys) { - m.pair(cell, base, baseKeys) + pair(cell, base) continue } pending = append(pending, cell) @@ -205,7 +214,7 @@ func (m *drawioMatcher) match() { switch { case cell.Vertex && cell.FQN != "": if base := m.firstUnmatched(byFQN[cell.FQN]); base != nil { - m.pair(cell, base, baseKeys) + pair(cell, base) } else { m.out.Unmatched = append(m.out.Unmatched, cell.FQN) } @@ -218,12 +227,20 @@ func (m *drawioMatcher) match() { continue } if base := m.firstUnmatched(byKey[key.unordered()]); base != nil { - m.pair(cell, base, baseKeys) + pair(cell, base) } else { m.out.Unmatched = append(m.out.Unmatched, key.String()) } } } + // Every pair is known before any is diffed, so pinning a container reads + // each child's edited cell whatever id the file gives it. + for _, p := range pairs { + m.diff(p.cell, p.base, baseKeys) + } + for _, cell := range plain { + m.plainCell(cell) + } m.deleted() } @@ -253,9 +270,8 @@ func (m *drawioMatcher) firstUnmatched(cells []*DrawioCell) *DrawioCell { return nil } -// pair diffs the file's cell against the baseline's it stands for. -func (m *drawioMatcher) pair(cell, base *DrawioCell, baseKeys map[string]DrawioEdgeKey) { - m.matched[base.ID] = true +// diff compares the file's cell with the baseline's it stands for. +func (m *drawioMatcher) diff(cell, base *DrawioCell, baseKeys map[string]DrawioEdgeKey) { if cell.Vertex { m.diffVertex(cell, base) return @@ -410,11 +426,9 @@ func (m *drawioMatcher) pin(id string) { if container == nil || base == nil { return } - cell := m.file.Cell(id) - if cell == nil { - cell = base - } - m.framePlace(cell, base, container, false) + // Placed, the container is no longer fitted around children drawn in its + // frame, so it keeps the size the file shows. + m.framePlace(m.editedOf(base), base, container, true) for i := range m.base.Cells { child := &m.base.Cells[i] node := m.nodes[child.ID] @@ -422,12 +436,17 @@ func (m *drawioMatcher) pin(id string) { continue } m.framed[child.ID] = true - edited := m.file.Cell(child.ID) - if edited == nil { - edited = child - } - m.framePlace(edited, child, node, node.Geometry.HasSize) + m.framePlace(m.editedOf(child), child, node, node.Geometry.HasSize) + } +} + +// editedOf is the file's cell paired with a baseline cell, the baseline's own +// when the file dropped it. +func (m *drawioMatcher) editedOf(base *DrawioCell) *DrawioCell { + if cell := m.paired[base.ID]; cell != nil { + return cell } + return base } // framePlace writes a Layout putting node at its page box, as the file has it. diff --git a/internal/semantic/semantics/annotations.go b/internal/semantic/semantics/annotations.go index a62e3bfee0..c3f2f85c15 100644 --- a/internal/semantic/semantics/annotations.go +++ b/internal/semantic/semantics/annotations.go @@ -73,6 +73,13 @@ func (m *Model) annotationsOf(sym *symbols.Symbol) []annotation { out = append(out, m.declaredAnnotations(sym)...) out = append(out, m.aboutAnnotations(sym)...) } + // Asked while the `about` index is still being built — a view's filter + // evaluating a candidate as an `about` target resolves — the answer lacks + // what the index has not reached yet, so it is given but not kept. + if m.aboutBuilding { + delete(m.annotations, sym) + return out + } m.annotations[sym] = out return out } @@ -634,6 +641,8 @@ func (m *Model) annotationsAbout() map[*symbols.Symbol][]annotation { // buildAbout indexes the `about` metadata usages of every gathered document. func (m *Model) buildAbout() { + m.aboutBuilding = true + defer func() { m.aboutBuilding = false }() m.aboutAnnots = make(map[*symbols.Symbol][]annotation) m.aboutByDecl = make(map[ast.Node][]annotation) gathers := m.gathers() diff --git a/internal/semantic/semantics/layout.go b/internal/semantic/semantics/layout.go index 1a6cd0dff9..d02990441d 100644 --- a/internal/semantic/semantics/layout.go +++ b/internal/semantic/semantics/layout.go @@ -261,6 +261,9 @@ func (m *Model) LayoutSitesOf(sym *symbols.Symbol) []*LayoutSite { } out = append(out, site) } + if m.aboutBuilding { + return out + } journal(m, m.layoutSites, sym, sym.Decl) m.layoutSites[sym] = out return out diff --git a/internal/semantic/semantics/model.go b/internal/semantic/semantics/model.go index f3576cabd6..b63a39128f 100644 --- a/internal/semantic/semantics/model.go +++ b/internal/semantic/semantics/model.go @@ -111,6 +111,8 @@ type Model struct { aboutByDecl map[ast.Node][]annotation // aboutOrder lists aboutAnnots' targets in first-annotation order. aboutOrder []*symbols.Symbol + // aboutBuilding is set while buildAbout runs, when the index is partial. + aboutBuilding bool // aboutShared is the about index shared with other models, nil when m builds its own. aboutShared *AboutIndex // docGathers holds what the workspace-wide indexes are built from, per From 02681cdab2fadf06e87a5897625900ff00a65d4e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 05:17:54 +0000 Subject: [PATCH 06/25] fix(view): emit draw.io import edits in the file's cell order Co-Authored-By: jason.han --- internal/ir/view/drawio_import_diff.go | 23 +++++++++++----------- internal/semantic/semantics/annotations.go | 5 ++--- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/internal/ir/view/drawio_import_diff.go b/internal/ir/view/drawio_import_diff.go index 07fae3d9ec..11af816d52 100644 --- a/internal/ir/view/drawio_import_diff.go +++ b/internal/ir/view/drawio_import_diff.go @@ -190,18 +190,16 @@ func (m *drawioMatcher) match() { } } } - var pending, plain []*DrawioCell - type pairing struct{ cell, base *DrawioCell } - var pairs []pairing + var pending []*DrawioCell + baseOf := map[*DrawioCell]*DrawioCell{} pair := func(cell, base *DrawioCell) { m.matched[base.ID] = true m.paired[base.ID] = cell - pairs = append(pairs, pairing{cell, base}) + baseOf[cell] = base } for i := range m.file.Cells { cell := &m.file.Cells[i] if cell.Kind == "" { - plain = append(plain, cell) continue } if base := m.base.Cell(cell.ID); base != nil && sameDrawioElement(cell, base, fileKeys, baseKeys) { @@ -234,12 +232,15 @@ func (m *drawioMatcher) match() { } } // Every pair is known before any is diffed, so pinning a container reads - // each child's edited cell whatever id the file gives it. - for _, p := range pairs { - m.diff(p.cell, p.base, baseKeys) - } - for _, cell := range plain { - m.plainCell(cell) + // each child's edited cell whatever id the file gives it; the edits come + // in the file's order. + for i := range m.file.Cells { + cell := &m.file.Cells[i] + if cell.Kind == "" { + m.plainCell(cell) + } else if base := baseOf[cell]; base != nil { + m.diff(cell, base, baseKeys) + } } m.deleted() } diff --git a/internal/semantic/semantics/annotations.go b/internal/semantic/semantics/annotations.go index c3f2f85c15..6f1e157c61 100644 --- a/internal/semantic/semantics/annotations.go +++ b/internal/semantic/semantics/annotations.go @@ -73,9 +73,8 @@ func (m *Model) annotationsOf(sym *symbols.Symbol) []annotation { out = append(out, m.declaredAnnotations(sym)...) out = append(out, m.aboutAnnotations(sym)...) } - // Asked while the `about` index is still being built — a view's filter - // evaluating a candidate as an `about` target resolves — the answer lacks - // what the index has not reached yet, so it is given but not kept. + // Asked while the `about` index is still being built (a view's filter + // resolving), the answer is partial: given, not kept. if m.aboutBuilding { delete(m.annotations, sym) return out From f267c909a4526798acff812eed30f9d6c8c686cc Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 06:06:50 +0000 Subject: [PATCH 07/25] fix(semantics): keep no filter verdict or import enumeration read while the about index is being built A view exposing through a filter on an annotation evaluates that filter while the about index is still being built, resolving an unqualified name its own about usage states through the exposure; the verdict reached then is partial. The filter cache and the resolver's kept enumerations both skip what is read while Model.AboutBuilding reports the index incomplete. Co-Authored-By: jason.han --- internal/semantic/resolve/filter.go | 4 +- internal/semantic/resolve/resolver.go | 13 +++++++ internal/semantic/semantics/annotations.go | 4 ++ internal/semantic/semantics/filter.go | 5 ++- .../semantic/semantics/filter_about_test.go | 39 +++++++++++++++++++ 5 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 internal/semantic/semantics/filter_about_test.go diff --git a/internal/semantic/resolve/filter.go b/internal/semantic/resolve/filter.go index 95c70743aa..7fc0692099 100644 --- a/internal/semantic/resolve/filter.go +++ b/internal/semantic/resolve/filter.go @@ -367,7 +367,7 @@ func (r *Resolver) importedMembersAdmitting(into, scope *symbols.Scope, imp *ast reads := r.capturing(func() { members = r.enumerateImported(scope, imp, includeUnnamed, admission) }) - if r.enumCut == cut && len(r.resolvingImports) == 0 && r.inCondition == 0 { + if r.enumCut == cut && len(r.resolvingImports) == 0 && r.inCondition == 0 && r.settled() { journalNew(r, r.enumResults, key, imp) r.enumResults[key] = keptMembers{members: members, reads: reads} } @@ -485,7 +485,7 @@ func (r *Resolver) declaredChildren(scope *symbols.Scope, target *symbols.Symbol }) delete(r.importStack, childImp) r.enumMembers[edge] = members - if r.enumCut == cut && len(r.resolvingImports) == 0 && r.inCondition == 0 { + if r.enumCut == cut && len(r.resolvingImports) == 0 && r.inCondition == 0 && r.settled() { journalNew(r, r.enumKept, edge, childImp) r.enumKept[edge] = keptMembers{members: members, reads: reads} } diff --git a/internal/semantic/resolve/resolver.go b/internal/semantic/resolve/resolver.go index ebbb028712..471003ae35 100644 --- a/internal/semantic/resolve/resolver.go +++ b/internal/semantic/resolve/resolver.go @@ -537,6 +537,19 @@ func (r *Resolver) lookupContributedMember(sym *symbols.Symbol, name string, hid // SetModel attaches a semantic model for inheritance-aware member resolution. // Must be called before resolving feature chains if inherited members are needed. +// settledProvider is a model that knows when the answers it gives — the +// element filters it evaluates — are partial, its `about` index being built. +type settledProvider interface { + AboutBuilding() bool +} + +// settled reports whether an enumeration read now may be kept: not while the +// model's answers to its filters are partial. +func (r *Resolver) settled() bool { + model, ok := r.model.(settledProvider) + return !ok || !model.AboutBuilding() +} + func (r *Resolver) SetModel(model MemberLookup) { r.model = model clear(r.enumKept) diff --git a/internal/semantic/semantics/annotations.go b/internal/semantic/semantics/annotations.go index 6f1e157c61..f465b4f938 100644 --- a/internal/semantic/semantics/annotations.go +++ b/internal/semantic/semantics/annotations.go @@ -638,6 +638,10 @@ func (m *Model) annotationsAbout() map[*symbols.Symbol][]annotation { return m.aboutAnnots } +// AboutBuilding reports whether the `about` index is being built, when what the +// model answers about annotations — and the filters reading them — is partial. +func (m *Model) AboutBuilding() bool { return m.aboutBuilding } + // buildAbout indexes the `about` metadata usages of every gathered document. func (m *Model) buildAbout() { m.aboutBuilding = true diff --git a/internal/semantic/semantics/filter.go b/internal/semantic/semantics/filter.go index 0b07ba9bd9..a5232285ea 100644 --- a/internal/semantic/semantics/filter.go +++ b/internal/semantic/semantics/filter.go @@ -116,8 +116,9 @@ func (m *Model) EvalElementFilter(f symbols.ElementFilter, cand *symbols.Symbol) // Only a decided verdict is remembered. A condition can be unevaluable // because the element it names is not indexed yet — an import is expanded // while the index is still filling — and re-deciding it later is what keeps - // the answer from depending on when it was first asked. - if err == nil { + // the answer from depending on when it was first asked; nor is one decided + // over the partial `about` index while it is being built. + if err == nil && !m.aboutBuilding { journal(m, m.filterVerdicts, key, cand.Decl) m.filterVerdicts[key] = verdict } diff --git a/internal/semantic/semantics/filter_about_test.go b/internal/semantic/semantics/filter_about_test.go new file mode 100644 index 0000000000..ef47366d08 --- /dev/null +++ b/internal/semantic/semantics/filter_about_test.go @@ -0,0 +1,39 @@ +package semantics + +import "testing" + +// A view exposing through a filter on an annotation evaluates that filter +// while the `about` index is still being built — resolving the unqualified +// name one of its `about` usages states looks through the exposure — so the +// verdict on an element annotated only by a later `about` usage is partial and +// must not be kept: once the index is complete, that element is exposed too. +func TestFilterVerdictReachedWhileTheAboutIndexBuildsIsNotKept(t *testing.T) { + m, _, user := buildOverlayModel(t, ` + metadata def Tag; + `, ` + package P { + part def A { @Tag; } + part def B; + } + package V { + view v { + expose P::*[@Tag]; + metadata u : Tag about A; + metadata t : Tag about P::B; + } + } + `) + p := sym(t, user, "P").Scope + a, b := sym(t, p, "A"), sym(t, p, "B") + v := sym(t, sym(t, user, "V").Scope, "v") + if types := annotationTypes(m, b); len(types) != 1 || types[0] != "Tag" { + t.Fatalf("annotations of B = %v, want [Tag]", types) + } + exposed, err := m.ExposedElements(v) + if err != nil { + t.Fatal(err) + } + if len(exposed) != 2 || exposed[0] != a || exposed[1] != b { + t.Fatalf("ExposedElements(v) = %v, want [A B]", exposed) + } +} From 05670eb90d062c0e2bae723bc87630b61a973056 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 06:06:51 +0000 Subject: [PATCH 08/25] fix(view): round trip the imported draw.io edits through the model and place inherited members per inheritor The round-trip test applies every imported edit through the model-edit path, re-renders and compares the changed cells by identity, which exposed: the matcher judged whether a Route steers an edge from a clone that carries no record of what was drawn; a member drawn under a node of another element (a part of the usage's type, an inherited state) was placed and coloured by the first Layout about its declaration rather than the one naming it as that node's, and its import named the declaration rather than the path a Layout resolves through; a Layout about the declaring body's start grew a start in every body inheriting it; and the edit refused a Layout about a member drawn under an exposed usage as not exposed. The unused drawioMatch type is gone. Co-Authored-By: jason.han --- internal/check/edit/drawio_test.go | 371 +++++++++++++++++++ internal/check/edit/layout.go | 33 +- internal/ir/view/behavior.go | 7 + internal/ir/view/drawio_import_diff.go | 38 +- internal/ir/view/drawio_import_match_test.go | 5 +- internal/ir/view/geometry.go | 38 ++ internal/ir/view/inherited_members_test.go | 109 ++++++ internal/ir/view/interconnection.go | 12 +- internal/ir/view/mixed.go | 2 +- internal/ir/view/place.go | 15 +- internal/ir/view/tree.go | 16 +- internal/ir/view/view.go | 5 +- 12 files changed, 604 insertions(+), 47 deletions(-) create mode 100644 internal/check/edit/drawio_test.go create mode 100644 internal/ir/view/inherited_members_test.go diff --git a/internal/check/edit/drawio_test.go b/internal/check/edit/drawio_test.go new file mode 100644 index 0000000000..41adb36890 --- /dev/null +++ b/internal/check/edit/drawio_test.go @@ -0,0 +1,371 @@ +package edit + +import ( + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/ir/view" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" +) + +const viewFixtures = "../../ir/view/testdata" + +// drawioCase is one draw.io golden of the view package: the model it was +// rendered from and the view, read off the golden's own attributes. +type drawioCase struct{ name, file, view string } + +func drawioCases(t *testing.T) []drawioCase { + t.Helper() + goldens, err := filepath.Glob(filepath.Join(viewFixtures, "*.drawio.golden")) + if err != nil || len(goldens) == 0 { + t.Fatalf("goldens: %v %v", goldens, err) + } + var cases []drawioCase + for _, g := range goldens { + data, err := os.ReadFile(g) + if err != nil { + t.Fatal(err) + } + file := regexp.MustCompile(`link="([^":]+):\d+"`).FindStringSubmatch(string(data)) + viewName := regexp.MustCompile(` view="([^"]+)"`).FindStringSubmatch(string(data)) + if file == nil || viewName == nil { + continue + } + cases = append(cases, drawioCase{strings.TrimSuffix(filepath.Base(g), ".drawio.golden"), file[1], viewName[1]}) + } + return cases +} + +// drawioModel loads a view fixture as an editable model and renders view, with +// the export options the CLI and the language server use: the sites naming +// each cell's declaration. +func drawioModel(t *testing.T, content, viewName string) (Model, *view.Rendering, view.Options) { + t.Helper() + m := loadContent(t, "model.sysml", content) + r, sem := m.resolver() + viewSym, err := m.viewNamed(0, viewName, "", viewName+" is no view") + if err != nil { + t.Fatalf("view %s: %v", viewName, err) + } + renderer := view.NewRenderer(sem, r, nil) + rendering, err := renderer.Render(viewSym) + if err != nil { + t.Fatalf("render %s: %v", viewName, err) + } + lines := func(string) *source.LineIndex { return m.Source.Lines() } + return m, rendering, view.Options{Links: view.Links{Sites: renderer.Sites(view.FileLocator(sem, lines))}} +} + +// drawioExport writes the rendering as the drawio form does, read back as cells. +func drawioExport(t *testing.T, rendering *view.Rendering, options view.Options) (string, *view.DrawioFile) { + t.Helper() + document, err := rendering.DrawioWith(options) + if err != nil { + t.Fatal(err) + } + file, err := view.ReadDrawio([]byte(document), rendering.View) + if err != nil { + t.Fatal(err) + } + return document, file +} + +// drawioMutation edits one cell of the exported document. +type drawioMutation struct { + name string + // pick is the cell to edit, nil when the fixture offers none; try imports + // the mutation of a cell, for a pick that depends on what the import makes of it. + pick func(file *view.DrawioFile, try func(*view.DrawioCell) *view.DrawioImport) *view.DrawioCell + edit func(cell string) string + // kind is the edit expected, and check the cell of the re-export against the + // edited one; a notice instead names what the import reports and leaves alone. + kind view.DrawioEditKind + notice string + check func(t *testing.T, edited, reexported *view.DrawioCell) +} + +func drawioFirst(file *view.DrawioFile, want func(c *view.DrawioCell) bool) *view.DrawioCell { + for i := range file.Cells { + if want(&file.Cells[i]) { + return &file.Cells[i] + } + } + return nil +} + +// A named vertex a Layout can place: not a port, which is drawn on its owner's border. +func drawioPlaceable(c *view.DrawioCell) bool { + return c.Vertex && c.FQN != "" && c.Kind != "port" && c.HasGeometry && !c.Collapsed +} + +// drawioLeaf picks the first placeable vertex containing no other, so its move +// is its own alone; drawioContainer the first one with children, whose move is +// theirs too, a Layout being absolute. +func drawioLeaf(f *view.DrawioFile, _ func(*view.DrawioCell) *view.DrawioImport) *view.DrawioCell { + return drawioFirst(f, func(c *view.DrawioCell) bool { return drawioPlaceable(c) && !drawioHasChildren(f, c) }) +} + +func drawioContainer(f *view.DrawioFile, _ func(*view.DrawioCell) *view.DrawioImport) *view.DrawioCell { + return drawioFirst(f, func(c *view.DrawioCell) bool { return drawioPlaceable(c) && drawioHasChildren(f, c) }) +} + +func drawioHasChildren(f *view.DrawioFile, c *view.DrawioCell) bool { + return drawioFirst(f, func(d *view.DrawioCell) bool { return d.Parent == c.ID && d.Vertex && !d.Relative }) != nil +} + +func drawioSameBox(a, b view.DrawioBox) bool { + near := func(x, y float64) bool { return x-y < view.DrawioTolerance && y-x < view.DrawioTolerance } + return near(a.X, b.X) && near(a.Y, b.Y) && near(a.Width, b.Width) && near(a.Height, b.Height) +} + +// drawioEdge picks the first semantic edge cell whose re-routing the import +// takes as a Route, or the first it reports instead: the writer's edge ids are +// an order over the edges it draws, so which is which is the import's to say. +func drawioEdge(steered bool) func(f *view.DrawioFile, try func(*view.DrawioCell) *view.DrawioImport) *view.DrawioCell { + return func(f *view.DrawioFile, try func(*view.DrawioCell) *view.DrawioImport) *view.DrawioCell { + return drawioFirst(f, func(c *view.DrawioCell) bool { + if !c.Edge || c.Kind == "" || c.Kind == "note" { + return false + } + imp := try(c) + return (len(imp.Edits) == 1 && imp.Edits[0].Kind == view.DrawioEditRoute) == steered + }) + } +} + +// drawioSameCell is the cell of the re-export standing for cell of the edited +// file, ids being an order renumbered by every change: a vertex of its kind and +// name under the same parent; an edge of its kind between the same ends or, once +// a routed member folds into a row and its edge reaches the member's definition, +// declared by the same line of source, by rank among those. +func drawioSameCell(edited *view.DrawioFile, content string, cell *view.DrawioCell, re *view.DrawioFile, recontent string) *view.DrawioCell { + fqnOf := func(f *view.DrawioFile, id string) string { + if c := f.Cell(id); c != nil { + return c.FQN + } + return "" + } + if !cell.Edge { + return drawioFirst(re, func(c *view.DrawioCell) bool { + return !c.Edge && c.Kind == cell.Kind && c.FQN == cell.FQN && fqnOf(re, c.Parent) == fqnOf(edited, cell.Parent) + }) + } + declared := drawioLinkLine(content, cell.Link) + same := func(f *view.DrawioFile, c *view.DrawioCell, source string) bool { + if !c.Edge || c.Kind != cell.Kind { + return false + } + ends := fqnOf(f, c.Source) == fqnOf(edited, cell.Source) && fqnOf(f, c.Target) == fqnOf(edited, cell.Target) + return ends || drawioLinkLine(source, c.Link) == declared + } + rank := 0 + for i := range edited.Cells { + if c := &edited.Cells[i]; c == cell { + break + } else if same(edited, c, content) { + rank++ + } + } + return drawioFirst(re, func(c *view.DrawioCell) bool { + if !same(re, c, recontent) { + return false + } + rank-- + return rank < 0 + }) +} + +// drawioLinkLine is the declaration a cell's link points at: its line of +// source without the `;` or `{` ending it, which an inline edit rewrites. +func drawioLinkLine(content, link string) string { + _, number, ok := strings.Cut(link, ":") + n, err := strconv.Atoi(number) + lines := strings.Split(content, "\n") + if !ok || err != nil || n < 1 || n > len(lines) { + return link + } + return strings.TrimSpace(strings.TrimRight(strings.TrimSpace(lines[n-1]), ";{")) +} + +// drawioReroute bends the edge through two waypoints of its own. +func drawioReroute(cell string) string { + const points = `` + if re := regexp.MustCompile(`(?s).*?`); re.MatchString(cell) { + return re.ReplaceAllString(cell, points) + } + return strings.Replace(cell, `as="geometry"/>`, `as="geometry">`+points+``, 1) +} + +var drawioMutations = []drawioMutation{ + {name: "move", pick: drawioLeaf, + edit: func(cell string) string { return drawioShift(cell, "x", 77) }, kind: view.DrawioEditLayout, + check: func(t *testing.T, edited, re *view.DrawioCell) { + if !drawioSameBox(edited.Geometry, re.Geometry) { + t.Errorf("moved to %+v, re-exported at %+v", edited.Geometry, re.Geometry) + } + }}, + {name: "move container", pick: drawioContainer, + edit: func(cell string) string { return drawioShift(cell, "y", 60) }, kind: view.DrawioEditLayout, + check: func(t *testing.T, edited, re *view.DrawioCell) { + if !drawioSameBox(edited.Geometry, re.Geometry) { + t.Errorf("%s moved to %+v, re-exported at %+v", edited.ID, edited.Geometry, re.Geometry) + } + }}, + {name: "resize", pick: drawioLeaf, + edit: func(cell string) string { return drawioShift(cell, "width", 50) }, kind: view.DrawioEditLayout, + check: func(t *testing.T, edited, re *view.DrawioCell) { + if !drawioSameBox(edited.Geometry, re.Geometry) { + t.Errorf("resized to %+v, re-exported at %+v", edited.Geometry, re.Geometry) + } + }}, + {name: "route", pick: drawioEdge(true), edit: drawioReroute, kind: view.DrawioEditRoute, + check: func(t *testing.T, edited, re *view.DrawioCell) { + if len(re.Points) != 2 || re.Points[0] != edited.Points[0] || re.Points[1] != edited.Points[1] { + t.Errorf("routed through %+v, re-exported through %+v", edited.Points, re.Points) + } + }}, + {name: "route without a Route home", pick: drawioEdge(false), edit: drawioReroute, + notice: "no Route steers"}, + {name: "recolour", pick: drawioLeaf, + edit: func(cell string) string { + if re := regexp.MustCompile(`fillColor=[^;"]*;?`); re.MatchString(cell) { + return re.ReplaceAllString(cell, "fillColor=#FFCC00;") + } + return strings.Replace(cell, `style="`, `style="fillColor=#FFCC00;`, 1) + }, kind: view.DrawioEditStyle, + check: func(t *testing.T, edited, re *view.DrawioCell) { + if re.Style["fillColor"] != "#FFCC00" { + t.Errorf("re-exported style = %s", re.StyleText) + } + }}, +} + +// drawioShift adds delta to one attribute of the cell's geometry. +func drawioShift(cell, attr string, delta float64) string { + m := regexp.MustCompile(`]*\b` + attr + `="([^"]*)"`).FindStringSubmatchIndex(cell) + if m == nil { + return strings.Replace(cell, " of the cell id in the document. +func drawioMutate(t *testing.T, document, id string, edit func(cell string) string) string { + t.Helper() + re := regexp.MustCompile(`(?s)]*>\s*()\s*||/>)`) + m := re.FindStringSubmatch(document) + if m == nil { + t.Fatalf("no cell %s", id) + } + whole, inner := m[0], m[1] + if inner == "" { + inner = whole + } + edited := strings.Replace(whole, inner, edit(inner), 1) + if edited == whole { + t.Fatalf("mutation of %s changed nothing", id) + } + return strings.Replace(document, whole, edited, 1) +} + +func drawioImport(t *testing.T, rendering *view.Rendering, options view.Options, document string) (*view.DrawioFile, *view.DrawioImport) { + t.Helper() + file, err := view.ReadDrawio([]byte(document), rendering.View) + if err != nil { + t.Fatal(err) + } + imp, err := rendering.ImportDrawio(file, options) + if err != nil { + t.Fatal(err) + } + return file, imp +} + +// TestDrawioRoundTrip is the import's contract over every draw.io golden: the +// untouched export writes nothing; each mutation imports as exactly its edit, +// applies through the model-edit path, and the re-export of the edited model +// draws the mutated cell as the file had it, after which the file imports clean. +func TestDrawioRoundTrip(t *testing.T) { + cases := drawioCases(t) + if len(cases) < 30 { + t.Fatalf("%d golden cases found, want the draw.io goldens", len(cases)) + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + content, err := os.ReadFile(filepath.Join(viewFixtures, c.file)) + if err != nil { + t.Fatal(err) + } + m, rendering, options := drawioModel(t, string(content), c.view) + document, exported := drawioExport(t, rendering, options) + if _, imp := drawioImport(t, rendering, options, document); len(imp.Edits)+len(imp.Unmatched)+len(imp.Deleted)+len(imp.Unknown)+len(imp.Notices) != 0 { + t.Fatalf("untouched export: %+v", imp) + } + try := func(mut drawioMutation) func(c *view.DrawioCell) *view.DrawioImport { + return func(c *view.DrawioCell) *view.DrawioImport { + _, imp := drawioImport(t, rendering, options, drawioMutate(t, document, c.ID, mut.edit)) + return imp + } + } + for _, mut := range drawioMutations { + cell := mut.pick(exported, try(mut)) + if cell == nil { + continue + } + t.Run(mut.name, func(t *testing.T) { + edited := drawioMutate(t, document, cell.ID, mut.edit) + editedFile, imp := drawioImport(t, rendering, options, edited) + if mut.notice != "" { + if len(imp.Edits) != 0 || len(imp.Notices) != 1 || !strings.Contains(imp.Notices[0], mut.notice) { + t.Fatalf("edits %+v notices %q, want none and one saying %q", imp.Edits, imp.Notices, mut.notice) + } + return + } + // A moved container moves what it contains, and a node moved in a + // container the model does not place pins that container and its + // placed siblings: a Layout mutation may import as several Layouts. + if len(imp.Edits) == 0 || len(imp.Edits) != 1 && mut.kind != view.DrawioEditLayout { + t.Fatalf("edits = %+v, want one %s of %s", imp.Edits, mut.kind, cell.ID) + } + picked := false + for _, e := range imp.Edits { + picked = picked || e.Cell == cell.ID + if e.Kind != mut.kind { + t.Fatalf("edit %+v, want a %s", e, mut.kind) + } + } + if !picked { + t.Fatalf("edits %+v, none of %s", imp.Edits, cell.ID) + } + res, err := Apply(m, DrawioOperations(imp, false)) + if err != nil { + t.Fatalf("apply %+v: %v", imp.Edits, err) + } + _, redrawn, reoptions := drawioModel(t, string(res.Content), c.view) + _, re := drawioExport(t, redrawn, reoptions) + for _, e := range imp.Edits { + was := editedFile.Cell(e.Cell) + again := drawioSameCell(editedFile, string(content), was, re, string(res.Content)) + if again == nil { + t.Fatalf("the re-export has no cell for %s, %s %s", e.Cell, was.Kind, was.FQN) + } + mut.check(t, was, again) + } + // The edited cells are where the file has them; unplaced neighbours may have flowed around them. + if _, imp := drawioImport(t, redrawn, reoptions, edited); len(imp.Edits) != 0 { + for _, e := range imp.Edits { + if e.Cell == cell.ID { + t.Errorf("the edited file still imports %+v against the edited model", e) + } + } + } + }) + } + }) + } +} diff --git a/internal/check/edit/layout.go b/internal/check/edit/layout.go index e029bb4ca8..2af632216a 100644 --- a/internal/check/edit/layout.go +++ b/internal/check/edit/layout.go @@ -473,32 +473,31 @@ func (m Model) checkPlaceable(i int, op Operation, renderer *view.Renderer, sem } return nil } - exposed := sym - if target.owner != nil { - exposed = target.owner - } - if !m.exposes(sem, viewSym, exposed) { - return &Error{Failure: FailureNotExposed, OperationIndex: i, - Message: fmt.Sprintf("%s does not expose %s, so a %s in its body would place nothing", - op.View, m.label(op), layoutTypeName(op.Annotation))} - } drawn, err := renderer.DrawnIn(viewSym) if err != nil { return &Error{Failure: FailureNotDrawn, OperationIndex: i, Message: fmt.Sprintf("%s does not render: %v", op.View, err)} } + var placed bool + exposed := sym if target.owner != nil { - if !drawn.MemberNode(target.owner, sym) { - return &Error{Failure: FailureNotDrawn, OperationIndex: i, - Message: fmt.Sprintf("the rendering of %s does not draw %s as %s", op.View, m.label(op), role)} - } + exposed = target.owner + placed = drawn.MemberNode(target.owner, sym) + } else { + placed = wantNode && drawn.Node(sym) || wantEdge && drawn.Edge(sym) + } + if placed { return nil } - if !(wantNode && drawn.Node(sym)) && !(wantEdge && drawn.Edge(sym)) { - return &Error{Failure: FailureNotDrawn, OperationIndex: i, - Message: fmt.Sprintf("the rendering of %s does not draw %s as %s", op.View, m.label(op), role)} + // A view draws what it exposes and, under those nodes, their members: a + // part of an exposed usage's type is drawn without being exposed itself. + if !m.exposes(sem, viewSym, exposed) { + return &Error{Failure: FailureNotExposed, OperationIndex: i, + Message: fmt.Sprintf("%s does not expose %s, so a %s in its body would place nothing", + op.View, m.label(op), layoutTypeName(op.Annotation))} } - return nil + return &Error{Failure: FailureNotDrawn, OperationIndex: i, + Message: fmt.Sprintf("the rendering of %s does not draw %s as %s", op.View, m.label(op), role)} } // exposes reports whether view exposes sym or an element sym is nested in. diff --git a/internal/ir/view/behavior.go b/internal/ir/view/behavior.go index e068ca22fb..d941de450e 100644 --- a/internal/ir/view/behavior.go +++ b/internal/ir/view/behavior.go @@ -814,6 +814,13 @@ func (r *Renderer) placedStart(view, owner *symbols.Symbol, graph *lower.ActionG if !ok { return false } + // A Layout the declaring owner's start falls back to places that body's + // start where it is drawn, not a start here: only one naming this body's adds one. + if member, ok := r.model.LookupMember(owner, "start"); ok && member.Owner() != nil && member.Owner().Decl != owner.Decl { + if via := r.model.MemberLayoutOwner(site, member); via == nil || via.Decl != owner.Decl { + return false + } + } if site.Layout == nil || site.Layout.Unplaced { r.noteLayoutProblems(site, owner, out) return false diff --git a/internal/ir/view/drawio_import_diff.go b/internal/ir/view/drawio_import_diff.go index 11af816d52..501c396bc6 100644 --- a/internal/ir/view/drawio_import_diff.go +++ b/internal/ir/view/drawio_import_diff.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" ) // DrawioTolerance is how far a coordinate may move in draw.io and still count @@ -106,11 +107,6 @@ type DrawioImport struct { Notices []string } -// drawioMatch pairs a file cell with the baseline cell it stands for. -type drawioMatch struct { - file, base *DrawioCell -} - // ImportDrawio reads an edited draw.io file of this rendering back as edits: // the file the writer would emit now under options is the baseline, and only // cells that differ from it are imported, so the writer's own geometry — grids, @@ -132,7 +128,7 @@ func (r *Rendering) ImportDrawio(file *DrawioFile, options Options) (*DrawioImpo return nil, err } out := &DrawioImport{View: norm.View} - m := &drawioMatcher{norm: norm, w: w, base: base, file: file, out: out, nodes: map[string]*Node{}, ports: map[string]Port{}, framed: map[string]bool{}, paired: map[string]*DrawioCell{}} + m := &drawioMatcher{norm: norm, drawn: r.drawn, w: w, base: base, file: file, out: out, nodes: map[string]*Node{}, ports: map[string]Port{}, framed: map[string]bool{}, paired: map[string]*DrawioCell{}} for _, node := range drawioNodes(norm.Roots) { m.nodes[node.ID] = node for _, port := range node.Ports { @@ -157,7 +153,10 @@ func drawioNodes(roots []*Node) []*Node { // drawioMatcher pairs the cells of the edited file with the baseline's and // turns each difference into an edit. type drawioMatcher struct { - norm *Rendering + norm *Rendering + // drawn is the rendering's own record of what it drew; its normalization + // for the writer, a clone, carries none. + drawn *Drawn w *drawioWriter base, file *DrawioFile out *DrawioImport @@ -334,7 +333,7 @@ func (m *drawioMatcher) edgeLabel(cell *DrawioCell, f *DrawioFile) string { // position is drawn on its owner's border, so only its style is read. func (m *drawioMatcher) diffVertex(cell, base *DrawioCell) { origin, isPort := m.originOf(base.ID) - edit := DrawioEdit{Cell: cell.ID, Label: cell.Label, FQN: cell.FQN, Origin: origin} + edit := DrawioEdit{Cell: cell.ID, Label: cell.Label, FQN: m.targetName(cell), Origin: origin} if isPort { if !sameBox(boxOf(cell), boxOf(base)) { m.out.Notices = append(m.out.Notices, fmt.Sprintf("%s: a port is drawn on its owner's border; its position in the file is not imported", cell.FQN)) @@ -461,7 +460,19 @@ func (m *drawioMatcher) framePlace(cell, base *DrawioCell, node *Node, sized boo layout.Hidden = node.Geometry.Hidden } origin, _ := m.originOf(base.ID) - m.out.Edits = append(m.out.Edits, DrawioEdit{Kind: DrawioEditLayout, Cell: cell.ID, Label: cell.Label, FQN: cell.FQN, Origin: origin, Layout: layout}) + m.out.Edits = append(m.out.Edits, DrawioEdit{Kind: DrawioEditLayout, Cell: cell.ID, Label: cell.Label, FQN: m.targetName(cell), Origin: origin, Layout: layout}) +} + +// targetName is the name an annotation about the cell's element states: its +// qualified name, or, for a member drawn under a node of another element — an +// inherited state, a part of the usage's type — that node's name and its own, +// the path a Layout about it resolves through (semantics.MemberLayoutOf). +func (m *drawioMatcher) targetName(cell *DrawioCell) string { + parent := m.file.Cell(cell.Parent) + if parent == nil || parent.FQN == "" || !parent.Vertex || cell.FQN == "" || strings.HasPrefix(cell.FQN, parent.FQN+"::") { + return cell.FQN + } + return parent.FQN + "::" + symbols.LastSegment(cell.FQN) } // diffEdge imports an edge's moved waypoints and changed colours or font. @@ -471,8 +482,13 @@ func (m *drawioMatcher) diffEdge(cell, base *DrawioCell, key DrawioEdgeKey) { return } edge := m.norm.Edges[i] - out := DrawioEdit{Cell: cell.ID, Label: cell.Label, Origin: edge.Origin, Edge: &key} - if !sameWaypoints(cell.Points, base.Points) { + out := DrawioEdit{Cell: cell.ID, Label: cell.Label, FQN: m.w.siteName(edge.Origin), Origin: edge.Origin, Edge: &key} + // A rendering built without a record of what it draws leaves the refusal + // of an edge no Route steers to the edit applying the import. + steered := m.drawn == nil || m.drawn.Steers(edge.Origin) + if !sameWaypoints(cell.Points, base.Points) && !steered { + m.out.Notices = append(m.out.Notices, fmt.Sprintf("edge %s re-routed: no Route steers a %s edge yet, so its waypoints are not imported", key.String(), edge.Kind)) + } else if !sameWaypoints(cell.Points, base.Points) { out.Kind = DrawioEditRoute if len(cell.Points) > 0 { out.Route = &semantics.Route{} diff --git a/internal/ir/view/drawio_import_match_test.go b/internal/ir/view/drawio_import_match_test.go index 5cf3df08a1..2fc890ba1c 100644 --- a/internal/ir/view/drawio_import_match_test.go +++ b/internal/ir/view/drawio_import_match_test.go @@ -58,7 +58,8 @@ func drawioCount(t *testing.T, document, fqn string) int { } // The tree draws Vehicle::mass once declared and three times inherited; the -// cells share one fqn, so the order of the file must not decide which is which. +// cells share one fqn, so the order of the file must not decide which is which, +// and the inherited one moved is placed as its inheritor's (vehicleFamily::mass). func TestDrawioImportReorderedDuplicatesKeepTheirIdentity(t *testing.T) { c, golden := drawioGolden(t, "abstract-tree") const mass = "AbstractViews::Vehicle::mass" @@ -82,7 +83,7 @@ func TestDrawioImportReorderedDuplicatesKeepTheirIdentity(t *testing.T) { if n7 == nil { t.Fatal("no node n7 in the rendering") } - if edit.Cell != "n7" || edit.FQN != mass || edit.Origin != n7.Origin || !strings.Contains(edit.Label, "^mass") { + if edit.Cell != "n7" || edit.FQN != "AbstractViews::vehicleFamily::mass" || edit.Origin != n7.Origin || !strings.Contains(edit.Label, "^mass") { t.Errorf("edit = %+v, want the inherited mass at n7 (origin %+v)", edit, n7.Origin) } if len(imp.Unmatched)+len(imp.Deleted) != 0 { diff --git a/internal/ir/view/geometry.go b/internal/ir/view/geometry.go index afb7fc2efa..d5347ace39 100644 --- a/internal/ir/view/geometry.go +++ b/internal/ir/view/geometry.go @@ -352,6 +352,44 @@ func (r *Renderer) declaredGeometryOf(view, elem *symbols.Symbol, decl ast.Node, return r.geometryOf(view, node, out) } +// inherits reports whether owner draws member as one it inherits — a part of +// its type, a state of its definition — rather than one it declares. +func (r *Renderer) inherits(owner, member *symbols.Symbol) bool { + if owner == nil || member == nil || member.Owner() == nil || member.Owner().Decl == owner.Decl { + return false + } + found, ok := r.model.LookupMember(owner, localName(member)) + return ok && found.Decl == member.Decl +} + +// inheritedGeometryOf is the Geometry of member drawn under owner's node: the +// Layout naming it as owner's when owner inherits it — one declaration is drawn +// once per inheritor, each placed by its own — else member's own. +func (r *Renderer) inheritedGeometryOf(view, owner, member *symbols.Symbol, out *Rendering) *Geometry { + if r.inherits(owner, member) { + return r.memberGeometryOf(view, owner, localName(member), out) + } + return r.geometryOf(view, member, out) +} + +// inheritedStyleOf is the Style of member drawn under owner's node, resolved as +// inheritedGeometryOf resolves its Layout. +func (r *Renderer) inheritedStyleOf(view, owner, member *symbols.Symbol, out *Rendering) *Style { + if !r.inherits(owner, member) { + return r.styleOf(view, member, out) + } + site, ok := r.model.MemberStyleOf(view, owner, localName(member)) + if !ok { + return nil + } + r.noteLayoutProblems(site, owner, out) + if site.Style == nil { + return nil + } + s := site.Style + return &Style{Fill: s.Fill, Line: s.Line, Text: s.Text, Font: s.Font, FontSize: s.FontSize, Bold: s.Bold, Italic: s.Italic} +} + // nodeGeometryOf is declaredGeometryOf for a graph node drawn as a member of // owner under name: one declaration inherited by several bodies is drawn once // per body, each positioned by the Layout naming it as that body's. A node owner diff --git a/internal/ir/view/inherited_members_test.go b/internal/ir/view/inherited_members_test.go new file mode 100644 index 0000000000..dd0bb6139b --- /dev/null +++ b/internal/ir/view/inherited_members_test.go @@ -0,0 +1,109 @@ +package view + +import ( + "strings" + "testing" +) + +// inheritedMembers draws one declaration under several inheritors: Pump's +// waterOut under pump and sensor, Operate's start under Operate and run. +const inheritedMembers = `package Plant { + part def Pump { item waterOut; } + part def Loop { + part pump : Pump; + part sensor : Pump; + } + action def Operate { + first start; + action step; + succession first start then step; + } + use case def Inspection { perform action run : Operate; } + package Diagrams { + private import Views::*; + private import OpenSysMLRenderings::*; + view loopView { + expose Loop; + render asInterconnectionDiagram; + metadata DiagramLayout::Layout about Loop::pump::waterOut { x = 10; y = 20; } + metadata DiagramLayout::Layout about Loop::sensor::waterOut { x = 30; y = 40; } + metadata DiagramLayout::Style about Loop::sensor::waterOut { fill = "#ffcc00"; } + } + view treeView { + expose Loop; + render asTreeDiagram; + metadata DiagramLayout::Layout about Loop::sensor::waterOut { x = 30; y = 40; } + } + view mixedView { + expose Plant; + render asMixedDiagram; + metadata DiagramLayout::Layout about Operate::start { x = 5; y = 6; } + } + } +} +` + +func renderInline(t *testing.T, view string) *Rendering { + t.Helper() + r, idx := loadSources(t, []string{"inherited.sysml"}, [][]byte{[]byte(inheritedMembers)}) + rendering, err := r.Render(lookup(t, idx, view)) + if err != nil { + t.Fatalf("render %s: %v", view, err) + } + return rendering +} + +// childNamed is the child of the node named owner that is named name. +func childNamed(t *testing.T, roots []*Node, owner, name string) *Node { + t.Helper() + for _, child := range findNode(t, roots, owner).Children { + if child.Name == name { + return child + } + } + t.Fatalf("no %s under %s", name, owner) + return nil +} + +// A member drawn under each inheritor is placed and coloured by the Layout +// and Style naming it as that inheritor's, not by another inheritor's. +func TestInheritedMemberIsPlacedPerInheritor(t *testing.T) { + for _, view := range []string{"Plant::Diagrams::loopView", "Plant::Diagrams::treeView"} { + rendering := renderInline(t, view) + sensor := childNamed(t, rendering.Roots, "sensor", "waterOut") + if sensor.Geometry == nil || sensor.Geometry.X != 30 || sensor.Geometry.Y != 40 { + t.Errorf("%s: sensor's waterOut at %+v, want (30, 40)", view, sensor.Geometry) + } + pump := childNamed(t, rendering.Roots, "pump", "waterOut") + if view == "Plant::Diagrams::loopView" { + if pump.Geometry == nil || pump.Geometry.X != 10 || pump.Geometry.Y != 20 { + t.Errorf("pump's waterOut at %+v, want (10, 20)", pump.Geometry) + } + if sensor.Style == nil || !strings.EqualFold(sensor.Style.Fill, "#ffcc00") || pump.Style != nil { + t.Errorf("styles: sensor %+v, pump %+v; want sensor's alone filled", sensor.Style, pump.Style) + } + } else if pump.Geometry != nil { + t.Errorf("tree: pump's waterOut at %+v, want unplaced", pump.Geometry) + } + } +} + +// A Layout about the declaring body's start places that start where its body +// is drawn; a body inheriting the start does not grow one at the same place. +func TestInheritedStartIsNotAddedByTheDeclaringBodysLayout(t *testing.T) { + rendering := renderInline(t, "Plant::Diagrams::mixedView") + placed := false + for _, child := range findNode(t, rendering.Roots, "Plant::Operate").Children { + if child.Kind == "initial" && child.Geometry != nil && child.Geometry.X == 5 && child.Geometry.Y == 6 { + placed = true + } + } + if !placed { + t.Errorf("Operate's start is not placed at (5, 6)") + } + for _, child := range findNode(t, rendering.Roots, "Plant::Inspection::run").Children { + if child.Kind == "initial" { + t.Errorf("run draws a start at %+v from Operate's Layout", child.Geometry) + } + } +} diff --git a/internal/ir/view/interconnection.go b/internal/ir/view/interconnection.go index 7a0e7c3738..e7ef669c0b 100644 --- a/internal/ir/view/interconnection.go +++ b/internal/ir/view/interconnection.go @@ -46,7 +46,7 @@ func (r *Renderer) renderInterconnectionWithIDs(view *symbols.Symbol, exposed [] ports = append(ports, elem) continue } - out.Roots = append(out.Roots, w.featureNode(elem, expansion{}, 0, true)) + out.Roots = append(out.Roots, w.featureNode(elem, nil, expansion{}, 0, true)) default: out.Notices = append(out.Notices, fmt.Sprintf( "%s %s has no place in an interconnection rendering; it is not shown", @@ -72,7 +72,7 @@ func (w *featureWalk) exposedPorts(ports []*symbols.Symbol) { } sites := w.pinSites(port) if len(sites) == 0 { - w.out.Roots = append(w.out.Roots, w.featureNode(port, expansion{}, 0, true)) + w.out.Roots = append(w.out.Roots, w.featureNode(port, nil, expansion{}, 0, true)) continue } for _, site := range sites { @@ -201,15 +201,15 @@ func (w *featureWalk) drawsFeature(sym *symbols.Symbol) bool { // featureNode renders one exposed feature and the features nested in it, // collecting the connectors declared along the way, which join the nodes it // renders. A member it has from its type and pins is not drawn nested as well. -func (w *featureWalk) featureNode(sym *symbols.Symbol, path expansion, depth int, qualified bool) *Node { +func (w *featureWalk) featureNode(sym, owner *symbols.Symbol, path expansion, depth int, qualified bool) *Node { r := w.r name := r.notationName(sym) if !qualified { name = localName(sym) } node := &Node{ID: w.ids.take(), Kind: declKind(sym), Name: name, NameSynthesized: r.model.NameSynthesized(sym), - Type: r.headType(sym), Typings: r.headTypings(sym), Origin: symbolOrigin(sym), Geometry: r.geometryOf(w.view, sym, w.out), - Style: r.styleOf(w.view, sym, w.out)} + Type: r.headType(sym), Typings: r.headTypings(sym), Origin: symbolOrigin(sym), Geometry: r.inheritedGeometryOf(w.view, owner, sym, w.out), + Style: r.inheritedStyleOf(w.view, owner, sym, w.out)} if path[sym] { node.Detail = detailWith(node.Detail, "already shown as "+w.node(sym).ID) return node @@ -248,7 +248,7 @@ func (w *featureWalk) featureNode(sym *symbols.Symbol, path expansion, depth int case isReferencedIncludedCase(member): // an `include u;` is drawn by the connection to the case it names case w.drawsFeature(member): - child := w.featureNode(member, path, depth+1, false) + child := w.featureNode(member, sym, path, depth+1, false) child.Inherited = member.Owner() != sym w.parent[child] = node node.Children = append(node.Children, child) diff --git a/internal/ir/view/mixed.go b/internal/ir/view/mixed.go index 0a3c18bece..118e7b8c0e 100644 --- a/internal/ir/view/mixed.go +++ b/internal/ir/view/mixed.go @@ -109,7 +109,7 @@ func (w *mixedWalk) render(sym *symbols.Symbol, exposed bool, parent *Node) { return } - node := w.r.treeNodeShallow(w.view, sym, w.ids, map[*symbols.Symbol]bool{}, 0, exposed, w.out) + node := w.r.treeNodeShallow(w.view, sym, nil, w.ids, map[*symbols.Symbol]bool{}, 0, exposed, w.out) w.remember(sym, node) w.append(node, parent) for _, member := range w.traversedMembers(sym) { diff --git a/internal/ir/view/place.go b/internal/ir/view/place.go index e1afc91459..e768254dee 100644 --- a/internal/ir/view/place.go +++ b/internal/ir/view/place.go @@ -11,6 +11,7 @@ import ( // an owner's — the start or done a body inherits — by the owner as well. type Drawn struct { nodes, edges map[ast.Node]bool + steered map[Origin]bool // origins of the edges a Route steers members map[ast.Node]map[ast.Node]bool // member decl -> owner decls it is drawn as a member of generals map[ast.Node]map[ast.Node]bool // element decl -> decls of the generals it is drawn with an edge to } @@ -36,6 +37,17 @@ func (d *Drawn) MemberNode(owner, sym *symbols.Symbol) bool { return owner != nil && owners[owner.Decl] } +func newDrawn() *Drawn { + return &Drawn{nodes: map[ast.Node]bool{}, edges: map[ast.Node]bool{}, steered: map[Origin]bool{}, + members: map[ast.Node]map[ast.Node]bool{}, generals: map[ast.Node]map[ast.Node]bool{}} +} + +// Steers reports whether a Route steers the edge declared at origin; the edge +// of a specialization, typing or subsetting has none an edit writes yet. +func (d *Drawn) Steers(origin Origin) bool { + return d != nil && d.steered[origin] +} + // Edge reports whether the rendering draws sym as an edge a Route steers. func (d *Drawn) Edge(sym *symbols.Symbol) bool { return d != nil && sym != nil && d.edges[sym.Decl] @@ -68,6 +80,7 @@ func (d *Drawn) note(elem *symbols.Symbol, asEdge bool) { } if asEdge { d.edges[elem.Decl] = true + d.steered[elem.Origin()] = true } else { d.nodes[elem.Decl] = true } @@ -91,7 +104,7 @@ func (d *Drawn) noteMember(owner, member *symbols.Symbol) { // Layout or Route stated in the view's body can apply to. A view that does // not render is the error Render gives. func (r *Renderer) DrawnIn(view *symbols.Symbol) (*Drawn, error) { - drawn := &Drawn{nodes: map[ast.Node]bool{}, edges: map[ast.Node]bool{}, members: map[ast.Node]map[ast.Node]bool{}, generals: map[ast.Node]map[ast.Node]bool{}} + drawn := newDrawn() if _, err := r.render(view, drawn); err != nil { return nil, err } diff --git a/internal/ir/view/tree.go b/internal/ir/view/tree.go index 1910f794ad..9cbe8f67f1 100644 --- a/internal/ir/view/tree.go +++ b/internal/ir/view/tree.go @@ -37,7 +37,7 @@ func (r *Renderer) renderTree(view *symbols.Symbol, exposed []*symbols.Symbol, o if descendants[symbols.KeyOf(elem)] { continue } - out.Roots = append(out.Roots, r.treeNode(view, elem, ids, map[*symbols.Symbol]bool{}, 0, true, out)) + out.Roots = append(out.Roots, r.treeNode(view, elem, nil, ids, map[*symbols.Symbol]bool{}, 0, true, out)) } out.Roots = append(out.Roots, r.nestedViewNodes(view, ids, map[*symbols.Symbol]bool{view: true}, out)...) r.exposedRelationships(view, exposed, ids, r.treeEdges(out), out) @@ -145,7 +145,7 @@ func (r *Renderer) nestedViewNodes(view *symbols.Symbol, ids *nodeIDs, rendered if descendants[symbols.KeyOf(elem)] { continue } - node.Children = append(node.Children, r.treeNode(sub, elem, ids, map[*symbols.Symbol]bool{}, 0, true, out)) + node.Children = append(node.Children, r.treeNode(sub, elem, nil, ids, map[*symbols.Symbol]bool{}, 0, true, out)) } } node.Children = append(node.Children, r.nestedViewNodes(sub, ids, rendered, out)...) @@ -161,8 +161,8 @@ func (r *Renderer) nestedViewNodes(view *symbols.Symbol, ids *nodeIDs, rendered // qualified name, which is what an exposed element is reported by; a member // nested in it is named as it was declared. view is the view the element is // shown in, nil outside any view. -func (r *Renderer) treeNode(view, sym *symbols.Symbol, ids *nodeIDs, path expansion, depth int, qualified bool, out *Rendering) *Node { - node := r.treeNodeShallow(view, sym, ids, path, depth, qualified, out) +func (r *Renderer) treeNode(view, sym, owner *symbols.Symbol, ids *nodeIDs, path expansion, depth int, qualified bool, out *Rendering) *Node { + node := r.treeNodeShallow(view, sym, owner, ids, path, depth, qualified, out) if path[sym] { return node } @@ -190,7 +190,7 @@ func (r *Renderer) treeNode(view, sym *symbols.Symbol, ids *nodeIDs, path expans } continue } - child := r.treeNode(view, member, ids, path, depth+1, false, out) + child := r.treeNode(view, member, sym, ids, path, depth+1, false, out) child.Inherited = member.Owner() != sym node.Children = append(node.Children, child) } @@ -203,14 +203,14 @@ func (r *Renderer) treeNode(view, sym *symbols.Symbol, ids *nodeIDs, path expans // treeNodeShallow draws sym without recursively drawing its members; one on // the path being expanded above it is noted as already shown. -func (r *Renderer) treeNodeShallow(view, sym *symbols.Symbol, ids *nodeIDs, path expansion, depth int, qualified bool, out *Rendering) *Node { +func (r *Renderer) treeNodeShallow(view, sym, owner *symbols.Symbol, ids *nodeIDs, path expansion, depth int, qualified bool, out *Rendering) *Node { name := r.notationName(sym) if !qualified { name = localName(sym) } node := &Node{ID: ids.take(), Kind: declKind(sym), Name: name, NameSynthesized: r.model.NameSynthesized(sym), - Type: r.headType(sym), Typings: r.headTypings(sym), Value: r.declValue(sym), Origin: symbolOrigin(sym), Geometry: r.geometryOf(view, sym, out), - Style: r.styleOf(view, sym, out), Referential: r.referenceRow(sym)} + Type: r.headType(sym), Typings: r.headTypings(sym), Value: r.declValue(sym), Origin: symbolOrigin(sym), Geometry: r.inheritedGeometryOf(view, owner, sym, out), + Style: r.inheritedStyleOf(view, owner, sym, out), Referential: r.referenceRow(sym)} out.noteTreeSite(node, view, sym) if path[sym] { node.Detail = detailWith(node.Detail, "already shown") diff --git a/internal/ir/view/view.go b/internal/ir/view/view.go index c9d47ab4ab..365c73c3f9 100644 --- a/internal/ir/view/view.go +++ b/internal/ir/view/view.go @@ -641,9 +641,12 @@ func (r *Rendering) Positioned() bool { // recognized kind this package does not produce is an *UnsupportedKindError — // never another kind's rendering. func (r *Renderer) Render(view *symbols.Symbol) (*Rendering, error) { - return r.render(view, nil) + return r.render(view, newDrawn()) } +// Drawn is what the rendering draws, for the edits that apply to it. +func (r *Rendering) Drawn() *Drawn { return r.drawn } + // render is Render, collecting what is drawn into drawn when it is not nil. func (r *Renderer) render(view *symbols.Symbol, drawn *Drawn) (*Rendering, error) { kind, stated, err := r.KindOf(view) From 1f2722b03ee83cc63242f0872b3097d073773e42 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 06:18:13 +0000 Subject: [PATCH 09/25] test(view): re-save the draw.io desktop fixture against the routed export Edge routing moved the notes of the layout golden, so the desktop save of the earlier export imported a Note edit besides the tank's Layout; draw.io desktop 32.6.1 saved the routed export again, compressed, with the tank dragged out of its loop. The merge of develop also left drawioPrepare returning a string for an error of the router. Co-Authored-By: jason.han --- internal/ir/view/drawio.go | 2 +- internal/ir/view/drawio_read_test.go | 4 ++-- internal/ir/view/testdata/layout-desktop.drawio | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/ir/view/drawio.go b/internal/ir/view/drawio.go index c4ee07ae5c..23f4f7700c 100644 --- a/internal/ir/view/drawio.go +++ b/internal/ir/view/drawio.go @@ -72,7 +72,7 @@ func (r *Rendering) drawioPrepare(options Options) (*Rendering, *drawioWriter, e w.layout(r) edges, err := w.routeEdges(r) if err != nil { - return "", err + return nil, nil, err } routed := *r routed.Edges = edges diff --git a/internal/ir/view/drawio_read_test.go b/internal/ir/view/drawio_read_test.go index bd09f17f5b..28b5ee6555 100644 --- a/internal/ir/view/drawio_read_test.go +++ b/internal/ir/view/drawio_read_test.go @@ -40,8 +40,8 @@ func TestReadDrawioCompressedDesktopSave(t *testing.T) { t.Fatal(err) } edit := editOf(t, imp, DrawioEditLayout, "n2") - if edit.FQN != "Plant::Loop::tank" || edit.Layout.X <= 256 || edit.Layout.Y != 218 { - t.Errorf("layout = %s %+v, want the tank moved right from (256, 218)", edit.FQN, edit.Layout) + if edit.FQN != "Plant::Loop::tank" || edit.Layout.X <= 256 || edit.Layout.Y != 242 { + t.Errorf("layout = %s %+v, want the tank moved right from (256, 242)", edit.FQN, edit.Layout) } if len(imp.Unmatched)+len(imp.Unknown)+len(imp.Deleted)+len(imp.Notices) != 0 { t.Errorf("reported %v %d %v %v, want nothing", imp.Unmatched, len(imp.Unknown), imp.Deleted, imp.Notices) diff --git a/internal/ir/view/testdata/layout-desktop.drawio b/internal/ir/view/testdata/layout-desktop.drawio index 66c886a399..0c3280d3ce 100644 --- a/internal/ir/view/testdata/layout-desktop.drawio +++ b/internal/ir/view/testdata/layout-desktop.drawio @@ -1 +1 @@ -7ZlLc6M4EIB/DcdNATK2r7YTzxxmq1Kbrd2zArLRWkiMkEO8v34lJEAg8CtkKodNpVKoab26P3W3iAc22fs3DvP0d5Yg4oV+8u6BRy8MF9FM/lWCkxbMlwst2HOcaFHQCl7wv8gIfSM94gQVHUXBGBE47wpjRimKRUcGOWdlV23HSHfWHO6RI3iJIXGlf+NEpEYa+n774jvC+9RMvaxfZLBWNoIihQkrLRF48sCGMyb0U/a+QUTZrraL7rcdedssjCMqBjqw13+UPUKfwFfpkkrB24Teai27KHmCdqq9Xnvh3AuBNDhY/2AsN4b6SXWfZwLV+Cv52749YGqWYQ1WvXnDqLQ6/iWbhe6dExijRAm0JsH0oDUJPLGjeChORSYXugoirVDvlLbWaqxgb9tYohCn2mlFiTM5u2ytU6HGfFSbK1Ms0EsuVyEFpaRVynaMihfTMZBtSPBebTyWoyMuBW+ICyxxWJkXgqluhZDbNrDO5NrWMSME5gV+bUYy5pDd0bu1TrONb4hlSPCTVEktegJgjkvZsgaiUMvMMDPDkzlQwdy0oSF93wzdwiAfjOnqpsHjGlT6kOTH7BIk2uGNXh+XqUBZ9EAJXFCMY9D4qaG+ww9nR5pUXSomeGxcXW3/AlEXCcpwkqiJ1jtMyIYRxqtJwXb7tFw/VnBxdkDWG/1jaN3CDBPl+xXHkNQIm/UFA0jfBSKIHA7rSF4PMu9gGC3HKTRT/SEhg3QvF9abaz46Vz0aJNKWFAq0Vp4pHLSbXdxLe/QkwSJIjHP9rGiunmxNi2w2IdlLB2ywosHDhUBI3UjYkjzGq4WzTZJqVwZ6ZgUWmA0i/aOnoIPjxRPwyoRg2d0x0oRDjggU+K2bp0s7R3d49R+ii4Q+M0xF2+W3LuJ1sx6A7XaF4WBSFntgmcpGWXgqvELQ5QsNgIUSpzByUy478hgNMNphUI1UByTKqqzcxdJCENFkpaq2VlVHw7r2AkPx0Zc/223Dk8zNe1QfiVCtKuycHIurQYZGAZErgydLIVe4FOP8zPxuql70Krq+etTL7P0KsKcfnR1ePuj13kemU29SJpA/ToJVfKUw164WVxRghYk3MytwELQT46WX7I/pXhVebQSB5IjqTFHCU3FfznPz0HzRzXmga/EwOJP1RmqvEcOCFaRxyriZ4ObT13rn/Mmz3JHAIm2KHPfgDRymESNefYpuMcmZfX8Z1iqPSRPeRZvvZqz6QJtR5r2AMLu90h8x7QS0uc6YmrbwF9IWfn3a4hTFB7VGjoriyNFUMa65cw7fLwGYTWXiCagLP5s69NFK4RaTgK9P3Y6jyUjr3SDDYPZh0m78dCHvnwfvik8Xrd4nfbqoy/yGBjvYXfWN69d8o7jL9fOB1BZ2UxtYfLySuu5ej+nZa/2fytPVk6X4Sbf6wHW6czkZuta7Qe//a/2XvtbLZvtdX/du/zkCnv4D \ No newline at end of file +7ZlLc+MoEIB/jY6bkpBk+2o78cxhtiq12do9EwlbrBFoEI7i/fULAiz08ivKVA6bSqVEq5tH9wfdKF64zt+/cVhkv7MUEQ/46bsXPnoAzONI/lWCoxbMFnMt2HGcalHQCF7wv8gIfSM94BSVLUXBGBG4aAsTRilKREsGOWdVW23LSHvUAu5QT/CSQNKX/o1TkRkp8P3mxXeEd5kZemFf5NAqG0GZwZRVjih88sI1Z0zop/x9jYjynfWLttuMvD1NjCMqBgzY6z/KH8An8FWGpFbw1sBbrqSJkqdoq9qrlQdmHgilw8PVD8YK46ifVNs8E6j6X8rf5u0eUzMNp7P6zRtGlWP4l2yW2rogMEGpEmhNguleaxJ4ZAfxUB7LXE50GcRawa6UNt46ecFdtvFEKY42aGWFczm6bK0yofp8VIurMizQSyFnIQWVpFXKtoyKF2MYyDYkeKcWnsjeEZeCN8QFljgszQvBlFkp5LINrJGc2yphhMCixK+nnow7pDl6d+ZplvENsRwJfpQqmUNPEC+0XdWwFsZAy0w3keHJbKhgZtrQkL47dd3AIB+M62zT4HENKl1IikN+CRId8JNeF5epQJl3QAn6oJjAoPFdQ/0eP5wdaFqb1EzwxIS6Xv4Foi4SlOM0VQOttpiQNSOM14OGm83TYvVYw8XZHjlv9I+hdQNzTFTslxxDYhE28wsGkL4LxDDucWhPctvJrIXhAoxTaIb6Q0IG6U5OrDPWbHQs2xsk0pcUCrRSkSl7aJ9WcS/t8ZMEiyAxzvWzorl+cjUdstmEZC96YIdLGjxcOAhp/yRsSB7j1cHZJUm1awc9sxILzAaR/tFR0IfjxR3wyoRg+d1npCGNIwIFfmvn6crN0S1e/Yf4IqHPDFPRmPzWRtw2bQdsuy0NB5Oy2AHLVDbKw1PhBcI2X2gALJT2CqN+ymUHnqABRlsMqp7sgURZnZXbWDoIIpouVdXWqOrT0NZe4dD56MufzebEk8zNO2S3BFCzAq2d43A1yNAoIHJm8OgoFAqXcpyfyG+n6nmnouuqx53M3q0AO/rx2e7lg57vfWT26k3KBPLHSXCKrwwWOtTiigKsNOdN5BwcBG3FeOkl7THdqcKrOUEgOSCbKSp4LO/Lef08NJu3c14nQiA6k/VGaq8Rx4ZLSJOMcTPAzbuvic75neeEI4Vldipy+htvYDONOPHqXXSLS86s+8uwVkdMuvAu2vx+xrIb2vQy6+AW3V7pj7h2Atr6wZiaNvALaQNfn7YkQ8lezZGjsjxwNNUZF4Ttwr5zvwztlfTjLp6AOvDZ1KGPVgq3uCT8+tRtOZqMtM4NEgTRh0m78dOFvH/uvSs+XTR6n/Tpwpb5Jxrcw+6qb1y/5hvFXaGfDaQ20E5tUfDxSuq6ez2mZ6/1f6pI10+O4ifd6oN+0HuXk6Frff/Q+/9a/6Wv9bLZfNfX1s0/R8Kn/wA= \ No newline at end of file From a9ecd019bb0fded67473c39bb7f1f9de2f5e7e52 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 06:59:25 +0000 Subject: [PATCH 10/25] fix(view): name nested-view elements by their own path and keep typing edges beside a membership edge unsteered An element a nested view exposes is drawn under the view's cell but is no member of the view: a Layout imported for it names the element, not a path through the view. The import now asks the rendering which members it drew as an owner's own (Drawn.Inherits) instead of comparing name prefixes. A usage's typing edge drawn beside its membership edge follows a Route naming its type, which no edit writes yet; the plain Route steers the membership edge only. Drawn.Steers now takes the edge kind, so re-routing the typing edge is reported rather than written as a Route about the usage that would move the membership edge. An unplaced container that frames children is pinned once when it moves, instead of being placed and then pinned again when a child moves. Co-Authored-By: jason.han --- internal/ir/view/drawio_import_diff.go | 42 +++++++++++---- internal/ir/view/drawio_import_match_test.go | 50 +++++++++++++++++ internal/ir/view/geometry.go | 3 ++ internal/ir/view/place.go | 56 +++++++++++++++++--- 4 files changed, 132 insertions(+), 19 deletions(-) diff --git a/internal/ir/view/drawio_import_diff.go b/internal/ir/view/drawio_import_diff.go index 501c396bc6..ad1bef0beb 100644 --- a/internal/ir/view/drawio_import_diff.go +++ b/internal/ir/view/drawio_import_diff.go @@ -333,7 +333,7 @@ func (m *drawioMatcher) edgeLabel(cell *DrawioCell, f *DrawioFile) string { // position is drawn on its owner's border, so only its style is read. func (m *drawioMatcher) diffVertex(cell, base *DrawioCell) { origin, isPort := m.originOf(base.ID) - edit := DrawioEdit{Cell: cell.ID, Label: cell.Label, FQN: m.targetName(cell), Origin: origin} + edit := DrawioEdit{Cell: cell.ID, Label: cell.Label, FQN: m.targetName(cell, base), Origin: origin} if isPort { if !sameBox(boxOf(cell), boxOf(base)) { m.out.Notices = append(m.out.Notices, fmt.Sprintf("%s: a port is drawn on its owner's border; its position in the file is not imported", cell.FQN)) @@ -383,11 +383,16 @@ func (m *drawioMatcher) layoutDiff(cell, base *DrawioCell) *semantics.Layout { return nil } shift := m.frameShift(base) + node := m.nodes[base.ID] + // A container the model does not place is pinned with its children once, + // not placed here and pinned again when a child drawn in its frame moves. + if node != nil && !node.Geometry.placed() && m.framesChildren(base.ID) { + m.pin(base.ID) + } if m.framed[base.ID] { return nil } layout := &semantics.Layout{X: fileBox.X - shift.X, Y: fileBox.Y - shift.Y, Collapsed: cell.Collapsed} - node := m.nodes[base.ID] if resized || (node != nil && node.Geometry != nil && node.Geometry.HasSize) { layout.Width, layout.Height, layout.HasSize = fileBox.Width, fileBox.Height, true } @@ -397,6 +402,17 @@ func (m *drawioMatcher) layoutDiff(cell, base *DrawioCell) *semantics.Layout { return layout } +// framesChildren reports whether the writer drew vertex cells inside the +// baseline cell id's frame. +func (m *drawioMatcher) framesChildren(id string) bool { + for i := range m.base.Cells { + if child := &m.base.Cells[i]; child.Parent == id && child.Vertex && !child.Relative { + return true + } + } + return false +} + // frameShift is what the writer added to a node's Layout to draw it on the page: // nothing for a root or a child of a placed container; for a child of one the // model does not place, the writer hugs the placed children to the container's @@ -460,19 +476,23 @@ func (m *drawioMatcher) framePlace(cell, base *DrawioCell, node *Node, sized boo layout.Hidden = node.Geometry.Hidden } origin, _ := m.originOf(base.ID) - m.out.Edits = append(m.out.Edits, DrawioEdit{Kind: DrawioEditLayout, Cell: cell.ID, Label: cell.Label, FQN: m.targetName(cell), Origin: origin, Layout: layout}) + m.out.Edits = append(m.out.Edits, DrawioEdit{Kind: DrawioEditLayout, Cell: cell.ID, Label: cell.Label, FQN: m.targetName(cell, base), Origin: origin, Layout: layout}) } // targetName is the name an annotation about the cell's element states: its -// qualified name, or, for a member drawn under a node of another element — an -// inherited state, a part of the usage's type — that node's name and its own, -// the path a Layout about it resolves through (semantics.MemberLayoutOf). -func (m *drawioMatcher) targetName(cell *DrawioCell) string { - parent := m.file.Cell(cell.Parent) - if parent == nil || parent.FQN == "" || !parent.Vertex || cell.FQN == "" || strings.HasPrefix(cell.FQN, parent.FQN+"::") { +// qualified name, or, for a member the rendering drew as its container's own — +// an inherited state, a part of the usage's type — that container's name and +// its own, the path a Layout about it resolves through (MemberLayoutOf). An +// element a nested view exposes keeps its own name: the view is no owner. +func (m *drawioMatcher) targetName(cell, base *DrawioCell) string { + parent, name := m.base.Cell(base.Parent), symbols.LastSegment(cell.FQN) + if parent == nil || parent.FQN == "" || !parent.Vertex || cell.FQN == "" { return cell.FQN } - return parent.FQN + "::" + symbols.LastSegment(cell.FQN) + if owner, isPort := m.originOf(parent.ID); !isPort && m.drawn.Inherits(owner, name) { + return parent.FQN + "::" + name + } + return cell.FQN } // diffEdge imports an edge's moved waypoints and changed colours or font. @@ -485,7 +505,7 @@ func (m *drawioMatcher) diffEdge(cell, base *DrawioCell, key DrawioEdgeKey) { out := DrawioEdit{Cell: cell.ID, Label: cell.Label, FQN: m.w.siteName(edge.Origin), Origin: edge.Origin, Edge: &key} // A rendering built without a record of what it draws leaves the refusal // of an edge no Route steers to the edit applying the import. - steered := m.drawn == nil || m.drawn.Steers(edge.Origin) + steered := m.drawn == nil || m.drawn.Steers(edge.Origin, edge.Kind) if !sameWaypoints(cell.Points, base.Points) && !steered { m.out.Notices = append(m.out.Notices, fmt.Sprintf("edge %s re-routed: no Route steers a %s edge yet, so its waypoints are not imported", key.String(), edge.Kind)) } else if !sameWaypoints(cell.Points, base.Points) { diff --git a/internal/ir/view/drawio_import_match_test.go b/internal/ir/view/drawio_import_match_test.go index 2fc890ba1c..e5bf7a8aac 100644 --- a/internal/ir/view/drawio_import_match_test.go +++ b/internal/ir/view/drawio_import_match_test.go @@ -169,3 +169,53 @@ func TestDrawioImportEmptiedPageReportsEveryDeletion(t *testing.T) { } } } + +// An element a nested view exposes is drawn under the view's node but is not +// the view's member: a Layout about it names the element, not a path through +// the view. +func TestDrawioImportNestedViewElementKeepsItsOwnName(t *testing.T) { + c, golden := drawioGolden(t, "tree") + const engine = "Vehicles::Engine" + if !strings.Contains(drawioObjectXML(t, golden, "n7"), `fqn="`+engine+`"`) { + t.Fatalf("n7 is not Engine:\n%s", drawioObjectXML(t, golden, "n7")) + } + file, err := ReadDrawio([]byte(golden), c.view) + if err != nil { + t.Fatal(err) + } + if parent := file.Cell(file.Cell("n7").Parent); parent == nil || parent.FQN != "VehicleViews::vehicleView::engineSubview" { + t.Fatalf("n7's parent = %+v, want the nested view", parent) + } + moved := mutateCell(t, golden, "n7", func(cell string) string { return setGeometryAttr(t, cell, "y", "444") }) + edit := editOf(t, drawioGoldenImport(t, c, moved), DrawioEditLayout, "n7") + if edit.FQN != engine { + t.Errorf("edit names %q, want %q", edit.FQN, engine) + } +} + +// A usage's typing edge drawn beside its membership edge follows a Route naming +// its type, which no edit writes yet: re-routing it is reported, while +// re-routing the membership edge, which the plain Route steers, is a Route. +func TestDrawioImportReroutedTypingEdgeBesideMembershipIsReported(t *testing.T) { + c, golden := drawioGolden(t, "mixed") + typing, membership := drawioObjectXML(t, golden, "e37"), drawioObjectXML(t, golden, "e15") + if !strings.Contains(typing, `kind="typing"`) || !strings.Contains(typing, `source="n26" target="n21"`) || + !strings.Contains(membership, `kind="composition"`) || !strings.Contains(membership, `target="n26"`) { + t.Fatalf("e37/e15 are not pump's typing and membership edges:\n%s\n%s", typing, membership) + } + const points = `` + reroute := func(cell string) string { + if strings.Contains(cell, ".*?`).ReplaceAllString(cell, points) + } + return strings.Replace(cell, ``, ``+points+``, 1) + } + imp := drawioGoldenImport(t, c, mutateCell(t, golden, "e37", reroute)) + if len(imp.Edits) != 0 || len(imp.Notices) != 1 || !strings.Contains(imp.Notices[0], "no Route steers a typing edge") { + t.Errorf("typing edge: edits %+v notices %v, want one notice and no edit", imp.Edits, imp.Notices) + } + imp = drawioGoldenImport(t, c, mutateCell(t, golden, "e15", reroute)) + if edit := onlyEdit(t, imp, DrawioEditRoute); edit.Cell != "e15" || edit.Route == nil || len(edit.Route.Points) != 1 { + t.Errorf("membership edge: edit %+v, want its one waypoint as a Route", edit) + } +} diff --git a/internal/ir/view/geometry.go b/internal/ir/view/geometry.go index d5347ace39..86cd1fe1ec 100644 --- a/internal/ir/view/geometry.go +++ b/internal/ir/view/geometry.go @@ -232,6 +232,7 @@ func (r *Renderer) memberGeometryOf(view, owner *symbols.Symbol, name string, ou if member, ok := r.model.LookupMember(owner, name); ok { out.drawn.noteMember(owner, member) } + out.drawn.noteInherited(owner, name) site, ok := r.model.MemberLayoutOf(view, owner, name) if !ok { return nil @@ -276,6 +277,7 @@ func (r *Renderer) memberNotesOf(view, owner *symbols.Symbol, name, anchor strin // no Route annotation gives any, resolved as geometryOf resolves a Layout. func (r *Renderer) routeOf(view, elem *symbols.Symbol, out *Rendering) []Point { out.drawn.note(elem, true) + out.drawn.noteSteered(elem) site, ok := r.model.RouteOf(view, elem) if !ok { return nil @@ -288,6 +290,7 @@ func (r *Renderer) routeOf(view, elem *symbols.Symbol, out *Rendering) []Point { // those of a Route about elem naming general, nil when none gives any. func (r *Renderer) specializationRouteOf(view, elem, general *symbols.Symbol, out *Rendering) []Point { out.drawn.noteSpecialization(elem, general) + out.drawn.noteGeneralEdged(elem) site, ok := r.model.SpecializationRouteOf(view, elem, general) if !ok { return nil diff --git a/internal/ir/view/place.go b/internal/ir/view/place.go index e768254dee..9c858e0c4a 100644 --- a/internal/ir/view/place.go +++ b/internal/ir/view/place.go @@ -11,7 +11,9 @@ import ( // an owner's — the start or done a body inherits — by the owner as well. type Drawn struct { nodes, edges map[ast.Node]bool - steered map[Origin]bool // origins of the edges a Route steers + steered map[Origin]bool // origins of the edges a plain Route steers + generalEdged map[Origin]bool // origins of the elements whose edge to a general a Route naming it steers + inherited map[Origin]map[string]bool // owner origin -> local names of the members drawn as its own members map[ast.Node]map[ast.Node]bool // member decl -> owner decls it is drawn as a member of generals map[ast.Node]map[ast.Node]bool // element decl -> decls of the generals it is drawn with an edge to } @@ -38,14 +40,53 @@ func (d *Drawn) MemberNode(owner, sym *symbols.Symbol) bool { } func newDrawn() *Drawn { - return &Drawn{nodes: map[ast.Node]bool{}, edges: map[ast.Node]bool{}, steered: map[Origin]bool{}, - members: map[ast.Node]map[ast.Node]bool{}, generals: map[ast.Node]map[ast.Node]bool{}} + return &Drawn{nodes: map[ast.Node]bool{}, edges: map[ast.Node]bool{}, steered: map[Origin]bool{}, generalEdged: map[Origin]bool{}, + inherited: map[Origin]map[string]bool{}, members: map[ast.Node]map[ast.Node]bool{}, generals: map[ast.Node]map[ast.Node]bool{}} } -// Steers reports whether a Route steers the edge declared at origin; the edge -// of a specialization, typing or subsetting has none an edit writes yet. -func (d *Drawn) Steers(origin Origin) bool { - return d != nil && d.steered[origin] +// Steers reports whether a plain Route about the element declared at origin +// steers the edge of kind drawn for it: its membership edge, or its own edge +// to a general when no membership edge took the Route (relationshipRoute); the +// edge to a general drawn beside a membership edge has none an edit writes yet. +func (d *Drawn) Steers(origin Origin, kind EdgeKind) bool { + if d == nil || !d.steered[origin] { + return false + } + return !(kind == EdgeTyping || kind == EdgeSpecialization) || !d.generalEdged[origin] +} + +// Inherits reports whether the rendering drew the member named name as the +// element at owner's own, placed by a Layout about owner's name and its. +func (d *Drawn) Inherits(owner Origin, name string) bool { + return d != nil && d.inherited[owner][name] +} + +// noteSteered records that a plain Route about elem steers an edge drawn for it. +func (d *Drawn) noteSteered(elem *symbols.Symbol) { + if d != nil && elem != nil && elem.Decl != nil { + d.steered[elem.Origin()] = true + } +} + +// noteGeneralEdged records that elem's edge to a general follows a Route +// naming that general, not a plain Route about elem. +func (d *Drawn) noteGeneralEdged(elem *symbols.Symbol) { + if d != nil && elem != nil && elem.Decl != nil { + d.generalEdged[elem.Origin()] = true + } +} + +// noteInherited records that the member named name is drawn as owner's own. +func (d *Drawn) noteInherited(owner *symbols.Symbol, name string) { + if d == nil || owner == nil || owner.Decl == nil { + return + } + names, ok := d.inherited[owner.Origin()] + if !ok { + names = map[string]bool{} + d.inherited[owner.Origin()] = names + } + names[name] = true } // Edge reports whether the rendering draws sym as an edge a Route steers. @@ -80,7 +121,6 @@ func (d *Drawn) note(elem *symbols.Symbol, asEdge bool) { } if asEdge { d.edges[elem.Decl] = true - d.steered[elem.Origin()] = true } else { d.nodes[elem.Decl] = true } From 333744de46d17ec0501837653bc64a2c9c698c75 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 07:26:18 +0000 Subject: [PATCH 11/25] fix(view): keep the children of a pinned container where the file shows them Pinning a container the model does not place writes a sized Layout, and the draw.io writer lays the children of a sized container out by a different gap, so children left unplaced moved on the next export. The pin now gives every child the box the file shows, a child container pinned in turn, and a placed child a size only when the model or the file states one. A placed container without a size was fitted around its placed children as if their absolute Layouts were offsets from its corner; the probe now measures from the corner. Co-Authored-By: jason.han --- internal/check/edit/drawio_test.go | 29 +++++++++++++++++--- internal/ir/view/drawio.go | 12 ++++++-- internal/ir/view/drawio_import_diff.go | 20 ++++++++++---- internal/ir/view/drawio_import_match_test.go | 24 ++++++++++++++++ 4 files changed, 72 insertions(+), 13 deletions(-) diff --git a/internal/check/edit/drawio_test.go b/internal/check/edit/drawio_test.go index 41adb36890..63ff8fef02 100644 --- a/internal/check/edit/drawio_test.go +++ b/internal/check/edit/drawio_test.go @@ -83,9 +83,11 @@ type drawioMutation struct { edit func(cell string) string // kind is the edit expected, and check the cell of the re-export against the // edited one; a notice instead names what the import reports and leaves alone. - kind view.DrawioEditKind - notice string - check func(t *testing.T, edited, reexported *view.DrawioCell) + kind view.DrawioEditKind + // children asks that the edited cell's children keep their boxes as well. + children bool + notice string + check func(t *testing.T, edited, reexported *view.DrawioCell) } func drawioFirst(file *view.DrawioFile, want func(c *view.DrawioCell) bool) *view.DrawioCell { @@ -208,13 +210,20 @@ var drawioMutations = []drawioMutation{ t.Errorf("moved to %+v, re-exported at %+v", edited.Geometry, re.Geometry) } }}, - {name: "move container", pick: drawioContainer, + {name: "move container", pick: drawioContainer, children: true, edit: func(cell string) string { return drawioShift(cell, "y", 60) }, kind: view.DrawioEditLayout, check: func(t *testing.T, edited, re *view.DrawioCell) { if !drawioSameBox(edited.Geometry, re.Geometry) { t.Errorf("%s moved to %+v, re-exported at %+v", edited.ID, edited.Geometry, re.Geometry) } }}, + {name: "resize container", pick: drawioContainer, children: true, + edit: func(cell string) string { return drawioShift(cell, "width", 50) }, kind: view.DrawioEditLayout, + check: func(t *testing.T, edited, re *view.DrawioCell) { + if !drawioSameBox(edited.Geometry, re.Geometry) { + t.Errorf("%s resized to %+v, re-exported at %+v", edited.ID, edited.Geometry, re.Geometry) + } + }}, {name: "resize", pick: drawioLeaf, edit: func(cell string) string { return drawioShift(cell, "width", 50) }, kind: view.DrawioEditLayout, check: func(t *testing.T, edited, re *view.DrawioCell) { @@ -356,6 +365,18 @@ func TestDrawioRoundTrip(t *testing.T) { } mut.check(t, was, again) } + if mut.children { + for i := range editedFile.Cells { + child := &editedFile.Cells[i] + if child.Parent != cell.ID || !drawioPlaceable(child) || child.Relative { + continue + } + again := drawioSameCell(editedFile, string(content), child, re, string(res.Content)) + if again == nil || !drawioSameBox(child.Geometry, again.Geometry) { + t.Errorf("child %s %s was at %+v, re-exported at %+v", child.ID, child.FQN, child.Geometry, again) + } + } + } // The edited cells are where the file has them; unplaced neighbours may have flowed around them. if _, imp := drawioImport(t, redrawn, reoptions, edited); len(imp.Edits) != 0 { for _, e := range imp.Edits { diff --git a/internal/ir/view/drawio.go b/internal/ir/view/drawio.go index 23f4f7700c..7fbc5f1077 100644 --- a/internal/ir/view/drawio.go +++ b/internal/ir/view/drawio.go @@ -291,12 +291,18 @@ func (w *drawioWriter) fitted(node *Node) drawioBox { } // Children are settled from a provisional corner so their extent is known; // settle moves them to the final one afterwards. + // A placed container's placed children sit at their absolute Layouts, so + // their extent is measured from the container's own corner. + corner := drawioBox{Width: width, Height: height} + if g := node.Geometry; g.placed() { + corner.X, corner.Y = g.X, g.Y + } probe := &drawioWriter{labels: w.labels, ports: w.ports, boxes: map[string]drawioBox{}} - probe.settleChildren(node, drawioBox{Width: width, Height: height}) + probe.settleChildren(node, corner) right, bottom := 0.0, 0.0 for _, child := range node.Children { - right = math.Max(right, probe.boxes[child.ID].right()) - bottom = math.Max(bottom, probe.boxes[child.ID].bottom()) + right = math.Max(right, probe.boxes[child.ID].right()-corner.X) + bottom = math.Max(bottom, probe.boxes[child.ID].bottom()-corner.Y) } return drawioBox{Width: math.Max(width, right+drawioNestedPad), Height: math.Max(height, bottom+drawioNestedPad)} } diff --git a/internal/ir/view/drawio_import_diff.go b/internal/ir/view/drawio_import_diff.go index ad1bef0beb..b45a777bbb 100644 --- a/internal/ir/view/drawio_import_diff.go +++ b/internal/ir/view/drawio_import_diff.go @@ -431,8 +431,11 @@ func (m *drawioMatcher) frameShift(base *DrawioCell) DrawioBox { } // pin places a container the model does not place at its box on the page, and -// its placed children at theirs, so that a child moved inside it lands where -// the file has it: the Layouts turn absolute, as the notation reads them. +// its children at theirs, so that a child moved inside it lands where the file +// has it: the Layouts turn absolute, as the notation reads them. Placed, the +// container is no longer fitted around the children drawn in its frame and lays +// out those it does not place by its stated size, so it and every child it does +// not place keep the box the file shows, a child container pinned in turn. func (m *drawioMatcher) pin(id string) { if m.framed[id] { return @@ -442,17 +445,22 @@ func (m *drawioMatcher) pin(id string) { if container == nil || base == nil { return } - // Placed, the container is no longer fitted around children drawn in its - // frame, so it keeps the size the file shows. m.framePlace(m.editedOf(base), base, container, true) for i := range m.base.Cells { child := &m.base.Cells[i] node := m.nodes[child.ID] - if child.Parent != id || !child.Vertex || child.Relative || node == nil || !node.Geometry.placed() || m.framed[child.ID] { + if child.Parent != id || !child.Vertex || child.Relative || node == nil || m.framed[child.ID] { + continue + } + if !node.Geometry.placed() && m.framesChildren(child.ID) { + m.pin(child.ID) continue } m.framed[child.ID] = true - m.framePlace(m.editedOf(child), child, node, node.Geometry.HasSize) + edited := m.editedOf(child) + sized := !node.Geometry.placed() || node.Geometry.HasSize || + !near(fullBox(edited).Width, fullBox(child).Width) || !near(fullBox(edited).Height, fullBox(child).Height) + m.framePlace(edited, child, node, sized) } } diff --git a/internal/ir/view/drawio_import_match_test.go b/internal/ir/view/drawio_import_match_test.go index e5bf7a8aac..061b9b28aa 100644 --- a/internal/ir/view/drawio_import_match_test.go +++ b/internal/ir/view/drawio_import_match_test.go @@ -219,3 +219,27 @@ func TestDrawioImportReroutedTypingEdgeBesideMembershipIsReported(t *testing.T) t.Errorf("membership edge: edit %+v, want its one waypoint as a Route", edit) } } + +// Resizing a container the model does not place pins it with the size the +// file shows; the writer then lays its unplaced children out by that size, so +// each child is pinned too, at the box the file shows, not left to move. +func TestDrawioImportResizedUnplacedContainerPinsItsChildren(t *testing.T) { + c, golden := drawioGolden(t, "tree") + if !strings.Contains(drawioObjectXML(t, golden, "n6"), `fqn="VehicleViews::vehicleView::engineSubview"`) { + t.Fatalf("n6 is not engineSubview:\n%s", drawioObjectXML(t, golden, "n6")) + } + file, err := ReadDrawio([]byte(golden), c.view) + if err != nil { + t.Fatal(err) + } + resized := mutateCell(t, golden, "n6", func(cell string) string { return setGeometryAttr(t, cell, "width", "400") }) + imp := drawioGoldenImport(t, c, resized) + container := editOf(t, imp, DrawioEditLayout, "n6") + if !container.Layout.HasSize || container.Layout.Width != 400 { + t.Errorf("container = %+v, want its new width stated", container.Layout) + } + engine := editOf(t, imp, DrawioEditLayout, "n7") + if box := file.Cell("n7").Geometry; engine.FQN != "Vehicles::Engine" || engine.Layout.X != box.X || engine.Layout.Y != box.Y { + t.Errorf("Engine = %+v, want it pinned at its unchanged box %+v", engine.Layout, box) + } +} From 26780b96727851198e71e432298ed61bb6314b17 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 07:29:34 +0000 Subject: [PATCH 12/25] style(view): keep the pin, fitted and pinning-test comments to two lines Co-Authored-By: jason.han --- internal/ir/view/drawio.go | 6 ++---- internal/ir/view/drawio_import_diff.go | 8 ++------ internal/ir/view/drawio_import_match_test.go | 5 ++--- 3 files changed, 6 insertions(+), 13 deletions(-) diff --git a/internal/ir/view/drawio.go b/internal/ir/view/drawio.go index 7fbc5f1077..3480cb21fb 100644 --- a/internal/ir/view/drawio.go +++ b/internal/ir/view/drawio.go @@ -289,10 +289,8 @@ func (w *drawioWriter) fitted(node *Node) drawioBox { if len(node.Children) == 0 { return drawioBox{Width: width, Height: height} } - // Children are settled from a provisional corner so their extent is known; - // settle moves them to the final one afterwards. - // A placed container's placed children sit at their absolute Layouts, so - // their extent is measured from the container's own corner. + // Children are settled from the container's corner, placed ones sitting at + // their absolute Layouts, so their extent is known; settle moves them after. corner := drawioBox{Width: width, Height: height} if g := node.Geometry; g.placed() { corner.X, corner.Y = g.X, g.Y diff --git a/internal/ir/view/drawio_import_diff.go b/internal/ir/view/drawio_import_diff.go index b45a777bbb..27a676ae97 100644 --- a/internal/ir/view/drawio_import_diff.go +++ b/internal/ir/view/drawio_import_diff.go @@ -430,12 +430,8 @@ func (m *drawioMatcher) frameShift(base *DrawioCell) DrawioBox { return DrawioBox{X: shift.X, Y: shift.Y} } -// pin places a container the model does not place at its box on the page, and -// its children at theirs, so that a child moved inside it lands where the file -// has it: the Layouts turn absolute, as the notation reads them. Placed, the -// container is no longer fitted around the children drawn in its frame and lays -// out those it does not place by its stated size, so it and every child it does -// not place keep the box the file shows, a child container pinned in turn. +// pin places a container the model does not place, and every child drawn in its +// frame, at the box the file shows, a child container pinned in turn. func (m *drawioMatcher) pin(id string) { if m.framed[id] { return diff --git a/internal/ir/view/drawio_import_match_test.go b/internal/ir/view/drawio_import_match_test.go index 061b9b28aa..2ea2842c02 100644 --- a/internal/ir/view/drawio_import_match_test.go +++ b/internal/ir/view/drawio_import_match_test.go @@ -220,9 +220,8 @@ func TestDrawioImportReroutedTypingEdgeBesideMembershipIsReported(t *testing.T) } } -// Resizing a container the model does not place pins it with the size the -// file shows; the writer then lays its unplaced children out by that size, so -// each child is pinned too, at the box the file shows, not left to move. +// A resized container the model does not place is pinned with its size, which +// changes how its unplaced children are laid out: each is pinned at its box too. func TestDrawioImportResizedUnplacedContainerPinsItsChildren(t *testing.T) { c, golden := drawioGolden(t, "tree") if !strings.Contains(drawioObjectXML(t, golden, "n6"), `fqn="VehicleViews::vehicleView::engineSubview"`) { From c63fea550ed9f031a0dfe57fe213d8d5d5e07813 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 07:40:55 +0000 Subject: [PATCH 13/25] feat(cli,lsp,mcp): import an edited draw.io file back into the model sysml -import-diagram [-view ] [-dry-run] reads a draw.io file the drawio rendering form wrote, renders the view it draws, and writes what differs back as DiagramLayout metadata through the model editor; -dry-run prints the planned edits and a unified diff and writes nothing. The LSP answers the same as opensysml/importDiagram, a WorkspaceEdit like opensysml/applyModelEdit's, and the MCP serves it as import_diagram, a dry run until apply. The line diff behind the LSP's text edits moves to the workspace model package so the CLI prints the same diff. Co-Authored-By: jason.han --- cmd/sysml/import_diagram.go | 125 +++++++++++++ cmd/sysml/import_diagram_test.go | 117 +++++++++++++ cmd/sysml/main.go | 35 ++++ cmd/sysml/usage.go | 6 + internal/frontend/lsp/formatting.go | 39 +++-- internal/frontend/lsp/importdiagram.go | 164 ++++++++++++++++++ internal/frontend/lsp/importdiagram_test.go | 58 +++++++ internal/frontend/lsp/modeledit.go | 10 +- internal/frontend/lsp/server.go | 2 +- internal/frontend/mcp/catalog/catalog.go | 1 + internal/frontend/mcp/lsptools/importing.go | 93 ++++++++++ .../frontend/mcp/lsptools/importing_test.go | 60 +++++++ internal/frontend/mcp/lsptools/lsptools.go | 1 + internal/frontend/repl/modeledit.go | 19 ++ .../lsp => workspace/model}/linediff.go | 114 ++++++++++-- .../lsp => workspace/model}/linediff_test.go | 76 ++++---- packaging/man/man1/sysml-mcp.1 | 5 + packaging/man/man1/sysml.1 | 15 ++ 18 files changed, 867 insertions(+), 73 deletions(-) create mode 100644 cmd/sysml/import_diagram.go create mode 100644 cmd/sysml/import_diagram_test.go create mode 100644 internal/frontend/lsp/importdiagram.go create mode 100644 internal/frontend/lsp/importdiagram_test.go create mode 100644 internal/frontend/mcp/lsptools/importing.go create mode 100644 internal/frontend/mcp/lsptools/importing_test.go create mode 100644 internal/frontend/repl/modeledit.go rename internal/{frontend/lsp => workspace/model}/linediff.go (52%) rename internal/{frontend/lsp => workspace/model}/linediff_test.go (66%) diff --git a/cmd/sysml/import_diagram.go b/cmd/sysml/import_diagram.go new file mode 100644 index 0000000000..bd161a4a0b --- /dev/null +++ b/cmd/sysml/import_diagram.go @@ -0,0 +1,125 @@ +package main + +import ( + "errors" + "fmt" + "io/fs" + "os" + "strings" + + modeledit "github.com/Open-MBEE/OpenSysML/internal/check/edit" + "github.com/Open-MBEE/OpenSysML/internal/ir/view" + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" +) + +// runImportDiagram reads a draw.io file the drawio form wrote and the user +// edited, and writes what moved back into the model as DiagramLayout metadata. +func runImportDiagram(files []string) error { + if len(files) == 0 { + return errors.New("no model to import into; name the files the view is declared in, as `sysml model.sysml -import-diagram view.drawio`") + } + data, err := os.ReadFile(importDiagramPath) + if err != nil { + return err + } + file, err := view.ReadDrawio(data, importView) + if err != nil { + return fmt.Errorf("%s: %w", importDiagramPath, err) + } + sess, err := loadArtifactModel(files, "nothing was imported") + if err != nil { + return err + } + rendering, err := sess.OverlaidViewRendering(file.View, "", 0) + if err != nil { + return err + } + sites, err := sess.ViewSites() + if err != nil { + return err + } + imp, err := rendering.ImportDrawio(file, view.Options{Links: view.Links{Sites: sites}}) + if err != nil { + return fmt.Errorf("%s: %w", importDiagramPath, err) + } + reportDrawioImport(imp) + if len(imp.Edits) == 0 { + fmt.Fprintf(os.Stderr, "%s: nothing differs from the rendering of %s; nothing to import\n", importDiagramPath, file.View) + return nil + } + ops := modeledit.DrawioOperations(imp, false) + doc := drawioImportDocument(imp, files) + result, err := sess.ApplyModelEdit(doc, ops) + if err != nil { + var refusal *modeledit.Error + if errors.As(err, &refusal) { + return fmt.Errorf("%s: %s is not imported: %s", importDiagramPath, imp.Edits[refusal.OperationIndex].Label, refusal.Message) + } + return err + } + for _, edited := range result.Documents { + if dryRun { + fmt.Fprint(os.Stdout, model.UnifiedDiff(edited.Name, edited.Original, edited.Content)) + continue + } + if err := writeEdited(edited.Name, edited.Content); err != nil { + return err + } + fmt.Fprintf(os.Stderr, "%s: written\n", edited.Name) + } + if dryRun { + fmt.Fprintf(os.Stderr, "dry run: %d edits planned, nothing written\n", len(imp.Edits)) + } + return nil +} + +// reportDrawioImport lists the edits an import plans and what it leaves alone. +func reportDrawioImport(imp *view.DrawioImport) { + for _, e := range imp.Edits { + fmt.Fprintf(os.Stdout, "%s about %s\n", e.Kind, drawioEditTarget(e)) + } + for _, name := range imp.Unmatched { + fmt.Fprintf(os.Stderr, "%s: %s is not in the rendering; its cell is not imported\n", importDiagramPath, name) + } + for _, cell := range imp.Unknown { + fmt.Fprintf(os.Stderr, "%s: cell %s names no element; it is not imported\n", importDiagramPath, cell.ID) + } + for _, name := range imp.Deleted { + fmt.Fprintf(os.Stderr, "%s: %s was deleted from the file; the element stays in the model\n", importDiagramPath, name) + } + for _, notice := range imp.Notices { + fmt.Fprintf(os.Stderr, "%s: %s\n", importDiagramPath, notice) + } +} + +func drawioEditTarget(e view.DrawioEdit) string { + switch { + case e.Kind == view.DrawioEditCanvas: + return "the page" + case e.Edge != nil: + return e.Edge.String() + case e.FQN != "": + return e.FQN + } + return strings.ReplaceAll(e.Label, "\n", " ") +} + +// drawioImportDocument is the session document the edits are applied through: +// the one declaring the first edited element, else the first file named. +func drawioImportDocument(imp *view.DrawioImport, files []string) string { + for _, e := range imp.Edits { + if e.Origin.Located() { + return e.Origin.Doc + } + } + return files[0] +} + +// writeEdited rewrites a model file in place, keeping its permissions. +func writeEdited(name string, content []byte) error { + mode := fs.FileMode(0o644) + if info, err := os.Stat(name); err == nil { + mode = info.Mode().Perm() + } + return os.WriteFile(name, content, mode) +} diff --git a/cmd/sysml/import_diagram_test.go b/cmd/sysml/import_diagram_test.go new file mode 100644 index 0000000000..a956b02227 --- /dev/null +++ b/cmd/sysml/import_diagram_test.go @@ -0,0 +1,117 @@ +package main + +import ( + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "testing" +) + +const importDiagramModel = `package Plant { + private import Views::*; + private import OpenSysMLRenderings::*; + part def Pump; + part def Loop { + part pump : Pump; + part spare : Pump; + } + view loopView { + expose Loop; + render asInterconnectionDiagram; + } +} +` + +// exportDrawio renders the model's view as a draw.io file beside the model and +// moves the pump down in it, as a user dragging the cell would. +func exportDrawio(t *testing.T, binary, dir, modelPath string) string { + t.Helper() + out := filepath.Join(dir, "loop.drawio") + got := runFiles(t, binary, []string{modelPath}, "-render", "Plant::loopView", "-render-form", "drawio", "-o", out) + if got.status != exitHolds { + t.Fatalf("export: exit status = %d\n%s", got.status, got.output()) + } + data, err := os.ReadFile(out) + if err != nil { + t.Fatal(err) + } + cell := regexp.MustCompile(`(?s)(]*fqn="Plant::Loop::pump"[^>]*>.*? 0 || fromFormat != "": + fmt.Fprintln(os.Stderr, "sysml: -import-diagram cannot be combined with -query, -eval or -from") + return 2 + case modelChecks.requested(): + return refuse(modelChecks, + "-import-diagram writes the model and decides nothing about it; check it in its own run") + } + if status := resolveRunBounds(); status != 0 { + return status + } + if err := runImportDiagram(args); err != nil { + return fail(err) + } + return exitHolds + } + if renderAllDir != "" { switch { case renderView != "": diff --git a/cmd/sysml/usage.go b/cmd/sysml/usage.go index 6344daf965..b30cd11269 100644 --- a/cmd/sysml/usage.go +++ b/cmd/sysml/usage.go @@ -738,6 +738,9 @@ func registerFlags(fs *flag.FlagSet) { fs.StringVar(&renderView, "render", "", "Render this view of the model instead of running it, in the form its render member states; # renders every file loaded and #: one element, kind being tree, interconnection, state, action, case, mixed, sequence, table or matrix, without a declared view") fs.StringVar(&renderAllDir, "render-all", "", "Render every declared view into this directory") + fs.StringVar(&importDiagramPath, "import-diagram", "", "Import this diagrams.net (draw.io) file, written by -render-form drawio and edited in draw.io, back into the model: what moved, resized, folded, re-routed, recoloured or re-sized the page becomes DiagramLayout metadata in the view's body, written through the model editor; cells the rendering lacks are reported, never created or deleted") + fs.StringVar(&importView, "view", "", "The view -import-diagram imports, when the file has several pages; default the one page's view") + fs.BoolVar(&dryRun, "dry-run", false, "With -import-diagram, print the planned edits and a unified diff of each file they change, writing nothing") fs.StringVar(&graphsSubject, "graphs", "", "Write the lowered graph of this action or state machine, and of every behavior it performs, as the canonical graphs:1 JSON an external analysis engine is sent, to stdout or -output") fs.StringVar(&renderForm, "render-form", "", "Form -render, -render-all or -render-run writes: text, mermaid, markdown, dot, plantuml, d2, drawio, csv or tsv (csv and tsv for a table or matrix; svg and svg-animated for -render-run); D2 writes tree, interconnection, state, action, sequence, requirement, definition and package renderings, not case or mixed; drawio writes the graph-shaped kinds but sequence as a diagrams.net file; default from the destination for -render, each kind's machine form for -render-all") fs.StringVar(&renderPalette, "render-palette", "", "Palette the dot, mermaid, plantuml, d2 or drawio form fills nodes from, by keyword family: okabe-ito, tol-bright, tol-muted, tol-light, brewer-set2, brewer-dark2, viridis or cividis; default black and white") @@ -889,6 +892,9 @@ func optionGroups() []usage.OptionGroup { Options: []usage.Option{ usage.Opt("render", ""), usage.Opt("render-all", ""), + usage.Opt("import-diagram", fileArg), + usage.Opt("view", ""), + usage.Opt("dry-run", ""), usage.Opt("graphs", nameArg), usage.Opt("render-form", formArg), usage.Opt("render-palette", ""), diff --git a/internal/frontend/lsp/formatting.go b/internal/frontend/lsp/formatting.go index 0358d45cbf..c61882c59b 100644 --- a/internal/frontend/lsp/formatting.go +++ b/internal/frontend/lsp/formatting.go @@ -12,6 +12,7 @@ import ( "go.lsp.dev/protocol" "github.com/Open-MBEE/OpenSysML/internal/syntax/format" + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" ) // formatSource is the formatter behind both formatting requests; tests swap it @@ -119,38 +120,38 @@ func formatEdits(content, formatted []byte, w lineWindow) []protocol.TextEdit { oldNext, newNext := 0, 0 keys := lineKeys{ids: make(map[string]int, len(oldLines))} oldIDs, newIDs := keys.of(oldLines, len(content)), keys.of(newLines, len(formatted)) - hunks := append(diffLines(oldIDs, newIDs), hunk{len(oldLines), len(oldLines), len(newLines), len(newLines)}) + hunks := append(model.DiffLines(oldIDs, newIDs), model.Hunk{OldStart: len(oldLines), OldEnd: len(oldLines), NewStart: len(newLines), NewEnd: len(newLines)}) for k, h := range hunks { - surplus := (h.oldEnd - h.oldStart) - (h.newEnd - h.newStart) - if surplus > 0 && uniform(oldIDs[h.oldStart:h.oldEnd], oldIDs[h.oldStart]) && uniform(newIDs[h.newStart:h.newEnd], oldIDs[h.oldStart]) { + surplus := (h.OldEnd - h.OldStart) - (h.NewEnd - h.NewStart) + if surplus > 0 && uniform(oldIDs[h.OldStart:h.OldEnd], oldIDs[h.OldStart]) && uniform(newIDs[h.NewStart:h.NewEnd], oldIDs[h.OldStart]) { // Dropping from a run of like lines (blanks) has no natural place; widen // the hunk over the run so any selected line of it may be the one to go. - id := oldIDs[h.oldStart] - for h.oldStart > oldNext && oldIDs[h.oldStart-1] == id { - h.oldStart, h.newStart = h.oldStart-1, h.newStart-1 + id := oldIDs[h.OldStart] + for h.OldStart > oldNext && oldIDs[h.OldStart-1] == id { + h.OldStart, h.NewStart = h.OldStart-1, h.NewStart-1 } - for h.oldEnd < hunks[k+1].oldStart && oldIDs[h.oldEnd] == id { - h.oldEnd, h.newEnd = h.oldEnd+1, h.newEnd+1 + for h.OldEnd < hunks[k+1].OldStart && oldIDs[h.OldEnd] == id { + h.OldEnd, h.NewEnd = h.OldEnd+1, h.NewEnd+1 } } - pairLines(oldNext, h.oldStart, newNext) - oldNext, newNext = h.oldEnd, h.newEnd + pairLines(oldNext, h.OldStart, newNext) + oldNext, newNext = h.OldEnd, h.NewEnd if surplus == 0 { - pairLines(h.oldStart, h.oldEnd, h.newStart) + pairLines(h.OldStart, h.OldEnd, h.NewStart) continue } if surplus < 0 { // More new lines than old: pair in order, insert the rest after the hunk. - pairLines(h.oldStart, h.oldEnd, h.newStart) - if w.has(max(h.oldEnd-1, 0)) { - replace(oldOffsets[h.oldEnd], oldOffsets[h.oldEnd], strings.Join(newLines[h.newStart+h.oldEnd-h.oldStart:h.newEnd], "")) + pairLines(h.OldStart, h.OldEnd, h.NewStart) + if w.has(max(h.OldEnd-1, 0)) { + replace(oldOffsets[h.OldEnd], oldOffsets[h.OldEnd], strings.Join(newLines[h.NewStart+h.OldEnd-h.OldStart:h.NewEnd], "")) } continue } // The hunk drops lines (the formatter only ever removes blank ones). Take // them from the end of the window's part first, so a selection never has // to change a line outside it, and pair the survivors with the new lines. - from, to := max(h.oldStart, w.from), min(h.oldEnd, w.to) + from, to := max(h.OldStart, w.from), min(h.OldEnd, w.to) if from >= to { continue } @@ -159,15 +160,15 @@ func formatEdits(content, formatted []byte, w lineWindow) []protocol.TextEdit { dropped := func(i int) bool { switch { case i >= to: - return h.oldEnd-i <= outside + return h.OldEnd-i <= outside case i < from: - return i-h.oldStart < outside-(h.oldEnd-to) + return i-h.OldStart < outside-(h.OldEnd-to) default: return i >= dropFrom } } - j := h.newStart - for i := h.oldStart; i < h.oldEnd; i++ { + j := h.NewStart + for i := h.OldStart; i < h.OldEnd; i++ { if dropped(i) { continue } diff --git a/internal/frontend/lsp/importdiagram.go b/internal/frontend/lsp/importdiagram.go new file mode 100644 index 0000000000..cdf59876e9 --- /dev/null +++ b/internal/frontend/lsp/importdiagram.go @@ -0,0 +1,164 @@ +package lsp + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + + "go.lsp.dev/jsonrpc2" + "go.lsp.dev/protocol" + + modeledit "github.com/Open-MBEE/OpenSysML/internal/check/edit" + "github.com/Open-MBEE/OpenSysML/internal/ir/view" + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" +) + +// MethodImportDiagram is the request importing a draw.io file the drawio +// rendering form wrote and the user edited, as DiagramLayout metadata edits. +const MethodImportDiagram = "opensysml/importDiagram" + +// ImportDiagramParams names the document whose view the file draws, the file +// by path or content, and the view when the file has several pages. +type ImportDiagramParams struct { + TextDocument protocol.TextDocumentIdentifier `json:"textDocument"` + Path string `json:"path,omitempty"` + Content string `json:"content,omitempty"` + View string `json:"view,omitempty"` + // Inline writes the annotations beside the elements rather than in the view's body. + Inline bool `json:"inline,omitempty"` + // DryRun answers the edits without the client being asked to apply them. + DryRun bool `json:"dryRun,omitempty"` +} + +// ImportDiagramEdit is one edit the import plans: the annotation kind, the +// element it is about, and the file cell it comes from. +type ImportDiagramEdit struct { + Kind string `json:"kind"` + Target string `json:"target,omitempty"` + Label string `json:"label,omitempty"` + Cell string `json:"cell"` +} + +// ImportDiagramResult is what the import plans and what it leaves alone, and +// the WorkspaceEdit the plan amounts to, as opensysml/applyModelEdit answers it. +type ImportDiagramResult struct { + View string `json:"view"` + DryRun bool `json:"dryRun"` + Edits []ImportDiagramEdit `json:"edits"` + Unmatched []string `json:"unmatched,omitempty"` + Unknown []string `json:"unknown,omitempty"` + Deleted []string `json:"deleted,omitempty"` + Notices []string `json:"notices,omitempty"` + Refused []ModelEditRefusal `json:"refused,omitempty"` + Version int `json:"version"` + Edit *protocol.WorkspaceEdit `json:"edit,omitempty"` +} + +// importDiagramHandler dispatches opensysml/importDiagram and passes everything +// else on. +func (s *Server) importDiagramHandler(inner jsonrpc2.Handler) jsonrpc2.Handler { + return func(ctx context.Context, reply jsonrpc2.Replier, req jsonrpc2.Request) error { + if req.Method() != MethodImportDiagram { + return inner(ctx, reply, req) + } + var params ImportDiagramParams + if err := json.Unmarshal(req.Params(), ¶ms); err != nil { + return reply(ctx, nil, fmt.Errorf("%s: %w", jsonrpc2.ErrParse, err)) + } + result, err := s.ImportDiagram(¶ms) + if err != nil { + return reply(ctx, nil, err) + } + return reply(ctx, result, nil) + } +} + +// ImportDiagram answers opensysml/importDiagram: the file is read, the view it +// draws rendered as the export rendered it, and what differs becomes edits. +func (s *Server) ImportDiagram(params *ImportDiagramParams) (*ImportDiagramResult, error) { + name := uriToName(params.TextDocument.URI) + if s.ws.Document(name) == nil { + return nil, fmt.Errorf("%s: no such document", name) + } + data := []byte(params.Content) + switch { + case params.Path != "" && params.Content != "": + return nil, fmt.Errorf("%s: path and content are alternatives; give one", jsonrpc2.ErrInvalidParams) + case params.Path != "": + var err error + if data, err = os.ReadFile(params.Path); err != nil { + return nil, err + } + case params.Content == "": + return nil, fmt.Errorf("%s: the draw.io file is required, by path or content", jsonrpc2.ErrInvalidParams) + } + file, err := view.ReadDrawio(data, params.View) + if err != nil { + return nil, err + } + var ( + rendering *view.Rendering + snapshot *model.Snapshot + ) + if err := s.ws.Read(func(r *model.Reading) (err error) { + rendering, snapshot, err = r.RenderOverlaidView(name, file.View, "", nil, 0, nil) + if err == nil { + r.LinkSites(rendering, snapshot) + } + return err + }); err != nil { + return nil, err + } + imp, err := rendering.ImportDrawio(file, view.Options{Links: view.Links{Sites: snapshot.Sites()}}) + if err != nil { + return nil, err + } + out := &ImportDiagramResult{View: file.View, DryRun: params.DryRun, Edits: []ImportDiagramEdit{}, + Unmatched: imp.Unmatched, Deleted: imp.Deleted, Notices: imp.Notices} + for _, cell := range imp.Unknown { + out.Unknown = append(out.Unknown, cell.ID) + } + for _, e := range imp.Edits { + out.Edits = append(out.Edits, ImportDiagramEdit{Kind: e.Kind.String(), Target: importTarget(e), Label: e.Label, Cell: e.Cell}) + } + if len(imp.Edits) == 0 { + return out, nil + } + result, version, ok, err := s.ws.ApplyEdit(name, modeledit.DrawioOperations(imp, params.Inline), nil) + if !ok { + return nil, fmt.Errorf("%s: no such document", name) + } + out.Version = version + if err != nil { + var refusal *modeledit.Error + if !errors.As(err, &refusal) { + return nil, err + } + out.Refused = []ModelEditRefusal{s.refusal(refusal, s.ws.Document(name).Content)} + return out, nil + } + changes := make([]protocol.TextDocumentEdit, 0, len(result.Documents)) + for _, edited := range result.Documents { + change, err := documentChange(edited) + if err != nil { + return nil, err + } + changes = append(changes, change) + } + out.Edit = &protocol.WorkspaceEdit{DocumentChanges: changes} + return out, nil +} + +// importTarget names what an import edit is about: the element, the edge's +// ends, or the page. +func importTarget(e view.DrawioEdit) string { + switch { + case e.Kind == view.DrawioEditCanvas: + return "" + case e.Edge != nil: + return e.Edge.String() + } + return e.FQN +} diff --git a/internal/frontend/lsp/importdiagram_test.go b/internal/frontend/lsp/importdiagram_test.go new file mode 100644 index 0000000000..3aec028bc6 --- /dev/null +++ b/internal/frontend/lsp/importdiagram_test.go @@ -0,0 +1,58 @@ +package lsp + +import ( + "regexp" + "strconv" + "strings" + "testing" + + "go.lsp.dev/protocol" +) + +// movePump shifts the pump's cell down in a draw.io export, as a user dragging it would. +func movePump(t *testing.T, document string) string { + t.Helper() + cell := regexp.MustCompile(`(?s)(]*fqn="Plant::Loop::pump"[^>]*>.*?]*fqn="Plant::Loop::pump"[^>]*>.*? 0 { prev := &d.hunks[n-1] - if prev.oldEnd == h.oldStart && prev.newEnd == h.newStart { - prev.oldEnd, prev.newEnd = h.oldEnd, h.newEnd + if prev.OldEnd == h.OldStart && prev.NewEnd == h.NewStart { + prev.OldEnd, prev.NewEnd = h.OldEnd, h.NewEnd return } } @@ -54,10 +59,10 @@ func (d *differ) diff(e, f []int, i, j int) { case n == 0 && m == 0: return case n == 0: - d.add(hunk{oldStart: i, oldEnd: i, newStart: j, newEnd: j + m}) + d.add(Hunk{OldStart: i, OldEnd: i, NewStart: j, NewEnd: j + m}) return case m == 0: - d.add(hunk{oldStart: i, oldEnd: i + n, newStart: j, newEnd: j}) + d.add(Hunk{OldStart: i, OldEnd: i + n, NewStart: j, NewEnd: j}) return } total := n + m @@ -135,3 +140,80 @@ func (d *differ) arrays(size int) (forward, reverse []int) { clear(reverse) return forward, reverse } + +// UnifiedDiff renders what turns old into updated as a unified diff of name, +// three lines of context around each Hunk; empty when nothing changed. +func UnifiedDiff(name string, old, updated []byte) string { + oldLines, newLines := splitLines(string(old)), splitLines(string(updated)) + ids := map[string]int{} + intern := func(lines []string) []int { + out := make([]int, len(lines)) + for i, line := range lines { + id, ok := ids[line] + if !ok { + id = len(ids) + ids[line] = id + } + out[i] = id + } + return out + } + hunks := DiffLines(intern(oldLines), intern(newLines)) + if len(hunks) == 0 { + return "" + } + const context = 3 + var b strings.Builder + fmt.Fprintf(&b, "--- a/%s\n+++ b/%s\n", name, name) + for i := 0; i < len(hunks); { + start := hunks[i] + oldFrom, newFrom := max(start.OldStart-context, 0), max(start.NewStart-context, 0) + j := i + for j+1 < len(hunks) && hunks[j+1].OldStart-hunks[j].OldEnd <= 2*context { + j++ + } + end := hunks[j] + oldTo, newTo := min(end.OldEnd+context, len(oldLines)), min(end.NewEnd+context, len(newLines)) + fmt.Fprintf(&b, "@@ -%s +%s @@\n", hunkRange(oldFrom, oldTo), hunkRange(newFrom, newTo)) + o, n := oldFrom, newFrom + for k := i; k <= j; k++ { + for ; o < hunks[k].OldStart; o, n = o+1, n+1 { + b.WriteString(" " + oldLines[o]) + } + for ; o < hunks[k].OldEnd; o++ { + b.WriteString("-" + oldLines[o]) + } + for ; n < hunks[k].NewEnd; n++ { + b.WriteString("+" + newLines[n]) + } + } + for ; o < oldTo; o, n = o+1, n+1 { + b.WriteString(" " + oldLines[o]) + } + i = j + 1 + } + return b.String() +} + +// hunkRange spells a unified-diff range, 1-based, as "start,count". +func hunkRange(from, to int) string { + if to-from == 1 { + return fmt.Sprint(from + 1) + } + return fmt.Sprintf("%d,%d", from+1, to-from) +} + +// splitLines cuts text into lines that keep their terminator; a last line +// without one is marked as the diff marks it. +func splitLines(text string) []string { + if text == "" { + return nil + } + lines := strings.SplitAfter(text, "\n") + if last := lines[len(lines)-1]; last == "" { + lines = lines[:len(lines)-1] + } else { + lines[len(lines)-1] = last + "\n\\ No newline at end of file\n" + } + return lines +} diff --git a/internal/frontend/lsp/linediff_test.go b/internal/workspace/model/linediff_test.go similarity index 66% rename from internal/frontend/lsp/linediff_test.go rename to internal/workspace/model/linediff_test.go index f4ad585a45..7043c8c64f 100644 --- a/internal/frontend/lsp/linediff_test.go +++ b/internal/workspace/model/linediff_test.go @@ -1,4 +1,4 @@ -package lsp +package model import ( "math/rand" @@ -7,29 +7,29 @@ import ( ) // applyHunks rebuilds the new side from a and the hunks, the way an editor -// applying the edits would; any hunk out of order or overlapping fails. -func applyHunks(t *testing.T, a, b []string, hunks []hunk) []string { +// applying the edits would; any Hunk out of order or overlapping fails. +func applyHunks(t *testing.T, a, b []string, hunks []Hunk) []string { t.Helper() var out []string oldPos, newPos := 0, 0 for i, h := range hunks { - if h.oldStart < oldPos || h.oldEnd < h.oldStart || h.newStart < newPos || h.newEnd < h.newStart { - t.Fatalf("hunk %d %+v is out of order or inverted (old at %d, new at %d)", i, h, oldPos, newPos) + if h.OldStart < oldPos || h.OldEnd < h.OldStart || h.NewStart < newPos || h.NewEnd < h.NewStart { + t.Fatalf("Hunk %d %+v is out of order or inverted (old at %d, new at %d)", i, h, oldPos, newPos) } - if i > 0 && h.oldStart == oldPos && h.newStart == newPos { - t.Fatalf("hunk %d %+v abuts the one before it; they should have merged", i, h) + if i > 0 && h.OldStart == oldPos && h.NewStart == newPos { + t.Fatalf("Hunk %d %+v abuts the one before it; they should have merged", i, h) } - if h.oldStart-oldPos != h.newStart-newPos { - t.Fatalf("hunk %d %+v: %d shared old lines before it but %d new", i, h, h.oldStart-oldPos, h.newStart-newPos) + if h.OldStart-oldPos != h.NewStart-newPos { + t.Fatalf("Hunk %d %+v: %d shared old lines before it but %d new", i, h, h.OldStart-oldPos, h.NewStart-newPos) } - for k := 0; k < h.oldStart-oldPos; k++ { + for k := 0; k < h.OldStart-oldPos; k++ { if a[oldPos+k] != b[newPos+k] { - t.Fatalf("hunk %d %+v: line %d before it is not shared (%q vs %q)", i, h, oldPos+k, a[oldPos+k], b[newPos+k]) + t.Fatalf("Hunk %d %+v: line %d before it is not shared (%q vs %q)", i, h, oldPos+k, a[oldPos+k], b[newPos+k]) } } - out = append(out, a[oldPos:h.oldStart]...) - out = append(out, b[h.newStart:h.newEnd]...) - oldPos, newPos = h.oldEnd, h.newEnd + out = append(out, a[oldPos:h.OldStart]...) + out = append(out, b[h.NewStart:h.NewEnd]...) + oldPos, newPos = h.OldEnd, h.NewEnd } if len(a)-oldPos != len(b)-newPos { t.Fatalf("after the hunks, %d old lines remain but %d new", len(a)-oldPos, len(b)-newPos) @@ -56,24 +56,36 @@ func lcsLength(a, b []string) int { return prev[len(b)] } -// diffStrings diffs two line lists through the same interning formatEdits uses. -func diffStrings(a, b []string) []hunk { - keys := lineKeys{ids: map[string]int{}} - return diffLines(keys.of(a, 0), keys.of(b, 0)) +// diffStrings diffs two line lists, each distinct line interned to one id. +func diffStrings(a, b []string) []Hunk { + ids := map[string]int{} + intern := func(lines []string) []int { + out := make([]int, len(lines)) + for i, line := range lines { + id, ok := ids[line] + if !ok { + id = len(ids) + ids[line] = id + } + out[i] = id + } + return out + } + return DiffLines(intern(a), intern(b)) } -func checkDiff(t *testing.T, a, b []string) []hunk { +func checkDiff(t *testing.T, a, b []string) []Hunk { t.Helper() hunks := diffStrings(a, b) if got := applyHunks(t, a, b, hunks); strings.Join(got, "|") != strings.Join(b, "|") { - t.Fatalf("diffLines(%q, %q) = %+v\napplied: %q", a, b, hunks, got) + t.Fatalf("DiffLines(%q, %q) = %+v\napplied: %q", a, b, hunks, got) } edits := 0 for _, h := range hunks { - edits += (h.oldEnd - h.oldStart) + (h.newEnd - h.newStart) + edits += (h.OldEnd - h.OldStart) + (h.NewEnd - h.NewStart) } if want := len(a) + len(b) - 2*lcsLength(a, b); edits != want { - t.Fatalf("diffLines(%q, %q) = %+v: %d line edits, a shortest script has %d", a, b, hunks, edits, want) + t.Fatalf("DiffLines(%q, %q) = %+v: %d line edits, a shortest script has %d", a, b, hunks, edits, want) } return hunks } @@ -82,19 +94,19 @@ func TestDiffLinesHandCases(t *testing.T) { cases := []struct { name string a, b []string - want []hunk + want []Hunk }{ {"both empty", nil, nil, nil}, {"identical", []string{"a", "b"}, []string{"a", "b"}, nil}, - {"all inserted", nil, []string{"a", "b"}, []hunk{{0, 0, 0, 2}}}, - {"all deleted", []string{"a", "b"}, nil, []hunk{{0, 2, 0, 0}}}, - {"replace middle", []string{"a", "b", "c"}, []string{"a", "x", "c"}, []hunk{{1, 2, 1, 2}}}, - {"insert at end", []string{"a"}, []string{"a", "b"}, []hunk{{1, 1, 1, 2}}}, - {"insert at start", []string{"a"}, []string{"b", "a"}, []hunk{{0, 0, 0, 1}}}, - {"delete a blank of two", []string{"x", "", "", "y"}, []string{"x", "", "y"}, []hunk{{2, 3, 2, 2}}}, - {"two separate hunks", []string{"a", "b", "c", "d", "e"}, []string{"a", "B", "c", "d", "E"}, []hunk{{1, 2, 1, 2}, {4, 5, 4, 5}}}, - {"unequal replacement", []string{"a", "b", "c", "d"}, []string{"a", "x", "d"}, []hunk{{1, 3, 1, 2}}}, - {"nothing shared", []string{"a", "b"}, []string{"c", "d", "e"}, []hunk{{0, 2, 0, 3}}}, + {"all inserted", nil, []string{"a", "b"}, []Hunk{{0, 0, 0, 2}}}, + {"all deleted", []string{"a", "b"}, nil, []Hunk{{0, 2, 0, 0}}}, + {"replace middle", []string{"a", "b", "c"}, []string{"a", "x", "c"}, []Hunk{{1, 2, 1, 2}}}, + {"insert at end", []string{"a"}, []string{"a", "b"}, []Hunk{{1, 1, 1, 2}}}, + {"insert at start", []string{"a"}, []string{"b", "a"}, []Hunk{{0, 0, 0, 1}}}, + {"delete a blank of two", []string{"x", "", "", "y"}, []string{"x", "", "y"}, []Hunk{{2, 3, 2, 2}}}, + {"two separate hunks", []string{"a", "b", "c", "d", "e"}, []string{"a", "B", "c", "d", "E"}, []Hunk{{1, 2, 1, 2}, {4, 5, 4, 5}}}, + {"unequal replacement", []string{"a", "b", "c", "d"}, []string{"a", "x", "d"}, []Hunk{{1, 3, 1, 2}}}, + {"nothing shared", []string{"a", "b"}, []string{"c", "d", "e"}, []Hunk{{0, 2, 0, 3}}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/packaging/man/man1/sysml-mcp.1 b/packaging/man/man1/sysml-mcp.1 index e1048a90ae..5757449688 100644 --- a/packaging/man/man1/sysml-mcp.1 +++ b/packaging/man/man1/sysml-mcp.1 @@ -259,6 +259,11 @@ file with apply. lsp: Explain the element at a position: kind, qualified name, type, value and documentation. .TP +.B import_diagram +lsp: Import a draw.io file render_view wrote with form drawio and a user +edited, as DiagramLayout annotations in the view's body; a dry run until apply +(sysml \-import\-diagram, sysml \-view, sysml \-dry\-run). +.TP .B inlay_hints lsp: List the inferred types and values an editor would show inline for a document or range. diff --git a/packaging/man/man1/sysml.1 b/packaging/man/man1/sysml.1 index 6ab0dedd7a..5d38d13415 100644 --- a/packaging/man/man1/sysml.1 +++ b/packaging/man/man1/sysml.1 @@ -372,6 +372,21 @@ sequence, table or matrix, without a declared view .BR \-render\-all " \fIdir\fP" Render every declared view into this directory .TP +.BR \-import\-diagram " \fIfile\fP" +Import this diagrams.net (draw.io) file, written by \-render\-form drawio and +edited in draw.io, back into the model: what moved, resized, folded, +re\-routed, recoloured or re\-sized the page becomes DiagramLayout metadata in +the view's body, written through the model editor; cells the rendering lacks +are reported, never created or deleted +.TP +.BR \-view " \fIview\fP" +The view \-import\-diagram imports, when the file has several pages; default +the one page's view +.TP +.B \-dry\-run +With \-import\-diagram, print the planned edits and a unified diff of each +file they change, writing nothing +.TP .BR \-graphs " \fIname\fP" Write the lowered graph of this action or state machine, and of every behavior it performs, as the canonical graphs:1 JSON an external analysis engine is From 4690034c9bc369ff5e75e2295b702b63ae99067c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 07:42:30 +0000 Subject: [PATCH 14/25] =?UTF-8?q?feat(vscode):=20Import=20layout=20from=20?= =?UTF-8?q?draw.io=E2=80=A6=20applies=20an=20edited=20diagram=20as=20one?= =?UTF-8?q?=20undoable=20edit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: jason.han --- editors/vscode/package.json | 15 ++++++++ editors/vscode/src/diagram.ts | 53 ++++++++++++++++++++++++++++- editors/vscode/src/manifest.test.ts | 15 +++++++- editors/vscode/src/protocol.ts | 31 +++++++++++++++++ 4 files changed, 112 insertions(+), 2 deletions(-) diff --git a/editors/vscode/package.json b/editors/vscode/package.json index 9177358865..4c19ded22f 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -277,6 +277,11 @@ "category": "SysML", "title": "Export Diagram" }, + { + "command": "opensysml.importDiagram", + "category": "SysML", + "title": "Import layout from draw.io…" + }, { "command": "opensysml.diagram.popOut", "category": "SysML", @@ -325,6 +330,11 @@ "group": "1_diagram", "when": "resourceLangId == sysml || resourceLangId == kerml" }, + { + "command": "opensysml.importDiagram", + "group": "1_diagram", + "when": "resourceLangId == sysml || resourceLangId == kerml" + }, { "command": "opensysml.diagram.popOut", "group": "1_diagram", @@ -341,6 +351,11 @@ "command": "opensysml.exportDiagram", "group": "navigation@11", "when": "editorLangId == sysml || editorLangId == kerml" + }, + { + "command": "opensysml.importDiagram", + "group": "navigation@12", + "when": "editorLangId == sysml || editorLangId == kerml" } ], "explorer/context": [ diff --git a/editors/vscode/src/diagram.ts b/editors/vscode/src/diagram.ts index 1902c19b86..76bbe2d018 100644 --- a/editors/vscode/src/diagram.ts +++ b/editors/vscode/src/diagram.ts @@ -1,6 +1,6 @@ import { randomBytes } from "node:crypto"; import * as vscode from "vscode"; -import type { LanguageClient } from "vscode-languageclient/node"; +import type { LanguageClient, WorkspaceEdit as ProtocolWorkspaceEdit } from "vscode-languageclient/node"; import { describeRefusal, describeStale, @@ -35,6 +35,9 @@ import { PickerEntry, RENDER_CAPABILITY, RENDER_CHANGED_METHOD, + IMPORT_DIAGRAM_METHOD, + ImportDiagramParams, + ImportDiagramResult, RENDER_METHOD, RENDER_PALETTE_CAPABILITY, RELATED_ELEMENTS_METHOD, @@ -126,6 +129,7 @@ export class DiagramPanels implements vscode.Disposable { this.disposables.push( vscode.commands.registerCommand("opensysml.openDiagram", (target?: unknown) => this.open(target)), vscode.commands.registerCommand("opensysml.exportDiagram", (target?: unknown) => this.export(target)), + vscode.commands.registerCommand("opensysml.importDiagram", (target?: unknown) => this.importDiagram(target)), vscode.commands.registerCommand("opensysml.diagram.popOut", (target?: unknown) => this.popOut(target)), vscode.window.onDidChangeTextEditorSelection((event) => { for (const panel of this.panelsOf(event.textEditor.document.uri)) { @@ -549,6 +553,53 @@ export class DiagramPanels implements vscode.Disposable { // exportedView is the view to export when no panel shows one: the document's // sole drawable view, the model tree when it has none, or the user's pick among // several. Undefined when the user picks none. + // Import layout from draw.io…: a draw.io file Export Diagram wrote and the + // user edited becomes DiagramLayout metadata, applied as one undoable edit. + private async importDiagram(target?: unknown): Promise { + const resolved = this.resolve(target, "import a draw.io layout into it"); + const client = this.client; + if (!resolved || !this.available() || !client) { + return; + } + const picked = await vscode.window.showOpenDialog({ + title: "Import layout from draw.io…", + canSelectMany: false, + defaultUri: vscode.Uri.joinPath(resolved.uri, ".."), + filters: { "draw.io diagram": ["drawio", "xml"] }, + }); + const file = picked?.[0]; + if (!file) { + return; + } + const params: ImportDiagramParams = { textDocument: { uri: resolved.uri.toString() }, path: file.fsPath }; + let result: ImportDiagramResult; + try { + result = await client.sendRequest(IMPORT_DIAGRAM_METHOD, params); + } catch (error) { + void vscode.window.showErrorMessage(`Cannot import ${basename(file)}: ${error instanceof Error ? error.message : String(error)}`); + return; + } + const reports = [...(result.unmatched ?? []).map((name) => `${name} is not in the rendering`), ...(result.deleted ?? []).map((name) => `${name} was deleted in the file; the element stays`), ...(result.notices ?? [])]; + if (result.unknown?.length) { + reports.push(`${result.unknown.length} cells name no element`); + } + for (const refusal of result.refused ?? []) { + void vscode.window.showErrorMessage(`${basename(file)}: not imported: ${refusal.message}`); + return; + } + if (!result.edit || result.edits.length === 0) { + void vscode.window.showInformationMessage(`${basename(file)} matches the rendering of ${result.view}; nothing to import${reports.length ? ` (${reports.join("; ")})` : ""}`); + return; + } + const edit = await client.protocol2CodeConverter.asWorkspaceEdit(result.edit as ProtocolWorkspaceEdit); + if (!(await vscode.workspace.applyEdit(edit))) { + void vscode.window.showErrorMessage(`The layout edits from ${basename(file)} could not be applied`); + return; + } + const summary = `${result.edits.length} layout ${result.edits.length === 1 ? "edit" : "edits"} imported from ${basename(file)} into ${result.view}`; + void (reports.length ? vscode.window.showWarningMessage(`${summary}; ${reports.join("; ")}`) : vscode.window.showInformationMessage(summary)); + } + private async exportedView(client: LanguageClient, uri: string): Promise { const { declared, pseudo } = await listViews(client, uri, this.output); const implied = impliedView(declared); diff --git a/editors/vscode/src/manifest.test.ts b/editors/vscode/src/manifest.test.ts index 444762e96f..4a7d308171 100644 --- a/editors/vscode/src/manifest.test.ts +++ b/editors/vscode/src/manifest.test.ts @@ -23,6 +23,7 @@ interface MenuItem { const OPEN = "opensysml.openDiagram"; const EXPORT = "opensysml.exportDiagram"; +const IMPORT = "opensysml.importDiagram"; const POP_OUT = "opensysml.diagram.popOut"; const { commands, keybindings, menus } = manifest.contributes as { @@ -53,7 +54,7 @@ test("keybindings and menus name declared commands only", () => { }); test("the diagram commands are palette entries under the SysML category, not gated by a context key", () => { - for (const id of [OPEN, EXPORT, POP_OUT]) { + for (const id of [OPEN, EXPORT, IMPORT, POP_OUT]) { const command = commands.find((entry) => entry.command === id); assert.ok(command, `${id} is declared`); assert.equal(command.category, "SysML"); @@ -198,3 +199,15 @@ test("the style setting offers every look the panel does, each described, and fo assert.equal(setting.enumDescriptions?.length, STYLES.length); assert.ok(setting.markdownDescription, "the setting is described"); }); + +test("Import layout from draw.io sits beside Export Diagram in the editor title and context menus", () => { + for (const menu of ["editor/title", "editor/context"]) { + const items = menus[menu].filter((item) => item.command === IMPORT); + assert.equal(items.length, 1, `${menu} lists the import once`); + const exported = menus[menu].find((item) => item.command === EXPORT); + assert.ok(exported, `${menu} lists the export`); + assert.equal(items[0].when, exported.when, `${menu}: the import is scoped as the export is`); + scopedToModels(items[0].when, menu === "editor/title" ? "resourceLangId" : "editorLangId"); + } + assert.equal(commands.find((entry) => entry.command === IMPORT)?.title, "Import layout from draw.io…"); +}); diff --git a/editors/vscode/src/protocol.ts b/editors/vscode/src/protocol.ts index b83379e04a..141158278c 100644 --- a/editors/vscode/src/protocol.ts +++ b/editors/vscode/src/protocol.ts @@ -500,3 +500,34 @@ export type FromWebview = /** A node dropped on another: moved into it as a declaration, and placed where it was released. */ | { type: "reparent"; id: string; owner: string; nodes: NodePlacement[]; edges: EdgePlacement[]; drawn: number } | { type: "failed"; message: string }; + +export const IMPORT_DIAGRAM_METHOD = "opensysml/importDiagram"; + +export interface ImportDiagramParams { + textDocument: { uri: string }; + path?: string; + content?: string; + view?: string; + inline?: boolean; + dryRun?: boolean; +} + +export interface ImportDiagramEdit { + kind: string; + target?: string; + label?: string; + cell: string; +} + +export interface ImportDiagramResult { + view: string; + dryRun: boolean; + edits: ImportDiagramEdit[]; + unmatched?: string[]; + unknown?: string[]; + deleted?: string[]; + notices?: string[]; + refused?: { operation: number; failure: string; message: string }[]; + version: number; + edit?: unknown; +} From d0f87bd8bdb05064ad4b4095717e8fcda587d3aa Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 07:44:59 +0000 Subject: [PATCH 15/25] docs(view): document the draw.io round trip across the CLI, LSP, MCP and VS Code Co-Authored-By: jason.han --- changes/unreleased/drawio-import.added.md | 1 + docs/guide/08-editors.md | 12 ++++++++ docs/project/diagram-layout-annotations.md | 6 ++++ docs/project/roadmap.md | 9 ++++-- docs/project/view-rendering-forms.md | 22 +++++++++++++++ docs/reference/cli.md | 3 ++ docs/reference/lsp.md | 32 ++++++++++++++++++++++ docs/reference/sysml-mcp.md | 9 ++++++ packaging/mcp/server.json | 2 +- 9 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 changes/unreleased/drawio-import.added.md diff --git a/changes/unreleased/drawio-import.added.md b/changes/unreleased/drawio-import.added.md new file mode 100644 index 0000000000..b7bdd8f8de --- /dev/null +++ b/changes/unreleased/drawio-import.added.md @@ -0,0 +1 @@ +- A draw.io file the `drawio` rendering form wrote and a user edited in draw.io is imported back as `DiagramLayout` metadata (`Layout`, `Route`, `Style`, `Canvas`, `Note`, `Picture`) through the model editor, by `sysml -import-diagram [-view ] [-dry-run]`, the LSP request `opensysml/importDiagram`, the MCP tool `import_diagram` and the VS Code command *Import layout from draw.io…*; only cells that differ from a fresh export are written, and cells the model cannot carry are reported. The LSP model edit and MCP `apply_model_edit` gain `setNote` and `setPicture`. diff --git a/docs/guide/08-editors.md b/docs/guide/08-editors.md index 91fb6c7336..ae93809a3d 100644 --- a/docs/guide/08-editors.md +++ b/docs/guide/08-editors.md @@ -253,6 +253,18 @@ declared without one is inferred to have and the constant a value evaluates to f alone (Ctrl+Alt toggles them when `editor.inlayHints.enabled` is `offUnlessPressed`). +#### Importing a layout from draw.io + +`SysML: Import layout from draw.io…` (beside *Export Diagram* in the title bar's `…` menu and the +right-click menu) reads a `.drawio` file that *Export Diagram* wrote and you edited in draw.io +desktop, the diagrams.net editor or the VS Code draw.io extension, and writes what you moved, +resized, collapsed, re-routed or recoloured — and a changed page size or a moved note — back into +the model as `DiagramLayout` metadata in the view's body, exactly as dragging a node in the +diagram panel does, in one undoable edit. Cells that name no element, elements the rendering no +longer has and cells you deleted are reported, not created or deleted. The same import is +`sysml model.sysml -import-diagram view.drawio -dry-run` on the command line, which prints the +planned edits and a diff first. + ## Helix, Neovim and Zed [tree-sitter-sysml](https://github.com/Open-MBEE/tree-sitter-sysml) ships Tree-sitter grammars diff --git a/docs/project/diagram-layout-annotations.md b/docs/project/diagram-layout-annotations.md index 550988fb84..46eec49c20 100644 --- a/docs/project/diagram-layout-annotations.md +++ b/docs/project/diagram-layout-annotations.md @@ -373,6 +373,12 @@ exactly the bytes it produced before; the existing rendering goldens pin that. ## Interop +- **draw.io.** The `drawio` rendering form round-trips: a file it wrote and a user edited in + draw.io is read back by `sysml -import-diagram`, `opensysml/importDiagram`, the MCP + `import_diagram` tool or the VS Code *Import layout from draw.io…* command, which diff it + against a fresh export of the same view and write what moved as `setLayout`, `setRoute`, + `setStyle`, `setCanvas`, `setNote` and `setPicture` operations — the same edits the panel + writes, view-local by default ([the round trip](view-rendering-forms.md#round-trip)). - **Editors.** The LSP fields are what a graphical client (the VS Code diagram panel, a SysON-style editor) reads to place nodes where the model says. Writing positions back is `opensysml/applyModelEdit` with `setLayout`, `setRoute`, `setCanvas`, `setStyle`, diff --git a/docs/project/roadmap.md b/docs/project/roadmap.md index 538da56800..03884cb0a2 100644 --- a/docs/project/roadmap.md +++ b/docs/project/roadmap.md @@ -2771,8 +2771,13 @@ and declaration so a reader can match an edited file back to the model. Unplaced grid for draw.io's layout to arrange. Offered by `-render-form drawio`, `%render`, `%viz`, the LSP and MCP `render` requests and the VS Code export picker; tree, interconnection, state, action, case, mixed, requirement, definition and package renderings, not sequence -([the draw.io form](view-rendering-forms.md#drawio)). A document figure in that form, a reader -of positions edited in draw.io and an *open in draw.io* editor command are the open follow-ons. +([the draw.io form](view-rendering-forms.md#drawio)). The round trip landed next: a file edited +in draw.io is read back — compressed or not, matched to the model by the stamped names and +diffed against a fresh export, so only what moved becomes `DiagramLayout` metadata written +through the model editor — by `sysml -import-diagram` (with `-dry-run`), `opensysml/importDiagram`, +the MCP `import_diagram` tool and the VS Code *Import layout from draw.io…* command +([the round trip](view-rendering-forms.md#round-trip)). A document figure in that form and an +*open in draw.io* editor command are the open follow-ons. --- diff --git a/docs/project/view-rendering-forms.md b/docs/project/view-rendering-forms.md index efe2a2d044..4b6bac2f1f 100644 --- a/docs/project/view-rendering-forms.md +++ b/docs/project/view-rendering-forms.md @@ -1541,6 +1541,28 @@ likewise, hidden labels, typings, multiplicities, keywords, compartments and qua unwritten, so DOT, Mermaid, PlantUML, D2, text and `Rendering.Data` agree. A name none of the elements has is refused with the names there are. +### Round trip + +A draw.io file the form wrote can be edited in draw.io and read back: `sysml model.sysml +-import-diagram view.drawio [-view ] [-dry-run]`, the LSP request `opensysml/importDiagram`, +the MCP tool `import_diagram` and the VS Code command *Import layout from draw.io…* each render +the view the file names, export it again as the writer would, and compare the file with that +export cell by cell. Only what differs becomes an edit, applied through the model editor as a +drag in the diagram panel is: a moved, resized or collapsed node is a `Layout` (absolute +coordinates, as the notation reads them; a container the model did not place is pinned with its +children so a child moved inside it lands where the file has it), a re-routed edge a `Route` (a +straightened one clears it), a recoloured cell a `Style`, a changed page size a `Canvas`, a moved +note or picture a `Note` or `Picture`. Nodes and ports are matched by the `fqn` the writer stamps, +edges by their ends, kind and order, notes and pictures by position; positions within half a +pixel are unchanged, so an untouched export imports nothing and the routes the writer computed +itself are never written back unless the user moved them. The compressed form draw.io desktop +saves by default is read as the uncompressed one is, and a file with several pages needs the +view named. What the import cannot carry is reported, not written: cells naming no element +(nothing is created), elements the rendering lacks (renamed or gone), cells the file lost +(nothing is deleted), a port recoloured (ports are drawn by direction), a note re-anchored, a +label moved, and an edge re-routed that no `Route` steers yet, such as a typing edge drawn beside +its membership edge. `-dry-run` prints the planned edits and a unified diff and writes nothing. + ## Limiting what a view draws A tree or interconnection rendering draws the contents of every element the view exposes, as the diff --git a/docs/reference/cli.md b/docs/reference/cli.md index a602239610..b9bb6d2808 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -255,6 +255,9 @@ the same member-path parser as `Project` and `OrderBy`. | `-portable` | | With `-migrate`: append the OpenSysML library packages the output refers to, so the one file loads in a tool that ships only the standard library; the report names them (see [Portable output with `-portable`](sysml-v1-migration.md#portable-output-with--portable)) | | `-render ` | | Render this view of the model (every file named, loaded as one) instead of running it, in the form its `render` member states (see [Rendering a view](#rendering-a-view)) | | `-render-all ` | | Render every declared view into the directory, one artifact per view | +| `-import-diagram ` | | Import a diagrams.net (draw.io) file that `-render-form drawio` wrote and a user edited in draw.io: the view it names is rendered and exported again, and what differs — a node moved, resized or collapsed, an edge re-routed, a cell recoloured, the page resized, a note or picture moved — is written into the model as `DiagramLayout` metadata (`Layout`, `Route`, `Style`, `Canvas`, `Note`, `Picture`) in the view's body, through the model editor the diagram panel uses. Cells naming no element, elements the rendering lacks and deleted cells are reported, never created or deleted; so is a change no annotation carries yet. The model files are rewritten in place; the planned edits are listed on standard output (see [Round trip](../project/view-rendering-forms.md#round-trip)) | +| `-view ` | | The view `-import-diagram` imports when the file has several pages; by default the one page's view | +| `-dry-run` | | With `-import-diagram`, print the planned edits and a unified diff of each file they would change, and write nothing | | `-graphs ` | | Write the lowered graph of this action or state machine, and of every behavior it performs, as the canonical `graphs:1` JSON an external analysis engine is sent ([the `graphs:1` model form](external-engines.md#the-graphs1-model-form)) to stdout, or to `-output`. The subject is a qualified name, definition or usage; a name that is no behavior, or that nothing declares, is refused with exit status 2. Cannot be combined with checks, rendering, `-convert`, `-migrate`, `-query` or `-eval` | | `-render-form
` | | Form `-render` or `-render-all` writes: `text`, `mermaid`, `markdown`, `dot`, `plantuml`, `d2`, `drawio`, `csv` or `tsv` (default: destination-dependent for `-render`, each kind's machine-readable form for `-render-all`). D2 writes `tree`, `interconnection`, `state`, `action`, `sequence`, `requirement`, `definition` and `package` renderings; case and mixed views refuse it with a typed error. `drawio` writes an uncompressed diagrams.net file for every graph-shaped kind but `sequence` | | `-render-palette ` | | Palette the `dot`, `mermaid`, `plantuml`, `d2` or `drawio` form of `-render` or `-render-all` fills nodes with, by keyword family: `okabe-ito`, `tol-bright`, `tol-muted`, `tol-light`, `brewer-set2`, `brewer-dark2`, `viridis` or `cividis`; black and white when absent. Mermaid sequence diagrams cannot fill individual participants; text and Markdown ignore palettes. An unknown name is refused with the names there are (see [Rendering a view](#rendering-a-view)) | diff --git a/docs/reference/lsp.md b/docs/reference/lsp.md index 35979b817a..fab6d0363d 100644 --- a/docs/reference/lsp.md +++ b/docs/reference/lsp.md @@ -658,6 +658,38 @@ parse or analyze with an error the original did not have. The check is the same one diagnostics come from, so what the panel refuses is exactly what the editor would have underlined. +## `opensysml/importDiagram` (request) + +Imports a diagrams.net (draw.io) file that `opensysml/render` wrote with form `drawio` and a user +edited in draw.io, as the `DiagramLayout` edits `opensysml/applyModelEdit` would write. + +```json +{ "textDocument": { "uri": "file:///…/plant.sysml" }, "path": "/…/loop.drawio", "view": "PlantViews::loopView", "dryRun": true } +``` + +| Field | Meaning | +|---|---| +| `textDocument` | The document the view is rendered through, as `opensysml/render` takes it. | +| `path` / `content` | The draw.io file, by path on the server's host or as its text; one of the two. The compressed form draw.io desktop saves is read as the uncompressed one is. | +| `view` | The view to import when the file has several pages; omitted, the one page's view. | +| `inline` | Write the annotations beside the elements rather than in the view's body. | +| `dryRun` | Echoed in the result; the server never writes files, so a client applying nothing gets the same answer. | + +The server renders the view, exports it again as the writer would, and compares the file with +that export cell by cell: only what differs becomes an edit, matched to the model by the `fqn` +each cell carries (edges by their ends and kind, notes and pictures by position). The result lists +the planned `edits` (`kind`, `target`, `label`, `cell`), what is reported rather than imported — +`unmatched` elements the rendering lacks, `unknown` cells naming no element, `deleted` cells, and +`notices` for changes no annotation carries yet — and either `edit`, a `WorkspaceEdit` the client +applies as it applies `opensysml/applyModelEdit`'s, or `refused`, the planned edit the model +editor refused and why. An untouched export answers no edits and no `edit`. + +```json +{ "view": "PlantViews::loopView", "dryRun": true, "version": 3, + "edits": [{ "kind": "Layout", "target": "Plant::Loop::pump", "label": "«part»\npump : Pump", "cell": "n2" }], + "edit": { "documentChanges": [ … ] } } +``` + ## `opensysml/renderChanged` (notification, server → client) ```json diff --git a/docs/reference/sysml-mcp.md b/docs/reference/sysml-mcp.md index 28a870ba2e..63bd3a7f2b 100644 --- a/docs/reference/sysml-mcp.md +++ b/docs/reference/sysml-mcp.md @@ -260,6 +260,7 @@ without this page. | `documents` | List the document definitions (doc def) declared across the workspace, which render_document renders to Markdown. | no | | `format` | Format a SysML v2 / KerML document, or only the lines of a range, in the canonical style. | yes | | `hover` | Explain the element at a position: its kind, qualified name, type, value and documentation, as Markdown. | no | +| `import_diagram` | Import a draw.io file render_view wrote with form drawio and a user edited, as DiagramLayout annotations in the view's body; a dry run until apply. | yes | | `inlay_hints` | List the inferred types and values the editor would show inline for the features of a document or range. | no | | `open_document` | Hold a document open with the given text, as an editor buffer the server analyzes instead of the file on disk, so text can be checked (diagnostics, hover, render) before it is written. | yes | | `references` | Find every place in the workspace that refers to the element at a position. | no | @@ -353,6 +354,14 @@ without this page. through a symlink, is refused, and writes are performed through the resolved root itself, so a link repointed after the check cannot carry one outside it. The `lsp` tools can also work on text held open with `open_document` instead of a file on disk. +- **Round-tripping a diagram** is `render_view` with `form: "drawio"`, an edit in draw.io, then + `import_diagram` with the edited file (`file` on the host or `content` as text): the tool + renders the view again, diffs the file against that export and answers the `DiagramLayout` + annotations the differences amount to — `Layout`, `Route`, `Style`, `Canvas`, `Note`, + `Picture` — as the text edits `apply_model_edit` would make, per document. Call it first + without `apply` to read the plan and what it reports rather than imports (cells naming no + element, elements the rendering lacks, deleted cells, and changes no annotation carries yet, + such as a re-routed typing edge), then with `"apply": true` to write the files. - **Writing** happens only on request: a tool marked as writing (`format`, `rename`, `apply_model_edit`, `session_save`, ...) returns the edit or the file it would write and touches the disk only with `"apply": true`. The session tools that write change the session's diff --git a/packaging/mcp/server.json b/packaging/mcp/server.json index 651aebb925..a05e88e727 100644 --- a/packaging/mcp/server.json +++ b/packaging/mcp/server.json @@ -36,7 +36,7 @@ "_meta": { "io.modelcontextprotocol.registry/publisher-provided": { "toolGroups": ["lsp", "runtime", "session"], - "tools": 82 + "tools": 83 } } } From 701b88a3ddf750556d3d591dadb6ae2e05b38690 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 07:54:10 +0000 Subject: [PATCH 16/25] fix(cli,lsp,mcp): make the draw.io import safe to apply The CLI stages every edited model file beside its own and swaps the set in, restoring each on failure; a whole-model refusal is reported without indexing the planned edits; -import-diagram '' is refused; the dry-run diff spells an empty range as the line before it with a zero count. The LSP applies the import against the documents it rendered and answers stale when they moved. The MCP resolves the draw.io file under the workspace root as it does documents. Co-Authored-By: jason.han --- cmd/sysml/import_diagram.go | 106 ++++++++++++++++---- cmd/sysml/main.go | 4 + internal/frontend/lsp/importdiagram.go | 8 +- internal/frontend/mcp/lsptools/importing.go | 11 +- internal/workspace/model/linediff.go | 3 + internal/workspace/model/linediff_test.go | 20 ++++ internal/workspace/model/render.go | 14 +++ 7 files changed, 143 insertions(+), 23 deletions(-) diff --git a/cmd/sysml/import_diagram.go b/cmd/sysml/import_diagram.go index bd161a4a0b..8fdf51a908 100644 --- a/cmd/sysml/import_diagram.go +++ b/cmd/sysml/import_diagram.go @@ -5,6 +5,7 @@ import ( "fmt" "io/fs" "os" + "path/filepath" "strings" modeledit "github.com/Open-MBEE/OpenSysML/internal/check/edit" @@ -53,26 +54,104 @@ func runImportDiagram(files []string) error { if err != nil { var refusal *modeledit.Error if errors.As(err, &refusal) { - return fmt.Errorf("%s: %s is not imported: %s", importDiagramPath, imp.Edits[refusal.OperationIndex].Label, refusal.Message) + if i := refusal.OperationIndex; i >= 0 && i < len(imp.Edits) { + return fmt.Errorf("%s: %s is not imported: %s", importDiagramPath, drawioEditTarget(imp.Edits[i]), refusal.Message) + } + return fmt.Errorf("%s: not imported: %s", importDiagramPath, refusal.Message) } return err } - for _, edited := range result.Documents { - if dryRun { + if dryRun { + for _, edited := range result.Documents { fmt.Fprint(os.Stdout, model.UnifiedDiff(edited.Name, edited.Original, edited.Content)) - continue - } - if err := writeEdited(edited.Name, edited.Content); err != nil { - return err } + fmt.Fprintf(os.Stderr, "dry run: %d edits planned, nothing written\n", len(imp.Edits)) + return nil + } + if err := commitEditedDocuments(result.Documents); err != nil { + return err + } + for _, edited := range result.Documents { fmt.Fprintf(os.Stderr, "%s: written\n", edited.Name) } - if dryRun { - fmt.Fprintf(os.Stderr, "dry run: %d edits planned, nothing written\n", len(imp.Edits)) + return nil +} + +// commitEditedDocuments rewrites the model files as a set: each is staged +// beside its file and swapped in; a failure puts every file back as it was. +func commitEditedDocuments(documents []model.DocumentEdit) (err error) { + type swap struct { + target, backup string + movedAside bool + } + var done []swap + defer func() { + if err == nil { + return + } + for i := len(done) - 1; i >= 0; i-- { + restoreBackup(done[i].target, done[i].backup, done[i].movedAside) + } + }() + staged := make([]string, 0, len(documents)) + defer func() { + for _, name := range staged { + _ = os.Remove(name) + } + }() + for _, edited := range documents { + info, statErr := os.Stat(edited.Name) + if statErr != nil { + return statErr + } + name, stageErr := stageEdited(edited, info.Mode().Perm()) + if name != "" { + staged = append(staged, name) + } + if stageErr != nil { + return stageErr + } + backup, movedAside, backupErr := backUp(edited.Name) + if backupErr != nil { + return fmt.Errorf("write %s: %w", edited.Name, backupErr) + } + done = append(done, swap{edited.Name, backup, movedAside}) + if renameErr := os.Rename(name, edited.Name); renameErr != nil { + return fmt.Errorf("write %s: %w", edited.Name, renameErr) + } } + for _, s := range done { + _ = os.Remove(s.backup) + } + done = nil return nil } +// stageEdited writes the edited document to a temporary file beside its own, +// with the same permissions, and names it. +func stageEdited(edited model.DocumentEdit, perm fs.FileMode) (string, error) { + file, err := os.CreateTemp(filepath.Dir(edited.Name), ".sysml-staged-*") + if err != nil { + return "", fmt.Errorf("stage %s: %w", edited.Name, err) + } + name := file.Name() + if _, err := file.Write(edited.Content); err != nil { + _ = file.Close() + return name, fmt.Errorf("stage %s: %w", edited.Name, err) + } + if err := file.Sync(); err != nil { + _ = file.Close() + return name, fmt.Errorf("stage %s: %w", edited.Name, err) + } + if err := file.Close(); err != nil { + return name, fmt.Errorf("stage %s: %w", edited.Name, err) + } + if err := os.Chmod(name, perm); err != nil { + return name, fmt.Errorf("stage %s: %w", edited.Name, err) + } + return name, nil +} + // reportDrawioImport lists the edits an import plans and what it leaves alone. func reportDrawioImport(imp *view.DrawioImport) { for _, e := range imp.Edits { @@ -114,12 +193,3 @@ func drawioImportDocument(imp *view.DrawioImport, files []string) string { } return files[0] } - -// writeEdited rewrites a model file in place, keeping its permissions. -func writeEdited(name string, content []byte) error { - mode := fs.FileMode(0o644) - if info, err := os.Stat(name); err == nil { - mode = info.Mode().Perm() - } - return os.WriteFile(name, content, mode) -} diff --git a/cmd/sysml/main.go b/cmd/sysml/main.go index 1da955dee8..e2dbeb3801 100644 --- a/cmd/sysml/main.go +++ b/cmd/sysml/main.go @@ -451,6 +451,10 @@ func runCLI() int { } } + if flagGiven("import-diagram") && importDiagramPath == "" { + fmt.Fprintln(os.Stderr, "sysml: -import-diagram needs the draw.io file to import") + return 2 + } if importView != "" && importDiagramPath == "" { fmt.Fprintln(os.Stderr, "sysml: -view names the page -import-diagram reads; name the file with -import-diagram") return 2 diff --git a/internal/frontend/lsp/importdiagram.go b/internal/frontend/lsp/importdiagram.go index cdf59876e9..2db8c5ab6e 100644 --- a/internal/frontend/lsp/importdiagram.go +++ b/internal/frontend/lsp/importdiagram.go @@ -46,6 +46,7 @@ type ImportDiagramEdit struct { type ImportDiagramResult struct { View string `json:"view"` DryRun bool `json:"dryRun"` + Stale bool `json:"stale,omitempty"` Edits []ImportDiagramEdit `json:"edits"` Unmatched []string `json:"unmatched,omitempty"` Unknown []string `json:"unknown,omitempty"` @@ -126,11 +127,16 @@ func (s *Server) ImportDiagram(params *ImportDiagramParams) (*ImportDiagramResul if len(imp.Edits) == 0 { return out, nil } - result, version, ok, err := s.ws.ApplyEdit(name, modeledit.DrawioOperations(imp, params.Inline), nil) + result, version, ok, err := s.ws.ApplyEdit(name, modeledit.DrawioOperations(imp, params.Inline), snapshot.Documents()) if !ok { return nil, fmt.Errorf("%s: no such document", name) } out.Version = version + var stale *model.StaleError + if errors.As(err, &stale) { + out.Stale = true + return out, nil + } if err != nil { var refusal *modeledit.Error if !errors.As(err, &refusal) { diff --git a/internal/frontend/mcp/lsptools/importing.go b/internal/frontend/mcp/lsptools/importing.go index 948dbe7413..c501a4c74d 100644 --- a/internal/frontend/mcp/lsptools/importing.go +++ b/internal/frontend/mcp/lsptools/importing.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "path/filepath" "strings" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -54,9 +53,13 @@ func registerImporting(s *mcp.Server, h *Host) { if err != nil { return nil, ImportDiagramResult{}, err } - file := in.File - if file != "" && !filepath.IsAbs(file) { - file = filepath.Join(h.Root, file) + file := "" + if in.File != "" { + res, err := h.root.Resolve(in.File) + if err != nil { + return nil, ImportDiagramResult{}, err + } + file = res.Real } result, err := h.Server.ImportDiagram(&lsp.ImportDiagramParams{ TextDocument: protocol.TextDocumentIdentifier{URI: ref.uri()}, diff --git a/internal/workspace/model/linediff.go b/internal/workspace/model/linediff.go index 7d505d4747..04727bf9b1 100644 --- a/internal/workspace/model/linediff.go +++ b/internal/workspace/model/linediff.go @@ -197,6 +197,9 @@ func UnifiedDiff(name string, old, updated []byte) string { // hunkRange spells a unified-diff range, 1-based, as "start,count". func hunkRange(from, to int) string { + if from == to { + return fmt.Sprintf("%d,0", from) + } if to-from == 1 { return fmt.Sprint(from + 1) } diff --git a/internal/workspace/model/linediff_test.go b/internal/workspace/model/linediff_test.go index 7043c8c64f..9f698e8c80 100644 --- a/internal/workspace/model/linediff_test.go +++ b/internal/workspace/model/linediff_test.go @@ -175,3 +175,23 @@ func TestDiffLinesLongDocumentSparseChanges(t *testing.T) { t.Fatalf("hunks = %+v, want three", hunks) } } + +// A unified diff of an insertion names the line before it with a zero count, +// at the start, in the middle and at the end, as patch reads it. +func TestUnifiedDiffInsertionRanges(t *testing.T) { + cases := []struct{ name, old, updated, header string }{ + {"start", "b\nc\n", "a\nb\nc\n", "@@ -1,2 +1,3 @@"}, + {"middle", "a\nb\nc\nd\ne\nf\ng\nh\n", "a\nb\nc\nd\nX\ne\nf\ng\nh\n", "@@ -2,6 +2,7 @@"}, + {"end", "a\nb\n", "a\nb\nc\n", "@@ -1,2 +1,3 @@"}, + {"empty", "", "a\n", "@@ -0,0 +1 @@"}, + } + for _, c := range cases { + got := UnifiedDiff("m.sysml", []byte(c.old), []byte(c.updated)) + if !strings.Contains(got, c.header+"\n") { + t.Errorf("%s: diff lacks %q:\n%s", c.name, c.header, got) + } + } + if got := UnifiedDiff("m.sysml", []byte("a\n"), []byte("a\n")); got != "" { + t.Errorf("unchanged text diffs as %q", got) + } +} diff --git a/internal/workspace/model/render.go b/internal/workspace/model/render.go index 68e4592ade..215da90593 100644 --- a/internal/workspace/model/render.go +++ b/internal/workspace/model/render.go @@ -163,6 +163,20 @@ func (s *Snapshot) Document(name string) *Document { return s.docs[name] } +// Documents are the documents the snapshot was rendered from, in name order. +func (s *Snapshot) Documents() []*Document { + names := make([]string, 0, len(s.docs)) + for name := range s.docs { + names = append(names, name) + } + sort.Strings(names) + docs := make([]*Document, 0, len(names)) + for _, name := range names { + docs = append(docs, s.docs[name]) + } + return docs +} + // Sites returns the source locations for the rendering's origins. func (s *Snapshot) Sites() view.Sites { if s == nil || s.sites == nil { From 78dc4cd1728f559da2fc342a408fffc98eca4a82 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 08:11:50 +0000 Subject: [PATCH 17/25] test(cli,mcp): refuse an empty -import-diagram and a draw.io file outside the root Co-Authored-By: jason.han --- cmd/sysml/import_diagram_test.go | 2 +- internal/frontend/mcp/lsptools/importing_test.go | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/cmd/sysml/import_diagram_test.go b/cmd/sysml/import_diagram_test.go index a956b02227..f1c9435f13 100644 --- a/cmd/sysml/import_diagram_test.go +++ b/cmd/sysml/import_diagram_test.go @@ -108,7 +108,7 @@ func TestImportDiagramWritesTheLayoutIntoTheModel(t *testing.T) { func TestImportDiagramFlagsNeedTheFile(t *testing.T) { binary := buildCLI(t) - for _, args := range [][]string{{"-dry-run"}, {"-view", "Plant::loopView"}} { + for _, args := range [][]string{{"-dry-run"}, {"-view", "Plant::loopView"}, {"-import-diagram", ""}} { got := runStreams(t, binary, importDiagramModel, args...) if got.status != 2 || !strings.Contains(got.stderr, "-import-diagram") { t.Errorf("%v: status %d\n%s", args, got.status, got.output()) diff --git a/internal/frontend/mcp/lsptools/importing_test.go b/internal/frontend/mcp/lsptools/importing_test.go index 66b52bbca2..13d63676ed 100644 --- a/internal/frontend/mcp/lsptools/importing_test.go +++ b/internal/frontend/mcp/lsptools/importing_test.go @@ -1,6 +1,7 @@ package lsptools import ( + "path/filepath" "regexp" "strconv" "strings" @@ -64,3 +65,13 @@ func TestImportDiagramRoundTripsARenderedView(t *testing.T) { } f.fails("import_diagram", map[string]any{"path": "plant.sysml"}) } + +func TestImportDiagramReadsFilesUnderTheRootAlone(t *testing.T) { + f := newFixture(t) + write(t, f.root, "plant.sysml", plant) + write(t, filepath.Dir(f.root), "outside.drawio", "") + msg := f.fails("import_diagram", map[string]any{"path": "plant.sysml", "file": "../outside.drawio"}) + if !strings.Contains(msg, "outside.drawio") || strings.Contains(msg, "mxfile") { + t.Fatalf("outside file: %s", msg) + } +} From 4bf41fe37dccf4c3cf1644df7bba3f6fb2a45582 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 08:22:55 +0000 Subject: [PATCH 18/25] docs(self-model): list the opensysml.importDiagram command Co-Authored-By: jason.han --- examples/self-model/surfaces.sysml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/self-model/surfaces.sysml b/examples/self-model/surfaces.sysml index afc9a680c0..8f440fa803 100644 --- a/examples/self-model/surfaces.sysml +++ b/examples/self-model/surfaces.sysml @@ -264,7 +264,7 @@ package OpenSysMLSurfaces { attribute :>> goPackage = "editors/vscode"; attribute speaksTo : String = "sysml-lsp"; attribute languages : String = "sysml, kerml"; - attribute commands : String = "opensysml.restartServer, opensysml.openDiagram, opensysml.exportDiagram, opensysml.diagram.popOut, opensysml.renderDocument, opensysml.runElement"; + attribute commands : String = "opensysml.restartServer, opensysml.openDiagram, opensysml.exportDiagram, opensysml.importDiagram, opensysml.diagram.popOut, opensysml.renderDocument, opensysml.runElement"; attribute diagramPanel : String = "editors/vscode/src/webview"; attribute landingPage : String = "editors/vscode/src/landing"; attribute grammarGenerator : String = "editors/vscode/tools/gengrammar"; From 5ff3aa94f74672fffa94b3d255b0c1b9fabbbfab Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 08:27:24 +0000 Subject: [PATCH 19/25] fix(cli): write imported layouts through model symlinks and leave unchanged files alone The import resolves each edited document to the file it names, stages the new text beside that file and swaps it in, so a symlinked model keeps its link and the linked file is edited; a document the import read but left as it was is neither staged nor reported written. Co-Authored-By: jason.han --- cmd/sysml/import_diagram.go | 43 +++++++++++++++++++++----------- cmd/sysml/import_diagram_test.go | 40 +++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 14 deletions(-) diff --git a/cmd/sysml/import_diagram.go b/cmd/sysml/import_diagram.go index 8fdf51a908..914b5d1ee2 100644 --- a/cmd/sysml/import_diagram.go +++ b/cmd/sysml/import_diagram.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "errors" "fmt" "io/fs" @@ -72,7 +73,9 @@ func runImportDiagram(files []string) error { return err } for _, edited := range result.Documents { - fmt.Fprintf(os.Stderr, "%s: written\n", edited.Name) + if !bytes.Equal(edited.Original, edited.Content) { + fmt.Fprintf(os.Stderr, "%s: written\n", edited.Name) + } } return nil } @@ -99,24 +102,36 @@ func commitEditedDocuments(documents []model.DocumentEdit) (err error) { _ = os.Remove(name) } }() + targets := map[string]string{} for _, edited := range documents { - info, statErr := os.Stat(edited.Name) + if bytes.Equal(edited.Original, edited.Content) { + continue + } + target, statErr := filepath.EvalSymlinks(edited.Name) + if statErr != nil { + return statErr + } + if other, dup := targets[target]; dup { + return fmt.Errorf("write %s: %s is the same file", edited.Name, other) + } + targets[target] = edited.Name + info, statErr := os.Stat(target) if statErr != nil { return statErr } - name, stageErr := stageEdited(edited, info.Mode().Perm()) + name, stageErr := stageEdited(target, edited.Content, info.Mode().Perm()) if name != "" { staged = append(staged, name) } if stageErr != nil { return stageErr } - backup, movedAside, backupErr := backUp(edited.Name) + backup, movedAside, backupErr := backUp(target) if backupErr != nil { return fmt.Errorf("write %s: %w", edited.Name, backupErr) } - done = append(done, swap{edited.Name, backup, movedAside}) - if renameErr := os.Rename(name, edited.Name); renameErr != nil { + done = append(done, swap{target, backup, movedAside}) + if renameErr := os.Rename(name, target); renameErr != nil { return fmt.Errorf("write %s: %w", edited.Name, renameErr) } } @@ -129,25 +144,25 @@ func commitEditedDocuments(documents []model.DocumentEdit) (err error) { // stageEdited writes the edited document to a temporary file beside its own, // with the same permissions, and names it. -func stageEdited(edited model.DocumentEdit, perm fs.FileMode) (string, error) { - file, err := os.CreateTemp(filepath.Dir(edited.Name), ".sysml-staged-*") +func stageEdited(target string, content []byte, perm fs.FileMode) (string, error) { + file, err := os.CreateTemp(filepath.Dir(target), ".sysml-staged-*") if err != nil { - return "", fmt.Errorf("stage %s: %w", edited.Name, err) + return "", fmt.Errorf("stage %s: %w", target, err) } name := file.Name() - if _, err := file.Write(edited.Content); err != nil { + if _, err := file.Write(content); err != nil { _ = file.Close() - return name, fmt.Errorf("stage %s: %w", edited.Name, err) + return name, fmt.Errorf("stage %s: %w", target, err) } if err := file.Sync(); err != nil { _ = file.Close() - return name, fmt.Errorf("stage %s: %w", edited.Name, err) + return name, fmt.Errorf("stage %s: %w", target, err) } if err := file.Close(); err != nil { - return name, fmt.Errorf("stage %s: %w", edited.Name, err) + return name, fmt.Errorf("stage %s: %w", target, err) } if err := os.Chmod(name, perm); err != nil { - return name, fmt.Errorf("stage %s: %w", edited.Name, err) + return name, fmt.Errorf("stage %s: %w", target, err) } return name, nil } diff --git a/cmd/sysml/import_diagram_test.go b/cmd/sysml/import_diagram_test.go index f1c9435f13..1e5eed2081 100644 --- a/cmd/sysml/import_diagram_test.go +++ b/cmd/sysml/import_diagram_test.go @@ -115,3 +115,43 @@ func TestImportDiagramFlagsNeedTheFile(t *testing.T) { } } } + +// A model reached through a symlink is edited where the link points, the link +// kept; a document the import leaves as it was is not rewritten. +func TestImportDiagramWritesThroughSymlinksAndLeavesUnchangedFilesAlone(t *testing.T) { + binary := buildCLI(t) + dir := t.TempDir() + real := filepath.Join(dir, "src", "plant.sysml") + if err := os.MkdirAll(filepath.Dir(real), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(real, []byte(importDiagramModel), 0o644); err != nil { + t.Fatal(err) + } + link := filepath.Join(dir, "plant.sysml") + if err := os.Symlink(real, link); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + other := filepath.Join(dir, "other.sysml") + if err := os.WriteFile(other, []byte("package Other;\n"), 0o644); err != nil { + t.Fatal(err) + } + before, err := os.Stat(other) + if err != nil { + t.Fatal(err) + } + file := exportDrawio(t, binary, dir, link) + got := runFiles(t, binary, []string{link, other}, "-import-diagram", file) + if got.status != exitHolds || !strings.Contains(got.stderr, link+": written") || strings.Contains(got.stderr, other+": written") { + t.Fatalf("import: status %d\n%s", got.status, got.output()) + } + if info, err := os.Lstat(link); err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("plant.sysml is no longer a symlink: %v %v", info, err) + } + if text, _ := os.ReadFile(real); !strings.Contains(string(text), "Layout about Plant::Loop::pump") { + t.Errorf("linked model not edited:\n%s", text) + } + if after, err := os.Stat(other); err != nil || !os.SameFile(before, after) || !after.ModTime().Equal(before.ModTime()) { + t.Errorf("other.sysml rewritten though unchanged: %v %v", after, err) + } +} From c6853cbcdf3531376cb5e2d4f0b001e61ce8d8a4 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:04:49 +0000 Subject: [PATCH 20/25] fix(cli,view): name ports in a draw.io export as the import does The CLI passed declaration sites to the draw.io writer only with -render-link, so a plain export named a port by its usage path while the import's baseline named it by its declaration and an untouched export of a view with ports was reported twice per port. The drawio form now always gets the sites. The reader resolves a port's fraction-of-owner geometry to absolute coordinates with its offset, and a port is noticed as dragged only when it moved against its owner. Co-Authored-By: jason.han --- cmd/sysml/import_diagram_test.go | 72 ++++++++++++++++++++++++++ cmd/sysml/render.go | 4 +- cmd/sysml/render_run.go | 2 +- docs/reference/cli.md | 2 +- internal/ir/view/drawio_import.go | 29 ++++++++--- internal/ir/view/drawio_import_diff.go | 13 ++++- internal/ir/view/drawio_import_test.go | 8 ++- 7 files changed, 116 insertions(+), 14 deletions(-) diff --git a/cmd/sysml/import_diagram_test.go b/cmd/sysml/import_diagram_test.go index 1e5eed2081..7414f460e0 100644 --- a/cmd/sysml/import_diagram_test.go +++ b/cmd/sysml/import_diagram_test.go @@ -155,3 +155,75 @@ func TestImportDiagramWritesThroughSymlinksAndLeavesUnchangedFilesAlone(t *testi t.Errorf("other.sysml rewritten though unchanged: %v %v", after, err) } } + +const importDiagramPortsModel = `package PumpStation { + private import Views::*; + private import OpenSysMLRenderings::*; + part def Tank { port outlet; } + part def Pump { port inlet; } + part station { + part tank : Tank; + part pump : Pump; + connect tank.outlet to pump.inlet; + } + view stationView { + expose station; + render asInterconnectionDiagram; + } +} +` + +// The export names ports as the import's baseline does, so an untouched export +// of a view with ports and a connection imports nothing and reports nothing; a +// port dragged along its owner is a notice, a re-routed connection a Route. +func TestImportDiagramPortsRoundTripSilently(t *testing.T) { + binary := buildCLI(t) + dir := t.TempDir() + modelPath := filepath.Join(dir, "pumps.sysml") + if err := os.WriteFile(modelPath, []byte(importDiagramPortsModel), 0o600); err != nil { + t.Fatal(err) + } + out := filepath.Join(dir, "station.drawio") + got := runFiles(t, binary, []string{modelPath}, "-render", "PumpStation::stationView", "-render-form", "drawio", "-o", out) + if got.status != exitHolds { + t.Fatalf("export: exit status = %d\n%s", got.status, got.output()) + } + exported, err := os.ReadFile(out) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(exported), `fqn="PumpStation::Tank::outlet"`) { + t.Errorf("the export does not name the port by its declaration:\n%s", exported) + } + untouched := runFiles(t, binary, []string{modelPath}, "-import-diagram", out, "-dry-run") + if untouched.status != exitHolds || !strings.Contains(untouched.stderr, "nothing to import") || + strings.Contains(untouched.stderr, "not in the rendering") || strings.Contains(untouched.stderr, "deleted from the file") { + t.Fatalf("untouched export: status %d\n%s", untouched.status, untouched.output()) + } + + port := regexp.MustCompile(`(?s)(]*fqn="PumpStation::Tank::outlet"[^>]*>.*?` | | Render this view of the model (every file named, loaded as one) instead of running it, in the form its `render` member states (see [Rendering a view](#rendering-a-view)) | | `-render-all ` | | Render every declared view into the directory, one artifact per view | -| `-import-diagram ` | | Import a diagrams.net (draw.io) file that `-render-form drawio` wrote and a user edited in draw.io: the view it names is rendered and exported again, and what differs — a node moved, resized or collapsed, an edge re-routed, a cell recoloured, the page resized, a note or picture moved — is written into the model as `DiagramLayout` metadata (`Layout`, `Route`, `Style`, `Canvas`, `Note`, `Picture`) in the view's body, through the model editor the diagram panel uses. Cells naming no element, elements the rendering lacks and deleted cells are reported, never created or deleted; so is a change no annotation carries yet. The model files are rewritten in place; the planned edits are listed on standard output (see [Round trip](../project/view-rendering-forms.md#round-trip)) | +| `-import-diagram ` | | Import a diagrams.net (draw.io) file that `-render-form drawio` wrote and a user edited in draw.io: the view it names is rendered and exported again, and what differs — a node moved, resized or collapsed, an edge re-routed, a cell recoloured, the page resized, a note or picture moved — is written into the model as `DiagramLayout` metadata (`Layout`, `Route`, `Style`, `Canvas`, `Note`, `Picture`) in the view's body, through the model editor the diagram panel uses. Cells naming no element, elements the rendering lacks and deleted cells are reported, never created or deleted; so is a change no annotation carries yet. The annotations go in the view's body; the LSP request and MCP tool take `inline` to write them beside the elements instead, the CLI does not. The model files are rewritten in place; the planned edits are listed on standard output (see [Round trip](../project/view-rendering-forms.md#round-trip)) | | `-view ` | | The view `-import-diagram` imports when the file has several pages; by default the one page's view | | `-dry-run` | | With `-import-diagram`, print the planned edits and a unified diff of each file they would change, and write nothing | | `-graphs ` | | Write the lowered graph of this action or state machine, and of every behavior it performs, as the canonical `graphs:1` JSON an external analysis engine is sent ([the `graphs:1` model form](external-engines.md#the-graphs1-model-form)) to stdout, or to `-output`. The subject is a qualified name, definition or usage; a name that is no behavior, or that nothing declares, is refused with exit status 2. Cannot be combined with checks, rendering, `-convert`, `-migrate`, `-query` or `-eval` | diff --git a/internal/ir/view/drawio_import.go b/internal/ir/view/drawio_import.go index e425d07cf0..91d281bd76 100644 --- a/internal/ir/view/drawio_import.go +++ b/internal/ir/view/drawio_import.go @@ -44,11 +44,13 @@ type DrawioCell struct { Style map[string]string StyleText string // Geometry is the cell's box in absolute coordinates, every container's - // offset added, when HasGeometry; Relative marks an edge or label geometry, - // which is left as written. + // offset added, when HasGeometry. Relative marks a geometry stated as a + // fraction of its parent: a vertex's (a port's) is resolved like a nested + // cell's, with Offset added; an edge's or label's is left as written. Geometry DrawioBox HasGeometry bool Relative bool + Offset Point // Alternate is the full box a collapsed cell expands to, when stated. Alternate *DrawioBox // Points are an edge's waypoints, Source and Target the cells it joins. @@ -351,13 +353,22 @@ func readDrawioModel(model []byte) (*DrawioFile, error) { case "Array": points = len(open) > 0 && attrs["as"] == "points" case "mxPoint": - if points && len(open) > 0 { + if len(open) == 0 { + break + } + cell := open[len(open)-1] + if points { box, err := drawioBoxOf(attrs) if err != nil { - return nil, fmt.Errorf("cell %s: waypoint: %w", open[len(open)-1].ID, err) + return nil, fmt.Errorf("cell %s: waypoint: %w", cell.ID, err) } - cell := open[len(open)-1] cell.Points = append(cell.Points, Point{X: box.X, Y: box.Y}) + } else if attrs["as"] == "offset" { + box, err := drawioBoxOf(attrs) + if err != nil { + return nil, fmt.Errorf("cell %s: offset: %w", cell.ID, err) + } + cell.Offset = Point{X: box.X, Y: box.Y} } } case xml.EndElement: @@ -420,7 +431,7 @@ func (f *DrawioFile) resolveGeometry() error { resolved := map[string]bool{} var resolve func(cell *DrawioCell, chain int) error resolve = func(cell *DrawioCell, chain int) error { - if resolved[cell.ID] || !cell.Vertex || !cell.HasGeometry || cell.Relative { + if resolved[cell.ID] || !cell.Vertex || !cell.HasGeometry { return nil } if chain > len(f.Cells) { @@ -439,7 +450,11 @@ func (f *DrawioFile) resolveGeometry() error { if err := resolve(parent, chain+1); err != nil { return err } - if parent.Vertex && parent.HasGeometry && !parent.Relative { + if parent.Vertex && parent.HasGeometry { + if cell.Relative { + cell.Geometry.X = cell.Geometry.X*parent.Geometry.Width + cell.Offset.X + cell.Geometry.Y = cell.Geometry.Y*parent.Geometry.Height + cell.Offset.Y + } cell.Geometry.X += parent.Geometry.X cell.Geometry.Y += parent.Geometry.Y } diff --git a/internal/ir/view/drawio_import_diff.go b/internal/ir/view/drawio_import_diff.go index 27a676ae97..e340cc6162 100644 --- a/internal/ir/view/drawio_import_diff.go +++ b/internal/ir/view/drawio_import_diff.go @@ -335,7 +335,7 @@ func (m *drawioMatcher) diffVertex(cell, base *DrawioCell) { origin, isPort := m.originOf(base.ID) edit := DrawioEdit{Cell: cell.ID, Label: cell.Label, FQN: m.targetName(cell, base), Origin: origin} if isPort { - if !sameBox(boxOf(cell), boxOf(base)) { + if !sameBox(ownerRelative(cell, m.file), ownerRelative(base, m.base)) { m.out.Notices = append(m.out.Notices, fmt.Sprintf("%s: a port is drawn on its owner's border; its position in the file is not imported", cell.FQN)) } } else if layout := m.layoutDiff(cell, base); layout != nil { @@ -683,6 +683,17 @@ func drawioColour(v string) string { // boxOf is a cell's box; fullBox the box it expands to when collapsed. func boxOf(cell *DrawioCell) DrawioBox { return cell.Geometry } +// ownerRelative is a port's box against its owner's corner, so a port that +// moved with its owner is where it was. +func ownerRelative(cell *DrawioCell, f *DrawioFile) DrawioBox { + box := cell.Geometry + if owner := f.Cell(cell.Parent); owner != nil && owner.Vertex && owner.HasGeometry { + box.X -= owner.Geometry.X + box.Y -= owner.Geometry.Y + } + return box +} + func fullBox(cell *DrawioCell) DrawioBox { box := cell.Geometry if cell.Collapsed && cell.Alternate != nil { diff --git a/internal/ir/view/drawio_import_test.go b/internal/ir/view/drawio_import_test.go index cc7f838b85..668d0a3624 100644 --- a/internal/ir/view/drawio_import_test.go +++ b/internal/ir/view/drawio_import_test.go @@ -201,8 +201,12 @@ func TestDrawioImportNestedChildResolvesAbsoluteCoordinates(t *testing.T) { if pump.Geometry.X != loop.Geometry.X+x || pump.Geometry.Y != loop.Geometry.Y+y { t.Errorf("pump at %+v, want the loop's %+v offset by (%v, %v)", pump.Geometry, loop.Geometry, x, y) } - if port := file.Cell("n1:n1.0"); port == nil || !port.Relative || port.Geometry.X != 0.5 { - t.Errorf("port = %+v, want its relative geometry kept", port) + port := file.Cell("n1:n1.0") + if port == nil || !port.Relative || port.Parent != pump.ID { + t.Fatalf("port = %+v, want the pump's relative port", port) + } + if want := pump.Geometry.X + 0.5*pump.Geometry.Width + port.Offset.X; port.Geometry.X != want || port.Offset.X == 0 { + t.Errorf("port at x=%v, want %v: its fraction of the pump's width plus its offset", port.Geometry.X, want) } } From 3c4611caf66820781a6c86d00dcedb9065599ca1 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:14:19 +0000 Subject: [PATCH 21/25] fix(view): place a port by its fraction of its owner when comparing imports A relative port keeps its fraction and offset, and the import compares ports on an owner of the baseline's size, so resizing the owner moves no port. Co-Authored-By: jason.han --- cmd/sysml/import_diagram_test.go | 20 +++++++++++++++++--- internal/ir/view/drawio_import.go | 12 ++++++------ internal/ir/view/drawio_import_diff.go | 17 ++++++++++++----- 3 files changed, 35 insertions(+), 14 deletions(-) diff --git a/cmd/sysml/import_diagram_test.go b/cmd/sysml/import_diagram_test.go index 7414f460e0..3e3972c0ee 100644 --- a/cmd/sysml/import_diagram_test.go +++ b/cmd/sysml/import_diagram_test.go @@ -173,9 +173,8 @@ const importDiagramPortsModel = `package PumpStation { } ` -// The export names ports as the import's baseline does, so an untouched export -// of a view with ports and a connection imports nothing and reports nothing; a -// port dragged along its owner is a notice, a re-routed connection a Route. +// An untouched export of a view with ports imports and reports nothing; a port +// dragged along its owner is a notice, a resized owner a Layout, a re-route a Route. func TestImportDiagramPortsRoundTripSilently(t *testing.T) { binary := buildCLI(t) dir := t.TempDir() @@ -214,6 +213,21 @@ func TestImportDiagramPortsRoundTripSilently(t *testing.T) { t.Errorf("moved port: status %d\n%s", moved.status, moved.output()) } + tank := regexp.MustCompile(`(?s)(]*fqn="PumpStation::station::tank"[^>]*>.*?]*width=")([^"]*)(")`) + tm := tank.FindStringSubmatch(string(exported)) + if tm == nil { + t.Fatalf("no tank cell with a width:\n%s", exported) + } + width, _ := strconv.ParseFloat(tm[2], 64) + resizedTank := filepath.Join(dir, "resized.drawio") + if err := os.WriteFile(resizedTank, []byte(tank.ReplaceAllString(string(exported), "${1}"+strconv.FormatFloat(width+60, 'f', -1, 64)+"${3}")), 0o600); err != nil { + t.Fatal(err) + } + resized := runFiles(t, binary, []string{modelPath}, "-import-diagram", resizedTank, "-dry-run") + if resized.status != exitHolds || !strings.Contains(resized.stdout, "Layout about PumpStation::station::tank") || strings.Contains(resized.stderr, "owner's border") { + t.Errorf("resized owner: status %d\n%s", resized.status, resized.output()) + } + bend := regexp.MustCompile(`(?s)(kind="connection".*? Date: Thu, 29 Oct 2026 09:30:38 +0000 Subject: [PATCH 22/25] fix(view): notice a port dragged onto another cell in a draw.io import A port stays on its owner, so a file that attaches it elsewhere is reported rather than silently compared on its old owner. Co-Authored-By: jason.han --- internal/ir/view/drawio_import_diff.go | 17 ++++++++++++++++- internal/ir/view/drawio_import_test.go | 12 ++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/internal/ir/view/drawio_import_diff.go b/internal/ir/view/drawio_import_diff.go index ae024f7789..5a725c6c2c 100644 --- a/internal/ir/view/drawio_import_diff.go +++ b/internal/ir/view/drawio_import_diff.go @@ -339,7 +339,10 @@ func (m *drawioMatcher) diffVertex(cell, base *DrawioCell) { if owner != nil { size = owner.Geometry } - if !sameBox(portPlace(cell, m.file.Cell(cell.Parent), size), portPlace(base, owner, size)) { + switch fileOwner := m.file.Cell(cell.Parent); { + case !sameOwner(fileOwner, owner): + m.out.Notices = append(m.out.Notices, fmt.Sprintf("%s: a port was moved to another cell; it stays on its owner, so the move is not imported", cell.FQN)) + case !sameBox(portPlace(cell, fileOwner, size), portPlace(base, owner, size)): m.out.Notices = append(m.out.Notices, fmt.Sprintf("%s: a port is drawn on its owner's border; its position in the file is not imported", cell.FQN)) } } else if layout := m.layoutDiff(cell, base); layout != nil { @@ -687,6 +690,18 @@ func drawioColour(v string) string { // boxOf is a cell's box; fullBox the box it expands to when collapsed. func boxOf(cell *DrawioCell) DrawioBox { return cell.Geometry } +// sameOwner tells whether two cells, one of each file, are the same owner: the +// same element when both are semantic, else the same cell. +func sameOwner(a, b *DrawioCell) bool { + if a == nil || b == nil { + return a == b + } + if a.FQN != "" || b.FQN != "" { + return a.FQN == b.FQN + } + return a.ID == b.ID +} + // portPlace is where a port sits on an owner of size: a relative port's fraction // of it plus its offset, an absolute port's box against its own owner's corner. func portPlace(cell, owner *DrawioCell, size DrawioBox) DrawioBox { diff --git a/internal/ir/view/drawio_import_test.go b/internal/ir/view/drawio_import_test.go index 668d0a3624..e3e89066bc 100644 --- a/internal/ir/view/drawio_import_test.go +++ b/internal/ir/view/drawio_import_test.go @@ -226,6 +226,18 @@ func TestDrawioImportReroutedEdgeIsARoute(t *testing.T) { } } +// A port dragged onto another node stays on its owner; the move is a notice. +func TestDrawioImportReparentedPortIsANotice(t *testing.T) { + golden, _ := os.ReadFile(filepath.Join("testdata", layoutCase.name+".drawio.golden")) + document := mutateCell(t, string(golden), "n1:n1.0", func(cell string) string { + return strings.Replace(cell, `parent="n1"`, `parent="n0"`, 1) + }) + imp := drawioGoldenImport(t, layoutCase, document) + if len(imp.Edits) != 0 || len(imp.Notices) != 1 || !strings.Contains(imp.Notices[0], "Plant::Pump::outlet: a port was moved to another cell") { + t.Errorf("edits %+v, notices %q; want one notice of the moved port and no edit", imp.Edits, imp.Notices) + } +} + func TestDrawioImportStraightenedEdgeClearsItsRoute(t *testing.T) { golden, _ := os.ReadFile(filepath.Join("testdata", layoutCase.name+".drawio.golden")) document := mutateCell(t, string(golden), "e0", func(cell string) string { From fc13fdee1d1f0ca17cc58b7b044882b17e4d8c01 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:32:35 +0000 Subject: [PATCH 23/25] fix(lsp): hand the draw.io writer the declaration sites on every export opensysml/render linked sites only with a link template, so a server export, and the MCP's render_view and the editor's export over it, named ports by their usage path while the import's baseline named them by their declaration. Co-Authored-By: jason.han --- internal/frontend/lsp/importdiagram_test.go | 37 +++++++++++++++++++++ internal/frontend/lsp/render.go | 2 +- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/internal/frontend/lsp/importdiagram_test.go b/internal/frontend/lsp/importdiagram_test.go index 3aec028bc6..80612d987f 100644 --- a/internal/frontend/lsp/importdiagram_test.go +++ b/internal/frontend/lsp/importdiagram_test.go @@ -56,3 +56,40 @@ func TestImportDiagramAnswersTheLayoutsAsAWorkspaceEdit(t *testing.T) { t.Error("a request without a file was answered") } } + +const portsModel = `package PumpStation { + private import Views::*; + private import OpenSysMLRenderings::*; + part def Tank { port outlet; } + part def Pump { port inlet; } + part station { + part tank : Tank; + part pump : Pump; + connect tank.outlet to pump.inlet; + } + view stationView { + expose station; + render asInterconnectionDiagram; + } +} +` + +// The server's export names ports by their declaration like the CLI's, so an +// untouched export of a view with ports imports and reports nothing. +func TestRenderedPortsImportSilently(t *testing.T) { + s, docURI := renderServer(t, "pumps.sysml", portsModel) + exported, err := s.Render(&RenderParams{TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, View: "PumpStation::stationView", Form: "drawio"}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(exported.Artifact, `fqn="PumpStation::Tank::outlet"`) || strings.Contains(exported.Artifact, `fqn="PumpStation::station::tank::outlet"`) { + t.Errorf("export names ports by their usage path:\n%s", exported.Artifact) + } + out, err := s.ImportDiagram(&ImportDiagramParams{TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, Content: exported.Artifact}) + if err != nil { + t.Fatal(err) + } + if len(out.Edits)+len(out.Unmatched)+len(out.Unknown)+len(out.Deleted)+len(out.Notices) != 0 || out.Edit != nil { + t.Errorf("untouched export imports %+v, want nothing", out) + } +} diff --git a/internal/frontend/lsp/render.go b/internal/frontend/lsp/render.go index e291a1057f..ede3c732a6 100644 --- a/internal/frontend/lsp/render.go +++ b/internal/frontend/lsp/render.go @@ -429,7 +429,7 @@ func (s *Server) Render(params *RenderParams) (*RenderResult, error) { verdicts = rt.RequirementVerdicts() } rendering, snapshot, err = r.RenderOverlaidView(name, params.View, overlay, verdicts, params.Depth, params.Include) - if err == nil && params.LinkTemplate != "" { + if err == nil && (params.LinkTemplate != "" || view.Form(params.Form) == view.FormDrawio) { r.LinkSites(rendering, snapshot) } if err == nil { From eef08d67ce3d56956aa2f461548a576a30cc51e5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:03:58 +0000 Subject: [PATCH 24/25] test(cli): derive the re-routed connection's expected Route from the exported bends Co-Authored-By: jason.han --- cmd/sysml/import_diagram_test.go | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/cmd/sysml/import_diagram_test.go b/cmd/sysml/import_diagram_test.go index 3e3972c0ee..1aba7df2a0 100644 --- a/cmd/sysml/import_diagram_test.go +++ b/cmd/sysml/import_diagram_test.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "os" "path/filepath" "regexp" @@ -228,16 +229,18 @@ func TestImportDiagramPortsRoundTripSilently(t *testing.T) { t.Errorf("resized owner: status %d\n%s", resized.status, resized.output()) } - bend := regexp.MustCompile(`(?s)(kind="connection".*? Date: Thu, 29 Oct 2026 16:39:28 +0000 Subject: [PATCH 25/25] chore(referee): re-record the api-json and pilot baselines for the examples and libraries digests The self-model's new opensysml.importDiagram line moved the examples digest; develop's DocumentQueries change moved the libraries digest and its library rows. The draw.io desktop fixture is saved again against the 50 px grid gap, and the two layout tests derive their expectations from the exported geometry. Co-Authored-By: jason.han --- .../api-json-differential-baseline.json | 32 +++++++++---------- docs/project/api-json-differential.md | 2 +- docs/project/pilot-differential-baseline.json | 6 ++-- internal/ir/view/drawio_import_test.go | 9 ++++-- internal/ir/view/drawio_read_test.go | 4 +-- .../ir/view/testdata/layout-desktop.drawio | 2 +- 6 files changed, 30 insertions(+), 25 deletions(-) diff --git a/docs/project/api-json-differential-baseline.json b/docs/project/api-json-differential-baseline.json index 5a91e2dc19..cea543f85d 100644 --- a/docs/project/api-json-differential-baseline.json +++ b/docs/project/api-json-differential-baseline.json @@ -65,7 +65,7 @@ "dir": "examples", "origin": "ours", "files": 53, - "digest": "sha256:bdb2697affc4ba131d52ed104b24d0aa0419e7cc261942d584472876b0d249b8" + "digest": "sha256:710f0a455f761d34938ae3a5c1bd16b4f212f7a66d7ed4d45d6d97fbaa8ae5c1" }, { "name": "probes", @@ -86,10 +86,10 @@ "dir": "internal/workspace/libs/stdlib/OpenSysML Libraries", "origin": "ours", "files": 16, - "digest": "sha256:ac6a1e3b3376227415fa16aec5bf1918312a69cad0d8d63b6adcda799a44c9b3" + "digest": "sha256:29a3bf0fb7bb630a1457768ba774a6baf042469b071c8bda677117ed65d98c65" } ], - "recorded": "2026-10-28" + "recorded": "2026-10-29" }, "exporter": "org.omg.sysml.util.traversal.facade.impl.JsonElementProcessingFacade with setIsIncludeDerived(true), each input resource's root elements visited through org.omg.sysml.util.traversal.Traversal, toJsonTree(true) (the path of SysMLInteractive.export, the %export magic), via scripts/pilot-json-exporter/ExportJSON.java", "normalizations": [ @@ -111,7 +111,7 @@ "oursUnconvertible": 0, "oursColliding": 28, "pilotFailed": 59, - "pilotLibraryElements": 37415, + "pilotLibraryElements": 37613, "elements": { "matched": 92426, "oursOnly": 18479, @@ -120,8 +120,8 @@ "typeMismatch": 1729 }, "properties": { - "presentEqual": 1120886, - "presentDifferent": 27496, + "presentEqual": 1120818, + "presentDifferent": 27564, "oursMissing": 1073445, "pilotMissing": 68202, "absentBoth": 1971713, @@ -371,7 +371,7 @@ "oursRefused": 0, "pilotFailed": 0, "oursColliding": 3, - "pilotLibraryElements": 15571, + "pilotLibraryElements": 15648, "elements": { "matched": 26937, "oursOnly": 6046, @@ -380,8 +380,8 @@ "typeMismatch": 186 }, "properties": { - "presentEqual": 332702, - "presentDifferent": 8290, + "presentEqual": 332672, + "presentDifferent": 8320, "oursMissing": 322123, "pilotMissing": 19997, "absentBoth": 598024, @@ -443,7 +443,7 @@ "files": 11, "oursRefused": 0, "pilotFailed": 0, - "pilotLibraryElements": 17088, + "pilotLibraryElements": 17209, "elements": { "matched": 7199, "oursOnly": 920, @@ -452,8 +452,8 @@ "typeMismatch": 0 }, "properties": { - "presentEqual": 88779, - "presentDifferent": 2034, + "presentEqual": 88741, + "presentDifferent": 2072, "oursMissing": 89601, "pilotMissing": 4816, "absentBoth": 150233, @@ -51891,8 +51891,8 @@ 0 ], "InvocationExpression.input": [ - 368, - 96, + 334, + 130, 1, 0, 15, @@ -52314,8 +52314,8 @@ 0 ], "InvocationExpression.parameter": [ - 382, - 98, + 348, + 132, 0, 0, 0, diff --git a/docs/project/api-json-differential.md b/docs/project/api-json-differential.md index 0d35d19849..a38d6d1c43 100644 --- a/docs/project/api-json-differential.md +++ b/docs/project/api-json-differential.md @@ -230,7 +230,7 @@ disagreement, which `-check` then shows as that row moving out of the column. ## Results (pilot `2026-09`, 924 files) -**Scorecard:** 932 files; 92426 elements matched (18479 ours-only, 37741 pilot-only, 0 of them implied); properties: 1120886 present-equal, 27496 present-different, 1073445 ours-missing, 68202 pilot-missing, 1971713 absent-both, 26127 indeterminate, 514 pilot-nondeterministic. +**Scorecard:** 932 files; 92426 elements matched (18479 ours-only, 37741 pilot-only, 0 of them implied); properties: 1120818 present-equal, 27564 present-different, 1073445 ours-missing, 68202 pilot-missing, 1971713 absent-both, 26127 indeterminate, 514 pilot-nondeterministic. The pilot's export failed on 59 files (`pilotFailed`: its own export throws on an element without a resource, 52 of them specification examples, 4 test fixtures and, since pilot `2026-09`, the three `Metadata Examples/{Requirement,Risk,Verification}MetadataExample.sysml` whose enumeration-valued metadata bindings the pilot now evaluates into elements with no resource; the files are named in the run's text report), ours refused 36 (all of them specification examples the parser could not read whole), left the pilot's 59 failed files out and converted 28 documents alone for declaring an element another document of their root declares (`oursColliding`); no root fell back to per-document conversion. 11728 (metaclass, property) rows carry a comparison; 111690 references compared through the unkeyed sentinel, 159865 of our `sysx:` annotations were left out, and 287/347 elements (ours/pilot) were dropped for ambiguous keys, 17040/32319 for having none. diff --git a/docs/project/pilot-differential-baseline.json b/docs/project/pilot-differential-baseline.json index 3279944e68..d243166c3f 100644 --- a/docs/project/pilot-differential-baseline.json +++ b/docs/project/pilot-differential-baseline.json @@ -69,7 +69,7 @@ "dir": "examples", "origin": "ours", "files": 53, - "digest": "sha256:bdb2697affc4ba131d52ed104b24d0aa0419e7cc261942d584472876b0d249b8" + "digest": "sha256:710f0a455f761d34938ae3a5c1bd16b4f212f7a66d7ed4d45d6d97fbaa8ae5c1" }, { "name": "probes", @@ -83,10 +83,10 @@ "dir": "internal/workspace/libs/stdlib/OpenSysML Libraries", "origin": "ours", "files": 16, - "digest": "sha256:ac6a1e3b3376227415fa16aec5bf1918312a69cad0d8d63b6adcda799a44c9b3" + "digest": "sha256:29a3bf0fb7bb630a1457768ba774a6baf042469b071c8bda677117ed65d98c65" } ], - "recorded": "2026-10-28" + "recorded": "2026-10-29" }, "totals": { "files": 921, diff --git a/internal/ir/view/drawio_import_test.go b/internal/ir/view/drawio_import_test.go index e3e89066bc..692b9358f4 100644 --- a/internal/ir/view/drawio_import_test.go +++ b/internal/ir/view/drawio_import_test.go @@ -146,8 +146,13 @@ func TestDrawioImportMovedNodeIsALayout(t *testing.T) { return setGeometryAttr(t, setGeometryAttr(t, cell, "x", "533"), "y", "71.5") }) edit := editOf(t, drawioGoldenImport(t, layoutCase, document), DrawioEditLayout, "n2") - // The tank sits in the loop drawn at (40, 160); a Layout states absolute coordinates. - if edit.FQN != "Plant::Loop::tank" || edit.Layout.X != 573 || edit.Layout.Y != 231.5 || edit.Layout.Collapsed { + // The tank's cell is relative to the loop's; a Layout states absolute coordinates. + exported, err := ReadDrawio(golden, layoutCase.view) + if err != nil { + t.Fatal(err) + } + loop := exported.Cell("n0").Geometry + if edit.FQN != "Plant::Loop::tank" || edit.Layout.X != loop.X+533 || edit.Layout.Y != loop.Y+71.5 || edit.Layout.Collapsed { t.Errorf("layout = %s %+v", edit.FQN, edit.Layout) } if edit.Origin.Doc != "layout.sysml" { diff --git a/internal/ir/view/drawio_read_test.go b/internal/ir/view/drawio_read_test.go index 28b5ee6555..ed997713dd 100644 --- a/internal/ir/view/drawio_read_test.go +++ b/internal/ir/view/drawio_read_test.go @@ -40,8 +40,8 @@ func TestReadDrawioCompressedDesktopSave(t *testing.T) { t.Fatal(err) } edit := editOf(t, imp, DrawioEditLayout, "n2") - if edit.FQN != "Plant::Loop::tank" || edit.Layout.X <= 256 || edit.Layout.Y != 242 { - t.Errorf("layout = %s %+v, want the tank moved right from (256, 242)", edit.FQN, edit.Layout) + if edit.FQN != "Plant::Loop::tank" || edit.Layout.X <= 266 || edit.Layout.Y != 262 { + t.Errorf("layout = %s %+v, want the tank moved right from (266, 262)", edit.FQN, edit.Layout) } if len(imp.Unmatched)+len(imp.Unknown)+len(imp.Deleted)+len(imp.Notices) != 0 { t.Errorf("reported %v %d %v %v, want nothing", imp.Unmatched, len(imp.Unknown), imp.Deleted, imp.Notices) diff --git a/internal/ir/view/testdata/layout-desktop.drawio b/internal/ir/view/testdata/layout-desktop.drawio index 0c3280d3ce..39881b0bc3 100644 --- a/internal/ir/view/testdata/layout-desktop.drawio +++ b/internal/ir/view/testdata/layout-desktop.drawio @@ -1 +1 @@ -7ZlLc+MoEIB/jY6bkpBk+2o78cxhtiq12do9EwlbrBFoEI7i/fULAiz08ivKVA6bSqVEq5tH9wfdKF64zt+/cVhkv7MUEQ/46bsXPnoAzONI/lWCoxbMFnMt2HGcalHQCF7wv8gIfSM94BSVLUXBGBG4aAsTRilKREsGOWdVW23LSHvUAu5QT/CSQNKX/o1TkRkp8P3mxXeEd5kZemFf5NAqG0GZwZRVjih88sI1Z0zop/x9jYjynfWLttuMvD1NjCMqBgzY6z/KH8An8FWGpFbw1sBbrqSJkqdoq9qrlQdmHgilw8PVD8YK46ifVNs8E6j6X8rf5u0eUzMNp7P6zRtGlWP4l2yW2rogMEGpEmhNguleaxJ4ZAfxUB7LXE50GcRawa6UNt46ecFdtvFEKY42aGWFczm6bK0yofp8VIurMizQSyFnIQWVpFXKtoyKF2MYyDYkeKcWnsjeEZeCN8QFljgszQvBlFkp5LINrJGc2yphhMCixK+nnow7pDl6d+ZplvENsRwJfpQqmUNPEC+0XdWwFsZAy0w3keHJbKhgZtrQkL47dd3AIB+M62zT4HENKl1IikN+CRId8JNeF5epQJl3QAn6oJjAoPFdQ/0eP5wdaFqb1EzwxIS6Xv4Foi4SlOM0VQOttpiQNSOM14OGm83TYvVYw8XZHjlv9I+hdQNzTFTslxxDYhE28wsGkL4LxDDucWhPctvJrIXhAoxTaIb6Q0IG6U5OrDPWbHQs2xsk0pcUCrRSkSl7aJ9WcS/t8ZMEiyAxzvWzorl+cjUdstmEZC96YIdLGjxcOAhp/yRsSB7j1cHZJUm1awc9sxILzAaR/tFR0IfjxR3wyoRg+d1npCGNIwIFfmvn6crN0S1e/Yf4IqHPDFPRmPzWRtw2bQdsuy0NB5Oy2AHLVDbKw1PhBcI2X2gALJT2CqN+ymUHnqABRlsMqp7sgURZnZXbWDoIIpouVdXWqOrT0NZe4dD56MufzebEk8zNO2S3BFCzAq2d43A1yNAoIHJm8OgoFAqXcpyfyG+n6nmnouuqx53M3q0AO/rx2e7lg57vfWT26k3KBPLHSXCKrwwWOtTiigKsNOdN5BwcBG3FeOkl7THdqcKrOUEgOSCbKSp4LO/Lef08NJu3c14nQiA6k/VGaq8Rx4ZLSJOMcTPAzbuvic75neeEI4Vldipy+htvYDONOPHqXXSLS86s+8uwVkdMuvAu2vx+xrIb2vQy6+AW3V7pj7h2Atr6wZiaNvALaQNfn7YkQ8lezZGjsjxwNNUZF4Ttwr5zvwztlfTjLp6AOvDZ1KGPVgq3uCT8+tRtOZqMtM4NEgTRh0m78dOFvH/uvSs+XTR6n/Tpwpb5Jxrcw+6qb1y/5hvFXaGfDaQ20E5tUfDxSuq6ez2mZ6/1f6pI10+O4ifd6oN+0HuXk6Frff/Q+/9a/6Wv9bLZfNfX1s0/R8Kn/wA= \ No newline at end of file +7Zlbc+smEIB/jR6bkcCW3UfbiU8fTmcyTad9JhK2OEagAo7j/vqChCzQxbcoZ/LQTCYjluW2+7EsJICr/P2bQEX2O08xDUCYvgfwMQBgBuf6rxEcK0E8A5VgK0haiaJG8EL+xVYYWumepFh6iopzqkjhCxPOGE6UJ0NC8IOvtuHUH7VAW9wRvCSIdqV/k1RlVgrCsKn4DZNtZoee1xU5qpWtQGYo5QdHBJ8CuBKcq+orf19hamxX26Vqtx6oPU1MYKZ6GvDXH8YeIKToVbukVAhWIFgsdRMjT/HGlJfLAMQBgNrgcPmd88Ia6h9WtXmmyPS/0L9N7Y4wOw2ns7LmjeCD0/AvXZRV64KiBKdGUGlSwnaVJkVHvlcP8ihzPdFFNK0U6pWyxlonK7jLtpaQ6lg7TR5IrkfXpWWmTJ+PZnGHjCj8UuhZaMFB06plG87Ui20Y6TKiZGsWnujesdCCNywU0TgsbIXipplUetkW1ome2zLhlKJCktdTT9Ycujl+d+Zpl/EN8xwrcdQqmUNPFNvtcmhYg1O7Y2w3U8uT3VDRzJaRJX176rqBQX9Y09VFi8c1qLQhKfb5JUgqh5/02riMBcqsBUrUBcU6Bg/vGhZ2+BF8z9KyScmESKyry+VfIOoiQTlJUzPQckMoXXHKRTkoXK+f5svHEi7Bd9ipqX4srWuUE2p8vxAE0RphO7+oB+m7QITTDofTiYdhFHsY/gqGKbRD/aEhQ2yrJ9YaKx4cq+4NUW1LhhReGs/IDtqnVdxL+/RJg0WxGub62dBcfrmaDtl8RLLnHbDhgkUPFwIh60bChuQhXh2cXZJMuTTQM5dEEd6L9PeWQhUcL+6AV64Uz++OkZY0gSlS5M0/pw/uGe3xGj5MLxL6zAlTTZNffMTrYt0B32yk5WBUFq8IrXJfFPTYRtDmQMYXY4EIoE8i7kEQp50Uqns4871IcA/NHq2mpzp0MV6e3z7ADqyYpQuT3zWqVdysszTYF0lD/bNen8jTp/gW15sHmFkBb485BPbSNoiSnhk6OgqFAUsOkzYJ/UN91sr92urtHKCdK7b0p2e71x/VfO9juJOZMq5wOEyCk6ZlqKhcra5I1aSNTBMnxFC8UcNJmm5P2NakaE2sQXSP6zPlgI7yvtOxe2LFM/90nPkWB/GZ83EgSxswLFwglmRc2AFu3n2Nd87vPMcdKZLZKR3qbryezTRgxKt30S0mObPuL8Na6TFtwrtoC7tnW72hbS9xKyBMbr8TDJh2BNq6zhibNvATaQNfn7Ykw8nOzFFgKfcCjxXjIuhfAVrQwfl8LBOPQB34bOrwRzOFW0wCvz51G4FHI6111wQ1eB8g7cZHDn1T3QVXZOKN3ic9ctQXghMNbrC76jXs57xm3OX6uOdoA/7RNoEfz6SuewEg7OwDwJ/G0+WXo/hJ9/+o6/TO5aTvAaAb9P5/APjSDwC62PwHoGrd/BsFPv0H \ No newline at end of file