diff --git a/.agents/skills/testing-sysml-repl/SKILL.md b/.agents/skills/testing-sysml-repl/SKILL.md index 62daf15a2..3d7152516 100644 --- a/.agents/skills/testing-sysml-repl/SKILL.md +++ b/.agents/skills/testing-sysml-repl/SKILL.md @@ -2530,9 +2530,43 @@ its output rather than in an exit code — so assert on the exact rendered text: ## Multi-file projects: `%load ...` and positional dirs/globs (PR #146) +### Per-file document isolation probes + +- Give only one file a root-level `private import ScalarValues::*;`. A second + file's bare `Real` must stay unresolved, as must a later prompt declaration's + bare `Real`. A qualified expression such as `%eval A::x + 1.0` should still + work, proving isolation did not remove the loaded package from the index. +- Two loaded files declaring the same root package are two root namespaces, not + a duplicate. References select the declaration in the document whose name + sorts first, independent of CLI argument order (see the CLI reference's + Multiple Files section). Put `A::X` in `first.sysml` and `A::Y` in + `second.sysml`, then reverse arguments: `A::X` must resolve and `A::Y` must + remain unresolved in both orders. Do not confuse reference precedence with + document rendering order. +- For rendering order, `%view` takes a **view**, not an ordinary package. + `%render #table` renders the loaded documents without a declared view; reverse + two nonalphabetical package names and assert their member groups reverse. + A declared view with `render asElementTable;` needs `private import Views::*;` + in its scope. +- `%save` passes notation through the formatter. Test source retention separately + from byte equality: tabs can become four spaces even while comments, members, + file order and typed declarations survive. Compare with a `develop` build + before attributing such formatting to a load-path regression. +- Both debugger fixtures in `internal/frontend/repl/testdata/` are load-ready: + `action_debug.sysml` (`%action Debug::tally`, `%step`, type `part def Z;`, + `%continue`) ends at `total = 5`; `state_debug.sysml` (`%state Debug::Cycle`, + `%advance 1`, type `part def Z;`, `%advance 9`, `%advance 5`) reaches working + at t=10 and done at t=15. This tests symbol rebinding across prompt edits. + +#### Devin Secrets Needed + +None for local multi-file CLI/REPL tests. + `sysml ...` and `%load ...` expand to model files via `internal/workspace/project.Expand`, and every file is accepted before one analysis pass -(`Session.SubmitAll`), so load order does not affect name resolution. Shapes to expect: +(`Session.SubmitAll`), each file a workspace document of its own indexed with the +others. Repeated root names resolve by document-name order, not load order. +Shapes to expect: - More than one file prints a `loaded N files:` header listing each path (a single file prints no header — a good tell that the multi-file path was taken). diff --git a/changes/unreleased/per-file-documents.changed.md b/changes/unreleased/per-file-documents.changed.md new file mode 100644 index 000000000..8ef5196eb --- /dev/null +++ b/changes/unreleased/per-file-documents.changed.md @@ -0,0 +1,2 @@ +- **Every file the command line or `%load` reads is a document of its own.** `sysml -validate`, `-satisfy`, `-e` and the REPL's `%load` used to join the files they were given into one buffer with the typed transcript, so a model split over files was analysed as if it were one file; each file is now a workspace document, indexed with the others and analysed on its own, exactly as the editor and the OMG corpus gates analyse it. Two things a reader will observe: a root-level import in one file (`private import ScalarValues::*;`) no longer serves the other files on the command line or the prompt after `%load` — a KerML root import surfaces its names in its own document's root namespace only, as `docs/project/spec-compliance.md` records — and two files that both declare `package A` are no longer reported as `Duplicate of other owned member name`: they are two root namespaces of one name, and a reference to `A` resolves to the declaration in the file whose name sorts first (the order the editor gives documents, whatever order the files were given in), as the pilot implementation resolves a repeated root name to the first. Root packages stay reachable from every file and from the prompt through the global namespace. A differential test runs every multi-file directory of the fixtures and of the four OMG corpora through the command line and through a workspace and asserts the same diagnostics. +- **A file named `` is refused by every load.** `` is the name the REPL keeps the typed transcript under, so `%load ` and `sysml -validate ` report `cannot load : the name is reserved for the text typed at the prompt` before anything loads and exit as a read failure does; `./` loads it. diff --git a/cmd/sysml/check.go b/cmd/sysml/check.go index caf9a5196..ac2465e0f 100644 --- a/cmd/sysml/check.go +++ b/cmd/sysml/check.go @@ -617,8 +617,8 @@ func runChecks(files []string, exprs []string, c checks) int { return rep.finish() } - // The files are loaded as one submission, indexed and analyzed once, and - // each is still summarized on its own. + // The files are loaded as one submission, each a document of its own indexed + // with the others, and each is summarized on its own. loaded, err := sess.LoadFilesSummary(paths) if err != nil { rep.failed(err.Error()) diff --git a/cmd/sysml/load_test.go b/cmd/sysml/load_test.go index ece381b29..8c0793701 100644 --- a/cmd/sysml/load_test.go +++ b/cmd/sysml/load_test.go @@ -113,11 +113,49 @@ func TestCheckGlobExitStatus(t *testing.T) { wantReport(t, checkPaths(t, binary, "-validate", filepath.Join(dir, "*.kerml")), 2, "no model files match") } +// TestLoadRefusesTheTranscriptsName checks that a file named as the session's +// transcript is a read failure like any other: refused before anything loads, +// named in the error, and exiting as a run that decided nothing. +func TestLoadRefusesTheTranscriptsName(t *testing.T) { + const reserved = "" + dir := t.TempDir() + write(t, filepath.Join(dir, reserved), "package FromFile { part def X; }\n") + write(t, filepath.Join(dir, "ok.sysml"), "package OK { part def Y; }\n") + t.Chdir(dir) + + sess := repl.NewSession() + status, err := loadFiles(sess, []string{"ok.sysml", reserved}) + var named *repl.ReservedNameError + if !errors.As(err, &named) || named.Name != reserved { + t.Fatalf("loadFiles error = %v, want a *repl.ReservedNameError naming %s", err, reserved) + } + if status != exitUnevaluable { + t.Errorf("status = %d, want %d", status, exitUnevaluable) + } + if got := sess.List(); len(got) != 0 { + t.Errorf("a refused load must load nothing, got %v", got) + } + + binary := buildCLI(t) + wantReport(t, checkPathsIn(t, dir, binary, "-validate", reserved), exitUnevaluable, + "sysml: cannot load : the name is reserved") + wantReport(t, checkPathsIn(t, dir, binary, "-constraint", "OK::Held", "ok.sysml", reserved), exitUnevaluable, + "cannot load : the name is reserved") + wantReport(t, checkPathsIn(t, dir, binary, "-validate", "./"+reserved), exitHolds) +} + // checkPaths runs the binary on paths the caller names, rather than on a model // written to a file for it as check does. func checkPaths(t *testing.T, binary string, args ...string) runOutcome { + t.Helper() + return checkPathsIn(t, "", binary, args...) +} + +// checkPathsIn is checkPaths run from dir, so a relative path is read there. +func checkPathsIn(t *testing.T, dir, binary string, args ...string) runOutcome { t.Helper() cmd := exec.Command(binary, args...) + cmd.Dir = dir var stdout, stderr bytes.Buffer cmd.Stdout, cmd.Stderr = &stdout, &stderr err := cmd.Run() diff --git a/docs/guide/04-repl.md b/docs/guide/04-repl.md index 0b9ac00d8..7050f4354 100644 --- a/docs/guide/04-repl.md +++ b/docs/guide/04-repl.md @@ -130,20 +130,27 @@ session, so the next submission is parsed against the model as it stood before t non-interactive use, a load's diagnostics are errors, so a script that loads a malformed file fails rather than continuing against an empty session. -Two loaded files that both open `package P` declare two packages of that name, and the load -reports this: +Each loaded file is a document of its own, analysed as the editor and the checker analyse it, +while everything typed at the prompt forms one transcript document. The transcript is kept +under the name ``, which is therefore reserved: a file whose path is literally `` +is refused by `%load` and by the command line (`cannot load : the name is reserved for the +text typed at the prompt`) before anything is loaded, like a file that could not be read; name it +`./` or from another directory to load it. Two consequences follow. + +A root-level import serves the file it is written in and no other: after `%load a.sysml`, a +`private import ScalarValues::*;` at the top of `a.sysml` does not make `Real` resolvable in +another loaded file or at the prompt. Write the import where it is used — in each file, or at +the prompt. The packages a file declares stay reachable from every other file and from the +prompt, since root packages share the global namespace. + +Two loaded files that both open `package P` declare two root packages of that name. That is not +a duplicate, and the load reports it as a note rather than a warning: ``` sysml> %load a.sysml b.sysml loaded 2 files: a.sysml b.sysml -a.sysml:1:9: warning: Duplicate of other owned member name -package P { part def A; } - ^ -b.sysml:1:9: warning: Duplicate of other owned member name -package P { part def B; } - ^ ✓ package P ✓ package P note: P is opened by more than one loaded file; each opening stays a declaration of its own, so a member of one is not visible unqualified in the other — qualify it (P::member) @@ -151,10 +158,25 @@ note: P is opened by more than one loaded file; each opening stays a declaration Each file keeps its own identity, which is what lets you reload one of them and replace only its own contribution. If the two openings were merged into a single namespace, an edit to one -file could silently delete the other file's members. The members of both openings are -declared and reachable when qualified (`P::Wheel`, `P::Axle`), but an unqualified reference -from one to the other does not resolve. Entering a package at the prompt is unaffected: it still -merges into the package already in the session. +file could silently delete the other file's members. A qualified reference to `P` resolves to +the declaration in the file whose name sorts first — the order the editor gives documents, +not the order the files were loaded in — so `P::A` resolves while `P::B` does not; an unqualified reference +from one opening to the other does not resolve either. Entering a package at the prompt is +unaffected: it still merges into the package already in the session. + +### Known behaviour + +Two evaluation rules of the prompt predate per-file loading and are kept as they are; both are +open to change. A prompt expression (`%eval`, the arguments of `%calc` and `%sweep`) evaluates in +the last namespace declared, and when a loaded file declares it, the expression sees that file's +root imports even though a typed declaration does not — after `%load a.sysml` with +`private import ScalarValues::*; package A { … }`, `%eval 1.5 as Real` resolves while a typed +`attribute y : Real;` reports `Real` unresolved; the alternative is a fallback to the transcript's +own root. A qualified command argument (`%eval A::y`, `%print A::y`) is looked up in the symbol +index, which holds every document's declarations, so with two loaded `package A` it reaches the +member of the second `A` that a reference in a model or in a compound expression cannot (`%eval A` +alone reports `A` ambiguous); the alternatives are a resolver-based lookup for the evaluating +commands, or rejecting a root that resolves ambiguously. ## Finding what a build offers diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 1ec25a04e..1d52efb7b 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -89,6 +89,18 @@ Load multiple files before evaluating: sysml -e "result" types.sysml instances.sysml ``` +Every file named on the command line is a document of its own, analysed as the editor and the +corpus gates analyse it, and the files are indexed together so that one file's reference to a +package another declares resolves. Two consequences follow: + +- A root-level import serves only the file it is written in. `private import ScalarValues::*;` + at the top of `types.sysml` does not make `Real` resolvable in `instances.sysml`; each file + imports what it uses. +- Two files that both declare `package A` are two root packages of that name, not a duplicate. + A reference to `A` resolves to the declaration in the file whose name sorts first (the + order the editor and the workspace give documents, whatever order the files were given + in), so `A::x` resolves where `x` is a member of that declaration. + ## Real-World Examples ### 1. Quick Calculation diff --git a/internal/frontend/repl/analysis.go b/internal/frontend/repl/analysis.go index 39e8483fb..cd0e9461e 100644 --- a/internal/frontend/repl/analysis.go +++ b/internal/frontend/repl/analysis.go @@ -251,8 +251,7 @@ func (s *Session) runAnalysis(inv analysisInvocation) (caseRun, error) { // analysisSymbol resolves the case an invocation names. It is resolved before the // runtime is built, so a misspelling is reported as one whatever the session holds. func (s *Session) analysisSymbol(inv analysisInvocation) (*symbols.Symbol, string, error) { - doc := s.ws.Document(docName) - if doc == nil || doc.Scope == nil { + if !s.hasDeclarations() { return nil, "", errors.New("no declarations loaded") } return s.lookupSymbolOfKinds(inv.name, @@ -295,7 +294,7 @@ func (s *Session) runAnalysisIn(x execution, ctx *runtime.Context, inv analysisI // A usage owned by a type is a feature of an object of that type, which the // session holds when one was created; a package-level case has no such owner. self := nestedCaseOwner(sym, fqn, objects) - runScope := declaringScope(sym, s.ws.Document(docName).Scope) + runScope := declaringScope(sym, s.rootScopeOf(sym)) // A verification case runs the same body; asking the run for its verdict too // reports it beside what the run computed. diff --git a/internal/frontend/repl/filedocs_test.go b/internal/frontend/repl/filedocs_test.go new file mode 100644 index 000000000..6f4f8fdbd --- /dev/null +++ b/internal/frontend/repl/filedocs_test.go @@ -0,0 +1,334 @@ +package repl + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "sort" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" + "github.com/Open-MBEE/OpenSysML/internal/workspace/model" +) + +// A file loaded from the command line is a document of its own: a root-level +// import in one file surfaces its names in that file's root namespace only, so +// the other files on the command line do not see them, exactly as the editor +// and the workspace report it. +func TestLoadedFilesDoNotShareRootImports(t *testing.T) { + dir := t.TempDir() + paths := []string{ + writeFile(t, filepath.Join(dir, "a.sysml"), "import ScalarValues::*;\npackage A { attribute x : Real; }\n"), + writeFile(t, filepath.Join(dir, "b.sysml"), "package B { attribute y : Real; }\n"), + } + s := NewSession() + if _, err := s.LoadFilesSummary(paths); err != nil { + t.Fatal(err) + } + var inB []string + for _, d := range s.LocatedDiagnostics() { + if d.File == paths[1] { + inB = append(inB, d.Message) + } + } + if len(inB) != 1 || !strings.Contains(inB[0], "unresolved reference: Real") { + t.Errorf("b.sysml should not see a.sysml's root import; its diagnostics: %q", inB) + } + if got, want := cliDiagnostics(t, paths), workspaceDiagnostics(t, paths); strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Errorf("the CLI reported:\n%s\nwant, as the workspace does:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + } +} + +// Two files declaring the same root package are two root namespaces of one name, +// not a duplicate; a reference resolves to the first declaration. +func TestLoadedFilesDeclaringOneRootPackageAreNotDuplicates(t *testing.T) { + dir := t.TempDir() + paths := []string{ + writeFile(t, filepath.Join(dir, "a.sysml"), "package A { part def X; }\n"), + writeFile(t, filepath.Join(dir, "b.sysml"), "package A { part def Y; }\n"), + writeFile(t, filepath.Join(dir, "c.sysml"), "package C { part x : A::X; }\n"), + } + s := NewSession() + out, err := s.LoadFilesSummary(paths) + if err != nil { + t.Fatal(err) + } + if s.HasErrors() { + t.Errorf("the files did not validate clean:\n%s", strings.Join(s.DiagnosticLines(), "\n")) + } + for _, line := range out { + if strings.Contains(line, "Duplicate") { + t.Errorf("a repeated root package was reported as a duplicate: %s", line) + } + } + if got, want := cliDiagnostics(t, paths), workspaceDiagnostics(t, paths); strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Errorf("the CLI reported:\n%s\nwant, as the workspace does:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + } +} + +// Which declaration of a repeated root name a reference reaches follows the +// documents' name order, as the workspace orders them, not the command line. +func TestRepeatedRootPackageResolvesByDocumentNameNotLoadOrder(t *testing.T) { + dir := t.TempDir() + first := writeFile(t, filepath.Join(dir, "first.sysml"), "package A { part def X; }\n") + second := writeFile(t, filepath.Join(dir, "second.sysml"), "package A { part def Y; }\n") + useX := writeFile(t, filepath.Join(dir, "use-x.sysml"), "package C { part x : A::X; }\n") + useY := writeFile(t, filepath.Join(dir, "use-y.sysml"), "package D { part y : A::Y; }\n") + + for _, paths := range [][]string{{first, second, useX, useY}, {second, first, useY, useX}} { + got := cliDiagnostics(t, paths) + if len(got) != 1 || !strings.Contains(got[0], "use-y.sysml") || !strings.Contains(got[0], "A::Y") { + t.Errorf("loading %v reported:\n%s\nwant only A::Y unresolved: first.sysml's A sorts first", basenames(paths), strings.Join(got, "\n")) + } + if want := workspaceDiagnostics(t, paths); strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Errorf("the CLI reported:\n%s\nwant, as the workspace does:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + } + } +} + +func basenames(paths []string) []string { + out := make([]string, len(paths)) + for i, p := range paths { + out[i] = filepath.Base(p) + } + return out +} + +// The prompt's transcript is a document of its own too: a root-level import in +// a loaded file does not serve what is typed after %load, though the file's +// root packages are reachable through the global namespace as before. +func TestPromptDoesNotSeeALoadedFilesRootImports(t *testing.T) { + s := NewSession() + path := tempFile(t, "a.sysml", "import ScalarValues::*;\npackage A { attribute x : Real; }\n") + if _, _, err := s.runMeta("%load " + path); err != nil { + t.Fatal(err) + } + res := s.Submit("package P { attribute y : Real; part a : A; }") + var messages []string + for _, d := range res.Diagnostics { + if res.mine(d.Span) { + messages = append(messages, d.Message) + } + } + if len(messages) != 1 || !strings.Contains(messages[0], "unresolved reference: Real") { + t.Errorf("the prompt should resolve A but not the file's import of Real; it reported %q", messages) + } +} + +// An error in a loaded file gates the deeper checks of that file only: a clean +// prompt submission is fully analyzed in its own document, so no blocker is named +// for it, and a later loaded file is not blocked by what the prompt holds either. +func TestLoadedFileErrorsDoNotBlockOtherDocuments(t *testing.T) { + s := NewSession() + bad := tempFile(t, "bad.sysml", "package Bad { part a : Missing; }\n") + if _, _, err := s.runMeta("%load " + bad); err != nil { + t.Fatal(err) + } + res := s.Submit("package Clean { part def A; }") + if note := res.Blocked.note(); note != "" { + t.Errorf("a loaded file's error should not block the prompt's document: %s", note) + } + + s.Submit("package Typed { part b : Absent::B; }") + good := tempFile(t, "good.sysml", "package Good { part def B; }\n") + if note := s.SubmitFiles([]SourceFile{{Name: good, Text: "package Good { part def B; }\n"}}).Blocked.note(); note != "" { + t.Errorf("the prompt's error should not block a loaded file's document: %s", note) + } + // Within the transcript, an earlier typed error still gates the deeper checks, + // and is named once: a load in between does not make it worth saying again. + if s.Submit("package Also { part def C; }").Blocked.note() == "" { + t.Error("the typed unresolved reference should still be named as blocking the prompt") + } + s.SubmitFiles([]SourceFile{{Name: good, Text: "package Good { part def B; }\n"}}) + if note := s.Submit("package More { part def D; }").Blocked.note(); note != "" { + t.Errorf("the standing error was named already; a load does not renew it: %s", note) + } + // A load that resolves the standing error ends its interval: should a reload + // bring the error back, the next prompt is told again. + s.SubmitFiles([]SourceFile{{Name: good, Text: "package Absent { part def B; }\n"}}) + s.SubmitFiles([]SourceFile{{Name: good, Text: "package Good { part def B; }\n"}}) + if s.Submit("package Yet { part def E; }").Blocked.note() == "" { + t.Error("an error resolved by a load and brought back by a reload should be named again") + } +} + +// Every multi-file directory of the fixtures and of the OMG corpora reports the +// same diagnostics loaded from the command line as opened in a workspace. +func TestCommandLineLoadMatchesWorkspace(t *testing.T) { + roots := []struct { + dir string + require string // set in CI, where an absent corpus fails instead of skipping + fetch string + }{ + {dir: "../../../tests/testdata"}, + {dir: "../../../examples"}, + { + dir: "../../../examples/sysml-v2-training", + require: "OPENSYSML_REQUIRE_TRAINING_CORPUS", + fetch: "./scripts/download-training-examples.sh", + }, + { + dir: "../../../examples/pilot-corpora/kerml-examples", + require: "OPENSYSML_REQUIRE_PILOT_CORPORA", + fetch: "./scripts/download-pilot-corpora.sh", + }, + { + dir: "../../../examples/pilot-corpora/sysml-examples", + require: "OPENSYSML_REQUIRE_PILOT_CORPORA", + fetch: "./scripts/download-pilot-corpora.sh", + }, + { + dir: "../../../examples/pilot-corpora/sysml-validation", + require: "OPENSYSML_REQUIRE_PILOT_CORPORA", + fetch: "./scripts/download-pilot-corpora.sh", + }, + } + seen := map[string]bool{} + for _, root := range roots { + if _, err := os.Stat(root.dir); os.IsNotExist(err) { + if os.Getenv(root.require) != "" { + t.Fatalf("%s is missing and %s is set; fetch it with %s", root.dir, root.require, root.fetch) + } + t.Logf("%s is absent (fetch it with %s), so this run proves nothing about it", root.dir, root.fetch) + continue + } + for _, files := range modelDirectories(t, root.dir) { + dir := filepath.Dir(files[0]) + if seen[dir] { + continue + } + seen[dir] = true + t.Run(filepath.ToSlash(dir), func(t *testing.T) { + got, want := cliDiagnostics(t, files), workspaceDiagnostics(t, files) + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Errorf("the CLI reported:\n%s\nwant, as the workspace does:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + } + }) + } + } +} + +// modelDirectories walks root and returns the model files of every directory +// holding more than one, each directory's files sorted, the corpora's directories +// under a root that contains them included. +func modelDirectories(t *testing.T, root string) [][]string { + t.Helper() + byDir := map[string][]string{} + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() || source.KindOf(path) == source.KindUnknown { + return nil + } + byDir[filepath.Dir(path)] = append(byDir[filepath.Dir(path)], path) + return nil + }) + if err != nil { + t.Fatalf("scan %s: %v", root, err) + } + dirs := make([]string, 0, len(byDir)) + for dir, files := range byDir { + if len(files) > 1 { + dirs = append(dirs, dir) + } + } + sort.Strings(dirs) + out := make([][]string, 0, len(dirs)) + for _, dir := range dirs { + files := byDir[dir] + sort.Strings(files) + out = append(out, files) + } + return out +} + +// cliDiagnostics loads the files as the command line does and returns what it +// reports, one sorted line per diagnostic. +func cliDiagnostics(t *testing.T, paths []string) []string { + t.Helper() + s := NewSession() + if _, err := s.LoadFilesSummary(paths); err != nil { + t.Fatal(err) + } + var out []string + for _, d := range s.LocatedDiagnostics() { + out = append(out, fmt.Sprintf("%s:%d:%d: %s: %s [%s]", filepath.Base(d.File), d.Line, d.Column, d.Severity, d.Message, d.Code)) + } + sort.Strings(out) + return out +} + +// workspaceDiagnostics opens the files in one workspace, as the editor and the +// corpus gates do, and returns what it reports in the same form as cliDiagnostics. +func workspaceDiagnostics(t *testing.T, paths []string) []string { + t.Helper() + ws := model.NewWorkspace() + contents := make(map[string][]byte, len(paths)) + for _, path := range paths { + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + contents[path] = content + ws.Open(path, content, 1) + } + var out []string + for _, path := range paths { + lines := source.New(path, contents[path]).Lines() + for _, d := range ws.Diagnostics(path) { + p := lines.PosAt(d.Span.Offset) + out = append(out, fmt.Sprintf("%s:%d:%d: %s: %s [%s]", filepath.Base(path), p.Line, p.Col, d.Severity, d.Message, d.Code)) + } + } + sort.Strings(out) + return out +} + +// The transcript is kept under one workspace name, so a file of that name is +// refused at the load rather than sharing the document with the typed text. +func TestLoadRefusesAFileNamedAsTheTranscript(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, docName), "package FromFile { part def X; }\n") + t.Chdir(dir) + + s := NewSession() + s.Submit("package Typed { part def T; }") + before := s.Text() + + _, err := s.LoadFilesSummary([]string{docName}) + var reserved *ReservedNameError + if !errors.As(err, &reserved) || reserved.Name != docName { + t.Fatalf("LoadFilesSummary(%q) error = %v, want a *ReservedNameError naming it", docName, err) + } + if _, _, err := s.runMeta("%load " + docName); err == nil || !strings.Contains(err.Error(), "reserved") { + t.Fatalf("%%load %s error = %v, want the name refused as reserved", docName, err) + } + if _, err := s.LoadFile(docName); !errors.As(err, &reserved) { + t.Errorf("LoadFile(%q) error = %v, want a *ReservedNameError", docName, err) + } + + // A direct submission has no error to return, so the whole of it is refused + // in the result: nothing accepted, the refusal all it renders. + res := s.SubmitFiles([]SourceFile{ + {Name: "ok.sysml", Text: "package Direct { part def D; }\n"}, + {Name: docName, Text: "package FromFile { part x : Missing; }\n"}, + }) + if !errors.As(res.Refused, &reserved) || len(res.Declared) != 0 || len(res.Origins) != 0 { + t.Errorf("a refused SubmitFiles = {Refused: %v, Declared: %v, Origins: %v}, want a *ReservedNameError and nothing else", + res.Refused, res.Declared, res.Origins) + } + want := []string{"error: cannot load : the name is reserved for the text typed at the prompt"} + if got := renderResult(res, VerbosityNormal); !slices.Equal(got, want) { + t.Errorf("a refused SubmitFiles rendered %q, want %q", got, want) + } + if got := s.Text(); got != before { + t.Errorf("the refused load changed the transcript:\n%s\nwas:\n%s", got, before) + } + if got := strings.Join(s.List(), "\n"); strings.Contains(got, "FromFile") || strings.Contains(got, "Direct") || !strings.Contains(got, "Typed") { + t.Errorf("the refused file's declarations must not enter the session; got %v", s.List()) + } +} diff --git a/internal/frontend/repl/load.go b/internal/frontend/repl/load.go index 180770e6a..888ccd116 100644 --- a/internal/frontend/repl/load.go +++ b/internal/frontend/repl/load.go @@ -53,22 +53,15 @@ func (s *Session) loadPathsReport(paths []string) (LoadReport, error) { if err != nil { return LoadReport{}, err } - files = s.withDependencies(files) - srcs := make([]SourceFile, 0, len(files)) - names := make([]string, 0, len(files)) - for _, file := range files { - name, data, err := project.ReadFile(file) - if err != nil { - return LoadReport{}, readError(name, err) - } - names = append(names, name) - srcs = append(srcs, SourceFile{Name: name, Text: string(data)}) + srcs, err := s.readSources(s.withDependencies(files)) + if err != nil { + return LoadReport{}, err } var loaded []string - if len(files) > 1 { - loaded = append(loaded, fmt.Sprintf("loaded %d files:", len(files))) - for _, name := range names { - loaded = append(loaded, " "+name) + if len(srcs) > 1 { + loaded = append(loaded, fmt.Sprintf("loaded %d files:", len(srcs))) + for _, src := range srcs { + loaded = append(loaded, " "+src.Name) } } found, declared := renderSplit(s.submitFiles(srcs), s.verbosity) @@ -104,6 +97,42 @@ func expandHomes(paths []string) []string { return out } +// readSources reads every path into the file a load submits, under the name it +// is reported by; the error is a *ReadError or a *ReservedNameError. +func (s *Session) readSources(paths []string) ([]SourceFile, error) { + files := make([]SourceFile, 0, len(paths)) + for _, path := range paths { + name, data, err := project.ReadFile(path) + if err != nil { + return nil, readError(name, err) + } + if err := reservedName(name); err != nil { + return nil, err + } + files = append(files, SourceFile{Name: name, Text: string(data)}) + } + return files, nil +} + +// ReservedNameError is a file a load refused because its name is the one the +// session keeps its typed text under, which a loaded file cannot share. +type ReservedNameError struct { + Name string +} + +func (e *ReservedNameError) Error() string { + return fmt.Sprintf("cannot load %s: the name is reserved for the text typed at the prompt", e.Name) +} + +// reservedName is the *ReservedNameError refusing a file named as the +// transcript, nil for any other name. +func reservedName(name string) error { + if name != docName { + return nil + } + return &ReservedNameError{Name: name} +} + // ReadError is a file a load could not read, under the name it is reported by. type ReadError struct { Path string diff --git a/internal/frontend/repl/meta.go b/internal/frontend/repl/meta.go index d3ed99e6a..e437e9e09 100644 --- a/internal/frontend/repl/meta.go +++ b/internal/frontend/repl/meta.go @@ -5,7 +5,6 @@ import ( "fmt" "math" "slices" - "sort" "strconv" "strings" "unicode" @@ -796,7 +795,7 @@ func (s *Session) evalIn(name, expr string) ([]string, error) { // contextScope is the namespace a pinned context evaluates in: the element's own // scope, so its members are named without qualification, else the scope it was -// declared in, searched through both session documents. +// declared in, searched through every session document. func (s *Session) contextScope(sym *symbols.Symbol) *symbols.Scope { if sym == nil { return nil @@ -850,14 +849,14 @@ func (s *Session) evalExpr(expr string) ([]string, error) { return literalResult, litErr } - doc := s.ws.Document(docName) + declared := s.hasDeclarations() // The library is indexed with or without session declarations, so a name it // declares is answered from it; only compound expressions, handled below, - // need the session's own document. + // need the session's own documents. ctx, err := s.getOrCreateRuntime() if err != nil { - if doc == nil || doc.Scope == nil { + if !declared { return nil, s.errWithoutDeclarations(expr) } return nil, err @@ -951,12 +950,14 @@ func (s *Session) evalExpr(expr string) ([]string, error) { // A compound expression is evaluated in the session's own namespace; an empty // session has none, so only the library answers there. - if doc == nil || doc.Scope == nil { + if !declared { return s.evalWithoutDeclarations(ctx, expr) } - // Complex expression with feature refs - inject into session context - tempSrc := s.joined() + fmt.Sprintf("\nattribute __eval__ = %s;", expr) + // Complex expression with feature refs - parsed after the transcript, the + // loaded files masked out of it as they are out of the transcript document + typed, _ := s.transcript() + tempSrc := typed + fmt.Sprintf("\nattribute __eval__ = %s;", expr) p := parser.New(source.New("eval", []byte(tempSrc))) root := p.ParseFile() @@ -1853,8 +1854,7 @@ func (s *Session) evalCalc(calcName, argText string) ([]string, []NamedValue, *a // calcSymbol resolves the calc %calc names. It is resolved before the runtime is // built, so a misspelling is reported as one whatever the session holds. func (s *Session) calcSymbol(calcName string) (*symbols.Symbol, error) { - doc := s.ws.Document(docName) - if doc == nil || doc.Scope == nil { + if !s.hasDeclarations() { return nil, errors.New("no declarations loaded") } sym, _, lerr := s.lookupSymbolOfKinds(calcName, symbols.SymbolCalcDef, symbols.SymbolCalcUsage) @@ -2161,31 +2161,21 @@ func (s *Session) doConstraint(name string) ([]string, bool, error) { // promptScope is the namespace a prompt expression is evaluated in: the last // namespace the session declared, whose imports are then visible to it exactly // as they are to a member written there (KerML 8.2.3.5.3). A session that -// declared no namespace evaluates at the document root. Both session documents -// are read, in buffer order, so a namespace loaded from a .kerml file counts. +// declared no namespace evaluates at the document root. Every session document +// is read, in buffer order, so a namespace a loaded file declares counts. func (s *Session) promptScope() *symbols.Scope { docs := s.sessionDocs() if len(docs) == 0 { return nil } - type entry struct { - member ast.Node - scope *symbols.Scope - } - var members []entry - for _, doc := range docs { - if doc.AST == nil || doc.Scope == nil { - continue - } - for _, m := range doc.AST.Members { - members = append(members, entry{m, doc.Scope}) + var members []Member + for _, m := range s.sessionMembers() { + if m.scope != nil { + members = append(members, m) } } - sort.SliceStable(members, func(i, j int) bool { - return members[i].member.Span().Offset < members[j].member.Span().Offset - }) for i := len(members) - 1; i >= 0; i-- { - member := members[i].member + member := members[i].Node if mem, ok := member.(*ast.Membership); ok { member = mem.Member } @@ -2207,7 +2197,7 @@ func (s *Session) promptScope() *symbols.Scope { } } // No namespace to work in: the root holding the last declaration, so a - // top-level member loaded from a .kerml file is still in reach. + // top-level member of the last loaded file is still in reach. if len(members) > 0 { return members[len(members)-1].scope } diff --git a/internal/frontend/repl/montecarlo.go b/internal/frontend/repl/montecarlo.go index 0e8232ac5..98fda6de3 100644 --- a/internal/frontend/repl/montecarlo.go +++ b/internal/frontend/repl/montecarlo.go @@ -180,8 +180,7 @@ func (m *monteCarloRuns) conclusion() (runtime.AnalysisResult, error) { // monteCarloSample makes count runs of the invocation, each in its own context on objects // made from their declarations; the plan is returned beside a refusal made after an engine ran. func (s *Session) monteCarloSample(inv analysisInvocation, count int64, seed *uint64) (*monteCarloRuns, *analysis.Plan, error) { - doc := s.ws.Document(docName) - if doc == nil || doc.Scope == nil { + if !s.hasDeclarations() { return nil, nil, errors.New("no declarations loaded") } if _, replaying := s.drivenSchedule().Replay(); replaying { @@ -228,7 +227,7 @@ func (s *Session) monteCarloSample(inv analysisInvocation, count int64, seed *ui return nil, nil, err } } - runScope := declaringScope(sym, doc.Scope) + runScope := declaringScope(sym, s.rootScopeOf(sym)) if seed == nil && s.modelSeed.set { session := s.modelSeed.value diff --git a/internal/frontend/repl/openinput_test.go b/internal/frontend/repl/openinput_test.go index 1c2f6dcf2..cfa113554 100644 --- a/internal/frontend/repl/openinput_test.go +++ b/internal/frontend/repl/openinput_test.go @@ -249,6 +249,35 @@ func TestReloadingAFixedFileClearsItsSyntaxError(t *testing.T) { } } +// The reverse: a file reloaded with its enclosure left open is masked, and takes +// its declarations out of the index with it, so a qualified lookup no longer +// finds what the session no longer holds; the library stays reachable. +func TestReloadingAFileLeftOpenDropsItsSymbols(t *testing.T) { + s := NewSession() + path := filepath.Join(t.TempDir(), "model.sysml") + if err := os.WriteFile(path, []byte("package P { attribute x = 1; }\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := s.LoadFile(path); err != nil { + t.Fatal(err) + } + if _, _, err := s.lookupSymbol("P::x"); err != nil { + t.Fatalf("P::x did not resolve after the load: %v", err) + } + if err := os.WriteFile(path, []byte("package P {\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := s.LoadFile(path); err != nil { + t.Fatal(err) + } + if sym, _, err := s.lookupSymbol("P::x"); err == nil { + t.Errorf("P::x should be gone with the file that declared it, found %v", sym) + } + if _, _, err := s.lookupSymbol("ScalarValues::Real"); err != nil { + t.Errorf("the library should still answer a qualified lookup: %v", err) + } +} + // Typed input that leaves an enclosure open is masked the same way, and the // declarations already in the buffer are untouched. func TestOpenTypedSubmissionKeepsTheBuffer(t *testing.T) { diff --git a/internal/frontend/repl/print.go b/internal/frontend/repl/print.go index edd7ff63c..83e3b6633 100644 --- a/internal/frontend/repl/print.go +++ b/internal/frontend/repl/print.go @@ -54,7 +54,7 @@ func (s *Session) printElement(name string) ([]string, bool, error) { shown = name } var doc *model.Document - if sym != nil && (sym.DocName == docName || sym.DocName == kermlDocName) { + if sym != nil { doc = s.ws.Document(sym.DocName) } if doc == nil || sym == nil || sym.Decl == nil { diff --git a/internal/frontend/repl/render.go b/internal/frontend/repl/render.go index 5b0fc385c..d9bcfad1b 100644 --- a/internal/frontend/repl/render.go +++ b/internal/frontend/repl/render.go @@ -8,16 +8,17 @@ import ( "golang.org/x/text/width" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" "github.com/Open-MBEE/OpenSysML/internal/syntax/diag" "github.com/Open-MBEE/OpenSysML/internal/syntax/source" ) -// Result is the outcome of one Submit: the top-level members parsed from the -// accumulated buffer (for the success summary), the names this submission -// declared, and any analysis diagnostics over the whole document. +// Result is the outcome of one Submit: the top-level members of the session's +// documents (for the success summary), the names this submission declared, and +// any analysis diagnostics over the whole buffer. type Result struct { - Members []ast.Node // top-level members of the AST (Task 5 renders these) + Members []Member // top-level members of the session documents, in buffer order Declared []string // names introduced by THIS submission Diagnostics []diag.Diagnostic // eager analysis over the whole buffer Source string // the full joined content (Task 6 caret rendering) @@ -25,6 +26,10 @@ type Result struct { Origins []Origin // the files of THIS submission, in buffer order Notices []string // side effects of the submission, e.g. a debugging session it ended + // Refused is the *ReservedNameError a submission was refused for, nil when + // it was accepted; a refused submission changed nothing. + Refused error + // Blocked names the unresolved error that stopped the deeper checks from // running over this submission, nil when they ran or when the session already // reported that error. @@ -38,6 +43,19 @@ type Result struct { // masked locates the submissions kept out of the analyzed buffer, whose // findings gated no validation tier. masked []source.Span + + // foreign locates the snippets analyzed in another document than this + // submission's, whose findings gated none of its validation tiers. + foreign []source.Span +} + +// Member is one top-level member of a session document; Offset is where the +// member begins in the buffer, a loaded file's document having offsets of its own. +type Member struct { + Node ast.Node + Offset int + // scope is the root scope of the document declaring the member. + scope *symbols.Scope } // Origin locates one file of a submission in the buffer, so a diagnostic is @@ -110,10 +128,10 @@ func (r Result) holdsMine(span source.Span) bool { } // renderSummary returns one accepted line per top-level member: "✓ ". -func renderSummary(members []ast.Node) []string { +func renderSummary(members []Member) []string { out := make([]string, 0, len(members)) for _, m := range members { - if line := renderMember(m); line != "" { + if line := renderMember(m.Node); line != "" { out = append(out, "✓ "+line) } } @@ -329,6 +347,9 @@ func renderResult(r Result, v Verbosity) []string { // analysis found apart from what the submission declared, so a caller outside // the prompt can send the two to different streams. func renderSplit(r Result, v Verbosity) (found, declared []string) { + if r.Refused != nil { + return []string{"error: " + r.Refused.Error()}, nil + } if v >= VerbosityDebug { // Everything the analysis produced over the whole buffer, at // buffer-absolute positions, plus where this submission landed in it. @@ -355,6 +376,9 @@ func renderSplit(r Result, v Verbosity) (found, declared []string) { // text just read rather than about the analysis of the model as a whole: a load // that defers the analysis still says why a file could not be read. func renderSyntax(r Result, v Verbosity) []string { + if r.Refused != nil { + return []string{"error: " + r.Refused.Error()} + } // A finding about the notation is no reason a file could not be read, and the // analysis this load defers reports it, so reporting it here would report it twice. var diags []diag.Diagnostic @@ -427,13 +451,18 @@ func (b *blocker) note() string { // blockedBy reports the unresolved error that stopped the deeper checks from // running over this submission: a standing error is named on the first -// submission whose report says so, not on every one after it. -func (s *Session) blockedBy(r Result) *blocker { +// submission whose report says so, not on every one after it. A load shares no +// document with the transcript, so it names nothing; one that resolves the +// standing error lets it be named again should it return. +func (s *Session) blockedBy(r Result, load bool) *blocker { b := r.analysisBlocked() if b == nil { s.notedBlocker.record("") return nil } + if load { + return nil + } key := b.key() if key == s.notedBlocker.reportedKey() { return nil @@ -467,7 +496,7 @@ func (n *blockerNote) record(key string) { func (r Result) analysisBlocked() *blocker { var first *blocker for _, d := range r.Diagnostics { - if !d.Blocking() || r.mine(d.Span) || r.isMasked(d.Span) { + if !d.Blocking() || r.mine(d.Span) || covers(r.masked, d.Span) || covers(r.foreign, d.Span) { continue } if first != nil { @@ -479,10 +508,10 @@ func (r Result) analysisBlocked() *blocker { return first } -// isMasked reports whether a span falls in a submission that was kept out of -// the analyzed buffer, so its errors blocked nothing. -func (r Result) isMasked(span source.Span) bool { - for _, m := range r.masked { +// covers reports whether a span starts in one of the snippets located, whose +// errors blocked nothing of the submission's. +func covers(snippets []source.Span, span source.Span) bool { + for _, m := range snippets { // End() included: a submission that does not close its own text is // reported at its end as often as inside it. if span.Offset >= m.Offset && span.Offset <= m.End() { @@ -505,13 +534,13 @@ func hasError(diags []diag.Diagnostic) bool { // within narrows the result to one span of the submission — one file of a load // of several — so what is reported as its own is scoped to that text alone. A -// member is the file's when it begins there: the last member of a document runs -// on over the other language's text masked out after it. +// member is the file's when it begins there: the transcript's last member runs +// on over the files' text masked out after it. func (r Result) within(span source.Span) Result { r.own = []source.Span{span} - members := make([]ast.Node, 0, len(r.Members)) + members := make([]Member, 0, len(r.Members)) for _, m := range r.Members { - if at := m.Span().Offset; at >= span.Offset && at < span.End() { + if m.Offset >= span.Offset && m.Offset < span.End() { members = append(members, m) } } @@ -521,10 +550,10 @@ func (r Result) within(span source.Span) Result { // ownMembers returns the top-level members this submission contributed, so a // summary does not re-announce everything typed earlier in the session. -func (r Result) ownMembers() []ast.Node { - out := make([]ast.Node, 0, len(r.Members)) +func (r Result) ownMembers() []Member { + out := make([]Member, 0, len(r.Members)) for _, m := range r.Members { - if r.holdsMine(m.Span()) { + if r.holdsMine(source.Span{Offset: m.Offset, Len: m.Node.Span().Len}) { out = append(out, m) } } diff --git a/internal/frontend/repl/run.go b/internal/frontend/repl/run.go index d7c7484c4..0d8ad3a34 100644 --- a/internal/frontend/repl/run.go +++ b/internal/frontend/repl/run.go @@ -11,7 +11,6 @@ import ( "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" "github.com/Open-MBEE/OpenSysML/internal/syntax/diag" "github.com/Open-MBEE/OpenSysML/internal/syntax/source" - "github.com/Open-MBEE/OpenSysML/internal/workspace/project" ) // errRuntimeInit marks a runtime the session could not create at all, which the @@ -54,17 +53,13 @@ func errorLines(lines []string, _ []NamedValue, err error) ([]string, bool, erro // LoadFile submits path and the files beside and below it that declare a root // namespace it imports as one submission, returning the lines `%load` prints. -// A lone "-" reads standard input; the error is a file it could not read. +// A lone "-" reads standard input; the error is a file it could not read or +// one named as the transcript is. func (s *Session) LoadFile(path string) ([]string, error) { defer s.enter()() - paths := s.withDependencies([]string{expandHome(path)}) - files := make([]SourceFile, 0, len(paths)) - for _, p := range paths { - name, data, err := project.ReadFile(p) - if err != nil { - return nil, readError(name, err) - } - files = append(files, SourceFile{Name: name, Text: string(data)}) + files, err := s.readSources(s.withDependencies([]string{expandHome(path)})) + if err != nil { + return nil, err } return renderResult(s.submitFiles(files), s.verbosity), nil } @@ -77,19 +72,16 @@ func (s *Session) LoadFileSummary(path string) ([]string, error) { return s.LoadFilesSummary([]string{path}) } -// LoadFilesSummary is LoadFileSummary over every path as one submission, indexed and -// analyzed once, each file still summarized on its own; a read failure is a *ReadError. -// Files beside and below the paths that declare an imported root namespace load too. +// LoadFilesSummary is LoadFileSummary over every path as one submission, each +// file a document of its own, indexed together and each summarized on its own; +// a read failure is a *ReadError and a file named as the transcript is a +// *ReservedNameError. Files beside and below the paths that declare an imported +// root namespace load too. func (s *Session) LoadFilesSummary(paths []string) ([]string, error) { defer s.enter()() - paths = s.withDependencies(expandHomes(paths)) - files := make([]SourceFile, 0, len(paths)) - for _, path := range paths { - name, data, err := project.ReadFile(path) - if err != nil { - return nil, readError(name, err) - } - files = append(files, SourceFile{Name: name, Text: string(data)}) + files, err := s.readSources(s.withDependencies(expandHomes(paths))) + if err != nil { + return nil, err } res, byFile, whole := s.submitEach(files) var lines []string diff --git a/internal/frontend/repl/session.go b/internal/frontend/repl/session.go index 0849f7bea..4aed6a279 100644 --- a/internal/frontend/repl/session.go +++ b/internal/frontend/repl/session.go @@ -18,7 +18,6 @@ import ( "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" - "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" "github.com/Open-MBEE/OpenSysML/internal/syntax/diag" "github.com/Open-MBEE/OpenSysML/internal/syntax/lexer" "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" @@ -27,23 +26,17 @@ import ( "github.com/Open-MBEE/OpenSysML/internal/workspace/model" ) -// docName is the in-memory workspace key for the accumulated REPL buffer. -// Text loaded from a .kerml file keeps that file's language: it is masked out -// of docName and analyzed in kermlDocName, whose name carries the KerML kind -// the parser's file-kind gates read. Both documents span the same joined -// buffer byte for byte, so every offset locates the same snippet in either. +// docName is the workspace key of the transcript: the typed submissions, joined, +// with each loaded file masked out — a file is a workspace document of its own. const docName = "" -// kermlDocName is the workspace key for the buffer's KerML text. -const kermlDocName = ".kerml" - // parseDocName is the document a snippet from origin is parsed and analyzed -// in, which carries the kind of the file it was loaded from. +// in: the file itself when it was loaded from one, else the transcript. func parseDocName(origin string) string { - if source.KindOf(origin) == source.KindKerML { - return kermlDocName + if origin == "" { + return docName } - return docName + return origin } // snippet is one accepted submission source, the top-level names it declares, @@ -75,7 +68,8 @@ type snippet struct { diags []diag.Diagnostic } -// Session accumulates submissions into a single implicit document. +// Session accumulates submissions: what is typed into the transcript document, +// and each loaded file into a document of its own. type Session struct { // mu serializes commands; state guards the session for readers beside one // (Complete answers Tab while a line evaluates). Exported commands take both, @@ -96,9 +90,10 @@ type Session struct { // replaced is a context a debugging session still runs against, whose identity // sequence the context built next takes over. replaced *runtime.Context - idx *symbols.Index // index over the session document, shared by lookup and runtime + idx *symbols.Index // index over the session documents, shared by lookup and runtime libSource libs.Source // the library files idx holds, for their spans' text - idxVersion int // document version idx holds, 0 when it holds none + idxVersion int // session version idx holds, 0 when it holds none + idxDocs []string // the session documents idx holds, taken back when they go about *semantics.AboutIndex // the `about` annotations of idx, renewed with it and shared by every runtime model over it names *nameTable // simple names of the documents, rebuilt when their scope trees change instances map[string]*runtime.Instance // FQN -> instance for %instantiate tracking @@ -635,7 +630,7 @@ const sessionOrigin = "" // write the session's text as a document of their own. const SessionOrigin = sessionOrigin -// joined is the buffer the session analyzes: every accepted submission, with a +// joined is the buffer the session presents: every accepted submission, with a // submission that does not close its own text masked out so it cannot change how // the others parse. Masking is byte for byte, so every offset still locates the // snippet and line it came from. @@ -651,15 +646,13 @@ func (s *Session) joined() string { return strings.Join(parts, "\n") } -// joinedFor is the buffer one session document analyzes: joined, with the -// snippets of the other language masked out too, so each document parses its -// own snippets as the kind its name carries while keeping every offset. The -// second result reports whether any snippet of that language survives. -func (s *Session) joinedFor(name string) (string, bool) { +// transcript is the buffer the transcript document analyzes: joined, with the +// loaded files masked out too; the second result reports whether any typed text remains. +func (s *Session) transcript() (string, bool) { parts := make([]string, len(s.snippets)) found := false for i, sn := range s.snippets { - if sn.open || parseDocName(sn.origin) != name { + if sn.open || sn.origin != "" { parts[i] = maskedText(sn.src) continue } @@ -669,6 +662,31 @@ func (s *Session) joinedFor(name string) (string, bool) { return strings.Join(parts, "\n"), found } +// openDocuments brings the workspace to the session's documents: the transcript, +// and one document per loaded file that parses, gone when its snippet goes. +func (s *Session) openDocuments() { + if typed, found := s.transcript(); found { + s.ws.Open(docName, []byte(typed), s.version) + } else { + s.ws.Remove(docName) + } + live := make(map[string]bool, len(s.snippets)) + for _, sn := range s.snippets { + if sn.origin == "" || sn.open { + continue + } + live[sn.origin] = true + if doc := s.ws.Document(sn.origin); doc == nil || doc.Version != sn.gen { + s.ws.Open(sn.origin, []byte(sn.src), sn.gen) + } + } + for _, name := range s.ws.DocumentNames() { + if name != docName && !live[name] { + s.ws.Remove(name) + } + } +} + // text is the buffer as it was submitted, masking nothing: what %save writes // back, so work the parser could not read is not lost. func (s *Session) text() string { @@ -720,39 +738,39 @@ func (s *Session) maskedSpans() []source.Span { return out } -// openDiagnostics reports the findings of the masked submissions, located in the -// session buffer so every surface places them in the file they came from. -func (s *Session) openDiagnostics() []diag.Diagnostic { - var out []diag.Diagnostic +// foreignSpans locates the loaded files in the buffer, each a document of its +// own whose findings gated nothing of the transcript's. +func (s *Session) foreignSpans() []source.Span { + var out []source.Span acc := 0 for _, sn := range s.snippets { - if sn.open { - for _, d := range sn.diags { - d.Span.Offset += acc - out = append(out, d) - } + if sn.origin != "" { + out = append(out, source.Span{Offset: acc, Len: len(sn.src)}) } - acc += len(sn.src) + 1 // the newline joined() writes between snippets + acc += len(sn.src) + 1 } return out } -// diagnostics reports the analysis of the buffer together with the syntax errors -// of the submissions masked out of it. The masked text is blanked rather than -// removed, so what the analysis finds is about the submissions that did parse -// and is reported as it stands. Both session documents share the buffer's -// coordinates, so their findings interleave by offset. +// diagnostics reports the analysis of every session document and the syntax errors +// of the masked submissions, each moved to where its text sits in the session buffer. func (s *Session) diagnostics() []diag.Diagnostic { - analyzed := append([]diag.Diagnostic{}, s.ws.Diagnostics(docName)...) - analyzed = append(analyzed, s.ws.Diagnostics(kermlDocName)...) - open := s.openDiagnostics() - if len(open) == 0 { - sort.SliceStable(analyzed, func(i, j int) bool { return analyzed[i].Span.Offset < analyzed[j].Span.Offset }) - return analyzed - } - out := make([]diag.Diagnostic, 0, len(analyzed)+len(open)) - out = append(out, analyzed...) - out = append(out, open...) + out := append([]diag.Diagnostic{}, s.ws.Diagnostics(docName)...) + acc := 0 + for _, sn := range s.snippets { + var own []diag.Diagnostic + switch { + case sn.open: + own = sn.diags + case sn.origin != "": + own = s.ws.Diagnostics(sn.origin) + } + for _, d := range own { + d.Span.Offset += acc + out = append(out, d) + } + acc += len(sn.src) + 1 // the newline joined() writes between snippets + } sort.SliceStable(out, func(i, j int) bool { return out[i].Span.Offset < out[j].Span.Offset }) return out } @@ -830,12 +848,34 @@ func (s *Session) submitAll(srcs []string) Result { // SubmitFiles accumulates every file as one submission: all of them are accepted // before the buffer is reindexed and analyzed, so a declaration in one resolves // against the others no matter which order they arrive in. This is what makes -// loading a multi-file project order-independent. +// loading a multi-file project order-independent. A file's Name is its +// workspace document, so a file named as the transcript is refused: nothing is +// accepted and the result carries the *ReservedNameError as Refused. func (s *Session) SubmitFiles(files []SourceFile) Result { defer s.enter()() + for _, f := range files { + if err := reservedName(f.Name); err != nil { + return s.refuse(err) + } + } return s.submitFiles(files) } +// refuse is the result of a submission no part of which was accepted: the +// session as it stands, with nothing of its own but the refusal. +func (s *Session) refuse(err error) Result { + text := s.text() + return Result{ + Members: s.sessionMembers(), + Diagnostics: s.diagnostics(), + Source: text, + Offset: len(text), + Refused: err, + masked: s.maskedSpans(), + foreign: s.foreignSpans(), + } +} + func (s *Session) submitFiles(files []SourceFile) Result { res, _, _ := s.submitEach(files) return res @@ -850,6 +890,7 @@ func (s *Session) submitEach(files []SourceFile) (res Result, byFile [][]string, ) seen := map[string]bool{} s.version++ + load := len(files) > 0 && files[0].Name != "" byFile = make([][]string, len(files)) for i, f := range files { names, dropped := s.acceptFrom(f.Name, f.Text) @@ -887,13 +928,14 @@ func (s *Session) submitEach(files []SourceFile) (res Result, byFile [][]string, Origins: s.origins(), own: own, masked: s.maskedSpans(), + foreign: s.foreignSpans(), Notices: notices, } - res.Blocked = s.blockedBy(res) + res.Blocked = s.blockedBy(res, load) return res, byFile, whole } -// rebuildOver replaces the open document and everything derived from it — the +// rebuildOver replaces the open documents and everything derived from them — the // runtime context, the resolutions held objects and debugging sessions were // made against — after the snippets changed, reporting what it carried over. func (s *Session) rebuildOver(drops []dropReport) []string { @@ -901,14 +943,8 @@ func (s *Session) rebuildOver(drops []dropReport) []string { // before the new text replaces that resolution, so what the new document does // not change can be told apart from what it does. over := s.recordCarryover() - sysml, _ := s.joinedFor(docName) - s.ws.Open(docName, []byte(sysml), s.version) - if kerml, found := s.joinedFor(kermlDocName); found { - s.ws.Open(kermlDocName, []byte(kerml), s.version) - } else { - s.ws.Remove(kermlDocName) - } - // The document is a new AST and scope tree, so the context derived from the + s.openDocuments() + // The documents are new ASTs and scope trees, so the context derived from the // previous one is replaced; the objects it holds are carried into the new one // where the declarations they were materialized against are unchanged. The // index is re-used and brought up to date on the next lookup instead, which is @@ -1156,17 +1192,13 @@ func (s *Session) Clear() []string { // goes is reported and recorded rather than silently emptied. func (s *Session) clear() []string { notices, lost := s.resetLoss() - s.ws.Remove(docName) - s.ws.Remove(kermlDocName) + for _, name := range s.ws.DocumentNames() { + s.ws.Remove(name) + } s.snippets = nil s.version = 0 s.rtCtx, s.replaced = nil, nil - if s.idx != nil { - // Drop the documents, keep the library the index was built with. - s.idx.RemoveDocument(docName) - s.idx.RemoveDocument(kermlDocName) - s.idxVersion, s.about = 0, semantics.NewAboutIndex() - } + s.dropIndexedDocs() s.instances = make(map[string]*runtime.Instance) s.unnamed, s.given = nil, nil s.lost = lost @@ -1269,49 +1301,127 @@ func (s *Session) newRuntimeOver(model *runtime.Model) (*runtime.Context, error) // and the ones its wildcard imports surfaced, so a submission costs its own // document rather than a reload of the library. func (s *Session) symbolIndex() *symbols.Index { - doc := s.ws.Document(docName) - if doc == nil || doc.Scope == nil { + docs := s.sessionDocs() + if !hasScope(docs) { + s.dropIndexedDocs() return nil } if s.idx == nil { s.idx, s.libSource = model.NewIndexWithStdlib() s.about = semantics.NewAboutIndex() - } else if s.idxVersion == doc.Version { + } else if s.idxVersion == s.version { return s.idx } - s.idx.AddDocument(docName, doc.AST) - if kdoc := s.ws.Document(kermlDocName); kdoc != nil { - s.idx.AddDocument(kermlDocName, kdoc.AST) - } else { - s.idx.RemoveDocument(kermlDocName) + live := make(map[string]bool, len(docs)) + for _, doc := range docs { + live[doc.Name] = true + } + for _, name := range s.idxDocs { + if !live[name] { + s.idx.RemoveDocument(name) + } + } + s.idxDocs = s.idxDocs[:0] + for _, doc := range docs { + s.idx.AddDocument(doc.Name, doc.AST) + s.idxDocs = append(s.idxDocs, doc.Name) } s.idx.ExpandWildcardImports() - s.idxVersion, s.about = doc.Version, semantics.NewAboutIndex() + s.idxVersion, s.about = s.version, semantics.NewAboutIndex() return s.idx } -// sessionDocs returns the session's open documents, the SysML buffer first, -// so a caller reading the whole session reads both languages. -func (s *Session) sessionDocs() []*model.Document { - var out []*model.Document - for _, name := range []string{docName, kermlDocName} { - if doc := s.ws.Document(name); doc != nil { - out = append(out, doc) +// dropIndexedDocs takes the session's documents back out of the index, keeping +// the library it was built with. +func (s *Session) dropIndexedDocs() { + if s.idx == nil { + return + } + for _, name := range s.idxDocs { + s.idx.RemoveDocument(name) + } + s.idxDocs = nil + s.idxVersion, s.about = 0, semantics.NewAboutIndex() +} + +// hasScope reports whether any of the documents built a scope tree. +func hasScope(docs []*model.Document) bool { + for _, doc := range docs { + if doc.Scope != nil { + return true } } - return out + return false } -// sessionMembers returns the top-level members of both session documents in -// buffer order, which their shared coordinates make the span order. -func (s *Session) sessionMembers() []ast.Node { - var out []ast.Node +// hasDeclarations reports whether the session holds a document with a scope tree. +func (s *Session) hasDeclarations() bool { + return hasScope(s.sessionDocs()) +} + +// rootScopeOf is the root scope of the session document declaring sym, and for a +// symbol the session declares nowhere that of its first document with one. +func (s *Session) rootScopeOf(sym *symbols.Symbol) *symbols.Scope { + if doc := s.ws.Document(sym.DocName); doc != nil && doc.Scope != nil { + return doc.Scope + } for _, doc := range s.sessionDocs() { - if doc.AST != nil { - out = append(out, doc.AST.Members...) + if doc.Scope != nil { + return doc.Scope + } + } + return nil +} + +// locatedDoc is a session document with the buffer offset its text begins at. +type locatedDoc struct { + doc *model.Document + base int +} + +// locatedDocs returns the transcript, whose offsets are the buffer's, then each +// loaded file's document at the offset its text sits in the buffer. +func (s *Session) locatedDocs() []locatedDoc { + var out []locatedDoc + if doc := s.ws.Document(docName); doc != nil { + out = append(out, locatedDoc{doc: doc}) + } + acc := 0 + for _, sn := range s.snippets { + if sn.origin != "" { + if doc := s.ws.Document(sn.origin); doc != nil { + out = append(out, locatedDoc{doc: doc, base: acc}) + } + } + acc += len(sn.src) + 1 // the newline joined() writes between snippets + } + return out +} + +// sessionDocs returns the session's documents, the transcript first and then the +// loaded files in buffer order. +func (s *Session) sessionDocs() []*model.Document { + located := s.locatedDocs() + out := make([]*model.Document, len(located)) + for i, l := range located { + out[i] = l.doc + } + return out +} + +// sessionMembers returns the top-level members of every session document in +// buffer order, each offset by where its document's text sits. +func (s *Session) sessionMembers() []Member { + var out []Member + for _, l := range s.locatedDocs() { + if l.doc.AST == nil { + continue + } + for _, m := range l.doc.AST.Members { + out = append(out, Member{Node: m, Offset: l.base + m.Span().Offset, scope: l.doc.Scope}) } } - sort.SliceStable(out, func(i, j int) bool { return out[i].Span().Offset < out[j].Span().Offset }) + sort.SliceStable(out, func(i, j int) bool { return out[i].Offset < out[j].Offset }) return out } diff --git a/internal/frontend/repl/sweep.go b/internal/frontend/repl/sweep.go index 64216d108..71a8c2bbf 100644 --- a/internal/frontend/repl/sweep.go +++ b/internal/frontend/repl/sweep.go @@ -157,8 +157,7 @@ func sweepLabel(inv analysisInvocation, draws sweepDraws) string { // row per value in a context of its own, held objects made there from their declarations or // from one image of the held graph; the session's state is released while the rows run. func (s *Session) runSweep(inv analysisInvocation, specs []sweepSpec, draws sweepDraws) (runtime.SweepTable, *analysis.Plan, error) { - doc := s.ws.Document(docName) - if doc == nil || doc.Scope == nil { + if !s.hasDeclarations() { return runtime.SweepTable{}, nil, errors.New("no declarations loaded") } sym, fqn, err := s.lookupSymbolOfKinds(inv.name, @@ -213,7 +212,7 @@ func (s *Session) runSweep(inv analysisInvocation, specs []sweepSpec, draws swee if err != nil { return runtime.SweepTable{}, nil, err } - runScope := declaringScope(sym, doc.Scope) + runScope := declaringScope(sym, s.rootScopeOf(sym)) run := func(rt *runtime.Context, bindings []runtime.SweepBinding) (runtime.SweepRunResult, error) { row, err := s.rowObjects(rt, args.objects, image) diff --git a/internal/frontend/repl/view.go b/internal/frontend/repl/view.go index 617194773..c0a71b35b 100644 --- a/internal/frontend/repl/view.go +++ b/internal/frontend/repl/view.go @@ -224,14 +224,16 @@ func (s *Session) Views() ([]model.ViewInfo, error) { func (s *Session) symbolsInLoadOrder(in func(*symbols.Scope) []*symbols.Symbol) []*symbols.Symbol { idx := s.browseIndex() var out []*symbols.Symbol - for _, doc := range s.sessionDocs() { - out = append(out, in(idx.DocumentRoot(doc.Name))...) - } - // The language documents are masked copies of one joined buffer, so their - // spans share coordinates and sorting restores submission order. - sort.SliceStable(out, func(i, j int) bool { - return out[i].DeclSpan.Offset < out[j].DeclSpan.Offset - }) + // Each document's symbols are placed where its text sits in the buffer, so + // sorting restores submission order across the documents. + at := make(map[*symbols.Symbol]int) + for _, l := range s.locatedDocs() { + for _, sym := range in(idx.DocumentRoot(l.doc.Name)) { + at[sym] = l.base + sym.DeclSpan.Offset + out = append(out, sym) + } + } + sort.SliceStable(out, func(i, j int) bool { return at[out[i]] < at[out[j]] }) return out } @@ -249,10 +251,10 @@ func (s *Session) viewRenderer() (*view.Renderer, error) { return view.NewRenderer(model, resolver, s.sessionSourceText()), nil } -// sessionSourceFile locates the file a span of the session buffer was loaded from, so a -// location a declaration states relative to its file resolves against that file, not the buffer. +// sessionSourceFile locates the file a span of a session document was loaded from: +// a loaded file is a document named for its path; the transcript's spans are typed. func (s *Session) sessionSourceFile(doc string, span source.Span) string { - if doc != docName && doc != kermlDocName { + if doc != docName { return source.FileNamed(doc, span) } sn, _ := s.snippetAt(span.Offset)