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", ""),
usage.Opt("render-unplaced", ""),
usage.Opt("render-style", "
+skinparam wrapWidth 300
+hide stereotype
+hide empty description
+state "//«action def»//\n**Drive**" as n0 <> [[https://example.test/src/action.sysml#L11]] {
+ state "**initial**" as n1 <>
+ state "//«action»//\n**provide : Provide**" as n2 <> <> [[https://example.test/src/action.sysml#L16]]
+ state "//«action»//\n**monitor**\nown flow" as n3 <> <> [[https://example.test/src/action.sysml#L20]] {
+ state "**initial**" as n4 <>
+ state "//«action»//\n**record**" as n5 <> <> [[https://example.test/src/action.sysml#L23]]
+ }
+ state "//«action»//\n**tally**" as n6 <> <> [[https://example.test/src/action.sysml#L29]]
+ state "//«fork»//\n**split**" as n7 <>
+ state "//«join»//\n**sync**" as n8 <>
+ state "**final**" as n9 <>
+ state "//«action»//\n**park**" as n10 <> <> [[https://example.test/src/action.sysml#L36]]
+ state "//«decision»//\n**check**" as n11 <>
+}
+n4 --> n5 : [[https://example.test/src/action.sysml#L24]]
+n1 --> n7 : [[https://example.test/src/action.sysml#L38]]
+n2 --> n8 : [[https://example.test/src/action.sysml#L41]]
+n2 -[dashed]-> n3 : torque to reading [[https://example.test/src/action.sysml#L52]]
+n3 --> n8 : [[https://example.test/src/action.sysml#L42]]
+n6 --> n8 : [[https://example.test/src/action.sysml#L44]]
+n7 --> n2 : [[https://example.test/src/action.sysml#L39]]
+n7 --> n3 : [[https://example.test/src/action.sysml#L40]]
+n7 --> n6 : [[https://example.test/src/action.sysml#L43]]
+n8 --> n11 : [[https://example.test/src/action.sysml#L47]]
+n10 --> n9 : [[https://example.test/src/action.sysml#L50]]
+n11 --> n9 : [speed 0] [[https://example.test/src/action.sysml#L48]]
+n11 --> n10 : [[https://example.test/src/action.sysml#L49]]
+@enduml
diff --git a/internal/ir/view/testdata/links-interconnection-dot.golden b/internal/ir/view/testdata/links-interconnection-dot.golden
new file mode 100644
index 0000000000..6fca8bcce6
--- /dev/null
+++ b/internal/ir/view/testdata/links-interconnection-dot.golden
@@ -0,0 +1,22 @@
+// view: PlantViews::loopView
+// kind: interconnection
+// stated: render asInterconnectionDiagram
+// layout: dot
+digraph "PlantViews::loopView" {
+ graph [fontname="Helvetica"];
+ node [shape=box, style=filled, fillcolor=white, color="#181818", fontname="Helvetica", fontsize=14, penwidth=0.5];
+ edge [color="#181818", fontname="Helvetica", fontsize=13, penwidth=1];
+ subgraph "cluster_n0" {
+ label=<«part def»
Loop>;
+ color=black;
+ penwidth=0.5;
+ URL="https://example.test/src/interconnection.sysml#L16";
+ tooltip="Plant::Loop";
+ "n0" [shape=point, style=invis, width=0, height=0, label=""];
+ "n1" [shape=plain, label=<>, URL="https://example.test/src/interconnection.sysml#L17", tooltip="Plant::Loop::pump"];
+ "n2" [shape=plain, label=<>, URL="https://example.test/src/interconnection.sysml#L18", tooltip="Plant::Loop::tank"];
+ "n3" [style="rounded,filled", label=<«part»
sensor : Pump>, URL="https://example.test/src/interconnection.sysml#L22", tooltip="Plant::Loop::sensor"];
+ }
+ "n1":"n1.0" -> "n2":"n2.0" [label="supply", arrowhead=none, penwidth=3, URL="https://example.test/src/interconnection.sysml#L19", tooltip="Plant::Loop::supply"];
+ "n1" -> "n2" [label="of Water", style=dashed, URL="https://example.test/src/interconnection.sysml#L20", tooltip="interconnection.sysml:20:3"];
+}
diff --git a/internal/ir/view/testdata/links-interconnection-mermaid.golden b/internal/ir/view/testdata/links-interconnection-mermaid.golden
new file mode 100644
index 0000000000..b639732a2c
--- /dev/null
+++ b/internal/ir/view/testdata/links-interconnection-mermaid.golden
@@ -0,0 +1,52 @@
+---
+config:
+ fontFamily: "Helvetica, Arial, sans-serif"
+ theme: base
+ themeCSS: ".edgeLabel rect { opacity: 1 !important; } .cluster-label .nodeLabel { text-align: center; }"
+ themeVariables:
+ fontFamily: "Helvetica, Arial, sans-serif"
+ fontSize: "14px"
+ primaryColor: "#FFFFFF"
+ secondaryColor: "#FFFFFF"
+ tertiaryColor: "#FFFFFF"
+ background: "#FFFFFF"
+ primaryBorderColor: "#181818"
+ primaryTextColor: "#000000"
+ lineColor: "#181818"
+ textColor: "#000000"
+ noteBkgColor: "#FEFFDD"
+ noteBorderColor: "#181818"
+ noteTextColor: "#000000"
+ clusterBkg: "#FFFFFF"
+ clusterBorder: "#181818"
+ edgeLabelBackground: "#FFFFFF"
+ flowchart:
+ subGraphTitleMargin:
+ bottom: 24
+---
+%% PlantViews::loopView — interconnection rendering (render asInterconnectionDiagram)
+flowchart LR
+ subgraph n0 ["`*«part def»*
+**Loop**`"]
+ direction LR
+ subgraph n1 ["`*«part»*
+**pump : Pump**`"]
+ direction LR
+ n1_anchor[" "]
+ n1.0["outlet"]
+ end
+ subgraph n2 ["`*«part»*
+**tank : Tank**`"]
+ direction LR
+ n2_anchor[" "]
+ n2.0["inlet"]
+ end
+ n3("`*«part»*
+**sensor : Pump**`")
+ end
+ n1.0 ===|"supply"| n2.0
+ n1_anchor -.->|"of Water"| n2_anchor
+ classDef anchor fill:none,stroke:none
+ class n1_anchor,n2_anchor anchor
+ linkStyle 0 stroke-width:3px
+ click n3 href "https://example.test/src/interconnection.sysml#L22"
diff --git a/internal/ir/view/testdata/links-interconnection-plantuml.golden b/internal/ir/view/testdata/links-interconnection-plantuml.golden
new file mode 100644
index 0000000000..dd1027968e
--- /dev/null
+++ b/internal/ir/view/testdata/links-interconnection-plantuml.golden
@@ -0,0 +1,54 @@
+@startuml
+' PlantViews::loopView — interconnection rendering (render asInterconnectionDiagram)
+
+skinparam wrapWidth 300
+hide stereotype
+rectangle "//«part def»//\n**Loop**" as n0 <> [[https://example.test/src/interconnection.sysml#L16]] {
+ rectangle "//«part»//\n**pump : Pump**" as n1 <> <> [[https://example.test/src/interconnection.sysml#L17]] {
+ port "outlet" as n1.0
+ }
+ rectangle "//«part»//\n**tank : Tank**" as n2 <> <> [[https://example.test/src/interconnection.sysml#L18]] {
+ port "inlet" as n2.0
+ }
+ rectangle "//«part»//\n**sensor : Pump**" as n3 <> <> [[https://example.test/src/interconnection.sysml#L22]]
+}
+n1.0 -[thickness=3]- n2.0 : supply [[https://example.test/src/interconnection.sysml#L19]]
+n1 -[dashed]-> n2 : of Water [[https://example.test/src/interconnection.sysml#L20]]
+@enduml
diff --git a/internal/ir/view/testdata/links-sequence-mermaid.golden b/internal/ir/view/testdata/links-sequence-mermaid.golden
new file mode 100644
index 0000000000..c60d382250
--- /dev/null
+++ b/internal/ir/view/testdata/links-sequence-mermaid.golden
@@ -0,0 +1,43 @@
+---
+config:
+ fontFamily: "Helvetica, Arial, sans-serif"
+ theme: base
+ themeVariables:
+ fontFamily: "Helvetica, Arial, sans-serif"
+ fontSize: "14px"
+ primaryColor: "#FFFFFF"
+ secondaryColor: "#FFFFFF"
+ tertiaryColor: "#FFFFFF"
+ background: "#FFFFFF"
+ primaryBorderColor: "#181818"
+ primaryTextColor: "#000000"
+ lineColor: "#181818"
+ textColor: "#000000"
+ noteBkgColor: "#FEFFDD"
+ noteBorderColor: "#181818"
+ noteTextColor: "#000000"
+ actorBkg: "#FFFFFF"
+ actorBorder: "#181818"
+ actorTextColor: "#000000"
+ actorLineColor: "#181818"
+ signalColor: "#181818"
+ signalTextColor: "#000000"
+ labelBoxBkgColor: "#FFFFFF"
+ labelBoxBorderColor: "#181818"
+ labelTextColor: "#000000"
+ loopTextColor: "#000000"
+ activationBorderColor: "#181818"
+ activationBkgColor: "#FFFFFF"
+ sequenceNumberColor: "#000000"
+---
+%% SequenceViews::pubSubView — sequence rendering (view def SequenceView)
+sequenceDiagram
+ participant n0 as «part»
producer
+ link n0: Source @ https://example.test/src/sequence.sysml#L26
+ participant n1 as «part»
server
+ link n1: Source @ https://example.test/src/sequence.sysml#L32
+ participant n2 as «part»
consumer
+ link n2: Source @ https://example.test/src/sequence.sysml#L41
+ n2->>n1: subscribe_message
+ n0->>n1: publish_message
+ n1->>n2: deliver_message
diff --git a/internal/ir/view/testdata/links-sequence-plantuml.golden b/internal/ir/view/testdata/links-sequence-plantuml.golden
new file mode 100644
index 0000000000..4eb341ce6a
--- /dev/null
+++ b/internal/ir/view/testdata/links-sequence-plantuml.golden
@@ -0,0 +1,49 @@
+@startuml
+' SequenceViews::pubSubView — sequence rendering (view def SequenceView)
+
+skinparam wrapWidth 300
+hide stereotype
+participant "//«part»//\n**producer**" as n0 <> <> [[https://example.test/src/sequence.sysml#L26]]
+participant "//«part»//\n**server**" as n1 <> <> [[https://example.test/src/sequence.sysml#L32]]
+participant "//«part»//\n**consumer**" as n2 <> <> [[https://example.test/src/sequence.sysml#L41]]
+n2 -> n1 : [[https://example.test/src/sequence.sysml#L38]] subscribe_message
+n0 -> n1 : [[https://example.test/src/sequence.sysml#L30]] publish_message
+n1 -> n2 : [[https://example.test/src/sequence.sysml#L39]] deliver_message
+@enduml
diff --git a/internal/ir/view/testdata/links-state-dot.golden b/internal/ir/view/testdata/links-state-dot.golden
new file mode 100644
index 0000000000..78afde2c5b
--- /dev/null
+++ b/internal/ir/view/testdata/links-state-dot.golden
@@ -0,0 +1,36 @@
+// view: MachineViews::vehicleStates
+// kind: state
+// stated: view def StateTransitionView
+// layout: dot
+digraph "MachineViews::vehicleStates" {
+ graph [fontname="Helvetica", compound=true];
+ node [shape=box, style=filled, fillcolor=white, color="#181818", fontname="Helvetica", fontsize=14, penwidth=0.5];
+ edge [color="#181818", fontname="Helvetica", fontsize=13, penwidth=1];
+ subgraph "cluster_n0" {
+ label=<«state def»
VehicleStates>;
+ color=black;
+ penwidth=0.5;
+ URL="https://example.test/src/state.sysml#L8";
+ tooltip="Machines::VehicleStates";
+ "n0" [shape=point, style=invis, width=0, height=0, label=""];
+ "n5" [shape=point, fillcolor=black, label=""];
+ "n1" [style="rounded,filled", label=<«state»
off
initial>, URL="https://example.test/src/state.sysml#L13", tooltip="Machines::VehicleStates::off"];
+ subgraph "cluster_n2" {
+ label=<«state»
operating>;
+ color=black;
+ penwidth=0.5;
+ URL="https://example.test/src/state.sysml#L14";
+ tooltip="Machines::VehicleStates::operating";
+ "n2" [shape=point, style=invis, width=0, height=0, label=""];
+ "n6" [shape=point, fillcolor=black, label=""];
+ "n3" [style="rounded,filled", label=<«state»
idle
initial>, URL="https://example.test/src/state.sysml#L17", tooltip="Machines::VehicleStates::operating::idle"];
+ "n4" [style="rounded,filled", label=<«state»
moving>, URL="https://example.test/src/state.sysml#L18", tooltip="Machines::VehicleStates::operating::moving"];
+ }
+ }
+ "n5" -> "n1" [URL="https://example.test/src/state.sysml#L11", tooltip="state.sysml:11:10"];
+ "n6" -> "n3" [URL="https://example.test/src/state.sysml#L15", tooltip="state.sysml:15:11"];
+ "n1" -> "n2" [URL="https://example.test/src/state.sysml#L29", tooltip="state.sysml:29:3", lhead="cluster_n2"];
+ "n2" -> "n1" [URL="https://example.test/src/state.sysml#L30", tooltip="state.sysml:30:3", ltail="cluster_n2"];
+ "n3" -> "n4" [label="accept Signal [temperature > 0]", URL="https://example.test/src/state.sysml#L20", tooltip="Machines::VehicleStates::operating::idle_to_moving"];
+ "n4" -> "n3" [URL="https://example.test/src/state.sysml#L26", tooltip="state.sysml:26:4"];
+}
diff --git a/internal/ir/view/testdata/links-state-mermaid.golden b/internal/ir/view/testdata/links-state-mermaid.golden
new file mode 100644
index 0000000000..9c073a9aab
--- /dev/null
+++ b/internal/ir/view/testdata/links-state-mermaid.golden
@@ -0,0 +1,48 @@
+---
+config:
+ fontFamily: "Helvetica, Arial, sans-serif"
+ theme: base
+ themeVariables:
+ fontFamily: "Helvetica, Arial, sans-serif"
+ fontSize: "14px"
+ primaryColor: "#FFFFFF"
+ secondaryColor: "#FFFFFF"
+ tertiaryColor: "#FFFFFF"
+ background: "#FFFFFF"
+ primaryBorderColor: "#181818"
+ primaryTextColor: "#000000"
+ lineColor: "#181818"
+ textColor: "#000000"
+ noteBkgColor: "#FEFFDD"
+ noteBorderColor: "#181818"
+ noteTextColor: "#000000"
+ stateBkg: "#FFFFFF"
+ stateBorder: "#181818"
+ stateLabelColor: "#000000"
+ compositeBackground: "#FFFFFF"
+ compositeBorder: "#181818"
+ compositeTitleBackground: "#FFFFFF"
+ compositeTitleBorder: "#181818"
+ transitionColor: "#181818"
+ transitionLabelColor: "#000000"
+ labelBackgroundColor: "#FFFFFF"
+ specialStateColor: "#181818"
+---
+%% MachineViews::vehicleStates — state rendering (view def StateTransitionView)
+stateDiagram-v2
+ state "«state def»
VehicleStates" as n0 {
+ state "«state»
off
initial" as n1
+ state "«state»
operating" as n2 {
+ state "«state»
idle
initial" as n3
+ state "«state»
moving" as n4
+ [*] --> n3
+ }
+ [*] --> n1
+ }
+ n1 --> n2
+ n2 --> n1
+ n3 --> n4 : accept Signal [temperature #gt; 0]
+ n4 --> n3
+ click n1 href "https://example.test/src/state.sysml#L13"
+ click n3 href "https://example.test/src/state.sysml#L17"
+ click n4 href "https://example.test/src/state.sysml#L18"
diff --git a/internal/ir/view/testdata/links-state-plantuml.golden b/internal/ir/view/testdata/links-state-plantuml.golden
new file mode 100644
index 0000000000..474a58a465
--- /dev/null
+++ b/internal/ir/view/testdata/links-state-plantuml.golden
@@ -0,0 +1,57 @@
+@startuml
+' MachineViews::vehicleStates — state rendering (view def StateTransitionView)
+
+skinparam wrapWidth 300
+hide stereotype
+hide empty description
+state "//«state def»//\n**VehicleStates**" as n0 <> [[https://example.test/src/state.sysml#L8]] {
+ state "//«state»//\n**off**\ninitial" as n1 <> <> [[https://example.test/src/state.sysml#L13]]
+ state "//«state»//\n**operating**" as n2 <> <> [[https://example.test/src/state.sysml#L14]] {
+ state "//«state»//\n**idle**\ninitial" as n3 <> <> [[https://example.test/src/state.sysml#L17]]
+ state "//«state»//\n**moving**" as n4 <> <> [[https://example.test/src/state.sysml#L18]]
+ [*] --> n3
+ }
+ [*] --> n1
+}
+n1 --> n2 : [[https://example.test/src/state.sysml#L29]]
+n2 --> n1 : [[https://example.test/src/state.sysml#L30]]
+n3 --> n4 : accept Signal [temperature 0] [[https://example.test/src/state.sysml#L20]]
+n4 --> n3 : [[https://example.test/src/state.sysml#L26]]
+@enduml
diff --git a/internal/ir/view/testdata/links-tree-dot.golden b/internal/ir/view/testdata/links-tree-dot.golden
new file mode 100644
index 0000000000..3623ef243f
--- /dev/null
+++ b/internal/ir/view/testdata/links-tree-dot.golden
@@ -0,0 +1,22 @@
+// view: VehicleViews::vehicleView
+// kind: tree
+// layout: dot
+digraph "VehicleViews::vehicleView" {
+ graph [fontname="Helvetica"];
+ node [shape=box, style=filled, fillcolor=white, color="#181818", fontname="Helvetica", fontsize=14, penwidth=0.5];
+ edge [color="#181818", fontname="Helvetica", fontsize=13, penwidth=1];
+ "n0" [label=<«part def»
Vehicles::Vehicle>, URL="https://example.test/src/tree.sysml#L11", tooltip="Vehicles::Vehicle"];
+ "n1" [style="rounded,filled", label=<«part»
engine : Engine>, URL="https://example.test/src/tree.sysml#L12", tooltip="Vehicles::Vehicle::engine"];
+ "n0" -> "n1" [arrowhead=none];
+ "n2" [style="rounded,filled", label=<«part»
wheels : Wheel>, URL="https://example.test/src/tree.sysml#L13", tooltip="Vehicles::Vehicle::wheels"];
+ "n0" -> "n2" [arrowhead=none];
+ "n3" [style="rounded,filled", label=<connect>, URL="https://example.test/src/tree.sysml#L14", tooltip="tree.sysml:14:3"];
+ "n0" -> "n3" [arrowhead=none];
+ "n4" [style="rounded,filled", label=<«view»
VehicleViews::vehicleView::engineSubview>, URL="https://example.test/src/tree.sysml#L29", tooltip="VehicleViews::vehicleView::engineSubview"];
+ "n5" [label=<«part def»
Vehicles::Engine>, URL="https://example.test/src/tree.sysml#L4", tooltip="Vehicles::Engine"];
+ "n6" [style="rounded,filled", label=<«attribute»
power>, URL="https://example.test/src/tree.sysml#L5", tooltip="Vehicles::Engine::power"];
+ "n5" -> "n6" [arrowhead=none];
+ "n7" [style="rounded,filled", label=<«part»
cylinder : Cylinder>, URL="https://example.test/src/tree.sysml#L6", tooltip="Vehicles::Engine::cylinder"];
+ "n5" -> "n7" [arrowhead=none];
+ "n4" -> "n5" [arrowhead=none];
+}
diff --git a/internal/ir/view/testdata/links-tree-mermaid.golden b/internal/ir/view/testdata/links-tree-mermaid.golden
new file mode 100644
index 0000000000..31b2798686
--- /dev/null
+++ b/internal/ir/view/testdata/links-tree-mermaid.golden
@@ -0,0 +1,54 @@
+---
+config:
+ fontFamily: "Helvetica, Arial, sans-serif"
+ theme: base
+ themeCSS: ".edgeLabel rect { opacity: 1 !important; } .cluster-label .nodeLabel { text-align: center; }"
+ themeVariables:
+ fontFamily: "Helvetica, Arial, sans-serif"
+ fontSize: "14px"
+ primaryColor: "#FFFFFF"
+ secondaryColor: "#FFFFFF"
+ tertiaryColor: "#FFFFFF"
+ background: "#FFFFFF"
+ primaryBorderColor: "#181818"
+ primaryTextColor: "#000000"
+ lineColor: "#181818"
+ textColor: "#000000"
+ noteBkgColor: "#FEFFDD"
+ noteBorderColor: "#181818"
+ noteTextColor: "#000000"
+ clusterBkg: "#FFFFFF"
+ clusterBorder: "#181818"
+ edgeLabelBackground: "#FFFFFF"
+---
+%% VehicleViews::vehicleView — tree rendering
+flowchart TD
+ n0["`*«part def»*
+**Vehicles::Vehicle**`"]
+ n1("`*«part»*
+**engine : Engine**`")
+ n0 --- n1
+ n2("`*«part»*
+**wheels : Wheel**`")
+ n0 --- n2
+ n3("`**connect**`")
+ n0 --- n3
+ n4("`*«view»*
+**VehicleViews::vehicleView::engineSubview**`")
+ n5["`*«part def»*
+**Vehicles::Engine**`"]
+ n6("`*«attribute»*
+**power**`")
+ n5 --- n6
+ n7("`*«part»*
+**cylinder : Cylinder**`")
+ n5 --- n7
+ n4 --- n5
+ click n0 href "https://example.test/src/tree.sysml#L11"
+ click n1 href "https://example.test/src/tree.sysml#L12"
+ click n2 href "https://example.test/src/tree.sysml#L13"
+ click n3 href "https://example.test/src/tree.sysml#L14"
+ click n4 href "https://example.test/src/tree.sysml#L29"
+ click n5 href "https://example.test/src/tree.sysml#L4"
+ click n6 href "https://example.test/src/tree.sysml#L5"
+ click n7 href "https://example.test/src/tree.sysml#L6"
diff --git a/internal/ir/view/testdata/links-tree-plantuml.golden b/internal/ir/view/testdata/links-tree-plantuml.golden
new file mode 100644
index 0000000000..00e5b3ccac
--- /dev/null
+++ b/internal/ir/view/testdata/links-tree-plantuml.golden
@@ -0,0 +1,59 @@
+@startuml
+' VehicleViews::vehicleView — tree rendering
+
+skinparam wrapWidth 300
+hide stereotype
+hide circle
+hide empty members
+class "//«part def»//\n**Vehicles::Vehicle**" as n0 <> [[https://example.test/src/tree.sysml#L11]]
+class "//«part»//\n**engine : Engine**" as n1 <> <> [[https://example.test/src/tree.sysml#L12]]
+n0 -- n1
+class "//«part»//\n**wheels : Wheel**" as n2 <> <> [[https://example.test/src/tree.sysml#L13]]
+n0 -- n2
+class "**connect**" as n3 <> <> [[https://example.test/src/tree.sysml#L14]]
+n0 -- n3
+class "//«view»//\n**VehicleViews::vehicleView::engineSubview**" as n4 <> <> [[https://example.test/src/tree.sysml#L29]]
+class "//«part def»//\n**Vehicles::Engine**" as n5 <> [[https://example.test/src/tree.sysml#L4]]
+class "//«attribute»//\n**power**" as n6 <> <> [[https://example.test/src/tree.sysml#L5]]
+n5 -- n6
+class "//«part»//\n**cylinder : Cylinder**" as n7 <> <> [[https://example.test/src/tree.sysml#L6]]
+n5 -- n7
+n4 -- n5
+@enduml
diff --git a/internal/workspace/model/reading.go b/internal/workspace/model/reading.go
index b49a9ecb63..0406443919 100644
--- a/internal/workspace/model/reading.go
+++ b/internal/workspace/model/reading.go
@@ -50,6 +50,23 @@ func (r *Reading) SourceText() view.SourceText { return r.w.sourceText() }
// Document is the held document name, nil for one the workspace does not hold.
func (r *Reading) Document(name string) *Document { return r.w.docs[name] }
+// LineIndex is the held document's line index, nil when the workspace does not hold it.
+func (r *Reading) LineIndex(name string) *source.LineIndex {
+ if doc := r.Document(name); doc != nil {
+ return doc.Lines()
+ }
+ return nil
+}
+
+// LineIndexes snapshots the line indexes of the held documents.
+func (r *Reading) LineIndexes() map[string]*source.LineIndex {
+ indexes := make(map[string]*source.LineIndex, len(r.w.docs))
+ for name, doc := range r.w.docs {
+ indexes[name] = doc.Lines()
+ }
+ return indexes
+}
+
// Declared is the element fqn names in doc: by qualified name in the index, else
// by qualified or simple name among the document's own declarations.
func (r *Reading) Declared(doc, fqn string) *symbols.Symbol {
diff --git a/internal/workspace/model/render.go b/internal/workspace/model/render.go
index 78e7aeba87..b64e5c1a94 100644
--- a/internal/workspace/model/render.go
+++ b/internal/workspace/model/render.go
@@ -89,6 +89,7 @@ type Snapshot struct {
// Rendered is the document the rendering was asked of.
Rendered *Document
docs map[string]*Document
+ sites map[view.Origin]view.Site
}
// Document is the named document as the snapshot holds it; nil for a name the
@@ -97,6 +98,18 @@ func (s *Snapshot) Document(name string) *Document {
return s.docs[name]
}
+// Sites returns the source locations for the rendering's origins.
+func (s *Snapshot) Sites() view.Sites {
+ if s == nil || s.sites == nil {
+ return nil
+ }
+ sites := s.sites
+ return func(origin view.Origin) (view.Site, bool) {
+ site, ok := sites[origin]
+ return site, ok
+ }
+}
+
// RenderView renders a view of a document. fqn names a declared view or a
// pseudo-view (`#[:]`); "" renders the document's own view. The
// snapshot returned holds the documents the rendering was made from, read under
@@ -107,6 +120,48 @@ func (w *Workspace) RenderView(doc, fqn string) (*view.Rendering, *Snapshot, err
return w.renderViewLocked(doc, fqn)
}
+// RenderViewLinked renders a view and resolves its source sites under the
+// workspace lock, so the returned snapshot can be used after the lock is released.
+func (w *Workspace) RenderViewLinked(doc, fqn string) (*view.Rendering, *Snapshot, error) {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ rendering, snapshot, err := w.renderViewLocked(doc, fqn)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ resolver, sem := w.semanticsLocked()
+ renderer := view.NewRenderer(sem, resolver, w.sourceText())
+ sites := renderer.Sites(view.FileLocator(sem, func(name string) *source.LineIndex {
+ if doc := snapshot.docs[name]; doc != nil {
+ return doc.Lines()
+ }
+ return nil
+ }))
+ frozen := make(map[view.Origin]view.Site)
+ add := func(origin view.Origin) {
+ if site, ok := sites(origin); ok {
+ frozen[origin] = site
+ }
+ }
+ var visit func([]*view.Node)
+ visit = func(nodes []*view.Node) {
+ for _, node := range nodes {
+ add(node.Origin)
+ for _, port := range node.Ports {
+ add(port.Origin)
+ }
+ visit(node.Children)
+ }
+ }
+ visit(rendering.Roots)
+ for _, edge := range rendering.Edges {
+ add(edge.Origin)
+ }
+ snapshot.sites = frozen
+ return rendering, snapshot, nil
+}
+
// renderViewLocked is RenderView under the lock.
func (w *Workspace) renderViewLocked(doc, fqn string) (*view.Rendering, *Snapshot, error) {
d := w.docs[doc]
@@ -121,7 +176,8 @@ func (w *Workspace) renderViewLocked(doc, fqn string) (*view.Rendering, *Snapsho
if err != nil {
return nil, nil, err
}
- return rendering, &Snapshot{Rendered: d, docs: maps.Clone(w.docs)}, nil
+ snapshot := &Snapshot{Rendered: d, docs: maps.Clone(w.docs)}
+ return rendering, snapshot, nil
}
// renderDocumentViewLocked renders fqn of the held document d, as a query owned by d.
diff --git a/internal/workspace/model/render_test.go b/internal/workspace/model/render_test.go
index 7b0dcf3669..90148314e2 100644
--- a/internal/workspace/model/render_test.go
+++ b/internal/workspace/model/render_test.go
@@ -162,6 +162,9 @@ func TestRenderViewSnapshotHoldsEveryDocumentAsRendered(t *testing.T) {
if err != nil {
t.Fatalf("render: %v", err)
}
+ if snapshot.Sites() != nil {
+ t.Error("plain RenderView returned source sites")
+ }
if snapshot.Rendered != ws.Document("views.sysml") {
t.Error("Rendered is not the document the rendering was asked of")
}
@@ -194,6 +197,95 @@ func TestRenderViewSnapshotHoldsEveryDocumentAsRendered(t *testing.T) {
}
}
+func TestRenderViewLinkedFreezesNodePortAndEdgeSites(t *testing.T) {
+ const model = `package Plant {
+ port def FluidPort;
+ item def Water;
+ part def Pump {
+ port outlet : FluidPort;
+ out item waterOut : Water;
+ }
+ part def Tank {
+ port inlet : FluidPort;
+ in item waterIn : Water;
+ }
+ part def Loop {
+ part pump : Pump;
+ part tank : Tank;
+ connection supply : Connection connect pump.outlet to tank.inlet;
+ flow of Water from pump.waterOut to tank.waterIn;
+ }
+ connection def Connection;
+}
+package PlantViews {
+ private import Views::*;
+ view loopView {
+ expose Plant::Loop;
+ render asInterconnectionDiagram;
+ }
+}
+`
+ ws := openDoc(t, "linked.sysml", model)
+ if _, plain, err := ws.RenderView("linked.sysml", "PlantViews::loopView"); err != nil {
+ t.Fatalf("plain render: %v", err)
+ } else if plain.Sites() != nil {
+ t.Fatal("plain RenderView returned source sites")
+ }
+
+ rendering, snapshot, err := ws.RenderViewLinked("linked.sysml", "PlantViews::loopView")
+ if err != nil {
+ t.Fatalf("linked render: %v", err)
+ }
+ sites := snapshot.Sites()
+ if sites == nil {
+ t.Fatal("RenderViewLinked returned no source sites")
+ }
+
+ want := map[view.Origin]view.Site{}
+ counts := map[string]int{}
+ check := func(origin view.Origin, kind string) {
+ if !origin.Located() || origin.Doc != "linked.sysml" {
+ return
+ }
+ site, ok := sites(origin)
+ if !ok {
+ t.Errorf("%s origin %+v has no frozen site", kind, origin)
+ return
+ }
+ if site.File != "linked.sysml" || site.Line < 1 || site.Col < 1 {
+ t.Errorf("%s site = %+v, want a file and source position", kind, site)
+ }
+ want[origin] = site
+ counts[kind]++
+ }
+ var visit func([]*view.Node)
+ visit = func(nodes []*view.Node) {
+ for _, node := range nodes {
+ check(node.Origin, "node")
+ for _, port := range node.Ports {
+ check(port.Origin, "port")
+ }
+ visit(node.Children)
+ }
+ }
+ visit(rendering.Roots)
+ for _, edge := range rendering.Edges {
+ check(edge.Origin, "edge")
+ }
+ for _, kind := range []string{"node", "port", "edge"} {
+ if counts[kind] == 0 {
+ t.Errorf("rendering had no located %s origins", kind)
+ }
+ }
+
+ ws.Update("linked.sysml", []byte("// newer workspace generation\n"+model), 2)
+ for origin, wantSite := range want {
+ if got, ok := sites(origin); !ok || got != wantSite {
+ t.Errorf("frozen site for %+v = %+v, %t; want %+v, true", origin, got, ok, wantSite)
+ }
+ }
+}
+
// A transition a usage inherits from a definition in another document is
// located there and labelled with its trigger and guard as written there.
func TestRenderViewLocatesInheritedTransitionsInTheirDocument(t *testing.T) {
diff --git a/internal/workspace/modeldoc/modeldoc.go b/internal/workspace/modeldoc/modeldoc.go
index ade3a66fe4..3209f3779f 100644
--- a/internal/workspace/modeldoc/modeldoc.go
+++ b/internal/workspace/modeldoc/modeldoc.go
@@ -66,10 +66,13 @@ func RenderDocumentMarkdown(ws *model.Workspace, fqn string, opts docrender.Mark
if opts.Files, err = DocumentFiles(model.DocumentNames(idx, sem), ".md"); err != nil {
return
}
+ lineIndexes := r.LineIndexes()
var document *docir.Document
document, err = docir.EvaluateLinked(plan,
model.SiblingDocumentPlans(idx, sem, resolver, sym),
- queryexec.Context{Index: idx, Resolver: resolver, Model: sem},
+ queryexec.Context{Index: idx, Resolver: resolver, Model: sem, LineIndex: func(doc string) *source.LineIndex {
+ return lineIndexes[doc]
+ }},
queryexec.Options{}, r.SourceText())
if err != nil {
return
diff --git a/packaging/man/man1/sysml.1 b/packaging/man/man1/sysml.1
index 3a83d8ec27..4479f3d476 100644
--- a/packaging/man/man1/sysml.1
+++ b/packaging/man/man1/sysml.1
@@ -310,6 +310,11 @@ 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
.TP
+.BR \-render\-link " \fItemplate\fP"
+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}
+.TP
.BR \-render\-unplaced " \fIplacement\fP"
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