From e62b3da1e5540eb3af70a773e23da27ad1e8d312 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 12:35:41 +0000 Subject: [PATCH 1/4] fix(odata): resolve constant credentials for the $metadata fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Literal credentials started working when the fetch learned to authenticate, but a constant reference still got a 401 and an empty client. That is the shape MDL pushes users towards — mxcli requires a constant for ServiceUrl, so a client written the documented way has constants for its credentials too. The tool insisted on the shape whose credentials it would not read. The quoted spelling was the sharp edge. `'@Module.ApiUser'` is a STRING_LITERAL, so the isLiteral flag says "literal" and the previous code sent the fifteen characters `@Module.ApiUser` as the username — worse than a 401, because it looks like it tried, and no unresolved-credential note fired either. All three spellings now resolve: a literal, `@Module.Name`, and the same reference quoted. A constant's design-time default is exactly what Studio Pro uses for its own fetch, so reading it is not a workaround — it is the value. An unknown constant, or one with no default, still reports itself unresolved rather than sending something that merely looks like a credential. Verified against a basic-auth server that 401s without credentials and 403s without a custom header: all three spellings cache the contract, where the quoted form previously failed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4 --- mdl/executor/cmd_odata.go | 102 ++++++++++++++++--- mdl/executor/cmd_odata_metadata_auth_test.go | 52 +++++++++- 2 files changed, 139 insertions(+), 15 deletions(-) diff --git a/mdl/executor/cmd_odata.go b/mdl/executor/cmd_odata.go index 2e98d8cf9..bbdf8034b 100644 --- a/mdl/executor/cmd_odata.go +++ b/mdl/executor/cmd_odata.go @@ -1127,7 +1127,7 @@ Got: %s`, stmt.ServiceUrl) } newSvc.MetadataUrl = normalizedUrl - auth := metadataAuthFromStmt(stmt) + auth := metadataAuthFromStmt(ctx, stmt) metadata, hash, err := fetchODataMetadata(normalizedUrl, auth) if err != nil { fmt.Fprintf(ctx.Output, "Warning: could not fetch $metadata: %v\n", err) @@ -1896,27 +1896,26 @@ func (a *metadataFetchAuth) apply(req *http.Request) { // metadataAuthFromStmt collects the statement's own credentials and headers for // the design-time fetch, keeping only the literals. -func metadataAuthFromStmt(stmt *ast.CreateODataClientStmt) *metadataFetchAuth { +func metadataAuthFromStmt(ctx *ExecContext, stmt *ast.CreateODataClientStmt) *metadataFetchAuth { auth := &metadataFetchAuth{Headers: map[string]string{}} - switch { - case stmt.HttpUsernameIsLiteral: - auth.Username = stmt.HttpUsername - case stmt.HttpUsername != "": + consts := designTimeConstants(ctx) + + if v, ok := resolveCredential(stmt.HttpUsername, stmt.HttpUsernameIsLiteral, consts); ok { + auth.Username = v + } else if stmt.HttpUsername != "" { auth.Unresolved = append(auth.Unresolved, "HttpUsername ("+stmt.HttpUsername+")") } - switch { - case stmt.HttpPasswordIsLiteral: - auth.Password = stmt.HttpPassword - case stmt.HttpPassword != "": + if v, ok := resolveCredential(stmt.HttpPassword, stmt.HttpPasswordIsLiteral, consts); ok { + auth.Password = v + } else if stmt.HttpPassword != "" { // Named, not printed: a constant reference is a name, but the value it // resolves to is a secret and this line goes to the console. auth.Unresolved = append(auth.Unresolved, "HttpPassword ("+stmt.HttpPassword+")") } for _, h := range stmt.Headers { - switch { - case h.ValueIsLiteral: - auth.Headers[h.Key] = h.Value - case h.Value != "": + if v, ok := resolveCredential(h.Value, h.ValueIsLiteral, consts); ok { + auth.Headers[h.Key] = v + } else if h.Value != "" { auth.Unresolved = append(auth.Unresolved, "header "+h.Key+" ("+h.Value+")") } } @@ -1924,6 +1923,81 @@ func metadataAuthFromStmt(stmt *ast.CreateODataClientStmt) *metadataFetchAuth { return auth } +// resolveCredential turns an MDL property value into the string to send on the +// design-time fetch. +// +// Three spellings reach here and all three have to work, because the shape MDL +// pushes users towards is the constant reference — mxcli requires a constant for +// ServiceUrl, so a client written the documented way has constants for its +// credentials too (mxcli-formula1 #23 follow-up): +// +// HttpUsername: 'f1api' a literal +// HttpUsername: @Module.ApiUser a constant reference +// HttpUsername: '@Module.ApiUser' the same reference, quoted +// +// The quoted form is the trap: it is a STRING_LITERAL, so the isLiteral flag says +// "literal" and the naive reading sends the eleven characters `@Module.ApiUser` +// as the username. Worse than a 401, because it looks like it tried. +// +// A constant's design-time default is exactly what Studio Pro uses for the same +// fetch, so resolving it here is not a workaround — it is the value. +func resolveCredential(value string, isLiteral bool, consts map[string]string) (string, bool) { + if value == "" { + return "", false + } + if ref, ok := constantReference(value, isLiteral); ok { + v, found := consts[strings.ToLower(ref)] + return v, found && v != "" + } + if isLiteral { + return value, true + } + return "", false +} + +// constantReference reports whether a property value names a constant, and which +// one. A leading @ marks a reference in either spelling; an unquoted qualified +// name is one too, since a bare Module.Name cannot be a credential. +func constantReference(value string, isLiteral bool) (string, bool) { + if rest, found := strings.CutPrefix(value, "@"); found { + return rest, true + } + if !isLiteral && strings.Contains(value, ".") { + return value, true + } + return "", false +} + +// designTimeConstants maps a constant's qualified name (lowercased) to its +// default value. Best-effort: a project that cannot be read yields an empty map, +// and every reference then reports itself unresolved rather than failing the +// statement. +func designTimeConstants(ctx *ExecContext) map[string]string { + out := map[string]string{} + if ctx == nil || ctx.Backend == nil { + return out + } + consts, err := ctx.Backend.ListConstants() + if err != nil { + return out + } + h, err := getHierarchy(ctx) + if err != nil { + return out + } + for _, c := range consts { + if c == nil { + continue + } + mod := h.GetModuleName(h.FindModuleID(c.ContainerID)) + if mod == "" { + continue + } + out[strings.ToLower(mod+"."+c.Name)] = c.DefaultValue + } + return out +} + // hints explains a failed fetch when the reason is credentials mxcli could not // resolve, and points at the workaround that also happens to be better practice. func (a *metadataFetchAuth) hints() []string { diff --git a/mdl/executor/cmd_odata_metadata_auth_test.go b/mdl/executor/cmd_odata_metadata_auth_test.go index 455286d29..ec11fe78b 100644 --- a/mdl/executor/cmd_odata_metadata_auth_test.go +++ b/mdl/executor/cmd_odata_metadata_auth_test.go @@ -69,7 +69,7 @@ func TestMetadataAuthFromStmt_LiteralsOnly(t *testing.T) { {Key: "X-Token", Value: "Module.Token"}, }, } - auth := metadataAuthFromStmt(stmt) + auth := metadataAuthFromStmt(nil, stmt) if auth.Username != "f1api" { t.Errorf("Username = %q, want the literal f1api", auth.Username) @@ -95,3 +95,53 @@ func TestMetadataAuthFromStmt_LiteralsOnly(t *testing.T) { } } } + +// mxcli-formula1 #23 follow-up: literal credentials worked after the first fix, +// but a constant reference still produced a 401 and an empty client — and the +// same release made a constant `ServiceUrl` mandatory, so the shape mxcli +// insists on for the URL was the shape whose credentials it would not read. +// +// The quoted spelling was the sharp edge. `'@Module.ApiUser'` is a STRING_LITERAL, +// so the isLiteral flag says "literal" and the naive reading sent the fifteen +// characters `@Module.ApiUser` as the username: worse than a 401, because it +// looks like it tried. +func TestResolveCredential(t *testing.T) { + consts := map[string]string{ + "m.apiuser": "f1api", + "m.apipass": "s3cret", + "m.empty": "", + } + cases := []struct { + name string + value string + isLiteral bool + want string + wantOK bool + }{ + {"a literal is itself", "f1api", true, "f1api", true}, + {"a quoted constant reference resolves", "@M.ApiUser", true, "f1api", true}, + {"a bare constant reference resolves", "@M.ApiUser", false, "f1api", true}, + {"an unquoted qualified name is a reference too", "M.ApiPass", false, "s3cret", true}, + {"case-insensitive, as MDL is elsewhere", "@m.APIUSER", true, "f1api", true}, + // Unresolvable cases must report themselves rather than send something + // that merely looks like a credential. + {"an unknown constant is unresolved", "@M.Nope", true, "", false}, + {"a constant with no default is unresolved", "@M.Empty", true, "", false}, + {"an empty value is nothing", "", true, "", false}, + // A literal that happens to contain a dot is still a literal — passwords + // contain dots, and that must not be read as a reference. + {"a dotted literal stays a literal", "s3.cret", true, "s3.cret", true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, ok := resolveCredential(tc.value, tc.isLiteral, consts) + if ok != tc.wantOK { + t.Fatalf("resolved = %v, want %v (value %q, literal %v)", ok, tc.wantOK, tc.value, tc.isLiteral) + } + if got != tc.want { + t.Errorf("got %q, want %q", got, tc.want) + } + }) + } +} From 734b1d6c9eff9b82b9f47225b6080552b1eb8554 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 12:46:32 +0000 Subject: [PATCH 2/4] fix(theme): re-point the filter-operator popovers at the palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The widget layer covered .column-selectors and stopped there. Four more rules in _datagrid-filters.scss bake the same two-layer light-mode shadow — box-shadow: 0 2px 20px 1px rgba(5, 15, 129, .05), 0 2px 16px 0 rgba(33, 43, 54, .08); — on the filter-operator popover, the dropdown filter's list in both its standalone and contained forms, and the list inside a dropdown container. Each already takes its background from --bg-color-secondary, so Atlas re-colours the panel and leaves the shadow: elevation drawn for a light ground, floating over a dark one. Selectors read out of the shipped themesource, not the report — the fourth is `.dropdown-container .dropdown-list`, which is nested and easy to miss. Verified the way §33 insists on: applied the theme to a real project, ran mxbuild, and read theme.compiled.css. The rule lands at line 30794, after the widget module's own at 27765, so it wins the cascade. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4 --- .../files/theme/web/_mxcli-widgets.scss | 19 +++++++++++++++++++ .../files/theme/web/_mxcli-widgets.scss | 19 +++++++++++++++++++ .../files/theme/web/_mxcli-widgets.scss | 19 +++++++++++++++++++ 3 files changed, 57 insertions(+) diff --git a/cmd/mxcli/theme/assets/console/files/theme/web/_mxcli-widgets.scss b/cmd/mxcli/theme/assets/console/files/theme/web/_mxcli-widgets.scss index afd531717..3e7073865 100644 --- a/cmd/mxcli/theme/assets/console/files/theme/web/_mxcli-widgets.scss +++ b/cmd/mxcli/theme/assets/console/files/theme/web/_mxcli-widgets.scss @@ -64,6 +64,25 @@ box-shadow: var(--mxt-shadow); } +// _datagrid-filters.scss:71, 143, 153 and 206 — the same two-layer light-mode +// shadow, baked four times: +// +// box-shadow: 0 2px 20px 1px rgba(5, 15, 129, .05), +// 0 2px 16px 0 rgba(33, 43, 54, .08); +// +// These are the filter-operator popover, the dropdown filter's list in both its +// standalone and contained forms, and the list inside a dropdown container. Each +// already takes its background from --bg-color-secondary, so Atlas re-colours the +// panel and leaves the shadow behind — elevation drawn for a light ground, +// floating over a dark one. Same treatment as .column-selectors above. +.filter-selectors, +.dropdown-content, +:not(.dropdown-content) > .dropdown-list, +.dropdown-container .dropdown-list { + border-color: var(--mxt-line); + box-shadow: var(--mxt-shadow); +} + // _three-state-checkbox.scss — the row-select boxes down the left of every // grid. Nine baked literals: #e7e7e9 borders and #f8f8f8 disabled fills that // vanish on a dark surface, #ffffff checkmarks, and #264ae5 for the checked and diff --git a/cmd/mxcli/theme/assets/ledger/files/theme/web/_mxcli-widgets.scss b/cmd/mxcli/theme/assets/ledger/files/theme/web/_mxcli-widgets.scss index afd531717..3e7073865 100644 --- a/cmd/mxcli/theme/assets/ledger/files/theme/web/_mxcli-widgets.scss +++ b/cmd/mxcli/theme/assets/ledger/files/theme/web/_mxcli-widgets.scss @@ -64,6 +64,25 @@ box-shadow: var(--mxt-shadow); } +// _datagrid-filters.scss:71, 143, 153 and 206 — the same two-layer light-mode +// shadow, baked four times: +// +// box-shadow: 0 2px 20px 1px rgba(5, 15, 129, .05), +// 0 2px 16px 0 rgba(33, 43, 54, .08); +// +// These are the filter-operator popover, the dropdown filter's list in both its +// standalone and contained forms, and the list inside a dropdown container. Each +// already takes its background from --bg-color-secondary, so Atlas re-colours the +// panel and leaves the shadow behind — elevation drawn for a light ground, +// floating over a dark one. Same treatment as .column-selectors above. +.filter-selectors, +.dropdown-content, +:not(.dropdown-content) > .dropdown-list, +.dropdown-container .dropdown-list { + border-color: var(--mxt-line); + box-shadow: var(--mxt-shadow); +} + // _three-state-checkbox.scss — the row-select boxes down the left of every // grid. Nine baked literals: #e7e7e9 borders and #f8f8f8 disabled fills that // vanish on a dark surface, #ffffff checkmarks, and #264ae5 for the checked and diff --git a/cmd/mxcli/theme/assets/signal/files/theme/web/_mxcli-widgets.scss b/cmd/mxcli/theme/assets/signal/files/theme/web/_mxcli-widgets.scss index afd531717..3e7073865 100644 --- a/cmd/mxcli/theme/assets/signal/files/theme/web/_mxcli-widgets.scss +++ b/cmd/mxcli/theme/assets/signal/files/theme/web/_mxcli-widgets.scss @@ -64,6 +64,25 @@ box-shadow: var(--mxt-shadow); } +// _datagrid-filters.scss:71, 143, 153 and 206 — the same two-layer light-mode +// shadow, baked four times: +// +// box-shadow: 0 2px 20px 1px rgba(5, 15, 129, .05), +// 0 2px 16px 0 rgba(33, 43, 54, .08); +// +// These are the filter-operator popover, the dropdown filter's list in both its +// standalone and contained forms, and the list inside a dropdown container. Each +// already takes its background from --bg-color-secondary, so Atlas re-colours the +// panel and leaves the shadow behind — elevation drawn for a light ground, +// floating over a dark one. Same treatment as .column-selectors above. +.filter-selectors, +.dropdown-content, +:not(.dropdown-content) > .dropdown-list, +.dropdown-container .dropdown-list { + border-color: var(--mxt-line); + box-shadow: var(--mxt-shadow); +} + // _three-state-checkbox.scss — the row-select boxes down the left of every // grid. Nine baked literals: #e7e7e9 borders and #f8f8f8 disabled fills that // vanish on a dark surface, #ffffff checkmarks, and #264ae5 for the checked and From 3c1dc5b4aceb15187c650c48dacf97574a07f5d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 12:49:06 +0000 Subject: [PATCH 3/4] docs(fix-issue): two symptom rows from the constant-credential and popover fixes Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4 --- .claude/skills/fix-issue.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.claude/skills/fix-issue.md b/.claude/skills/fix-issue.md index 9ff58a9da..a52aaf3a8 100644 --- a/.claude/skills/fix-issue.md +++ b/.claude/skills/fix-issue.md @@ -421,3 +421,5 @@ extracting `OffsetExpression`/`LimitExpression`. | `create external entities from` a contract that restricts capabilities produces a project that will not build: `'Seasons' is marked Countable=False in the OData service, but True in the app`, `'latitude' is marked Filterable=False …` — one per restricted resource or property | Insert/Update/Delete restrictions were parsed; **Count/Filter/Sort were not**, so the import had nothing to honour and defaulted all three to true — on the one command whose entire job is fidelity to the contract | `mdl/types/edmx.go` (`EdmEntitySet.Countable`, `NonFilterableProperties`, `NonSortableProperties` + the three `applyCapabilityAnnotations` arms), `mdl/executor/cmd_contract.go` | An unannotated set still means countable/filterable/sortable — **silence in a contract is not a restriction**, it is OData's own default, so `nil` and `false` must stay distinguishable (`*bool`, as with the publish-side query options). The generated entity is compared against the contract at *build* time, so anything the contract can say is something the importer must be able to read. Tests `mdl/types/edmx_test.go`. mxcli-formula1 #24 | | A contract property called `name` is generated as `Stg_Drivername` / `Circuitname` — prefixed with the remote type. A page written against the published `$metadata` then fails with `The selected attribute 'F1Live.Drivers.name' no longer exists`, and the *same* field carries a different name in every module because the remote type names differ | `attrNameForOData` disambiguates any name in `reservedEntityAttrNames`, and `name` was on that list with the comment "Mendix system-managed attribute for the object name". It is not: Mendix builds an external entity with an attribute literally named `name` | `mdl/executor/cmd_contract.go` (`reservedEntityAttrNames` loses one entry; the import now reports the renames it does make) | **Test the whole list at once, not the reported entry.** One contract with a property per listed name, prefixing disabled, then `mx check`: CE7247 "The name 'x' is a reserved word" for `id`/`owner`/`changedBy`/`changedDate`/`createdDate`/`type`/`context`, and silence for `name`. That turns "is the list wrong?" into "which rows are wrong?" for the cost of a single build, and it *earns* the seven entries that stay rather than leaving them as folklore. Two existing tests pinned the old behaviour and had to be corrected — a hand-maintained list of platform rules will accrete guesses unless each row can point at an error code. **Migration**: a re-import renames the attribute back, so references to the prefixed name must follow. Tests `cmd_contract_reserved_test.go`. mxcli-formula1 #28 | | `MOVE JAVA ACTION …` / `MOVE ODATA SERVICE …` is a parse error (`no viable alternative at input 'MOVEJAVA'`), and neither `CREATE` form takes a folder clause — so those documents can never leave the module root from MDL | The `moveStatement` rule listed seven doctypes and nothing else; the missing ones were never unimplemented, just unlisted | `mdl/grammar/MDLParser.g4` (two alternatives), `mdl/ast/ast.go`, `mdl/visitor/visitor_entity.go` (dispatch **and** the MOVE FOLDER discriminator), `mdl/executor/cmd_move.go`, backend `MoveJavaAction` / `MovePublishedODataService`, `sdk/mpr` exports `MoveUnitByID` | Both reduce to the existing reparent primitive — a top-level document move is one containment row, so a new doctype is a list entry plus a lookup, not new machinery. **Watch the discriminator**: `MOVE FOLDER` is told apart from a document move by the *absence* of a doctype keyword, so every keyword added to the rule must also be added to that condition or a folder move starts parsing as a document move. **Verify placement by differential count, not by reading the model**: run the script with and without the MOVE lines and diff `select ContainmentName, count(*) from Unit` — three new Folders rows (a nested path creates two) and an unchanged Documents count says reparented rather than copied or dropped. Grepping blobs for names is a trap; stock modules are full of the same words. Tests `visitor_move_doctypes_test.go`, example in `18-folder-examples.mdl` — which must sit **before** that script's `drop module`, a mistake the integration gate caught and `mxcli check` did not. mxcli-formula1 #32 | +| `create odata client` with credentials given as constants (`HttpUsername: '@Module.ApiUser'`) still gets HTTP 401 and an empty client, after the fix that made literal credentials work. Sharpened by the same release making a constant `ServiceUrl` mandatory — the shape the tool insists on is the shape whose credentials it will not read | `resolveCredential` trusted the visitor's isLiteral flag. `'@Module.ApiUser'` **is** a STRING_LITERAL, so the flag said "literal" and the previous code sent the fifteen characters `@Module.ApiUser` as the username — and the unresolved-credential note did not fire either, because as far as the code knew nothing was unresolved | `mdl/executor/cmd_odata.go` (`resolveCredential`, `constantReference`, `designTimeConstants`) | **A syntactic classification is not a semantic one.** The visitor can say "this was a quoted string"; only the executor can say "this quoted string names a constant". Any flag of the form isLiteral needs the consumer to ask what the literal *contains* before treating it as a value. **The fix is to resolve, not to refuse**: a constant's design-time default is exactly what Studio Pro sends on the same fetch, so reading it is the value rather than a workaround — and mxcli already has the project open. Three spellings must all work (`'v'`, `@M.C`, `'@M.C'`); a dotted literal like a password containing a dot must not be mistaken for a reference. Tests `cmd_odata_metadata_auth_test.go`. mxcli-formula1 #23 follow-up | +| An app themed dark still shows light-mode drop shadows under the datagrid's filter-operator popover and dropdown filter lists | The generated widget layer re-pointed `.column-selectors` but not the four rules in `_datagrid-filters.scss` that bake the same two-layer shadow. Each already takes its *background* from `--bg-color-secondary`, so Atlas re-colours the panel and leaves the shadow — which is why it reads as a partial fix rather than an untouched widget | `cmd/mxcli/theme/assets/*/files/theme/web/_mxcli-widgets.scss` | **Read the selectors out of the shipped `themesource/`, not the bug report** — the fourth here is `.dropdown-container .dropdown-list`, nested under a parent the report did not name. **Verify in the compiled CSS, never the source** (the §33 rule): apply the theme to a real project, run `mxbuild --target=deploy --java-home=… --java-exe-path=…`, then grep `theme-cache/web/theme.compiled.css` and check the *line number* — the fix must land after the widget module's own rule (30794 vs 27765 here) or the cascade eats it. A theme-cache file older than the SCSS you just wrote is a stale compile, and mtime is the cheapest way to catch it. mxcli-formula1 #33 / suggested issue 4 | From b48a50c0e899a00e97aac3efc0b9c11f53f26d5e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 15:25:43 +0000 Subject: [PATCH 4/4] Add LIST FOLDERS: read a module's folder layout back out of the model MOVE could place a document in a folder, but nothing could read the placement back. SHOW STRUCTURE groups by document type at every depth and never names a folder; DESCRIBE answers for one document at a time. So a move could not be confirmed, and an intended layout could not be diffed against the real one, without opening the .mpr as SQLite. LIST FOLDERS [IN Module] renders module -> folder path -> documents: Mv (module root) [1] Microflow Read_Rows Api [0] Api/Published [1] ODataService Api Support [1] JavaAction Helper Three properties are load-bearing for the diff use case: - Empty folders are listed ([0]). A listing that hid them could not round-trip against an intended layout. - Documents still at the module root appear under "(module root)" rather than by subtraction -- what is not filed yet is what you most want to notice. - Ordering is stable, so a diff shows only real movement. Documents are indexed by ContainerID across every list call the backend offers, each best-effort: a backend that cannot answer one kind yields a listing missing that kind rather than no listing at all. LIST is the verb per .claude/skills/design-mdl-syntax.md; SHOW is accepted as the legacy spelling. FOLDERS is added to the keyword rule so it remains usable as an identifier. Wired end to end: MDLLexer.g4 (FOLDERS), MDLCatalog.g4 (showOrList FOLDERS (IN ...)?), ast.ShowFolders, visitor, execShow. Syntax topic "folders", quick-reference rows, organize-project skill section, doctype example, symptom row. Tests: cmd_list_folders_test.go. Verified live against a real project (both verbs, with and without IN, --json). mxcli-formula1 issue #2 --- .claude/skills/fix-issue.md | 1 + .claude/skills/mendix/organize-project.md | 42 +++ cmd/mxcli/lsp_completions_gen.go | 1 + cmd/mxcli/syntax/features_misc.go | 35 ++- docs/01-project/MDL_QUICK_REFERENCE.md | 2 + .../doctype-tests/18-folder-examples.mdl | 18 ++ mdl/ast/ast_query.go | 3 + mdl/executor/cmd_list_folders.go | 265 ++++++++++++++++++ mdl/executor/cmd_list_folders_test.go | 110 ++++++++ mdl/executor/executor_query.go | 2 + mdl/grammar/MDLLexer.g4 | 1 + mdl/grammar/domains/MDLCatalog.g4 | 5 + mdl/grammar/domains/MDLSettings.g4 | 2 +- mdl/visitor/visitor_query.go | 10 + 14 files changed, 495 insertions(+), 2 deletions(-) create mode 100644 mdl/executor/cmd_list_folders.go create mode 100644 mdl/executor/cmd_list_folders_test.go diff --git a/.claude/skills/fix-issue.md b/.claude/skills/fix-issue.md index a52aaf3a8..cb6eddabe 100644 --- a/.claude/skills/fix-issue.md +++ b/.claude/skills/fix-issue.md @@ -423,3 +423,4 @@ extracting `OffsetExpression`/`LimitExpression`. | `MOVE JAVA ACTION …` / `MOVE ODATA SERVICE …` is a parse error (`no viable alternative at input 'MOVEJAVA'`), and neither `CREATE` form takes a folder clause — so those documents can never leave the module root from MDL | The `moveStatement` rule listed seven doctypes and nothing else; the missing ones were never unimplemented, just unlisted | `mdl/grammar/MDLParser.g4` (two alternatives), `mdl/ast/ast.go`, `mdl/visitor/visitor_entity.go` (dispatch **and** the MOVE FOLDER discriminator), `mdl/executor/cmd_move.go`, backend `MoveJavaAction` / `MovePublishedODataService`, `sdk/mpr` exports `MoveUnitByID` | Both reduce to the existing reparent primitive — a top-level document move is one containment row, so a new doctype is a list entry plus a lookup, not new machinery. **Watch the discriminator**: `MOVE FOLDER` is told apart from a document move by the *absence* of a doctype keyword, so every keyword added to the rule must also be added to that condition or a folder move starts parsing as a document move. **Verify placement by differential count, not by reading the model**: run the script with and without the MOVE lines and diff `select ContainmentName, count(*) from Unit` — three new Folders rows (a nested path creates two) and an unchanged Documents count says reparented rather than copied or dropped. Grepping blobs for names is a trap; stock modules are full of the same words. Tests `visitor_move_doctypes_test.go`, example in `18-folder-examples.mdl` — which must sit **before** that script's `drop module`, a mistake the integration gate caught and `mxcli check` did not. mxcli-formula1 #32 | | `create odata client` with credentials given as constants (`HttpUsername: '@Module.ApiUser'`) still gets HTTP 401 and an empty client, after the fix that made literal credentials work. Sharpened by the same release making a constant `ServiceUrl` mandatory — the shape the tool insists on is the shape whose credentials it will not read | `resolveCredential` trusted the visitor's isLiteral flag. `'@Module.ApiUser'` **is** a STRING_LITERAL, so the flag said "literal" and the previous code sent the fifteen characters `@Module.ApiUser` as the username — and the unresolved-credential note did not fire either, because as far as the code knew nothing was unresolved | `mdl/executor/cmd_odata.go` (`resolveCredential`, `constantReference`, `designTimeConstants`) | **A syntactic classification is not a semantic one.** The visitor can say "this was a quoted string"; only the executor can say "this quoted string names a constant". Any flag of the form isLiteral needs the consumer to ask what the literal *contains* before treating it as a value. **The fix is to resolve, not to refuse**: a constant's design-time default is exactly what Studio Pro sends on the same fetch, so reading it is the value rather than a workaround — and mxcli already has the project open. Three spellings must all work (`'v'`, `@M.C`, `'@M.C'`); a dotted literal like a password containing a dot must not be mistaken for a reference. Tests `cmd_odata_metadata_auth_test.go`. mxcli-formula1 #23 follow-up | | An app themed dark still shows light-mode drop shadows under the datagrid's filter-operator popover and dropdown filter lists | The generated widget layer re-pointed `.column-selectors` but not the four rules in `_datagrid-filters.scss` that bake the same two-layer shadow. Each already takes its *background* from `--bg-color-secondary`, so Atlas re-colours the panel and leaves the shadow — which is why it reads as a partial fix rather than an untouched widget | `cmd/mxcli/theme/assets/*/files/theme/web/_mxcli-widgets.scss` | **Read the selectors out of the shipped `themesource/`, not the bug report** — the fourth here is `.dropdown-container .dropdown-list`, nested under a parent the report did not name. **Verify in the compiled CSS, never the source** (the §33 rule): apply the theme to a real project, run `mxbuild --target=deploy --java-home=… --java-exe-path=…`, then grep `theme-cache/web/theme.compiled.css` and check the *line number* — the fix must land after the widget module's own rule (30794 vs 27765 here) or the cascade eats it. A theme-cache file older than the SCSS you just wrote is a stale compile, and mtime is the cheapest way to catch it. mxcli-formula1 #33 / suggested issue 4 | +| A `MOVE` cannot be confirmed and a module's layout cannot be reviewed: `SHOW STRUCTURE` groups by document type at every depth and never names a folder, `DESCRIBE` answers for one document at a time, so checking where things ended up means opening the `.mpr` as SQLite | The read side of folders was simply never built. `MOVE`/`DROP FOLDER` write containment; nothing read it back | New `mdl/executor/cmd_list_folders.go` + grammar (`FOLDERS` lexer token, `showOrList FOLDERS (IN …)?` in `MDLCatalog.g4`, `FOLDERS` added to the `keyword` rule so it stays usable as an identifier), `mdl/ast/ast_query.go`, `mdl/visitor/visitor_query.go`, `mdl/executor/executor_query.go` | **A layout listing must show what is *not* there**: empty folders (`[0]`) and documents still at the module root, or it cannot be diffed against an intended layout — that is the whole use. Documents are indexed by `ContainerID` across every list call the backend offers, each best-effort, so a backend that cannot answer one kind yields a listing missing that kind rather than no listing. **Do not stub the hierarchy in the test** — `mkHierarchy` populates `moduleNames` but not `folderNames`, so `BuildFolderPath` returns `""` and every folder silently collapses into the module root, which looks exactly like the bug. Build it from the mock's `ListModules`/`ListUnits`/`ListFolders`, as `getHierarchy` does. Tests `cmd_list_folders_test.go`, example in `18-folder-examples.mdl`. mxcli-formula1 issue #2 | diff --git a/.claude/skills/mendix/organize-project.md b/.claude/skills/mendix/organize-project.md index 4f28da0fc..64929037f 100644 --- a/.claude/skills/mendix/organize-project.md +++ b/.claude/skills/mendix/organize-project.md @@ -106,6 +106,47 @@ begin end; ``` +## Reading the Layout Back + +`list folders` shows the folder layout of a module and what is in each folder. +This is the counterpart to `move`: `move` puts a document somewhere, `list +folders` shows where everything actually is. + +```sql +-- One module +list folders in MyModule; + +-- Every module in the project +list folders; +``` + +``` +MyModule + (module root) [1] + Microflow ACT_Unfiled + Api [0] + Api/Published [1] + ODataService PublicApi + Support [1] + JavaAction Helper + +(3 folder(s), 3 document(s)) +``` + +Three things about the output are deliberate: + +- **Empty folders are listed** (`Api [0]`), so the listing is the whole layout + and can be diffed against an intended one. +- **Documents still at the module root** appear under `(module root)` — what is + not filed yet is the thing you most want to notice. +- **Ordering is stable**, so a diff between two runs shows only real movement. + +Use the CLI's `--json` flag for a row per document (`Module, Folder, Kind, Document`) +when comparing against a checked-in layout. + +Do **not** reach for `show structure` here: it groups by document type at every +depth and never shows which folder a document sits in. + ## Moving Documents The `move` command relocates existing documents between folders and modules. @@ -252,3 +293,4 @@ drop folder 'Processing' in MyModule; - [ ] Cross-module moves: checked impact with `show impact of` first - [ ] Folder naming is consistent across modules - [ ] DROP FOLDER: verify folder is empty before dropping +- [ ] After a batch of moves: `list folders in MyModule` to confirm the layout diff --git a/cmd/mxcli/lsp_completions_gen.go b/cmd/mxcli/lsp_completions_gen.go index 40b294377..00b1f9edd 100644 --- a/cmd/mxcli/lsp_completions_gen.go +++ b/cmd/mxcli/lsp_completions_gen.go @@ -256,6 +256,7 @@ var mdlGeneratedKeywords = []protocol.CompletionItem{ {Label: "AUTOFILL", Kind: protocol.CompletionItemKindKeyword, Detail: "Widget keyword"}, {Label: "URL", Kind: protocol.CompletionItemKindKeyword, Detail: "Widget keyword"}, {Label: "FOLDER", Kind: protocol.CompletionItemKindKeyword, Detail: "Widget keyword"}, + {Label: "FOLDERS", Kind: protocol.CompletionItemKindKeyword, Detail: "Widget keyword"}, {Label: "PASSING", Kind: protocol.CompletionItemKindKeyword, Detail: "Widget keyword"}, {Label: "CONTEXT", Kind: protocol.CompletionItemKindKeyword, Detail: "Widget keyword"}, {Label: "EDITABLE", Kind: protocol.CompletionItemKindKeyword, Detail: "Widget keyword"}, diff --git a/cmd/mxcli/syntax/features_misc.go b/cmd/mxcli/syntax/features_misc.go index 8d8837aa1..a2d8e5ed1 100644 --- a/cmd/mxcli/syntax/features_misc.go +++ b/cmd/mxcli/syntax/features_misc.go @@ -236,7 +236,40 @@ SHOW IMPACT OF OldModule.CustomerPage; MOVE PAGE OldModule.CustomerPage TO NewModule; -- Drop empty folder -DROP FOLDER 'OldFolder' IN Module;`, +DROP FOLDER 'OldFolder' IN Module; + +-- Read the placement back +LIST FOLDERS IN MyModule;`, + SeeAlso: []string{"folders"}, + }) + + // ── Folders ───────────────────────────────────────────────────────── + + Register(SyntaxFeature{ + Path: "folders", + Summary: "LIST FOLDERS — the folder layout of a module, with what is in each folder", + Keywords: []string{ + "folders", "list folders", "show folders", "layout", + "folder tree", "where is this document", "unfiled", + }, + Syntax: "LIST FOLDERS [IN ];", + Example: `-- Layout of one module +LIST FOLDERS IN MyModule; + +-- Every module in the project +LIST FOLDERS; + +-- As rows, to diff against an intended layout +mxcli -p app.mpr --json -c "LIST FOLDERS IN MyModule" + +-- Complements MOVE: MOVE places a document in a folder, LIST FOLDERS reads +-- the placement back. SHOW STRUCTURE is organised by document type at every +-- depth, so it never shows which folder a document sits in. +-- +-- Empty folders are listed too (with [0]), and documents still at the module +-- root appear under "(module root)" — so the output is the whole layout and +-- can be diffed against an intended one.`, + SeeAlso: []string{"move", "structure"}, }) // ── Search ────────────────────────────────────────────────────────── diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index a4ba27383..7775d3167 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -333,6 +333,7 @@ it is for pages. | Statement | Syntax | Notes | |-----------|--------|-------| +| List folders | `list folders [in module];` | The folder layout, with the documents in each folder | | Microflow folder | `folder 'path'` (before BEGIN) | `create microflow ... folder 'ACT' begin ... end;` | | Page folder | `folder: 'path'` (in properties) | `create page ... (folder: 'pages/Detail') { ... }` | | Drop folder | `drop folder 'path' in module;` | Folder must be empty | @@ -485,6 +486,7 @@ alter workflow Module.OrderApproval | Full types | `show structure depth 3;` | Typed attributes, named parameters | | Filter by module | `show structure in ModuleName;` | Single module only | | Include all modules | `show structure depth 1 all;` | Include system/marketplace modules | +| Folder layout | `list folders [in module];` | `show structure` is by document type at every depth and never shows folders — use this to read back where a `move` put something | ## Navigation diff --git a/mdl-examples/doctype-tests/18-folder-examples.mdl b/mdl-examples/doctype-tests/18-folder-examples.mdl index 18e5a7cec..06afa13fc 100644 --- a/mdl-examples/doctype-tests/18-folder-examples.mdl +++ b/mdl-examples/doctype-tests/18-folder-examples.mdl @@ -132,4 +132,22 @@ create odata service FolderTest.PublicApi ( move odata service FolderTest.PublicApi to folder 'Api/Published'; +-- ============================================================================ +-- Level 7: Read the layout back +-- ============================================================================ + +/** + * LIST FOLDERS is the counterpart to MOVE: it shows the folder layout and what + * sits in each folder, including empty folders and anything still at the module + * root. SHOW STRUCTURE groups by document type at every depth and never shows + * which folder a document is in. + */ +list folders in FolderTest; + +-- SHOW is accepted as the legacy verb for the same statement. +show folders in FolderTest; + +-- With no IN clause, every module in the project. +list folders; + drop module FolderTest; diff --git a/mdl/ast/ast_query.go b/mdl/ast/ast_query.go index 56bf540f0..d2b80be1f 100644 --- a/mdl/ast/ast_query.go +++ b/mdl/ast/ast_query.go @@ -24,6 +24,7 @@ type ShowObjectType int const ( ShowModules ShowObjectType = iota ShowEnumerations + ShowFolders ShowConstants ShowEntities ShowEntity @@ -110,6 +111,8 @@ func (t ShowObjectType) String() string { return "MODULES" case ShowEnumerations: return "ENUMERATIONS" + case ShowFolders: + return "FOLDERS" case ShowConstants: return "CONSTANTS" case ShowEntities: diff --git a/mdl/executor/cmd_list_folders.go b/mdl/executor/cmd_list_folders.go new file mode 100644 index 000000000..abdd99915 --- /dev/null +++ b/mdl/executor/cmd_list_folders.go @@ -0,0 +1,265 @@ +// SPDX-License-Identifier: Apache-2.0 + +// cmd_list_folders.go — LIST FOLDERS, the layout view. +// +// MOVE could place a document in a folder, but nothing could read the placement +// back: SHOW STRUCTURE is flat at every depth and DESCRIBE reports one document +// at a time. So a move could not be confirmed, and an intended layout could not +// be diffed against the real one, without opening the .mpr as SQLite +// (mxcli-formula1 #32). +package executor + +import ( + "fmt" + "sort" + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" + mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" + "github.com/mendixlabs/mxcli/model" +) + +// folderEntry is one folder and the documents directly inside it. +type folderEntry struct { + Module string + Path string // "" for the module root + Docs []folderDoc +} + +// folderDoc is a document placed in a folder, named by kind and name so the +// listing reads the way the model does. +type folderDoc struct { + Kind string + Name string +} + +// listFolders handles LIST FOLDERS [IN Module]. +func listFolders(ctx *ExecContext, s *ast.ShowStmt) error { + // execShow has already refused an unconnected project. + h, err := getHierarchy(ctx) + if err != nil { + return mdlerrors.NewBackend("build hierarchy", err) + } + folders, err := ctx.Backend.ListFolders() + if err != nil { + return mdlerrors.NewBackend("list folders", err) + } + + docsByContainer := documentsByContainer(ctx, h) + + // Every folder is a row, even an empty one — an empty folder is part of the + // layout and a diff that hid it would not round-trip. + entries := make(map[string]*folderEntry) + key := func(mod, path string) string { return mod + "\x00" + path } + add := func(mod, path string) *folderEntry { + k := key(mod, path) + if e, ok := entries[k]; ok { + return e + } + e := &folderEntry{Module: mod, Path: path} + entries[k] = e + return e + } + + for _, f := range folders { + if f == nil { + continue + } + mod := h.GetModuleName(h.FindModuleID(f.ID)) + if mod == "" || !moduleMatches(mod, s.InModule) { + continue + } + e := add(mod, h.BuildFolderPath(f.ID)) + e.Docs = append(e.Docs, docsByContainer[f.ID]...) + } + + // Documents still at the module root, so "what is not filed yet" is visible + // in the same view rather than by subtraction. + for _, m := range modulesInScope(ctx, s.InModule) { + if docs := docsByContainer[m.ID]; len(docs) > 0 { + add(m.Name, "").Docs = append(add(m.Name, "").Docs, docs...) + } + } + + ordered := make([]*folderEntry, 0, len(entries)) + for _, e := range entries { + sortFolderDocs(e.Docs) + ordered = append(ordered, e) + } + // Module, then path: the root ("") sorts first inside each module, which is + // where an unfiled document is most worth noticing. + sort.Slice(ordered, func(i, j int) bool { + if ordered[i].Module != ordered[j].Module { + return ordered[i].Module < ordered[j].Module + } + return ordered[i].Path < ordered[j].Path + }) + + if ctx.Format == FormatJSON { + return writeResult(ctx, foldersJSON(ordered)) + } + return writeFoldersText(ctx, ordered, s.InModule) +} + +// writeFoldersText renders the layout as an indented list — a shape that diffs +// cleanly against a checked-in expectation. +func writeFoldersText(ctx *ExecContext, entries []*folderEntry, inModule string) error { + if len(entries) == 0 { + if inModule != "" { + fmt.Fprintf(ctx.Output, "No folders or documents in %s.\n", inModule) + } else { + fmt.Fprintln(ctx.Output, "No folders in this project.") + } + return nil + } + + lastModule := "" + folderCount, docCount := 0, 0 + for _, e := range entries { + if e.Module != lastModule { + if lastModule != "" { + fmt.Fprintln(ctx.Output) + } + fmt.Fprintf(ctx.Output, "%s\n", e.Module) + lastModule = e.Module + } + label := e.Path + if label == "" { + label = "(module root)" + } else { + folderCount++ + } + fmt.Fprintf(ctx.Output, " %s [%d]\n", label, len(e.Docs)) + for _, d := range e.Docs { + fmt.Fprintf(ctx.Output, " %s %s\n", d.Kind, d.Name) + docCount++ + } + } + fmt.Fprintf(ctx.Output, "\n(%d folder(s), %d document(s))\n", folderCount, docCount) + return nil +} + +func foldersJSON(entries []*folderEntry) *TableResult { + result := &TableResult{Columns: []string{"Module", "Folder", "Kind", "Document"}} + for _, e := range entries { + path := e.Path + if len(e.Docs) == 0 { + result.Rows = append(result.Rows, []any{e.Module, path, "", ""}) + continue + } + for _, d := range e.Docs { + result.Rows = append(result.Rows, []any{e.Module, path, d.Kind, d.Name}) + } + } + return result +} + +// documentsByContainer indexes every document mxcli can name by the container it +// sits in. Container, not module: that is the whole point — a document's folder +// is its ContainerID, and the module is only what you get by walking up. +// +// Each list is best-effort. A backend that cannot answer one of them yields a +// listing missing that kind rather than no listing at all. +func documentsByContainer(ctx *ExecContext, h *ContainerHierarchy) map[model.ID][]folderDoc { + out := map[model.ID][]folderDoc{} + put := func(kind, name string, container model.ID) { + if name == "" { + return + } + out[container] = append(out[container], folderDoc{Kind: kind, Name: name}) + } + + if v, err := ctx.Backend.ListMicroflows(); err == nil { + for _, x := range v { + put("Microflow", x.Name, x.ContainerID) + } + } + if v, err := ctx.Backend.ListNanoflows(); err == nil { + for _, x := range v { + put("Nanoflow", x.Name, x.ContainerID) + } + } + if v, err := ctx.Backend.ListPages(); err == nil { + for _, x := range v { + put("Page", x.Name, x.ContainerID) + } + } + if v, err := ctx.Backend.ListSnippets(); err == nil { + for _, x := range v { + put("Snippet", x.Name, x.ContainerID) + } + } + if v, err := ctx.Backend.ListEnumerations(); err == nil { + for _, x := range v { + put("Enumeration", x.Name, x.ContainerID) + } + } + if v, err := ctx.Backend.ListConstants(); err == nil { + for _, x := range v { + put("Constant", x.Name, x.ContainerID) + } + } + if v, err := ctx.Backend.ListJavaActionsFull(); err == nil { + for _, x := range v { + put("JavaAction", x.Name, x.ContainerID) + } + } + if v, err := ctx.Backend.ListDatabaseConnections(); err == nil { + for _, x := range v { + put("DatabaseConnection", x.Name, x.ContainerID) + } + } + if v, err := ctx.Backend.ListPublishedODataServices(); err == nil { + for _, x := range v { + put("ODataService", x.Name, x.ContainerID) + } + } + if v, err := ctx.Backend.ListConsumedODataServices(); err == nil { + for _, x := range v { + put("ODataClient", x.Name, x.ContainerID) + } + } + if v, err := ctx.Backend.ListWorkflows(); err == nil { + for _, x := range v { + put("Workflow", x.Name, x.ContainerID) + } + } + if v, err := ctx.Backend.ListScheduledEvents(); err == nil { + for _, x := range v { + put("ScheduledEvent", x.Name, x.ContainerID) + } + } + return out +} + +// modulesInScope returns the modules the listing covers. +func modulesInScope(ctx *ExecContext, inModule string) []*model.Module { + modules, err := getModulesFromCache(ctx) + if err != nil { + return nil + } + var out []*model.Module + for _, m := range modules { + if moduleMatches(m.Name, inModule) { + out = append(out, m) + } + } + return out +} + +// moduleMatches applies the optional IN clause, case-insensitively as MDL is +// elsewhere. An empty filter matches everything. +func moduleMatches(name, filter string) bool { + return filter == "" || strings.EqualFold(name, filter) +} + +// sortFolderDocs orders a folder's contents by kind then name, so the same model +// always renders the same listing and a diff shows only real movement. +func sortFolderDocs(docs []folderDoc) { + sort.Slice(docs, func(i, j int) bool { + if docs[i].Kind != docs[j].Kind { + return docs[i].Kind < docs[j].Kind + } + return docs[i].Name < docs[j].Name + }) +} diff --git a/mdl/executor/cmd_list_folders_test.go b/mdl/executor/cmd_list_folders_test.go new file mode 100644 index 000000000..38f7a5adb --- /dev/null +++ b/mdl/executor/cmd_list_folders_test.go @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "bytes" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/javaactions" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// mxcli-formula1 #32: MOVE could place a document in a folder, but nothing could +// read the placement back — SHOW STRUCTURE is flat at every depth and DESCRIBE +// reports one document at a time. So a move could not be confirmed, and an +// intended layout could not be diffed against the real one, without opening the +// .mpr as SQLite. +func TestListFolders(t *testing.T) { + ctx, buf := foldersFixture(t) + + assertNoError(t, listFolders(ctx, &ast.ShowStmt{ObjectType: ast.ShowFolders, InModule: "Mv"})) + out := buf.String() + + // A nested path is one row per level, so the layout reads as a tree. + assertContainsStr(t, out, "Api/Published") + assertContainsStr(t, out, "ODataService Api") + assertContainsStr(t, out, "Support") + assertContainsStr(t, out, "JavaAction Helper") + + // An empty folder is part of the layout: a listing that hid it could not + // round-trip against an intended one. + if !strings.Contains(out, "Api [0]") { + t.Errorf("the empty intermediate folder is missing:\n%s", out) + } + + // A document still at the module root is the thing you most want to notice, + // so it appears in the same view rather than by subtraction. + assertContainsStr(t, out, "(module root)") + assertContainsStr(t, out, "Microflow Unfiled") +} + +// The IN clause scopes the listing, case-insensitively as MDL is elsewhere. +func TestListFolders_ModuleFilter(t *testing.T) { + for _, filter := range []string{"Mv", "mv", "MV"} { + ctx, buf := foldersFixture(t) + assertNoError(t, listFolders(ctx, &ast.ShowStmt{ObjectType: ast.ShowFolders, InModule: filter})) + if out := buf.String(); !strings.Contains(out, "Support") || strings.Contains(out, "Other") { + t.Errorf("filter %q listed the wrong modules:\n%s", filter, out) + } + } + + // A module with no folders and no documents says so rather than printing an + // empty listing that reads like a broken command. + ctx, buf := foldersFixture(t) + assertNoError(t, listFolders(ctx, &ast.ShowStmt{ObjectType: ast.ShowFolders, InModule: "Nope"})) + assertContainsStr(t, buf.String(), "No folders or documents in Nope") +} + +// Two runs over the same model must render identically, or a diff against a +// checked-in layout shows movement that did not happen. +func TestListFolders_Deterministic(t *testing.T) { + ctx1, buf1 := foldersFixture(t) + assertNoError(t, listFolders(ctx1, &ast.ShowStmt{ObjectType: ast.ShowFolders})) + ctx2, buf2 := foldersFixture(t) + assertNoError(t, listFolders(ctx2, &ast.ShowStmt{ObjectType: ast.ShowFolders})) + if buf1.String() != buf2.String() { + t.Errorf("output is not stable across runs:\n--- 1 ---\n%s\n--- 2 ---\n%s", buf1.String(), buf2.String()) + } +} + +// foldersFixture builds a two-module model: Mv with Support and Api/Published +// (the second nested under an otherwise empty Api), one document in each, one +// microflow left at the module root, and an unrelated module to test the filter. +func foldersFixture(t *testing.T) (*ExecContext, *bytes.Buffer) { + t.Helper() + mv := mkModule("Mv") + other := mkModule("Other") + + support := &types.FolderInfo{ID: "f-support", ContainerID: mv.ID, Name: "Support"} + api := &types.FolderInfo{ID: "f-api", ContainerID: mv.ID, Name: "Api"} + published := &types.FolderInfo{ID: "f-pub", ContainerID: api.ID, Name: "Published"} + + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mv, other}, nil }, + ListFoldersFunc: func() ([]*types.FolderInfo, error) { + return []*types.FolderInfo{support, api, published}, nil + }, + ListUnitsFunc: func() ([]*types.UnitInfo, error) { return nil, nil }, + ListMicroflowsFunc: func() ([]*microflows.Microflow, error) { + return []*microflows.Microflow{{Name: "Unfiled", ContainerID: mv.ID}}, nil + }, + ListJavaActionsFullFunc: func() ([]*javaactions.JavaAction, error) { + return []*javaactions.JavaAction{{Name: "Helper", ContainerID: support.ID}}, nil + }, + ListPublishedODataServicesFunc: func() ([]*model.PublishedODataService, error) { + return []*model.PublishedODataService{{Name: "Api", ContainerID: published.ID}}, nil + }, + } + + // No stub hierarchy: the listing's whole job is to read placement back out of + // the model, so it is built from the same three backend calls production uses. + ctx, buf := newMockCtx(t, withBackend(mb)) + return ctx, buf +} diff --git a/mdl/executor/executor_query.go b/mdl/executor/executor_query.go index b7bcaab41..2ba2519b6 100644 --- a/mdl/executor/executor_query.go +++ b/mdl/executor/executor_query.go @@ -15,6 +15,8 @@ func execShow(ctx *ExecContext, s *ast.ShowStmt) error { switch s.ObjectType { case ast.ShowModules: return listModules(ctx) + case ast.ShowFolders: + return listFolders(ctx, s) case ast.ShowEnumerations: return listEnumerations(ctx, s.InModule) case ast.ShowConstants: diff --git a/mdl/grammar/MDLLexer.g4 b/mdl/grammar/MDLLexer.g4 index cd9f1f98c..57d3684fb 100644 --- a/mdl/grammar/MDLLexer.g4 +++ b/mdl/grammar/MDLLexer.g4 @@ -344,6 +344,7 @@ HEIGHT: H E I G H T; AUTOFILL: A U T O F I L L; URL: U R L; FOLDER: F O L D E R; +FOLDERS: F O L D E R S; PASSING: P A S S I N G; CONTEXT: C O N T E X T; EDITABLE: E D I T A B L E; diff --git a/mdl/grammar/domains/MDLCatalog.g4 b/mdl/grammar/domains/MDLCatalog.g4 index 9cd613bbd..f5e606fae 100644 --- a/mdl/grammar/domains/MDLCatalog.g4 +++ b/mdl/grammar/domains/MDLCatalog.g4 @@ -26,6 +26,11 @@ showStatement | showOrList SNIPPETS (IN (qualifiedName | IDENTIFIER))? | showOrList BUILDING BLOCKS (IN (qualifiedName | IDENTIFIER))? | showOrList ENUMERATIONS (IN (qualifiedName | IDENTIFIER))? + // LIST FOLDERS is the layout view: which folders exist and what is in them. + // MOVE could place a document but nothing could read the placement back, so + // a layout could not be confirmed or diffed without opening the .mpr as + // SQLite (mxcli-formula1 #32). + | showOrList FOLDERS (IN (qualifiedName | IDENTIFIER))? | showOrList CONSTANTS (IN (qualifiedName | IDENTIFIER))? | showOrList CONSTANT VALUES (IN (qualifiedName | IDENTIFIER))? | showOrList LAYOUTS (IN (qualifiedName | IDENTIFIER))? diff --git a/mdl/grammar/domains/MDLSettings.g4 b/mdl/grammar/domains/MDLSettings.g4 index df442089f..29bc1efdc 100644 --- a/mdl/grammar/domains/MDLSettings.g4 +++ b/mdl/grammar/domains/MDLSettings.g4 @@ -498,7 +498,7 @@ keyword | INTEGER_TYPE | LONG_TYPE | STRING_TYPE | STRINGTEMPLATE_TYPE // Module / project structure - | ACTIONS | ARTIFACT | COLLECTION | DEPENDENCIES | DEPENDENCY | EXCLUSION | FOLDER + | ACTIONS | ARTIFACT | COLLECTION | DEPENDENCIES | DEPENDENCY | EXCLUSION | FOLDER | FOLDERS | INCLUDED | JAR | LAYOUT | LAYOUTS | LOCAL | MODEL | MODELS | MODULE | MODULES | NOTEBOOK | NOTEBOOKS | PAGE | PAGES | PROJECT | SNIPPET | SNIPPETS | BUILDING | BLOCK | BLOCKS diff --git a/mdl/visitor/visitor_query.go b/mdl/visitor/visitor_query.go index 8ae708a13..79170dfae 100644 --- a/mdl/visitor/visitor_query.go +++ b/mdl/visitor/visitor_query.go @@ -104,6 +104,16 @@ func (b *Builder) ExitShowStatement(ctx *parser.ShowStatementContext) { Name: &name, }) } + } else if ctx.FOLDERS() != nil { + stmt := &ast.ShowStmt{ObjectType: ast.ShowFolders} + if ctx.IN() != nil { + if qn := ctx.QualifiedName(); qn != nil { + stmt.InModule = getQualifiedNameText(qn) + } else if id := ctx.IDENTIFIER(); id != nil { + stmt.InModule = id.GetText() + } + } + b.statements = append(b.statements, stmt) } else if ctx.ENUMERATIONS() != nil { stmt := &ast.ShowStmt{ObjectType: ast.ShowEnumerations} if ctx.IN() != nil {