From a2490ea780a2861529dc20b599df0b8be2224f15 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:40:46 +0000 Subject: [PATCH 1/4] test(lsp): open test documents under the test's own temp directory A document opened under no workspace folder has its own directory indexed, so the tests that opened documents at /tmp/ had the server scan the system temp directory and whatever model tree happened to sit there, and documents named relatively were placed in the package directory. Every helper and test now opens its document in a directory of its own from t.TempDir(); two tests pin that only the document's directory is indexed and that the helpers stay under the test root. Co-Authored-By: jason.han --- internal/frontend/lsp/debug_test.go | 93 +++++++-------- internal/frontend/lsp/handler_test.go | 33 +++--- internal/frontend/lsp/integration_test.go | 4 +- .../lsp/modeledit_cross_document_test.go | 2 +- internal/frontend/lsp/modeledit_test.go | 8 +- .../frontend/lsp/opened_directory_test.go | 109 ++++++++++++++++++ .../lsp/protocol_editor_facts_test.go | 2 +- .../frontend/lsp/protocol_features_test.go | 2 +- internal/frontend/lsp/render_filter_test.go | 8 +- internal/frontend/lsp/render_test.go | 4 +- internal/frontend/lsp/run_test.go | 5 +- internal/frontend/lsp/stdlib_test.go | 7 +- internal/frontend/lsp/sync_test.go | 12 +- internal/frontend/lsp/tempdoc_test.go | 26 +++++ 14 files changed, 226 insertions(+), 89 deletions(-) create mode 100644 internal/frontend/lsp/opened_directory_test.go create mode 100644 internal/frontend/lsp/tempdoc_test.go diff --git a/internal/frontend/lsp/debug_test.go b/internal/frontend/lsp/debug_test.go index 92c1165a49..a8e1c44929 100644 --- a/internal/frontend/lsp/debug_test.go +++ b/internal/frontend/lsp/debug_test.go @@ -133,7 +133,7 @@ func debugServer(t *testing.T, name, src string) (*Server, uri.URI, *debugRecord rec := &debugRecorder{} s.client = rec s.notifier = rec - docURI := uri.File(name) + docURI := testDocURI(t, name) if err := s.DidOpen(context.Background(), &protocol.DidOpenTextDocumentParams{ TextDocument: protocol.TextDocumentItem{URI: docURI, LanguageID: "sysml", Version: 1, Text: src}, }); err != nil { @@ -358,7 +358,7 @@ func TestDebugProtocolShapes(t *testing.T) { // object, a session not live, a node not drawn or a signal not taken is refused // with a typed InvalidParams error. func TestDebugRefusesWhatItCannotRun(t *testing.T) { - s, docURI, _ := debugServer(t, "/w/m.sysml", debugMachine+` + s, docURI, _ := debugServer(t, "m.sysml", debugMachine+` package Other { part def Widget; view widgets : TreeView { expose Other::Widget; } @@ -435,7 +435,7 @@ package Other { // A state machine session reports active states per region, fired transitions // and queued events in render IDs; signals, the clock and breakpoints drive it. func TestDebugStateMachineSession(t *testing.T) { - s, docURI, _ := debugServer(t, "/w/m.sysml", debugMachine) + s, docURI, _ := debugServer(t, "m.sysml", debugMachine) r := render(t, s, docURI, "MachineViews::opsView") id := ids(t, r) edge := func(from, to string) string { return id[from] + "->" + id[to] } @@ -577,7 +577,7 @@ package MachineViews { // An entry transition into a nested state is taken from the start marker of the // body it is written in — the machine's — not that of the state it lands in. func TestDebugEntryTransitionIntoANestedState(t *testing.T) { - s, docURI, _ := debugServer(t, "/w/m.sysml", debugDeep) + s, docURI, _ := debugServer(t, "m.sysml", debugDeep) r := render(t, s, docURI, "MachineViews::deepView") id := ids(t, r) var machineStart string @@ -601,7 +601,7 @@ func TestDebugEntryTransitionIntoANestedState(t *testing.T) { // A continue runs what is due now and holds the clock: an event due later // leaves the machine waiting, at the same instant, until an advance reaches it. func TestDebugContinueHoldsTheClock(t *testing.T) { - s, docURI, _ := debugServer(t, "/w/m.sysml", debugMachine) + s, docURI, _ := debugServer(t, "m.sysml", debugMachine) id := ids(t, render(t, s, docURI, "MachineViews::opsView")) snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{ TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, @@ -670,7 +670,7 @@ package TransientViews { // An advance pauses at a breakpoint state as it becomes active, a state left // again at the same instant included, the clock held at that instant. func TestDebugAdvancePausesOnATransientState(t *testing.T) { - s, docURI, _ := debugServer(t, "/w/t.sysml", debugTransient) + s, docURI, _ := debugServer(t, "t.sysml", debugTransient) id := ids(t, render(t, s, docURI, "TransientViews::descentView")) snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{ TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, @@ -759,7 +759,7 @@ package BViews { // does: the machine's own Go rather than the first Go declared in the document, // and a signal declared in its body; a qualified name still names what it says. func TestDebugSendResolvesSignalInTargetScope(t *testing.T) { - s, docURI, _ := debugServer(t, "/w/s.sysml", debugSameNamedSignals) + s, docURI, _ := debugServer(t, "s.sysml", debugSameNamedSignals) id := ids(t, render(t, s, docURI, "BViews::machineView")) snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{ TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, @@ -792,7 +792,7 @@ func TestDebugSendResolvesSignalInTargetScope(t *testing.T) { // A breakpoint on a pseudostate pauses the run once the dispatch routed through // it completes, the pseudostate reported as where it paused. func TestDebugBreakpointOnAPseudostatePauses(t *testing.T) { - s, docURI, _ := debugServer(t, "/w/j.sysml", debugJunction) + s, docURI, _ := debugServer(t, "j.sysml", debugJunction) id := ids(t, render(t, s, docURI, "RoutingViews::opsView")) snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{ TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, @@ -820,7 +820,7 @@ func TestDebugBreakpointOnAPseudostatePauses(t *testing.T) { // A breakpoint on the machine's `done` pauses the run standing on it, before the // machine completes; the next step completes it. func TestDebugBreakpointOnDonePausesBeforeCompletion(t *testing.T) { - s, docURI, _ := debugServer(t, "/w/h.sysml", `package Halting { + s, docURI, _ := debugServer(t, "h.sysml", `package Halting { attribute def Halt; state def Runner { entry; then idle; @@ -872,7 +872,7 @@ package HaltingViews { // A step that lands a token on an action breakpoint pauses there, as a continue // does; the step after moves it past the breakpoint. func TestDebugStepPausesAtAnActionBreakpoint(t *testing.T) { - s, docURI, _ := debugServer(t, "/w/f.sysml", debugFlow) + s, docURI, _ := debugServer(t, "f.sysml", debugFlow) id := ids(t, render(t, s, docURI, "FlowViews::driveView")) snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{ TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, View: "FlowViews::driveView", Target: "Flows::Drive", @@ -904,7 +904,7 @@ func TestDebugStepPausesAtAnActionBreakpoint(t *testing.T) { // on its fork, returning the session and the rendering's IDs. func pausedAtSplit(t *testing.T) (*Server, string, map[string]string) { t.Helper() - s, docURI, _ := debugServer(t, "/w/f.sysml", debugFlow) + s, docURI, _ := debugServer(t, "f.sysml", debugFlow) id := ids(t, render(t, s, docURI, "FlowViews::driveView")) snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{ TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, View: "FlowViews::driveView", Target: "Flows::Drive", @@ -1004,7 +1004,7 @@ package ReusedViews { // A breakpoint on one drawing of a node a nested flow reuses stops the run at // that occurrence only, not at the same declaration another nested flow runs. func TestDebugBreakpointOnAReusedNodeStopsAtItsOccurrence(t *testing.T) { - s, docURI, _ := debugServer(t, "/w/r.sysml", debugReused) + s, docURI, _ := debugServer(t, "r.sysml", debugReused) r := render(t, s, docURI, "ReusedViews::twiceView") var a, b, lookInA, lookInB string for _, n := range r.Nodes { @@ -1052,7 +1052,7 @@ func TestDebugBreakpointOnAReusedNodeStopsAtItsOccurrence(t *testing.T) { // found on a breakpoint: a token still held at one already stopped at does not // stand in for the one the run has just reached. func TestDebugPausedAtIsTheBreakpointReachedNotTheFirstTokenOnOne(t *testing.T) { - s, docURI, _ := debugServer(t, "/w/f.sysml", debugFlow) + s, docURI, _ := debugServer(t, "f.sysml", debugFlow) id := ids(t, render(t, s, docURI, "FlowViews::driveView")) snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{ TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, View: "FlowViews::driveView", Target: "Flows::Drive", @@ -1115,7 +1115,7 @@ package CountingViews { // already runs, so its timed effect happens once; an object exhibiting it twice // is ambiguous. func TestDebugStartAttachesToThePerformersMachine(t *testing.T) { - s, docURI, _ := debugServer(t, "/w/c.sysml", debugCounter) + s, docURI, _ := debugServer(t, "c.sysml", debugCounter) id := ids(t, render(t, s, docURI, "CountingViews::opsView")) snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{ TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, @@ -1143,7 +1143,7 @@ func TestDebugStartAttachesToThePerformersMachine(t *testing.T) { // the last snapshot read from: each snapshot releases what the one before it // reported, so a long-lived session's record stays bounded while the counts go on. func TestDebugSessionReleasesTheHistoryItReports(t *testing.T) { - s, docURI, _ := debugServer(t, "/w/m.sysml", debugMachine) + s, docURI, _ := debugServer(t, "m.sysml", debugMachine) snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{ TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, View: "MachineViews::opsView", Target: "Machines::Ops", Object: "Machines::Robot", @@ -1165,7 +1165,7 @@ func TestDebugSessionReleasesTheHistoryItReports(t *testing.T) { t.Errorf("after a request firing nothing taken %d, kept %d of %d; want none, none kept, %d counted", len(snap.Taken), len(machine.FiredTransitions()), machine.FiredCount(), fired+1) } - s, docURI, _ = debugServer(t, "/w/f.sysml", debugFlow) + s, docURI, _ = debugServer(t, "f.sysml", debugFlow) snap = mustDebug(t, s, MethodDebugStart, &debugStartParams{ TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, View: "FlowViews::driveView", Target: "Flows::Drive", }) @@ -1186,7 +1186,7 @@ func TestDebugSessionReleasesTheHistoryItReports(t *testing.T) { // A machine's queue shows every message pending in its context, a message no // state of the machine accepts now included: it is in flight all the same. func TestDebugMachineQueueShowsEveryPendingMessage(t *testing.T) { - s, docURI, _ := debugServer(t, "/w/m.sysml", debugMachine) + s, docURI, _ := debugServer(t, "m.sysml", debugMachine) snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{ TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, View: "MachineViews::opsView", Target: "Machines::Ops", Object: "Machines::Robot", @@ -1211,7 +1211,7 @@ func TestDebugMachineQueueShowsEveryPendingMessage(t *testing.T) { // An action session reports every token (forked, nested, parked on the clock or // a signal, held at a join) with the edges it took, in the rendering's IDs. func TestDebugActionSession(t *testing.T) { - s, docURI, _ := debugServer(t, "/w/f.sysml", debugFlow) + s, docURI, _ := debugServer(t, "f.sysml", debugFlow) r := render(t, s, docURI, "FlowViews::driveView") id := ids(t, r) edge := func(from, to string) string { return id[from] + "->" + id[to] } @@ -1320,7 +1320,7 @@ func TestDebugActionSession(t *testing.T) { // An action that fails at a step reports the failure; a run that exceeds its // budget does too, as a failure rather than a hang. func TestDebugActionFailure(t *testing.T) { - s, docURI, _ := debugServer(t, "/w/f.sysml", `package Flows { + s, docURI, _ := debugServer(t, "f.sysml", `package Flows { private import ScalarValues::*; action def Spin { attribute n : Integer = 0; @@ -1372,7 +1372,7 @@ package FlowViews { // An edit leaving the behavior, performer and view as they were moves the session // to the fresh IDs, announced by debugChanged; one to the behavior ends it. func TestDebugSessionFollowsEdits(t *testing.T) { - s, docURI, rec := debugServer(t, "/w/m.sysml", debugMachine) + s, docURI, rec := debugServer(t, "m.sysml", debugMachine) ctx := context.Background() name := uriToName(docURI) r := render(t, s, docURI, "MachineViews::opsView") @@ -1441,7 +1441,7 @@ func TestDebugSessionFollowsEdits(t *testing.T) { // flight finishes at the documents it began with and the next one finds the // session already moved to the edited ones. func TestDebugEditWaitsForTheRequestInFlight(t *testing.T) { - s, docURI, rec := debugServer(t, "/w/m.sysml", debugMachine) + s, docURI, rec := debugServer(t, "m.sysml", debugMachine) name := uriToName(docURI) session := mustDebug(t, s, MethodDebugStart, &debugStartParams{ TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, @@ -1477,7 +1477,7 @@ func TestDebugEditWaitsForTheRequestInFlight(t *testing.T) { // Every snapshot of a session, answered or notified, takes the next revision, so // a client can tell the newer of two that arrive out of order. func TestDebugSnapshotsAreNumbered(t *testing.T) { - s, docURI, rec := debugServer(t, "/w/m.sysml", debugMachine) + s, docURI, rec := debugServer(t, "m.sysml", debugMachine) snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{ TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, View: "MachineViews::opsView", Target: "Machines::Ops", Object: "Machines::Robot", @@ -1520,7 +1520,7 @@ func TestDebugSnapshotsAreNumbered(t *testing.T) { // a machine and for an action paused in a nested flow alike. func TestDebugPauseFollowsEdits(t *testing.T) { t.Run("machine", func(t *testing.T) { - s, docURI, rec := debugServer(t, "/w/m.sysml", debugMachine) + s, docURI, rec := debugServer(t, "m.sysml", debugMachine) id := ids(t, render(t, s, docURI, "#state")) snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{ TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, @@ -1559,7 +1559,7 @@ func TestDebugPauseFollowsEdits(t *testing.T) { wantStrings(t, "active after resuming", snap.ActiveStates, []string{fresh["motion"], fresh["busy"], fresh["working"], fresh["clock"], fresh["waiting"]}) }) t.Run("action", func(t *testing.T) { - s, docURI, rec := debugServer(t, "/w/f.sysml", debugFlow) + s, docURI, rec := debugServer(t, "f.sysml", debugFlow) id := ids(t, render(t, s, docURI, "#action")) snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{ TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, View: "#action", Target: "Flows::Drive", @@ -1602,7 +1602,7 @@ func TestDebugPauseFollowsEdits(t *testing.T) { // session to the fresh IDs before it answers; one to the behavior — of the same // length, so every span stays put — refuses the start. func TestDebugStartSeesAnEditSinceItsReading(t *testing.T) { - s, docURI, rec := debugServer(t, "/w/m.sysml", debugMachine) + s, docURI, rec := debugServer(t, "m.sysml", debugMachine) ctx := context.Background() name := uriToName(docURI) params := &debugStartParams{ @@ -1692,7 +1692,7 @@ func TestDebugSessionEnds(t *testing.T) { } t.Run("performer edited", func(t *testing.T) { - s, docURI, rec := debugServer(t, "/w/m.sysml", debugMachine) + s, docURI, rec := debugServer(t, "m.sysml", debugMachine) session := start(t, s, docURI) s.applyDidChange(ctx, uriToName(docURI), []rawContentChange{{Text: strings.Replace(debugMachine, "exhibit state ops : Ops;", "exhibit state ops : Ops;\n\t\tattribute id : Integer;", 1)}}, 2) if reason := lastEnd(t, rec, session).Reason; !strings.Contains(reason, "Machines::Robot was edited") { @@ -1700,7 +1700,7 @@ func TestDebugSessionEnds(t *testing.T) { } }) t.Run("view removed", func(t *testing.T) { - s, docURI, rec := debugServer(t, "/w/m.sysml", debugMachine) + s, docURI, rec := debugServer(t, "m.sysml", debugMachine) session := start(t, s, docURI) s.applyDidChange(ctx, uriToName(docURI), []rawContentChange{{Text: strings.Replace(debugMachine, "view opsView : StateTransitionView { expose Machines::Ops; }", "", 1)}}, 2) if reason := lastEnd(t, rec, session).Reason; !strings.Contains(reason, "MachineViews::opsView") { @@ -1708,7 +1708,7 @@ func TestDebugSessionEnds(t *testing.T) { } }) t.Run("view rewritten but still drawing the target", func(t *testing.T) { - s, docURI, rec := debugServer(t, "/w/m.sysml", debugMachine) + s, docURI, rec := debugServer(t, "m.sysml", debugMachine) session := start(t, s, docURI) s.applyDidChange(ctx, uriToName(docURI), []rawContentChange{{Text: strings.Replace(debugMachine, "{ expose Machines::Ops; }", "{ expose Machines::Ops; expose Machines::Robot; }", 1)}}, 2) if reason := lastEnd(t, rec, session).Reason; !strings.Contains(reason, "MachineViews::opsView was edited") { @@ -1716,7 +1716,7 @@ func TestDebugSessionEnds(t *testing.T) { } }) t.Run("pseudo-view outlives edits around the target", func(t *testing.T) { - s, docURI, rec := debugServer(t, "/w/m.sysml", debugMachine) + s, docURI, rec := debugServer(t, "m.sysml", debugMachine) snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{ TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, View: "#state:Machines::Ops", Target: "Machines::Ops", }) @@ -1731,7 +1731,7 @@ func TestDebugSessionEnds(t *testing.T) { } }) t.Run("document closed", func(t *testing.T) { - s, docURI, rec := debugServer(t, "/w/m.sysml", debugMachine) + s, docURI, rec := debugServer(t, "m.sysml", debugMachine) session := start(t, s, docURI) if err := s.DidClose(ctx, &protocol.DidCloseTextDocumentParams{TextDocument: protocol.TextDocumentIdentifier{URI: docURI}}); err != nil { t.Fatal(err) @@ -1762,7 +1762,7 @@ func TestDebugSessionEnds(t *testing.T) { } }) t.Run("server shut down", func(t *testing.T) { - s, docURI, rec := debugServer(t, "/w/m.sysml", debugMachine) + s, docURI, rec := debugServer(t, "m.sysml", debugMachine) first, second := start(t, s, docURI), start(t, s, docURI) if err := s.Shutdown(ctx); err != nil { t.Fatal(err) @@ -1850,7 +1850,7 @@ func TestDebugSessionEndsWhenInheritedContentChanges(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - s, docURI, rec := debugServer(t, "/w/i.sysml", debugDerived) + s, docURI, rec := debugServer(t, "i.sysml", debugDerived) snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{ TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, View: tc.view, Target: tc.target, }) @@ -1865,7 +1865,7 @@ func TestDebugSessionEndsWhenInheritedContentChanges(t *testing.T) { } // An edit elsewhere in the definition's package leaves the session running. - s, docURI, rec := debugServer(t, "/w/i.sysml", debugDerived) + s, docURI, rec := debugServer(t, "i.sysml", debugDerived) snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{ TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, View: "InheritedViews::derivedView", Target: "Inherited::Sub::Derived", }) @@ -1882,9 +1882,10 @@ func TestDebugTargetDeclaredInAnotherDocument(t *testing.T) { ctx := context.Background() split := strings.Index(debugMachine, "package MachineViews") machines, views := debugMachine[:split], debugMachine[split:] + dir := t.TempDir() open := func(t *testing.T, s *Server, name, src string) uri.URI { t.Helper() - docURI := uri.File(name) + docURI := uri.File(filepath.Join(dir, name)) if err := s.DidOpen(ctx, &protocol.DidOpenTextDocumentParams{ TextDocument: protocol.TextDocumentItem{URI: docURI, LanguageID: "sysml", Version: 1, Text: src}, }); err != nil { @@ -1892,9 +1893,9 @@ func TestDebugTargetDeclaredInAnotherDocument(t *testing.T) { } return docURI } - s, viewsURI, rec := debugServer(t, "/w/views.sysml", views) - machinesURI := open(t, s, "/w/machines.sysml", machines) - open(t, s, "/w/robots.sysml", "package Robots { part def Rover { exhibit state ops : Machines::Ops; } }\n") + s, viewsURI, rec := debugServer(t, "views.sysml", views) + machinesURI := open(t, s, "machines.sysml", machines) + open(t, s, "robots.sysml", "package Robots { part def Rover { exhibit state ops : Machines::Ops; } }\n") r := render(t, s, viewsURI, "MachineViews::opsView") id := ids(t, r) @@ -1938,12 +1939,12 @@ func TestDebugTargetDeclaredInAnotherDocument(t *testing.T) { } // The same name declared by two documents names neither. - open(t, s, "/w/machines.sysml", machines) - open(t, s, "/w/spare.sysml", machines) + open(t, s, "machines.sysml", machines) + spareURI := open(t, s, "spare.sysml", machines) _, err := s.DebugStart(&debugStartParams{ TextDocument: protocol.TextDocumentIdentifier{URI: viewsURI}, View: "MachineViews::opsView", Target: "Machines::Ops", }) - if !errors.Is(err, ErrDebugTarget) || !strings.Contains(err.Error(), "/w/machines.sysml, /w/spare.sysml") { + if !errors.Is(err, ErrDebugTarget) || !strings.Contains(err.Error(), machinesURI.Filename()+", "+spareURI.Filename()) { t.Fatalf("start of an ambiguous target: err = %v, want %v naming both documents", err, ErrDebugTarget) } var wire *jsonrpc2.Error @@ -2082,7 +2083,7 @@ func TestDebugSessionEndsWhenWhatItReadsChanges(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - s, docURI, rec := debugServer(t, "/w/d.sysml", debugReads) + s, docURI, rec := debugServer(t, "d.sysml", debugReads) snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{ TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, View: tc.view, Target: tc.target, Object: tc.object, }) @@ -2097,7 +2098,7 @@ func TestDebugSessionEndsWhenWhatItReadsChanges(t *testing.T) { } t.Run("signal a bare trigger names", func(t *testing.T) { - s, docURI, rec := debugServer(t, "/w/m.sysml", debugMachine) + s, docURI, rec := debugServer(t, "m.sysml", debugMachine) snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{ TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, View: "MachineViews::opsView", Target: "Machines::Ops", }) @@ -2109,7 +2110,7 @@ func TestDebugSessionEndsWhenWhatItReadsChanges(t *testing.T) { }) t.Run("value a send named", func(t *testing.T) { - s, docURI, rec := debugServer(t, "/w/d.sysml", debugReads) + s, docURI, rec := debugServer(t, "d.sysml", debugReads) snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{ TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, View: "DepViews::opsView", Target: "Deps::Ops", }) @@ -2128,7 +2129,7 @@ func TestDebugSessionEndsWhenWhatItReadsChanges(t *testing.T) { t.Errorf("step after the end: %v, want %v", err, ErrDebugSession) } - s, docURI, rec = debugServer(t, "/w/d.sysml", debugReads) + s, docURI, rec = debugServer(t, "d.sysml", debugReads) snap = mustDebug(t, s, MethodDebugStart, &debugStartParams{ TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, View: "DepViews::opsView", Target: "Deps::Ops", }) @@ -2155,7 +2156,7 @@ func TestDebugSessionEndsWhenWhatItReadsChanges(t *testing.T) { } for _, tc := range unread { t.Run(tc.name, func(t *testing.T) { - s, docURI, rec := debugServer(t, "/w/d.sysml", debugReads) + s, docURI, rec := debugServer(t, "d.sysml", debugReads) snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{ TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, View: "DepViews::opsView", Target: "Deps::Ops", Object: "Deps::Bots::Robot", }) @@ -2175,7 +2176,7 @@ func TestDebugSessionEndsWhenWhatItReadsChanges(t *testing.T) { // Sessions run in runtimes of their own: two over one machine do not share // state, and rendering the view they draw on is unchanged by their running. func TestDebugSessionsAreIsolated(t *testing.T) { - s, docURI, _ := debugServer(t, "/w/m.sysml", debugMachine) + s, docURI, _ := debugServer(t, "m.sysml", debugMachine) plain := render(t, s, docURI, "MachineViews::opsView") params := &debugStartParams{ TextDocument: protocol.TextDocumentIdentifier{URI: docURI}, diff --git a/internal/frontend/lsp/handler_test.go b/internal/frontend/lsp/handler_test.go index 6f2361d314..9d389545b7 100644 --- a/internal/frontend/lsp/handler_test.go +++ b/internal/frontend/lsp/handler_test.go @@ -12,13 +12,12 @@ import ( "time" "github.com/Open-MBEE/OpenSysML/internal/workspace/model" + "go.lsp.dev/uri" ) // These tests drive textDocument/didChange with the framed JSON an editor sends, // where an omitted range (full replace) and a {0,0}-{0,0} range (insert) differ. -const wireURI = "file:///tmp/wire.sysml" - const wireSource = "package Vehicles {\n part def Wheel;\n part w : Wheel;\n}\n" // wireSession is a served Server reached only through its framed stream; @@ -26,6 +25,7 @@ const wireSource = "package Vehicles {\n part def Wheel;\n part w : Wheel; type wireSession struct { t *testing.T s *Server + uri string client net.Conn msgs chan map[string]any readErr chan error @@ -55,6 +55,7 @@ func newWireSession(t *testing.T) *wireSession { w := &wireSession{ t: t, s: s, + uri: string(tempDocURI(t, "wire.sysml")), client: client, msgs: make(chan map[string]any, 256), readErr: make(chan error, 1), @@ -155,42 +156,42 @@ func (w *wireSession) next(what string, want func(map[string]any) bool) map[stri } } -// open initializes the session and opens wireURI with text, consuming its diagnostics. +// open initializes the session and opens w.uri with text, consuming its diagnostics. func (w *wireSession) open(text string) { w.t.Helper() w.call("initialize", `{"capabilities":{}}`) w.notify("initialized", `{}`) w.notify("textDocument/didOpen", fmt.Sprintf( - `{"textDocument":{"uri":%q,"languageId":"sysml","version":1,"text":%s}}`, wireURI, jsonString(text))) - if diags := w.awaitDiagnostics(wireURI); len(diags) != 0 { + `{"textDocument":{"uri":%q,"languageId":"sysml","version":1,"text":%s}}`, w.uri, jsonString(text))) + if diags := w.awaitDiagnostics(w.uri); len(diags) != 0 { w.t.Fatalf("didOpen published %v, want a clean document to start from", diags) } } -// didChange sends the given JSON change objects verbatim for wireURI and +// didChange sends the given JSON change objects verbatim for w.uri and // returns the diagnostics published for the result. func (w *wireSession) didChange(version int, changes ...string) []any { w.t.Helper() w.notify("textDocument/didChange", fmt.Sprintf( `{"textDocument":{"uri":%q,"version":%d},"contentChanges":[%s]}`, - wireURI, version, strings.Join(changes, ","))) - return w.awaitDiagnostics(wireURI) + w.uri, version, strings.Join(changes, ","))) + return w.awaitDiagnostics(w.uri) } -// content is the text the workspace holds for wireURI. +// content is the text the workspace holds for w.uri. func (w *wireSession) content() string { w.t.Helper() - doc := w.s.ws.Document(uriToName(wireURI)) + doc := w.s.ws.Document(uriToName(uri.URI(w.uri))) if doc == nil { - w.t.Fatalf("%s is not in the workspace", wireURI) + w.t.Fatalf("%s is not in the workspace", w.uri) } return string(doc.Content) } -// symbolNames returns the top-level documentSymbol names of wireURI. +// symbolNames returns the top-level documentSymbol names of w.uri. func (w *wireSession) symbolNames() []string { w.t.Helper() - result := w.call("textDocument/documentSymbol", fmt.Sprintf(`{"textDocument":{"uri":%q}}`, wireURI)) + result := w.call("textDocument/documentSymbol", fmt.Sprintf(`{"textDocument":{"uri":%q}}`, w.uri)) symbols, ok := result.([]any) if !ok { w.t.Fatalf("documentSymbol result = %v, want an array", result) @@ -208,7 +209,7 @@ func (w *wireSession) symbolNames() []string { func (w *wireSession) hoverText(line, character int) string { w.t.Helper() result := w.call("textDocument/hover", fmt.Sprintf( - `{"textDocument":{"uri":%q},"position":{"line":%d,"character":%d}}`, wireURI, line, character)) + `{"textDocument":{"uri":%q},"position":{"line":%d,"character":%d}}`, w.uri, line, character)) if result == nil { return "" } @@ -319,7 +320,7 @@ func TestWireDidChangeMixedBatchAppliesInOrder(t *testing.T) { if got := w.content(); got != want { t.Errorf("content = %q, want %q", got, want) } - result := w.call("textDocument/documentSymbol", fmt.Sprintf(`{"textDocument":{"uri":%q}}`, wireURI)) + result := w.call("textDocument/documentSymbol", fmt.Sprintf(`{"textDocument":{"uri":%q}}`, w.uri)) symbols, _ := result.([]any) if len(symbols) != 1 { t.Fatalf("documentSymbol = %v, want the one package Final", result) @@ -384,7 +385,7 @@ func TestWireRangeFormattingAnswersEditsOnSelectedLines(t *testing.T) { request := func(startLine, endLine int) []any { result := w.call("textDocument/rangeFormatting", fmt.Sprintf( `{"textDocument":{"uri":%q},"range":{"start":{"line":%d,"character":0},"end":{"line":%d,"character":0}},"options":{"tabSize":4,"insertSpaces":true}}`, - wireURI, startLine, endLine)) + w.uri, startLine, endLine)) if result == nil { return nil } diff --git a/internal/frontend/lsp/integration_test.go b/internal/frontend/lsp/integration_test.go index 6904fbaf00..e0b99709f7 100644 --- a/internal/frontend/lsp/integration_test.go +++ b/internal/frontend/lsp/integration_test.go @@ -6,7 +6,6 @@ import ( "testing" "go.lsp.dev/protocol" - "go.lsp.dev/uri" "github.com/Open-MBEE/OpenSysML/internal/workspace/model" ) @@ -29,9 +28,8 @@ func TestLSPEndToEnd(t *testing.T) { } // Open a document. - name := "e2e.sysml" src := "package P { namespace N; }\nimport P::N;\n" - docURI := uri.File(name) + docURI := tempDocURI(t, "e2e.sysml") if err := s.DidOpen(ctx, &protocol.DidOpenTextDocumentParams{ TextDocument: protocol.TextDocumentItem{ URI: docURI, diff --git a/internal/frontend/lsp/modeledit_cross_document_test.go b/internal/frontend/lsp/modeledit_cross_document_test.go index 12d4951eb1..c86c30e44a 100644 --- a/internal/frontend/lsp/modeledit_cross_document_test.go +++ b/internal/frontend/lsp/modeledit_cross_document_test.go @@ -21,7 +21,7 @@ const ( func engineWorkspace(t *testing.T) (s *Server, viewsURI, partsURI uri.URI) { t.Helper() s, viewsURI = renderServer(t, "views.sysml", engineViews) - partsURI = uri.File("parts.sysml") + partsURI = tempDocURI(t, "parts.sysml") openDoc(t, s, partsURI, engineParts) return s, viewsURI, partsURI } diff --git a/internal/frontend/lsp/modeledit_test.go b/internal/frontend/lsp/modeledit_test.go index e48047d430..500ab8314a 100644 --- a/internal/frontend/lsp/modeledit_test.go +++ b/internal/frontend/lsp/modeledit_test.go @@ -513,7 +513,7 @@ func TestApplyModelEditRefusesImpossibleMoves(t *testing.T) { t.Errorf("clashing move = %+v, want member-name-taken", out) } s, docURI = renderServer(t, "vehicle.sysml", editModel) - fleetURI := uri.File("fleet.sysml") + fleetURI := tempDocURI(t, "fleet.sysml") openDoc(t, s, fleetURI, "package Fleet {\n part truck : Vehicle::Car;\n}\n") out = applyModelEdit(t, s, docURI, 1, ModelEditOperation{Kind: EditMove, Target: "Vehicle::Car", Owner: "Vehicle::Tank"}) if len(out.Refused) != 1 || out.Refused[0].Failure != "referenced-elsewhere" { @@ -553,7 +553,7 @@ const fleetModel = "package Fleet {\n part truck : Vehicle::Car;\n part va // the version the server holds for it. func TestApplyModelEditRenameRespellsAnotherDocument(t *testing.T) { s, docURI := renderServer(t, "vehicle.sysml", editModel) - fleetURI := uri.File("fleet.sysml") + fleetURI := tempDocURI(t, "fleet.sysml") openDoc(t, s, fleetURI, fleetModel) op := ModelEditOperation{Kind: EditRename, Target: "Vehicle::Car", NewName: "Auto"} out := applyModelEdit(t, s, docURI, 1, op) @@ -581,7 +581,7 @@ func TestApplyModelEditRenameRespellsAnotherDocument(t *testing.T) { // without cascade it is refused, naming them by document. func TestApplyModelEditDeleteCascadesIntoAnotherDocument(t *testing.T) { s, docURI := renderServer(t, "vehicle.sysml", editModel) - fleetURI := uri.File("fleet.sysml") + fleetURI := tempDocURI(t, "fleet.sysml") openDoc(t, s, fleetURI, fleetModel) out := applyModelEdit(t, s, docURI, 1, ModelEditOperation{Kind: EditDelete, Target: "Vehicle::Car"}) @@ -622,7 +622,7 @@ func TestApplyModelEditDeleteCascadesIntoAnotherDocument(t *testing.T) { // document read from disk, which no client versions, carries none. func TestApplyModelEditVersionsOtherDocumentsAsHeld(t *testing.T) { s, docURI := renderServer(t, "vehicle.sysml", editModel) - fleetURI := uri.File("fleet.sysml") + fleetURI := tempDocURI(t, "fleet.sysml") openDoc(t, s, fleetURI, fleetModel) sendDidChange(t, s, fleetURI, 3, []json.RawMessage{json.RawMessage(`{"text":"package Fleet {\n part van : Vehicle::Car;\n}\n"}`)}) depotName := uri.File("depot.sysml").Filename() diff --git a/internal/frontend/lsp/opened_directory_test.go b/internal/frontend/lsp/opened_directory_test.go new file mode 100644 index 0000000000..1fef66f486 --- /dev/null +++ b/internal/frontend/lsp/opened_directory_test.go @@ -0,0 +1,109 @@ +package lsp + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "go.lsp.dev/protocol" + "go.lsp.dev/uri" + + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" +) + +// A document opened under no folder has its own directory indexed and nothing +// above it: a model file in the parent directory stays out of the workspace. +func TestOpenedDocumentIndexesOnlyItsOwnDirectory(t *testing.T) { + root := t.TempDir() + stray := filepath.Join(root, "stray.sysml") + if err := os.WriteFile(stray, []byte("package Stray;\n"), 0o600); err != nil { + t.Fatal(err) + } + dir := filepath.Join(root, "proj") + if err := os.MkdirAll(dir, 0o750); err != nil { + t.Fatal(err) + } + sibling := filepath.Join(dir, "sibling.sysml") + if err := os.WriteFile(sibling, []byte("package Sibling;\n"), 0o600); err != nil { + t.Fatal(err) + } + + s := NewServer(model.NewWorkspace()) + docURI := uri.File(filepath.Join(dir, "doc.sysml")) + openDoc(t, s, docURI, "package P;\n") + + names := s.ws.DocumentNames() + if !containsName(names, sibling) { + t.Errorf("sibling %s not indexed; documents = %v", sibling, names) + } + if containsName(names, stray) { + t.Errorf("%s in the parent directory was indexed; documents = %v", stray, names) + } + if len(s.openDirs) != 1 || !s.openDirs[dir] { + t.Errorf("indexed directories = %v, want only %s", s.openDirs, dir) + } +} + +// The test helpers that open a document through the server place it in a +// directory of the test's own: a stray model file in the system temp directory +// is never indexed, and no directory outside the test's root is. +func TestOpenHelpersStayUnderTheTestRoot(t *testing.T) { + stray := filepath.Join(os.TempDir(), "opensysml-lsp-stray-guard.sysml") + if err := os.WriteFile(stray, []byte("package OpenSysMLStrayGuard;\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Remove(stray) }) + testRoot := filepath.Dir(t.TempDir()) + + const src = "package P { part def Wheel; }\n" + check := func(what string, s *Server) { + t.Helper() + if names := s.ws.DocumentNames(); containsName(names, stray) { + t.Errorf("%s indexed %s outside the test root; documents = %v", what, stray, names) + } + if len(s.openDirs) == 0 { + t.Errorf("%s indexed no directory for the document it opened", what) + } + for dir := range s.openDirs { + if !underFolder(dir, testRoot) { + t.Errorf("%s indexed %s, outside the test root %s", what, dir, testRoot) + } + } + } + + w := newWireSession(t) + w.open(src) + if !strings.HasPrefix(uriToName(uri.URI(w.uri)), testRoot+string(filepath.Separator)) { + t.Errorf("wire session document %s is outside the test root %s", w.uri, testRoot) + } + check("wireSession.open", w.s) + + s := NewServer(model.NewWorkspace()) + openDoc(t, s, tempDocURI(t, "a.sysml"), src) + check("openDoc(tempDocURI)", s) + + s, _ = renderServer(t, "kit.sysml", src) + check("renderServer", s) + + s, _, _ = debugServer(t, "m.sysml", src) + check("debugServer", s) + + s = NewServer(model.NewWorkspace()) + if err := s.DidOpen(context.Background(), &protocol.DidOpenTextDocumentParams{ + TextDocument: protocol.TextDocumentItem{URI: tempDocURI(t, "b.sysml"), LanguageID: "sysml", Version: 1, Text: src}, + }); err != nil { + t.Fatalf("DidOpen err = %v", err) + } + check("DidOpen(tempDocURI)", s) +} + +func containsName(names []string, name string) bool { + for _, n := range names { + if n == name { + return true + } + } + return false +} diff --git a/internal/frontend/lsp/protocol_editor_facts_test.go b/internal/frontend/lsp/protocol_editor_facts_test.go index 10c24975bf..299d8018c3 100644 --- a/internal/frontend/lsp/protocol_editor_facts_test.go +++ b/internal/frontend/lsp/protocol_editor_facts_test.go @@ -21,7 +21,7 @@ func TestRunServesSignatureHelpCodeLensAndInlayHints(t *testing.T) { done := make(chan error, 1) go func() { done <- s.Run(context.Background(), server) }() - const file = "file:///tmp/protocol_editor_facts.sysml" + file := string(tempDocURI(t, "protocol_editor_facts.sysml")) doc := map[string]any{"uri": file} callPos := positionAfter(t, editorFactsModel, "fell = Fall(20.0, 9.") lines := uint32(len(editorFactsModel)) diff --git a/internal/frontend/lsp/protocol_features_test.go b/internal/frontend/lsp/protocol_features_test.go index 12e91ad0c7..dfdaf68b16 100644 --- a/internal/frontend/lsp/protocol_features_test.go +++ b/internal/frontend/lsp/protocol_features_test.go @@ -24,7 +24,7 @@ func TestRunServesSemanticTokensAndCodeAction(t *testing.T) { go func() { done <- s.Run(context.Background(), server) }() const src = "package P {\n part def Wheel;\n part w : Wheeel;\n}\n" - const file = "file:///tmp/protocol_features.sysml" + file := string(tempDocURI(t, "protocol_features.sysml")) doc := map[string]any{"uri": file} requests := []map[string]any{ {"jsonrpc": "2.0", "id": 1, "method": "initialize", diff --git a/internal/frontend/lsp/render_filter_test.go b/internal/frontend/lsp/render_filter_test.go index 78e22e0c15..2e55b03b90 100644 --- a/internal/frontend/lsp/render_filter_test.go +++ b/internal/frontend/lsp/render_filter_test.go @@ -15,7 +15,7 @@ import ( func TestRenderFiltersByAMetaclassDeclaredByTwoDocuments(t *testing.T) { s := initializedServer(t, map[string]any{CrossDocumentCapability: true}) open := func(name, src string) uri.URI { - docURI := uri.File(name) + docURI := tempDocURI(t, name) if err := s.DidOpen(context.Background(), &protocol.DidOpenTextDocumentParams{ TextDocument: protocol.TextDocumentItem{URI: docURI, LanguageID: "sysml", Version: 1, Text: src}, }); err != nil { @@ -24,9 +24,9 @@ func TestRenderFiltersByAMetaclassDeclaredByTwoDocuments(t *testing.T) { return docURI } const metaclassCopy = `package SysML { package Systems { metadata def PartUsage; } }` - open("/ws/lib_one.sysml", metaclassCopy) - open("/ws/lib_two.sysml", metaclassCopy) - docURI := open("/ws/interconnection.sysml", ` + open("lib_one.sysml", metaclassCopy) + open("lib_two.sysml", metaclassCopy) + docURI := open("interconnection.sysml", ` package Model { part abcd { part a { port a; } diff --git a/internal/frontend/lsp/render_test.go b/internal/frontend/lsp/render_test.go index a0afd92680..5e7a510472 100644 --- a/internal/frontend/lsp/render_test.go +++ b/internal/frontend/lsp/render_test.go @@ -139,7 +139,7 @@ func (r *recorder) all() []string { func renderServer(t *testing.T, name, src string) (*Server, uri.URI) { t.Helper() s := initializedServer(t, map[string]any{CrossDocumentCapability: true}) - docURI := uri.File(name) + docURI := testDocURI(t, name) if err := s.DidOpen(context.Background(), &protocol.DidOpenTextDocumentParams{ TextDocument: protocol.TextDocumentItem{ URI: docURI, LanguageID: "sysml", Version: 1, Text: src, @@ -1173,7 +1173,7 @@ func TestDidChangeNotifiesRenderChangedAfterDiagnostics(t *testing.T) { s.client = rec s.notifier = rec ctx := context.Background() - docURI := uri.File("kit.sysml") + docURI := tempDocURI(t, "kit.sysml") if err := s.DidOpen(ctx, &protocol.DidOpenTextDocumentParams{ TextDocument: protocol.TextDocumentItem{ URI: docURI, LanguageID: "sysml", Version: 1, Text: "package Kit {\n\tpart def Widget;\n}\n", diff --git a/internal/frontend/lsp/run_test.go b/internal/frontend/lsp/run_test.go index 9dfaed5ce1..9a62ca9c1d 100644 --- a/internal/frontend/lsp/run_test.go +++ b/internal/frontend/lsp/run_test.go @@ -122,6 +122,7 @@ func TestRunServesEveryRequestOverOneStream(t *testing.T) { // Writes run concurrently with reads: net.Pipe is unbuffered, so the // server's diagnostics notifications must be drained as they are sent. const requests = 25 + file := string(tempDocURI(t, "run.sysml")) writeErr := make(chan error, 1) go func() { messages := []any{ @@ -133,7 +134,7 @@ func TestRunServesEveryRequestOverOneStream(t *testing.T) { map[string]any{ "jsonrpc": "2.0", "method": "textDocument/didOpen", "params": map[string]any{"textDocument": map[string]any{ - "uri": "file:///tmp/run.sysml", "languageId": "sysml", "version": 1, + "uri": file, "languageId": "sysml", "version": 1, "text": "package P {\n part def Wheel { attribute pressure; }\n part w : Wheel;\n}\n", }}, }, @@ -142,7 +143,7 @@ func TestRunServesEveryRequestOverOneStream(t *testing.T) { messages = append(messages, map[string]any{ "jsonrpc": "2.0", "id": id, "method": "textDocument/completion", "params": map[string]any{ - "textDocument": map[string]any{"uri": "file:///tmp/run.sysml"}, + "textDocument": map[string]any{"uri": file}, "position": map[string]any{"line": 2, "character": 18}, }, }) diff --git a/internal/frontend/lsp/stdlib_test.go b/internal/frontend/lsp/stdlib_test.go index 1127b70ef8..316c8f4fbf 100644 --- a/internal/frontend/lsp/stdlib_test.go +++ b/internal/frontend/lsp/stdlib_test.go @@ -416,17 +416,18 @@ func TestRunServesLibraryDocumentsOverStream(t *testing.T) { send(map[string]any{"jsonrpc": "2.0", "method": "initialized", "params": map[string]any{}}) src := "package P { attribute x : ScalarValues::Integer; }\n" + file := string(tempDocURI(t, "stream.sysml")) send(map[string]any{ "jsonrpc": "2.0", "method": "textDocument/didOpen", "params": map[string]any{"textDocument": map[string]any{ - "uri": "file:///tmp/stream.sysml", "languageId": "sysml", "version": 1, "text": src, + "uri": file, "languageId": "sysml", "version": 1, "text": src, }}, }) pos := offsetToPosition([]byte(src), strings.Index(src, "Integer")) send(map[string]any{ "jsonrpc": "2.0", "id": 2, "method": "textDocument/definition", "params": map[string]any{ - "textDocument": map[string]any{"uri": "file:///tmp/stream.sysml"}, + "textDocument": map[string]any{"uri": file}, "position": map[string]any{"line": pos.Line, "character": pos.Character}, }, }) @@ -451,7 +452,7 @@ func TestRunServesLibraryDocumentsOverStream(t *testing.T) { t.Errorf("%s returned %d bytes, want the bundled ScalarValues.kerml (%d bytes)", MethodStdlibContent, len(got), len(text)) } - send(map[string]any{"jsonrpc": "2.0", "id": 4, "method": MethodStdlibContent, "params": map[string]any{"uri": "file:///tmp/stream.sysml"}}) + send(map[string]any{"jsonrpc": "2.0", "id": 4, "method": MethodStdlibContent, "params": map[string]any{"uri": file}}) res = await(4, nil) if res["error"] == nil { t.Errorf("%s on a file URI answered %v, want an error", MethodStdlibContent, res["result"]) diff --git a/internal/frontend/lsp/sync_test.go b/internal/frontend/lsp/sync_test.go index 04500aa43a..0fa4235ec9 100644 --- a/internal/frontend/lsp/sync_test.go +++ b/internal/frontend/lsp/sync_test.go @@ -15,7 +15,7 @@ import ( func TestDidOpenRegistersDocument(t *testing.T) { s := NewServer(model.NewWorkspace()) - u := uri.File("/tmp/a.sysml") + u := tempDocURI(t, "a.sysml") err := s.DidOpen(context.Background(), &protocol.DidOpenTextDocumentParams{ TextDocument: protocol.TextDocumentItem{ URI: u, @@ -69,7 +69,7 @@ func openDoc(t *testing.T, s *Server, u uri.URI, text string) { func TestDidChangeFullReplace(t *testing.T) { s := NewServer(model.NewWorkspace()) - u := uri.File("/tmp/a.sysml") + u := tempDocURI(t, "a.sysml") openDoc(t, s, u, "package P;\n") // A full-document replace omits "range" entirely. sendDidChange(t, s, u, 2, []json.RawMessage{ @@ -82,7 +82,7 @@ func TestDidChangeFullReplace(t *testing.T) { func TestDidChangeIncremental(t *testing.T) { s := NewServer(model.NewWorkspace()) - u := uri.File("/tmp/a.sysml") + u := tempDocURI(t, "a.sysml") openDoc(t, s, u, "package P;\n") // Replace the single character 'P' (line 0, chars 8..9) with "Hello". sendDidChange(t, s, u, 2, []json.RawMessage{ @@ -98,7 +98,7 @@ func TestDidChangeIncremental(t *testing.T) { // front instead of wiping the buffer. func TestDidChangeIncrementalInsertAtStart(t *testing.T) { s := NewServer(model.NewWorkspace()) - u := uri.File("/tmp/a.sysml") + u := tempDocURI(t, "a.sysml") openDoc(t, s, u, "package P;\n") sendDidChange(t, s, u, 2, []json.RawMessage{ json.RawMessage(`{"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":0}},"text":"X"}`), @@ -112,7 +112,7 @@ func TestDidChangeIncrementalInsertAtStart(t *testing.T) { // start must apply all edits sequentially against the accumulating buffer. func TestDidChangeMultiChangeStartingAtOffsetZero(t *testing.T) { s := NewServer(model.NewWorkspace()) - u := uri.File("/tmp/a.sysml") + u := tempDocURI(t, "a.sysml") openDoc(t, s, u, "abc\n") sendDidChange(t, s, u, 2, []json.RawMessage{ json.RawMessage(`{"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":0}},"text":"X"}`), @@ -127,7 +127,7 @@ func TestDidChangeMultiChangeStartingAtOffsetZero(t *testing.T) { // UTF-16 columns; the byte offset must skip the 4-byte emoji correctly. func TestDidChangeIncrementalAstral(t *testing.T) { s := NewServer(model.NewWorkspace()) - u := uri.File("/tmp/a.sysml") + u := tempDocURI(t, "a.sysml") openDoc(t, s, u, "😀ab\n") // emoji = 2 UTF-16 units; 'a' at char 2, 'b' at char 3 // Replace 'a' (chars 2..3) with "Z". sendDidChange(t, s, u, 2, []json.RawMessage{ diff --git a/internal/frontend/lsp/tempdoc_test.go b/internal/frontend/lsp/tempdoc_test.go new file mode 100644 index 0000000000..dfc6163eb7 --- /dev/null +++ b/internal/frontend/lsp/tempdoc_test.go @@ -0,0 +1,26 @@ +package lsp + +import ( + "path/filepath" + "testing" + + "go.lsp.dev/uri" +) + +// tempDocURI is the URI of a document named name in a directory of the test's +// own, so a test opening it under no folder has only that directory indexed, +// never a shared one such as the system temp directory and what sits there. +func tempDocURI(t testing.TB, name string) uri.URI { + t.Helper() + return uri.File(filepath.Join(t.TempDir(), name)) +} + +// testDocURI is the URI a helper opens name under: an absolute name as given, +// a bare one in a directory of the test's own. +func testDocURI(t testing.TB, name string) uri.URI { + t.Helper() + if filepath.IsAbs(name) { + return uri.File(name) + } + return tempDocURI(t, name) +} From a7d470ec764d0ecfb6c6caafd54cf11b4c7d766e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:47:38 +0000 Subject: [PATCH 2/4] test(lsp): move the cross-document debug target test to its own file debug_test.go is allowlisted at its size in tests/hygiene; the test that debugs a behavior exposed from another document moves to debug_cross_document_test.go and the allowlist records the smaller file. Co-Authored-By: jason.han --- .../frontend/lsp/debug_cross_document_test.go | 90 +++++++++++++++++++ internal/frontend/lsp/debug_test.go | 77 ---------------- tests/hygiene/size_test.go | 2 +- 3 files changed, 91 insertions(+), 78 deletions(-) create mode 100644 internal/frontend/lsp/debug_cross_document_test.go diff --git a/internal/frontend/lsp/debug_cross_document_test.go b/internal/frontend/lsp/debug_cross_document_test.go new file mode 100644 index 0000000000..adc95b63bc --- /dev/null +++ b/internal/frontend/lsp/debug_cross_document_test.go @@ -0,0 +1,90 @@ +package lsp + +import ( + "context" + "errors" + "path/filepath" + "strings" + "testing" + + "go.lsp.dev/jsonrpc2" + "go.lsp.dev/protocol" + "go.lsp.dev/uri" +) + +// A view debugs the behavior it exposes from another document, performed by an +// object from a third; edits there move or end the session, duplicates are refused. +func TestDebugTargetDeclaredInAnotherDocument(t *testing.T) { + ctx := context.Background() + split := strings.Index(debugMachine, "package MachineViews") + machines, views := debugMachine[:split], debugMachine[split:] + dir := t.TempDir() + open := func(t *testing.T, s *Server, name, src string) uri.URI { + t.Helper() + docURI := uri.File(filepath.Join(dir, name)) + if err := s.DidOpen(ctx, &protocol.DidOpenTextDocumentParams{ + TextDocument: protocol.TextDocumentItem{URI: docURI, LanguageID: "sysml", Version: 1, Text: src}, + }); err != nil { + t.Fatalf("DidOpen %s: %v", name, err) + } + return docURI + } + s, viewsURI, rec := debugServer(t, "views.sysml", views) + machinesURI := open(t, s, "machines.sysml", machines) + open(t, s, "robots.sysml", "package Robots { part def Rover { exhibit state ops : Machines::Ops; } }\n") + r := render(t, s, viewsURI, "MachineViews::opsView") + id := ids(t, r) + + snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: viewsURI}, + View: "MachineViews::opsView", Target: "Machines::Ops", Object: "Robots::Rover", + }) + if snap.Target != "Machines::Ops" || snap.Object != "Robots::Rover" || snap.Root != r.Nodes[0].ID { + t.Fatalf("start snapshot = %s", describe(snap)) + } + wantState(t, snap, debugWaiting) + wantStrings(t, "initial active states", snap.ActiveStates, []string{id["motion"], id["idle"], id["clock"], id["waiting"]}) + + // An edit to the machine's document that leaves the machine as declared + // keeps the session running; one rewriting the machine ends it. + s.applyDidChange(ctx, uriToName(machinesURI), []rawContentChange{{Text: strings.Replace(machines, "attribute def Halt;", "attribute def Halt;\n\tattribute def Stop;", 1)}}, 2) + for _, moved := range rec.debugChanged() { + if moved.Session != snap.Session || moved.State == debugEnded { + t.Fatalf("debugChanged after an unrelated edit to the machine's document = %s", describe(moved)) + } + wantStrings(t, "active states after an unrelated edit", moved.ActiveStates, snap.ActiveStates) + } + s.applyDidChange(ctx, uriToName(machinesURI), []rawContentChange{{Text: strings.Replace(machines, "state elapsed;", "state elapsed;\n\t\tstate cooling;", 1)}}, 3) + changed := rec.debugChanged() + last := changed[len(changed)-1] + if last.Session != snap.Session || last.State != debugEnded || !strings.Contains(last.Reason, "Machines::Ops was edited") { + t.Fatalf("debugChanged after the machine was edited = %s", describe(last)) + } + + // Closing the machine's document ends a session on it too. + snap = mustDebug(t, s, MethodDebugStart, &debugStartParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: viewsURI}, View: "MachineViews::opsView", Target: "Machines::Ops", + }) + if err := s.DidClose(ctx, &protocol.DidCloseTextDocumentParams{TextDocument: protocol.TextDocumentIdentifier{URI: machinesURI}}); err != nil { + t.Fatalf("DidClose: %v", err) + } + changed = rec.debugChanged() + last = changed[len(changed)-1] + if last.Session != snap.Session || last.State != debugEnded || !strings.Contains(last.Reason, "machines.sysml was closed") { + t.Fatalf("debugChanged after closing the machine's document = %s", describe(last)) + } + + // The same name declared by two documents names neither. + open(t, s, "machines.sysml", machines) + spareURI := open(t, s, "spare.sysml", machines) + _, err := s.DebugStart(&debugStartParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: viewsURI}, View: "MachineViews::opsView", Target: "Machines::Ops", + }) + if !errors.Is(err, ErrDebugTarget) || !strings.Contains(err.Error(), machinesURI.Filename()+", "+spareURI.Filename()) { + t.Fatalf("start of an ambiguous target: err = %v, want %v naming both documents", err, ErrDebugTarget) + } + var wire *jsonrpc2.Error + if !errors.As(err, &wire) || wire.Code != jsonrpc2.InvalidParams { + t.Errorf("err = %v, want an InvalidParams reply", err) + } +} diff --git a/internal/frontend/lsp/debug_test.go b/internal/frontend/lsp/debug_test.go index a8e1c44929..8a0649000e 100644 --- a/internal/frontend/lsp/debug_test.go +++ b/internal/frontend/lsp/debug_test.go @@ -1876,83 +1876,6 @@ func TestDebugSessionEndsWhenInheritedContentChanges(t *testing.T) { } } -// A view debugs the behavior it exposes from another document, performed by an -// object from a third; edits there move or end the session, duplicates are refused. -func TestDebugTargetDeclaredInAnotherDocument(t *testing.T) { - ctx := context.Background() - split := strings.Index(debugMachine, "package MachineViews") - machines, views := debugMachine[:split], debugMachine[split:] - dir := t.TempDir() - open := func(t *testing.T, s *Server, name, src string) uri.URI { - t.Helper() - docURI := uri.File(filepath.Join(dir, name)) - if err := s.DidOpen(ctx, &protocol.DidOpenTextDocumentParams{ - TextDocument: protocol.TextDocumentItem{URI: docURI, LanguageID: "sysml", Version: 1, Text: src}, - }); err != nil { - t.Fatalf("DidOpen %s: %v", name, err) - } - return docURI - } - s, viewsURI, rec := debugServer(t, "views.sysml", views) - machinesURI := open(t, s, "machines.sysml", machines) - open(t, s, "robots.sysml", "package Robots { part def Rover { exhibit state ops : Machines::Ops; } }\n") - r := render(t, s, viewsURI, "MachineViews::opsView") - id := ids(t, r) - - snap := mustDebug(t, s, MethodDebugStart, &debugStartParams{ - TextDocument: protocol.TextDocumentIdentifier{URI: viewsURI}, - View: "MachineViews::opsView", Target: "Machines::Ops", Object: "Robots::Rover", - }) - if snap.Target != "Machines::Ops" || snap.Object != "Robots::Rover" || snap.Root != r.Nodes[0].ID { - t.Fatalf("start snapshot = %s", describe(snap)) - } - wantState(t, snap, debugWaiting) - wantStrings(t, "initial active states", snap.ActiveStates, []string{id["motion"], id["idle"], id["clock"], id["waiting"]}) - - // An edit to the machine's document that leaves the machine as declared - // keeps the session running; one rewriting the machine ends it. - s.applyDidChange(ctx, uriToName(machinesURI), []rawContentChange{{Text: strings.Replace(machines, "attribute def Halt;", "attribute def Halt;\n\tattribute def Stop;", 1)}}, 2) - for _, moved := range rec.debugChanged() { - if moved.Session != snap.Session || moved.State == debugEnded { - t.Fatalf("debugChanged after an unrelated edit to the machine's document = %s", describe(moved)) - } - wantStrings(t, "active states after an unrelated edit", moved.ActiveStates, snap.ActiveStates) - } - s.applyDidChange(ctx, uriToName(machinesURI), []rawContentChange{{Text: strings.Replace(machines, "state elapsed;", "state elapsed;\n\t\tstate cooling;", 1)}}, 3) - changed := rec.debugChanged() - last := changed[len(changed)-1] - if last.Session != snap.Session || last.State != debugEnded || !strings.Contains(last.Reason, "Machines::Ops was edited") { - t.Fatalf("debugChanged after the machine was edited = %s", describe(last)) - } - - // Closing the machine's document ends a session on it too. - snap = mustDebug(t, s, MethodDebugStart, &debugStartParams{ - TextDocument: protocol.TextDocumentIdentifier{URI: viewsURI}, View: "MachineViews::opsView", Target: "Machines::Ops", - }) - if err := s.DidClose(ctx, &protocol.DidCloseTextDocumentParams{TextDocument: protocol.TextDocumentIdentifier{URI: machinesURI}}); err != nil { - t.Fatalf("DidClose: %v", err) - } - changed = rec.debugChanged() - last = changed[len(changed)-1] - if last.Session != snap.Session || last.State != debugEnded || !strings.Contains(last.Reason, "machines.sysml was closed") { - t.Fatalf("debugChanged after closing the machine's document = %s", describe(last)) - } - - // The same name declared by two documents names neither. - open(t, s, "machines.sysml", machines) - spareURI := open(t, s, "spare.sysml", machines) - _, err := s.DebugStart(&debugStartParams{ - TextDocument: protocol.TextDocumentIdentifier{URI: viewsURI}, View: "MachineViews::opsView", Target: "Machines::Ops", - }) - if !errors.Is(err, ErrDebugTarget) || !strings.Contains(err.Error(), machinesURI.Filename()+", "+spareURI.Filename()) { - t.Fatalf("start of an ambiguous target: err = %v, want %v naming both documents", err, ErrDebugTarget) - } - var wire *jsonrpc2.Error - if !errors.As(err, &wire) || wire.Code != jsonrpc2.InvalidParams { - t.Errorf("err = %v, want an InvalidParams reply", err) - } -} - // Closing the target's or the object's document ends a session even when the // file is on disk, so the workspace goes on holding the document it declares. func TestDebugEndsWhenTargetOrObjectDocumentClosedOnDisk(t *testing.T) { diff --git a/tests/hygiene/size_test.go b/tests/hygiene/size_test.go index f046f46be2..e03be7a372 100644 --- a/tests/hygiene/size_test.go +++ b/tests/hygiene/size_test.go @@ -35,7 +35,7 @@ var oversizeTestFiles = []oversize{ {"internal/exec/runtime/conformance_test.go", 2714, "execution-conformance driver and its per-case assertions"}, {"internal/exec/runtime/signal_test.go", 2491, "signal delivery scenarios on one shared harness"}, {"tests/migrate/behavior_more_test.go", 2465, "SysML v1 behavior migration scenarios on one shared harness"}, - {"internal/frontend/lsp/debug_test.go", 2203, "LSP debug-protocol scenarios over one wire fixture"}, + {"internal/frontend/lsp/debug_test.go", 2127, "LSP debug-protocol scenarios over one wire fixture"}, {"internal/exec/runtime/signal_injection_test.go", 2181, "signal-injection scenarios on one shared harness"}, {"internal/exec/runtime/adopt_test.go", 2163, "adoption scenarios on one shared harness"}, {"internal/doc/queryexec/execute_test.go", 2114, "query-execution scenarios on one shared harness"}, From fd2159b19b925c63963f8381d9076de129b1b196 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:17:55 +0000 Subject: [PATCH 3/4] test(lsp): shorten the temp-document helper comments Co-Authored-By: jason.han --- internal/frontend/lsp/opened_directory_test.go | 5 ++--- internal/frontend/lsp/tempdoc_test.go | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/internal/frontend/lsp/opened_directory_test.go b/internal/frontend/lsp/opened_directory_test.go index 1fef66f486..f070b14b12 100644 --- a/internal/frontend/lsp/opened_directory_test.go +++ b/internal/frontend/lsp/opened_directory_test.go @@ -46,9 +46,8 @@ func TestOpenedDocumentIndexesOnlyItsOwnDirectory(t *testing.T) { } } -// The test helpers that open a document through the server place it in a -// directory of the test's own: a stray model file in the system temp directory -// is never indexed, and no directory outside the test's root is. +// Every helper that opens a document places it under the test's root: a stray +// model file in the system temp directory is never indexed, nor any directory outside. func TestOpenHelpersStayUnderTheTestRoot(t *testing.T) { stray := filepath.Join(os.TempDir(), "opensysml-lsp-stray-guard.sysml") if err := os.WriteFile(stray, []byte("package OpenSysMLStrayGuard;\n"), 0o600); err != nil { diff --git a/internal/frontend/lsp/tempdoc_test.go b/internal/frontend/lsp/tempdoc_test.go index dfc6163eb7..82db08562c 100644 --- a/internal/frontend/lsp/tempdoc_test.go +++ b/internal/frontend/lsp/tempdoc_test.go @@ -7,9 +7,8 @@ import ( "go.lsp.dev/uri" ) -// tempDocURI is the URI of a document named name in a directory of the test's -// own, so a test opening it under no folder has only that directory indexed, -// never a shared one such as the system temp directory and what sits there. +// tempDocURI names a document in a directory of the test's own, so opening it +// under no folder indexes that directory and never a shared one such as /tmp. func tempDocURI(t testing.TB, name string) uri.URI { t.Helper() return uri.File(filepath.Join(t.TempDir(), name)) From 4d8fdcf117af46289d283ab447567222aaec556a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 13:07:18 +0000 Subject: [PATCH 4/4] test(lsp): open every cross-document test document under the test's own directory Co-Authored-By: jason.han --- .../lsp/modeledit_cross_document_test.go | 8 ++++---- internal/frontend/lsp/sync_test.go | 1 + internal/frontend/lsp/tempdoc_test.go | 19 ++++++++++++++++++- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/internal/frontend/lsp/modeledit_cross_document_test.go b/internal/frontend/lsp/modeledit_cross_document_test.go index c86c30e44a..97483a9322 100644 --- a/internal/frontend/lsp/modeledit_cross_document_test.go +++ b/internal/frontend/lsp/modeledit_cross_document_test.go @@ -93,7 +93,7 @@ func TestRenderLocatesNodesInTheDocumentDeclaringThem(t *testing.T) { func TestRenderMarksWhatEachDocumentsNodesAdmit(t *testing.T) { const views = "package EngineViews {\n\tprivate import Views::*;\n\tprivate import StandardViewDefinitions::*;\n\n\tpart def Mount;\n\tview engineView : InterconnectionView {\n\t\texpose Mount;\n\t\texpose Machinery::Engine;\n\t\texpose Parts::Part;\n\t}\n}\n" s, viewsURI := renderServer(t, "views.sysml", views) - partsURI := uri.File("parts.sysml") + partsURI := tempDocURI(t, "parts.sysml") openDoc(t, s, partsURI, engineParts) drawn := render(t, s, viewsURI, "EngineViews::engineView") @@ -137,7 +137,7 @@ func TestRenderMarksWhatEachDocumentsNodesAdmit(t *testing.T) { func TestRenderNamesOnlyTheRequestedDocumentsDeclarationsToAClientWithoutTheCapability(t *testing.T) { const views = "package EngineViews {\n\tprivate import Views::*;\n\tprivate import StandardViewDefinitions::*;\n\n\tpart def Mount;\n\tview engineView : InterconnectionView {\n\t\texpose Mount;\n\t\texpose Machinery::Engine;\n\t}\n}\n" s := initializedServer(t, nil) - viewsURI, partsURI := uri.File("views.sysml"), uri.File("parts.sysml") + viewsURI, partsURI := tempDocURI(t, "views.sysml"), tempDocURI(t, "parts.sysml") openDoc(t, s, viewsURI, views) openDoc(t, s, partsURI, engineParts) drawn := render(t, s, viewsURI, "EngineViews::engineView") @@ -296,7 +296,7 @@ func TestApplyModelEditPlacesInlineIntoTheDeclaringDocument(t *testing.T) { const motor = "package Motors {\n\tstate def Motor {\n\t\tstate off;\n\t\tstate on;\n\t\ttransition first off then on;\n\t}\n}\n" const fleet = "package Fleet {\n\tstate motor : Motors::Motor;\n}\n" s, fleetURI := renderServer(t, "fleet.sysml", fleet) - motorURI := uri.File("motor.sysml") + motorURI := tempDocURI(t, "motor.sysml") openDoc(t, s, motorURI, motor) tellChanged(t, s, motorURI, motor, 2) @@ -413,7 +413,7 @@ func TestApplyModelEditLayoutIntoDiskAndLibraryDocuments(t *testing.T) { func TestApplyModelEditLayoutRefusesWhenAnotherDocumentBecomesInvalid(t *testing.T) { shadowed := strings.Replace(engineViews, "\tview engineView", "\tpart def DiagramLayout;\n\tview engineView", 1) s, partsURI := renderServer(t, "parts.sysml", engineParts) - viewsURI := uri.File("views.sysml") + viewsURI := tempDocURI(t, "views.sysml") openDoc(t, s, viewsURI, shadowed) out := applyModelEdit(t, s, partsURI, 1, diff --git a/internal/frontend/lsp/sync_test.go b/internal/frontend/lsp/sync_test.go index 0fa4235ec9..9cf8a76868 100644 --- a/internal/frontend/lsp/sync_test.go +++ b/internal/frontend/lsp/sync_test.go @@ -60,6 +60,7 @@ func sendDidChange(t *testing.T, s *Server, u uri.URI, version int32, rawChanges func openDoc(t *testing.T, s *Server, u uri.URI, text string) { t.Helper() + requireTestOwnedDir(t, u) if err := s.DidOpen(context.Background(), &protocol.DidOpenTextDocumentParams{ TextDocument: protocol.TextDocumentItem{URI: u, Version: 1, Text: text}, }); err != nil { diff --git a/internal/frontend/lsp/tempdoc_test.go b/internal/frontend/lsp/tempdoc_test.go index 82db08562c..76106dc0a2 100644 --- a/internal/frontend/lsp/tempdoc_test.go +++ b/internal/frontend/lsp/tempdoc_test.go @@ -1,6 +1,7 @@ package lsp import ( + "os" "path/filepath" "testing" @@ -19,7 +20,23 @@ func tempDocURI(t testing.TB, name string) uri.URI { func testDocURI(t testing.TB, name string) uri.URI { t.Helper() if filepath.IsAbs(name) { - return uri.File(name) + u := uri.File(name) + requireTestOwnedDir(t, u) + return u } return tempDocURI(t, name) } + +// requireTestOwnedDir fails a test about to open u in a directory it does not +// own: the working directory, where a bare name lands, or the system temp directory. +func requireTestOwnedDir(t testing.TB, u uri.URI) { + t.Helper() + dir := filepath.Dir(u.Filename()) + wd, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd: %v", err) + } + if dir == wd || dir == os.TempDir() { + t.Fatalf("document %s opens in shared directory %s; use tempDocURI", u, dir) + } +}