From 47fdfc74e58b465f0e3411ab4bfc74569bed0266 Mon Sep 17 00:00:00 2001 From: Ako Date: Sat, 26 Sep 2026 20:50:45 +0000 Subject: [PATCH 1/3] mdl: language header `mdl ;` with version-gated semantics (#710) An optional first statement declares the MDL language version a script is written in (ADR-0011 decision 2). No header is mdl 0, the alpha meaning. - grammar: `program : languageHeader? statement* EOF`; the word is an IDENTIFIER checked by the visitor, so `mdl` stays usable as a name (e.g. `describe contract entity X format mdl`). - mdl/langver: Version, Latest, and Frozen, the single preview/frozen switch. Before beta mdl 1 is a preview: it warns MDL-LANG01 "preview: may still change" and HeaderLine() (what describe/fmt emit) is "". - langver.Change + Builder.gate: the helper every change of meaning goes through; under an older version the old meaning is kept and a warning is recorded on the Program, reported by ValidateProgram. - executor: ExecContext.LanguageVersion carries the program's header to handlers; nested EXECUTE SCRIPT runs under its own header. - unknown versions are refused; a header after the first statement gets a hint; a header-only script counts as empty for exec/check/fmt. Co-Authored-By: Claude Opus 5.5 --- cmd/mxcli/cmd_fmt.go | 3 +- cmd/mxcli/empty_script.go | 11 +- cmd/mxcli/empty_script_test.go | 4 + cmd/mxcli/syntax/features_misc.go | 29 +++ docs-site/src/language/basics.md | 19 ++ docs/01-project/MDL_QUICK_REFERENCE.md | 9 + mdl/ast/ast.go | 26 ++- mdl/executor/exec_context.go | 8 + mdl/executor/executor.go | 17 +- mdl/executor/executor_dispatch.go | 1 + mdl/executor/language_version_test.go | 70 ++++++++ mdl/executor/validate_language_version.go | 44 +++++ .../validate_language_version_test.go | 62 +++++++ mdl/executor/validate_program.go | 4 + mdl/formatter/format_header_test.go | 33 ++++ mdl/grammar/MDLParser.g4 | 22 ++- mdl/langver/langver.go | 133 ++++++++++++++ mdl/langver/langver_test.go | 66 +++++++ mdl/visitor/language_header_test.go | 169 ++++++++++++++++++ mdl/visitor/visitor.go | 27 ++- mdl/visitor/visitor_language_header.go | 74 ++++++++ 21 files changed, 824 insertions(+), 7 deletions(-) create mode 100644 mdl/executor/language_version_test.go create mode 100644 mdl/executor/validate_language_version.go create mode 100644 mdl/executor/validate_language_version_test.go create mode 100644 mdl/formatter/format_header_test.go create mode 100644 mdl/langver/langver.go create mode 100644 mdl/langver/langver_test.go create mode 100644 mdl/visitor/language_header_test.go create mode 100644 mdl/visitor/visitor_language_header.go diff --git a/cmd/mxcli/cmd_fmt.go b/cmd/mxcli/cmd_fmt.go index c092a738bb..928c364a10 100644 --- a/cmd/mxcli/cmd_fmt.go +++ b/cmd/mxcli/cmd_fmt.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/mendixlabs/mxcli/mdl/formatter" + "github.com/mendixlabs/mxcli/mdl/langver" "github.com/mendixlabs/mxcli/mdl/visitor" "github.com/spf13/cobra" ) @@ -108,7 +109,7 @@ func init() { func hasSubstantiveContent(s string) bool { for _, line := range strings.Split(s, "\n") { t := strings.TrimSpace(line) - if t != "" && !strings.HasPrefix(t, "--") { + if t != "" && !strings.HasPrefix(t, "--") && !langver.IsHeaderLine(t) { return true } } diff --git a/cmd/mxcli/empty_script.go b/cmd/mxcli/empty_script.go index dd0681bdb5..57129a6801 100644 --- a/cmd/mxcli/empty_script.go +++ b/cmd/mxcli/empty_script.go @@ -2,7 +2,11 @@ package main -import "strings" +import ( + "strings" + + "github.com/mendixlabs/mxcli/mdl/langver" +) // unparsableInput reports input that has content but produced no statements. // @@ -30,6 +34,11 @@ func unparsableInput(src string, statements int) (string, bool) { if line == "" || strings.HasPrefix(line, "--") || strings.HasPrefix(line, "//") { continue } + // A language header declares the version and contributes no + // statement, so a script of only a header is empty too (ADR-0011). + if langver.IsHeaderLine(line) { + continue + } return line, true } return "", false diff --git a/cmd/mxcli/empty_script_test.go b/cmd/mxcli/empty_script_test.go index 6cd67ec7f5..b9e5c46489 100644 --- a/cmd/mxcli/empty_script_test.go +++ b/cmd/mxcli/empty_script_test.go @@ -36,6 +36,10 @@ func TestGenuinelyEmptyInputIsStillAccepted(t *testing.T) { "whitespace": " \n\n\t\n", "comments only": "-- set up the domain model\n-- (nothing yet)\n", "comments + blank": "\n-- TODO\n\n", + // A language header declares a version and nothing else (ADR-0011); + // a script of only a header is empty, not unparsable. + "header only": "mdl 1;\n", + "header + comments": "-- slice 1\nmdl 1;\n-- nothing yet\n", } { if _, bad := unparsableInput(src, 0); bad { t.Errorf("%s: refused, but it is a legitimately empty script", name) diff --git a/cmd/mxcli/syntax/features_misc.go b/cmd/mxcli/syntax/features_misc.go index 7a6f16eec6..3bb1e41eb6 100644 --- a/cmd/mxcli/syntax/features_misc.go +++ b/cmd/mxcli/syntax/features_misc.go @@ -3,6 +3,35 @@ package syntax func init() { + // ── Language header ───────────────────────────────────────────────── + + // The `mdl ;` header (ADR-0011, ako/mxcli#710). Documented as its own + // topic because it is a property of the whole script, not of a statement. + Register(SyntaxFeature{ + Path: "language-header", + Summary: "mdl ; — the MDL language version a script is written in", + Keywords: []string{ + "mdl 1", "mdl 0", "language version", "header", "edition", + "preview", "beta", "version-gated", "meaning", + }, + Syntax: "mdl ;\n\n" + + "-- Optional, and only as the FIRST statement of a script. It declares the\n" + + "-- language version the whole script is read under.\n" + + "--\n" + + "-- no header mdl 0, the alpha meaning. A construct whose meaning is\n" + + "-- different under mdl 1 keeps its old meaning and warns.\n" + + "-- mdl 1; the beta meaning. Until beta it is a PREVIEW: it parses\n" + + "-- but warns 'preview: may still change' (MDL-LANG01), and\n" + + "-- describe and fmt do not emit it.\n" + + "-- mdl 2; refused: this mxcli does not know that version.\n" + + "--\n" + + "-- A script's meaning never depends on which mxcli release runs it: a\n" + + "-- change of meaning applies only under the version that introduces it.\n" + + "-- The header is independent of the Mendix version the project targets.", + Example: "mdl 1;\n\ncreate persistent entity MyModule.Customer (\n Name: String(200)\n);", + SeeAlso: []string{"create-modifiers"}, + }) + // ── CREATE modifiers ──────────────────────────────────────────────── // OR MODIFY / OR REPLACE sit on the top-level createStatement rule, so they diff --git a/docs-site/src/language/basics.md b/docs-site/src/language/basics.md index c0856ea183..cd09b83366 100644 --- a/docs-site/src/language/basics.md +++ b/docs-site/src/language/basics.md @@ -35,6 +35,25 @@ INDEX (OrderDate DESC); Simple commands such as `HELP`, `EXIT`, `STATUS`, `SHOW`, and `DESCRIBE` do not require a terminator. +## Language Version Header + +A script may start with a header that names the MDL language version it is written in: + +```sql +mdl 1; + +create persistent entity Sales.Customer ( + Name: String(200) +); +``` + +- **No header** means `mdl 0`, the current (alpha) language. When a construct means something different under `mdl 1`, a headerless script keeps the old meaning and `check`/`exec` warn about it. A script's meaning never depends on which mxcli release runs it. +- **`mdl 1;`** selects the beta language. Until beta it is a **preview**: it parses, but warns `preview: may still change` (`MDL-LANG01`), and `describe` and `fmt` do not emit it. +- The header must be the **first** statement. A version this mxcli does not know is refused. +- It is independent of the Mendix version your project targets. + +The design is in [ADR-0011](https://github.com/mendixlabs/mxcli/blob/main/docs/13-decisions/0011-mdl-language-versioning.md); `mxcli syntax language-header` has the details. + ## Case Insensitivity All MDL **keywords** are case-insensitive. The following are equivalent: diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index af27e8df25..92452d926a 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -4,6 +4,15 @@ Complete syntax reference for MDL (Mendix Definition Language). This is the auth For task-specific guidance, see the skill files listed in [CLAUDE.md](../CLAUDE.md#important-before-writing-mdl-scripts-or-working-with-data). +## Language header — `mdl ;` + +An optional first statement naming the MDL language version the script is written in. No header is `mdl 0` (alpha meaning; constructs whose meaning differs under `mdl 1` keep the old meaning and warn). `mdl 1;` is a preview until beta: it warns `MDL-LANG01` and `describe`/`fmt` do not emit it. Unknown versions are refused. Independent of the Mendix target version ([ADR-0011](../13-decisions/0011-mdl-language-versioning.md)). + +```sql +mdl 1; +create persistent entity Sales.Customer ( Name: String(200) ); +``` + ## DESCRIBE — type is optional Every `describe Module.Name` statement also accepts a **bare** form with the type omitted — `describe Module.Name` — and the document type is auto-detected from the project (via the catalog `objects` index, built on demand). Use it anywhere: the REPL, `exec` scripts, and `mxcli describe Module.Name`. diff --git a/mdl/ast/ast.go b/mdl/ast/ast.go index 5c4aa32574..3ad8bef9cb 100644 --- a/mdl/ast/ast.go +++ b/mdl/ast/ast.go @@ -5,7 +5,11 @@ // associations, enumerations, and view entities. package ast -import "strings" +import ( + "strings" + + "github.com/mendixlabs/mxcli/mdl/langver" +) // Statement represents any MDL statement that can be executed. type Statement interface { @@ -53,6 +57,26 @@ type Program struct { // next to knownActivityAnnotations, instead of being spread across the seven // visitor sites that read them. DocumentAnnotations []DocumentAnnotation + + // LanguageVersion is the MDL language version the script is written in: + // the number in its `mdl ;` header, or mdl 0 when it has none + // (ADR-0011). A construct whose meaning differs between versions reads it + // through langver.Change; nothing may assume the latest. + LanguageVersion langver.Version + // LanguageHeaderLine is the 1-based line of the header, 0 when the script + // has none. + LanguageHeaderLine int + // LanguageNotes are the constructs kept at their older meaning because of + // LanguageVersion, one per occurrence, for check and exec to warn on. + LanguageNotes []LanguageNote +} + +// LanguageNote is one construct whose meaning depends on the language version, +// kept at the meaning of the version the script is written in. +type LanguageNote struct { + Line int // 1-based source line of the construct + Code string // the langver.Change's rule ID + Message string } // DocumentAnnotation is one annotation written before a CREATE statement. diff --git a/mdl/executor/exec_context.go b/mdl/executor/exec_context.go index 7a5e97a653..208eb47211 100644 --- a/mdl/executor/exec_context.go +++ b/mdl/executor/exec_context.go @@ -12,6 +12,7 @@ import ( "github.com/mendixlabs/mxcli/mdl/backend" "github.com/mendixlabs/mxcli/mdl/catalog" "github.com/mendixlabs/mxcli/mdl/diaglog" + "github.com/mendixlabs/mxcli/mdl/langver" "github.com/mendixlabs/mxcli/model" sqllib "github.com/mendixlabs/mxcli/sql" ) @@ -133,6 +134,13 @@ type ExecContext struct { // exactly how the toolbox-bitmap example broke the doctype harness, whose // working directory is the package under test. ScriptDir string + + // LanguageVersion is the `mdl ;` header of the script being run, mdl 0 + // when it has none or when the statement is not from a script (ADR-0011). + // A handler whose meaning depends on it declares a langver.Change and + // branches on Change.Applies(ctx.LanguageVersion); it never assumes the + // latest version. + LanguageVersion langver.Version } // ResolveScriptRelative turns a path written inside an MDL script into an diff --git a/mdl/executor/executor.go b/mdl/executor/executor.go index 0bd056918b..bb19581283 100644 --- a/mdl/executor/executor.go +++ b/mdl/executor/executor.go @@ -18,6 +18,7 @@ import ( "github.com/mendixlabs/mxcli/mdl/catalog" "github.com/mendixlabs/mxcli/mdl/diaglog" mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" + "github.com/mendixlabs/mxcli/mdl/langver" "github.com/mendixlabs/mxcli/mdl/types" "github.com/mendixlabs/mxcli/model" "github.com/mendixlabs/mxcli/sdk/domainmodel" @@ -245,7 +246,8 @@ type Executor struct { output io.Writer guard *outputGuard // line-limit wrapper around output mprPath string - scriptDir string // directory of the .mdl file being executed (see SetScriptDir) + scriptDir string // directory of the .mdl file being executed (see SetScriptDir) + langVersion langver.Version // `mdl ;` of the program being run (see enterLanguage) settings map[string]any cache *executorCache catalog *catalog.Catalog @@ -365,6 +367,7 @@ func (e *Executor) ExecuteProgram(prog *ast.Program) error { if e.beginTally() { defer e.flushTally() } + defer e.enterLanguage(prog.LanguageVersion)() // Collect all names defined in the script for forward-reference hints. allDefined := newScriptContext() @@ -382,6 +385,17 @@ func (e *Executor) ExecuteProgram(prog *ast.Program) error { return e.finalizeProgramExecution() } +// enterLanguage runs the following statements under a program's language +// version and returns the function that restores the previous one. The version +// belongs to the script, so a nested EXECUTE SCRIPT runs under its own header +// and its caller's is back afterwards. A statement run outside a program (the +// REPL, a -c one-liner) is mdl 0, the same as a headerless script. +func (e *Executor) enterLanguage(v langver.Version) func() { + prev := e.langVersion + e.langVersion = v + return func() { e.langVersion = prev } +} + // ExecuteProgramResult reports the outcome of a continue-on-error run. type ExecuteProgramResult struct { Total int // statements attempted @@ -401,6 +415,7 @@ func (e *Executor) ExecuteProgramContinueOnError(prog *ast.Program, w io.Writer) if e.beginTally() { defer e.flushTally() } + defer e.enterLanguage(prog.LanguageVersion)() allDefined := newScriptContext() allDefined.collectDefinitions(prog) diff --git a/mdl/executor/executor_dispatch.go b/mdl/executor/executor_dispatch.go index 444a92da1a..e000e859c5 100644 --- a/mdl/executor/executor_dispatch.go +++ b/mdl/executor/executor_dispatch.go @@ -101,6 +101,7 @@ func (e *Executor) newExecContext(ctx context.Context) *ExecContext { Cache: e.cache, MprPath: e.mprPath, ScriptDir: e.scriptDir, + LanguageVersion: e.langVersion, SqlMgr: e.sqlMgr, ThemeRegistry: e.themeRegistry, Settings: e.settings, diff --git a/mdl/executor/language_version_test.go b/mdl/executor/language_version_test.go new file mode 100644 index 0000000000..ba56ec09a5 --- /dev/null +++ b/mdl/executor/language_version_test.go @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "context" + "io" + "reflect" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/langver" + "github.com/mendixlabs/mxcli/mdl/visitor" +) + +// A handler whose meaning depends on the script's `mdl ;` header reads it +// from its ExecContext. The version belongs to the program being run, so a +// nested EXECUTE SCRIPT runs under its own header and the caller's is back +// afterwards. +func TestExecContextCarriesLanguageVersion(t *testing.T) { + e := New(io.Discard) + if got := e.newExecContext(context.Background()).LanguageVersion; got != langver.V0 { + t.Fatalf("outside a program: got %s, want mdl 0", got) + } + + restoreOuter := e.enterLanguage(langver.V1) + if got := e.newExecContext(context.Background()).LanguageVersion; got != langver.V1 { + t.Fatalf("inside an mdl 1 program: got %s", got) + } + restoreInner := e.enterLanguage(langver.V0) + if got := e.newExecContext(context.Background()).LanguageVersion; got != langver.V0 { + t.Fatalf("inside a nested headerless script: got %s", got) + } + restoreInner() + if got := e.newExecContext(context.Background()).LanguageVersion; got != langver.V1 { + t.Fatalf("after the nested script: got %s, want the caller's mdl 1 back", got) + } + restoreOuter() + if got := e.newExecContext(context.Background()).LanguageVersion; got != langver.V0 { + t.Fatalf("after the program: got %s, want mdl 0", got) + } +} + +// End to end: the header a script was parsed with is the version its +// statements' handlers see, and a statement run outside the program is mdl 0. +// The headerless script is the control. +func TestExecuteProgramRunsStatementsUnderTheHeader(t *testing.T) { + e := New(io.Discard) + var seen []langver.Version + e.registry.handlers[reflect.TypeOf(&ast.ShowStmt{})] = func(ctx *ExecContext, _ ast.Statement) error { + seen = append(seen, ctx.LanguageVersion) + return nil + } + for _, src := range []string{"show modules;", "mdl 1;\nshow modules;"} { + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatal(errs) + } + if err := e.ExecuteProgram(prog); err != nil { + t.Fatal(err) + } + if err := e.Execute(prog.Statements[0]); err != nil { + t.Fatal(err) + } + } + want := []langver.Version{langver.V0, langver.V0, langver.V1, langver.V0} + if !reflect.DeepEqual(seen, want) { + t.Fatalf("handlers saw %v, want %v (headerless, outside, mdl 1, outside)", seen, want) + } +} diff --git a/mdl/executor/validate_language_version.go b/mdl/executor/validate_language_version.go new file mode 100644 index 0000000000..919ef4e01b --- /dev/null +++ b/mdl/executor/validate_language_version.go @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/langver" + "github.com/mendixlabs/mxcli/mdl/linter" +) + +// ValidateLanguageVersion reports what the script's `mdl ;` header means +// for it (ADR-0011): +// +// - MDL-LANG01: the header names a preview version, whose meaning may still +// change between mxcli releases until it is frozen at beta. +// - one warning per construct the visitor kept at its older meaning, under +// that change's own rule ID, so a headerless script lists everything whose +// meaning differs under the newer language. +// +// Both are warnings: an mdl 0 script must keep running unchanged, and a preview +// is usable, only not yet a contract. An unknown version is refused earlier, by +// the parser. +func ValidateLanguageVersion(prog *ast.Program) []linter.Violation { + var out []linter.Violation + if v := prog.LanguageVersion; v.IsPreview() { + out = append(out, linter.Violation{ + RuleID: "MDL-LANG01", + Severity: linter.SeverityWarning, + Message: fmt.Sprintf("line %d: %s", prog.LanguageHeaderLine, langver.PreviewWarning(v)), + Suggestion: "Keep using it to try the beta language; omit the header for the " + + "alpha meaning (mdl 0) if the script must not change under a later release.", + }) + } + for _, n := range prog.LanguageNotes { + out = append(out, linter.Violation{ + RuleID: n.Code, + Severity: linter.SeverityWarning, + Message: fmt.Sprintf("line %d: %s", n.Line, n.Message), + }) + } + return out +} diff --git a/mdl/executor/validate_language_version_test.go b/mdl/executor/validate_language_version_test.go new file mode 100644 index 0000000000..395783f6c4 --- /dev/null +++ b/mdl/executor/validate_language_version_test.go @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/langver" + "github.com/mendixlabs/mxcli/mdl/linter" + "github.com/mendixlabs/mxcli/mdl/visitor" +) + +func languageViolations(t *testing.T, src string) []linter.Violation { + t.Helper() + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("parse errors: %v", errs) + } + var out []linter.Violation + for _, v := range ValidateProgram(prog, "") { + if strings.HasPrefix(v.RuleID, "MDL-LANG") || strings.HasPrefix(v.RuleID, "MDL-TEST") { + out = append(out, v) + } + } + return out +} + +// Before beta, `mdl 1;` parses but warns that it may still change (ADR-0011). +func TestValidateLanguageVersion_PreviewWarns(t *testing.T) { + vs := languageViolations(t, "mdl 1;\nshow entities;") + if len(vs) != 1 { + t.Fatalf("want one preview warning, got %v", vs) + } + v := vs[0] + if v.RuleID != "MDL-LANG01" || v.Severity != linter.SeverityWarning || + !strings.Contains(v.Message, "preview: may still change") { + t.Fatalf("got %+v", v) + } +} + +// Control: the same script without the header does not warn. +func TestValidateLanguageVersion_HeaderlessDoesNotWarn(t *testing.T) { + if vs := languageViolations(t, "show entities;"); len(vs) != 0 { + t.Fatalf("a headerless script with no gated construct warned: %v", vs) + } +} + +// A construct kept at its old meaning is reported as a warning with its own +// rule ID and line — not an error, since mdl 0 scripts must keep running. +func TestValidateLanguageVersion_ReportsGatedConstructs(t *testing.T) { + prog := &ast.Program{ + LanguageVersion: langver.V0, + LanguageNotes: []ast.LanguageNote{{Line: 7, Code: "MDL-TEST-GATE", Message: "kept"}}, + } + vs := ValidateLanguageVersion(prog) + if len(vs) != 1 || vs[0].RuleID != "MDL-TEST-GATE" || vs[0].Severity != linter.SeverityWarning || + !strings.Contains(vs[0].Message, "line 7") { + t.Fatalf("got %+v", vs) + } +} diff --git a/mdl/executor/validate_program.go b/mdl/executor/validate_program.go index 3c98718616..4935b6f2d1 100644 --- a/mdl/executor/validate_program.go +++ b/mdl/executor/validate_program.go @@ -290,5 +290,9 @@ func ValidateProgram(prog *ast.Program, projectPath string) []linter.Violation { // parsed and did nothing (MDL059, the same rule statements already have). violations = append(violations, ValidateDocumentAnnotations(prog)...) + // The `mdl ;` header: a preview version warns that it may still change, + // and every construct kept at an older meaning warns (ADR-0011). + violations = append(violations, ValidateLanguageVersion(prog)...) + return violations } diff --git a/mdl/formatter/format_header_test.go b/mdl/formatter/format_header_test.go new file mode 100644 index 0000000000..62027fef41 --- /dev/null +++ b/mdl/formatter/format_header_test.go @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: Apache-2.0 + +package formatter + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/langver" +) + +// fmt keeps a script's language header where it is, and adds none: mdl 1 is a +// preview until beta, and only `fmt --upgrade` may add a header (ADR-0011). +func TestFormat_LanguageHeaderRoundTrips(t *testing.T) { + src := "mdl 1;\ncreate entity Shop.Customer ( Name: String(200) );\n" + got := Format(src) + if !strings.HasPrefix(got, "mdl 1;\n") { + t.Fatalf("header not kept as the first line:\n%s", got) + } + if again := Format(got); again != got { + t.Fatalf("not idempotent:\n%s\n---\n%s", got, again) + } +} + +func TestFormat_AddsNoHeaderWhileMdl1IsAPreview(t *testing.T) { + if langver.HeaderLine() != "" { + t.Skip("mdl 1 is frozen; fmt emitting it is decided by HeaderLine") + } + got := Format("create entity Shop.Customer ( Name: String(200) );\n") + if langver.IsHeaderLine(strings.SplitN(got, "\n", 2)[0]) { + t.Fatalf("fmt added a language header to a headerless script:\n%s", got) + } +} diff --git a/mdl/grammar/MDLParser.g4 b/mdl/grammar/MDLParser.g4 index fc2f79d57e..225eb0e353 100644 --- a/mdl/grammar/MDLParser.g4 +++ b/mdl/grammar/MDLParser.g4 @@ -29,9 +29,27 @@ import // TOP-LEVEL RULES // ============================================================================= -/** Entry point: a program is a sequence of statements */ +/** Entry point: a program is an optional language header and a sequence of statements */ program - : statement* EOF + : languageHeader? statement* EOF + ; + +/** + * The MDL language version a script is written in (ADR-0011): `mdl ;`. + * Only the first statement may be a header; a script without one is mdl 0, the alpha meaning. + * It is independent of the Mendix version the project targets. + * + * @example + * ```mdl + * mdl 1; + * create entity Shop.Customer ( Name: String(200) ); + * ``` + */ +languageHeader + // The word is an IDENTIFIER, not a keyword, and the visitor requires it to + // be `mdl`: reserving it would take the word away from every place that + // accepts only an IDENTIFIER, such as `describe contract entity X format mdl`. + : IDENTIFIER NUMBER_LITERAL SEMICOLON ; /** A statement can be DDL, DQL, or utility */ diff --git a/mdl/langver/langver.go b/mdl/langver/langver.go new file mode 100644 index 0000000000..2a41594297 --- /dev/null +++ b/mdl/langver/langver.go @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package langver is the MDL language version: the `mdl ;` header a script +// may start with, and the gate every change of meaning goes through. +// +// ADR-0011 decision 2 is the contract this package implements: +// +// - A script with no header is mdl 0, the alpha meaning. Nobody writes +// `mdl 0;`; it is only ever implicit. +// - A change of meaning, or a new rejection, applies only under the version +// that introduces it. Under an older version the old meaning is kept and +// the construct warns, so a committed script never changes behaviour just +// because a newer mxcli runs it. +// - A version is a PREVIEW until it is frozen. A preview parses but warns +// "preview: may still change", and describe/fmt do not emit its header. +// Freezing is one edit to Frozen below. +// +// The language version is independent of the Mendix target version (ADR-0011 +// decision 3); nothing here reads or writes a Mendix version. +package langver + +import ( + "fmt" + "regexp" +) + +// Version is an MDL language version, the number in `mdl ;`. +type Version int + +const ( + // V0 is the alpha language: every script written before the header + // existed, and every script that still omits it. + V0 Version = 0 + // V1 is the beta language (ADR-0010's canonical syntax and the §5 changes + // of meaning in PROPOSAL_mdl_beta_syntax_freeze.md). + V1 Version = 1 +) + +// Latest is the newest version this mxcli understands. A header naming a +// higher one is refused: running a script under rules it was not written for +// is exactly the silent change of meaning the header exists to prevent. +const Latest = V1 + +// Frozen is the newest FROZEN version. It is the single preview/frozen switch. +// +// Every version above Frozen and up to Latest is a preview: it parses, warns +// that it may still change, and describe and fmt do not emit it. At beta this +// becomes V1 and nothing else has to change: the preview warning stops, and +// HeaderLine starts returning "mdl 1;" for describe and fmt to write. +// +// After that, a change of meaning needs a new version (V2), which starts life +// as a preview by being above Frozen. +const Frozen = V0 + +// Default is the version of a script that has no header. +const Default = V0 + +// Known reports whether this mxcli can run a script written in v. +func (v Version) Known() bool { return v >= V0 && v <= Latest } + +// IsPreview reports whether v is a version that may still change. +func (v Version) IsPreview() bool { return isPreview(v, Frozen) } + +func isPreview(v, frozen Version) bool { return v > frozen && v <= Latest } + +// String renders the header spelling of v without the terminator: "mdl 1". +func (v Version) String() string { return fmt.Sprintf("mdl %d", int(v)) } + +// HeaderLine is the header describe and fmt write at the top of a script, or +// "" when they write none. +// +// It is the newest frozen version, never a preview: output carrying a preview +// header would pin the reader's script to rules that may still change under +// it. While Frozen is V0 this is "", because mdl 0 is only ever implicit. +func HeaderLine() string { return headerLine(Frozen) } + +func headerLine(frozen Version) string { + if frozen <= V0 { + return "" + } + return frozen.String() + ";" +} + +// PreviewWarning is the message for a header naming a preview version. +func PreviewWarning(v Version) string { + return fmt.Sprintf("%s is a preview: may still change. It is not frozen until beta, "+ + "so a script written against it can behave differently under a later mxcli release.", v) +} + +// UnknownVersionError is the message for a header naming a version this mxcli +// does not know. +func UnknownVersionError(v Version) string { + return fmt.Sprintf("unknown MDL language version %d: this mxcli understands mdl 0 through mdl %d. "+ + "Running a script under rules older than the ones it was written for would change "+ + "its meaning; upgrade mxcli.", int(v), int(Latest)) +} + +// headerLineRe matches a line holding only a language header. +var headerLineRe = regexp.MustCompile(`(?i)^\s*mdl\s+\d+\s*;\s*(--.*)?$`) + +// IsHeaderLine reports whether a source line holds a language header and +// nothing else (a trailing `--` comment aside). +func IsHeaderLine(line string) bool { return headerLineRe.MatchString(line) } + +// Change is one construct whose meaning differs between language versions: a +// change of meaning or a new rejection from PROPOSAL_mdl_beta_syntax_freeze.md +// §5, gated on the header per ADR-0011. +// +// Declare one per change, next to the code that implements both meanings, and +// branch on Applies. Under an older version the caller keeps the old meaning +// and reports Warning, so a headerless script names every construct whose +// meaning differs under the newer language. +type Change struct { + // Code is the rule ID reported with the old-version warning. + Code string + // Since is the first version with the new meaning. + Since Version + // Old describes what the construct means before Since. + Old string + // New describes what it means from Since on. + New string +} + +// Applies reports whether the new meaning is in force for a script written in v. +func (c Change) Applies(v Version) bool { return v >= c.Since } + +// Warning is the message for a construct kept at its old meaning because the +// script is written in a version before Since. +func (c Change) Warning(v Version) string { + return fmt.Sprintf("%s under %s; under %s it means %s. "+ + "Start the script with `%s;` to opt in, or keep this meaning explicitly.", + c.Old, v, c.Since, c.New, c.Since) +} diff --git a/mdl/langver/langver_test.go b/mdl/langver/langver_test.go new file mode 100644 index 0000000000..869257a0c7 --- /dev/null +++ b/mdl/langver/langver_test.go @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: Apache-2.0 + +package langver + +import "testing" + +func TestHeaderlessIsMdl0(t *testing.T) { + if Default != V0 { + t.Fatal("a script without a header must get the alpha meaning (ADR-0011), never the latest") + } +} + +// Before beta, mdl 1 is a preview and nothing emits a header. +func TestPreviewState(t *testing.T) { + if !V1.IsPreview() { + t.Error("mdl 1 must be a preview until beta") + } + if V0.IsPreview() { + t.Error("mdl 0 is the alpha language, not a preview") + } + if got := HeaderLine(); got != "" { + t.Errorf("describe/fmt must not emit a header while mdl 1 is a preview, got %q", got) + } +} + +// Flipping the switch to V1 is all beta takes: mdl 1 stops being a preview and +// becomes the header describe/fmt write. +func TestFrozenSwitch(t *testing.T) { + if isPreview(V1, V1) { + t.Error("mdl 1 is still a preview once frozen") + } + if got := headerLine(V1); got != "mdl 1;" { + t.Errorf("frozen mdl 1: got header %q", got) + } + if isPreview(Latest+1, V1) { + t.Error("an unknown version is not a preview, it is refused") + } +} + +func TestKnown(t *testing.T) { + for v, want := range map[Version]bool{-1: false, V0: true, V1: true, Latest + 1: false} { + if v.Known() != want { + t.Errorf("Known(%d) = %v", v, !want) + } + } +} + +func TestChangeApplies(t *testing.T) { + c := Change{Code: "X", Since: V1, Old: "old", New: "new"} + if c.Applies(V0) || !c.Applies(V1) { + t.Fatal("a change applies from its version on, and only then") + } +} + +func TestIsHeaderLine(t *testing.T) { + for line, want := range map[string]bool{ + "mdl 1;": true, + " MDL 1 ; -- note": true, + "mdl 1": false, + "create entity mdl.X ( A: String );": false, + } { + if IsHeaderLine(line) != want { + t.Errorf("IsHeaderLine(%q) = %v", line, !want) + } + } +} diff --git a/mdl/visitor/language_header_test.go b/mdl/visitor/language_header_test.go new file mode 100644 index 0000000000..3ede13178b --- /dev/null +++ b/mdl/visitor/language_header_test.go @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "strings" + "testing" + + "github.com/antlr4-go/antlr/v4" + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/grammar/parser" + "github.com/mendixlabs/mxcli/mdl/langver" +) + +// The `mdl ;` header declares the language version a script is written in +// (ADR-0011 decision 2, ako/mxcli#710). A script without one is mdl 0. + +func TestLanguageHeader_HeaderlessScriptIsMdl0(t *testing.T) { + prog, errs := Build("show entities;") + if len(errs) > 0 { + t.Fatalf("unexpected errors: %v", errs) + } + if prog.LanguageVersion != langver.V0 || prog.LanguageHeaderLine != 0 { + t.Fatalf("headerless script: got version %d, header line %d; want mdl 0 with no header", + prog.LanguageVersion, prog.LanguageHeaderLine) + } +} + +func TestLanguageHeader_Parses(t *testing.T) { + for _, src := range []string{ + "mdl 1;\nshow entities;", + "MDL 1;\nshow entities;", + "-- a leading comment is not a statement\nmdl 1;\nshow entities;", + "mdl 0;\nshow entities;", // only ever implicit, but harmless when written + } { + prog, errs := Build(src) + if len(errs) > 0 { + t.Errorf("%q: unexpected errors: %v", src, errs) + continue + } + if len(prog.Statements) != 1 { + t.Errorf("%q: got %d statements, want 1 (the header is not a statement)", src, len(prog.Statements)) + } + want := langver.V1 + if strings.HasPrefix(src, "mdl 0") { + want = langver.V0 + } + if prog.LanguageVersion != want { + t.Errorf("%q: got version %d, want %d", src, prog.LanguageVersion, want) + } + if prog.LanguageHeaderLine == 0 { + t.Errorf("%q: header line not recorded", src) + } + } +} + +func TestLanguageHeader_HeaderOnlyScript(t *testing.T) { + prog, errs := Build("mdl 1;") + if len(errs) > 0 { + t.Fatalf("unexpected errors: %v", errs) + } + if prog.LanguageVersion != langver.V1 || len(prog.Statements) != 0 { + t.Fatalf("got version %d and %d statements", prog.LanguageVersion, len(prog.Statements)) + } +} + +func TestLanguageHeader_UnknownVersionIsRefused(t *testing.T) { + _, errs := Build("mdl 2;\nshow entities;") + if len(errs) == 0 || !strings.Contains(errs[0].Error(), "unknown MDL language version 2") { + t.Fatalf("want an unknown-version error, got %v", errs) + } +} + +func TestLanguageHeader_NonIntegerVersionIsRefused(t *testing.T) { + _, errs := Build("mdl 1.5;\nshow entities;") + if len(errs) == 0 || !strings.Contains(errs[0].Error(), "whole number") { + t.Fatalf("want a whole-number error, got %v", errs) + } +} + +func TestLanguageHeader_OnlyAsFirstStatement(t *testing.T) { + _, errs := Build("show entities;\nmdl 1;") + if len(errs) == 0 { + t.Fatal("a header after a statement parsed") + } + if !strings.Contains(errs[0].Error(), "must be the first statement") { + t.Fatalf("error does not say where the header belongs: %v", errs[0]) + } +} + +func TestLanguageHeader_OnlyTheWordMdl(t *testing.T) { + _, errs := Build("mdk 1;\nshow entities;") + if len(errs) == 0 || !strings.Contains(errs[0].Error(), "`mdl ;`") { + t.Fatalf("a misspelled header was accepted or not explained: %v", errs) + } +} + +// The header must not take the word away from names, including positions that +// accept only an IDENTIFIER. +func TestLanguageHeader_MdlIsStillAName(t *testing.T) { + prog, errs := Build("create entity mdl.Mdl ( mdl: String(20) );\n" + + "describe contract entity MyModule.Api.Product format mdl;") + if len(errs) > 0 { + t.Fatalf("unexpected errors: %v", errs) + } + e, ok := prog.Statements[0].(*ast.CreateEntityStmt) + if !ok || e.Name.Module != "mdl" || e.Attributes[0].Name != "mdl" { + t.Fatalf("got %#v", prog.Statements[0]) + } + if prog.LanguageHeaderLine != 0 { + t.Fatal("a name was read as a header") + } +} + +// A gated construct: one whose meaning differs between mdl 0 and mdl 1. No +// real construct is gated yet (the §5 changes land in their own issues), so a +// test-only change is attached to `show`, through the same Builder.gate the +// real ones will call. +var testGatedChange = langver.Change{ + Code: "MDL-TEST-GATE", + Since: langver.V1, + Old: "`show` means the old thing", + New: "the new thing", +} + +type gateProbe struct { + *Builder + newMeaning []bool +} + +func (p *gateProbe) ExitShowStatement(ctx *parser.ShowStatementContext) { + p.Builder.ExitShowStatement(ctx) + p.newMeaning = append(p.newMeaning, p.gate(testGatedChange, ctx)) +} + +func buildWithProbe(t *testing.T, src string) (*ast.Program, *gateProbe) { + t.Helper() + var probe *gateProbe + prog, errs := build(src, func(b *Builder) antlr.ParseTreeListener { + probe = &gateProbe{Builder: b} + return probe + }) + if len(errs) > 0 { + t.Fatalf("unexpected errors: %v", errs) + } + return prog, probe +} + +func TestLanguageHeader_GatedConstructDiffersByVersion(t *testing.T) { + prog, probe := buildWithProbe(t, "show entities;\nshow modules;") + if len(probe.newMeaning) != 2 || probe.newMeaning[0] || probe.newMeaning[1] { + t.Fatalf("mdl 0: want the old meaning for both statements, got %v", probe.newMeaning) + } + if len(prog.LanguageNotes) != 2 { + t.Fatalf("mdl 0: want one warning per gated construct, got %v", prog.LanguageNotes) + } + n := prog.LanguageNotes[1] + if n.Code != "MDL-TEST-GATE" || n.Line != 2 || !strings.Contains(n.Message, "under mdl 1 it means the new thing") { + t.Fatalf("mdl 0 warning: got %+v", n) + } + + prog, probe = buildWithProbe(t, "mdl 1;\nshow entities;") + if len(probe.newMeaning) != 1 || !probe.newMeaning[0] { + t.Fatalf("mdl 1: want the new meaning, got %v", probe.newMeaning) + } + if len(prog.LanguageNotes) != 0 { + t.Fatalf("mdl 1: the new meaning must not warn, got %v", prog.LanguageNotes) + } +} diff --git a/mdl/visitor/visitor.go b/mdl/visitor/visitor.go index f20b39c753..5441ac74ce 100644 --- a/mdl/visitor/visitor.go +++ b/mdl/visitor/visitor.go @@ -12,6 +12,7 @@ import ( "github.com/antlr4-go/antlr/v4" "github.com/mendixlabs/mxcli/mdl/ast" "github.com/mendixlabs/mxcli/mdl/grammar/parser" + "github.com/mendixlabs/mxcli/mdl/langver" "github.com/mendixlabs/mxcli/mdl/types" ) @@ -112,6 +113,14 @@ func enhanceErrorMessage(msg, offendingLine string) string { // branch below (and the fall-through) shows the tamer form. msg = simplifyExpecting(msg) + // A language header anywhere but first. It declares the rules the whole + // script is read under, so it cannot switch them part-way (ADR-0011). + if langver.IsHeaderLine(offendingLine) { + return fmt.Sprintf("%s\n\n The language header `mdl ;` must be the first statement of a script:\n"+ + " it declares the language version the whole script is written in.\n"+ + " mdl 1;\n"+ + " create entity Shop.Customer ( Name: String(200) ); (correct)", msg) + } // A bare `not $x` — Mendix requires `not(expr)`. The parse error surfaces // downstream (e.g. "missing THEN at '$x'"), so key off the source line, which // is unambiguous for `not $…`. (sudoku findings #3) @@ -477,6 +486,13 @@ type Builder struct { // layoutBracedPlaceholders collects the braced placeholder names seen while // inLayout, at any depth, for the checker to report. layoutBracedPlaceholders []string + + // langVersion is the script's `mdl ;` header, mdl 0 without one. The + // header can only be the first statement, so it is set before any + // construct that depends on it is visited. See gate. + langVersion langver.Version + langHeaderLine int + langNotes []ast.LanguageNote } // NewBuilder creates a new AST builder. @@ -511,6 +527,12 @@ func collectLeafTokens(tree antlr.Tree, tokens *[]string) { // Build parses the input and returns the AST program. func Build(input string) (*ast.Program, []error) { + return build(input, func(b *Builder) antlr.ParseTreeListener { return b }) +} + +// build is Build with the tree walked by listen(builder) instead of the +// builder itself, so a test can observe the walk through a wrapper. +func build(input string, listen func(*Builder) antlr.ParseTreeListener) (*ast.Program, []error) { // Create custom error listener to capture syntax errors errListener := newErrorListener() errListener.source = strings.Split(input, "\n") @@ -530,13 +552,16 @@ func Build(input string) (*ast.Program, []error) { // Create builder and walk the tree builder := NewBuilder() tree := p.Program() - antlr.ParseTreeWalkerDefault.Walk(builder, tree) + antlr.ParseTreeWalkerDefault.Walk(listen(builder), tree) // Combine syntax errors and builder errors allErrors := append(errListener.errors, builder.errors...) return &ast.Program{ Statements: builder.statements, DocumentAnnotations: builder.documentAnnotations, + LanguageVersion: builder.langVersion, + LanguageHeaderLine: builder.langHeaderLine, + LanguageNotes: builder.langNotes, }, allErrors } diff --git a/mdl/visitor/visitor_language_header.go b/mdl/visitor/visitor_language_header.go new file mode 100644 index 0000000000..556ec527ee --- /dev/null +++ b/mdl/visitor/visitor_language_header.go @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "fmt" + "strconv" + "strings" + + "github.com/antlr4-go/antlr/v4" + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/grammar/parser" + "github.com/mendixlabs/mxcli/mdl/langver" +) + +// ExitLanguageHeader records the script's `mdl ;` header (ADR-0011). +// +// An unknown version is an error rather than a warning: running a script under +// rules older than the ones it was written for is the silent change of meaning +// the header exists to prevent. Whether the version is a preview is reported by +// the validator, next to every other warning check and exec print. +func (b *Builder) ExitLanguageHeader(ctx *parser.LanguageHeaderContext) { + word, num := ctx.IDENTIFIER(), ctx.NUMBER_LITERAL() + if word == nil || num == nil { + return // a syntax error has already been reported + } + // The grammar takes any word here so that `mdl` stays usable as a name; + // only `mdl` is a header. + if !strings.EqualFold(word.GetText(), "mdl") { + b.addError(fmt.Errorf("line %d: `%s %s;` is not a statement; the only statement of this shape is "+ + "the language header, `mdl ;`", ctx.GetStart().GetLine(), word.GetText(), num.GetText())) + return + } + n, err := strconv.Atoi(num.GetText()) + if err != nil { + b.addError(fmt.Errorf("line %d: the language version in `mdl %s;` must be a whole number, e.g. `mdl 1;`", + ctx.GetStart().GetLine(), num.GetText())) + return + } + v := langver.Version(n) + if !v.Known() { + b.addError(fmt.Errorf("line %d: %s", ctx.GetStart().GetLine(), langver.UnknownVersionError(v))) + return + } + b.langVersion = v + b.langHeaderLine = ctx.GetStart().GetLine() +} + +// gate decides which meaning a version-dependent construct gets. It returns +// true when the script's language version has the new meaning. Otherwise the +// caller keeps the old meaning, and gate records a warning naming the +// construct, so a script without the header lists everything whose meaning +// differs under the newer language (ADR-0011 decision 2). +// +// Every change of meaning or new rejection goes through here, declared as a +// langver.Change next to the code that implements both meanings: +// +// var limitOneIsAList = langver.Change{Code: "MDL-V1-…", Since: langver.V1, …} +// if b.gate(limitOneIsAList, ctx) { /* mdl 1 meaning */ } else { /* mdl 0 meaning */ } +func (b *Builder) gate(c langver.Change, ctx antlr.ParserRuleContext) bool { + if c.Applies(b.langVersion) { + return true + } + line := 0 + if ctx != nil && ctx.GetStart() != nil { + line = ctx.GetStart().GetLine() + } + b.langNotes = append(b.langNotes, ast.LanguageNote{ + Line: line, + Code: c.Code, + Message: c.Warning(b.langVersion), + }) + return false +} From 5d3ec446c8740c025e067d0d4d4b78d3ab078e41 Mon Sep 17 00:00:00 2001 From: Ako Date: Sat, 26 Sep 2026 21:43:30 +0000 Subject: [PATCH 2/3] test: exec --continue-on-error runs statements under the language header (#710) ExecuteProgramContinueOnError enters the header's version separately from ExecuteProgram, and nothing covered it: removing its enterLanguage call left the suite green. The new test fails with handlers seeing mdl 0 throughout. Co-Authored-By: Claude Opus 5.5 --- mdl/executor/language_version_test.go | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/mdl/executor/language_version_test.go b/mdl/executor/language_version_test.go index ba56ec09a5..df096f4f8f 100644 --- a/mdl/executor/language_version_test.go +++ b/mdl/executor/language_version_test.go @@ -68,3 +68,30 @@ func TestExecuteProgramRunsStatementsUnderTheHeader(t *testing.T) { t.Fatalf("handlers saw %v, want %v (headerless, outside, mdl 1, outside)", seen, want) } } + +// `exec --continue-on-error` runs through ExecuteProgramContinueOnError, a +// separate entry point that must enter the header's version too. +func TestExecuteProgramContinueOnErrorRunsStatementsUnderTheHeader(t *testing.T) { + e := New(io.Discard) + var seen []langver.Version + e.registry.handlers[reflect.TypeOf(&ast.ShowStmt{})] = func(ctx *ExecContext, _ ast.Statement) error { + seen = append(seen, ctx.LanguageVersion) + return nil + } + for _, src := range []string{"show modules;", "mdl 1;\nshow modules;"} { + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatal(errs) + } + if _, err := e.ExecuteProgramContinueOnError(prog, io.Discard); err != nil { + t.Fatal(err) + } + if err := e.Execute(prog.Statements[0]); err != nil { + t.Fatal(err) + } + } + want := []langver.Version{langver.V0, langver.V0, langver.V1, langver.V0} + if !reflect.DeepEqual(seen, want) { + t.Fatalf("handlers saw %v, want %v (headerless, outside, mdl 1, outside)", seen, want) + } +} From 3c393eb79680487d4c9dcb6604dbfac143959cb7 Mon Sep 17 00:00:00 2001 From: Ako Date: Sat, 26 Sep 2026 21:43:30 +0000 Subject: [PATCH 3/3] mdl: a language version too large for an int is unknown, not malformed (#710) `mdl 99999999999999999999;` said the version "must be a whole number", which it is. strconv.ErrRange now reports the unknown-version error with the number as written. Co-Authored-By: Claude Opus 5.5 --- mdl/langver/langver.go | 9 +++++---- mdl/visitor/language_header_test.go | 9 +++++++++ mdl/visitor/visitor_language_header.go | 8 +++++++- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/mdl/langver/langver.go b/mdl/langver/langver.go index 2a41594297..2a814feac8 100644 --- a/mdl/langver/langver.go +++ b/mdl/langver/langver.go @@ -88,11 +88,12 @@ func PreviewWarning(v Version) string { } // UnknownVersionError is the message for a header naming a version this mxcli -// does not know. -func UnknownVersionError(v Version) string { - return fmt.Sprintf("unknown MDL language version %d: this mxcli understands mdl 0 through mdl %d. "+ +// does not know. It takes the version as written, since a whole number too +// large for a Version is unknown rather than malformed. +func UnknownVersionError(written string) string { + return fmt.Sprintf("unknown MDL language version %s: this mxcli understands mdl 0 through mdl %d. "+ "Running a script under rules older than the ones it was written for would change "+ - "its meaning; upgrade mxcli.", int(v), int(Latest)) + "its meaning; upgrade mxcli.", written, int(Latest)) } // headerLineRe matches a line holding only a language header. diff --git a/mdl/visitor/language_header_test.go b/mdl/visitor/language_header_test.go index 3ede13178b..5110f9d2b7 100644 --- a/mdl/visitor/language_header_test.go +++ b/mdl/visitor/language_header_test.go @@ -71,6 +71,15 @@ func TestLanguageHeader_UnknownVersionIsRefused(t *testing.T) { } } +// A version too large for an int is still a whole number: it is unknown, not +// malformed, and the message must say so. +func TestLanguageHeader_OverflowingVersionIsUnknown(t *testing.T) { + _, errs := Build("mdl 99999999999999999999;\nshow entities;") + if len(errs) == 0 || !strings.Contains(errs[0].Error(), "unknown MDL language version 99999999999999999999") { + t.Fatalf("want an unknown-version error, got %v", errs) + } +} + func TestLanguageHeader_NonIntegerVersionIsRefused(t *testing.T) { _, errs := Build("mdl 1.5;\nshow entities;") if len(errs) == 0 || !strings.Contains(errs[0].Error(), "whole number") { diff --git a/mdl/visitor/visitor_language_header.go b/mdl/visitor/visitor_language_header.go index 556ec527ee..e1a3e6cabe 100644 --- a/mdl/visitor/visitor_language_header.go +++ b/mdl/visitor/visitor_language_header.go @@ -3,6 +3,7 @@ package visitor import ( + "errors" "fmt" "strconv" "strings" @@ -32,6 +33,11 @@ func (b *Builder) ExitLanguageHeader(ctx *parser.LanguageHeaderContext) { return } n, err := strconv.Atoi(num.GetText()) + if errors.Is(err, strconv.ErrRange) { + // All digits, only too large: a version this mxcli does not know. + b.addError(fmt.Errorf("line %d: %s", ctx.GetStart().GetLine(), langver.UnknownVersionError(num.GetText()))) + return + } if err != nil { b.addError(fmt.Errorf("line %d: the language version in `mdl %s;` must be a whole number, e.g. `mdl 1;`", ctx.GetStart().GetLine(), num.GetText())) @@ -39,7 +45,7 @@ func (b *Builder) ExitLanguageHeader(ctx *parser.LanguageHeaderContext) { } v := langver.Version(n) if !v.Known() { - b.addError(fmt.Errorf("line %d: %s", ctx.GetStart().GetLine(), langver.UnknownVersionError(v))) + b.addError(fmt.Errorf("line %d: %s", ctx.GetStart().GetLine(), langver.UnknownVersionError(num.GetText()))) return } b.langVersion = v