diff --git a/etc/schemas/arduino-library-properties-definitions-schema.json b/etc/schemas/arduino-library-properties-definitions-schema.json index c9d8b938..90e4d294 100644 --- a/etc/schemas/arduino-library-properties-definitions-schema.json +++ b/etc/schemas/arduino-library-properties-definitions-schema.json @@ -21,13 +21,7 @@ "allowedCharacters": { "allOf": [ { - "$ref": "#/definitions/propertiesObjects/depends/base/definitions/patternObject" - }, - { - "not": { - "$comment": "The depends property is a comma separated list of names, so a valid name pattern is a valid depends pattern with the comma excluded", - "pattern": "^.*,.*$" - } + "pattern": "^(([a-zA-Z][a-zA-Z0-9 _.\\-]*)|([0-9][a-zA-Z0-9 _.\\-]*[a-zA-Z][a-zA-Z0-9 _.\\-]*))*$" } ] } @@ -494,18 +488,14 @@ }, "depends": { "base": { - "definitions": { - "patternObject": { - "pattern": "^(([a-zA-Z][a-zA-Z0-9 _\\.\\-,]*)|([0-9][a-zA-Z0-9 _\\.\\-]*[a-zA-Z][a-zA-Z0-9 _\\.\\-,]*))*$" - } - }, "object": { "allOf": [ { "type": "string" }, { - "$ref": "#/definitions/propertiesObjects/depends/base/definitions/patternObject" + "$comment": "Based on #/definitions/propertiesObjects/name/base/definitions/patternObjects/allowedCharacters and general-definitions-schema.json#/definitions/patternObjects/relaxedSemver", + "pattern": "^((((([a-zA-Z][a-zA-Z0-9 _.\\-]*)|([0-9][a-zA-Z0-9 _.\\-]*[a-zA-Z][a-zA-Z0-9 _.\\-]*))+( \\( *(<|<=|=|>=|>)(0|[1-9]\\d*)(\\.(0|[1-9]\\d*))?(\\.(0|[1-9]\\d*))?(-((0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(\\.(0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(\\+([0-9a-zA-Z-]+(\\.[0-9a-zA-Z-]+)*))? *\\) *)?), *)*((([a-zA-Z][a-zA-Z0-9 _.\\-]*)|([0-9][a-zA-Z0-9 _.\\-]*[a-zA-Z][a-zA-Z0-9 _.\\-]*))+( \\( *(<|<=|=|>=|>)(0|[1-9]\\d*)(\\.(0|[1-9]\\d*))?(\\.(0|[1-9]\\d*))?(-((0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(\\.(0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(\\+([0-9a-zA-Z-]+(\\.[0-9a-zA-Z-]+)*))? *\\) *)?))?$" } ] } diff --git a/internal/project/library/libraryproperties/librarypropertiesschemas_test.go b/internal/project/library/libraryproperties/librarypropertiesschemas_test.go index 9bb064e4..f17270ca 100644 --- a/internal/project/library/libraryproperties/librarypropertiesschemas_test.go +++ b/internal/project/library/libraryproperties/librarypropertiesschemas_test.go @@ -330,6 +330,18 @@ func TestPropertiesUrlFormat(t *testing.T) { func TestPropertiesDependsPattern(t *testing.T) { testTables := []propertyValueTestTable{ + {"Valid name", "foo", compliancelevel.Permissive, assert.False}, + {"Valid name", "foo", compliancelevel.Specification, assert.False}, + {"Valid name", "foo", compliancelevel.Strict, assert.False}, + + {"Valid names", "foo,bar", compliancelevel.Permissive, assert.False}, + {"Valid names", "foo,bar", compliancelevel.Specification, assert.False}, + {"Valid names", "foo,bar", compliancelevel.Strict, assert.False}, + + {"Trailing comma", "foo,", compliancelevel.Permissive, assert.True}, + {"Trailing comma", "foo,", compliancelevel.Specification, assert.True}, + {"Trailing comma", "foo,", compliancelevel.Strict, assert.True}, + {"Invalid characters", "-foo", compliancelevel.Permissive, assert.True}, {"Invalid characters", "-foo", compliancelevel.Specification, assert.True}, {"Invalid characters", "-foo", compliancelevel.Strict, assert.True}, @@ -337,6 +349,41 @@ func TestPropertiesDependsPattern(t *testing.T) { {"Empty", "", compliancelevel.Permissive, assert.False}, {"Empty", "", compliancelevel.Specification, assert.False}, {"Empty", "", compliancelevel.Strict, assert.False}, + + {"=version", "foo (>=1.2.3)", compliancelevel.Permissive, assert.False}, + {">=version", "foo (>=1.2.3)", compliancelevel.Specification, assert.False}, + {">=version", "foo (>=1.2.3)", compliancelevel.Strict, assert.False}, + {">version", "foo (>1.2.3)", compliancelevel.Permissive, assert.False}, + {">version", "foo (>1.2.3)", compliancelevel.Specification, assert.False}, + {">version", "foo (>1.2.3)", compliancelevel.Strict, assert.False}, + + {"Relaxed version", "foo (=1.2)", compliancelevel.Permissive, assert.False}, + {"Relaxed version", "foo (=1.2)", compliancelevel.Specification, assert.False}, + {"Relaxed version", "foo (=1.2)", compliancelevel.Strict, assert.False}, + {"Pre-release version", "foo (=1.2.3-rc1)", compliancelevel.Permissive, assert.False}, + {"Pre-release version", "foo (=1.2.3-rc1)", compliancelevel.Specification, assert.False}, + {"Pre-release version", "foo (=1.2.3-rc1)", compliancelevel.Strict, assert.False}, + + {"Invalid version", "foo (bar)", compliancelevel.Permissive, assert.True}, + {"Invalid version", "foo (bar)", compliancelevel.Specification, assert.True}, + {"Invalid version", "foo (bar)", compliancelevel.Strict, assert.True}, + + {"Version w/o space", "foo(>1.2.3)", compliancelevel.Permissive, assert.True}, + {"Version w/o space", "foo(>1.2.3)", compliancelevel.Specification, assert.True}, + {"Version w/o space", "foo(>1.2.3)", compliancelevel.Strict, assert.True}, + + {"Names w/ version", "foo (<=1.2.3),bar", compliancelevel.Permissive, assert.False}, + {"Names w/ version", "foo (<=1.2.3),bar", compliancelevel.Specification, assert.False}, + {"Names w/ version", "foo (<=1.2.3),bar", compliancelevel.Strict, assert.False}, } checkPropertyPatternMismatch("depends", testTables, t) diff --git a/internal/project/projectdata/library.go b/internal/project/projectdata/library.go index 211ff3db..02346a8a 100644 --- a/internal/project/projectdata/library.go +++ b/internal/project/projectdata/library.go @@ -16,12 +16,12 @@ package projectdata import ( - "encoding/json" - "io/ioutil" + "io" "net/http" "os" "github.com/arduino/arduino-cli/arduino/libraries" + "github.com/arduino/arduino-cli/arduino/libraries/librariesmanager" "github.com/arduino/arduino-lint/internal/configuration" "github.com/arduino/arduino-lint/internal/configuration/rulemode" "github.com/arduino/arduino-lint/internal/project" @@ -29,6 +29,7 @@ import ( "github.com/arduino/arduino-lint/internal/result/feedback" "github.com/arduino/arduino-lint/internal/rule/schema" "github.com/arduino/arduino-lint/internal/rule/schema/compliancelevel" + "github.com/arduino/go-paths-helper" "github.com/arduino/go-properties-orderedmap" "github.com/client9/misspell" "github.com/sirupsen/logrus" @@ -60,23 +61,34 @@ func InitializeForLibrary(project project.Type) { // Download the Library Manager index if needed. if !configuration.RuleModes(project.SuperprojectType)[rulemode.LibraryManagerIndexing] && libraryManagerIndex == nil { - url := "http://downloads.arduino.cc/libraries/library_index.json" - httpResponse, err := http.Get(url) + // Set up the temporary folder for the index + libraryIndexFolderPath, err := paths.TempDir().MkTempDir("arduino-lint-library-index-folder") + defer libraryIndexFolderPath.RemoveAll() if err != nil { - feedback.Errorf("Unable to download Library Manager index from %s: %s", err, url) + panic(err) + } + libraryIndexPath := libraryIndexFolderPath.Join("library_index.json") + + // Download the index data + httpResponse, err := http.Get(librariesmanager.LibraryIndexURL.String()) + if err != nil { + feedback.Errorf("Unable to download Library Manager index from %s: %s", err, librariesmanager.LibraryIndexURL) os.Exit(1) } defer httpResponse.Body.Close() - bytes, err := ioutil.ReadAll(httpResponse.Body) + // Write the index data to file + libraryIndexFile, err := libraryIndexPath.Create() + defer libraryIndexFile.Close() if err != nil { panic(err) } - - err = json.Unmarshal(bytes, &libraryManagerIndex) - if err != nil { + if _, err := io.Copy(libraryIndexFile, httpResponse.Body); err != nil { panic(err) } + + libraryManagerIndex = librariesmanager.NewLibraryManager(libraryIndexFolderPath, nil) + libraryManagerIndex.LoadIndex() } if misspelledWordsReplacer == nil { // The replacer only needs to be compiled once per run. @@ -120,10 +132,10 @@ func SourceHeaders() []string { return sourceHeaders } -var libraryManagerIndex map[string]interface{} +var libraryManagerIndex *librariesmanager.LibrariesManager // LibraryManagerIndex returns the Library Manager index data. -func LibraryManagerIndex() map[string]interface{} { +func LibraryManagerIndex() *librariesmanager.LibrariesManager { return libraryManagerIndex } diff --git a/internal/rule/ruleconfiguration/ruleconfiguration.go b/internal/rule/ruleconfiguration/ruleconfiguration.go index 414da657..e0fc1c3d 100644 --- a/internal/rule/ruleconfiguration/ruleconfiguration.go +++ b/internal/rule/ruleconfiguration/ruleconfiguration.go @@ -1007,15 +1007,15 @@ var configurations = []Type{ Category: "library.properties", Subcategory: "depends field", ID: "LP047", - Brief: "prohibited character in depends", + Brief: "invalid depends format", Description: "", - MessageTemplate: "Prohibited character(s) in library.properties depends field {{.}}. See: https://arduino.github.io/arduino-cli/latest/library-specification/#libraryproperties-file-format", + MessageTemplate: "Invalid format of library.properties depends field {{.}}. See: https://arduino.github.io/arduino-cli/latest/library-specification/#libraryproperties-file-format", DisableModes: nil, EnableModes: []rulemode.Type{rulemode.Default}, InfoModes: nil, WarningModes: nil, ErrorModes: []rulemode.Type{rulemode.Default}, - RuleFunction: rulefunction.LibraryPropertiesDependsFieldDisallowedCharacters, + RuleFunction: rulefunction.LibraryPropertiesDependsFieldInvalidFormat, }, { ProjectType: projecttype.Library, diff --git a/internal/rule/rulefunction/library.go b/internal/rule/rulefunction/library.go index db72dd8d..0417301f 100644 --- a/internal/rule/rulefunction/library.go +++ b/internal/rule/rulefunction/library.go @@ -22,9 +22,11 @@ import ( "net/http" "os" "path/filepath" + "regexp" "strings" "github.com/arduino/arduino-cli/arduino/libraries" + "github.com/arduino/arduino-cli/arduino/libraries/librariesindex" "github.com/arduino/arduino-cli/arduino/utils" "github.com/arduino/arduino-lint/internal/project/library" "github.com/arduino/arduino-lint/internal/project/projectdata" @@ -1158,8 +1160,8 @@ func LibraryPropertiesArchitecturesFieldValueCase() (result ruleresult.Type, out return ruleresult.Pass, "" } -// LibraryPropertiesDependsFieldDisallowedCharacters checks for disallowed characters in the library.properties "depends" field. -func LibraryPropertiesDependsFieldDisallowedCharacters() (result ruleresult.Type, output string) { +// LibraryPropertiesDependsFieldInvalidFormat checks for the library.properties "depends" field having an invalid format. +func LibraryPropertiesDependsFieldInvalidFormat() (result ruleresult.Type, output string) { if projectdata.LibraryPropertiesLoadError() != nil { return ruleresult.NotRun, "Couldn't load library.properties" } @@ -1187,21 +1189,55 @@ func LibraryPropertiesDependsFieldNotInIndex() (result ruleresult.Type, output s return ruleresult.Skip, "Field not present" } - dependencies := commaSeparatedToList(depends) + dependsList := commaSeparatedToList(depends) - dependenciesNotInIndex := []string{} - for _, dependency := range dependencies { - if dependency == "" { + var dependencyRegexp = regexp.MustCompile("^([^()]+?) *(?:\\((.+)\\))?$") + dependsNotInIndex := []string{} + for _, depend := range dependsList { + // Process raw depend string into a dependency object + if depend == "" { + // This is the responsibility of LibraryPropertiesDependsFieldInvalidFormat() continue } - logrus.Tracef("Checking if dependency %s is in index.", dependency) - if !nameInLibraryManagerIndex(dependency) { - dependenciesNotInIndex = append(dependenciesNotInIndex, dependency) + dependencyData := dependencyRegexp.FindAllStringSubmatch(depend, -1) + if dependencyData == nil { + // This is the responsibility of LibraryPropertiesDependsFieldInvalidFormat() + continue + } + dependencyConstraint, err := semver.ParseConstraint(dependencyData[0][2]) + if err != nil { + // This is the responsibility of LibraryPropertiesDependsFieldInvalidFormat() + continue + } + var dependency semver.Dependency = &librariesindex.Dependency{ + Name: dependencyData[0][1], + VersionConstraint: dependencyConstraint, + } + + logrus.Tracef("Checking if dependency %s is in index.", depend) + // Get all releases of the dependency + library := projectdata.LibraryManagerIndex().Index.FindIndexedLibrary(&libraries.Library{Name: dependency.GetName()}) + if library == nil { + logrus.Tracef("Dependency is not in the index.") + dependsNotInIndex = append(dependsNotInIndex, depend) + continue + } + // Convert the dependency's libraries.Library object to a semver.Releases object + var releases semver.Releases + for _, release := range library.Releases { + releases = append(releases, release) + } + // Filter the dependency's releases according to the dependency's constraint + dependencyReleases := releases.FilterBy(dependency) + if len(dependencyReleases) == 0 { + logrus.Tracef("No releases match dependency's constraint.") + dependsNotInIndex = append(dependsNotInIndex, depend) + continue } } - if len(dependenciesNotInIndex) > 0 { - return ruleresult.Fail, strings.Join(dependenciesNotInIndex, ", ") + if len(dependsNotInIndex) > 0 { + return ruleresult.Fail, strings.Join(dependsNotInIndex, ", ") } return ruleresult.Pass, "" @@ -1461,15 +1497,8 @@ func IncorrectExamplesFolderNameCase() (result ruleresult.Type, output string) { // nameInLibraryManagerIndex returns whether there is a library in Library Manager index using the given name. func nameInLibraryManagerIndex(name string) bool { - libraries := projectdata.LibraryManagerIndex()["libraries"].([]interface{}) - for _, libraryInterface := range libraries { - library := libraryInterface.(map[string]interface{}) - if library["name"].(string) == name { - return true - } - } - - return false + library := projectdata.LibraryManagerIndex().Index.FindIndexedLibrary(&libraries.Library{Name: name}) + return library != nil } // spellCheckLibraryPropertiesFieldValue returns the value of the provided library.properties field with commonly misspelled words corrected. diff --git a/internal/rule/rulefunction/library_test.go b/internal/rule/rulefunction/library_test.go index fb2f2a3d..b1f30462 100644 --- a/internal/rule/rulefunction/library_test.go +++ b/internal/rule/rulefunction/library_test.go @@ -794,22 +794,25 @@ func TestLibraryPropertiesArchitecturesFieldValueCase(t *testing.T) { checkLibraryRuleFunction(LibraryPropertiesArchitecturesFieldValueCase, testTables, t) } -func TestLibraryPropertiesDependsFieldDisallowedCharacters(t *testing.T) { +func TestLibraryPropertiesDependsFieldInvalidFormat(t *testing.T) { testTables := []libraryRuleFunctionTestTable{ {"Invalid", "InvalidLibraryProperties", ruleresult.NotRun, ""}, {"Legacy", "Legacy", ruleresult.Skip, ""}, {"Depends field has disallowed characters", "DependsHasBadChars", ruleresult.Fail, ""}, - {"Valid", "DependsIndexed", ruleresult.Pass, ""}, + {"Valid", "DependsValid", ruleresult.Pass, ""}, } - checkLibraryRuleFunction(LibraryPropertiesDependsFieldDisallowedCharacters, testTables, t) + checkLibraryRuleFunction(LibraryPropertiesDependsFieldInvalidFormat, testTables, t) } func TestLibraryPropertiesDependsFieldNotInIndex(t *testing.T) { testTables := []libraryRuleFunctionTestTable{ {"Unable to load", "InvalidLibraryProperties", ruleresult.NotRun, ""}, + {"Legacy", "Legacy", ruleresult.Skip, ""}, + {"No depends field", "MissingFields", ruleresult.Skip, ""}, {"Dependency not in index", "DependsNotIndexed", ruleresult.Fail, "^NotIndexed$"}, - {"Dependency in index", "DependsIndexed", ruleresult.Pass, ""}, + {"Dependency constraint not in index", "DependsConstraintNotIndexed", ruleresult.Fail, "^Servo \\(=0\\.0\\.1\\)$"}, + {"Dependencies in index", "DependsIndexed", ruleresult.Pass, ""}, {"Depends field empty", "DependsEmpty", ruleresult.Pass, ""}, {"No depends", "NoDepends", ruleresult.Skip, ""}, } diff --git a/internal/rule/rulefunction/testdata/libraries/DependsConstraintNotIndexed/library.properties b/internal/rule/rulefunction/testdata/libraries/DependsConstraintNotIndexed/library.properties new file mode 100644 index 00000000..3b90c6f5 --- /dev/null +++ b/internal/rule/rulefunction/testdata/libraries/DependsConstraintNotIndexed/library.properties @@ -0,0 +1,11 @@ +name=DependsConstraintNotIndexed +version=1.0.0 +author=Cristian Maglie , Pippo Pluto +maintainer=Cristian Maglie +sentence=A library that makes coding a web server a breeze. +paragraph=Supports HTTP1.1 and you can do GET and POST. +category=Communication +url=http://example.com/ +architectures=avr +depends=Servo (=0.0.1) +includes=DependsConstraintNotIndexed.h diff --git a/internal/rule/rulefunction/testdata/libraries/DependsConstraintNotIndexed/src/DependsConstraintNotIndexed.h b/internal/rule/rulefunction/testdata/libraries/DependsConstraintNotIndexed/src/DependsConstraintNotIndexed.h new file mode 100644 index 00000000..e69de29b diff --git a/internal/rule/rulefunction/testdata/libraries/DependsIndexed/library.properties b/internal/rule/rulefunction/testdata/libraries/DependsIndexed/library.properties index 35691134..06d2811f 100644 --- a/internal/rule/rulefunction/testdata/libraries/DependsIndexed/library.properties +++ b/internal/rule/rulefunction/testdata/libraries/DependsIndexed/library.properties @@ -7,4 +7,4 @@ paragraph=Supports HTTP1.1 and you can do GET and POST. category=Communication url=http://example.com/ architectures=avr -depends=Servo, , Adafruit NeoPixel +depends=,(foo),foo (bar),Adafruit NeoPixel,Servo (<1.1.4),Stepper (<=1.1.3),Mouse (=1.0.0),Keyboard (>=1.0.1),WiFiNINA (>1.0.0) diff --git a/internal/rule/rulefunction/testdata/libraries/DependsValid/library.properties b/internal/rule/rulefunction/testdata/libraries/DependsValid/library.properties new file mode 100644 index 00000000..313ff6f1 --- /dev/null +++ b/internal/rule/rulefunction/testdata/libraries/DependsValid/library.properties @@ -0,0 +1,11 @@ +name=DependsValid +version=1.0.0 +author=Cristian Maglie , Pippo Pluto +maintainer=Cristian Maglie +sentence=A library that makes coding a web server a breeze. +paragraph=Supports HTTP1.1 and you can do GET and POST. +category=Communication +url=http://example.com/ +architectures=avr +depends=Servo , Adafruit NeoPixel,Stepper (<1.1.3) , Mouse (<=1.0.0),Keyboard (=1.2.3-beta),Ethernet (>=2.0),WiFiNINA (>0.0.8) +includes=DependsValid.h diff --git a/internal/rule/rulefunction/testdata/libraries/DependsValid/src/DependsValid.h b/internal/rule/rulefunction/testdata/libraries/DependsValid/src/DependsValid.h new file mode 100644 index 00000000..e69de29b diff --git a/internal/rule/schema/schemadata/bindata.go b/internal/rule/schema/schemadata/bindata.go index 16cc58ce..76c99be6 100644 --- a/internal/rule/schema/schemadata/bindata.go +++ b/internal/rule/schema/schemadata/bindata.go @@ -1440,13 +1440,7 @@ var _arduinoLibraryPropertiesDefinitionsSchemaJson = []byte(`{ "allowedCharacters": { "allOf": [ { - "$ref": "#/definitions/propertiesObjects/depends/base/definitions/patternObject" - }, - { - "not": { - "$comment": "The depends property is a comma separated list of names, so a valid name pattern is a valid depends pattern with the comma excluded", - "pattern": "^.*,.*$" - } + "pattern": "^(([a-zA-Z][a-zA-Z0-9 _.\\-]*)|([0-9][a-zA-Z0-9 _.\\-]*[a-zA-Z][a-zA-Z0-9 _.\\-]*))*$" } ] } @@ -1913,18 +1907,14 @@ var _arduinoLibraryPropertiesDefinitionsSchemaJson = []byte(`{ }, "depends": { "base": { - "definitions": { - "patternObject": { - "pattern": "^(([a-zA-Z][a-zA-Z0-9 _\\.\\-,]*)|([0-9][a-zA-Z0-9 _\\.\\-]*[a-zA-Z][a-zA-Z0-9 _\\.\\-,]*))*$" - } - }, "object": { "allOf": [ { "type": "string" }, { - "$ref": "#/definitions/propertiesObjects/depends/base/definitions/patternObject" + "$comment": "Based on #/definitions/propertiesObjects/name/base/definitions/patternObjects/allowedCharacters and general-definitions-schema.json#/definitions/patternObjects/relaxedSemver", + "pattern": "^((((([a-zA-Z][a-zA-Z0-9 _.\\-]*)|([0-9][a-zA-Z0-9 _.\\-]*[a-zA-Z][a-zA-Z0-9 _.\\-]*))+( \\( *(<|<=|=|>=|>)(0|[1-9]\\d*)(\\.(0|[1-9]\\d*))?(\\.(0|[1-9]\\d*))?(-((0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(\\.(0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(\\+([0-9a-zA-Z-]+(\\.[0-9a-zA-Z-]+)*))? *\\) *)?), *)*((([a-zA-Z][a-zA-Z0-9 _.\\-]*)|([0-9][a-zA-Z0-9 _.\\-]*[a-zA-Z][a-zA-Z0-9 _.\\-]*))+( \\( *(<|<=|=|>=|>)(0|[1-9]\\d*)(\\.(0|[1-9]\\d*))?(\\.(0|[1-9]\\d*))?(-((0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(\\.(0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(\\+([0-9a-zA-Z-]+(\\.[0-9a-zA-Z-]+)*))? *\\) *)?))?$" } ] }