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) + } + } +}