Skip to content

Commit a58bb72

Browse files
committed
add config subcommand
1 parent a450aa1 commit a58bb72

7 files changed

Lines changed: 420 additions & 163 deletions

File tree

‎README.md‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,16 @@ Do you want to create your own policies? Please refer to our guide [Create new p
4545

4646
## Subcommands Reference
4747

48+
> Configuration-driven commands: subcommand-specific options are read from merged config (`default.yaml` + system `mxlint.yaml` + project `mxlint.yaml`). You can also pass `--config <path>` to apply a specific config file with the highest precedence. Use global `--verbose` on the root command to enable debug logging for any subcommand.
49+
50+
Global verbose examples:
51+
52+
```bash
53+
mxlint-cli --verbose lint
54+
mxlint-cli -v export-model
55+
mxlint-cli -v serve
56+
```
57+
4858
### export-model
4959

5060
Export Mendix model to yaml files. The output is a text representation of the model. It is a one-way conversion that aims to keep the semantics yet readable for humans and computers.
@@ -69,14 +79,15 @@ mxlint-cli export-model [flags]
6979

7080
The `lint` command supports configuration files loaded in this order:
7181

72-
1. Default config: `config.yaml` baked into the executable at compile time
82+
1. Default config: `default.yaml` baked into the executable at compile time
7383
2. System config:
7484
- Windows: `%USERPROFILE%/mxlint.yaml`
7585
- Unix/Linux/macOS: `~/.config/mxlint.yaml`
7686
- Optional override: `MXLINT_SYSTEM_CONFIG`
7787
3. `$PROJECT/mxlint.yaml` (current working directory)
88+
4. Optional explicit config via `--config <path>` (highest precedence, file must exist)
7889

79-
Project config has higher precedence than system config.
90+
Project config has higher precedence than system config, and `--config` has the highest precedence.
8091

8192
Example:
8293

@@ -130,6 +141,17 @@ mxlint-cli lint [flags]
130141

131142
---
132143

144+
### config
145+
146+
Show the active merged configuration and where config sources were found/used.
147+
148+
**Usage:**
149+
```bash
150+
mxlint-cli config
151+
```
152+
153+
---
154+
133155
### serve
134156

135157
Run a server that exports model and lints whenever the input MPR file changes. Works in standalone mode and via integration with the Mendix Studio Pro extension.

‎config.yaml‎

Lines changed: 0 additions & 11 deletions
This file was deleted.

‎default.yaml‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
rules:
2+
path: .mendix-cache/rules
3+
rulesets:
4+
- https://github.com/mxlint/mxlint-rules/releases/download/v3.3.0/rules-v3.3.0.zip
5+
lint:
6+
xunitReport: ""
7+
jsonFile: ""
8+
ignoreNoqa: false
9+
noCache: false
10+
skip: {}
11+
modelsource: modelsource
12+
projectDirectory: .
13+
export:
14+
mode: basic
15+
filter: ".*"
16+
raw: false
17+
appstore: false
18+
serve:
19+
port: 8082
20+
debounce: 500

‎lint/config.go‎

Lines changed: 145 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,12 @@ import (
1414
const configFileName = "mxlint.yaml"
1515

1616
type Config struct {
17-
Rules ConfigRulesSpec `yaml:"rules"`
18-
Lint ConfigLintSpec `yaml:"lint"`
19-
Export ConfigExportSpec `yaml:"export"`
17+
Rules ConfigRulesSpec `yaml:"rules"`
18+
Lint ConfigLintSpec `yaml:"lint"`
19+
Export ConfigExportSpec `yaml:"export"`
20+
Serve ConfigServeSpec `yaml:"serve"`
21+
Modelsource string `yaml:"modelsource"`
22+
ProjectDirectory string `yaml:"projectDirectory"`
2023
}
2124

2225
type ConfigRulesSpec struct {
@@ -25,14 +28,23 @@ type ConfigRulesSpec struct {
2528
}
2629

2730
type ConfigExportSpec struct {
28-
Output string `yaml:"output"`
29-
Input string `yaml:"input"`
30-
Mode string `yaml:"mode"`
31-
Filter string `yaml:"filter"`
31+
Mode string `yaml:"mode"`
32+
Filter string `yaml:"filter"`
33+
Raw *bool `yaml:"raw"`
34+
Appstore *bool `yaml:"appstore"`
3235
}
3336

3437
type ConfigLintSpec struct {
35-
Skip map[string][]ConfigSkipRule `yaml:"skip"`
38+
XunitReport string `yaml:"xunitReport"`
39+
JSONFile string `yaml:"jsonFile"`
40+
IgnoreNoqa *bool `yaml:"ignoreNoqa"`
41+
NoCache *bool `yaml:"noCache"`
42+
Skip map[string][]ConfigSkipRule `yaml:"skip"`
43+
}
44+
45+
type ConfigServeSpec struct {
46+
Port *int `yaml:"port"`
47+
Debounce *int `yaml:"debounce"`
3648
}
3749

3850
type ConfigSkipRule struct {
@@ -41,6 +53,20 @@ type ConfigSkipRule struct {
4153
Date string `yaml:"date"`
4254
}
4355

56+
type ConfigSourceStatus struct {
57+
Name string
58+
Path string
59+
Found bool
60+
Used bool
61+
}
62+
63+
type ConfigLoadReport struct {
64+
Default ConfigSourceStatus
65+
System ConfigSourceStatus
66+
Project ConfigSourceStatus
67+
Explicit ConfigSourceStatus
68+
}
69+
4470
var activeConfig = struct {
4571
mu sync.RWMutex
4672
config *Config
@@ -92,33 +118,73 @@ func getConfig() *Config {
92118
}
93119

94120
func LoadMergedConfig(projectDir string) (*Config, error) {
121+
cfg, _, err := LoadMergedConfigWithReport(projectDir)
122+
return cfg, err
123+
}
124+
125+
func LoadMergedConfigFromPath(projectDir string, explicitConfigPath string) (*Config, error) {
126+
cfg, _, err := LoadMergedConfigWithReportFromPath(projectDir, explicitConfigPath)
127+
return cfg, err
128+
}
129+
130+
func LoadMergedConfigWithReport(projectDir string) (*Config, ConfigLoadReport, error) {
131+
return LoadMergedConfigWithReportFromPath(projectDir, "")
132+
}
133+
134+
func LoadMergedConfigWithReportFromPath(projectDir string, explicitConfigPath string) (*Config, ConfigLoadReport, error) {
95135
cfg := &Config{}
136+
report := ConfigLoadReport{
137+
Default: ConfigSourceStatus{Name: "embedded-default", Path: "embedded:default.yaml"},
138+
System: ConfigSourceStatus{Name: "system", Path: ""},
139+
Project: ConfigSourceStatus{Name: "project", Path: filepath.Join(projectDir, configFileName)},
140+
Explicit: ConfigSourceStatus{Name: "explicit", Path: strings.TrimSpace(explicitConfigPath)},
141+
}
96142

97143
systemConfigPath, err := resolveSystemConfigPath()
98144
if err != nil {
99-
return nil, err
145+
return nil, report, err
100146
}
147+
report.System.Path = systemConfigPath
101148
projectConfigPath := filepath.Join(projectDir, configFileName)
149+
resolvedExplicitPath := strings.TrimSpace(explicitConfigPath)
150+
if resolvedExplicitPath != "" && !filepath.IsAbs(resolvedExplicitPath) {
151+
resolvedExplicitPath = filepath.Join(projectDir, resolvedExplicitPath)
152+
}
153+
report.Explicit.Path = resolvedExplicitPath
102154

103-
defaultCfg, err := loadConfigYAML(getDefaultConfigYAML(), "embedded default config")
155+
defaultCfg, defaultFound, err := loadConfigYAML(getDefaultConfigYAML(), "embedded default config")
104156
if err != nil {
105-
return nil, err
157+
return nil, report, err
106158
}
159+
report.Default.Found = defaultFound
160+
report.Default.Used = defaultCfg != nil
107161
mergeConfig(cfg, defaultCfg)
108162

109-
systemCfg, err := loadConfigFile(systemConfigPath)
163+
systemCfg, systemFound, err := loadConfigFile(systemConfigPath)
110164
if err != nil {
111-
return nil, err
165+
return nil, report, err
112166
}
167+
report.System.Found = systemFound
168+
report.System.Used = systemCfg != nil
113169
mergeConfig(cfg, systemCfg)
114170

115-
projectCfg, err := loadConfigFile(projectConfigPath)
171+
projectCfg, projectFound, err := loadConfigFile(projectConfigPath)
116172
if err != nil {
117-
return nil, err
173+
return nil, report, err
118174
}
175+
report.Project.Found = projectFound
176+
report.Project.Used = projectCfg != nil
119177
mergeConfig(cfg, projectCfg)
120178

121-
return cfg, nil
179+
explicitCfg, explicitFound, err := loadConfigFileRequired(resolvedExplicitPath)
180+
if err != nil {
181+
return nil, report, err
182+
}
183+
report.Explicit.Found = explicitFound
184+
report.Explicit.Used = explicitCfg != nil
185+
mergeConfig(cfg, explicitCfg)
186+
187+
return cfg, report, nil
122188
}
123189

124190
func resolveSystemConfigPath() (string, error) {
@@ -140,29 +206,51 @@ func resolveSystemConfigPath() (string, error) {
140206
return filepath.Join(homeDir, ".config", configFileName), nil
141207
}
142208

143-
func loadConfigFile(configPath string) (*Config, error) {
209+
func loadConfigFile(configPath string) (*Config, bool, error) {
210+
if configPath == "" {
211+
return nil, false, nil
212+
}
213+
content, err := os.ReadFile(configPath)
214+
if err != nil {
215+
if os.IsNotExist(err) {
216+
return nil, false, nil
217+
}
218+
return nil, false, fmt.Errorf("failed to read config %s: %w", configPath, err)
219+
}
220+
cfg, _, err := loadConfigYAML(content, configPath)
221+
if err != nil {
222+
return nil, true, err
223+
}
224+
return cfg, true, nil
225+
}
226+
227+
func loadConfigFileRequired(configPath string) (*Config, bool, error) {
144228
if configPath == "" {
145-
return nil, nil
229+
return nil, false, nil
146230
}
147231
content, err := os.ReadFile(configPath)
148232
if err != nil {
149233
if os.IsNotExist(err) {
150-
return nil, nil
234+
return nil, false, fmt.Errorf("failed to read config %s: file does not exist", configPath)
151235
}
152-
return nil, fmt.Errorf("failed to read config %s: %w", configPath, err)
236+
return nil, false, fmt.Errorf("failed to read config %s: %w", configPath, err)
153237
}
154-
return loadConfigYAML(content, configPath)
238+
cfg, _, err := loadConfigYAML(content, configPath)
239+
if err != nil {
240+
return nil, true, err
241+
}
242+
return cfg, true, nil
155243
}
156244

157-
func loadConfigYAML(content []byte, source string) (*Config, error) {
245+
func loadConfigYAML(content []byte, source string) (*Config, bool, error) {
158246
if len(content) == 0 {
159-
return nil, nil
247+
return nil, false, nil
160248
}
161249
cfg := &Config{}
162250
if err := yaml.Unmarshal(content, cfg); err != nil {
163-
return nil, fmt.Errorf("failed to parse config %s: %w", source, err)
251+
return nil, true, fmt.Errorf("failed to parse config %s: %w", source, err)
164252
}
165-
return cfg, nil
253+
return cfg, true, nil
166254
}
167255

168256
func mergeConfig(base *Config, overlay *Config) {
@@ -176,18 +264,44 @@ func mergeConfig(base *Config, overlay *Config) {
176264
base.Rules.Rulesets = append([]string{}, overlay.Rules.Rulesets...)
177265
}
178266

179-
if strings.TrimSpace(overlay.Export.Output) != "" {
180-
base.Export.Output = strings.TrimSpace(overlay.Export.Output)
181-
}
182-
if strings.TrimSpace(overlay.Export.Input) != "" {
183-
base.Export.Input = strings.TrimSpace(overlay.Export.Input)
184-
}
185267
if strings.TrimSpace(overlay.Export.Mode) != "" {
186268
base.Export.Mode = strings.TrimSpace(overlay.Export.Mode)
187269
}
188270
if overlay.Export.Filter != "" {
189271
base.Export.Filter = strings.TrimSpace(overlay.Export.Filter)
190272
}
273+
if overlay.Export.Raw != nil {
274+
base.Export.Raw = overlay.Export.Raw
275+
}
276+
if overlay.Export.Appstore != nil {
277+
base.Export.Appstore = overlay.Export.Appstore
278+
}
279+
280+
if strings.TrimSpace(overlay.Modelsource) != "" {
281+
base.Modelsource = strings.TrimSpace(overlay.Modelsource)
282+
}
283+
if strings.TrimSpace(overlay.ProjectDirectory) != "" {
284+
base.ProjectDirectory = strings.TrimSpace(overlay.ProjectDirectory)
285+
}
286+
if overlay.Lint.XunitReport != "" {
287+
base.Lint.XunitReport = strings.TrimSpace(overlay.Lint.XunitReport)
288+
}
289+
if overlay.Lint.JSONFile != "" {
290+
base.Lint.JSONFile = strings.TrimSpace(overlay.Lint.JSONFile)
291+
}
292+
if overlay.Lint.IgnoreNoqa != nil {
293+
base.Lint.IgnoreNoqa = overlay.Lint.IgnoreNoqa
294+
}
295+
if overlay.Lint.NoCache != nil {
296+
base.Lint.NoCache = overlay.Lint.NoCache
297+
}
298+
299+
if overlay.Serve.Port != nil {
300+
base.Serve.Port = overlay.Serve.Port
301+
}
302+
if overlay.Serve.Debounce != nil {
303+
base.Serve.Debounce = overlay.Serve.Debounce
304+
}
191305

192306
if len(overlay.Lint.Skip) == 0 {
193307
return

0 commit comments

Comments
 (0)