From 0bee79d949045637644feb14d325585d9a711a21 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 22:36:05 +0000 Subject: [PATCH 1/6] feat(wasm): serve RenderDocument from sysml-engine and sysml-wasm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The combined module and the engine answer RenderDocument with the protojson RenderDocumentResponse the service answers, in the forms markdown, html and asciidoc, refusing pdf by naming the three. The pipeline the service rendered through is extracted to internal/doc/docset — document definitions, sibling plans, the files a set writes, evaluation, the backends and the fault classification each transport maps to its status codes — so the service, the engine and the workspace render through one implementation. Positioned diagrams fall back to Mermaid with the renderer's notice under the engine, which runs no Graphviz. Scope declaration walking moves to symbols.Scope. Co-Authored-By: jason.han --- .../unreleased/wasm-render-document.added.md | 1 + client/node/README.md | 8 +- docs/internals/architecture.md | 3 +- docs/reference/wasm.md | 40 ++- examples/self-model/surfaces.sysml | 4 +- internal/doc/docset/docset.go | 293 ++++++++++++++++++ internal/doc/docset/render_test.go | 69 +++++ internal/frontend/combined/combined.go | 6 +- internal/frontend/combined/combined_test.go | 86 +++++ internal/frontend/engine/engine.go | 7 + internal/frontend/engine/json.go | 18 ++ internal/frontend/engine/render_document.go | 82 +++++ internal/frontend/grpc/docquery.go | 181 +++-------- internal/frontend/grpc/docquery_test.go | 4 +- internal/frontend/repl/docrender.go | 11 +- internal/semantic/symbols/members.go | 36 ++- internal/workspace/model/docquery.go | 2 +- internal/workspace/model/identity.go | 2 +- .../workspace/model/library_version_test.go | 4 +- internal/workspace/model/render.go | 2 +- internal/workspace/model/selection.go | 86 +---- .../workspace/modeldoc/docquery_files_test.go | 3 +- internal/workspace/modeldoc/modeldoc.go | 47 +-- tests/hygiene/layering_test.go | 1 + tests/wasm/combined_test.go | 25 +- tests/wasm/engine_test.go | 12 +- tests/wasm/render_document_test.go | 155 +++++++++ 27 files changed, 889 insertions(+), 299 deletions(-) create mode 100644 changes/unreleased/wasm-render-document.added.md create mode 100644 internal/doc/docset/docset.go create mode 100644 internal/doc/docset/render_test.go create mode 100644 internal/frontend/engine/render_document.go create mode 100644 tests/wasm/render_document_test.go 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/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/reference/wasm.md b/docs/reference/wasm.md index 7bb32cfc92..047810970a 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,32 @@ 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`; 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 +299,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 +321,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..d156379628 --- /dev/null +++ b/internal/frontend/engine/render_document.go @@ -0,0 +1,82 @@ +// 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" +) + +// 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), + } + 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 + } +} 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 461fb6f0f6..000aa75d81 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..668122dabe 100644 --- a/tests/wasm/combined_test.go +++ b/tests/wasm/combined_test.go @@ -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..1a4309ecf8 100644 --- a/tests/wasm/engine_test.go +++ b/tests/wasm/engine_test.go @@ -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) + } + } +} From b6602f61706ada87cdb2772de391349a1afe5d29 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 22:36:05 +0000 Subject: [PATCH 2/6] test(wasm): raise the engine and combined size budgets for RenderDocument The document pipeline (docir, docrender, docset, translate/filename) the engine now links grows the gate's gzipped js builds to 10,816,237 bytes (sysml-engine, was 9,451,750) and 11,396,106 bytes (sysml-wasm, was 10,413,317); the budgets move to 11,000,000 and 11,500,000, the measured sizes recorded in their comments. Co-Authored-By: jason.han --- tests/wasm/combined_test.go | 4 ++-- tests/wasm/engine_test.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/wasm/combined_test.go b/tests/wasm/combined_test.go index 668122dabe..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; }` diff --git a/tests/wasm/engine_test.go b/tests/wasm/engine_test.go index 1a4309ecf8..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" From d734a00572fc9032b2324b805bcf73cc970ecd8e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 22:39:21 +0000 Subject: [PATCH 3/6] fix(engine): read elementId for RenderDocument through the registered converter The engine's query context now supplies ElementID as the service's does, through queryexec.OpenElementIDs over the model's documents, so a document query projecting elementId answers ids where the binary links the converter; the js build links none and leaves the column absent, which docs/reference/wasm.md now states. Co-Authored-By: jason.han --- docs/reference/wasm.md | 3 ++- internal/frontend/engine/render_document.go | 26 +++++++++++++++++---- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/docs/reference/wasm.md b/docs/reference/wasm.md index 047810970a..6490d17ece 100644 --- a/docs/reference/wasm.md +++ b/docs/reference/wasm.md @@ -191,7 +191,8 @@ definition's qualified name and, optionally, the form: 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`; and no Graphviz draws for it, so a diagram block whose view carries layout +`Instantiate`; a query's `elementId` column is empty in the `js` build, which links no +converter to read 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, diff --git a/internal/frontend/engine/render_document.go b/internal/frontend/engine/render_document.go index d156379628..02197f7fe1 100644 --- a/internal/frontend/engine/render_document.go +++ b/internal/frontend/engine/render_document.go @@ -10,6 +10,7 @@ import ( "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 @@ -35,10 +36,11 @@ func (e *Engine) renderDocument(ctx context.Context, req *JRenderDocumentRequest } sem := cached.typedModel() qctx := queryexec.Context{ - Index: cached.Index, - Resolver: cached.resolver, - Model: sem, - Runtime: e.newRuntime(ctx, cached), + 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 { @@ -80,3 +82,19 @@ func faultCode(fault docset.Fault) uint32 { 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 (the js build), and elementId is absent. +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) + } +} From f701a8429d7b82bc37c40d8f0ba3438779fdd88a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 22:47:04 +0000 Subject: [PATCH 4/6] docs(wasm): state that no engine build fills elementId sysml-engine and sysml-wasm link no converter, so the elementId column RenderDocument answers is empty on every target, not only the js build; the comment and docs/reference/wasm.md say so. Co-Authored-By: jason.han --- docs/reference/wasm.md | 4 ++-- internal/frontend/engine/render_document.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/reference/wasm.md b/docs/reference/wasm.md index 6490d17ece..b809bb677c 100644 --- a/docs/reference/wasm.md +++ b/docs/reference/wasm.md @@ -191,8 +191,8 @@ definition's qualified name and, optionally, the form: 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 in the `js` build, which links no -converter to read ids from; and no Graphviz draws for it, so a diagram block whose view carries layout +`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, diff --git a/internal/frontend/engine/render_document.go b/internal/frontend/engine/render_document.go index 02197f7fe1..bce48cb7af 100644 --- a/internal/frontend/engine/render_document.go +++ b/internal/frontend/engine/render_document.go @@ -84,7 +84,7 @@ func faultCode(fault docset.Fault) uint32 { } // elementIDs reads the elementId a conversion writes for each element through the registered -// converter, as the service does; nil when the binary links none (the js build), and elementId is absent. +// 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 { From c256bc3a84ab99cd77974f546bdf77d925aeeded Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 23:17:08 +0000 Subject: [PATCH 5/6] test(node): expect the RenderDocument capabilities of the wasm module The module's GetServerInfo now ends with render_document, render_document_html, render_document_asciidoc and render_document_file_name; the Node client's wasm connection test lists the capabilities it expects and so lists them too. Co-Authored-By: jason.han --- client/node/test/wasm.test.ts | 4 ++++ 1 file changed, 4 insertions(+) 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); From 12efeb8f5c6bb2f28a23bfb8c00f9c589663d3e5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 12 Nov 2026 05:10:27 +0000 Subject: [PATCH 6/6] chore(referee): re-record the examples provenance digest after the self-model surfaces change The served-method strings in examples/self-model/surfaces.sysml now name RenderDocument, which moves the digest of the examples input recorded by the pilot differential and api-json differential baselines. Re-recorded with pilot-apijson-diff -update and pilot-diff -update; every per-file verdict and total is unchanged. Co-Authored-By: jason.han --- docs/project/api-json-differential/index.json | 2 +- docs/project/pilot-differential-baseline.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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,