Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion cmd/mxcli/cmd_fmt.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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
}
}
Expand Down
11 changes: 10 additions & 1 deletion cmd/mxcli/empty_script.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions cmd/mxcli/empty_script_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
29 changes: 29 additions & 0 deletions cmd/mxcli/syntax/features_misc.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,35 @@
package syntax

func init() {
// ── Language header ─────────────────────────────────────────────────

// The `mdl <n>;` 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 <n>; — 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\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
Expand Down
19 changes: 19 additions & 0 deletions docs-site/src/language/basics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 9 additions & 0 deletions docs/01-project/MDL_QUICK_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <n>;`

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 <type> 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`.
Expand Down
26 changes: 25 additions & 1 deletion mdl/ast/ast.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 <n>;` 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.
Expand Down
8 changes: 8 additions & 0 deletions mdl/executor/exec_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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 <n>;` 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
Expand Down
17 changes: 16 additions & 1 deletion mdl/executor/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 <n>;` of the program being run (see enterLanguage)
settings map[string]any
cache *executorCache
catalog *catalog.Catalog
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand All @@ -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)
Expand Down
1 change: 1 addition & 0 deletions mdl/executor/executor_dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
97 changes: 97 additions & 0 deletions mdl/executor/language_version_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// 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 <n>;` 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)
}
}

// `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)
}
}
44 changes: 44 additions & 0 deletions mdl/executor/validate_language_version.go
Original file line number Diff line number Diff line change
@@ -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 <n>;` 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
}
Loading
Loading