Skip to content

feat(repl): print the session model as notation at the prompt - #273

Merged
HuiJun merged 2 commits into
mainfrom
devin/1786994453-repl-print
Aug 17, 2026
Merged

feat(repl): print the session model as notation at the prompt#273
HuiJun merged 2 commits into
mainfrom
devin/1786994453-repl-print

Conversation

@devin-ai-integration

Copy link
Copy Markdown

Summary

Seeing the model a session holds required %save <file> and another program to open the file. %print writes it back at the prompt instead, and %print <name> writes one element and its body, taking the quoted/qualified spellings the other name commands take (%print 'My Pkg'::Car, %print Top::'My Pkg'::Car).

It is not a second renderer: the whole-buffer print is export.ConvertTolerant(sysml → sysml), the same call %save's .sysml path makes, and the one-element print goes through a new export.SysMLElement that runs one element's source through that same format.Source:

// internal/core/export/convert.go
var ErrNoNotation = errors.New("no notation to write")

func SysMLElement(file *source.SourceFile, span source.Span) ([]byte, *SyntaxError, error) {
    if file == nil || span.Len <= 0 || span.Offset < 0 || span.End() > file.Len() { return nil, nil, ErrNoNotation }
    text := strings.TrimSpace(file.Text(span))
    if text == "" { return nil, nil, ErrNoNotation }
    return convert(file.Name(), []byte(text), FormatSysML, FormatSysML, true)
}

Because the printed text is the source (re-indented), comments survive and a print submitted again rebuilds the same model — TestPrintRoundTripsThroughSubmit submits a print into a fresh session and asserts the reprint is byte-identical.

The element's span is its declaration plus the non-whitespace leading trivia above it, so the note explaining a definition prints with it (declarationSpan).

Printing is a read, and tested as one: printSession uses s.text() (never getOrCreateRuntime), so no object is materialized and %instances, %list, the buffer and a running %action/%state debugging session are unchanged across a print — including a print of an unresolvable name. Notation only, so no RDF notice follows a print. Three one-line answers replace silence: an empty session, a name this session declares nowhere (a library symbol carrying no source), and a declaration spanning no source. A buffer with syntax errors prints as typed with a warning: prefix, matching how %save reports it.

Tab completion needed no change — %print is not a path command, so it falls through to the generic name-completion branch of complete.go; TestPrintCompletion pins that both the command and names after it complete.

Verification

go build ./...            clean
go vet ./...              clean
gofmt -l .                empty
go test ./...             all packages ok
go test -race ./...       all packages ok
make lint                 ✓ Lint passed
python3 scripts/check-doc-links.py   0 broken link(s)

OMG corpus gate, run locally: 98/100 training files clean (the known baseline; 2 files / 4 errors are pinned OMG source bugs). internal/core/model/testdata/training_examples_expected.txt is untouched.

Link to Devin session: https://nasa-jpl-demo.devinenterprise.com/sessions/d15785b463e7487eaa58dc9513d22478
Requested by: @HuiJun

%print writes the whole session model back as SysML v2 notation, and %print <name> one element and its body, through the writer %save writes .sysml with: export.SysMLElement formats one element's source via the same format.Source path, so comments and text as typed survive and a print can be submitted again. Printing is a read: no runtime object, no change to instances, the buffer, or an active %action/%state debugging session.

Co-Authored-By: jason.han <jason.han@jpl.nasa.gov>
@HuiJun HuiJun self-assigned this Aug 17, 2026
@devin-ai-integration

Copy link
Copy Markdown
Author
Original prompt from Devin Bot

# Task: add a %print REPL command that shows the model as notation

Repo: JPL-Devin/Systemica (Go, module github.com/Open-MBEE/Systemica). Branch off current main. Read AGENTS.md and CONTRIBUTING.md first — they are binding, in particular: correctness over expedience, no stubs, never weaken/skip/delete tests, immutable AST + semantic side tables, Conventional Commits, and update docs/project/spec-compliance.md and the docs when a user-visible surface changes.

#``# What users want

Today the only way to see the model the session holds as SysML notation is %save &lt;file&gt; — you have to write a file and open it elsewhere. Add a command that prints it at the prompt instead:

  • %print — print the whole session model as SysML v2 notation.
  • %print &lt;name&gt; — print just that element and its body (accepting the same quoted/qualified name spellings every other command takes, e.g. %print 'My Pkg'::Car, %print Top::'My Pkg'::Car), so a user can inspect one definition without reading the whole buffer.

Behavior requirements:

  • Reuse the existing notation writer. %save's .sysml path already renders the session model with comments preserved (internal/repl/save.go and whatever it calls). Factor out what you need and call it — do not write a second renderer that can drift from the one %save uses. If the existing writer only handles a whole document, extend it to render a subtree rather than duplicating it.
  • Printing is read-only: it must not create runtime objects, must not touch s.instances, must not end or disturb an active %action/%state debugging session, and must not alter the submission buffer. (A sibling review just caught %view violating exactly this, so it will be checked.)
  • What round-trips must round-trip: printing the session and re-submitting the printed text must produce the same model. Prove it in a test.
  • An unresolvable name, an empty session, and a name that resolves to something with no printable notation each get a... (2429 chars truncated...)

@devin-ai-integration

Copy link
Copy Markdown
Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR that start with 'DevinAI' or '@devin'.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@devin-ai-integration

Copy link
Copy Markdown
Author

E2E test of %print (built bin/sysml @ 1966222, interactive REPL in a real terminal)

All tested behaviors passed.

Fixture: Top (with a //-commented part def Car) containing a nested package 'My Pkg' with a second Car, plus Fleet with an import, a part, and an action def.

Whole-model print and single-element print

%print reproduces the multi-package model — both packages, the nested package 'My Pkg' { with quotes intact, the // comment, and import Top::*;:

whole model print

%print <name> prints just the element and its body; Top::Car carries its leading // comment, and Top::'My Pkg'::Car correctly resolves to the other Car (attribute mass):

element prints

Round-trip is byte-exact

%save a.sysml%clear%load a.sysml%save b.sysmldiff exit 0 (358 bytes both), and a piped %print run diffs clean against the %saved .sysml, i.e. %print and %save share one writer:

print == save

Read-only: instances, list and debugging session survive

%instances is unchanged across %print (still exactly Top::Car (ID: 1)), and while stepping %action Fleet::Drive, a %print in the middle leaves the session running — %step still advances the token start → <final>:

stepping survives print

Messages: empty session, unresolved, stdlib symbol, syntax errors

One clear line each, no panics, no silent empty output:

errors

A session with syntax errors prints as typed behind two warning: lines:

syntax errors

No RDF mention, help row, tab completion

In the same session %save x.ttl does emit note: RDF conversion is experimental… while %print emits nothing about RDF — so the absence is a real check, not an unreachable path. %help shows the new %print [name] row and Tab completes both %pri%print and %print Top::CTop::Car:

rdf contrast

Notes (not blockers, all pre-existing behavior shared with other commands)

  • %print 'My Pkg'::Car where 'My Pkg' is nested inside Top answers error: unresolved reference: 'My Pkg'::Car — did you mean Actions::ForLoopAction::var?; %instantiate 'My Pkg'::Car answers identically, so this is lookupSymbol's partial-qualification behavior, not new. The docs example matches the repo's existing convention where 'My Pkg' is top level (which resolves); the nonsense "did you mean" suggestion is worth a separate look.
  • Tab after %pri completes to %print without a trailing space, the same as %load.
  • Not covered on camera: %print during a %state debugging session; only the %action executor path was exercised there (unit test TestPrintLeavesStateDebuggerRunning covers the state path).

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

View 1 additional finding in Devin Review.

Open in Devin Review

Comment thread internal/repl/print.go
Comment on lines +76 to +91
func declarationSpan(sym *symbols.Symbol) source.Span {
span := sym.DeclSpan
if sym.Decl != nil {
span = sym.Decl.Span()
}
start := span.Offset
for _, tr := range sym.LeadingTrivia {
if tr.Kind == ast.TriviaWhitespace || tr.Span.Offset >= span.Offset {
continue
}
if tr.Span.Offset < start {
start = tr.Span.Offset
}
}
return source.Span{Offset: start, Len: span.End() - start}
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Printing one element also prints the note written for the element that follows it

The printed slice of the model is cut at the start of the next declaration (span.End() from sym.Decl.Span() at internal/repl/print.go:79) rather than at the end of the element itself, so any comment lines sitting between the two are printed as part of the first element.
Impact: A user printing one element sees a comment that belongs to the next element appended below it, and the same comment appears again when that next element is printed.

Why the declaration span runs past the element into the following trivia

The parser builds a declaration's span with spanFrom(start), whose end is the current token's offset (internal/core/parser/parser.go:270-276). Token offsets skip trivia (internal/core/parser/parser.go:97-114 records whitespace/comments as pending trivia and never widens a token's span), so a declaration's span ends where the next real token begins — after every blank line and comment written between them.

declarationSpan (internal/repl/print.go:76-91) takes that end verbatim and only extends the start backwards over leading comment trivia, so for

part def Engine { … }

// how heavy it is
part def Vehicle { … }

%print Engine yields the source part def Engine { … }\n\n// how heavy it is\n; strings.TrimSpace in export.SysMLElement (internal/core/export/convert.go:211) removes only the whitespace, leaving the note attached to Engine. %print Vehicle prints the same note again, since LeadingTrivia pulls the start back to it. The existing tests miss this because internal/repl/testdata/vehicle_package.sysml has no comments between members, and TestPrintKeepsComments prints the last member of its package (whose span ends at the closing brace).

A fix would clamp the span's end to the end of the element's last real token (e.g. trim trailing trivia, or record the declaration's end at the last consumed token rather than at the next token's start).

Prompt for agents
`declarationSpan` in internal/repl/print.go uses `sym.Decl.Span()` as the extent of the element to print. Parser spans are built with `spanFrom(start)`, whose end is the offset of the *next* token, and token offsets exclude trivia — so the span reaches past the element's final `;`/`}` over the blank lines and comments that follow, which are exactly the notes belonging to the next declaration. As a result `%print <name>` appends the following element's comment to the printed element, and printing that next element shows the comment a second time (its own leading-trivia walk pulls the start back to it). The fix should bound the printed span at the end of the element's own last non-trivia token: either trim trailing trivia/comment text off the span in `declarationSpan` (the symbol has no trailing-trivia record, so trimming may need to scan the buffer text back to the last non-whitespace, non-comment byte), or make the parser record a declaration's end at the last consumed token (`lastEnd()`), which would need care since many span-based behaviors depend on `spanFrom`.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 0a44bc5. Reproduced against the built REPL first — %print Demo::Engine printed part def Engine { … } followed by the // how heavy it is note belonging to Vehicle, and %print Demo::Vehicle printed it again.

Fixed in export.SysMLElement rather than in the parser, since spanFrom's end-at-next-token semantics is depended on widely: the element's source is now cut at its last token that is neither whitespace nor a comment/note, lexing the slice for that end.

func trimTrailingTrivia(text string) string {
	lx := lexer.New(source.New("element", []byte(text)))
	end := 0
	for tok := lx.Next(); tok.Kind != lexer.EOF; tok = lx.Next() {
		if tok.IsTrivia() || tok.Kind == lexer.RegularComment {
			continue
		}
		end = tok.Span.End()
	}
	return strings.TrimSpace(text[:end])
}

Leading notes are still included (only the end moves), and a span holding nothing but a comment now reports ErrNoNotation instead of writing an empty document. Regression tests: repl/print_test.go:TestPrintStopsBeforeTheNextElementsComment (the note prints with Car and not with Engine) and export/export_test.go:TestSysMLElementDropsTrailingComments. Full gate re-run clean, corpus still 98/100.

A declaration's span ends where the next token begins, so it runs over the blank lines and comments between the two. export.SysMLElement now cuts the source at the element's last real token, so the note belonging to what follows is printed with that element only.

Co-Authored-By: jason.han <jason.han@jpl.nasa.gov>
@HuiJun
HuiJun merged commit 68dab48 into main Aug 17, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant