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/cmd/sysml/import_diagram.go b/cmd/sysml/import_diagram.go new file mode 100644 index 0000000000..914b5d1ee2 --- /dev/null +++ b/cmd/sysml/import_diagram.go @@ -0,0 +1,210 @@ +package main + +import ( + "bytes" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "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) { + 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 + } + if dryRun { + for _, edited := range result.Documents { + fmt.Fprint(os.Stdout, model.UnifiedDiff(edited.Name, edited.Original, edited.Content)) + } + 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 { + if !bytes.Equal(edited.Original, edited.Content) { + fmt.Fprintf(os.Stderr, "%s: written\n", edited.Name) + } + } + 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) + } + }() + targets := map[string]string{} + for _, edited := range documents { + 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(target, edited.Content, info.Mode().Perm()) + if name != "" { + staged = append(staged, name) + } + if stageErr != nil { + return stageErr + } + backup, movedAside, backupErr := backUp(target) + if backupErr != nil { + return fmt.Errorf("write %s: %w", edited.Name, backupErr) + } + done = append(done, swap{target, backup, movedAside}) + if renameErr := os.Rename(name, target); 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(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", target, err) + } + name := file.Name() + if _, err := file.Write(content); err != nil { + _ = file.Close() + return name, fmt.Errorf("stage %s: %w", target, err) + } + if err := file.Sync(); err != nil { + _ = file.Close() + return name, fmt.Errorf("stage %s: %w", target, err) + } + if err := file.Close(); err != nil { + return name, fmt.Errorf("stage %s: %w", target, err) + } + if err := os.Chmod(name, perm); err != nil { + return name, fmt.Errorf("stage %s: %w", target, 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 { + 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] +} diff --git a/cmd/sysml/import_diagram_test.go b/cmd/sysml/import_diagram_test.go new file mode 100644 index 0000000000..1aba7df2a0 --- /dev/null +++ b/cmd/sysml/import_diagram_test.go @@ -0,0 +1,246 @@ +package main + +import ( + "fmt" + "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"[^>]*>.*?]*fqn="PumpStation::Tank::outlet"[^>]*>.*?]*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()) + } + + bends := regexp.MustCompile(`(?s)(kind="connection".*? 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/render.go b/cmd/sysml/render.go index da39ae8529..78e95116ed 100644 --- a/cmd/sysml/render.go +++ b/cmd/sysml/render.go @@ -47,7 +47,7 @@ func runRender(files []string) error { if err != nil { return err } - if options.Links.Template != "" { + if options.Links.Template != "" || form == view.FormDrawio { options.Links.Sites, err = sess.ViewSites() if err != nil { return err @@ -89,7 +89,7 @@ func runRenderAll(files []string) error { if len(views) == 0 { return errors.New("the model declares no views; nothing was rendered") } - if options.Links.Template != "" { + if options.Links.Template != "" || form == view.FormDrawio { options.Links.Sites, err = sess.ViewSites() if err != nil { return err diff --git a/cmd/sysml/render_run.go b/cmd/sysml/render_run.go index f90e83d52a..31502082a3 100644 --- a/cmd/sysml/render_run.go +++ b/cmd/sysml/render_run.go @@ -142,7 +142,7 @@ func finishRunCheck(rep *reporter, sess *repl.Session) int { return 2 } var sites view.Sites - if renderLink != "" { + if renderLink != "" || slices.ContainsFunc(targets, func(t runRenderTarget) bool { return t.form == view.FormDrawio }) { sites, err = sess.ViewSites() if err != nil { fmt.Fprintln(os.Stderr, errPrefix, err) diff --git a/cmd/sysml/usage.go b/cmd/sysml/usage.go index bf1a39c4cd..391699d065 100644 --- a/cmd/sysml/usage.go +++ b/cmd/sysml/usage.go @@ -739,6 +739,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 for -render: text on a terminal, else the kind's machine form whatever the -o extension; for -render-all: each kind's machine form") 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") @@ -890,6 +893,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/docs/guide/08-editors.md b/docs/guide/08-editors.md index cbead6f0d3..dddf1b3855 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/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/diagram-layout-annotations.md b/docs/project/diagram-layout-annotations.md index e67088be53..46eec49c20 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. @@ -373,10 +373,16 @@ 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` 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/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/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 19d4b1b043..d3357e33ae 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 `fqn`, the declaration's qualified name, on a node, a -port, and an edge that is a declaration of its own (a connection, flow, succession, transition, +port (for a `start` or `done` the model did not declare, the owner's member a Layout names it as, +`Machines::VehicleStates::start`), and an edge that is a declaration of its own (a connection, flow, succession, transition, dependency and the like). An edge drawn from a member's declaration — a composition, a typing — carries no `fqn` 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 @@ -1550,6 +1551,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 01555f927a..9c9efea57f 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 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` | | `-render-form
` | | Form `-render` or `-render-all` writes: `text`, `mermaid`, `markdown`, `dot`, `plantuml`, `d2`, `drawio`, `csv` or `tsv` (default for `-render`: `text` when stdout is a terminal, otherwise the kind's machine-readable form — Mermaid for a graph-shaped kind, `markdown` for a table or matrix — whatever the extension of `-o`; for `-render-all`: each kind's machine-readable form). 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 c62fdb617a..573c78e961 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 @@ -655,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 d327eaf628..63bd3a7f2b 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 | @@ -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/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; +} 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"; 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/drawio_test.go b/internal/check/edit/drawio_test.go new file mode 100644 index 0000000000..63ff8fef02 --- /dev/null +++ b/internal/check/edit/drawio_test.go @@ -0,0 +1,392 @@ +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 + // 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 { + 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, 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) { + 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) + } + 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 { + if e.Cell == cell.ID { + t.Errorf("the edited file still imports %+v against the edited model", e) + } + } + } + }) + } + }) + } +} 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 08c5d4feaa..2af632216a 100644 --- a/internal/check/edit/layout.go +++ b/internal/check/edit/layout.go @@ -1,6 +1,7 @@ package edit import ( + "errors" "fmt" "strconv" "strings" @@ -16,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 @@ -29,27 +32,30 @@ 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 { 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) 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 { - 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, sym, viewSym, op.Annotation) var splices []splice switch { case bindings == nil: @@ -58,15 +64,27 @@ 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 + // 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. func (m Model) addressed(splices []splice, i int, op Operation, in Model) []splice { label := m.label(op) @@ -82,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 + } + docName := owner.DocName + if target.site != nil { + docName = symbols.DocNameOf(target.site.Scope) } - in, ok := m.inDocument(owner.DocName) + 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, @@ -112,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 { @@ -229,41 +295,129 @@ 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) { - if op.Annotation == semantics.CanvasFQN && (op.Target != "" || op.Declaration.Len == 0) { - sym, err = m.viewNamed(i, op.Target, op.DeclarationDoc, +func (m Model) layoutTargets(i int, op Operation, sem *semantics.Model) (layoutTarget, error) { + var target layoutTarget + var err error + 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 { - sym, err = m.element(i, op) + 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 nil, nil, err + 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 != "" && !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 +} + +// 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. +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,8 +448,10 @@ 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 { - if op.Annotation == semantics.CanvasFQN { +func (m Model) checkPlaceable(i int, op Operation, renderer *view.Renderer, sem *semantics.Model, target layoutTarget) error { + 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 } wantNode := op.Annotation != semantics.RouteFQN @@ -306,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) @@ -315,21 +473,31 @@ func (m Model) checkPlaceable(i int, op Operation, renderer *view.Renderer, sem } return nil } - if !m.exposes(sem, viewSym, sym) { - 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)} } - 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)} + var placed bool + exposed := sym + if target.owner != nil { + exposed = target.owner + placed = drawn.MemberNode(target.owner, sym) + } else { + placed = wantNode && drawn.Node(sym) || wantEdge && drawn.Edge(sym) } - return nil + if placed { + return nil + } + // 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 &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. @@ -352,12 +520,18 @@ 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 viewSym == nil && site.View == nil { + if target.owner != nil { + if via := sem.MemberLayoutOwner(site, sym); via == nil || via.Decl != target.owner.Decl { + continue + } + } + 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 { @@ -368,13 +542,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_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/check/edit/layout_test.go b/internal/check/edit/layout_test.go index 5ea8feb154..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" @@ -893,3 +894,91 @@ 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) + } + } +} + +// 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/frontend/lsp/formatting.go b/internal/frontend/lsp/formatting.go index ead4c67fa6..3f3115ee94 100644 --- a/internal/frontend/lsp/formatting.go +++ b/internal/frontend/lsp/formatting.go @@ -13,6 +13,7 @@ import ( "go.lsp.dev/uri" "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 @@ -120,38 +121,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 } @@ -160,15 +161,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..36436ce88d --- /dev/null +++ b/internal/frontend/lsp/importdiagram.go @@ -0,0 +1,165 @@ +package lsp + +import ( + "context" + "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"` + Stale bool `json:"stale,omitempty"` + 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, req *jsonrpc2.Request) (any, error) { + if req.Method() != MethodImportDiagram { + return inner(ctx, req) + } + params, err := decodeParams[ImportDiagramParams](req) + if err != nil { + return nil, err + } + return s.ImportDiagram(params) + } +} + +// 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), 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) { + return nil, err + } + out.Refused = []ModelEditRefusal{s.refusal(refusal, s.ws.Document(name).Content)} + return out, nil + } + changes := make([]protocol.DocumentChange, 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..80612d987f --- /dev/null +++ b/internal/frontend/lsp/importdiagram_test.go @@ -0,0 +1,95 @@ +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"[^>]*>.*? 0 { + edited++ + } + } + var b strings.Builder + fmt.Fprintf(&b, "%s: %d edits planned, %d refused, edits to %d documents", res.View, len(res.Edits), len(res.Refused), edited) + if res.Applied { + b.WriteString(", written") + } + for _, e := range res.Edits { + fmt.Fprintf(&b, "\n%s about %s", e.Kind, e.Target) + } + for _, r := range res.Refused { + fmt.Fprintf(&b, "\nrefused (%s): %s", r.Failure, r.Message) + } + for _, n := range res.Notices { + b.WriteString("\n" + n) + } + return text(b.String()), res, nil + })) +} diff --git a/internal/frontend/mcp/lsptools/importing_test.go b/internal/frontend/mcp/lsptools/importing_test.go new file mode 100644 index 0000000000..13d63676ed --- /dev/null +++ b/internal/frontend/mcp/lsptools/importing_test.go @@ -0,0 +1,77 @@ +package lsptools + +import ( + "path/filepath" + "regexp" + "strconv" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/frontend/lsp" +) + +const plant = `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; + } +} +` + +func TestImportDiagramRoundTripsARenderedView(t *testing.T) { + f := newFixture(t) + write(t, f.root, "plant.sysml", plant) + var render lsp.RenderResult + f.ok("render_view", map[string]any{"path": "plant.sysml", "form": "drawio"}, &render) + cell := regexp.MustCompile(`(?s)(]*fqn="Plant::Loop::pump"[^>]*>.*? 0) { + t.Fatalf("dry run changes = %+v", res.Changes) + } + } + f.unchanged(before) + + f.ok("import_diagram", map[string]any{"path": "plant.sysml", "content": moved, "apply": true}, &res) + if !res.Applied { + t.Fatalf("apply = %+v", res) + } + if after := f.read("plant.sysml"); !strings.Contains(after, "metadata DiagramLayout::Layout about Plant::Loop::pump") { + t.Errorf("plant.sysml has no Layout about the pump:\n%s", after) + } + f.ok("import_diagram", map[string]any{"path": "plant.sysml", "content": moved}, &res) + if len(res.Edits) != 0 { + t.Errorf("the written model still imports %+v", res.Edits) + } + 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) + } +} diff --git a/internal/frontend/mcp/lsptools/lsptools.go b/internal/frontend/mcp/lsptools/lsptools.go index 3c6a19d8c1..7e26525c3c 100644 --- a/internal/frontend/mcp/lsptools/lsptools.go +++ b/internal/frontend/mcp/lsptools/lsptools.go @@ -371,6 +371,7 @@ func Register(s *mcp.Server, h *Host) { })) registerQueries(s, h) registerEdits(s, h) + registerImporting(s, h) registerRendering(s, h) registerSync(s, h) registerResources(s, h) diff --git a/internal/frontend/repl/modeledit.go b/internal/frontend/repl/modeledit.go new file mode 100644 index 0000000000..e9ed539b3e --- /dev/null +++ b/internal/frontend/repl/modeledit.go @@ -0,0 +1,19 @@ +package repl + +import ( + "fmt" + + sedit "github.com/Open-MBEE/OpenSysML/internal/check/edit" + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" +) + +// ApplyModelEdit applies ops to the session document named name, as the +// editor's panel applies its edits, and answers each document they rewrite. +func (s *Session) ApplyModelEdit(name string, ops []sedit.Operation) (*model.EditResult, error) { + defer s.enter()() + res, _, ok, err := s.ws.ApplyEdit(name, ops, nil) + if err == nil && !ok { + err = fmt.Errorf("%s is not a document of the session", name) + } + return res, err +} diff --git a/internal/ir/view/behavior.go b/internal/ir/view/behavior.go index 6367e89cd8..d941de450e 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 { @@ -812,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.go b/internal/ir/view/drawio.go index be1be71e2a..04061dc490 100644 --- a/internal/ir/view/drawio.go +++ b/internal/ir/view/drawio.go @@ -21,17 +21,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() { @@ -63,13 +75,13 @@ func (r *Rendering) DrawioWith(options Options) (string, error) { w.layout(r) edges, err := w.routeEdges(r) if err != nil { - return "", err + return nil, nil, err } routed := *r routed.Edges = edges r = &routed w.edgeIDs = drawioEdgeIDs(r.Edges) - return w.document(r, options), nil + return r, w, nil } // drawioWriter composes one draw.io file from a rendering. @@ -290,14 +302,18 @@ 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. + // 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 + } 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)} } @@ -558,8 +574,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 new file mode 100644 index 0000000000..2452c0ac8b --- /dev/null +++ b/internal/ir/view/drawio_import.go @@ -0,0 +1,516 @@ +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 when HasGeometry; a + // Relative vertex (a port) is resolved from Fraction of its parent plus Offset. + Geometry DrawioBox + HasGeometry bool + Relative bool + Fraction Point + 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. + 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. 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 { + 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 + if f.View == "" { + f.View = 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 +} + +// 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) { + 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(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) + } + 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 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", cell.ID, err) + } + 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: + 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 { + 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 { + if cell.Relative { + cell.Fraction = Point{X: cell.Geometry.X, Y: cell.Geometry.Y} + cell.Geometry.X = cell.Fraction.X*parent.Geometry.Width + cell.Offset.X + cell.Geometry.Y = cell.Fraction.Y*parent.Geometry.Height + cell.Offset.Y + } + 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..5a725c6c2c --- /dev/null +++ b/internal/ir/view/drawio_import_diff.go @@ -0,0 +1,743 @@ +package view + +import ( + "fmt" + "math" + "sort" + "strconv" + "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 +// 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 +} + +// 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, 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 { + 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 + // 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 + nodes map[string]*Node + ports map[string]Port + matched map[string]bool + // 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 +// 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 { + 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) + } + } + } + var pending []*DrawioCell + baseOf := map[*DrawioCell]*DrawioCell{} + pair := func(cell, base *DrawioCell) { + m.matched[base.ID] = true + m.paired[base.ID] = cell + baseOf[cell] = base + } + for i := range m.file.Cells { + cell := &m.file.Cells[i] + if cell.Kind == "" { + continue + } + if base := m.base.Cell(cell.ID); base != nil && sameDrawioElement(cell, base, fileKeys, baseKeys) { + pair(cell, base) + continue + } + pending = append(pending, cell) + } + for _, cell := range pending { + switch { + case cell.Vertex && cell.FQN != "": + if base := m.firstUnmatched(byFQN[cell.FQN]); base != nil { + pair(cell, base) + } else { + m.out.Unmatched = append(m.out.Unmatched, cell.FQN) + } + 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 + } + if base := m.firstUnmatched(byKey[key.unordered()]); base != nil { + 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; 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() +} + +// 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 +} + +// 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 + } + 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 +// 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) +} + +// 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: m.targetName(cell, base), Origin: origin} + if isPort { + owner, size := m.base.Cell(base.Parent), DrawioBox{} + if owner != nil { + size = owner.Geometry + } + 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 { + 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 + } + 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. +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 { + 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) + folded := cell.Collapsed != base.Collapsed + if !moved && !resized && !folded { + 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} + 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 +} + +// 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 +// 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, 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 + } + m.framed[id] = true + container, base := m.nodes[id], m.base.Cell(id) + if container == nil || base == nil { + return + } + 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 || m.framed[child.ID] { + continue + } + if !node.Geometry.placed() && m.framesChildren(child.ID) { + m.pin(child.ID) + continue + } + m.framed[child.ID] = true + 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) + } +} + +// 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. +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: 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 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 + } + 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. +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, 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, 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) { + 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 || 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: + 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))) + } + } +} + +// 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")) + 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 } + +// 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 { + box := cell.Geometry + if cell.Relative { + box.X = cell.Fraction.X*size.Width + cell.Offset.X + box.Y = cell.Fraction.Y*size.Height + cell.Offset.Y + } else if 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 { + 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_match_test.go b/internal/ir/view/drawio_import_match_test.go new file mode 100644 index 0000000000..2ea2842c02 --- /dev/null +++ b/internal/ir/view/drawio_import_match_test.go @@ -0,0 +1,244 @@ +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, +// 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" + 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 := editOf(t, imp, DrawioEditLayout, "n7") + 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 != "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 { + 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 := 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) + } + 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) + } + } +} + +// 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) + } +} + +// 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"`) { + 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) + } +} diff --git a/internal/ir/view/drawio_import_test.go b/internal/ir/view/drawio_import_test.go new file mode 100644 index 0000000000..692b9358f4 --- /dev/null +++ b/internal/ir/view/drawio_import_test.go @@ -0,0 +1,340 @@ +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) + } +} + +// 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 { + 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/drawio_read_test.go b/internal/ir/view/drawio_read_test.go new file mode 100644 index 0000000000..ed997713dd --- /dev/null +++ b/internal/ir/view/drawio_read_test.go @@ -0,0 +1,156 @@ +package view + +import ( + "bytes" + "compress/flate" + "encoding/base64" + "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) + } + // 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), "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) + } +} + +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/geometry.go b/internal/ir/view/geometry.go index 53372fb99e..86cd1fe1ec 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 } @@ -230,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 @@ -274,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 @@ -286,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 @@ -350,6 +355,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/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 +} diff --git a/internal/ir/view/place.go b/internal/ir/view/place.go index e1afc91459..9c858e0c4a 100644 --- a/internal/ir/view/place.go +++ b/internal/ir/view/place.go @@ -11,6 +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 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 } @@ -36,6 +39,56 @@ 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{}, 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 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. func (d *Drawn) Edge(sym *symbols.Symbol) bool { return d != nil && sym != nil && d.edges[sym.Decl] @@ -91,7 +144,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/testdata/action.drawio.golden b/internal/ir/view/testdata/action.drawio.golden index 679437d4d0..5e8c5545f1 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/layout-desktop.drawio b/internal/ir/view/testdata/layout-desktop.drawio new file mode 100644 index 0000000000..39881b0bc3 --- /dev/null +++ b/internal/ir/view/testdata/layout-desktop.drawio @@ -0,0 +1 @@ +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 diff --git a/internal/ir/view/testdata/mixed.drawio.golden b/internal/ir/view/testdata/mixed.drawio.golden index 670aa4d5a2..3067eab7dc 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 262959445b..486105f1d3 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 bb44a21f85..d9c5d82ac9 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 f05e6039ce..199cb99740 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 00300f414c..e2cb701973 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/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 3c0e268d8b..365c73c3f9 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 @@ -638,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) 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 a62e3bfee0..f465b4f938 100644 --- a/internal/semantic/semantics/annotations.go +++ b/internal/semantic/semantics/annotations.go @@ -73,6 +73,12 @@ 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 + // resolving), the answer is partial: given, not kept. + if m.aboutBuilding { + delete(m.annotations, sym) + return out + } m.annotations[sym] = out return out } @@ -632,8 +638,14 @@ 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 + 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/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) + } +} diff --git a/internal/semantic/semantics/layout.go b/internal/semantic/semantics/layout.go index b209e67615..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 @@ -295,6 +298,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 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 diff --git a/internal/frontend/lsp/linediff.go b/internal/workspace/model/linediff.go similarity index 51% rename from internal/frontend/lsp/linediff.go rename to internal/workspace/model/linediff.go index 8eb02af292..04727bf9b1 100644 --- a/internal/frontend/lsp/linediff.go +++ b/internal/workspace/model/linediff.go @@ -1,34 +1,39 @@ -package lsp +package model -// hunk is a run of lines a diff rewrites: old[oldStart:oldEnd] becomes -// new[newStart:newEnd]. A pure insertion has oldStart == oldEnd, a pure -// deletion newStart == newEnd. -type hunk struct { - oldStart, oldEnd int - newStart, newEnd int +import ( + "fmt" + "strings" +) + +// Hunk is a run of lines a diff rewrites: old[OldStart:OldEnd] becomes +// new[NewStart:NewEnd]. A pure insertion has OldStart == OldEnd, a pure +// deletion NewStart == NewEnd. +type Hunk struct { + OldStart, OldEnd int + NewStart, NewEnd int } -// diffLines returns the minimal edit script turning line ids a into b, in order. +// DiffLines returns the minimal edit script turning line ids a into b, in order. // Linear-space Myers: O(D) passes for D differing lines, O(len(a)+len(b)) memory. -func diffLines(a, b []int) []hunk { +func DiffLines(a, b []int) []Hunk { d := &differ{} d.diff(a, b, 0, 0) return d.hunks } type differ struct { - hunks []hunk + hunks []Hunk forward []int reverse []int } -// add records a hunk, merging it into the previous one when they abut: the +// add records a Hunk, merging it into the previous one when they abut: the // search emits a deletion and the insertion beside it as separate steps. -func (d *differ) add(h hunk) { +func (d *differ) add(h Hunk) { if n := len(d.hunks); n > 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,83 @@ 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 from == to { + return fmt.Sprintf("%d,0", from) + } + 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 57% rename from internal/frontend/lsp/linediff_test.go rename to internal/workspace/model/linediff_test.go index f4ad585a45..9f698e8c80 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) { @@ -163,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 dfa89bdd09..44bda41959 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 { 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 d5d3068b23..edf18c609c 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 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 } } }