From 311fc8c097b6edd11bbca313dd59cf11ba01a45e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:30:50 +0000 Subject: [PATCH 1/5] feat(view): add source links to rendered diagrams Co-Authored-By: jason.han --- .../unreleased/render-source-links.added.md | 1 + cmd/sysml/main.go | 7 +- cmd/sysml/render.go | 18 + cmd/sysml/render_document.go | 8 +- cmd/sysml/render_document_test.go | 15 + cmd/sysml/render_pdf.go | 1 + cmd/sysml/render_test.go | 41 ++ cmd/sysml/usage.go | 2 + docs/project/html-document-backend.md | 5 + docs/project/spec-compliance.md | 8 +- docs/project/view-rendering-forms.md | 28 +- docs/reference/cli.md | 19 + docs/reference/lsp.md | 5 + docs/reference/repl-commands.md | 7 +- editors/vscode/src/diagram.ts | 5 +- editors/vscode/src/export.test.ts | 9 +- editors/vscode/src/export.ts | 5 + editors/vscode/src/protocol.ts | 2 + internal/doc/docir/diagram_test.go | 11 +- internal/doc/docir/evaluate.go | 2 + internal/doc/docir/ir.go | 5 + internal/doc/docpdf/docpdf.go | 8 +- internal/doc/docrender/artwork.go | 3 + internal/doc/docrender/html.go | 5 +- internal/doc/docrender/html_test.go | 14 + internal/doc/docrender/markdown.go | 6 +- internal/doc/docrender/markdown_test.go | 31 +- internal/doc/queryexec/execute.go | 8 +- internal/frontend/lsp/document.go | 9 +- internal/frontend/lsp/document_test.go | 23 + internal/frontend/lsp/render.go | 6 +- internal/frontend/lsp/render_test.go | 30 ++ internal/frontend/repl/docquery.go | 10 + internal/frontend/repl/meta.go | 23 +- internal/frontend/repl/view.go | 40 ++ internal/frontend/repl/view_render_test.go | 34 +- internal/ir/view/dot.go | 29 +- internal/ir/view/form.go | 7 + internal/ir/view/links.go | 194 ++++++++ internal/ir/view/links_test.go | 444 ++++++++++++++++++ internal/ir/view/mermaid.go | 46 +- internal/ir/view/plantuml.go | 43 +- .../ir/view/testdata/links-action-dot.golden | 45 ++ .../view/testdata/links-action-mermaid.golden | 87 ++++ .../testdata/links-action-plantuml.golden | 72 +++ .../testdata/links-interconnection-dot.golden | 20 + .../links-interconnection-mermaid.golden | 52 ++ .../links-interconnection-plantuml.golden | 54 +++ .../testdata/links-sequence-mermaid.golden | 43 ++ .../testdata/links-sequence-plantuml.golden | 49 ++ .../ir/view/testdata/links-state-dot.golden | 32 ++ .../view/testdata/links-state-mermaid.golden | 48 ++ .../view/testdata/links-state-plantuml.golden | 57 +++ .../ir/view/testdata/links-tree-dot.golden | 22 + .../view/testdata/links-tree-mermaid.golden | 54 +++ .../view/testdata/links-tree-plantuml.golden | 59 +++ internal/workspace/model/reading.go | 17 + internal/workspace/model/render.go | 15 +- internal/workspace/modeldoc/modeldoc.go | 5 +- packaging/man/man1/sysml.1 | 5 + 60 files changed, 1897 insertions(+), 56 deletions(-) create mode 100644 changes/unreleased/render-source-links.added.md create mode 100644 internal/ir/view/links.go create mode 100644 internal/ir/view/links_test.go create mode 100644 internal/ir/view/testdata/links-action-dot.golden create mode 100644 internal/ir/view/testdata/links-action-mermaid.golden create mode 100644 internal/ir/view/testdata/links-action-plantuml.golden create mode 100644 internal/ir/view/testdata/links-interconnection-dot.golden create mode 100644 internal/ir/view/testdata/links-interconnection-mermaid.golden create mode 100644 internal/ir/view/testdata/links-interconnection-plantuml.golden create mode 100644 internal/ir/view/testdata/links-sequence-mermaid.golden create mode 100644 internal/ir/view/testdata/links-sequence-plantuml.golden create mode 100644 internal/ir/view/testdata/links-state-dot.golden create mode 100644 internal/ir/view/testdata/links-state-mermaid.golden create mode 100644 internal/ir/view/testdata/links-state-plantuml.golden create mode 100644 internal/ir/view/testdata/links-tree-dot.golden create mode 100644 internal/ir/view/testdata/links-tree-mermaid.golden create mode 100644 internal/ir/view/testdata/links-tree-plantuml.golden diff --git a/changes/unreleased/render-source-links.added.md b/changes/unreleased/render-source-links.added.md new file mode 100644 index 0000000000..fc7b361d15 --- /dev/null +++ b/changes/unreleased/render-source-links.added.md @@ -0,0 +1 @@ +- **Link rendered diagram elements to their source.** PlantUML and DOT link nodes and edges, Mermaid links supported nodes and participants, and CLI, REPL, LSP and document renderings accept source-link templates. diff --git a/cmd/sysml/main.go b/cmd/sysml/main.go index 203b8cce23..5cfc081212 100644 --- a/cmd/sysml/main.go +++ b/cmd/sysml/main.go @@ -118,6 +118,7 @@ var ( renderAllDir string renderForm string renderPalette string + renderLink string renderUnplaced string renderStyle string renderPorts string @@ -384,6 +385,10 @@ func runCLI() int { fmt.Fprintln(os.Stderr, "sysml: -render-palette is the palette -render or -render-all fills DOT, Mermaid or PlantUML with; name the view to render with -render or a directory with -render-all") return 2 } + if renderLink != "" && renderView == "" && renderAllDir == "" && renderDoc == "" && renderDocsDir == "" { + fmt.Fprintln(os.Stderr, "sysml: -render-link links rendered elements to their source; name what to render with -render, -render-all, -render-document or -render-documents") + return 2 + } if renderPorts != "" && renderView == "" && renderAllDir == "" { fmt.Fprintln(os.Stderr, "sysml: -render-ports is how much of a part's ports -render or -render-all draws on an interconnection; name the view to render with -render or a directory with -render-all") return 2 @@ -538,7 +543,7 @@ func runCLI() int { case convertFormat != "" || migrateFormat != "" || renderView != "" || renderDoc != "" || renderAllDir != "" || renderDocsDir != "" || queryText != "" || len(evalExprs) > 0: fmt.Fprintf(os.Stderr, "sysml: %s syncs a change set; it cannot be combined with -convert, -migrate, -render, -render-all, -render-document, -render-documents, -query or -eval\n", mode) return 2 - case outputPath != "" || fromFormat != "" || renderForm != "" || renderPalette != "" || renderUnplaced != "" || renderStyle != "" || renderPorts != "" || docForm != "" || diagramForm != "" || pdfEngine != "" || pdfTitlePage || pdfTOC || pdfNumbering || docNumberFigures: + case outputPath != "" || fromFormat != "" || renderForm != "" || renderPalette != "" || renderLink != "" || renderUnplaced != "" || renderStyle != "" || renderPorts != "" || docForm != "" || diagramForm != "" || pdfEngine != "" || pdfTitlePage || pdfTOC || pdfNumbering || docNumberFigures: fmt.Fprintf(os.Stderr, "sysml: %s reads SysML or Turtle inputs and reports the change set; -output, -from and the render options do not apply\n", mode) return 2 case modelChecks.requested(): diff --git a/cmd/sysml/render.go b/cmd/sysml/render.go index 729b28ec8c..cd6c96f571 100644 --- a/cmd/sysml/render.go +++ b/cmd/sysml/render.go @@ -43,6 +43,12 @@ func runRender(files []string) error { if err != nil { return err } + if options.Links.Template != "" { + options.Links.Sites, err = sess.ViewSites() + if err != nil { + return err + } + } artifact, err := rendering.WriteWith(form, options) if err != nil { return err @@ -75,6 +81,12 @@ func runRenderAll(files []string) error { if len(views) == 0 { return errors.New("the model declares no views; nothing was rendered") } + if options.Links.Template != "" { + options.Links.Sites, err = sess.ViewSites() + if err != nil { + return err + } + } if err := os.MkdirAll(renderAllDir, 0o750); err != nil { return fmt.Errorf("create rendering directory %s: %w", renderAllDir, err) } @@ -144,6 +156,12 @@ func renderFilenames(views []model.ViewInfo, form view.Form) (map[string]string, // names, each of which must be one there is. func renderOptions(width int) (view.Options, error) { options := view.Options{Width: width} + if renderLink != "" { + if err := view.ParseLinkTemplate(renderLink); err != nil { + return view.Options{}, fmt.Errorf("-render-link: %w", err) + } + options.Links.Template = renderLink + } if renderPalette != "" { palette, ok := view.ParsePalette(renderPalette) if !ok { diff --git a/cmd/sysml/render_document.go b/cmd/sysml/render_document.go index 50005a7ff6..6ee221c690 100644 --- a/cmd/sysml/render_document.go +++ b/cmd/sysml/render_document.go @@ -139,6 +139,7 @@ func documentOptions(outputDir string) docrender.HTMLOptions { DiagramForm: view.Form(diagramForm), Unplaced: view.Unplaced(renderUnplaced), Style: view.DrawingStyle(renderStyle), + LinkTemplate: renderLink, Drawer: replext.Drawer(), WithoutGraphviz: replext.Drawer() == nil, OutputDir: outputDir, @@ -149,7 +150,7 @@ func documentOptions(outputDir string) docrender.HTMLOptions { // outputDir, "" for standard output. func markdownOptions(outputDir string) docrender.MarkdownOptions { return docrender.MarkdownOptions{ - DiagramForm: view.Form(diagramForm), Unplaced: view.Unplaced(renderUnplaced), Style: view.DrawingStyle(renderStyle), + DiagramForm: view.Form(diagramForm), Unplaced: view.Unplaced(renderUnplaced), Style: view.DrawingStyle(renderStyle), LinkTemplate: renderLink, Drawer: replext.Drawer(), WithoutGraphviz: replext.Drawer() == nil, OutputDir: outputDir, NumberFigures: docNumberFigures, } @@ -168,6 +169,11 @@ func artifactDir() string { // -render-unplaced value naming no placement and a -render-style value naming // no drawing style. func checkDiagramForm() error { + if renderLink != "" { + if err := view.ParseLinkTemplate(renderLink); err != nil { + return fmt.Errorf("-render-link: %w", err) + } + } if _, err := unplacedOption(); err != nil { return err } diff --git a/cmd/sysml/render_document_test.go b/cmd/sysml/render_document_test.go index 003ca8cd60..3ec342781e 100644 --- a/cmd/sysml/render_document_test.go +++ b/cmd/sysml/render_document_test.go @@ -129,6 +129,21 @@ func TestRenderDocumentDiagramForm(t *testing.T) { 2, "SomeView") } +func TestRenderDocumentDiagramSourceLinks(t *testing.T) { + binary := buildCLI(t) + fixture := filepath.Join("..", "..", "internal", "doc", "docrender", "testdata", "telescope_report.sysml") + template := "https://example.test/src/{file}#L{line}" + cmd := exec.Command(binary, fixture, "-render-document", "Observatory::MassReport", "-diagram-form", "plantuml", "-render-link", template) + out, err := cmd.Output() + if err != nil { + t.Fatalf("render linked document: %v", err) + } + want := "https://example.test/src/" + filepath.ToSlash(fixture) + "#L" + if !strings.Contains(string(out), want) || !strings.Contains(string(out), "[[") { + t.Errorf("document diagrams lack source links to %q:\n%s", want, out) + } +} + // TestRenderDocumentCommittedFixture renders the renderer's committed fixture // through the binary's full analysis, matching the committed golden Markdown. func TestRenderDocumentCommittedFixture(t *testing.T) { diff --git a/cmd/sysml/render_pdf.go b/cmd/sysml/render_pdf.go index a15f241e74..c86cf7ed4b 100644 --- a/cmd/sysml/render_pdf.go +++ b/cmd/sysml/render_pdf.go @@ -67,5 +67,6 @@ func pdfOptions() (docpdf.Options, error) { DiagramForm: page.DiagramForm, Unplaced: page.Unplaced, Style: page.Style, + LinkTemplate: page.LinkTemplate, }, nil } diff --git a/cmd/sysml/render_test.go b/cmd/sysml/render_test.go index b54950b0fb..0f14a8308b 100644 --- a/cmd/sysml/render_test.go +++ b/cmd/sysml/render_test.go @@ -307,6 +307,47 @@ func TestRenderPalette(t *testing.T) { } } +func TestRenderLinkFlag(t *testing.T) { + binary := buildCLI(t) + dir := t.TempDir() + path := writeModel(t, dir, "linked.sysml", `package Demo { + part def Vehicle; + view overview { expose Demo::Vehicle; } +} +`) + template := "https://example.test/src/{file}#L{line}:{col}" + got := runFiles(t, binary, []string{path}, "-render", "Demo::overview", "-render-form", "mermaid", "-render-link", template) + if got.status != exitHolds { + t.Fatalf("exit status = %d, want %d\n%s", got.status, exitHolds, got.output()) + } + want := `click n0 href "https://example.test/src/` + filepath.ToSlash(path) + `#L2:` + if !strings.Contains(got.stdout, want) { + t.Errorf("rendered artifact lacks the source link %q:\n%s", want, got.stdout) + } + + allDir := filepath.Join(t.TempDir(), "all") + all := runFiles(t, binary, []string{path}, "-render-all", allDir, "-render-form", "mermaid", "-render-link", template) + if all.status != exitHolds { + t.Fatalf("-render-all status = %d, want %d\n%s", all.status, exitHolds, all.output()) + } + artifact, err := os.ReadFile(filepath.Join(allDir, "Demo.overview.mmd")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(artifact), want) { + t.Errorf("-render-all artifact lacks source links:\n%s", artifact) + } + + invalid := runFiles(t, binary, []string{path}, "-render", "Demo::overview", "-render-link", "https://example.test/{unknown}") + if invalid.status != exitUnevaluable || !strings.Contains(invalid.stderr, "-render-link: unknown link template placeholder {unknown}") || invalid.stdout != "" { + t.Errorf("invalid link template = %d\n%s", invalid.status, invalid.output()) + } + alone := runStreams(t, binary, renderModel, "-render-link", template) + if alone.status != 2 || !strings.Contains(alone.stderr, "-render-link links rendered elements to their source") { + t.Errorf("link template without a render target = %d\n%s", alone.status, alone.output()) + } +} + // TestRenderSeveralFiles checks that a view declared in one file renders the // elements its sibling files declare, loaded as one model, on stdout and into // -o in the form -render-form names. diff --git a/cmd/sysml/usage.go b/cmd/sysml/usage.go index 9a57bcf382..89da0252f1 100644 --- a/cmd/sysml/usage.go +++ b/cmd/sysml/usage.go @@ -665,6 +665,7 @@ func registerFlags(fs *flag.FlagSet) { fs.StringVar(&renderAllDir, "render-all", "", "Render every declared view into this directory") fs.StringVar(&renderForm, "render-form", "", "Form -render or -render-all writes: text, mermaid, markdown, dot, plantuml, csv or tsv (csv and tsv for a table); default from the destination for -render, each kind's machine form for -render-all") fs.StringVar(&renderPalette, "render-palette", "", "Palette the dot, mermaid or plantuml 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") + fs.StringVar(&renderLink, "render-link", "", "Link template for rendered elements: {file} is the path as loaded; use absolute paths for vscode:// or file:// links. Placeholders: {file}, {line}, {col}, {qname}, {id}") fs.StringVar(&renderStyle, "render-style", "", "Drawing style of the dot or mermaid form: pilot (default), the Pilot visualizer's black and white, or cameo, the look of Cameo Systems Modeler; applies to -render, -render-all and document diagrams") fs.StringVar(&renderPorts, "render-ports", "", "How much of a part's ports -render or -render-all draws on an interconnection: minimal (default), the ports its connectors end at, each a small square on the part's border named beside it, or full, every port, labelled name : Type") fs.StringVar(&renderUnplaced, "render-unplaced", "", "Where a graph form of a view some Layout positions puts the nodes none does: omit (default) leaves them undrawn in every form, strip draws them, in rows below the dot drawing; applies to -render, -render-all and document diagrams") @@ -790,6 +791,7 @@ func optionGroups() []usage.OptionGroup { usage.Opt("render-all", ""), usage.Opt("render-form", formArg), usage.Opt("render-palette", ""), + usage.Opt("render-link", "