diff --git a/changes/unreleased/wasm-render-document.added.md b/changes/unreleased/wasm-render-document.added.md
new file mode 100644
index 0000000000..858cf55c76
--- /dev/null
+++ b/changes/unreleased/wasm-render-document.added.md
@@ -0,0 +1 @@
+- `sysml-wasm` and `sysml-engine` serve `RenderDocument`: a browser or stdio consumer runs the whole document pipeline — the definition's queries, its document plan and tree, and the Markdown, HTML or AsciiDoc backend — without a service, taking and answering the protojson of the service's `RenderDocumentRequest` and `RenderDocumentResponse` so a typed client's `renderDocument` works over either transport. The combined module advertises `render_document`, `render_document_html`, `render_document_asciidoc` and `render_document_file_name`; `pdf` is refused naming the three forms, and a diagram block whose view carries layout positions is written as Mermaid with the renderer's Graphviz notice, since no WebAssembly build runs Graphviz. The service's `RenderDocument` and the workspace's Markdown rendering now share one pipeline (`internal/doc/docset`).
diff --git a/client/node/README.md b/client/node/README.md
index 025b80cb12..5ae563946e 100644
--- a/client/node/README.md
+++ b/client/node/README.md
@@ -201,7 +201,7 @@ while another origin is not.
`connectWasm()` uses the same `Connection`, `Model`, values and errors over the
combined `sysml-wasm` module. It serves `ParseFile`, `ParseSources`,
`GetDiagnostics`, `GetSymbol`, `Evaluate`, `Instantiate`, `ExecuteAction`,
-`ExecuteState`, `RenderView` and `GetServerInfo`. Those are the module's complete RPC surface;
+`ExecuteState`, `RenderView`, `RenderDocument` and `GetServerInfo`. Those are the module's complete RPC surface;
other capability-gated operations fail with `MissingCapabilityError`, and a
direct unsupported RPC fails with `UNIMPLEMENTED`.
The adapter uses JSON encoding; requesting protobuf encoding is refused.
@@ -212,6 +212,12 @@ compartment rows, each a node whose `compartment` names the compartment of its `
page paints before its module has loaded). A traced `ExecuteState` answers the service's
`DocumentEvent` records and the module advertises `state_trace`, so
`connection.executeState(…, { trace: true })` returns the same typed trace over either.
+`RenderDocument` answers the service's protojson `RenderDocumentResponse` in the forms
+`markdown`, `html` and `asciidoc` and the module advertises `render_document`,
+`render_document_html`, `render_document_asciidoc` and `render_document_file_name`, so
+`model.renderDocument(name, { form })` returns the same text over either; `pdf` is refused
+naming the three forms, and a diagram whose view carries layout positions is written as
+Mermaid with the renderer's Graphviz notice, since no WebAssembly build runs Graphviz.
In Node, install the optional `@openmbee/opensysml-wasm` package at the same
version as this client. `connectWasm()` resolves its module and matching Go
diff --git a/client/node/test/wasm.test.ts b/client/node/test/wasm.test.ts
index c445d98fdc..cb73006306 100644
--- a/client/node/test/wasm.test.ts
+++ b/client/node/test/wasm.test.ts
@@ -548,6 +548,10 @@ for (const thread of ["worker", "inline"] as const) {
"state_trace",
"action_trace",
"symbol_members",
+ "render_document",
+ "render_document_html",
+ "render_document_asciidoc",
+ "render_document_file_name",
]);
const wasmModel = await wasm.loads(SAMPLE);
diff --git a/docs/internals/architecture.md b/docs/internals/architecture.md
index 48452b1a05..11644da931 100644
--- a/docs/internals/architecture.md
+++ b/docs/internals/architecture.md
@@ -108,7 +108,8 @@ github.com/Open-MBEE/OpenSysML
│ │ ├── queryexec/ # Query plan execution → typed, ordered row sets
│ │ ├── querytranslate/ # OSLC Query text and the API Query → query plans (the shared translator core)
│ │ ├── docir/ # Document plan evaluation → backend-agnostic document tree
-│ │ ├── docrender/ # Markdown and HTML document backends
+│ │ ├── docrender/ # Markdown, HTML and AsciiDoc document backends
+│ │ ├── docset/ # One document of a model's set, end to end: plan, siblings, files, a backend's text (service, engine and workspace share it)
│ │ └── docpdf/ # PDF document backend
│ ├── workspace/
│ │ ├── model/ # Workspace, document management
diff --git a/docs/project/api-json-differential/index.json b/docs/project/api-json-differential/index.json
index 33a2616ea0..ddb4a0021d 100644
--- a/docs/project/api-json-differential/index.json
+++ b/docs/project/api-json-differential/index.json
@@ -60,7 +60,7 @@
"dir": "examples",
"origin": "ours",
"files": 56,
- "digest": "sha256:af4213fc66647bf2ed1342ec555c62cfceb07fa6a40ed35b2ec369b22bb5d436"
+ "digest": "sha256:cf60c702750d56f2651c4627a002167eb4ed353ba56df16f13b34e42be717555"
},
{
"name": "probes",
diff --git a/docs/project/pilot-differential-baseline.json b/docs/project/pilot-differential-baseline.json
index a9334f253f..27a240e4ce 100644
--- a/docs/project/pilot-differential-baseline.json
+++ b/docs/project/pilot-differential-baseline.json
@@ -69,7 +69,7 @@
"dir": "examples",
"origin": "ours",
"files": 56,
- "digest": "sha256:af4213fc66647bf2ed1342ec555c62cfceb07fa6a40ed35b2ec369b22bb5d436"
+ "digest": "sha256:cf60c702750d56f2651c4627a002167eb4ed353ba56df16f13b34e42be717555"
},
{
"name": "probes",
@@ -86,7 +86,7 @@
"digest": "sha256:f39db343dafb9c99359f5a92e56dcdd5c52992dc50e50b9268d349aa9cdfdf26"
}
],
- "recorded": "2026-11-10"
+ "recorded": "2026-11-12"
},
"totals": {
"files": 924,
diff --git a/docs/reference/wasm.md b/docs/reference/wasm.md
index 7bb32cfc92..b809bb677c 100644
--- a/docs/reference/wasm.md
+++ b/docs/reference/wasm.md
@@ -131,7 +131,8 @@ interactive stream work as they do under Node.
## The execution engine
`sysml-engine` is the in-process half of [the service](service-transports.md): the execution
-RPCs — `ParseSources`, `Evaluate`, `Instantiate`, `ExecuteAction`, `ExecuteState` — answered
+RPCs — `ParseSources`, `Evaluate`, `Instantiate`, `ExecuteAction`, `ExecuteState`, `RenderView`,
+`RenderDocument` — answered
with the same JSON `sysml-grpc` emits, but without the protobuf machinery, the analysis
framework or a held-object store, which is what keeps a WebAssembly build small enough to
embed in a page. A model parsed through it hashes to the same `modelHash` the service
@@ -171,6 +172,33 @@ The engine has no current-document context, so a pseudo-view must name an elemen
example `#tree:OpenSysMLStack::stack`. An untargeted `#tree`, `#interconnection` or `#matrix` is
refused with InvalidArgument and the supported pseudo-view spellings.
+It also serves `RenderDocument`, running the document pipeline — the definition's queries,
+its document plan and document tree, and a backend's text — as the protojson of the
+`RenderDocumentResponse` [`sysml-grpc` answers the RPC with](wire-contract.md#queries):
+`markdown`, `html` or `asciidoc` holds the text of the form asked for and `fileName` the
+file a rendered set writes the document to. A request names the cached model, the document
+definition's qualified name and, optionally, the form:
+
+```json
+{
+ "modelHash": "…",
+ "documentId": "Observatory::MassReport",
+ "form": "html"
+}
+```
+
+`form` is `markdown` (the default when empty), `html` or `asciidoc`, exactly the forms the
+service renders; `pdf` or any other spelling is refused with InvalidArgument naming those
+three, since the PDF backend drives converters no WebAssembly build can run. The engine
+holds no objects, so a document reads the model alone, as the service does before an
+`Instantiate`; a query's `elementId` column is empty, since neither `sysml-engine` nor
+`sysml-wasm` links the converter the service reads ids from; and no Graphviz draws for it, so a diagram block whose view carries layout
+positions is written as Mermaid with the renderer's notice (`drawn as Mermaid, not at its
+stated positions: Graphviz (dot) is not installed`) where the service writes DOT. A document
+that is not one — a part def not specializing `DocumentQueries::Document` — is InvalidArgument,
+an unknown name NotFound, and a fault in the model's own queries FailedPrecondition, with the
+source declaration named, as the service reports them.
+
The `js` build installs a host surface instead of reading a pipe: load it through
`wasm_exec.js` with no arguments and `globalThis.sysmlEngine` appears with
@@ -272,14 +300,18 @@ system and stdin, as the site's REPL page does.
`sysml-wasm` combines the parsing, validation and execution methods of `sysml-core` and
`sysml-engine` in one WebAssembly module. It serves `ParseSources`, `ParseFile`,
`GetDiagnostics`, `GetSymbol`, `Evaluate`, `Instantiate`, `ExecuteAction`, `ExecuteState`,
-`RenderView` and `GetServerInfo`. Parsing and symbol facts route through the core;
-evaluation, execution and view rendering route through the engine. `ParseSources` and
+`RenderView`, `RenderDocument` and `GetServerInfo`. Parsing and symbol facts route through the core;
+evaluation, execution, view rendering and document rendering route through the engine. `ParseSources` and
`ParseFile` check that both frontends produce the same model hash and return the core
response, including roots and diagnostics. `RenderView` answers with the protojson
`RenderViewResponse` the service returns, so the module advertises the `render_view`
capability and a service client's typed `renderView` works over it unchanged. A traced
`ExecuteState` likewise answers its `trace` as the service's `DocumentEvent` records and
the module advertises `state_trace`, so a typed `executeState` with `trace` works over it.
+`RenderDocument` answers the protojson `RenderDocumentResponse` in the forms `markdown`,
+`html` and `asciidoc`, and the module advertises `render_document`, `render_document_html`,
+`render_document_asciidoc` and `render_document_file_name`, so a typed `renderDocument`
+works over it too; `pdf` is refused naming the three forms, as [the engine](#the-execution-engine) refuses it.
A hash from either parse method is shared by every method that takes a `modelHash`, including
`GetDiagnostics`, `GetSymbol`, `Evaluate`, `Instantiate`, `ExecuteAction` and `ExecuteState`.
@@ -290,7 +322,8 @@ The module retains the 16 most recently used models; an evicted hash is evicted
`structured_values`, `measurement_refs`, `function_values`, `set_values`, `tensor_values`,
`infinity_value`, `diagnostic_codes`, `schedule`, `final_time`, `metaobject_values`,
`undetermined_value`, `performer`, `big_int_values`, `render_view`, `state_trace`,
-`action_trace`.
+`action_trace`, `render_document`, `render_document_html`, `render_document_asciidoc`,
+`render_document_file_name`.
The `js` build installs one synchronous host surface, `globalThis.sysmlWasm`, with
`version` and `call(method, paramsJSON)`. The call returns a JSON-RPC envelope string.
diff --git a/examples/self-model/surfaces.sysml b/examples/self-model/surfaces.sysml
index 6a977b94a7..2ff72d07bc 100644
--- a/examples/self-model/surfaces.sysml
+++ b/examples/self-model/surfaces.sysml
@@ -195,7 +195,7 @@ package OpenSysMLSurfaces {
#Surface part def EngineCommand :> JsonCommand {
attribute :>> goPackage = "internal/frontend/engine";
attribute :>> entrypoint = "cmd/sysml-engine";
- attribute :>> serves = "ParseSources, Evaluate, Instantiate, ExecuteAction, ExecuteState, RenderView";
+ attribute :>> serves = "ParseSources, Evaluate, Instantiate, ExecuteAction, ExecuteState, RenderView, RenderDocument";
}
#Surface part def CoreCommand :> JsonCommand {
@@ -208,7 +208,7 @@ package OpenSysMLSurfaces {
#Surface part def CombinedCommand :> JsonCommand {
attribute :>> goPackage = "internal/frontend/combined";
attribute :>> entrypoint = "cmd/sysml-wasm";
- attribute :>> serves = "ParseSources, ParseFile, GetDiagnostics, GetSymbol, Evaluate, Instantiate, ExecuteAction, ExecuteState, RenderView, GetServerInfo";
+ attribute :>> serves = "ParseSources, ParseFile, GetDiagnostics, GetSymbol, Evaluate, Instantiate, ExecuteAction, ExecuteState, RenderView, RenderDocument, GetServerInfo";
attribute releaseAsset : String = "sysml-wasm.wasm";
}
diff --git a/internal/doc/docset/docset.go b/internal/doc/docset/docset.go
new file mode 100644
index 0000000000..6c87e49913
--- /dev/null
+++ b/internal/doc/docset/docset.go
@@ -0,0 +1,293 @@
+// Copyright 2025 Open‐MBEE Foundation. All rights reserved.
+// Use of this source code is governed by the LICENSE file.
+
+// Package docset renders one document of a model's set end to end — plan, siblings,
+// the files a set writes, a backend's text — for the service, engine and workspace alike.
+package docset
+
+import (
+ "errors"
+ "fmt"
+ "slices"
+ "sort"
+ "strings"
+
+ "github.com/Open-MBEE/OpenSysML/internal/doc/docir"
+ "github.com/Open-MBEE/OpenSysML/internal/doc/docrender"
+ "github.com/Open-MBEE/OpenSysML/internal/doc/queryexec"
+ "github.com/Open-MBEE/OpenSysML/internal/exec/runtime"
+ "github.com/Open-MBEE/OpenSysML/internal/ir/docplan"
+ "github.com/Open-MBEE/OpenSysML/internal/ir/queryplan"
+ "github.com/Open-MBEE/OpenSysML/internal/ir/view"
+ "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve"
+ "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics"
+ "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols"
+ "github.com/Open-MBEE/OpenSysML/internal/syntax/source"
+ "github.com/Open-MBEE/OpenSysML/internal/translate/filename"
+)
+
+// Definitions lists the document definitions declared in scope, outermost first.
+func Definitions(index *symbols.Index, sem *semantics.Model, scope *symbols.Scope) []*symbols.Symbol {
+ var out []*symbols.Symbol
+ scope.WalkDeclarations(func(sym *symbols.Symbol) {
+ if docplan.IsDocumentDefinition(index, sem, sym) {
+ out = append(out, sym)
+ }
+ })
+ return out
+}
+
+// Names lists the qualified names of every document definition the model's
+// workspace documents declare, sorted.
+func Names(index *symbols.Index, sem *semantics.Model) []string {
+ var names []string
+ for _, doc := range index.WorkspaceDocuments() {
+ for _, sym := range Definitions(index, sem, index.DocumentRoot(doc)) {
+ names = append(names, symbols.FQNOf(sym))
+ }
+ }
+ sort.Strings(names)
+ return names
+}
+
+// SiblingPlans compiles every other document definition the workspace documents declare,
+// skipping any that does not compile, so a single render emits the anchors siblings link to.
+func SiblingPlans(index *symbols.Index, sem *semantics.Model, resolver *resolve.Resolver, entry *symbols.Symbol) []*docplan.Plan {
+ var plans []*docplan.Plan
+ for _, doc := range index.WorkspaceDocuments() {
+ for _, sym := range Definitions(index, sem, index.DocumentRoot(doc)) {
+ if sym == entry {
+ continue
+ }
+ plan, err := docplan.Compile(index, sem, resolver, sym)
+ if err != nil {
+ continue
+ }
+ plans = append(plans, plan)
+ }
+ }
+ return plans
+}
+
+// Files plans the file a set rendered in the form of extension writes each named document to:
+// its stem cut to fit, tagged where two would meet letter case aside; two of one name cannot be told apart.
+func Files(names []string, extension string) (map[string]string, error) {
+ files, err := filename.Plan(names, func(name string, tagged bool) string {
+ return filename.Fit(docrender.DocumentFileStem(name), extension, '.', tagged)
+ })
+ var collision *filename.CollisionError
+ if errors.As(err, &collision) {
+ if collision.Names[0] == collision.Names[1] {
+ return nil, fmt.Errorf("%s names more than one document; rename one so the name is unambiguous", source.QualifiedNameText(collision.Names[0]))
+ }
+ return nil, fmt.Errorf("%s and %s render to one file name %s; rename one so both files can coexist", source.QualifiedNameText(collision.Names[0]), source.QualifiedNameText(collision.Names[1]), collision.File)
+ }
+ return files, err
+}
+
+// Form is the text a document definition is rendered as: what a RenderDocument
+// request names in form, on every transport that serves one.
+type Form string
+
+const (
+ FormMarkdown Form = "markdown"
+ FormHTML Form = "html"
+ FormAsciiDoc Form = "asciidoc"
+)
+
+// Forms lists every form Render renders, in the order a refusal names them.
+// PDF is not one: it drives converters only the sysml command runs.
+func Forms() []Form { return []Form{FormMarkdown, FormHTML, FormAsciiDoc} }
+
+// extension is the file extension a set rendered in the form writes.
+func (f Form) extension() string {
+ switch f {
+ case FormHTML:
+ return ".html"
+ case FormAsciiDoc:
+ return ".adoc"
+ default:
+ return ".md"
+ }
+}
+
+// UnknownFormError refuses a form Render does not render, naming the ones it does.
+type UnknownFormError struct{ Form string }
+
+func (e *UnknownFormError) Error() string {
+ forms := Forms()
+ names := make([]string, len(forms))
+ for i, form := range forms {
+ names[i] = string(form)
+ }
+ return fmt.Sprintf("form %q is not one a document is rendered in: %s or %s",
+ e.Form, strings.Join(names[:len(names)-1], ", "), names[len(names)-1])
+}
+
+// NotDocumentError refuses an element that is not a document definition.
+type NotDocumentError struct{ Name string }
+
+func (e *NotDocumentError) Error() string {
+ return fmt.Sprintf("%s is not a document: one is a part def specializing DocumentQueries::Document", e.Name)
+}
+
+// Options is how Render writes a document.
+type Options struct {
+ // Form is the form the text is written in; empty renders Markdown.
+ Form Form
+ // WithoutGraphviz records that no Graphviz draws DOT for this render, so a
+ // positioned diagram is written as Mermaid, the output stating so.
+ WithoutGraphviz bool
+}
+
+// Rendered is one document's text in one form and the file a set rendered in
+// that form writes it to.
+type Rendered struct {
+ Text string
+ FileName string
+}
+
+// Evaluate compiles sym's document definition and evaluates its queries in
+// qctx, over the objects qctx holds: the document tree every form is written from.
+func Evaluate(qctx queryexec.Context, sym *symbols.Symbol, text view.SourceText) (*docir.Document, error) {
+ if !docplan.IsDocumentDefinition(qctx.Index, qctx.Model, sym) {
+ return nil, &NotDocumentError{Name: symbols.FQNOf(sym)}
+ }
+ plan, err := docplan.Compile(qctx.Index, qctx.Model, qctx.Resolver, sym)
+ if err != nil {
+ return nil, err
+ }
+ return docir.EvaluateLinked(plan,
+ SiblingPlans(qctx.Index, qctx.Model, qctx.Resolver, sym),
+ qctx, queryexec.Options{}, text)
+}
+
+// Render evaluates sym's document and writes it in opts.Form, linking the
+// model's other documents by the files a set in that form writes them to.
+func Render(qctx queryexec.Context, sym *symbols.Symbol, text view.SourceText, opts Options) (Rendered, error) {
+ form := opts.Form
+ if form == "" {
+ form = FormMarkdown
+ }
+ if !slices.Contains(Forms(), form) {
+ return Rendered{}, &UnknownFormError{Form: string(opts.Form)}
+ }
+ document, err := Evaluate(qctx, sym, text)
+ if err != nil {
+ return Rendered{}, err
+ }
+ files, err := Files(Names(qctx.Index, qctx.Model), form.extension())
+ if err != nil {
+ return Rendered{}, err
+ }
+ rendered := Rendered{FileName: files[symbols.FQNOf(sym)]}
+ markdown := docrender.MarkdownOptions{Files: files, WithoutGraphviz: opts.WithoutGraphviz}
+ switch form {
+ case FormHTML:
+ rendered.Text, err = docrender.HTML(document, docrender.HTMLOptions{Files: files, WithoutGraphviz: opts.WithoutGraphviz})
+ case FormAsciiDoc:
+ rendered.Text, err = docrender.AsciiDoc(document, docrender.AsciiDocOptions(markdown))
+ default:
+ rendered.Text, err = docrender.Markdown(document, markdown)
+ }
+ if err != nil {
+ return Rendered{}, err
+ }
+ return rendered, nil
+}
+
+// Fault is who a document failure is charged to: the status a transport
+// reports the failure under.
+type Fault int
+
+const (
+ // FaultInternal is the pipeline's own failure, or one it does not classify.
+ FaultInternal Fault = iota
+ // FaultArgument is the caller's: a form, name or binding the request got wrong.
+ FaultArgument
+ // FaultModel is a fault in the model's own document and query definitions.
+ FaultModel
+ // FaultBudget is a budget or the held-objects bound the evaluation exhausted.
+ FaultBudget
+)
+
+// FaultOf classifies err: a wrong binding, form or non-document is the caller's, an exhausted
+// budget a resource limit, a missing library or context the implementation's, the rest the model's.
+func FaultOf(err error) Fault {
+ var notDocument *NotDocumentError
+ var unknownForm *UnknownFormError
+ var planErr *queryplan.Error
+ var execErr *queryexec.Error
+ var docPlanErr *docplan.Error
+ switch {
+ case errors.As(err, ¬Document), errors.As(err, &unknownForm):
+ return FaultArgument
+ case errors.Is(err, runtime.ErrInstanceLimitExceeded):
+ return FaultBudget
+ case errors.As(err, &planErr):
+ switch planErr.Kind {
+ case queryplan.ErrorNotQueryDefinition:
+ return FaultArgument
+ case queryplan.ErrorLibraryUnavailable, queryplan.ErrorInvalidContext:
+ return FaultInternal
+ default:
+ return FaultModel
+ }
+ case errors.As(err, &execErr):
+ switch execErr.Kind {
+ case queryexec.ErrorUnknownBinding, queryexec.ErrorMissingBinding,
+ queryexec.ErrorBindingType, queryexec.ErrorBindingMultiplicity:
+ return FaultArgument
+ case queryexec.ErrorVisitBudget, queryexec.ErrorInvocationBudget,
+ queryexec.ErrorInvocationDepth:
+ return FaultBudget
+ case queryexec.ErrorInvalidContext:
+ return FaultInternal
+ default:
+ return FaultModel
+ }
+ case errors.As(err, &docPlanErr):
+ switch docPlanErr.Kind {
+ case docplan.ErrorNotDocumentDefinition:
+ return FaultArgument
+ case docplan.ErrorLibraryUnavailable, docplan.ErrorInvalidContext:
+ return FaultInternal
+ default:
+ return FaultModel
+ }
+ default:
+ // A docir.Error not wrapping a query's failure is the evaluator's own.
+ return FaultInternal
+ }
+}
+
+// OriginOf is the source declaration the pipeline names behind err, unlocated
+// when it names none.
+func OriginOf(err error) symbols.Origin {
+ var planErr *queryplan.Error
+ var execErr *queryexec.Error
+ var docPlanErr *docplan.Error
+ var docIRErr *docir.Error
+ switch {
+ case errors.As(err, &planErr):
+ return planErr.Origin
+ case errors.As(err, &execErr):
+ return execErr.Origin
+ case errors.As(err, &docPlanErr):
+ return docPlanErr.Origin
+ case errors.As(err, &docIRErr):
+ return docIRErr.Origin
+ default:
+ return symbols.Origin{}
+ }
+}
+
+// QueryOutcomeOf is the outcome record of the query failure behind err, nil
+// when no query failed.
+func QueryOutcomeOf(err error) *queryexec.Outcome {
+ var execErr *queryexec.Error
+ if errors.As(err, &execErr) {
+ return execErr.Outcome
+ }
+ return nil
+}
diff --git a/internal/doc/docset/render_test.go b/internal/doc/docset/render_test.go
new file mode 100644
index 0000000000..77582be5db
--- /dev/null
+++ b/internal/doc/docset/render_test.go
@@ -0,0 +1,69 @@
+// Copyright 2025 Open‐MBEE Foundation. All rights reserved.
+// Use of this source code is governed by the LICENSE file.
+
+package docset_test
+
+import (
+ "errors"
+ "testing"
+
+ "github.com/Open-MBEE/OpenSysML/internal/doc/docir"
+ "github.com/Open-MBEE/OpenSysML/internal/doc/docset"
+ "github.com/Open-MBEE/OpenSysML/internal/doc/queryexec"
+ "github.com/Open-MBEE/OpenSysML/internal/exec/runtime"
+ "github.com/Open-MBEE/OpenSysML/internal/ir/docplan"
+ "github.com/Open-MBEE/OpenSysML/internal/ir/queryplan"
+ "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols"
+)
+
+// A form Render does not render is refused before anything is evaluated, the
+// refusal naming every form it does render.
+func TestRenderRefusesAnUnknownFormNamingTheForms(t *testing.T) {
+ _, err := docset.Render(queryexec.Context{}, nil, nil, docset.Options{Form: "pdf"})
+ var unknown *docset.UnknownFormError
+ if !errors.As(err, &unknown) || unknown.Form != "pdf" {
+ t.Fatalf("Render(pdf) error = %v, want an UnknownFormError for pdf", err)
+ }
+ if err.Error() != `form "pdf" is not one a document is rendered in: markdown, html or asciidoc` {
+ t.Errorf("message = %q", err.Error())
+ }
+ if docset.FaultOf(err) != docset.FaultArgument {
+ t.Errorf("FaultOf(unknown form) = %v, want FaultArgument", docset.FaultOf(err))
+ }
+}
+
+// Every transport reports a failure under the fault FaultOf charges it to, so
+// the classification is the one the service's status codes were written to.
+func TestFaultOfClassifiesTheDocumentPipelinesFailures(t *testing.T) {
+ origin := symbols.Origin{Doc: "report.sysml"}
+ wrapped := &docir.Error{Kind: docir.ErrorQueryExecution, Err: &queryexec.Error{Kind: queryexec.ErrorVisitBudget, Origin: origin}}
+ for name, tc := range map[string]struct {
+ err error
+ fault docset.Fault
+ }{
+ "not a document": {&docset.NotDocumentError{Name: "Demo::Part"}, docset.FaultArgument},
+ "an unknown binding": {&queryexec.Error{Kind: queryexec.ErrorUnknownBinding}, docset.FaultArgument},
+ "not a query": {&queryplan.Error{Kind: queryplan.ErrorNotQueryDefinition}, docset.FaultArgument},
+ "a visit budget": {&queryexec.Error{Kind: queryexec.ErrorVisitBudget}, docset.FaultBudget},
+ "a wrapped budget": {wrapped, docset.FaultBudget},
+ "the held-objects bound": {runtime.ErrInstanceLimitExceeded, docset.FaultBudget},
+ "a model's query fault": {&queryexec.Error{Kind: queryexec.ErrorUnknownProperty}, docset.FaultModel},
+ "a model's plan fault": {&docplan.Error{Kind: docplan.ErrorMissingTitle}, docset.FaultModel},
+ "no library": {&docplan.Error{Kind: docplan.ErrorLibraryUnavailable}, docset.FaultInternal},
+ "the evaluator's own": {&docir.Error{Kind: docir.ErrorInvalidContext}, docset.FaultInternal},
+ "an unclassified error": {errors.New("disk full"), docset.FaultInternal},
+ } {
+ if fault := docset.FaultOf(tc.err); fault != tc.fault {
+ t.Errorf("FaultOf(%s) = %v, want %v", name, fault, tc.fault)
+ }
+ }
+ if got := docset.OriginOf(wrapped); got != origin {
+ t.Errorf("OriginOf(wrapped) = %v, want the query's origin", got)
+ }
+ if docset.OriginOf(errors.New("disk full")).Located() {
+ t.Error("OriginOf(an unclassified error) is located")
+ }
+ if docset.QueryOutcomeOf(wrapped) != nil || docset.QueryOutcomeOf(&docplan.Error{}) != nil {
+ t.Error("QueryOutcomeOf answers an outcome no query recorded")
+ }
+}
diff --git a/internal/frontend/combined/combined.go b/internal/frontend/combined/combined.go
index 797ef4d8a4..bcd0142dc5 100644
--- a/internal/frontend/combined/combined.go
+++ b/internal/frontend/combined/combined.go
@@ -46,6 +46,10 @@ var capabilities = []string{
"state_trace",
"action_trace",
"symbol_members",
+ "render_document",
+ "render_document_html",
+ "render_document_asciidoc",
+ "render_document_file_name",
}
// Server combines the parsing/validation and execution JSON surfaces.
@@ -119,7 +123,7 @@ func (s *Server) Call(ctx context.Context, method string, params []byte) ([]byte
s.touchRequest(params)
}
return body, err
- case "Evaluate", "Instantiate", "ExecuteAction", "ExecuteState", "RenderView":
+ case "Evaluate", "Instantiate", "ExecuteAction", "ExecuteState", "RenderView", "RenderDocument":
body, err := s.engine.Call(ctx, method, params)
if err == nil {
s.touchRequest(params)
diff --git a/internal/frontend/combined/combined_test.go b/internal/frontend/combined/combined_test.go
index 31acfe7a06..62dab66d49 100644
--- a/internal/frontend/combined/combined_test.go
+++ b/internal/frontend/combined/combined_test.go
@@ -9,6 +9,7 @@ import (
"errors"
"fmt"
"reflect"
+ "strings"
"testing"
"github.com/Open-MBEE/OpenSysML/internal/frontend/jsonrpc"
@@ -294,3 +295,88 @@ func TestRenderViewRoutesThroughEngine(t *testing.T) {
t.Fatalf("RenderView of an unknown model error = %v, want NotFound", err)
}
}
+
+func TestRenderDocumentRoutesThroughEngine(t *testing.T) {
+ server, err := New("combined-test")
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ model := `package Demo {
+ private import DocumentQueries::*;
+ part def Telescope { part mount; }
+ part telescope : Telescope;
+ part def MassReport :> Document {
+ :>> title = "Telescope Mass Report";
+ rep language "markdown" /* The mount is at [](sysml:telescope.mount). */
+ part breakdown : Section { :>> title = "Subsystem Masses"; }
+ }
+}`
+ parseBody, err := server.Call(context.Background(), "ParseFile", []byte(fmt.Sprintf(`{"content":%q}`, model)))
+ if err != nil {
+ t.Fatalf("ParseFile: %v", err)
+ }
+ var parsed struct {
+ ModelHash string `json:"modelHash"`
+ }
+ if err := json.Unmarshal(parseBody, &parsed); err != nil {
+ t.Fatalf("decoding ParseFile: %v\n%s", err, parseBody)
+ }
+ render := func(form string) map[string]string {
+ t.Helper()
+ params := fmt.Sprintf(`{"modelHash":%q,"documentId":"Demo::MassReport"}`, parsed.ModelHash)
+ if form != "" {
+ params = fmt.Sprintf(`{"modelHash":%q,"documentId":"Demo::MassReport","form":%q}`, parsed.ModelHash, form)
+ }
+ body, err := server.Call(context.Background(), "RenderDocument", []byte(params))
+ if err != nil {
+ t.Fatalf("RenderDocument(%q): %v", form, err)
+ }
+ var rendered map[string]string
+ if err := json.Unmarshal(body, &rendered); err != nil {
+ t.Fatalf("decoding RenderDocument(%q): %v\n%s", form, err, body)
+ }
+ return rendered
+ }
+ // The default form is Markdown, answered in markdown alone with the file a set writes.
+ markdown := render("")
+ if !strings.HasPrefix(markdown["markdown"], "# Telescope Mass Report\n") || !strings.Contains(markdown["markdown"], "## Subsystem Masses") {
+ t.Errorf("markdown = %q, want the titled report", markdown["markdown"])
+ }
+ if markdown["html"] != "" || markdown["asciidoc"] != "" || markdown["fileName"] != "Demo-MassReport.md" {
+ t.Errorf("markdown answer = %v, want markdown and Demo-MassReport.md alone", markdown)
+ }
+ html := render("html")
+ if !strings.HasPrefix(html["html"], "") || !strings.Contains(html["html"], "
Telescope Mass Report") {
+ t.Errorf("html = %q, want a standalone page", html["html"])
+ }
+ if html["markdown"] != "" || html["fileName"] != "Demo-MassReport.html" {
+ t.Errorf("html answer = %v, want html and Demo-MassReport.html alone", html)
+ }
+ asciidoc := render("asciidoc")
+ if !strings.HasPrefix(asciidoc["asciidoc"], "= Telescope Mass Report\n") || asciidoc["fileName"] != "Demo-MassReport.adoc" {
+ t.Errorf("asciidoc answer = %v, want the AsciiDoc document and Demo-MassReport.adoc", asciidoc)
+ }
+
+ refused := func(name, params string, code uint32, message string) {
+ t.Helper()
+ _, err := server.Call(context.Background(), "RenderDocument", []byte(params))
+ var callErr *jsonrpc.Error
+ if !errors.As(err, &callErr) || callErr.Code != code {
+ t.Fatalf("%s: error = %v, want code %d", name, err, code)
+ }
+ if !strings.Contains(callErr.Message, message) {
+ t.Errorf("%s: message = %q, want it to contain %q", name, callErr.Message, message)
+ }
+ }
+ // PDF needs converters the module cannot run; the refusal names the forms it renders.
+ refused("pdf", fmt.Sprintf(`{"modelHash":%q,"documentId":"Demo::MassReport","form":"pdf"}`, parsed.ModelHash),
+ jsonrpc.CodeInvalidArgument, `form "pdf" is not one a document is rendered in: markdown, html or asciidoc`)
+ refused("an unknown document", fmt.Sprintf(`{"modelHash":%q,"documentId":"Demo::Missing"}`, parsed.ModelHash),
+ jsonrpc.CodeNotFound, "symbol not found: Demo::Missing")
+ refused("a part def that is no document", fmt.Sprintf(`{"modelHash":%q,"documentId":"Demo::Telescope"}`, parsed.ModelHash),
+ jsonrpc.CodeInvalidArgument, "Demo::Telescope is not a document")
+ refused("no document", fmt.Sprintf(`{"modelHash":%q}`, parsed.ModelHash),
+ jsonrpc.CodeInvalidArgument, "a document to render must be named in documentId")
+ refused("a missing model", `{"modelHash":"missing","documentId":"Demo::MassReport"}`,
+ jsonrpc.CodeNotFound, "model not found: missing")
+}
diff --git a/internal/frontend/engine/engine.go b/internal/frontend/engine/engine.go
index 2cf06b4df4..48da137063 100644
--- a/internal/frontend/engine/engine.go
+++ b/internal/frontend/engine/engine.go
@@ -328,6 +328,13 @@ func (e *Engine) Call(ctx context.Context, method string, params []byte) (result
return nil, err
}
return e.renderView(&req)
+ case "RenderDocument":
+ // RenderDocument is served by both sysml-engine and sysml-grpc.
+ var req JRenderDocumentRequest
+ if err := decode(params, &req); err != nil {
+ return nil, err
+ }
+ return e.renderDocument(ctx, &req)
}
return nil, statusErrorf(codeUnimplemented, "%s is not served by sysml-engine", method)
}
diff --git a/internal/frontend/engine/json.go b/internal/frontend/engine/json.go
index 6ecf1582d5..76fdfefdef 100644
--- a/internal/frontend/engine/json.go
+++ b/internal/frontend/engine/json.go
@@ -366,6 +366,24 @@ type JRenderViewResponse struct {
Shown []string `json:"shown,omitempty"`
}
+// The RenderDocument request and reply, shaped as protojson writes the service's
+// RenderDocumentRequest and RenderDocumentResponse so one client talks to either transport.
+
+type JRenderDocumentRequest struct {
+ ModelHash string `json:"modelHash,omitempty"`
+ DocumentId string `json:"documentId,omitempty"`
+ // Form is the text the document is written as: "markdown" (the default),
+ // "html" or "asciidoc"; the answer carries it in the field of that name.
+ Form string `json:"form,omitempty"`
+}
+
+type JRenderDocumentResponse struct {
+ Markdown string `json:"markdown,omitempty"`
+ Html string `json:"html,omitempty"`
+ Asciidoc string `json:"asciidoc,omitempty"`
+ FileName string `json:"fileName,omitempty"`
+}
+
// JRenderFrame is the view frame round a diagram: its name compartment and
// the info compartments at its corners (exposes, filters, annotations).
type JRenderFrame struct {
diff --git a/internal/frontend/engine/render_document.go b/internal/frontend/engine/render_document.go
new file mode 100644
index 0000000000..bce48cb7af
--- /dev/null
+++ b/internal/frontend/engine/render_document.go
@@ -0,0 +1,100 @@
+// Copyright 2025 Open‐MBEE Foundation. All rights reserved.
+// Use of this source code is governed by the LICENSE file.
+
+package engine
+
+import (
+ "context"
+ "fmt"
+ "slices"
+
+ "github.com/Open-MBEE/OpenSysML/internal/doc/docset"
+ "github.com/Open-MBEE/OpenSysML/internal/doc/queryexec"
+ "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols"
+)
+
+// renderDocument renders a document as the service does, over a run holding no objects (the
+// engine keeps no Instantiate store) and without Graphviz, so positioned diagrams fall back to Mermaid.
+func (e *Engine) renderDocument(ctx context.Context, req *JRenderDocumentRequest) ([]byte, error) {
+ form := docset.Form(req.Form)
+ if form == "" {
+ form = docset.FormMarkdown
+ }
+ if !slices.Contains(docset.Forms(), form) {
+ return nil, statusError(codeInvalidArgument, (&docset.UnknownFormError{Form: req.Form}).Error())
+ }
+ cached, ok := e.get(req.ModelHash)
+ if !ok {
+ return nil, statusErrorf(codeNotFound, msgModelNotFound, req.ModelHash)
+ }
+ if req.DocumentId == "" {
+ return nil, statusError(codeInvalidArgument, "a document to render must be named in documentId")
+ }
+ syms := lookupNamed(cached.Index, req.DocumentId)
+ if len(syms) == 0 {
+ return nil, statusErrorf(codeNotFound, "symbol not found: %s", req.DocumentId)
+ }
+ sem := cached.typedModel()
+ qctx := queryexec.Context{
+ Index: cached.Index,
+ Resolver: cached.resolver,
+ Model: sem,
+ Runtime: e.newRuntime(ctx, cached),
+ ElementID: cached.elementIDs(),
+ }
+ rendered, err := docset.Render(qctx, syms[0], cached.sourceText(), docset.Options{Form: form, WithoutGraphviz: true})
+ if err != nil {
+ return nil, documentError(err)
+ }
+ resp := &JRenderDocumentResponse{FileName: rendered.FileName}
+ switch form {
+ case docset.FormHTML:
+ resp.Html = rendered.Text
+ case docset.FormAsciiDoc:
+ resp.Asciidoc = rendered.Text
+ default:
+ resp.Markdown = rendered.Text
+ }
+ return marshal(resp)
+}
+
+// documentError reports a document failure under the code the service reports
+// it, naming the source declaration behind it when the pipeline reports one.
+func documentError(err error) *Error {
+ message := err.Error()
+ if origin := docset.OriginOf(err); origin.Located() {
+ message = fmt.Sprintf("%s (declared in %s)", message, origin.Doc)
+ }
+ return statusError(faultCode(docset.FaultOf(err)), message)
+}
+
+// faultCode is the status code a document fault is reported under, as the
+// service's faultCode numbers it.
+func faultCode(fault docset.Fault) uint32 {
+ switch fault {
+ case docset.FaultArgument:
+ return codeInvalidArgument
+ case docset.FaultModel:
+ return codeFailedPrecondition
+ case docset.FaultBudget:
+ return codeResourceExhausted
+ default:
+ return codeInternal
+ }
+}
+
+// elementIDs reads the elementId a conversion writes for each element through the registered
+// converter, as the service does; nil when the binary links none (sysml-engine and sysml-wasm do not).
+func (m *cachedModel) elementIDs() func(*symbols.Symbol) (string, error) {
+ documents := make([]queryexec.ElementIDDocument, 0, len(m.Documents))
+ for _, doc := range m.Documents {
+ documents = append(documents, queryexec.ElementIDDocument{File: doc.Source, Root: doc.Root, ParseDiags: doc.Errors})
+ }
+ read := queryexec.OpenElementIDs(documents)
+ if read == nil {
+ return nil
+ }
+ return func(sym *symbols.Symbol) (string, error) {
+ return queryexec.ElementIDOf(read, m.Index, sym)
+ }
+}
diff --git a/internal/frontend/grpc/docquery.go b/internal/frontend/grpc/docquery.go
index 8144cce443..8dba6cb6e4 100644
--- a/internal/frontend/grpc/docquery.go
+++ b/internal/frontend/grpc/docquery.go
@@ -8,12 +8,10 @@ import (
"connectrpc.com/connect"
pb "github.com/Open-MBEE/OpenSysML/api/proto"
"github.com/Open-MBEE/OpenSysML/internal/check/passes"
- "github.com/Open-MBEE/OpenSysML/internal/doc/docir"
- "github.com/Open-MBEE/OpenSysML/internal/doc/docrender"
+ "github.com/Open-MBEE/OpenSysML/internal/doc/docset"
"github.com/Open-MBEE/OpenSysML/internal/doc/queryexec"
"github.com/Open-MBEE/OpenSysML/internal/frontend/protoconv"
"github.com/Open-MBEE/OpenSysML/internal/frontend/symbolfacts"
- "github.com/Open-MBEE/OpenSysML/internal/ir/docplan"
"github.com/Open-MBEE/OpenSysML/internal/ir/queryplan"
"github.com/Open-MBEE/OpenSysML/internal/ir/view"
corequery "github.com/Open-MBEE/OpenSysML/internal/semantic/query"
@@ -23,8 +21,6 @@ import (
"github.com/Open-MBEE/OpenSysML/internal/semantic/symbols"
"github.com/Open-MBEE/OpenSysML/internal/syntax/source"
"github.com/Open-MBEE/OpenSysML/internal/workspace/libs"
- "github.com/Open-MBEE/OpenSysML/internal/workspace/model"
- "github.com/Open-MBEE/OpenSysML/internal/workspace/modeldoc"
)
const (
@@ -163,36 +159,28 @@ func rowHoldsBigInt(row *pb.DocumentQueryRow) bool {
})
}
-// Forms RenderDocument renders, named as the CLI's -doc-form names them.
-const (
- renderFormMarkdown = "markdown"
- renderFormHTML = "html"
- renderFormAsciiDoc = "asciidoc"
-)
-
// RenderDocument renders a named document to Markdown, HTML or AsciiDoc, as
// -render-document does.
func (s *Service) RenderDocument(ctx context.Context, req *pb.RenderDocumentRequest) (*pb.RenderDocumentResponse, error) {
if err := s.requireCapability(CapabilityRenderDocument); err != nil {
return nil, err
}
- form := req.Form
+ form := docset.Form(req.Form)
if form == "" {
- form = renderFormMarkdown
+ form = docset.FormMarkdown
}
switch form {
- case renderFormMarkdown:
- case renderFormHTML:
+ case docset.FormMarkdown:
+ case docset.FormHTML:
if err := s.requireCapability(CapabilityRenderDocumentHTML); err != nil {
return nil, err
}
- case renderFormAsciiDoc:
+ case docset.FormAsciiDoc:
if err := s.requireCapability(CapabilityRenderDocumentAsciiDoc); err != nil {
return nil, err
}
default:
- return nil, statusErrorf(connect.CodeInvalidArgument,
- "form %q is not one this service renders: markdown, html or asciidoc", req.Form)
+ return nil, statusError(connect.CodeInvalidArgument, (&docset.UnknownFormError{Form: req.Form}).Error())
}
cached, ok := s.cache.Get(req.ModelHash)
if !ok {
@@ -210,59 +198,25 @@ func (s *Service) RenderDocument(ctx context.Context, req *pb.RenderDocumentRequ
if err != nil {
return nil, err
}
- if !docplan.IsDocumentDefinition(qctx.Index, qctx.Model, sym) {
- return nil, statusErrorf(connect.CodeInvalidArgument,
- "%s is not a document: one is a part def specializing DocumentQueries::Document", req.DocumentId)
- }
- plan, err := docplan.Compile(qctx.Index, qctx.Model, qctx.Resolver, sym)
- if err != nil {
- return nil, documentStatus(err)
- }
- document, err := docir.EvaluateLinked(plan,
- model.SiblingDocumentPlans(qctx.Index, qctx.Model, qctx.Resolver, sym),
- qctx, queryexec.Options{}, cachedSourceText(cached))
+ rendered, err := docset.Render(qctx, sym, cachedSourceText(cached), docset.Options{Form: form})
if err != nil {
return nil, held.documentStatus(err)
}
- extension := ".md"
- switch form {
- case renderFormHTML:
- extension = ".html"
- case renderFormAsciiDoc:
- extension = ".adoc"
- }
- files, err := modeldoc.DocumentFiles(model.DocumentNames(qctx.Index, qctx.Model), extension)
- if err != nil {
- return nil, documentStatus(err)
- }
- if form == renderFormHTML {
- page, err := docrender.HTML(document, docrender.HTMLOptions{Files: files})
- if err != nil {
- return nil, documentStatus(err)
- }
- return &pb.RenderDocumentResponse{Html: page, FileName: s.documentFileName(files, sym)}, nil
+ resp := &pb.RenderDocumentResponse{}
+ // The file a rendered set writes the document to is answered under the
+ // render_document_file_name capability.
+ if s.capabilities.has(CapabilityRenderDocumentFileName) {
+ resp.FileName = rendered.FileName
}
- if form == renderFormAsciiDoc {
- adoc, err := docrender.AsciiDoc(document, docrender.AsciiDocOptions{Files: files})
- if err != nil {
- return nil, documentStatus(err)
- }
- return &pb.RenderDocumentResponse{Asciidoc: adoc, FileName: s.documentFileName(files, sym)}, nil
- }
- markdown, err := docrender.Markdown(document, docrender.MarkdownOptions{Files: files})
- if err != nil {
- return nil, documentStatus(err)
- }
- return &pb.RenderDocumentResponse{Markdown: markdown, FileName: s.documentFileName(files, sym)}, nil
-}
-
-// documentFileName is the file a rendered set writes sym's document to,
-// under the render_document_file_name capability.
-func (s *Service) documentFileName(files map[string]string, sym *symbols.Symbol) string {
- if !s.capabilities.has(CapabilityRenderDocumentFileName) {
- return ""
+ switch form {
+ case docset.FormHTML:
+ resp.Html = rendered.Text
+ case docset.FormAsciiDoc:
+ resp.Asciidoc = rendered.Text
+ default:
+ resp.Markdown = rendered.Text
}
- return files[symbols.FQNOf(sym)]
+ return resp, nil
}
// documentSymbol resolves the query or document a request names, failing with
@@ -546,27 +500,23 @@ func documentStatus(err error) error {
if errors.As(err, &inlineErr) {
return inlineStatus(inlineErr)
}
- var planErr *queryplan.Error
- if errors.As(err, &planErr) {
- return statusWithOrigin(queryPlanCode(planErr.Kind), err, planErr.Origin)
- }
- var execErr *queryexec.Error
- if errors.As(err, &execErr) {
- return withQueryOutcome(statusWithOrigin(queryExecCode(execErr.Kind), err, execErr.Origin), execErr.Outcome)
- }
- var docPlanErr *docplan.Error
- if errors.As(err, &docPlanErr) {
- return statusWithOrigin(docPlanCode(docPlanErr.Kind), err, docPlanErr.Origin)
- }
- var docIRErr *docir.Error
- if errors.As(err, &docIRErr) {
- status := statusWithOrigin(docIRCode(docIRErr), err, docIRErr.Origin)
- if errors.As(docIRErr.Err, &execErr) {
- return withQueryOutcome(status, execErr.Outcome)
- }
- return status
+ status := statusWithOrigin(faultCode(docset.FaultOf(err)), err, docset.OriginOf(err))
+ return withQueryOutcome(status, docset.QueryOutcomeOf(err))
+}
+
+// faultCode is the status a document fault is reported under: the caller's argument, the
+// model's definitions as a failed precondition, a budget as exhausted, the pipeline's own as internal.
+func faultCode(fault docset.Fault) connect.Code {
+ switch fault {
+ case docset.FaultArgument:
+ return connect.CodeInvalidArgument
+ case docset.FaultModel:
+ return connect.CodeFailedPrecondition
+ case docset.FaultBudget:
+ return connect.CodeResourceExhausted
+ default:
+ return connect.CodeInternal
}
- return connect.NewError(connect.CodeInternal, err)
}
// inlineStatus is the status a fault in an inline query reports as: text that
@@ -574,7 +524,7 @@ func documentStatus(err error) error {
func inlineStatus(inlineErr *queryplan.InlineError) error {
var execErr *queryexec.Error
if errors.As(inlineErr.Err, &execErr) {
- return withQueryOutcome(connect.NewError(queryExecCode(execErr.Kind), inlineErr), execErr.Outcome)
+ return withQueryOutcome(connect.NewError(faultCode(docset.FaultOf(execErr)), inlineErr), execErr.Outcome)
}
return connect.NewError(connect.CodeInvalidArgument, inlineErr)
}
@@ -716,49 +666,6 @@ func statusWithOrigin(code connect.Code, err error, origin symbols.Origin) error
return statusErrorf(code, "%s (declared in %s)", err.Error(), origin.Doc)
}
-// queryPlanCode is the status a query-planning failure reports as: a fault in
-// the model's own query definitions is a failed precondition of the call.
-func queryPlanCode(kind queryplan.ErrorKind) connect.Code {
- switch kind {
- case queryplan.ErrorNotQueryDefinition:
- return connect.CodeInvalidArgument
- case queryplan.ErrorLibraryUnavailable, queryplan.ErrorInvalidContext:
- return connect.CodeInternal
- default:
- return connect.CodeFailedPrecondition
- }
-}
-
-// queryExecCode is the status an execution failure reports as: a wrong binding
-// is the caller's fault, an exhausted budget is a resource limit, and the rest
-// are faults in the model's queries.
-func queryExecCode(kind queryexec.ErrorKind) connect.Code {
- switch kind {
- case queryexec.ErrorUnknownBinding, queryexec.ErrorMissingBinding,
- queryexec.ErrorBindingType, queryexec.ErrorBindingMultiplicity:
- return connect.CodeInvalidArgument
- case queryexec.ErrorVisitBudget, queryexec.ErrorInvocationBudget,
- queryexec.ErrorInvocationDepth:
- return connect.CodeResourceExhausted
- case queryexec.ErrorInvalidContext:
- return connect.CodeInternal
- default:
- return connect.CodeFailedPrecondition
- }
-}
-
-// docPlanCode is the status a document-planning failure reports as.
-func docPlanCode(kind docplan.ErrorKind) connect.Code {
- switch kind {
- case docplan.ErrorNotDocumentDefinition:
- return connect.CodeInvalidArgument
- case docplan.ErrorLibraryUnavailable, docplan.ErrorInvalidContext:
- return connect.CodeInternal
- default:
- return connect.CodeFailedPrecondition
- }
-}
-
// cachedSourceText reads notation from whichever of the model's documents a span
// belongs to, and behind them from the library files its index holds, for the
// labels a diagram rendering takes verbatim.
@@ -774,15 +681,3 @@ func cachedSourceText(model *CachedModel) view.SourceText {
}
return source.TextOf(sources, libs.Text(model.Library))
}
-
-// docIRCode is the status a document-evaluation failure reports as: a failed
-// query keeps its execution mapping, and the rest are the evaluator's own.
-func docIRCode(err *docir.Error) connect.Code {
- if err.Kind == docir.ErrorQueryExecution {
- var execErr *queryexec.Error
- if errors.As(err.Err, &execErr) {
- return queryExecCode(execErr.Kind)
- }
- }
- return connect.CodeInternal
-}
diff --git a/internal/frontend/grpc/docquery_test.go b/internal/frontend/grpc/docquery_test.go
index 7f64964cd0..6cd58c91da 100644
--- a/internal/frontend/grpc/docquery_test.go
+++ b/internal/frontend/grpc/docquery_test.go
@@ -12,8 +12,8 @@ import (
"connectrpc.com/connect"
pb "github.com/Open-MBEE/OpenSysML/api/proto"
+ "github.com/Open-MBEE/OpenSysML/internal/doc/docset"
"github.com/Open-MBEE/OpenSysML/internal/doc/queryexec"
- "github.com/Open-MBEE/OpenSysML/internal/workspace/modeldoc"
)
// telescopeFixture is the document pipeline's own telescope-domain fixture, so
@@ -690,7 +690,7 @@ func TestRenderDocumentLinksSiblingsByPlannedFiles(t *testing.T) {
{"markdown", ".md", "](%s)", func(r *pb.RenderDocumentResponse) string { return r.Markdown }},
{"html", ".html", `href="%s"`, func(r *pb.RenderDocumentResponse) string { return r.Html }},
} {
- files, err := modeldoc.DocumentFiles(names, form.extension)
+ files, err := docset.Files(names, form.extension)
if err != nil {
t.Fatalf("DocumentFiles: %v", err)
}
diff --git a/internal/frontend/repl/docrender.go b/internal/frontend/repl/docrender.go
index 96603d3b5d..4c94b6d4ad 100644
--- a/internal/frontend/repl/docrender.go
+++ b/internal/frontend/repl/docrender.go
@@ -8,14 +8,13 @@ import (
"github.com/Open-MBEE/OpenSysML/internal/doc/docir"
"github.com/Open-MBEE/OpenSysML/internal/doc/docrender"
+ "github.com/Open-MBEE/OpenSysML/internal/doc/docset"
"github.com/Open-MBEE/OpenSysML/internal/doc/queryexec"
"github.com/Open-MBEE/OpenSysML/internal/frontend/repl/replext"
"github.com/Open-MBEE/OpenSysML/internal/ir/docplan"
"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/workspace/model"
- "github.com/Open-MBEE/OpenSysML/internal/workspace/modeldoc"
)
// renderDocumentUsage is what %render-document accepts: a document's name,
@@ -111,12 +110,12 @@ func (s *Session) evaluateDocument(invocation, extension string) (*docir.Documen
for _, sibling := range s.documentSymbols(idx, sem) {
names = append(names, symbols.FQNOf(sibling))
}
- if files, err = modeldoc.DocumentFiles(names, extension); err != nil {
+ if files, err = docset.Files(names, extension); err != nil {
return nil, nil, err
}
}
document, err := docir.EvaluateLinked(plan,
- model.SiblingDocumentPlans(idx, sem, resolver, sym),
+ docset.SiblingPlans(idx, sem, resolver, sym),
s.queryContext(ctx),
queryexec.Options{},
s.sessionSourceText())
@@ -183,7 +182,7 @@ func (s *Session) renderDocumentSet(
for _, sym := range syms {
names = append(names, symbols.FQNOf(sym))
}
- files, err := modeldoc.DocumentFiles(names, extension)
+ files, err := docset.Files(names, extension)
if err != nil {
return nil, err
}
@@ -237,7 +236,7 @@ func (s *Session) renderDocumentSet(
// in fully-qualified-name order.
func (s *Session) documentSymbols(idx *symbols.Index, sem *semantics.Model) []*symbols.Symbol {
syms := s.symbolsInLoadOrder(func(scope *symbols.Scope) []*symbols.Symbol {
- return model.DeclaredDocumentDefinitions(idx, sem, scope)
+ return docset.Definitions(idx, sem, scope)
})
sort.SliceStable(syms, func(i, j int) bool {
return symbols.FQNOf(syms[i]) < symbols.FQNOf(syms[j])
diff --git a/internal/semantic/symbols/members.go b/internal/semantic/symbols/members.go
index 364b8e3064..c9f4338b35 100644
--- a/internal/semantic/symbols/members.go
+++ b/internal/semantic/symbols/members.go
@@ -1,6 +1,10 @@
package symbols
-import "github.com/Open-MBEE/OpenSysML/internal/syntax/source"
+import (
+ "sort"
+
+ "github.com/Open-MBEE/OpenSysML/internal/syntax/source"
+)
// Members returns distinct symbols declared directly in this scope in
// declaration order, with duplicate keys interleaved as declared.
@@ -18,6 +22,36 @@ func (s *Scope) Members() []*Symbol {
return out
}
+// Declarations returns the symbols this scope itself declares — aliases, and
+// the declarations of other documents it imports, left out — in declaration order.
+func (s *Scope) Declarations() []*Symbol {
+ if s == nil {
+ return nil
+ }
+ members := s.AllMembers()
+ out := make([]*Symbol, 0, len(members))
+ // AllMembers indexes a short-named declaration under both names.
+ seen := make(map[*Symbol]bool, len(members))
+ for _, sym := range members {
+ if sym == nil || seen[sym] || sym.Kind == SymbolAlias || sym.DocName != s.DocName() {
+ continue
+ }
+ seen[sym] = true
+ out = append(out, sym)
+ }
+ sort.SliceStable(out, func(i, j int) bool { return out[i].DeclSpan.Offset < out[j].DeclSpan.Offset })
+ return out
+}
+
+// WalkDeclarations visits every declaration of this scope tree, each before
+// the ones nested in it, in declaration order.
+func (s *Scope) WalkDeclarations(visit func(*Symbol)) {
+ for _, sym := range s.Declarations() {
+ visit(sym)
+ sym.Scope.WalkDeclarations(visit)
+ }
+}
+
// DeclaredAt returns the symbol whose declaration was parsed from exactly span,
// in this scope or one nested in it, or nil. An anonymous declaration is found
// as a named one is, so an edit can reach an element no qualified name does.
diff --git a/internal/workspace/model/docquery.go b/internal/workspace/model/docquery.go
index bb41bbcffc..1187448283 100644
--- a/internal/workspace/model/docquery.go
+++ b/internal/workspace/model/docquery.go
@@ -30,7 +30,7 @@ func (w *Workspace) DocumentDefinitions() []DocumentDefinition {
out := []DocumentDefinition{}
for name, d := range w.docs {
w.queryLocked(name, func(_ *resolve.Resolver, sem *semantics.Model) {
- walkScope(w.index.DocumentRoot(name), func(sym *symbols.Symbol) {
+ w.index.DocumentRoot(name).WalkDeclarations(func(sym *symbols.Symbol) {
if docplan.IsDocumentDefinition(w.index, sem, sym) {
out = append(out, DocumentDefinition{
FQN: notationFQN(w.index, sym),
diff --git a/internal/workspace/model/identity.go b/internal/workspace/model/identity.go
index d793478771..57a447c336 100644
--- a/internal/workspace/model/identity.go
+++ b/internal/workspace/model/identity.go
@@ -30,7 +30,7 @@ func (w *Workspace) IdentityOf(name string, sym *symbols.Symbol) (*identity.Info
}
}
var indexed *symbols.Symbol
- walkScope(idx.DocumentRoot(name), func(candidate *symbols.Symbol) {
+ idx.DocumentRoot(name).WalkDeclarations(func(candidate *symbols.Symbol) {
if indexed == nil && candidate.Name == sym.Name && same(candidate) {
indexed = candidate
}
diff --git a/internal/workspace/model/library_version_test.go b/internal/workspace/model/library_version_test.go
index 4751557b54..355fa79381 100644
--- a/internal/workspace/model/library_version_test.go
+++ b/internal/workspace/model/library_version_test.go
@@ -57,7 +57,7 @@ func TestWorkspaceLibraryVersionStandsInForBundledFile(t *testing.T) {
t.Error("the displaced bundled file is still a library document")
}
var bundledReal *symbols.Symbol
- walkScope(lib.Scope, func(s *symbols.Symbol) {
+ lib.Scope.WalkDeclarations(func(s *symbols.Symbol) {
if s.Name == "Real" {
bundledReal = s
}
@@ -954,7 +954,7 @@ func TestWorkspaceDisplacedIdentityIgnoresRemovedBaseFile(t *testing.T) {
ws := NewWorkspaceWithIndex(idx)
var tank *symbols.Symbol
- walkScope(base.DocumentRoot(lib), func(s *symbols.Symbol) {
+ base.DocumentRoot(lib).WalkDeclarations(func(s *symbols.Symbol) {
if s.Name == "Tank" {
tank = s
}
diff --git a/internal/workspace/model/render.go b/internal/workspace/model/render.go
index 44bda41959..b96354250c 100644
--- a/internal/workspace/model/render.go
+++ b/internal/workspace/model/render.go
@@ -443,7 +443,7 @@ func declaredIn(idx *symbols.Index, doc, fqn string) *symbols.Symbol {
}
}
var found *symbols.Symbol
- walkScope(idx.DocumentRoot(doc), func(sym *symbols.Symbol) {
+ idx.DocumentRoot(doc).WalkDeclarations(func(sym *symbols.Symbol) {
if found != nil {
return
}
diff --git a/internal/workspace/model/selection.go b/internal/workspace/model/selection.go
index df93b6a807..7eb73cb0fd 100644
--- a/internal/workspace/model/selection.go
+++ b/internal/workspace/model/selection.go
@@ -1,10 +1,6 @@
package model
import (
- "sort"
-
- "github.com/Open-MBEE/OpenSysML/internal/ir/docplan"
- "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve"
"github.com/Open-MBEE/OpenSysML/internal/semantic/semantics"
"github.com/Open-MBEE/OpenSysML/internal/semantic/symbols"
)
@@ -13,7 +9,7 @@ import (
// outermost first and in declaration order.
func DeclaredViews(scope *symbols.Scope) []*symbols.Symbol {
var out []*symbols.Symbol
- walkScope(scope, func(sym *symbols.Symbol) {
+ scope.WalkDeclarations(func(sym *symbols.Symbol) {
if semantics.IsView(sym) {
out = append(out, sym)
}
@@ -21,94 +17,18 @@ func DeclaredViews(scope *symbols.Scope) []*symbols.Symbol {
return out
}
-// DeclaredDocumentDefinitions returns the document definitions declared in
-// scope and its nested scopes, outermost first and in declaration order.
-func DeclaredDocumentDefinitions(index *symbols.Index, sem *semantics.Model, scope *symbols.Scope) []*symbols.Symbol {
- var out []*symbols.Symbol
- walkScope(scope, func(sym *symbols.Symbol) {
- if docplan.IsDocumentDefinition(index, sem, sym) {
- out = append(out, sym)
- }
- })
- return out
-}
-
-// DocumentNames is the qualified name of every document definition the
-// workspace documents declare, in name order.
-func DocumentNames(index *symbols.Index, sem *semantics.Model) []string {
- var names []string
- for _, doc := range index.WorkspaceDocuments() {
- for _, sym := range DeclaredDocumentDefinitions(index, sem, index.DocumentRoot(doc)) {
- names = append(names, symbols.FQNOf(sym))
- }
- }
- sort.Strings(names)
- return names
-}
-
-// SiblingDocumentPlans compiles the document definitions the workspace
-// documents declare other than entry, skipping any whose plan does not
-// compile, so a single-document render can emit the anchors incoming
-// cross-document references expect.
-func SiblingDocumentPlans(index *symbols.Index, sem *semantics.Model, resolver *resolve.Resolver, entry *symbols.Symbol) []*docplan.Plan {
- var plans []*docplan.Plan
- for _, doc := range index.WorkspaceDocuments() {
- for _, sym := range DeclaredDocumentDefinitions(index, sem, index.DocumentRoot(doc)) {
- if sym == entry {
- continue
- }
- plan, err := docplan.Compile(index, sem, resolver, sym)
- if err != nil {
- continue
- }
- plans = append(plans, plan)
- }
- }
- return plans
-}
-
// TopLevelDeclarations returns root declarations, expanding packages one level.
func TopLevelDeclarations(root *symbols.Scope) []*symbols.Symbol {
if root == nil {
return nil
}
var out []*symbols.Symbol
- for _, sym := range scopeMembers(root) {
+ for _, sym := range root.Declarations() {
if sym.Kind == symbols.SymbolPackage {
- out = append(out, scopeMembers(sym.Scope)...)
- continue
- }
- out = append(out, sym)
- }
- return out
-}
-
-func walkScope(scope *symbols.Scope, visit func(*symbols.Symbol)) {
- if scope == nil {
- return
- }
- for _, sym := range scopeMembers(scope) {
- visit(sym)
- walkScope(sym.Scope, visit)
- }
-}
-
-func scopeMembers(scope *symbols.Scope) []*symbols.Symbol {
- if scope == nil {
- return nil
- }
- members := scope.AllMembers()
- out := make([]*symbols.Symbol, 0, len(members))
- // AllMembers indexes a short-named declaration under both names.
- seen := make(map[*symbols.Symbol]bool, len(members))
- for _, sym := range members {
- // Aliases and imports name declarations owned by another scope.
- if sym == nil || seen[sym] || sym.Kind == symbols.SymbolAlias || sym.DocName != scope.DocName() {
+ out = append(out, sym.Scope.Declarations()...)
continue
}
- seen[sym] = true
out = append(out, sym)
}
- sort.SliceStable(out, func(i, j int) bool { return out[i].DeclSpan.Offset < out[j].DeclSpan.Offset })
return out
}
diff --git a/internal/workspace/modeldoc/docquery_files_test.go b/internal/workspace/modeldoc/docquery_files_test.go
index 992a42e7c3..d214ee2af1 100644
--- a/internal/workspace/modeldoc/docquery_files_test.go
+++ b/internal/workspace/modeldoc/docquery_files_test.go
@@ -5,6 +5,7 @@ import (
"testing"
"github.com/Open-MBEE/OpenSysML/internal/doc/docrender"
+ "github.com/Open-MBEE/OpenSysML/internal/doc/docset"
"github.com/Open-MBEE/OpenSysML/internal/workspace/modeldoc"
)
@@ -38,7 +39,7 @@ func TestRenderDocumentMarkdownLinksSiblingsByPlannedFiles(t *testing.T) {
if err != nil {
t.Fatalf("RenderDocumentMarkdown: %v", err)
}
- files, err := modeldoc.DocumentFiles([]string{"Reports::Weekly", "Reports::WEEKLY"}, ".md")
+ files, err := docset.Files([]string{"Reports::Weekly", "Reports::WEEKLY"}, ".md")
if err != nil {
t.Fatalf("DocumentFiles: %v", err)
}
diff --git a/internal/workspace/modeldoc/modeldoc.go b/internal/workspace/modeldoc/modeldoc.go
index 0b9bd44265..49232fcfb0 100644
--- a/internal/workspace/modeldoc/modeldoc.go
+++ b/internal/workspace/modeldoc/modeldoc.go
@@ -3,41 +3,19 @@
package modeldoc
import (
- "errors"
"fmt"
"github.com/Open-MBEE/OpenSysML/internal/doc/docir"
"github.com/Open-MBEE/OpenSysML/internal/doc/docrender"
+ "github.com/Open-MBEE/OpenSysML/internal/doc/docset"
"github.com/Open-MBEE/OpenSysML/internal/doc/queryexec"
- "github.com/Open-MBEE/OpenSysML/internal/ir/docplan"
"github.com/Open-MBEE/OpenSysML/internal/semantic/resolve"
"github.com/Open-MBEE/OpenSysML/internal/semantic/semantics"
"github.com/Open-MBEE/OpenSysML/internal/semantic/symbols"
"github.com/Open-MBEE/OpenSysML/internal/syntax/source"
- "github.com/Open-MBEE/OpenSysML/internal/translate/filename"
"github.com/Open-MBEE/OpenSysML/internal/workspace/model"
)
-// DocumentFiles plans the file each named document is written to when the
-// documents are rendered as a set in the form of extension: its stem cut to
-// fit, and tagged with a hash of the whole where two would meet letter case
-// aside. Every renderer links documents to one another by this plan, so a
-// document rendered on its own links to the files a set writes. Two documents
-// of one name cannot be told apart.
-func DocumentFiles(names []string, extension string) (map[string]string, error) {
- files, err := filename.Plan(names, func(name string, tagged bool) string {
- return filename.Fit(docrender.DocumentFileStem(name), extension, '.', tagged)
- })
- var collision *filename.CollisionError
- if errors.As(err, &collision) {
- if collision.Names[0] == collision.Names[1] {
- return nil, fmt.Errorf("%s names more than one document; rename one so the name is unambiguous", source.QualifiedNameText(collision.Names[0]))
- }
- return nil, fmt.Errorf("%s and %s render to one file name %s; rename one so both files can coexist", source.QualifiedNameText(collision.Names[0]), source.QualifiedNameText(collision.Names[1]), collision.File)
- }
- return files, err
-}
-
// RenderDocumentMarkdown compiles the named document definition, evaluates its
// queries against the workspace model, and renders the result as Markdown,
// linking the other documents by the files a Markdown set writes them to.
@@ -55,28 +33,17 @@ func RenderDocumentMarkdown(ws *model.Workspace, fqn string, opts docrender.Mark
sym := matches[0]
var err error
r.Query(sym.DocName, func(resolver *resolve.Resolver, sem *semantics.Model) {
- if !docplan.IsDocumentDefinition(idx, sem, sym) {
- err = fmt.Errorf("%s is not a document: one is a part def specializing DocumentQueries::Document", fqn)
- return
- }
- var plan *docplan.Plan
- if plan, err = docplan.Compile(idx, sem, resolver, sym); err != nil {
- return
- }
- 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, LineIndex: func(doc string) *source.LineIndex {
- return lineIndexes[doc]
- }, ElementID: elementIDs(idx, r)},
- queryexec.Options{}, r.SourceText())
+ document, err = docset.Evaluate(queryexec.Context{Index: idx, Resolver: resolver, Model: sem, LineIndex: func(doc string) *source.LineIndex {
+ return lineIndexes[doc]
+ }, ElementID: elementIDs(idx, r)}, sym, r.SourceText())
if err != nil {
return
}
+ if opts.Files, err = docset.Files(docset.Names(idx, sem), ".md"); err != nil {
+ return
+ }
out, err = docrender.Markdown(document, opts)
})
return err
diff --git a/tests/hygiene/layering_test.go b/tests/hygiene/layering_test.go
index f2dac8c9c0..f7f84715e1 100644
--- a/tests/hygiene/layering_test.go
+++ b/tests/hygiene/layering_test.go
@@ -120,6 +120,7 @@ var packageLayer = map[string]string{
"internal/doc/querytranslate": "doc",
"internal/doc/docir": "doc",
"internal/doc/docrender": "doc",
+ "internal/doc/docset": "doc",
"internal/doc/docpdf": "doc",
"internal/workspace/model": "workspace",
diff --git a/tests/wasm/combined_test.go b/tests/wasm/combined_test.go
index 6f1f51fa29..c9d43a5555 100644
--- a/tests/wasm/combined_test.go
+++ b/tests/wasm/combined_test.go
@@ -20,8 +20,8 @@ import (
)
// combinedGzipBudget bounds the gzipped js sysml-wasm the gate links and `make build-release-wasm` ships (-trimpath, stripped,
-// the library snapshot embedded): 10,413,317 bytes under go1.27.3 on 2026-11-09 at develop ddc4a547fe, 8,726,216 without the snapshot.
-const combinedGzipBudget = 10500000
+// the library snapshot embedded): 11,396,106 bytes under go1.27.4 on 2026-11-11 with RenderDocument served, 9,639,079 without the snapshot.
+const combinedGzipBudget = 11500000
const combinedModel = `package Demo { part def Item; }`
@@ -340,6 +340,23 @@ func TestCombinedWireParity(t *testing.T) {
}
}
+ // A document renders to the same text on either transport, in every form.
+ documentParams := fmt.Sprintf(`{"content":%s}`, mustJSON(t, readFixture(t, telescopeFixture)))
+ grpcDocumentFile, err := svc.ParseFile(ctx, mustUnmarshal[pb.ParseFileRequest](t, documentParams))
+ if err != nil {
+ t.Fatalf("grpc ParseFile telescope: %v", err)
+ }
+ documentBody := call("ParseFile", documentParams)
+ equal("ParseFile telescope", documentParams, mustMarshal(t, grpcDocumentFile), documentBody)
+ for _, form := range []string{"", "markdown", "html", "asciidoc"} {
+ params := fmt.Sprintf(`{"modelHash":%q,"documentId":"Observatory::MassReport","form":%q}`, grpcDocumentFile.ModelHash, form)
+ response, err := svc.RenderDocument(ctx, mustUnmarshal[pb.RenderDocumentRequest](t, params))
+ if err != nil {
+ t.Fatalf("grpc RenderDocument(%q): %v", form, err)
+ }
+ equal("RenderDocument", params, mustMarshal(t, response), call("RenderDocument", params))
+ }
+
path, err := filepath.Abs(filepath.Join("testdata", "engine.sysml"))
if err != nil {
t.Fatalf("resolving engine fixture: %v", err)
@@ -368,6 +385,7 @@ func TestCombinedWireParity(t *testing.T) {
"undetermined_value", "performer", "big_int_values", "render_view", "state_trace",
"action_trace",
"symbol_members",
+ "render_document", "render_document_html", "render_document_asciidoc", "render_document_file_name",
}
if info.Version != "combined-wire-test" {
t.Errorf("GetServerInfo version = %q", info.Version)
@@ -396,6 +414,8 @@ func TestCombinedDependencies(t *testing.T) {
module + "/internal/frontend/engine",
module + "/internal/frontend/core",
module + "/internal/doc/queryexec",
+ module + "/internal/doc/docrender",
+ module + "/internal/doc/docset",
module + "/internal/frontend/symbolfacts",
module + "/internal/semantic/naming",
}
@@ -427,9 +447,6 @@ func TestCombinedDependencies(t *testing.T) {
}
func forbiddenCombinedDependency(module, dependency string) bool {
- if dependency == module+"/internal/doc/queryexec" {
- return false
- }
for _, prefix := range []string{
module + "/api/",
"google.golang.org/protobuf",
@@ -438,7 +455,7 @@ func forbiddenCombinedDependency(module, dependency string) bool {
module + "/internal/frontend/grpc",
module + "/internal/frontend/protoconv",
module + "/internal/frontend/stdiorpc",
- module + "/internal/doc/",
+ module + "/internal/doc/docpdf",
} {
if strings.HasPrefix(dependency, prefix) {
return true
diff --git a/tests/wasm/engine_test.go b/tests/wasm/engine_test.go
index 5d72c4d754..8eb84d2ce9 100644
--- a/tests/wasm/engine_test.go
+++ b/tests/wasm/engine_test.go
@@ -23,8 +23,8 @@ import (
)
// engineGzipBudget bounds the gzipped js sysml-engine the gate links (-trimpath, stripped, the library snapshot embedded, as
-// every Makefile build target does): 9,451,750 bytes under go1.27.3 on 2026-11-09 at develop ddc4a547fe, 7,764,359 without the snapshot.
-const engineGzipBudget = 9500000
+// every Makefile build target does): 10,816,237 bytes under go1.27.4 on 2026-11-11 with RenderDocument served, 9,061,783 without the snapshot.
+const engineGzipBudget = 11000000
func TestEngineDependencies(t *testing.T) {
const module = "github.com/Open-MBEE/OpenSysML"
@@ -77,11 +77,12 @@ func forbiddenEngineDependency(module, dependency string) bool {
return true
}
}
- // ExecuteState's trace events come from queryexec; the document IR and backends stay out.
- if hasDependencyPrefix(dependency, module+"/internal/doc") && dependency != module+"/internal/doc/queryexec" {
+ // RenderDocument writes Markdown, HTML and AsciiDoc; PDF drives converters the engine cannot run.
+ if hasDependencyPrefix(dependency, module+"/internal/doc/docpdf") {
return true
}
- // The workspace pipeline belongs to the LSP and REPL.
+ // The workspace pipeline belongs to the LSP and REPL; the engine renders a
+ // document through internal/doc/docset, as the service does.
for _, prefix := range []string{
module + "/internal/workspace/model",
module + "/internal/workspace/modeldoc",
@@ -91,9 +92,10 @@ func forbiddenEngineDependency(module, dependency string) bool {
return true
}
}
- // RDF is the only converter shared with the engine.
+ // RDF is the only converter shared with the engine; filename plans the
+ // files a rendered document set writes, which RenderDocument links by.
translate := module + "/internal/translate"
- return hasDependencyPrefix(dependency, translate) && dependency != translate+"/rdf"
+ return hasDependencyPrefix(dependency, translate) && dependency != translate+"/rdf" && dependency != translate+"/filename"
}
func hasDependencyPrefix(dependency, prefix string) bool {
diff --git a/tests/wasm/render_document_test.go b/tests/wasm/render_document_test.go
new file mode 100644
index 0000000000..3b4f8a26da
--- /dev/null
+++ b/tests/wasm/render_document_test.go
@@ -0,0 +1,155 @@
+// Copyright 2025 Open‐MBEE Foundation. All rights reserved.
+// Use of this source code is governed by the LICENSE file.
+
+package wasm
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/Open-MBEE/OpenSysML/internal/doc/docrender"
+ "github.com/Open-MBEE/OpenSysML/internal/frontend/engine"
+ "github.com/Open-MBEE/OpenSysML/internal/frontend/jsonrpc"
+)
+
+// telescopeFixture is the document renderer's own fixture, whose goldens the
+// service's RenderDocument answers match; the engine answers the same text.
+const telescopeFixture = "../../internal/doc/docrender/testdata/telescope_report.sysml"
+
+// graphViewsFixture declares a document whose views carry layout positions, so
+// a render without Graphviz must fall back to Mermaid and say so.
+const graphViewsFixture = "../../internal/doc/docrender/testdata/graph_views.sysml"
+
+func readFixture(t *testing.T, path string) string {
+ t.Helper()
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("reading the fixture: %v", err)
+ }
+ return string(data)
+}
+
+// parseEngineFixture loads a fixture file into eng and returns its model hash.
+func parseEngineFixture(t *testing.T, eng *engine.Engine, path string) string {
+ t.Helper()
+ body := mustCall(t, eng, "ParseSources", renderParamsJSON(t, map[string]any{
+ "documents": []map[string]string{{"name": filepath.Base(path), "content": readFixture(t, path)}},
+ }))
+ var parsed engine.JParseSourcesResponse
+ if err := json.Unmarshal(body, &parsed); err != nil {
+ t.Fatalf("decoding ParseSources: %v\n%s", err, body)
+ }
+ if parsed.ModelHash == "" || len(parsed.Diagnostics) != 0 {
+ t.Fatalf("ParseSources(%s) = %+v, want a hash and no diagnostics", path, parsed)
+ }
+ return parsed.ModelHash
+}
+
+func engineRenderDocument(t *testing.T, eng *engine.Engine, hash, document, form string) engine.JRenderDocumentResponse {
+ t.Helper()
+ params := map[string]any{"modelHash": hash, "documentId": document}
+ if form != "" {
+ params["form"] = form
+ }
+ body := mustCall(t, eng, "RenderDocument", renderParamsJSON(t, params))
+ var rendered engine.JRenderDocumentResponse
+ if err := json.Unmarshal(body, &rendered); err != nil {
+ t.Fatalf("decoding RenderDocument(%q): %v\n%s", form, err, body)
+ }
+ return rendered
+}
+
+// The engine renders a document to the text the renderer's goldens lock in,
+// in each form, in the field of that form's name, with the file a set writes.
+func TestEngineRenderDocumentMatchesTheRendererGoldens(t *testing.T) {
+ eng, err := engine.New()
+ if err != nil {
+ t.Fatalf("engine.New: %v", err)
+ }
+ hash := parseEngineFixture(t, eng, telescopeFixture)
+ for _, tc := range []struct {
+ form, golden, fileName string
+ text func(engine.JRenderDocumentResponse) string
+ }{
+ {"", "telescope_report.golden.md", "Observatory-MassReport.md", func(r engine.JRenderDocumentResponse) string { return r.Markdown }},
+ {"markdown", "telescope_report.golden.md", "Observatory-MassReport.md", func(r engine.JRenderDocumentResponse) string { return r.Markdown }},
+ {"html", "telescope_report.golden.html", "Observatory-MassReport.html", func(r engine.JRenderDocumentResponse) string { return r.Html }},
+ {"asciidoc", "telescope_report.golden.adoc", "Observatory-MassReport.adoc", func(r engine.JRenderDocumentResponse) string { return r.Asciidoc }},
+ } {
+ rendered := engineRenderDocument(t, eng, hash, "Observatory::MassReport", tc.form)
+ want := readFixture(t, filepath.Join(filepath.Dir(telescopeFixture), tc.golden))
+ if got := tc.text(rendered); got != want {
+ t.Errorf("RenderDocument(%q) differs from %s:\n%s", tc.form, tc.golden, got)
+ }
+ if rendered.FileName != tc.fileName {
+ t.Errorf("RenderDocument(%q) fileName = %q, want %q", tc.form, rendered.FileName, tc.fileName)
+ }
+ written := 0
+ for _, text := range []string{rendered.Markdown, rendered.Html, rendered.Asciidoc} {
+ if text != "" {
+ written++
+ }
+ }
+ if written != 1 {
+ t.Errorf("RenderDocument(%q) wrote %d forms, want the one asked for", tc.form, written)
+ }
+ }
+}
+
+// Without Graphviz, which no engine build runs, a positioned diagram is written
+// as Mermaid with the renderer's notice rather than as DOT nothing will draw.
+func TestEngineRenderDocumentFallsBackToMermaid(t *testing.T) {
+ eng, err := engine.New()
+ if err != nil {
+ t.Fatalf("engine.New: %v", err)
+ }
+ hash := parseEngineFixture(t, eng, graphViewsFixture)
+ notice := docrender.GraphvizFallbackNotice
+ markdown := engineRenderDocument(t, eng, hash, "Pump::Handbook", "").Markdown
+ if strings.Count(markdown, "```mermaid") != 2 || strings.Count(markdown, "*"+notice+"*") != 2 || strings.Contains(markdown, "```dot") {
+ t.Errorf("Markdown does not fall back to Mermaid visibly:\n%s", markdown)
+ }
+ page := engineRenderDocument(t, eng, hash, "Pump::Handbook", "html").Html
+ if strings.Count(page, ``) != 2 || strings.Count(page, ``+notice+"
") != 2 {
+ t.Errorf("HTML does not fall back to Mermaid visibly:\n%s", page)
+ }
+}
+
+// A request the engine refuses answers the status the service answers: the
+// forms it renders are named, and a missing model or document is not found.
+func TestEngineRenderDocumentRefusals(t *testing.T) {
+ eng, err := engine.New()
+ if err != nil {
+ t.Fatalf("engine.New: %v", err)
+ }
+ hash := parseEngineFixture(t, eng, telescopeFixture)
+ for name, tc := range map[string]struct {
+ params string
+ code uint32
+ message string
+ }{
+ "pdf": {fmt.Sprintf(`{"modelHash":%q,"documentId":"Observatory::MassReport","form":"pdf"}`, hash), jsonrpc.CodeInvalidArgument, `form "pdf" is not one a document is rendered in: markdown, html or asciidoc`},
+ "an unknown form": {fmt.Sprintf(`{"modelHash":%q,"documentId":"Observatory::MassReport","form":"docx"}`, hash), jsonrpc.CodeInvalidArgument, `form "docx" is not one a document is rendered in`},
+ "an unknown document": {fmt.Sprintf(`{"modelHash":%q,"documentId":"Observatory::Missing"}`, hash), jsonrpc.CodeNotFound, "symbol not found: Observatory::Missing"},
+ "a non-document": {fmt.Sprintf(`{"modelHash":%q,"documentId":"Observatory::Subsystem"}`, hash), jsonrpc.CodeInvalidArgument, "Observatory::Subsystem is not a document: one is a part def specializing DocumentQueries::Document"},
+ "no document": {fmt.Sprintf(`{"modelHash":%q}`, hash), jsonrpc.CodeInvalidArgument, "a document to render must be named in documentId"},
+ "a missing model": {`{"modelHash":"missing","documentId":"Observatory::MassReport"}`, jsonrpc.CodeNotFound, "model not found: missing"},
+ "no model": {`{"documentId":"Observatory::MassReport"}`, jsonrpc.CodeNotFound, "model not found: "},
+ } {
+ _, err := eng.Call(context.Background(), "RenderDocument", []byte(tc.params))
+ var callErr *jsonrpc.Error
+ if !errors.As(err, &callErr) || callErr.Code != tc.code {
+ t.Errorf("%s: error = %v, want code %d", name, err, tc.code)
+ continue
+ }
+ if !strings.Contains(callErr.Message, tc.message) {
+ t.Errorf("%s: message = %q, want it to contain %q", name, callErr.Message, tc.message)
+ }
+ }
+}