Skip to content
Open
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
138 changes: 138 additions & 0 deletions cmd/mangle-lint/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// Copyright 2024 Google LLC
//
// Licensed 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.

// Binary mangle-lint is a standalone linter for Mangle programs.
package main

import (
"flag"
"fmt"
"os"
"path/filepath"
"strings"

"github.com/google/mangle/lint"
)

var (
format = flag.String("format", "text", "output format: text or json")
severity = flag.String("severity", "info", "minimum severity to report: info, warning, or error")
disable = flag.String("disable", "", "comma-separated list of rule names to disable")
enable = flag.String("enable", "", "comma-separated list of rule names to enable (all others disabled)")
listRules = flag.Bool("list-rules", false, "list all available lint rules and exit")
maxPremises = flag.Int("max-premises", 8, "threshold for overly-complex-rule check")
)

func main() {
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage: mangle-lint [flags] <file.mg> [file.mg...]\n\n")
fmt.Fprintf(os.Stderr, "A linter for the Mangle Datalog language.\n\n")
fmt.Fprintf(os.Stderr, "Flags:\n")
flag.PrintDefaults()
fmt.Fprintf(os.Stderr, "\nExit codes:\n")
fmt.Fprintf(os.Stderr, " 0 No findings (or only info)\n")
fmt.Fprintf(os.Stderr, " 1 Warnings found\n")
fmt.Fprintf(os.Stderr, " 2 Errors found\n")
}
flag.Parse()

if *listRules {
fmt.Println("Available lint rules:")
fmt.Println()
for _, r := range lint.AllRules() {
fmt.Printf(" %-25s [%s] %s\n", r.Name(), r.DefaultSeverity(), r.Description())
}
os.Exit(0)
}

args := flag.Args()
if len(args) == 0 {
flag.Usage()
os.Exit(2)
}

// Expand glob patterns.
var files []string
for _, arg := range args {
matches, err := filepath.Glob(arg)
if err != nil || len(matches) == 0 {
files = append(files, arg) // treat as literal path
} else {
files = append(files, matches...)
}
}

config := lint.DefaultConfig()
config.MaxPremises = *maxPremises
config.MinSeverity = lint.ParseSeverity(*severity)

if *disable != "" {
for _, name := range strings.Split(*disable, ",") {
config.DisabledRules[strings.TrimSpace(name)] = true
}
}
if *enable != "" {
// Disable all rules first, then enable only the specified ones.
for _, r := range lint.AllRules() {
config.DisabledRules[r.Name()] = true
}
for _, name := range strings.Split(*enable, ",") {
delete(config.DisabledRules, strings.TrimSpace(name))
}
}

linter := lint.NewLinter(config)
var allResults []lint.LintResult
hasParseError := false

for _, path := range files {
results, err := linter.LintFile(path)
if err != nil {
fmt.Fprintf(os.Stderr, "%s: %v\n", path, err)
hasParseError = true
continue
}
allResults = append(allResults, results...)
}

// Output.
switch *format {
case "json":
if err := lint.FormatJSON(os.Stdout, allResults); err != nil {
fmt.Fprintf(os.Stderr, "error writing JSON: %v\n", err)
os.Exit(2)
}
default:
lint.FormatText(os.Stdout, allResults)
}

// Exit code.
if hasParseError {
os.Exit(2)
}
maxSev := lint.SeverityInfo
for _, r := range allResults {
if r.Severity > maxSev {
maxSev = r.Severity
}
}
switch {
case maxSev >= lint.SeverityError:
os.Exit(2)
case maxSev >= lint.SeverityWarning:
os.Exit(1)
default:
os.Exit(0)
}
}
43 changes: 43 additions & 0 deletions lint/check_complexity.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Copyright 2024 Google LLC
//
// Licensed 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 lint

import "fmt"

// OverlyComplexRule flags rules with too many premises.
type OverlyComplexRule struct{}

func (r *OverlyComplexRule) Name() string { return "overly-complex-rule" }
func (r *OverlyComplexRule) Description() string { return "Flags rules with too many premises" }
func (r *OverlyComplexRule) DefaultSeverity() Severity { return SeverityInfo }

func (r *OverlyComplexRule) Check(input *LintInput, config LintConfig) []LintResult {
var results []LintResult
threshold := config.MaxPremises
if threshold <= 0 {
threshold = 8
}
for _, clause := range input.ProgramInfo.Rules {
if len(clause.Premises) > threshold {
results = append(results, LintResult{
RuleName: r.Name(),
Severity: r.DefaultSeverity(),
Message: fmt.Sprintf("rule for %q has %d premises (threshold: %d); consider breaking into intermediate predicates", clause.Head.Predicate.Symbol, len(clause.Premises), threshold),
Predicate: clause.Head.Predicate.Symbol,
})
}
}
return results
}
57 changes: 57 additions & 0 deletions lint/check_dead_code.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Copyright 2024 Google LLC
//
// Licensed 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 lint

import (
"fmt"

"github.com/google/mangle/ast"
)

// DeadCodeRule flags IDB predicates whose derived facts are never consumed
// by any other rule's premises.
type DeadCodeRule struct{}

func (r *DeadCodeRule) Name() string { return "dead-code" }
func (r *DeadCodeRule) Description() string { return "Flags derived predicates whose results are never consumed" }
func (r *DeadCodeRule) DefaultSeverity() Severity { return SeverityInfo }

func (r *DeadCodeRule) Check(input *LintInput, config LintConfig) []LintResult {
// Build set of predicates consumed in premises (excluding self-references).
consumed := make(map[ast.PredicateSym]bool)
for _, clause := range input.ProgramInfo.Rules {
for _, p := range clause.Premises {
for _, pred := range extractPredicatesFromTerm(p) {
consumed[pred] = true
}
}
}

var results []LintResult
for pred := range input.ProgramInfo.IdbPredicates {
if !isUserPredicate(pred) {
continue
}
if !consumed[pred] {
results = append(results, LintResult{
RuleName: r.Name(),
Severity: r.DefaultSeverity(),
Message: fmt.Sprintf("predicate %q derives facts but results are never consumed by another rule", pred.Symbol),
Predicate: pred.Symbol,
})
}
}
return results
}
46 changes: 46 additions & 0 deletions lint/check_missing_doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Copyright 2024 Google LLC
//
// Licensed 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 lint

import "fmt"

// MissingDocRule flags predicates that have no documentation descriptors.
type MissingDocRule struct{}

func (r *MissingDocRule) Name() string { return "missing-doc" }
func (r *MissingDocRule) Description() string { return "Flags predicates without documentation" }
func (r *MissingDocRule) DefaultSeverity() Severity { return SeverityInfo }

func (r *MissingDocRule) Check(input *LintInput, config LintConfig) []LintResult {
var results []LintResult
for pred, decl := range input.ProgramInfo.Decls {
if !isUserPredicate(pred) {
continue
}
if decl.IsSynthetic() {
continue
}
docs := decl.Doc()
if len(docs) == 0 || (len(docs) == 1 && docs[0] == "") {
results = append(results, LintResult{
RuleName: r.Name(),
Severity: r.DefaultSeverity(),
Message: fmt.Sprintf("predicate %q has no documentation", pred.Symbol),
Predicate: pred.Symbol,
})
}
}
return results
}
91 changes: 91 additions & 0 deletions lint/check_naming.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// Copyright 2024 Google LLC
//
// Licensed 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 lint

import (
"fmt"
"regexp"

"github.com/google/mangle/ast"
)

var (
// predicateNameRe matches valid snake_case predicate names, optionally with package prefixes.
predicateNameRe = regexp.MustCompile(`^([a-z][a-z0-9_]*\.)*[a-z][a-z0-9_]*$`)
// variableNameRe matches valid variable names: uppercase start, alphanumeric + underscore.
variableNameRe = regexp.MustCompile(`^[A-Z][A-Za-z0-9_]*$`)
)

// NamingConventionRule checks that predicate names are snake_case and variables
// are uppercase.
type NamingConventionRule struct{}

func (r *NamingConventionRule) Name() string { return "naming-convention" }
func (r *NamingConventionRule) Description() string { return "Checks predicate and variable naming conventions" }
func (r *NamingConventionRule) DefaultSeverity() Severity { return SeverityWarning }

func (r *NamingConventionRule) Check(input *LintInput, config LintConfig) []LintResult {
var results []LintResult

// Check predicate names.
checked := make(map[ast.PredicateSym]bool)
allClauses := append(input.ProgramInfo.Rules, factsAsClauses(input)...)
for _, clause := range allClauses {
pred := clause.Head.Predicate
if checked[pred] || !isUserPredicate(pred) {
continue
}
checked[pred] = true
if !predicateNameRe.MatchString(pred.Symbol) {
results = append(results, LintResult{
RuleName: r.Name(),
Severity: r.DefaultSeverity(),
Message: fmt.Sprintf("predicate %q does not follow snake_case naming convention", pred.Symbol),
Predicate: pred.Symbol,
})
}
}

// Check variable names in rules.
for _, clause := range input.ProgramInfo.Rules {
vars := make(map[ast.Variable]bool)
ast.AddVarsFromClause(clause, vars)
for v := range vars {
name := v.Symbol
if name == "_" {
continue
}
if !variableNameRe.MatchString(name) {
results = append(results, LintResult{
RuleName: r.Name(),
Severity: r.DefaultSeverity(),
Message: fmt.Sprintf("variable %q in rule for %q does not follow naming convention (should start with uppercase)", name, clause.Head.Predicate.Symbol),
Predicate: clause.Head.Predicate.Symbol,
})
}
}
}

return results
}

// factsAsClauses wraps initial facts from ProgramInfo into Clause values.
func factsAsClauses(input *LintInput) []ast.Clause {
var clauses []ast.Clause
for _, fact := range input.ProgramInfo.InitialFacts {
clauses = append(clauses, ast.Clause{Head: fact})
}
return clauses
}
Loading