Skip to content
Merged
1 change: 1 addition & 0 deletions changes/unreleased/document-multi-valued-cells.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **A table column over a multi-valued feature renders its values instead of failing the document.** A `Column` whose expression reads a feature declared `[0..*]` (a migrated DocGen table over `attribute :>> tCalibNB = (69.0, 98.0);`) stopped the whole document with "produced 2 values, expected one". The query planner now carries the column's declared multiplicity into execution: a cell holds as many values as the feature admits, written in order and `, `-joined in Markdown, HTML and PDF alike, and an optional feature with no value is an empty cell rather than an error. A column declared `[1]` still refuses zero or several values, naming the bound it expected, unless `?? null` opts its empty rows into an empty cell; a scalar place — a caption, a `Ref`, a comparison operand — still takes one value.
1 change: 1 addition & 0 deletions changes/unreleased/positioned-view-every-form.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **A view with `DiagramLayout::Layout` positions draws the same nodes in every graph form.** Only the DOT writer left the members no position placed undrawn; the Mermaid and PlantUML forms drew the whole exposed tree, so a migrated diagram exposing a package it never drew expanded into thousands of nodes and tripped the Mermaid workload bound. One placement decision now serves `dot`, `mermaid` and `plantuml`, through `-render`, `-render-all`, document figures, the LSP and gRPC alike: the placed members and the edges between them by default, every member under `-render-unplaced strip`, and each form accounts for what it left out in its own comment syntax (`%% not represented: …` in Mermaid, `' not represented: …` in PlantUML). The bound itself is unchanged.
1 change: 1 addition & 0 deletions changes/unreleased/render-documents-partial-set.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **`-render-documents` writes every document it can and exits `3` when one could not be rendered.** One document whose query failed stopped the run before anything was written. Each document in the set is now compiled and evaluated on its own: the ones that render are written, a page stating **This document could not be rendered.** and the error stands in for each that does not — so links to it from the other pages resolve rather than dangle — and each failure is reported on stderr as `document <qualified name> could not be rendered: <reason>`. The new exit status `3` says the run was carried out in part; `0` still means every document was written and `2` that nothing was (no documents, a model that did not analyse). Same for `-doc-form html`.
1 change: 1 addition & 0 deletions changes/unreleased/render-documents-same-name.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **`-render-documents` writes documents whose file names differ in letter case alone instead of stopping.** Two documents `Reports::Summary` and `Reports::SUMMARY` stopped the run with "render to file names that differ only by letter case"; each is now written under its name tagged with `~` and a hash, as `-render-all` writes such views, and the same planner escapes a stem Windows reads as a device and cuts a name too long for a path component. Cross-document links point at the tagged name where one was needed, in a set and in a single document rendered on its own — by `-render-document`, `%render-document`, the LSP's `opensysml/renderDocument` or the gRPC `RenderDocument`, which all plan the set's file names the same way. Documents sharing a short name in different packages were always written apart, by qualified name, and naming one to `-render-document` by the short name alone is refused with every candidate's qualified name.
5 changes: 3 additions & 2 deletions cmd/sysml/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -560,10 +560,11 @@ func runCLI() int {
if status := resolveRunBounds(); status != 0 {
return status
}
if err := runRenderDocuments(args); err != nil {
status, err := runRenderDocuments(args)
if err != nil {
return fail(err)
}
return exitHolds
return status
}

if renderAllDir != "" {
Expand Down
119 changes: 17 additions & 102 deletions cmd/sysml/render.go
Original file line number Diff line number Diff line change
@@ -1,22 +1,19 @@
package main

import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"unicode"
"unicode/utf8"

"github.com/chzyer/readline"

"github.com/Open-MBEE/OpenSysML/internal/frontend/repl"
"github.com/Open-MBEE/OpenSysML/internal/ir/view"
"github.com/Open-MBEE/OpenSysML/internal/translate/export"
"github.com/Open-MBEE/OpenSysML/internal/translate/filename"
"github.com/Open-MBEE/OpenSysML/internal/workspace/model"
)

Expand Down Expand Up @@ -121,71 +118,26 @@ func runRenderAll(files []string) error {
// renderFilenames is the file -render-all writes each view it writes to, by view name;
// files meeting letter case aside are tagged until no two meet, or refused if two still do.
func renderFilenames(views []model.ViewInfo, form view.Form) (map[string]string, error) {
type plan struct {
name string
form view.Form
tagged bool
}
var plans []*plan
forms := make(map[string]view.Form, len(views))
var names []string
for _, info := range views {
written := form
if written == "" {
written = info.Kind.MachineForm()
}
if info.Supported && info.Kind.SupportsForm(written) {
plans = append(plans, &plan{name: info.Name, form: written})
}
}
for {
meeting := map[string][]*plan{}
var keys []string
for _, p := range plans {
key := caseFolded(renderFilename(p.name, p.form, p.tagged))
if _, seen := meeting[key]; !seen {
keys = append(keys, key)
}
meeting[key] = append(meeting[key], p)
}
progressed := false
for _, key := range keys {
group := meeting[key]
if len(group) < 2 {
continue
}
settled := true
for _, p := range group {
if !p.tagged {
p.tagged, settled, progressed = true, false, true
}
}
if settled {
return nil, fmt.Errorf("views %s and %s have the same rendering path %s",
group[0].name, group[1].name, renderFilename(group[0].name, group[0].form, true))
}
forms[info.Name] = written
names = append(names, info.Name)
}
if !progressed {
break
}
}
filenames := make(map[string]string, len(plans))
for _, p := range plans {
filenames[p.name] = renderFilename(p.name, p.form, p.tagged)
}
return filenames, nil
}

// caseFolded is text under simple Unicode case folding: two texts fold alike
// exactly when strings.EqualFold holds of them.
func caseFolded(text string) string {
var b strings.Builder
for _, r := range text {
least := r
for f := unicode.SimpleFold(r); f != r; f = unicode.SimpleFold(f) {
least = min(least, f)
}
b.WriteRune(least)
filenames, err := filename.Plan(names, func(name string, tagged bool) string {
return renderFilename(name, forms[name], tagged)
})
var collision *filename.CollisionError
if errors.As(err, &collision) {
return nil, fmt.Errorf("views %s and %s have the same rendering path %s", collision.Names[0], collision.Names[1], collision.File)
}
return b.String()
return filenames, err
}

// renderOptions is what -render and -render-all write with: the text width,
Expand All @@ -209,7 +161,7 @@ func renderOptions(width int) (view.Options, error) {
}

// unplacedOption is the placement -render-unplaced names for the nodes a
// positioned DOT drawing leaves unplaced, which must be one there is; none
// positioned drawing leaves unplaced, which must be one there is; none
// named is the default, leaving them undrawn.
func unplacedOption() (view.Unplaced, error) {
if renderUnplaced == "" {
Expand Down Expand Up @@ -279,54 +231,17 @@ func renderFilename(name string, form view.Form, tagged bool) string {
b.WriteByte(c)
}
}
filename := b.String()
if stem, _, _ := strings.Cut(filename, "."); windowsDeviceNames[strings.ToUpper(strings.TrimRight(stem, " "))] {
filename = fmt.Sprintf("%%%02X", filename[0]) + filename[1:]
}
ext := renderExtension(form)
if tagged || len(filename)+len(ext) > maxFilenameBytes {
sum := sha256.Sum256([]byte(filename))
tag := "~" + hex.EncodeToString(sum[:filenameTagBytes])
filename = cutFilename(filename, maxFilenameBytes-len(ext)-len(tag)) + tag
}
return filename + ext
}

// maxFilenameBytes is the longest name every common filesystem takes for one path component;
// filenameTagBytes of the encoded name's hash keep a cut name apart from its neighbours.
const (
maxFilenameBytes = 255
filenameTagBytes = 8
)

// cutFilename is the longest prefix of an encoded filename within n bytes that
// splits neither a UTF-8 sequence nor a `%XX` escape.
func cutFilename(filename string, n int) string {
n = min(n, len(filename))
for n > 0 && n < len(filename) && !utf8.RuneStart(filename[n]) {
n--
}
if i := strings.LastIndexByte(filename[:n], '%'); i >= 0 && i > n-3 {
n = i
encoded := b.String()
if filename.DeviceStem(encoded) {
encoded = fmt.Sprintf("%%%02X", encoded[0]) + encoded[1:]
}
return filename[:n]
return filename.Fit(encoded, renderExtension(form), '%', tagged)
}

// unsafeFilenameBytes are the printable bytes a rendering filename encodes: path separators,
// the drive colon, the encoding's own `%`, the `.` standing for `::`, and what Windows reserves.
const unsafeFilenameBytes = "/\\:%.<>\"|?*"

// windowsDeviceNames are the stems Windows reads as devices whatever the extension,
// trailing spaces and letter case aside: the serial and printer ports include the
// superscript digits Windows counts among them.
var windowsDeviceNames = map[string]bool{
"CON": true, "PRN": true, "AUX": true, "NUL": true,
"COM0": true, "COM1": true, "COM2": true, "COM3": true, "COM4": true, "COM5": true, "COM6": true, "COM7": true, "COM8": true, "COM9": true,
"COM¹": true, "COM²": true, "COM³": true,
"LPT0": true, "LPT1": true, "LPT2": true, "LPT3": true, "LPT4": true, "LPT5": true, "LPT6": true, "LPT7": true, "LPT8": true, "LPT9": true,
"LPT¹": true, "LPT²": true, "LPT³": true,
}

func renderExtension(form view.Form) string {
switch form {
case view.FormMermaid:
Expand Down
57 changes: 40 additions & 17 deletions cmd/sysml/render_document.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,20 +86,44 @@ func pdfOptions() (docpdf.Options, error) {
}, nil
}

// runRenderDocuments renders every document definition of the model named on
// the command line as linked files in the directory -render-documents names,
// so cross-document references resolve on disk.
func runRenderDocuments(files []string) error {
// runRenderDocuments renders every document of the model named on the command
// line into -render-documents as a linked set, writing the pages of the
// documents that render and, for each that does not, a page stating why; it
// returns the status of the run and, when nothing was written, what stopped it.
func runRenderDocuments(files []string) (int, error) {
documents, form, err := renderDocumentSet(files)
if err != nil {
return exitUnevaluable, err
}
if err := os.MkdirAll(renderDocsDir, 0o750); err != nil {
return exitUnevaluable, fmt.Errorf("create rendering directory %s: %w", renderDocsDir, err)
}
if err := commitDocumentSet(documents, form); err != nil {
return exitUnevaluable, err
}
status := exitHolds
for _, document := range documents {
if document.Err != nil {
fmt.Fprintf(os.Stderr, "%sdocument %s could not be rendered: %v\n", commandPrefix, source.QualifiedNameText(document.Name), document.Err)
status = exitPartial
}
}
return status, nil
}

// renderDocumentSet renders the model's documents in the form -doc-form names,
// the stylesheets of an HTML set among them, without writing anything.
func renderDocumentSet(files []string) ([]repl.RenderedDocument, string, error) {
form, err := documentSetForm()
if err != nil {
return err
return nil, "", err
}
if len(files) == 0 {
return errors.New("no model to render; name the files the documents are declared in, as `sysml model.sysml -render-documents rendered`")
return nil, "", errors.New("no model to render; name the files the documents are declared in, as `sysml model.sysml -render-documents rendered`")
}
sess, err := loadRenderingModel(files)
if err != nil {
return err
return nil, "", err
}
// A set links its stylesheets as files beside the pages, so a reader
// downloads each once and edits it in one place.
Expand All @@ -108,7 +132,7 @@ func runRenderDocuments(files []string) error {
if form == docFormHTML {
links, assets, err := setStylesheets()
if err != nil {
return err
return nil, "", err
}
sheets = assets
opts := documentOptions()
Expand All @@ -117,22 +141,18 @@ func runRenderDocuments(files []string) error {
opts.NoDefaultStylesheet = true
documents, err = sess.RenderDocumentSetHTML(opts)
if err != nil {
return err
return nil, "", err
}
} else {
documents, err = sess.RenderDocumentSetMarkdown(markdownOptions())
if err != nil {
return err
return nil, "", err
}
}
if len(documents) == 0 {
return errors.New("the model declares no documents; nothing was rendered")
}
documents = append(documents, sheets...)
if err := os.MkdirAll(renderDocsDir, 0o750); err != nil {
return fmt.Errorf("create rendering directory %s: %w", renderDocsDir, err)
return nil, "", errors.New("the model declares no documents; nothing was rendered")
}
return commitDocumentSet(documents, form)
return append(documents, sheets...), form, nil
}

// documentOptions carries the flags shaping the document itself, leaving its
Expand Down Expand Up @@ -468,8 +488,11 @@ func commitDocumentSet(documents []repl.RenderedDocument, form string) error {
}
path := filepath.Join(renderDocsDir, document.FileName)
what := ""
if document.Err != nil {
what = ", a page stating why the document could not be rendered"
}
if replaced[i] {
what = ", replaced the existing file"
what += ", replaced the existing file"
}
fmt.Fprintf(os.Stderr, "wrote %s (%s, %d bytes%s)\n", path, setForm(document, form), len(documentBytes(document)), what)
}
Expand Down
Loading
Loading