diff --git a/go.mod b/go.mod index 2ef809e9c..5906470e4 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,10 @@ module ballerina-lang-go go 1.24.4 + +require github.com/BurntSushi/toml v1.5.0 + +require ( + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 000000000..240bcfa0a --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= diff --git a/tomlparser/schema.go b/tomlparser/schema.go new file mode 100644 index 000000000..c74d34354 --- /dev/null +++ b/tomlparser/schema.go @@ -0,0 +1,93 @@ +// Copyright (c) 2025, WSO2 LLC. (http://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package tomlparser + +import ( + "encoding/json" + "fmt" + "io" + "io/fs" + + "github.com/santhosh-tekuri/jsonschema/v6" +) + +type Schema interface { + Validate(data any) error + FromPath(fsys fs.FS, path string) (Schema, error) + FromString(content string) (Schema, error) +} + +type schemaImpl struct { + compiled *jsonschema.Schema +} + +func NewSchemaFromPath(fsys fs.FS, path string) (Schema, error) { + content, err := readFile(fsys, path) + if err != nil { + return nil, fmt.Errorf("failed to read schema file %s: %w", path, err) + } + return NewSchemaFromString(content) +} + +func NewSchemaFromString(content string) (Schema, error) { + var schemaDoc any + if err := json.Unmarshal([]byte(content), &schemaDoc); err != nil { + return nil, fmt.Errorf("failed to parse schema JSON: %w", err) + } + + compiler := jsonschema.NewCompiler() + + if err := compiler.AddResource("schema.json", schemaDoc); err != nil { + return nil, fmt.Errorf("failed to add schema resource: %w", err) + } + + schema, err := compiler.Compile("schema.json") + if err != nil { + return nil, fmt.Errorf("failed to compile schema: %w", err) + } + + return &schemaImpl{ + compiled: schema, + }, nil +} + +func NewSchemaFromReader(reader io.Reader) (Schema, error) { + content, err := readFromReader(reader) + if err != nil { + return nil, fmt.Errorf("failed to read schema: %w", err) + } + return NewSchemaFromString(content) +} + +func NewSchemaFromFile(file fs.File) (Schema, error) { + return NewSchemaFromReader(file) +} + +func (s *schemaImpl) Validate(data any) error { + if err := s.compiled.Validate(data); err != nil { + return fmt.Errorf("schema validation failed: %w", err) + } + return nil +} + +func (s *schemaImpl) FromPath(fsys fs.FS, path string) (Schema, error) { + return NewSchemaFromPath(fsys, path) +} + +func (s *schemaImpl) FromString(content string) (Schema, error) { + return NewSchemaFromString(content) +} diff --git a/tomlparser/testdata/ballerina-package.toml b/tomlparser/testdata/ballerina-package.toml new file mode 100644 index 000000000..ace39bab1 --- /dev/null +++ b/tomlparser/testdata/ballerina-package.toml @@ -0,0 +1,9 @@ +[package] +org = "foo" +name = "winery" +version = "0.1.0" +license = ["MIT", "Apache-2.0"] +authors = ["jo@wso2.com", "pramodya@wso2.com"] +repository = "https://github.com/ballerinalang/ballerina" +keywords = ["ballerina", "security", "crypto"] +exported = ["winery", "service"] diff --git a/tomlparser/testdata/invalid-sample.toml b/tomlparser/testdata/invalid-sample.toml new file mode 100644 index 000000000..be451cf98 --- /dev/null +++ b/tomlparser/testdata/invalid-sample.toml @@ -0,0 +1,10 @@ +[package] +org = "foo" +name = "winery" +version = "0.1.0" +license = ["MIT", "Apache-2.0"] +authors = ["jo@wso2.com", "pramodya@wso2.com"] +repository = "https://github.com/ballerinalang/ballerina" +keywords = ["ballerina", "security", "crypto"] +exported = ["winery", "service"] +unexpected = "this field is not in schema" diff --git a/tomlparser/testdata/missing-required.toml b/tomlparser/testdata/missing-required.toml new file mode 100644 index 000000000..68e453f51 --- /dev/null +++ b/tomlparser/testdata/missing-required.toml @@ -0,0 +1,2 @@ +name = "test" +description = "missing version field" diff --git a/tomlparser/testdata/required-schema.json b/tomlparser/testdata/required-schema.json new file mode 100644 index 000000000..7dc586014 --- /dev/null +++ b/tomlparser/testdata/required-schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Config Schema", + "description": "Schema with required fields", + "type": "object", + "additionalProperties": false, + "required": ["name", "version"], + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "description": { + "type": "string" + } + } +} diff --git a/tomlparser/testdata/sample-schema.json b/tomlparser/testdata/sample-schema.json new file mode 100644 index 000000000..2797cfa64 --- /dev/null +++ b/tomlparser/testdata/sample-schema.json @@ -0,0 +1,51 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Ballerina Manifest Spec", + "description": "Schema for Ballerina Manifest", + "type": "object", + "additionalProperties": false, + "properties": { + "package": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "org": { + "type": "string" + }, + "version": { + "type": "string" + }, + "license": { + "type": "array", + "items": { + "type": "string" + } + }, + "authors": { + "type": "array", + "items": { + "type": "string" + } + }, + "repository": { + "type": "string" + }, + "keywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "exported": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } +} diff --git a/tomlparser/testdata/sample.toml b/tomlparser/testdata/sample.toml new file mode 100644 index 000000000..b56e07894 --- /dev/null +++ b/tomlparser/testdata/sample.toml @@ -0,0 +1,28 @@ +title = "Server Configuration" + +[owner] +name = "WSO2" + +[database] +server = "192.168.1.1" +ports = [ 8001, 8001, 8002 ] +connection_max = 5000 +enabled = true + +[servers] + [servers.alpha] + ip = "10.0.0.1" + dc = "eqdc10" + + [servers.beta] + ip = "10.0.0.2" + dc = "eqdc10" + +[[routes]] +name = "health" +path = "/health" + +[[routes]] +name = "metrics" +path = "/metrics" +method = "GET" diff --git a/tomlparser/toml.go b/tomlparser/toml.go new file mode 100644 index 000000000..38c656a20 --- /dev/null +++ b/tomlparser/toml.go @@ -0,0 +1,313 @@ +// Copyright (c) 2025, WSO2 LLC. (http://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package tomlparser + +// TODO: Currently toml parser gets a single diagnostic at a time. After migrating the parser, should use the same for toml as well. + +import ( + "errors" + "io" + "io/fs" + "strings" + + "ballerina-lang-go/tools/diagnostics" + + "github.com/BurntSushi/toml" +) + +type Toml struct { + rootNode map[string]any + metadata toml.MetaData + diagnostics []Diagnostic + content string +} + +type Diagnostic struct { + Message string + Severity diagnostics.DiagnosticSeverity + Location *Location +} + +type Location struct { + StartLine int + StartColumn int + EndLine int + EndColumn int +} + +func readFile(fsys fs.FS, path string) (string, error) { + content, err := fs.ReadFile(fsys, path) + if err != nil { + return "", err + } + return string(content), nil +} + +func readFromReader(reader io.Reader) (string, error) { + content, err := io.ReadAll(reader) + if err != nil { + return "", err + } + return string(content), nil +} + +func Read(fsys fs.FS, path string) (*Toml, error) { + content, err := readFile(fsys, path) + if err != nil { + return nil, err + } + return ReadString(content) +} + +func ReadWithSchema(fsys fs.FS, path string, schema Schema) (*Toml, error) { + content, err := readFile(fsys, path) + if err != nil { + return nil, err + } + return ReadStringWithSchema(content, schema) +} + +func ReadStream(reader io.Reader) (*Toml, error) { + content, err := readFromReader(reader) + if err != nil { + return nil, err + } + return ReadString(content) +} + +func ReadStreamWithSchema(reader io.Reader, schema Schema) (*Toml, error) { + content, err := readFromReader(reader) + if err != nil { + return nil, err + } + return ReadStringWithSchema(content, schema) +} + +func ReadString(content string) (*Toml, error) { + var data map[string]any + metadata, err := toml.Decode(content, &data) + + t := &Toml{ + rootNode: data, + metadata: metadata, + diagnostics: make([]Diagnostic, 0), + content: content, + } + + if err != nil { + t.diagnostics = append(t.diagnostics, parseErrorDiagnostic(err)) + } + + return t, err +} + +func ReadStringWithSchema(content string, schema Schema) (*Toml, error) { + t, err := ReadString(content) + if err != nil { + return t, err + } + + validator := NewValidator(schema) + validationErr := validator.Validate(t) + if validationErr != nil { + return t, validationErr + } + + return t, nil +} + +func (t *Toml) Validate(schema Schema) error { + validator := NewValidator(schema) + return validator.Validate(t) +} + +func (t *Toml) Get(dottedKey string) (any, bool) { + keys := splitDottedKey(dottedKey) + return t.getValueByPath(keys) +} + +func (t *Toml) GetString(dottedKey string) (string, bool) { + val, ok := t.Get(dottedKey) + if !ok { + return "", false + } + str, ok := val.(string) + return str, ok +} + +func (t *Toml) GetInt(dottedKey string) (int64, bool) { + val, ok := t.Get(dottedKey) + if !ok { + return 0, false + } + + switch v := val.(type) { + case int64: + return v, true + case int: + return int64(v), true + default: + return 0, false + } +} + +func (t *Toml) GetFloat(dottedKey string) (float64, bool) { + val, ok := t.Get(dottedKey) + if !ok { + return 0, false + } + f, ok := val.(float64) + return f, ok +} + +func (t *Toml) GetBool(dottedKey string) (bool, bool) { + val, ok := t.Get(dottedKey) + if !ok { + return false, false + } + b, ok := val.(bool) + return b, ok +} + +func (t *Toml) GetArray(dottedKey string) ([]any, bool) { + val, ok := t.Get(dottedKey) + if !ok { + return nil, false + } + arr, ok := val.([]any) + return arr, ok +} + +func (t *Toml) GetTable(dottedKey string) (*Toml, bool) { + val, ok := t.Get(dottedKey) + if !ok { + return nil, false + } + + table, ok := val.(map[string]any) + if !ok { + return nil, false + } + + return &Toml{ + rootNode: table, + metadata: t.metadata, + diagnostics: nil, + content: "", + }, true +} + +func (t *Toml) GetTables(dottedKey string) ([]*Toml, bool) { + val, ok := t.Get(dottedKey) + if !ok { + return nil, false + } + + arr, ok := val.([]any) + if !ok { + if tableArr, ok := val.([]map[string]any); ok { + result := make([]*Toml, len(tableArr)) + for i, table := range tableArr { + result[i] = &Toml{ + rootNode: table, + metadata: t.metadata, + diagnostics: nil, + content: "", + } + } + return result, true + } + return nil, false + } + + result := make([]*Toml, 0) + for _, item := range arr { + if table, ok := item.(map[string]any); ok { + result = append(result, &Toml{ + rootNode: table, + metadata: t.metadata, + diagnostics: nil, + content: "", + }) + } + } + + if len(result) == 0 { + return nil, false + } + + return result, true +} + +func (t *Toml) Diagnostics() []Diagnostic { + return t.diagnostics +} + +func (t *Toml) ToMap() map[string]any { + return t.rootNode +} + +func (t *Toml) To(target any) { + _, err := toml.Decode(t.content, target) + if err != nil { + t.diagnostics = append(t.diagnostics, parseErrorDiagnostic(err)) + } +} + +func splitDottedKey(dottedKey string) []string { + return strings.Split(dottedKey, ".") +} + +func (t *Toml) getValueByPath(keys []string) (any, bool) { + current := any(t.rootNode) + + for _, key := range keys { + key = strings.Trim(key, "\"") + + currentMap, ok := current.(map[string]any) + if !ok { + return nil, false + } + + val, exists := currentMap[key] + if !exists { + return nil, false + } + + current = val + } + + return current, true +} + +func parseErrorDiagnostic(err error) Diagnostic { + diagnostic := Diagnostic{ + Message: err.Error(), + Severity: diagnostics.Error, + } + var parseErr toml.ParseError + if errors.As(err, &parseErr) { + diagnostic.Message = parseErr.Message + diagnostic.Location = &Location{ + StartLine: parseErr.Position.Line, + StartColumn: parseErr.Position.Col, + EndLine: parseErr.Position.Line, + EndColumn: parseErr.Position.Col + parseErr.Position.Len, + } + } + return diagnostic +} diff --git a/tomlparser/toml_test.go b/tomlparser/toml_test.go new file mode 100644 index 000000000..4e5a80bd6 --- /dev/null +++ b/tomlparser/toml_test.go @@ -0,0 +1,448 @@ +// Copyright (c) 2025, WSO2 LLC. (http://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package tomlparser + +import ( + "os" + "strings" + "testing" +) + +const sampleToml = ` +title = "Server Configuration" + +[owner] +name = "WSO2" + +[database] +server = "192.168.1.1" +ports = [ 8001, 8001, 8002 ] +connection_max = 5000 +enabled = true + +[servers] + [servers.alpha] + ip = "10.0.0.1" + dc = "eqdc10" + + [servers.beta] + ip = "10.0.0.2" + dc = "eqdc10" + +[[routes]] +name = "health" +path = "/health" + +[[routes]] +name = "metrics" +path = "/metrics" +method = "GET" +` + +func TestReadString(t *testing.T) { + toml, err := ReadString(sampleToml) + if err != nil { + t.Fatalf("Failed to parse TOML: %v", err) + } + + if toml == nil { + t.Fatal("Expected non-nil TOML object") + } +} + +func TestGet(t *testing.T) { + toml, err := ReadString(sampleToml) + if err != nil { + t.Fatalf("Failed to parse TOML: %v", err) + } + + tests := []struct { + name string + key string + expected any + }{ + {"root string", "title", "Server Configuration"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := toml.Get(tt.key) + if !ok { + t.Fatalf("Get(%q) returned ok=false; want true", tt.key) + } + if got != tt.expected { + t.Errorf("Get(%q) = %v, want %v", tt.key, got, tt.expected) + } + }) + } +} + +func TestGetString(t *testing.T) { + toml, err := ReadString(sampleToml) + if err != nil { + t.Fatalf("Failed to parse TOML: %v", err) + } + + tests := []struct { + key string + expected string + }{ + {"owner.name", "WSO2"}, + {"servers.alpha.ip", "10.0.0.1"}, + } + + for _, tt := range tests { + t.Run(tt.key, func(t *testing.T) { + got, ok := toml.GetString(tt.key) + if !ok { + t.Fatalf("GetString(%q) returned ok=false; want true", tt.key) + } + if got != tt.expected { + t.Errorf("GetString(%q) = %q, want %q", tt.key, got, tt.expected) + } + }) + } +} + +func TestGetInt(t *testing.T) { + toml, err := ReadString(sampleToml) + if err != nil { + t.Fatalf("Failed to parse TOML: %v", err) + } + + tests := []struct { + key string + expected int64 + }{ + {"database.connection_max", 5000}, + } + + for _, tt := range tests { + t.Run(tt.key, func(t *testing.T) { + got, ok := toml.GetInt(tt.key) + if !ok { + t.Fatalf("GetInt(%q) returned ok=false; want true", tt.key) + } + if got != tt.expected { + t.Errorf("GetInt(%q) = %d, want %d", tt.key, got, tt.expected) + } + }) + } +} + +func TestGetBool(t *testing.T) { + toml, err := ReadString(sampleToml) + if err != nil { + t.Fatalf("Failed to parse TOML: %v", err) + } + + tests := []struct { + key string + expected bool + }{ + {"database.enabled", true}, + } + + for _, tt := range tests { + t.Run(tt.key, func(t *testing.T) { + got, ok := toml.GetBool(tt.key) + if !ok { + t.Fatalf("GetBool(%q) returned ok=false; want true", tt.key) + } + if got != tt.expected { + t.Errorf("GetBool(%q) = %v, want %v", tt.key, got, tt.expected) + } + }) + } +} + +func TestGetArray(t *testing.T) { + toml, err := ReadString(sampleToml) + if err != nil { + t.Fatalf("Failed to parse TOML: %v", err) + } + + t.Run("database.ports length", func(t *testing.T) { + ports, ok := toml.GetArray("database.ports") + if !ok { + t.Fatalf("GetArray(%q) returned ok=false; want true", "database.ports") + } + if len(ports) != 3 { + t.Errorf("len(GetArray(%q)) = %d, want %d", "database.ports", len(ports), 3) + } + }) +} + +func TestGetTable(t *testing.T) { + toml, err := ReadString(sampleToml) + if err != nil { + t.Fatalf("Failed to parse TOML: %v", err) + } + + t.Run("servers.alpha ip", func(t *testing.T) { + serverTable, ok := toml.GetTable("servers.alpha") + if !ok { + t.Fatalf("GetTable(%q) returned ok=false; want true", "servers.alpha") + } + ip, ok := serverTable.GetString("ip") + if !ok { + t.Fatalf("GetString(%q) on table returned ok=false; want true", "ip") + } + if ip != "10.0.0.1" { + t.Errorf("servers.alpha.ip = %q, want %q", ip, "10.0.0.1") + } + }) +} + +func TestGetTables(t *testing.T) { + toml, err := ReadString(sampleToml) + if err != nil { + t.Fatalf("Failed to parse TOML: %v", err) + } + + routes, ok := toml.GetTables("routes") + if !ok { + t.Fatalf("GetTables(%q) returned ok=false; want true", "routes") + } + if len(routes) != 2 { + t.Fatalf("len(GetTables(%q)) = %d, want %d", "routes", len(routes), 2) + } + + cases := []struct { + idx int + key string + wantStr string + wantInt int64 + isString bool + }{ + {0, "name", "health", 0, true}, + {1, "path", "/metrics", 0, true}, + } + + for _, c := range cases { + t.Run("route-idx-"+c.key, func(t *testing.T) { + if c.isString { + got, ok := routes[c.idx].GetString(c.key) + if !ok { + t.Fatalf("GetString(%q) on routes[%d] returned ok=false; want true", c.key, c.idx) + } + if got != c.wantStr { + t.Errorf("routes[%d].%s = %q, want %q", c.idx, c.key, got, c.wantStr) + } + return + } + got, ok := routes[c.idx].GetInt(c.key) + if !ok { + t.Fatalf("GetInt(%q) on routes[%d] returned ok=false; want true", c.key, c.idx) + } + if got != c.wantInt { + t.Errorf("routes[%d].%s = %d, want %d", c.idx, c.key, got, c.wantInt) + } + }) + } +} + +func TestToMap(t *testing.T) { + toml, err := ReadString(sampleToml) + if err != nil { + t.Fatalf("Failed to parse TOML: %v", err) + } + + m := toml.ToMap() + if m == nil { + t.Fatal("ToMap() returned nil; want non-nil map") + } + if got := m["title"]; got != "Server Configuration" { + t.Errorf("ToMap()[%q] = %v, want %v", "title", got, "Server Configuration") + } +} + +func TestTo(t *testing.T) { + toml, err := ReadString(sampleToml) + if err != nil { + t.Fatalf("Failed to parse TOML: %v", err) + } + + t.Run("successful unmarshal", func(t *testing.T) { + type Config struct { + Title string + Owner struct { + Name string + } + Database struct { + Server string + Ports []int + ConnectionMax int `toml:"connection_max"` + Enabled bool + } + } + + var config Config + toml.To(&config) + + if len(toml.Diagnostics()) > 0 { + t.Errorf("To() added unexpected diagnostics: %v", toml.Diagnostics()) + } + + if config.Title != "Server Configuration" { + t.Errorf("config.Title = %q, want %q", config.Title, "Server Configuration") + } + if config.Owner.Name != "WSO2" { + t.Errorf("config.Owner.Name = %q, want %q", config.Owner.Name, "WSO2") + } + if config.Database.ConnectionMax != 5000 { + t.Errorf("config.Database.ConnectionMax = %d, want %d", config.Database.ConnectionMax, 5000) + } + }) + + t.Run("unmarshal type mismatch", func(t *testing.T) { + freshToml, _ := ReadString(sampleToml) + + type BadConfig struct { + Title int + } + + var badConfig BadConfig + freshToml.To(&badConfig) + + diagnostics := freshToml.Diagnostics() + if len(diagnostics) == 0 { + t.Error("To() should add diagnostics for type mismatch, but got none") + } + }) +} + +func TestReadFile(t *testing.T) { + toml, err := Read(os.DirFS("."), "testdata/sample.toml") + if err != nil { + t.Fatalf("Failed to read TOML file: %v", err) + } + + title, ok := toml.GetString("title") + if !ok { + t.Fatalf("GetString(%q) returned ok=false; want true", "title") + } + if title != "Server Configuration" { + t.Errorf("GetString(%q) = %q, want %q", "title", title, "Server Configuration") + } +} + +func TestReadStream(t *testing.T) { + reader := strings.NewReader(sampleToml) + toml, err := ReadStream(reader) + if err != nil { + t.Fatalf("Failed to read TOML from stream: %v", err) + } + + title, ok := toml.GetString("title") + if !ok { + t.Fatalf("GetString(%q) returned ok=false; want true", "title") + } + if title != "Server Configuration" { + t.Errorf("GetString(%q) = %q, want %q", "title", title, "Server Configuration") + } +} + +func TestDiagnostics(t *testing.T) { + invalidToml := ` + invalid toml syntax here + missing = sign + ` + + toml, err := ReadString(invalidToml) + if err == nil { + t.Error("Expected error for invalid TOML") + } + + if toml == nil { + t.Fatal("Expected non-nil TOML object even with errors") + } + + diagnostics := toml.Diagnostics() + if len(diagnostics) == 0 { + t.Error("Expected diagnostics for invalid TOML") + } +} + +func TestNonExistentKey(t *testing.T) { + toml, err := ReadString(sampleToml) + if err != nil { + t.Fatalf("Failed to parse TOML: %v", err) + } + + _, ok := toml.Get("nonexistent.key") + if ok { + t.Error("Expected false for non-existent key") + } +} + +// Consolidated essential location/diagnostics tests +func TestErrorLocation_DuplicateKeys(t *testing.T) { + invalidToml := ` +title = "Test" +title = "Duplicate" +` + + tomlDoc, err := ReadString(invalidToml) + if err == nil { + t.Fatal("Expected error for duplicate keys, got nil") + } + + diags := tomlDoc.Diagnostics() + if len(diags) == 0 { + t.Fatal("Expected diagnostics, got none") + } + + diag := diags[0] + if diag.Location == nil { + t.Fatal("Expected location information, got nil") + } + + // The error should be on line 3 (duplicate title) + if diag.Location.StartLine != 3 { + t.Errorf("Expected StartLine 3, got %d", diag.Location.StartLine) + } + if diag.Location.StartColumn <= 0 { + t.Errorf("Expected positive StartColumn, got %d", diag.Location.StartColumn) + } +} + +func TestErrorLocation_SyntaxError(t *testing.T) { + invalidToml := ` +[section +key = "value" +` + + tomlDoc, err := ReadString(invalidToml) + if err == nil { + t.Fatal("Expected error for invalid syntax, got nil") + } + + diags := tomlDoc.Diagnostics() + if len(diags) == 0 { + t.Fatal("Expected diagnostics, got none") + } + + diag := diags[0] + if diag.Location == nil { + t.Fatal("Expected location information, got nil") + } + // The error should be on line 3 where "key" starts + if diag.Location.StartLine != 3 { + t.Errorf("Expected StartLine 3, got %d", diag.Location.StartLine) + } +} diff --git a/tomlparser/validator.go b/tomlparser/validator.go new file mode 100644 index 000000000..277d58257 --- /dev/null +++ b/tomlparser/validator.go @@ -0,0 +1,56 @@ +// Copyright (c) 2025, WSO2 LLC. (http://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package tomlparser + +import ( + "fmt" + + "ballerina-lang-go/tools/diagnostics" +) + +type Validator interface { + Validate(toml *Toml) error +} + +type validatorImpl struct { + schema Schema +} + +func NewValidator(schema Schema) Validator { + return &validatorImpl{ + schema: schema, + } +} + +func (v *validatorImpl) Validate(toml *Toml) error { + if toml == nil { + return fmt.Errorf("toml document is nil") + } + + data := toml.ToMap() + + if err := v.schema.Validate(data); err != nil { + diagnostic := Diagnostic{ + Message: err.Error(), + Severity: diagnostics.Error, + } + toml.diagnostics = append(toml.diagnostics, diagnostic) + return err + } + + return nil +} diff --git a/tomlparser/validator_test.go b/tomlparser/validator_test.go new file mode 100644 index 000000000..c7b2fcef9 --- /dev/null +++ b/tomlparser/validator_test.go @@ -0,0 +1,415 @@ +// Copyright (c) 2025, WSO2 LLC. (http://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package tomlparser + +import ( + "os" + "strings" + "testing" + + "ballerina-lang-go/tools/diagnostics" +) + +var fsys = os.DirFS(".") + +func TestSchemaFromPath(t *testing.T) { + schema, err := NewSchemaFromPath(fsys, "testdata/sample-schema.json") + if err != nil { + t.Fatalf("Failed to load schema from path: %v", err) + } + + if schema == nil { + t.Fatal("Schema should not be nil") + } +} + +func TestSchemaFromString(t *testing.T) { + schemaJSON := `{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "name": {"type": "string"} + } + }` + + schema, err := NewSchemaFromString(schemaJSON) + if err != nil { + t.Fatalf("Failed to create schema from string: %v", err) + } + + if schema == nil { + t.Fatal("Schema should not be nil") + } +} + +func TestSchemaFromFile(t *testing.T) { + file, err := os.Open("testdata/sample-schema.json") + if err != nil { + t.Fatalf("Failed to open schema file: %v", err) + } + defer file.Close() + + schema, err := NewSchemaFromFile(file) + if err != nil { + t.Fatalf("Failed to create schema from file: %v", err) + } + + if schema == nil { + t.Fatal("Schema should not be nil") + } +} + +func TestValidatorWithValidToml(t *testing.T) { + schema, err := NewSchemaFromPath(fsys, "testdata/sample-schema.json") + if err != nil { + t.Fatalf("Failed to load schema: %v", err) + } + + tomlDoc, err := Read(fsys, "testdata/ballerina-package.toml") + if err != nil { + t.Fatalf("Failed to read TOML: %v", err) + } + + validator := NewValidator(schema) + err = validator.Validate(tomlDoc) + if err != nil { + t.Errorf("Validation should pass for valid TOML: %v", err) + } + + if len(tomlDoc.Diagnostics()) > 0 { + t.Errorf("Expected no diagnostics, but got %d", len(tomlDoc.Diagnostics())) + } +} + +func TestValidatorWithInvalidToml(t *testing.T) { + schema, err := NewSchemaFromPath(fsys, "testdata/sample-schema.json") + if err != nil { + t.Fatalf("Failed to load schema: %v", err) + } + + tomlDoc, err := Read(fsys, "testdata/invalid-sample.toml") + if err != nil { + t.Fatalf("Failed to read TOML: %v", err) + } + + validator := NewValidator(schema) + err = validator.Validate(tomlDoc) + if err == nil { + t.Error("Validation should fail for invalid TOML") + } + + if len(tomlDoc.Diagnostics()) == 0 { + t.Error("Expected diagnostics for validation errors") + } + + diagnostic := tomlDoc.Diagnostics()[0] + if diagnostic.Severity != diagnostics.Error { + t.Errorf("Expected ERROR severity, got %s", diagnostic.Severity) + } + + if !strings.Contains(diagnostic.Message, "additionalProperties") && + !strings.Contains(diagnostic.Message, "unexpected") { + t.Logf("Diagnostic message: %s", diagnostic.Message) + } +} + +func TestReadWithSchema(t *testing.T) { + schema, err := NewSchemaFromPath(fsys, "testdata/sample-schema.json") + if err != nil { + t.Fatalf("Failed to load schema: %v", err) + } + + tomlDoc, err := ReadWithSchema(fsys, "testdata/ballerina-package.toml", schema) + if err != nil { + t.Errorf("ReadWithSchema should succeed for valid TOML: %v", err) + } + + if tomlDoc == nil { + t.Fatal("TOML document should not be nil") + } + + org, ok := tomlDoc.GetString("package.org") + if !ok || org != "foo" { + t.Errorf("Expected org to be 'foo', got %s", org) + } +} + +func TestReadWithSchemaInvalid(t *testing.T) { + schema, err := NewSchemaFromPath(fsys, "testdata/sample-schema.json") + if err != nil { + t.Fatalf("Failed to load schema: %v", err) + } + + tomlDoc, err := ReadWithSchema(fsys, "testdata/invalid-sample.toml", schema) + if err == nil { + t.Error("ReadWithSchema should fail for invalid TOML") + } + + if tomlDoc == nil { + t.Fatal("TOML document should not be nil even on validation error") + } + + if len(tomlDoc.Diagnostics()) == 0 { + t.Error("Expected diagnostics for validation errors") + } +} + +func TestReadStringWithSchema(t *testing.T) { + schemaJSON := `{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "properties": { + "name": {"type": "string"}, + "version": {"type": "string"} + } + }` + + schema, err := NewSchemaFromString(schemaJSON) + if err != nil { + t.Fatalf("Failed to create schema: %v", err) + } + + tomlContent := ` +name = "test" +version = "1.0.0" +` + + tomlDoc, err := ReadStringWithSchema(tomlContent, schema) + if err != nil { + t.Errorf("ReadStringWithSchema should succeed: %v", err) + } + + if tomlDoc == nil { + t.Fatal("TOML document should not be nil") + } +} + +func TestReadStringWithSchemaInvalid(t *testing.T) { + schemaJSON := `{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "properties": { + "name": {"type": "string"} + } + }` + + schema, err := NewSchemaFromString(schemaJSON) + if err != nil { + t.Fatalf("Failed to create schema: %v", err) + } + + tomlContent := ` +name = "test" +unexpected = "field" +` + + tomlDoc, err := ReadStringWithSchema(tomlContent, schema) + if err == nil { + t.Error("ReadStringWithSchema should fail for invalid TOML") + } + + if len(tomlDoc.Diagnostics()) == 0 { + t.Error("Expected diagnostics for validation errors") + } +} + +func TestReadStreamWithSchema(t *testing.T) { + schema, err := NewSchemaFromPath(fsys, "testdata/sample-schema.json") + if err != nil { + t.Fatalf("Failed to load schema: %v", err) + } + + file, err := os.Open("testdata/ballerina-package.toml") + if err != nil { + t.Fatalf("Failed to open TOML file: %v", err) + } + defer file.Close() + + tomlDoc, err := ReadStreamWithSchema(file, schema) + if err != nil { + t.Errorf("ReadStreamWithSchema should succeed: %v", err) + } + + if tomlDoc == nil { + t.Fatal("TOML document should not be nil") + } +} + +func TestValidateMethod(t *testing.T) { + tomlDoc, err := Read(fsys, "testdata/ballerina-package.toml") + if err != nil { + t.Fatalf("Failed to read TOML: %v", err) + } + + schema, err := NewSchemaFromPath(fsys, "testdata/sample-schema.json") + if err != nil { + t.Fatalf("Failed to load schema: %v", err) + } + + err = tomlDoc.Validate(schema) + if err != nil { + t.Errorf("Validate should succeed: %v", err) + } +} + +func TestValidateMethodWithInvalidData(t *testing.T) { + tomlDoc, err := Read(fsys, "testdata/invalid-sample.toml") + if err != nil { + t.Fatalf("Failed to read TOML: %v", err) + } + + schema, err := NewSchemaFromPath(fsys, "testdata/sample-schema.json") + if err != nil { + t.Fatalf("Failed to load schema: %v", err) + } + + err = tomlDoc.Validate(schema) + if err == nil { + t.Error("Validate should fail for invalid TOML") + } + + if len(tomlDoc.Diagnostics()) == 0 { + t.Error("Expected diagnostics for validation errors") + } +} + +func TestSchemaWithRequiredFields(t *testing.T) { + schema, err := NewSchemaFromPath(fsys, "testdata/required-schema.json") + if err != nil { + t.Fatalf("Failed to load schema: %v", err) + } + + tomlDoc, err := Read(fsys, "testdata/missing-required.toml") + if err != nil { + t.Fatalf("Failed to read TOML: %v", err) + } + + err = tomlDoc.Validate(schema) + if err == nil { + t.Error("Validation should fail when required fields are missing") + } + + if !strings.Contains(err.Error(), "required") && + !strings.Contains(err.Error(), "missing") && + !strings.Contains(err.Error(), "version") { + t.Logf("Error message: %s", err.Error()) + } +} + +func TestSchemaValidationWithTypeError(t *testing.T) { + schemaJSON := `{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "count": {"type": "number"} + } + }` + + schema, err := NewSchemaFromString(schemaJSON) + if err != nil { + t.Fatalf("Failed to create schema: %v", err) + } + + tomlContent := `count = "not a number"` + + _, err = ReadStringWithSchema(tomlContent, schema) + if err == nil { + t.Error("Validation should fail for type mismatch") + } +} + +func TestSchemaValidationWithArrays(t *testing.T) { + schemaJSON := `{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": {"type": "string"} + } + } + }` + + schema, err := NewSchemaFromString(schemaJSON) + if err != nil { + t.Fatalf("Failed to create schema: %v", err) + } + + tomlContent := `tags = ["tag1", "tag2", "tag3"]` + + tomlDoc, err := ReadStringWithSchema(tomlContent, schema) + if err != nil { + t.Errorf("Validation should succeed for valid array: %v", err) + } + + tags, ok := tomlDoc.GetArray("tags") + if !ok || len(tags) != 3 { + t.Errorf("Expected 3 tags, got %d", len(tags)) + } +} + +func TestSchemaValidationWithNestedObjects(t *testing.T) { + schema, err := NewSchemaFromPath(fsys, "testdata/sample-schema.json") + if err != nil { + t.Fatalf("Failed to load schema: %v", err) + } + + tomlContent := ` +[package] +org = "myorg" +name = "mypackage" +version = "1.0.0" +` + + tomlDoc, err := ReadStringWithSchema(tomlContent, schema) + if err != nil { + t.Errorf("Validation should succeed for nested object: %v", err) + } + + name, ok := tomlDoc.GetString("package.name") + if !ok || name != "mypackage" { + t.Errorf("Expected package.name to be 'mypackage', got %s", name) + } +} + +func TestValidatorWithNilToml(t *testing.T) { + schema, err := NewSchemaFromPath(fsys, "testdata/sample-schema.json") + if err != nil { + t.Fatalf("Failed to load schema: %v", err) + } + + validator := NewValidator(schema) + err = validator.Validate(nil) + if err == nil { + t.Error("Validator should return error for nil TOML") + } + + if !strings.Contains(err.Error(), "nil") { + t.Errorf("Error should mention nil, got: %s", err.Error()) + } +} + +func TestSchemaInvalidJSON(t *testing.T) { + invalidJSON := `{"invalid json` + + _, err := NewSchemaFromString(invalidJSON) + if err == nil { + t.Error("Should fail for invalid JSON schema") + } +}