diff --git a/.claude/skills/fix-issue/findings/cmd-mxcli.jsonl b/.claude/skills/fix-issue/findings/cmd-mxcli.jsonl index 8d26d2071..b6fb95bab 100644 --- a/.claude/skills/fix-issue/findings/cmd-mxcli.jsonl +++ b/.claude/skills/fix-issue/findings/cmd-mxcli.jsonl @@ -167,3 +167,4 @@ {"area": "cmd/mxcli", "date": "2026-10-05", "symptom": "`mxcli playwright check` (and the login of `run --page-check`/`--screenshot-user`) failed with \"node not found; the page check needs the Node that Playwright uses\" on a machine with no system node whose newest cached mxbuild is 11.15, although mxbuild 11.15 ships tools/node//node", "cause": "resolveNodeForScript found mxbuild's bundled node through resolveNodeTooling, which also requires tools/node/rollup-runner.mjs; mxbuild 11.15 dropped the rollup runner (rspack only), so the lookup failed before it looked for the node binary", "file": "`cmd/mxcli/docker/screenshot_login.go` (resolveNodeForScript)", "fix": "look up the node binary directly with findNodeBinary(/tools/node) instead of going through the rollup-runner check", "insight": "a helper that validates a whole toolchain was reused to find one binary in it; when the platform dropped an unrelated piece of that toolchain the reuse broke a caller that never needed it. Found by a smoke test on a machine whose newest mxbuild was 11.15, not by unit tests, which put node on PATH", "test": "cmd/mxcli/docker/screenshot_login_node_test.go TestResolveNodeForScript_RspackOnlyMxBuild (PATH emptied, 11.15 tools/node layout; fails with the old lookup)"} {"area": "cmd/mxcli", "date": "2026-10-05", "symptom": "`mxcli run --local --watch`: a model change made while the app is still booting is never built — the app keeps serving the model from before it, `--watch` logs no `Change detected`, and a waiter on the change times out. Re-running the exec does nothing (byte-idempotent: it writes nothing, so nothing re-triggers the watcher)", "cause": "watchAndApply took its baseline `last := sourceMTime(...)` after the boot finished, so an edit landing between the boot build's model read and the watch loop's start was already folded into the baseline", "file": "`cmd/mxcli/docker/runlocal.go` (`RunLocal` bootSource, `watchAndApply`)", "insight": "A watcher's baseline must be the source time the last build was MADE FROM, taken before that build — not 'now' when the watcher starts. Same class as the earlier fix that stopped moving the baseline to time.Now() after an apply. Measured with the run-lifecycle integration test: exec a page while the detached run's state says the boot build is in progress; with the old baseline `run wait` times out after 2m (no build #2), with the fix it reports `applied: build #2 via reload`. Exposed by making `run wait` race-free on source time: a generation-counting wait could not have told the change was lost.", "refs": ["cmd_run_lifecycle_integration_test.go"]} {"area": "cmd/mxcli", "date": "2026-10-06", "symptom": "`mxcli run --local --watch` on Mendix 10.24 / 11.6 dies after 5 minutes with `starting web client bundler: web client watcher timed out after 5m0s`, while the bundler's own log says `Bundling finished in 8999 milliseconds` — the bundle was done in 9s", "cause": "`parseBundlerStatus` (`cmd/mxcli/docker/webclient_watch.go`) only understood the modern-web-bundler protocol (`{\"protocol\":\"mx-modern-web-bundler\",\"type\":\"status\",\"payload\":{\"kind\":…}}`). The rollup-runner.mjs shipped with 10.24 and 11.6 predates it and writes `{\"code\":\"START\"|\"SUCCESS\"|\"ERROR\",\"payload\":…}` (ERROR payload an object with `message`, or a bare string when the config fails to load). Every status line was dropped as plain logging, so the first-build wait never saw a success", "file": "cmd/mxcli/docker/webclient_watch.go", "insight": "--watch had never worked on these versions: the parser has accepted only the new protocol since it was written (#349). Nothing exercised the watcher below 11.12 until the run-lifecycle integration test reached the nightly matrix — and it reached it only because ResolveMxForVersion substitutes ANY cached mxbuild for the test's default 11.13.0, so each matrix leg ran it against its own version. The runner's protocol is part of the mxbuild version contract: read tools/node/rollup-runner.mjs of the oldest supported version before assuming a stdout shape. Control: the lifecycle test on 11.6.8 with the parser reverted fails with the nightly's exact timeout (MXCLI_WEB_CLIENT_TIMEOUT=60s makes it fail in a minute); with the fix it passes", "refs": ["nightly 2026-10-06 (ako/mxcli run 37437089065, mendixlabs/mxcli run 37437605368)"]} +{"area": "cmd/mxcli", "date": "2026-10-06", "symptom": "`mxcli syntax rename` printed `Unknown topic: rename` and `mxcli syntax --json` had rename only for attributes, values and definitions, while `RENAME MICROFLOW TO ;` passed `mxcli check` and `mxcli help rename` documented it; an agent concluded MDL could not rename a microflow and copied, re-pointed and deleted it by hand", "cause": "The renameStatement rule (MDLParser.g4) and its executor (mdl/executor/cmd_rename.go) never got a SyntaxFeature in cmd/mxcli/syntax/; the only discovery path was the cobra `rename` subcommand's Long text, which `syntax` does not read. BySegmentMatch could not rescue it because no registered path has a `rename` segment", "file": "cmd/mxcli/syntax/features_misc.go", "insight": "Same class as the TABCONTAINER gap (widget_keywords_drift_test.go): an agent treats absence from `mxcli syntax` as absence from the language, so a statement the grammar accepts but the registry omits is effectively missing. Fixed with a `rename` topic plus a guard (rename_topic_test.go) that reads the renameTarget rule from the .g4 and requires every alternative in the topic, and see_also links from microflow/page/entity/move. The CLI subcommand and the MDL statement diverge (MDL also takes JAVA ACTION and WORKFLOW); the topic says so rather than implying parity. Control: with the topic reverted the built binary prints `Unknown topic: rename` verbatim and TestSyntaxRenameTopic fails; dropping the RENAME WORKFLOW line fails the grammar guard. A sweep for other top-level statements with no topic would be the cheap next step", "refs": ["mendixlabs/mxcli#1318"]} diff --git a/.claude/skills/fix-issue/findings/mdl-backend.jsonl b/.claude/skills/fix-issue/findings/mdl-backend.jsonl index 776ee1c5a..69631cc90 100644 --- a/.claude/skills/fix-issue/findings/mdl-backend.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-backend.jsonl @@ -170,4 +170,5 @@ {"area": "mdl/backend", "date": "2026-10-04", "symptom": "ako/mxcli#980: create or modify navigation on a native profile ignored its menu block, sync block, login/not-found page and on-sync-error while reporting 'updated'; describe printed a native nanoflow home as `home microflow` and bottom bar items without action or icon", "cause": "navPatchNativeProfile only patched home pages; nativeNavProfileFromGen read BottomBarItem.Page only; the grammar had no nanoflow home", "file": "`mdl/backend/modelsdk/navigation_write.go` navPatchNativeProfile, `navigation_read.go` nativeNavProfileFromGen, `mdl/executor/cmd_navigation.go` checkProfileClauses", "insight": "A profile-kind-specific writer must refuse every clause it does not apply (checked in the executor before anything is written, including profile creation), and describe must not print what exec would refuse — list it as comments. No native profile exists in any local fixture, so the reader is pinned with a hand-built gen document", "refs": ["#980"]} {"area": "mdl/backend", "date": "2026-10-05", "symptom": "On Mendix 11.15 `TestMappingFixtureRoundTrip` fails for the blank app's own FeedbackModule mappings: describe -> exec adds `\"MessageDefinition\": \"\"`; and a mapping `with message definition` built on 11.15 fails `mx check` with CE0270 \"No root element could be found in the schema\"", "cause": "Mendix 11.15 REMOVED the mapping's `MessageDefinition` key (message definitions became `MessageDefinitions$MessageDefinition2` documents; the source moved to `MessageDefinition2` as `Module.MessageName`, measured with `mx convert` 11.15.0). The writer added the key unconditionally and the executor put the source in it, which 11.15 no longer reads", "file": "`model.ImportMapping`/`ExportMapping` (`MessageDefinition *string`, `MessageDefinitionSource()`), `mdl/backend/modelsdk/mapping_read.go` (`optionalStringFromRaw`) + `mapping_write.go`, `mdl/executor/mapping_messagedefinition.go` (`mappingMessageDefinitionKeys`, `messageDefinitionsAreDocuments`), `sdk/versions/mendix-1{0,1}.yaml` (`integration.message_definition_collection` max 11.14.99, `message_definition_document` min 11.15.0)", "insight": "**A key can be REMOVED by a version, not only introduced** \u2014 the #279 carry rule applies in both directions, so `MessageDefinition` is now a pointer like `MessageDefinition2`. The source key follows the stored document's shape on an update (a pre-11.15 document transplanted into an 11.15 project keeps its source in `MessageDefinition`, which is what kept the fixture's AgentCore/Email_Connector mappings green) and the project version only on a create. Caught by the new 11.15 nightly job, not by any fixture: none of the pinned documents was 11.15-shaped until the blank 11.15 app's own FeedbackModule mappings appeared in the base project", "refs": ["ako/mxcli#987", "ako/mxcli#279"]} {"area": "mdl/backend", "date": "2026-10-06", "symptom": "`describe microflow` prints `-- Unsupported action: Microflows$SendEmailAction` for the Studio Pro 11.13+ Send Email activity, and MDL has no statement to create it", "cause": "No reader case, and no grammar/AST/builder/writer. The vendored gen type `genMf.SendEmailAction`/`EmailMessage` predates 11.12: it binds Subject/MessageBody* as expressions, but 11.13 deleted those and stores SubjectTemplate/MessageBodyPlainTextTemplate/MessageBodyHtmlTemplate (Microflows$StringTemplate) plus a CustomHeaders list the gen type has no field for", "file": "mdl/backend/modelsdk/microflow_send_email.go", "insight": "The gen type existing (the issue cited it as 'the storage side seems known') was a false lead: it is the pre-11.12 shape, so both directions are raw-keyed against a Studio Pro-saved document (ako/TestApp Email.EmailMF) and a key-for-key shape test (TestSendEmailActionToGen_MatchesStudioProShape) pins types and markers — it caught ConnectionTimeout being int64 and the empty CustomHeaders list needing marker 3. Version facts come from mendixmodelsdk 4.116 StructureVersionInfo (templates 11.12.0, plain Subject deleted 11.13.0), hence the 11.13.0 gate. Run negative controls through mxbuild before writing check rules: of the plausible rules, 'check server identity needs SSL' and the header-name rule are ACCEPTED by mxbuild 11.15.0-rc.4 (so warnings), while type faults are CE9528 for address/host/port/user (not the CE0117 exprcheck's E009 used to name — slots can now carry their own Mxbuild code). Attachment is a bare variable name (CE0109 on a wrong one). Shapes MDL cannot restate (auth document, pre-11.13 expression subject, non-SMTP) stay UnsupportedAction. Studio Pro's Test Email tab (TestEmailMessage) is written empty on rewrite, by design. An unedited describe->exec round trip is elided as Unchanged, so it cannot demonstrate loss; author-from-script is the meaningful control (old binary: `mismatched input 'email' expecting REST`).", "refs": ["mendixlabs/mxcli#1315"], "ce": ["CE9528", "CE0117", "CE0720", "CE0109"], "rules": ["MDL-EMAIL01", "MDL-EMAIL02", "MDL-EMAIL03", "E009"]} +{"area": "mdl/backend", "date": "2026-10-07", "symptom": "After `RENAME JAVA ACTION M.JA_Old TO JA_New` (or `mxcli rename java-action`), javasource/m/actions/JA_New.java still declares `public class JA_Old`, its constructor `public JA_Old(` and toString `return \"JA_Old\";` \u2014 javac: `class JA_Old is public, should be declared in a file named JA_Old.java`. `mxcli docker build` reports BUILD SUCCEEDED regardless", "cause": "Backend.RenameJavaSourceFile (mdl/backend/modelsdk/java_write.go) only os.Rename'd the file; nothing rewrote the generated parts that carry the action's name", "file": "mdl/backend/modelsdk/java_write.go, sdk/javaactions/rename.go", "insight": "A green mxbuild does NOT prove the javasource on disk compiles: mxbuild regenerates action stubs in the copy it builds (class, constructor, toString follow the model; user code, extra code and the import list are kept byte-for-byte, a mention of the old name inside them included), so the defect only shows where the on-disk file is compiled directly \u2014 `run --local --watch` hot reload, IDEs. The cheap probe is javac on the one file with runtime/bundles/com.mendix.public-api.jar on the classpath. The oracle for the rewrite is mxbuild itself: run the mxbuild binary on the project IN PLACE (not `docker build`, which uses a temp copy) and diff the javasource file \u2014 it changed exactly three lines; that pair is now testdata/mxbuild/JA_Renamed*. Rewrite those three spots, not a full GenerateSource, which would reformat a Studio Pro-authored file. Control: unfixed binary \u2192 javac error above; fixed \u2192 javac exit 0, docker build BUILD SUCCEEDED", "refs": ["follow-up to mendixlabs/mxcli#1318"]} {"area": "mdl/backend", "date": "2026-10-06", "symptom": "Opening a change in Studio Pro's Changes panel throws `System.InvalidOperationException: Objects with ID … of type Forms$MicroflowParameterMapping do not have the same properties. baseNames = Expression, Parameter, Variable; newNames = Parameter, Expression` after `alter page … set ('onClickAction': call microflow M.F(P = $P)) on w`. `mx check` is 0 errors; the page opens. Second, silent half: `$P` (a page parameter) is stored as the Expression \"$P\", which Studio Pro does not bind (CE1571, the #1140 form)", "cause": "(1) `bindParameterMappingValue` sets only Expression for an Expression-bound argument; the gen `Variable` Part stays unset and the encoder omits an unset Part on a new element, so the key vanished. Studio Pro writes all three keys, nulling the unused slot, and its merge library compares key sets per $ID — the canon $ID carry makes the new mapping the SAME object as the stored one, so the diff sees two shapes of one element. (2) `convertASTAction` (ALTER PAGE SET Action and named action slots) built its pageBuilder with no paramScope/localVariables, so `classifyFlowArgValue` could not recognise a page/snippet parameter", "file": "`mdl/backend/modelsdk/widget_write.go` (RegisterTypeDefaults NullFields Variable on Forms$MicroflowParameterMapping + Forms$NanoflowParameterMapping), `mdl/executor/cmd_alter_page.go` (convertASTAction seeds ParamScope, storedPageVariables, isSnippet from the mutator)", "insight": "**`mx diff base.mpr new.mpr out.mpr` reproduces Changes-panel crashes headless** — it runs the same MergeLib DifferenceComputer and prints the identical \"do not have the same properties\" line (exit 129). Base = a copy of the project before the write, made by the FIXED build so it has Studio Pro's key set. That turns a Windows-GUI-only symptom into a two-binary A/B on Linux. **The base must share the $ID** — the crash needs a paired object, which the identity carry supplies; a fresh element is only an add and never trips it, so a create-only repro passes on broken code. **Register the null slot, don't set it at the call site**: `Part.Set(nil)` still encodes as omitted, and there are three construction sites (action, nanoflow action, nanoflow data source) — NullFields covers all and any future one. **A builder constructed outside CREATE PAGE is the recurring hole for #1140-style classification**: INSERT/REPLACE already seeded the scope from the mutator, SET did not; grep for `&pageBuilder{` and check each seeds paramScope + localVariables + isSnippet. Measured on mxbuild 11.12.2: buggy mx diff exit 129 with the reported line, fixed exit 0; mx check 0 errors on both", "refs": ["mendixlabs/mxcli#1317", "mendixlabs/mxcli#1180", "mendixlabs/mxcli#1140"], "ce": ["CE1571"]} diff --git a/CHANGELOG.md b/CHANGELOG.md index f9a7e5387..5da7ff5aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,8 +19,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **`call rest service` takes its settings as one property list** (ADR-0013) — the activity's dialog settings go in one `( Key: value, … )` list after the URL, keyed as the consumed REST service names the same concepts: `$Html = call rest service get 'https://example.com' (Headers: ('Accept': 'text/html'), Authentication: basic (Username: $User, Password: $Password), Timeout: 300) returns String;`. `Body:` is `template '…' [with ({1} = …)]`, `mapping M.EMM from $Var`, `binary ` or an expression. The method, URL, `returns …` and `on error …` stay words. An unknown or repeated key, or a value of the wrong shape, is an error. `describe` writes this form. **Migrating a script:** nothing breaks — the clauses `header 'N' = v`, `auth basic $u password $p`, `body …` and `timeout n` (**MDL-DEPR720**) still parse and store the same activity, `check` / `exec` warn, and `mxcli fmt --upgrade` rewrites them; a statement cannot mix the two forms. ADR-0013 makes this the rule for every new microflow activity and every activity with several settings. - **`run --local --page-check` signs in with `--screenshot-user` without `--screenshot`, and checks all pages in one browser** — the sign-in only ran when `--screenshot` was also given, so `--page-check --screenshot-user U` reported every secured page as the login page. The verdict also no longer counts a list view's "No items found" placeholder or a data grid's header as rows, ignores the demo-user switcher's "Select user" heading, and reports a failed same-origin request (`HTTP 560 POST /xas/`) instead of the duplicate "Failed to load resource" console line. The login script used by `--screenshot-user` falls back to `/login.html` when the app root does not show the sign-in form, and finds Playwright the way the page check does (no `playwright` CLI on `PATH` needed). +- **`mxcli rename` takes `java-action` and `workflow`** — the subcommand offered eight of the ten targets `RENAME` accepts; Java actions and workflows could only be renamed from MDL. `mxcli rename -p app.mpr java-action M.JA_Old JA_New` also renames the `.java` source file, and both update `docs/brain/` anchors like the other types. The type list is now held to the grammar by a test, so a new `RENAME` target cannot be left out of the subcommand again. `mxcli syntax rename` documents both forms. (follow-up to mendixlabs/mxcli#1318) + ### Fixed +- **`RENAME JAVA ACTION` renames the Java class, not just the file** — renaming `M.JA_Old` to `JA_New` moved the source to `JA_New.java` but left `public class JA_Old`, its constructor and its `toString` inside it, which javac rejects (`class JA_Old is public, should be declared in a file named JA_Old.java`). A full build hid it, because mxbuild regenerates the stub in its own copy; `run --local --watch` hot reload and IDEs compile the file on disk. Those three places now follow the rename exactly as mxbuild rewrites them; user code and extra code are left as written. Applies to `mxcli rename java-action` too. - **`run --local --watch` starts on Mendix 10.24 and 11.6** — it waited the full web-client timeout (5 minutes) for a bundle that had finished in seconds, then failed with `web client watcher timed out`. The rollup runner those versions ship reports its status as `{"code":"SUCCESS"}` rather than the modern-web-bundler protocol mxcli was reading; both are now understood, including that runner's error reports. - **`run --local --watch` no longer loses a change made while the app boots** — the watch loop took its baseline after the boot, so a model written during the ~15 s boot was never built and the app kept serving the model from before it. The baseline is now the source time the boot build was made from, so that change is built on the first tick. diff --git a/cmd/mxcli/cmd_rename.go b/cmd/mxcli/cmd_rename.go index 9eb077082..e44f06289 100644 --- a/cmd/mxcli/cmd_rename.go +++ b/cmd/mxcli/cmd_rename.go @@ -26,6 +26,8 @@ Types: enumeration Rename an enumeration association Rename an association constant Rename a constant + java-action Rename a Java action (also renames its .java source file) + workflow Rename a workflow module Rename a module (updates all qualified names) Use --dry-run to preview changes without modifying. @@ -39,6 +41,8 @@ Example: mxcli rename -p app.mpr entity MyModule.Customer Client mxcli rename -p app.mpr microflow MyModule.ACT_Old ACT_New mxcli rename -p app.mpr page MyModule.OldPage NewPage + mxcli rename -p app.mpr java-action MyModule.JA_Old JA_New + mxcli rename -p app.mpr workflow MyModule.WF_Old WF_New mxcli rename -p app.mpr module OldModule NewModule mxcli rename -p app.mpr entity MyModule.Customer Client --dry-run `, @@ -52,36 +56,12 @@ Example: dryRun, _ := cmd.Flags().GetBool("dry-run") - objectType := strings.ToUpper(args[0]) qualifiedName := args[1] newName := args[2] - var mdlCmd string - dryRunSuffix := "" - if dryRun { - dryRunSuffix = " DRY RUN" - } - - switch objectType { - case "ENTITY": - mdlCmd = fmt.Sprintf("RENAME ENTITY %s TO %s%s", qualifiedName, newName, dryRunSuffix) - case "MICROFLOW": - mdlCmd = fmt.Sprintf("RENAME MICROFLOW %s TO %s%s", qualifiedName, newName, dryRunSuffix) - case "NANOFLOW": - mdlCmd = fmt.Sprintf("RENAME NANOFLOW %s TO %s%s", qualifiedName, newName, dryRunSuffix) - case "PAGE": - mdlCmd = fmt.Sprintf("RENAME PAGE %s TO %s%s", qualifiedName, newName, dryRunSuffix) - case "ENUMERATION": - mdlCmd = fmt.Sprintf("RENAME ENUMERATION %s TO %s%s", qualifiedName, newName, dryRunSuffix) - case "ASSOCIATION": - mdlCmd = fmt.Sprintf("RENAME ASSOCIATION %s TO %s%s", qualifiedName, newName, dryRunSuffix) - case "CONSTANT": - mdlCmd = fmt.Sprintf("RENAME CONSTANT %s TO %s%s", qualifiedName, newName, dryRunSuffix) - case "MODULE": - mdlCmd = fmt.Sprintf("RENAME MODULE %s TO %s%s", qualifiedName, newName, dryRunSuffix) - default: - fmt.Fprintf(os.Stderr, "Unknown type: %s\n", args[0]) - fmt.Fprintln(os.Stderr, "Valid types: entity, microflow, nanoflow, page, enumeration, association, constant, module") + mdlCmd, objectType, err := renameStatement(args[0], qualifiedName, newName, dryRun) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } @@ -118,6 +98,50 @@ Example: }, } +// renameTypes maps the subcommand's type argument to the RENAME target keyword. +// +// One table instead of a case per type: the switch it replaces offered eight of +// the grammar's ten targets, JAVA ACTION and WORKFLOW being reachable only from +// MDL, and nothing noticed. cmd_rename_test.go now holds this table to the +// renameTarget rule in MDLParser.g4. A two-word keyword is typed with '-' (or +// '_', or run together), since a space would split the shell argument. +var renameTypes = []struct { + arg string + keyword string +}{ + {"entity", "ENTITY"}, + {"microflow", "MICROFLOW"}, + {"nanoflow", "NANOFLOW"}, + {"page", "PAGE"}, + {"enumeration", "ENUMERATION"}, + {"association", "ASSOCIATION"}, + {"constant", "CONSTANT"}, + {"java-action", "JAVA ACTION"}, + {"workflow", "WORKFLOW"}, + {"module", "MODULE"}, +} + +// renameStatement builds the RENAME statement for one `mxcli rename` call and +// returns it with the target keyword, which is what renameBrainAnchors keys on. +func renameStatement(typeArg, qualifiedName, newName string, dryRun bool) (stmt, keyword string, err error) { + norm := strings.NewReplacer("_", "", "-", "").Replace(strings.ToLower(typeArg)) + var valid []string + for _, t := range renameTypes { + valid = append(valid, t.arg) + if strings.ReplaceAll(t.arg, "-", "") == norm { + keyword = t.keyword + } + } + if keyword == "" { + return "", "", fmt.Errorf("unknown type: %s (valid types: %s)", typeArg, strings.Join(valid, ", ")) + } + stmt = fmt.Sprintf("RENAME %s %s TO %s", keyword, qualifiedName, newName) + if dryRun { + stmt += " DRY RUN" + } + return stmt, keyword, nil +} + // renameBrainAnchors keeps docs/brain/ pointing at the thing that was renamed. // // The model's own cross-references are updated by the RENAME statement above. @@ -185,7 +209,8 @@ func renameBrainAnchors(projectPath, objectType, qualifiedName, newName string, // corrupt entries instead of repairing them. func brainRenameTarget(objectType, qualifiedName string) (string, bool) { switch objectType { - case "ENTITY", "MICROFLOW", "NANOFLOW", "PAGE", "ENUMERATION", "ASSOCIATION", "CONSTANT": + case "ENTITY", "MICROFLOW", "NANOFLOW", "PAGE", "ENUMERATION", "ASSOCIATION", "CONSTANT", + "JAVA ACTION", "WORKFLOW": // These are all Module.Element, which is what an anchor names. if !strings.Contains(qualifiedName, ".") { return "", false diff --git a/cmd/mxcli/cmd_rename_brain_test.go b/cmd/mxcli/cmd_rename_brain_test.go index 75c9f0462..f936e8a7a 100644 --- a/cmd/mxcli/cmd_rename_brain_test.go +++ b/cmd/mxcli/cmd_rename_brain_test.go @@ -22,6 +22,8 @@ func TestBrainRenameTargetOnlyClaimsWhatAnAnchorCanName(t *testing.T) { {"ENUMERATION", "Sales.ENUM_Status", "Sales.ENUM_Status", true}, {"ASSOCIATION", "Sales.Order_Customer", "Sales.Order_Customer", true}, {"CONSTANT", "Sales.ApiRoot", "Sales.ApiRoot", true}, + {"JAVA ACTION", "Sales.JA_Hash", "Sales.JA_Hash", true}, + {"WORKFLOW", "Sales.WF_Approve", "Sales.WF_Approve", true}, {"MODULE", "Sales", "Sales", true}, // An element rename with no module cannot be turned into an anchor: diff --git a/cmd/mxcli/cmd_rename_test.go b/cmd/mxcli/cmd_rename_test.go new file mode 100644 index 000000000..b78bd8dcd --- /dev/null +++ b/cmd/mxcli/cmd_rename_test.go @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/visitor" +) + +// grammarRenameTargets reads the renameTarget rule out of the committed +// grammar, plus MODULE (renameStatement's second alternative), as the words a +// user types: "JAVA ACTION", not two tokens. +func grammarRenameTargets(t *testing.T) []string { + t.Helper() + path := filepath.Join("..", "..", "mdl", "grammar", "MDLParser.g4") + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + rule := regexp.MustCompile(`(?s)\nrenameTarget\s*\n\s*:(.*?)\n\s*;`).FindStringSubmatch(string(b)) + if rule == nil { + t.Fatal("renameTarget rule not found — re-point this guard rather than deleting it") + } + var out []string + for _, alt := range strings.Split(rule[1], "|") { + if alt = strings.Join(strings.Fields(alt), " "); alt != "" { + out = append(out, alt) + } + } + return append(out, "MODULE") +} + +// `mxcli rename` offered eight of the ten targets RENAME accepts: JAVA ACTION +// and WORKFLOW were MDL-only, because the subcommand kept its own switch of +// types and nothing tied it to the grammar (follow-up to mendixlabs/mxcli#1318). +// Every grammar target must be reachable from the shell, spelled as the +// keyword in lower case with '-' for the space, and must build a statement +// that parses to the RENAME it names. +func TestRenameSubcommandCoversEveryGrammarTarget(t *testing.T) { + for _, target := range grammarRenameTargets(t) { + arg := strings.ToLower(strings.ReplaceAll(target, " ", "-")) + t.Run(arg, func(t *testing.T) { + name := "Sales.Old" + if target == "MODULE" { + name = "Sales" + } + stmt, keyword, err := renameStatement(arg, name, "New", false) + if err != nil { + t.Fatalf("mxcli rename %s: %v", arg, err) + } + if keyword != target { + t.Errorf("type %q maps to %q, want %q", arg, keyword, target) + } + prog, errs := visitor.Build(stmt) + if len(errs) > 0 { + t.Fatalf("%q does not parse: %v", stmt, errs[0]) + } + rs, ok := prog.Statements[0].(*ast.RenameStmt) + if !ok { + t.Fatalf("%q parsed as %T, not a RENAME", stmt, prog.Statements[0]) + } + if want := strings.ToLower(strings.ReplaceAll(target, " ", "")); rs.ObjectType != want { + t.Errorf("%q renames a %q, want %q", stmt, rs.ObjectType, want) + } + }) + } +} + +func TestRenameStatementSpellings(t *testing.T) { + for _, tc := range []struct { + arg string + want string + }{ + {"java-action", "RENAME JAVA ACTION Sales.Old TO New DRY RUN"}, + {"javaaction", "RENAME JAVA ACTION Sales.Old TO New DRY RUN"}, + {"java_action", "RENAME JAVA ACTION Sales.Old TO New DRY RUN"}, + {"Workflow", "RENAME WORKFLOW Sales.Old TO New DRY RUN"}, + {"entity", "RENAME ENTITY Sales.Old TO New DRY RUN"}, + } { + got, _, err := renameStatement(tc.arg, "Sales.Old", "New", true) + if err != nil || got != tc.want { + t.Errorf("renameStatement(%q) = %q, %v; want %q", tc.arg, got, err, tc.want) + } + } + if _, _, err := renameStatement("folder", "Sales.Old", "New", false); err == nil { + t.Error("an unknown type must be refused") + } +} diff --git a/cmd/mxcli/cmd_syntax_test.go b/cmd/mxcli/cmd_syntax_test.go index 27f42f6a5..38c995575 100644 --- a/cmd/mxcli/cmd_syntax_test.go +++ b/cmd/mxcli/cmd_syntax_test.go @@ -127,3 +127,17 @@ func firstLines(s string, n int) string { } return strings.Join(lines, "\n") } + +// mendixlabs/mxcli#1318, the reported command verbatim: `mxcli syntax rename` +// answered "Unknown topic: rename" while RENAME MICROFLOW parsed and ran. +func TestSyntaxRenameTopic(t *testing.T) { + out := runSyntax(t, "rename") + if strings.Contains(out, "Unknown topic") { + t.Fatalf("mxcli syntax rename reported an unknown topic:\n%s", firstLines(out, 3)) + } + for _, want := range []string{"RENAME MICROFLOW", "RENAME PAGE", "mxcli rename"} { + if !strings.Contains(out, want) { + t.Errorf("mxcli syntax rename does not show %q", want) + } + } +} diff --git a/cmd/mxcli/syntax/features_domain_model.go b/cmd/mxcli/syntax/features_domain_model.go index d9901a517..eb38e2ea8 100644 --- a/cmd/mxcli/syntax/features_domain_model.go +++ b/cmd/mxcli/syntax/features_domain_model.go @@ -71,7 +71,7 @@ func init() { }, Syntax: "CREATE PERSISTENT ENTITY Module.Name (\n Attr: Type [constraints],\n ...\n) [INDEX (attr1)];\n\n-- Documentation is the /** … */ doc comment before the statement.\n-- (A `COMMENT 'text'` option existed, set nothing, and has been removed.)\n\nCREATE NON-PERSISTENT ENTITY Module.Name (...);\n\nCREATE PERSISTENT ENTITY Module.Name EXTENDS Module.Parent (...);", Example: "/** Stores customer information. */\nCREATE PERSISTENT ENTITY MyModule.Customer (\n Name: String(100) NOT NULL ERROR MESSAGE 'Name is required',\n Email: String(200) UNIQUE,\n Balance: Decimal DEFAULT 0,\n IsActive: Boolean DEFAULT true,\n Status: Enumeration(MyModule.CustomerType)\n)\nINDEX (Email);", - SeeAlso: []string{"domain-model.entity.create", "domain-model.entity.alter", "domain-model.entity.attributes"}, + SeeAlso: []string{"domain-model.entity.create", "domain-model.entity.alter", "domain-model.entity.attributes", "rename"}, }) Register(SyntaxFeature{ diff --git a/cmd/mxcli/syntax/features_microflow.go b/cmd/mxcli/syntax/features_microflow.go index 2af7960b7..f3a288318 100644 --- a/cmd/mxcli/syntax/features_microflow.go +++ b/cmd/mxcli/syntax/features_microflow.go @@ -12,7 +12,7 @@ func init() { }, Syntax: "CREATE [OR REPLACE | OR MODIFY] MICROFLOW Module.Name ($Param: Type) RETURNS Type AS $Result\nBEGIN\n \nEND;", Example: "mdl 1;\nCREATE MICROFLOW MyModule.ACT_CreateOrder ($Code: String)\nRETURNS MyModule.Order AS $NewOrder\nBEGIN\n $NewOrder = CREATE MyModule.Order (OrderNumber = $Code);\n COMMIT $NewOrder;\n RETURN $NewOrder;\nEND;\n\n-- Re-runnable: replaces the microflow if it already exists\nCREATE OR MODIFY MICROFLOW MyModule.ACT_CreateOrder ($Code: String)\nRETURNS MyModule.Order AS $NewOrder\nBEGIN\n $NewOrder = CREATE MyModule.Order (OrderNumber = $Code);\n RETURN $NewOrder;\nEND;", - SeeAlso: []string{"microflow.create", "microflow.variables", "microflow.control-flow", "create-modifiers"}, + SeeAlso: []string{"microflow.create", "microflow.variables", "microflow.control-flow", "create-modifiers", "rename"}, }) Register(SyntaxFeature{ diff --git a/cmd/mxcli/syntax/features_misc.go b/cmd/mxcli/syntax/features_misc.go index e0db87520..385339155 100644 --- a/cmd/mxcli/syntax/features_misc.go +++ b/cmd/mxcli/syntax/features_misc.go @@ -1061,7 +1061,72 @@ DROP FOLDER 'OldFolder' IN Module; -- Read the placement back LIST FOLDERS IN MyModule;`, - SeeAlso: []string{"folders"}, + SeeAlso: []string{"folders", "rename"}, + }) + + // ── Rename ────────────────────────────────────────────────────────── + + // RENAME parsed, ran and was in `mxcli help rename` but had no topic here, + // so an agent consulting `syntax` concluded a microflow could not be + // renamed and rebuilt it by hand (mendixlabs/mxcli#1318). + // rename_topic_test.go holds the target list to the grammar. + Register(SyntaxFeature{ + Path: "rename", + Summary: "RENAME — rename a document, entity, association or module and update every reference", + Keywords: []string{ + "rename", "rename microflow", "rename nanoflow", "rename page", + "rename entity", "rename enumeration", "rename association", + "rename constant", "rename java action", "rename workflow", + "rename module", "dry run", "refactor", "update references", + }, + Syntax: `RENAME Module.OldName TO NewName [DRY RUN]; +-- target: ENTITY | MICROFLOW | NANOFLOW | PAGE | ENUMERATION | ASSOCIATION +-- | CONSTANT | JAVA ACTION | WORKFLOW +RENAME MODULE OldModule TO NewModule [DRY RUN]; + +-- The new name is BARE: the element stays in its module (use MOVE to change +-- module). An element of that name already in the module is an error. +-- +-- Every reference is updated in the same statement: each stored string in the +-- project that IS the old qualified name, or starts with it plus '.', is +-- rewritten (Module.Old -> Module.New, Module.Old.Attr -> Module.New.Attr). +-- That covers calls, page and microflow parameters, show-page actions, +-- navigation, security, attribute types, association ends. +-- A name inside free text — a microflow expression, an XPath string — is not +-- such a string and is left as it was; build or 'mxcli docker check' reports +-- what remains. +-- +-- RENAME JAVA ACTION also renames the .java source file and the class in it +-- (class, constructor, toString); the user and extra code are left as written. +-- RENAME MODULE rewrites every 'OldModule.' prefix project-wide. +-- +-- DRY RUN changes nothing and lists each document that would change and how +-- many references it holds. +-- +-- Members are renamed with ALTER, not RENAME: +-- ALTER ENTITY Module.E RENAME ATTRIBUTE Old TO New; +-- ALTER ENUMERATION Module.E RENAME VALUE Old TO New; +-- +-- From the shell, the same statement for one element: +-- mxcli rename -p app.mpr Module.OldName NewName [--dry-run] +-- type: entity | microflow | nanoflow | page | enumeration | association +-- | constant | java-action | workflow | module +-- (it also updates docs/brain/ anchors; see 'mxcli help rename')`, + Example: `mdl 1; +-- See what would change first +RENAME MICROFLOW Shop.ACT_Old TO ACT_ProcessOrder DRY RUN; + +RENAME MICROFLOW Shop.ACT_Old TO ACT_ProcessOrder; +RENAME NANOFLOW Shop.NF_Old TO NF_Validate; +RENAME PAGE Shop.OldPage TO Order_Edit; +RENAME ENTITY Shop.Customer TO Client; +RENAME ENUMERATION Shop.Status TO OrderStatus; +RENAME ASSOCIATION Shop.Order_Customer TO Order_Client; +RENAME CONSTANT Shop.ApiUrl TO ServiceUrl; +RENAME JAVA ACTION Shop.JA_Old TO JA_Hash; +RENAME WORKFLOW Shop.WF_Old TO WF_Approve; +RENAME MODULE Shop TO Store;`, + SeeAlso: []string{"move", "domain-model.entity.alter", "domain-model.enumeration"}, }) // ── Folders ───────────────────────────────────────────────────────── diff --git a/cmd/mxcli/syntax/features_page.go b/cmd/mxcli/syntax/features_page.go index bda76bf87..0e4a53514 100644 --- a/cmd/mxcli/syntax/features_page.go +++ b/cmd/mxcli/syntax/features_page.go @@ -14,7 +14,7 @@ func init() { }, Syntax: "CREATE PAGE Module.Name [FOLDER 'FolderPath']\n (\n Title: 'Page Title',\n Layout: Module.LayoutName\n [, Params: ( $Param: Module.Entity )]\n [, Url: 'page-url']\n [, Variables: ( $var: Boolean = 'true' )]\n [, PopupWidth: 800, PopupHeight: 480, PopupResizable: true]\n [, PopupCloseAction: cancelButton1]\n [, Class: 'css-class', Style: 'css: rule']\n )\n {\n -- widgets\n }", Example: "CREATE PAGE MyModule.EditCustomer\n (\n Params: ( $Customer: MyModule.Customer ),\n Title: 'Edit Customer',\n Layout: Atlas_Core.PopupLayout,\n Class: 'container-fluid'\n )\n {\n DATAVIEW dvCustomer (DataSource: $Customer) {\n TEXTBOX txtName (Label: 'Name', Attribute: Name)\n FOOTER {\n ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE CHANGES, ButtonStyle: Primary)\n ACTIONBUTTON btnCancel (Caption: 'Cancel', Action: CANCEL CHANGES)\n }\n }\n };", - SeeAlso: []string{"page.create", "page.widgets", "page.alter", "snippet"}, + SeeAlso: []string{"page.create", "page.widgets", "page.alter", "snippet", "rename"}, }) Register(SyntaxFeature{ diff --git a/cmd/mxcli/syntax/rename_topic_test.go b/cmd/mxcli/syntax/rename_topic_test.go new file mode 100644 index 000000000..63892db34 --- /dev/null +++ b/cmd/mxcli/syntax/rename_topic_test.go @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: Apache-2.0 + +package syntax + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// mendixlabs/mxcli#1318: "mxcli syntax rename" said "Unknown topic: rename" +// while RENAME MICROFLOW/PAGE/ENTITY parsed, executed, and were documented by +// `mxcli help rename`. An agent consulting `syntax` concluded MDL could not +// rename a microflow and rebuilt it by hand: copy, re-point every caller, +// delete the original. +// +// As with the widget keywords (widget_keywords_drift_test.go), the grammar is +// the authority: every target the renameStatement rule accepts must be named in +// the rename topic, so a target added to the parser cannot ship undocumented. + +// renameTargets reads the renameTarget rule plus the MODULE alternative of +// renameStatement out of the committed grammar, as the words a user types +// ("JAVA ACTION", not "JAVA ACTION" split in two). +func renameTargets(t *testing.T) []string { + t.Helper() + path := filepath.Join("..", "..", "..", "mdl", "grammar", "MDLParser.g4") + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + src := string(b) + rule := regexp.MustCompile(`(?s)\nrenameTarget\s*\n\s*:(.*?)\n\s*;`).FindStringSubmatch(src) + if rule == nil { + t.Fatal("renameTarget rule not found — if the rule was renamed, re-point this guard " + + "rather than deleting it; it exists because RENAME shipped absent from `mxcli syntax` (#1318)") + } + var out []string + for _, alt := range strings.Split(rule[1], "|") { + if alt = strings.Join(strings.Fields(alt), " "); alt != "" { + out = append(out, alt) + } + } + if !regexp.MustCompile(`RENAME MODULE identifierOrKeyword TO`).MatchString(src) { + t.Fatal("RENAME MODULE alternative not found in renameStatement — re-point this guard") + } + return append(out, "MODULE") +} + +func TestRenameTopicIsRegistered(t *testing.T) { + if ByPath(ResolveAlias("rename")) == nil { + t.Fatal(`"rename" resolves to no syntax topic — ` + "`mxcli syntax rename`" + ` reports "Unknown topic: rename" (#1318)`) + } +} + +func TestRenameTopicNamesEveryGrammarTarget(t *testing.T) { + f := ByPath(ResolveAlias("rename")) + if f == nil { + t.Fatal(`no "rename" topic (#1318)`) + } + doc := strings.ToUpper(f.Syntax + "\n" + f.Example) + for _, target := range renameTargets(t) { + if !strings.Contains(doc, "RENAME "+target) { + t.Errorf("the grammar accepts RENAME %s but the rename topic never shows it", target) + } + } +} + +// The topics a reader is on when the question arises point at it, so the +// statement is found from the document type as well as from the verb. +func TestRenameTopicIsCrossReferenced(t *testing.T) { + for _, from := range []string{"microflow", "page", "domain-model.entity", "move"} { + f := ByPath(from) + if f == nil { + t.Errorf("topic %q not registered", from) + continue + } + found := false + for _, ref := range f.SeeAlso { + if ref == "rename" { + found = true + } + } + if !found { + t.Errorf("topic %q has no see_also to rename", from) + } + } +} diff --git a/mdl-examples/bug-tests/1318-rename-statements-documented.mdl b/mdl-examples/bug-tests/1318-rename-statements-documented.mdl new file mode 100644 index 000000000..47e2790d8 --- /dev/null +++ b/mdl-examples/bug-tests/1318-rename-statements-documented.mdl @@ -0,0 +1,27 @@ +mdl 1; +-- ============================================================================ +-- mendixlabs/mxcli#1318 — "mxcli syntax rename" said "Unknown topic: rename" +-- ============================================================================ +-- +-- RENAME MICROFLOW/PAGE/ENTITY parsed and ran, and `mxcli help rename` +-- documented them, but `mxcli syntax` had no rename topic, so an agent +-- concluded MDL could not rename a microflow and rebuilt it by hand. +-- +-- The fix is a syntax topic; the guard that keeps it in step with the grammar +-- is cmd/mxcli/syntax/rename_topic_test.go. This file keeps the documented +-- forms parsing: every RENAME target, DRY RUN, and RENAME MODULE. +-- ============================================================================ + +CREATE MODULE BugRename1318; + +CREATE OR MODIFY PERSISTENT ENTITY BugRename1318.Customer ( Name: String(100) ); + +CREATE OR MODIFY MICROFLOW BugRename1318.ACT_Old () +BEGIN + LOG INFO NODE 'BugRename1318' 'old'; +END; + +RENAME MICROFLOW BugRename1318.ACT_Old TO ACT_New DRY RUN; +RENAME MICROFLOW BugRename1318.ACT_Old TO ACT_New; +RENAME ENTITY BugRename1318.Customer TO Client; +RENAME MODULE BugRename1318 TO BugRename1318b; diff --git a/mdl-examples/bug-tests/rename-java-action-renames-class.mdl b/mdl-examples/bug-tests/rename-java-action-renames-class.mdl new file mode 100644 index 000000000..fe6ed81ec --- /dev/null +++ b/mdl-examples/bug-tests/rename-java-action-renames-class.mdl @@ -0,0 +1,33 @@ +mdl 1; +-- ============================================================================ +-- RENAME JAVA ACTION left `public class JA_Old` inside JA_New.java +-- ============================================================================ +-- +-- The rename moved javasource//actions/JA_Old.java to JA_New.java and +-- left the class, its constructor and toString's "JA_Old" as they were. javac: +-- class JA_Old is public, should be declared in a file named JA_Old.java +-- `mxcli docker build` still succeeded — mxbuild regenerates the stub in its +-- copy — so only the file on disk was wrong, which is the one `run --local +-- --watch` hot reload and an IDE compile. +-- +-- The file on disk is not something `mxcli check` can see, so the assertions +-- are Go tests: sdk/javaactions/rename_test.go (against a golden mxbuild wrote) +-- and mdl/backend/modelsdk/java_rename_source_test.go. This script is the +-- reproduction: exec it, then javac javasource/bugrenameja/actions/JA_New.java +-- against runtime/bundles/com.mendix.public-api.jar. +-- ============================================================================ + +CREATE MODULE BugRenameJa; + +CREATE JAVA ACTION BugRenameJa.JA_Old(Text: String NOT NULL) RETURNS String +AS $$ +return Text.trim(); +$$; + +CREATE OR MODIFY MICROFLOW BugRenameJa.ACT_UsesJava ($t: String) RETURNS String +BEGIN + $r = CALL JAVA ACTION BugRenameJa.JA_Old(Text = $t); + RETURN $r; +END; + +RENAME JAVA ACTION BugRenameJa.JA_Old TO JA_New; diff --git a/mdl/backend/modelsdk/java_rename_source_test.go b/mdl/backend/modelsdk/java_rename_source_test.go new file mode 100644 index 000000000..57a426ecc --- /dev/null +++ b/mdl/backend/modelsdk/java_rename_source_test.go @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: Apache-2.0 + +package modelsdkbackend + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// RenameJavaSourceFile moved Old.java to New.java and left `public class Old` +// inside it — not valid Java, which `run --local --watch` hot reload and every +// IDE compile from disk. The class, constructor and toString must follow the +// file; the user code, mentions of the old name included, must not. +func TestRenameJavaSourceFileRenamesTheClass(t *testing.T) { + root := t.TempDir() + dir := filepath.Join(root, "javasource", "sales", "actions") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + src := "package sales.actions;\r\n\r\n" + + "public class JA_Old extends UserAction\r\n{\r\n" + + "\tpublic JA_Old(\r\n\t\tIContext context\r\n\t)\r\n\t{\r\n\t\tsuper(context);\r\n\t}\r\n\r\n" + + "\t@java.lang.Override\r\n\tpublic java.lang.String executeAction() throws Exception\r\n\t{\r\n" + + "\t\t// BEGIN USER CODE\r\n\t\treturn \"JA_Old\";\r\n\t\t// END USER CODE\r\n\t}\r\n\r\n" + + "\t@java.lang.Override\r\n\tpublic java.lang.String toString()\r\n\t{\r\n\t\treturn \"JA_Old\";\r\n\t}\r\n}\r\n" + if err := os.WriteFile(filepath.Join(dir, "JA_Old.java"), []byte(src), 0o644); err != nil { + t.Fatal(err) + } + + b := &Backend{path: filepath.Join(root, "App.mpr")} + if err := b.RenameJavaSourceFile("Sales", "JA_Old", "JA_New"); err != nil { + t.Fatal(err) + } + + if _, err := os.Stat(filepath.Join(dir, "JA_Old.java")); !os.IsNotExist(err) { + t.Errorf("JA_Old.java still exists (stat err %v)", err) + } + got, err := os.ReadFile(filepath.Join(dir, "JA_New.java")) + if err != nil { + t.Fatal(err) + } + want := strings.NewReplacer( + "public class JA_Old", "public class JA_New", + "public JA_Old(", "public JA_New(", + "\t\treturn \"JA_Old\";\r\n\t}\r\n}", "\t\treturn \"JA_New\";\r\n\t}\r\n}", + ).Replace(src) + if string(got) != want { + t.Errorf("JA_New.java:\n%s\nwant:\n%s", got, want) + } + if !strings.Contains(string(got), "// BEGIN USER CODE\r\n\t\treturn \"JA_Old\";") { + t.Error("the user code's mention of JA_Old was rewritten; mxbuild keeps it") + } +} + +// A missing stub is still not an error: the action may never have had one. +func TestRenameJavaSourceFileMissingIsNotAnError(t *testing.T) { + b := &Backend{path: filepath.Join(t.TempDir(), "App.mpr")} + if err := b.RenameJavaSourceFile("Sales", "JA_Old", "JA_New"); err != nil { + t.Errorf("missing source: %v", err) + } +} diff --git a/mdl/backend/modelsdk/java_write.go b/mdl/backend/modelsdk/java_write.go index 8a36d3c68..f1b5bb8cc 100644 --- a/mdl/backend/modelsdk/java_write.go +++ b/mdl/backend/modelsdk/java_write.go @@ -125,7 +125,11 @@ func (b *Backend) DeleteJavaAction(id model.ID) error { return b.writer.DeleteUnit(string(id)) } -// RenameJavaSourceFile renames javasource//actions/.java to .java. +// RenameJavaSourceFile renames javasource//actions/.java to +// .java and renames the class inside it, as mxbuild's regeneration does: +// moving the file alone left `public class Old` in New.java, which is not valid +// Java — a full build hides it by regenerating the stub in its own copy, but +// `run --local --watch` hot reload and every IDE compile the file on disk. // A missing source file is not an error (the action may have no generated stub yet). func (b *Backend) RenameJavaSourceFile(moduleName, oldName, newName string) error { if b.path == "" { @@ -134,9 +138,23 @@ func (b *Backend) RenameJavaSourceFile(moduleName, oldName, newName string) erro dir := filepath.Join(filepath.Dir(b.path), "javasource", strings.ToLower(moduleName), "actions") oldPath := filepath.Join(dir, oldName+".java") newPath := filepath.Join(dir, newName+".java") - if err := os.Rename(oldPath, newPath); err != nil && !os.IsNotExist(err) { + content, err := os.ReadFile(oldPath) + if os.IsNotExist(err) { + return nil + } + if err != nil { return fmt.Errorf("RenameJavaSourceFile: %w", err) } + // Move first, then rewrite in place, so the file keeps its mode and a + // failed rewrite still leaves the source where the model now expects it. + if err := os.Rename(oldPath, newPath); err != nil { + return fmt.Errorf("RenameJavaSourceFile: %w", err) + } + // The move is a write whether or not the text needed rewriting. + b.noteFileWrite(true) + if _, err := javaactions.WriteSourceIfChanged(newPath, javaactions.RenameSource(string(content), oldName, newName)); err != nil { + return fmt.Errorf("RenameJavaSourceFile: rewrite class name: %w", err) + } return nil } diff --git a/sdk/javaactions/rename.go b/sdk/javaactions/rename.go new file mode 100644 index 000000000..73081dc11 --- /dev/null +++ b/sdk/javaactions/rename.go @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: Apache-2.0 + +package javaactions + +import ( + "regexp" + "strings" +) + +// RenameSource rewrites a Java action's source for a rename from oldName to +// newName the way mxbuild regenerates it: the three generated places that carry +// the action's name — `public class Old`, the constructor `public Old(` and +// toString's `return "Old";` — and nothing else. The user code and extra code +// are the author's and are left byte-for-byte, a mention of the old name +// included; so is everything else, CRLF line endings among it. +// +// Measured against mxbuild 11.14.0 (testdata/mxbuild/JA_Renamed*): renaming the +// file alone left `public class Old` in New.java, which is not valid Java. +func RenameSource(source, oldName, newName string) string { + if oldName == newName || oldName == "" { + return source + } + old := regexp.QuoteMeta(oldName) + patterns := []*regexp.Regexp{ + regexp.MustCompile(`(\bclass\s+)` + old + `\b`), + regexp.MustCompile(`(\bpublic\s+)` + old + `(\s*\()`), + regexp.MustCompile(`(\breturn\s+)"` + old + `"(\s*;)`), + } + replacements := []string{"${1}" + newName, "${1}" + newName + "${2}", `${1}"` + newName + `"${2}`} + + rewrite := func(generated string) string { + for i, re := range patterns { + generated = re.ReplaceAllString(generated, replacements[i]) + } + return generated + } + + // Rewrite only the generated stretches between the author's sections. + var b strings.Builder + rest := source + for { + start, end, ok := nextAuthoredSection(rest) + if !ok { + b.WriteString(rewrite(rest)) + return b.String() + } + b.WriteString(rewrite(rest[:start])) + b.WriteString(rest[start:end]) + rest = rest[end:] + } +} + +// authoredSections are the marker pairs whose contents mxbuild keeps as written. +var authoredSections = [][2]string{ + {"// BEGIN USER CODE", "// END USER CODE"}, + {"// BEGIN EXTRA CODE", "// END EXTRA CODE"}, +} + +// nextAuthoredSection finds the first authored section in s, markers included. +// Markers match case-insensitively, as in RetainedSections. +func nextAuthoredSection(s string) (start, end int, ok bool) { + lower := asciiLower(s) + start = -1 + for _, m := range authoredSections { + bi := strings.Index(lower, asciiLower(m[0])) + if bi == -1 || (start != -1 && bi > start) { + continue + } + ei := strings.Index(lower[bi:], asciiLower(m[1])) + if ei == -1 { + continue + } + start, end = bi, bi+ei+len(m[1]) + } + return start, end, start != -1 +} diff --git a/sdk/javaactions/rename_test.go b/sdk/javaactions/rename_test.go new file mode 100644 index 000000000..880c070db --- /dev/null +++ b/sdk/javaactions/rename_test.go @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: Apache-2.0 + +package javaactions + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// RENAME JAVA ACTION moved javasource//actions/.java to .java +// and left `public class Old`, its constructor and toString's "Old" inside, so +// the project's own source was not valid Java: javac requires a public class to +// live in a file of its name. A full build hides it — mxbuild regenerates the +// stub in its copy — but `run --local --watch` hot reload compiles the file on +// disk, and so does every IDE. +// +// The goldens are measured, not written: JA_Renamed.before is what mxcli left +// after `mxcli rename java-action R1318.JA_Old JA_New` on a fresh 11.14.0 app, +// hand-edited to add an import, extra code, and user code that mentions JA_Old +// in a string and in comments; JA_Renamed is the same file after mxbuild ran on +// the project in place. mxbuild changed exactly three lines — class, constructor, +// toString — and kept the user code, extra code and CRLF as they were. +func TestRenameSourceMatchesMxbuild(t *testing.T) { + read := func(name string) string { + b, err := os.ReadFile(filepath.Join("testdata", "mxbuild", name)) + if err != nil { + t.Fatal(err) + } + return string(b) + } + before := read("JA_Renamed.before.java.golden") + want := read("JA_Renamed.java.golden") + + got := RenameSource(before, "JA_Old", "JA_New") + if got != want { + gl, wl := strings.Split(got, "\n"), strings.Split(want, "\n") + for i := 0; i < len(gl) && i < len(wl); i++ { + if gl[i] != wl[i] { + t.Fatalf("line %d differs from mxbuild's regeneration:\n got: %q\nwant: %q", i+1, gl[i], wl[i]) + } + } + t.Fatalf("length differs from mxbuild's regeneration: %d lines, want %d", len(gl), len(wl)) + } +} + +// A name that is a prefix of another identifier, or of the new name, must not +// be rewritten inside it. +func TestRenameSourceWholeWordsOnly(t *testing.T) { + src := "public class JA extends UserAction\n{\n\tpublic JA(\n\t)\n\t{\n\t}\n\tpublic java.lang.String toString()\n\t{\n\t\treturn \"JA\";\n\t}\n}\n" + want := "public class JA_X extends UserAction\n{\n\tpublic JA_X(\n\t)\n\t{\n\t}\n\tpublic java.lang.String toString()\n\t{\n\t\treturn \"JA_X\";\n\t}\n}\n" + if got := RenameSource(src, "JA", "JA_X"); got != want { + t.Errorf("got:\n%s\nwant:\n%s", got, want) + } +} diff --git a/sdk/javaactions/testdata/mxbuild/JA_Renamed.before.java.golden b/sdk/javaactions/testdata/mxbuild/JA_Renamed.before.java.golden new file mode 100644 index 000000000..a543c4db1 --- /dev/null +++ b/sdk/javaactions/testdata/mxbuild/JA_Renamed.before.java.golden @@ -0,0 +1,53 @@ +// This file was generated by Mendix Studio Pro. +// +// WARNING: Only the following code will be retained when actions are regenerated: +// - the import list +// - the code between BEGIN USER CODE and END USER CODE +// - the code between BEGIN EXTRA CODE and END EXTRA CODE +// Other code you write will be lost the next time you deploy the project. +// Special characters, e.g., é, ö, à, etc. are supported in comments. + +package r1318.actions; + +import com.mendix.systemwideinterfaces.core.IContext; +import com.mendix.systemwideinterfaces.core.UserAction; +import java.util.Locale; + +public class JA_Old extends UserAction +{ + private final java.lang.String Text; + + public JA_Old( + IContext context, + java.lang.String _text + ) + { + super(context); + this.Text = _text; + } + + @java.lang.Override + public java.lang.String executeAction() throws Exception + { + // BEGIN USER CODE + + String who = "JA_Old"; // JA_Old named in user code + return helper(Text) + who.toLowerCase(Locale.ROOT).length() * 0; + + // END USER CODE + } + + /** + * Returns a string representation of this action + * @return a string representation of this action + */ + @java.lang.Override + public java.lang.String toString() + { + return "JA_Old"; + } + + // BEGIN EXTRA CODE + private static String helper(String s) { return s.trim(); } // was JA_Old + // END EXTRA CODE +} diff --git a/sdk/javaactions/testdata/mxbuild/JA_Renamed.java.golden b/sdk/javaactions/testdata/mxbuild/JA_Renamed.java.golden new file mode 100644 index 000000000..6ecb10ab9 --- /dev/null +++ b/sdk/javaactions/testdata/mxbuild/JA_Renamed.java.golden @@ -0,0 +1,53 @@ +// This file was generated by Mendix Studio Pro. +// +// WARNING: Only the following code will be retained when actions are regenerated: +// - the import list +// - the code between BEGIN USER CODE and END USER CODE +// - the code between BEGIN EXTRA CODE and END EXTRA CODE +// Other code you write will be lost the next time you deploy the project. +// Special characters, e.g., é, ö, à, etc. are supported in comments. + +package r1318.actions; + +import com.mendix.systemwideinterfaces.core.IContext; +import com.mendix.systemwideinterfaces.core.UserAction; +import java.util.Locale; + +public class JA_New extends UserAction +{ + private final java.lang.String Text; + + public JA_New( + IContext context, + java.lang.String _text + ) + { + super(context); + this.Text = _text; + } + + @java.lang.Override + public java.lang.String executeAction() throws Exception + { + // BEGIN USER CODE + + String who = "JA_Old"; // JA_Old named in user code + return helper(Text) + who.toLowerCase(Locale.ROOT).length() * 0; + + // END USER CODE + } + + /** + * Returns a string representation of this action + * @return a string representation of this action + */ + @java.lang.Override + public java.lang.String toString() + { + return "JA_New"; + } + + // BEGIN EXTRA CODE + private static String helper(String s) { return s.trim(); } // was JA_Old + // END EXTRA CODE +}