diff --git a/main.go b/main.go index 70516c9..a167f00 100644 --- a/main.go +++ b/main.go @@ -2,8 +2,10 @@ package main import ( "bufio" + "fmt" "os" + "github.com/charmbracelet/lipgloss" "github.com/halleck45/ast-metrics/src/Cli" "github.com/halleck45/ast-metrics/src/Command" "github.com/halleck45/ast-metrics/src/Configuration" @@ -82,8 +84,8 @@ func main() { }, // JSON report &cli.StringFlag{ - Name: "report-json", - Usage: "Generate a report in JSON format", + Name: "report-json", + Usage: "Generate a report in JSON format", Category: "Report", }, // Watch mode @@ -92,6 +94,12 @@ func main() { Usage: "Re-run the analysis when files change", Category: "Global options", }, + // CI mode (alias of --non-interactive, --report-html and --report-markdown) + &cli.BoolFlag{ + Name: "ci", + Usage: "Enable CI mode", + Category: "Global options", + }, // Configuration &cli.StringFlag{ Name: "config", @@ -114,14 +122,16 @@ func main() { // get option --non-interactive isInteractive := true - if cCtx.Bool("non-interactive") { + if cCtx.Bool("non-interactive") || cCtx.Bool("ci") { pterm.DisableColor() isInteractive = false } // Stdout outWriter := bufio.NewWriter(os.Stdout) - pterm.DefaultBasicText.Println(pterm.LightMagenta(" AST Metrics ") + "is a language-agnostic static code analyzer.") + var style = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true) + fmt.Println(style.Render("\nšŸ¦« AST Metrics is a language-agnostic static code analyzer.")) + fmt.Println("") // Prepare configuration object configuration := Configuration.NewConfiguration() @@ -183,6 +193,16 @@ func main() { configuration.Reports.Json = cCtx.String("report-json") } + // CI mode + if cCtx.Bool("ci") { + if configuration.Reports.Html == "" { + configuration.Reports.Html = "ast-metrics-html-report" + } + if configuration.Reports.Markdown == "" { + configuration.Reports.Markdown = "ast-metrics-markdown-report.md" + } + } + // Compare with if cCtx.String("compare-with") != "" { configuration.CompareWith = cCtx.String("compare-with") diff --git a/src/Cli/ScreenHome.go b/src/Cli/ScreenHome.go index f2017a7..5cfbd81 100644 --- a/src/Cli/ScreenHome.go +++ b/src/Cli/ScreenHome.go @@ -5,6 +5,7 @@ import ( "os" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "github.com/fsnotify/fsnotify" "github.com/halleck45/ast-metrics/src/Analyzer" pb "github.com/halleck45/ast-metrics/src/NodeType" @@ -74,7 +75,8 @@ func (r *ScreenHome) Render() { if !r.isInteractive { // If not interactive - fmt.Println("No interactive mode detected.") + var style = lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")).Italic(true) + fmt.Println(style.Render("No interactive mode detected.")) return } diff --git a/src/Cli/ScreenHtmlReport.go b/src/Cli/ScreenHtmlReport.go index 013957f..23e0c13 100644 --- a/src/Cli/ScreenHtmlReport.go +++ b/src/Cli/ScreenHtmlReport.go @@ -74,7 +74,7 @@ func (m modelScreenHtmlReport) View() string { // Generate report // report: html htmlReportGenerator := Report.NewHtmlReportGenerator(directory) - err := htmlReportGenerator.Generate(m.files, m.projectAggregated) + _, err := htmlReportGenerator.Generate(m.files, m.projectAggregated) if err != nil { return fmt.Sprintf("Error generating report: %s", err) } diff --git a/src/Command/AnalyzeCommand.go b/src/Command/AnalyzeCommand.go index cd9bc49..b88fd07 100644 --- a/src/Command/AnalyzeCommand.go +++ b/src/Command/AnalyzeCommand.go @@ -3,6 +3,7 @@ package Command import ( "bufio" "errors" + "fmt" "os" "github.com/fsnotify/fsnotify" @@ -12,7 +13,8 @@ import ( "github.com/halleck45/ast-metrics/src/Configuration" "github.com/halleck45/ast-metrics/src/Engine" pb "github.com/halleck45/ast-metrics/src/NodeType" - Report "github.com/halleck45/ast-metrics/src/Report/Html" + Report "github.com/halleck45/ast-metrics/src/Report" + Html "github.com/halleck45/ast-metrics/src/Report/Html" Json "github.com/halleck45/ast-metrics/src/Report/Json" Markdown "github.com/halleck45/ast-metrics/src/Report/Markdown" "github.com/halleck45/ast-metrics/src/Storage" @@ -174,24 +176,28 @@ func (v *AnalyzeCommand) Execute() error { v.spinner.Increment() } - // report: html - htmlReportGenerator := Report.NewHtmlReportGenerator(v.configuration.Reports.Html) - err = htmlReportGenerator.Generate(allResults, projectAggregated) - if err != nil { - pterm.Error.Println("Cannot generate html report: " + err.Error()) - return err - } - // report: markdown - markdownReportGenerator := Markdown.NewMarkdownReportGenerator(v.configuration.Reports.Markdown) - err = markdownReportGenerator.Generate(allResults, projectAggregated) - if err != nil { - pterm.Error.Println("Cannot generate markdown report: " + err.Error()) - } - // report: json - jsonReportGenerator := Json.NewJsonReportGenerator(v.configuration.Reports.Json) - err = jsonReportGenerator.Generate(allResults, projectAggregated) - if err != nil { - pterm.Error.Println("Cannot generate json report: " + err.Error()) + reporters := []Report.Reporter{} + generatedReports := []Report.GeneratedReport{} + if v.configuration.Reports.HasReports() { + if v.configuration.Reports.Html != "" { + reporters = append(reporters, Html.NewHtmlReportGenerator(v.configuration.Reports.Html)) + } + if v.configuration.Reports.Markdown != "" { + reporters = append(reporters, Markdown.NewMarkdownReportGenerator(v.configuration.Reports.Markdown)) + } + if v.configuration.Reports.Json != "" { + reporters = append(reporters, Json.NewJsonReportGenerator(v.configuration.Reports.Json)) + } + + // Generate reports + for _, reporter := range reporters { + reports, err := reporter.Generate(allResults, projectAggregated) + if err != nil { + pterm.Error.Println("Cannot generate report: " + err.Error()) + return err + } + generatedReports = append(generatedReports, reports...) + } } // Evaluate requirements @@ -245,6 +251,20 @@ func (v *AnalyzeCommand) Execute() error { } } + // List reports + if v.configuration.Reports.HasReports() { + + fmt.Println("") + fmt.Println("šŸ“ These reports have been generated:") + + for _, report := range generatedReports { + fmt.Println("\n āœ” " + report.Path + " (" + report.Type + ")") + fmt.Println("\n " + report.Description) + } + + fmt.Println("") + } + // Link to file wartcher (in order to close it when app is closed) if v.FileWatcher != nil { v.currentPage.FileWatcher = v.FileWatcher @@ -253,6 +273,15 @@ func (v *AnalyzeCommand) Execute() error { // Store state of the command v.alreadyExecuted = true + // Tips if configuration file does not exist + if !v.configuration.IsComingFromConfigFile { + fmt.Println("\nšŸ’” We noticed that you haven't yet created a configuration file. You can create a .ast-metrics.yaml configuration file by running: ast-metrics init") + fmt.Println("") + } + + fmt.Println("\nšŸŒŸ If you like AST Metrics, please consider starring the project on GitHub: https://github.com/Halleck45/ast-metrics/. Thanks!") + fmt.Println("") + if shouldFail { os.Exit(1) } diff --git a/src/Configuration/Configuration.go b/src/Configuration/Configuration.go index 9d952a5..7e712b0 100644 --- a/src/Configuration/Configuration.go +++ b/src/Configuration/Configuration.go @@ -27,6 +27,8 @@ type Configuration struct { // Location of cache files Storage *Storage.Workdir + + IsComingFromConfigFile bool } type ConfigurationReport struct { @@ -35,6 +37,11 @@ type ConfigurationReport struct { Json string `yaml:"json"` } +// function HasReports() bool { +func (c *ConfigurationReport) HasReports() bool { + return c.Html != "" || c.Markdown != "" || c.Json != "" +} + type ConfigurationRequirements struct { Rules *struct { CyclomaticComplexity *ConfigurationDefaultRule `yaml:"cyclomatic_complexity"` @@ -64,6 +71,7 @@ func NewConfiguration() *Configuration { Watching: false, CompareWith: "", Storage: Storage.Default(), + IsComingFromConfigFile: false, } } diff --git a/src/Configuration/ConfigurationLoader.go b/src/Configuration/ConfigurationLoader.go index 664583c..99f9c58 100644 --- a/src/Configuration/ConfigurationLoader.go +++ b/src/Configuration/ConfigurationLoader.go @@ -1,75 +1,76 @@ package Configuration import ( - "errors" - "os" + "errors" + "os" - "gopkg.in/yaml.v3" + "gopkg.in/yaml.v3" ) type ConfigurationLoader struct { - FilenameToChecks []string + FilenameToChecks []string } func NewConfigurationLoader() *ConfigurationLoader { - return &ConfigurationLoader{ - FilenameToChecks: []string{ - ".ast-metrics.yaml", - ".ast-metrics.dist.yaml", - }, - } + return &ConfigurationLoader{ + FilenameToChecks: []string{ + ".ast-metrics.yaml", + ".ast-metrics.dist.yaml", + }, + } } func (c *ConfigurationLoader) Loads(cfg *Configuration) (*Configuration, error) { - // Load configuration file - for _, filename := range c.FilenameToChecks { - - if _, err := os.Stat(filename); err == nil { - - // Load configuration - f, err := os.Open(filename) - if err != nil { - return cfg, err - } - defer f.Close() - - decoder := yaml.NewDecoder(f) - err = decoder.Decode(&cfg) - if err != nil { - return cfg, err - } - - return cfg, nil - } - } - - return cfg, nil + // Load configuration file + for _, filename := range c.FilenameToChecks { + + if _, err := os.Stat(filename); err == nil { + + // Load configuration + f, err := os.Open(filename) + if err != nil { + return cfg, err + } + defer f.Close() + + decoder := yaml.NewDecoder(f) + err = decoder.Decode(&cfg) + if err != nil { + return cfg, err + } + + cfg.IsComingFromConfigFile = true + return cfg, nil + } + } + + return cfg, nil } func (c *ConfigurationLoader) Import(yamlString string) (*Configuration, error) { - // Load YAML string into configuration - cfg := &Configuration{} - err := yaml.Unmarshal([]byte(yamlString), cfg) - if err != nil { - return cfg, err - } - - return cfg, nil + // Load YAML string into configuration + cfg := &Configuration{} + err := yaml.Unmarshal([]byte(yamlString), cfg) + if err != nil { + return cfg, err + } + + return cfg, nil } func (c *ConfigurationLoader) CreateDefaultFile() error { - if len(c.FilenameToChecks) == 0 { - return errors.New("No filename to check") - } - filename := c.FilenameToChecks[0] - - // Create default configuration file - f, err := os.Create(filename) - if err != nil { - return err - } - - _, err = f.WriteString(`# AST Metrics configuration file + if len(c.FilenameToChecks) == 0 { + return errors.New("No filename to check") + } + filename := c.FilenameToChecks[0] + + // Create default configuration file + f, err := os.Create(filename) + if err != nil { + return err + } + + _, err = f.WriteString(`# AST Metrics configuration file # This file is used to configure AST Metrics # You can find more information at https://github.com/Halleck45/ast-metrics/ @@ -115,9 +116,9 @@ requirements: to: "Controller" `) - if err != nil { - return err - } + if err != nil { + return err + } - return nil + return nil } diff --git a/src/Report/GeneratedReport.go b/src/Report/GeneratedReport.go new file mode 100644 index 0000000..f8be0e6 --- /dev/null +++ b/src/Report/GeneratedReport.go @@ -0,0 +1,15 @@ +package Report + +type GeneratedReport struct { + // The path to the generated report + Path string + + // The type of the report + Type string + + // Description of the report + Description string + + // Icon of the report + Icon string +} diff --git a/src/Report/Html/HtmlReportGenerator.go b/src/Report/Html/HtmlReportGenerator.go index c9461c7..d1888f1 100644 --- a/src/Report/Html/HtmlReportGenerator.go +++ b/src/Report/Html/HtmlReportGenerator.go @@ -14,6 +14,7 @@ import ( "github.com/halleck45/ast-metrics/src/Analyzer" "github.com/halleck45/ast-metrics/src/Engine" pb "github.com/halleck45/ast-metrics/src/NodeType" + "github.com/halleck45/ast-metrics/src/Report" "github.com/halleck45/ast-metrics/src/Ui" ) @@ -27,30 +28,30 @@ type HtmlReportGenerator struct { ReportPath string } -func NewHtmlReportGenerator(reportPath string) *HtmlReportGenerator { +func NewHtmlReportGenerator(reportPath string) Report.Reporter { return &HtmlReportGenerator{ ReportPath: reportPath, } } -func (v *HtmlReportGenerator) Generate(files []*pb.File, projectAggregated Analyzer.ProjectAggregated) error { +func (v *HtmlReportGenerator) Generate(files []*pb.File, projectAggregated Analyzer.ProjectAggregated) ([]Report.GeneratedReport, error) { // Ensure report is required if v.ReportPath == "" { - return nil + return nil, nil } // Ensure destination folder exists err := v.EnsureFolder(v.ReportPath) if err != nil { - return err + return nil, err } // copy the templates from embed, to temporary folder templateDir := fmt.Sprintf("%s/templates", os.TempDir()) err = os.MkdirAll(templateDir, os.ModePerm) if err != nil { - return err + return nil, err } for _, file := range []string{ @@ -74,13 +75,13 @@ func (v *HtmlReportGenerator) Generate(files []*pb.File, projectAggregated Analy // read the file content, err := content.ReadFile(fmt.Sprintf("templates/%s", file)) if err != nil { - return err + return nil, err } // write the file to temporary folder (/tmp) err = os.WriteFile(fmt.Sprintf("%s/%s", templateDir, file), content, 0644) if err != nil { - return err + return nil, err } } @@ -115,10 +116,19 @@ func (v *HtmlReportGenerator) Generate(files []*pb.File, projectAggregated Analy // cleanup temporary folder err = os.RemoveAll(templateDir) if err != nil { - return err + return nil, err } - return nil + reports := []Report.GeneratedReport{ + { + Path: v.ReportPath, + Type: "directory", + Description: "The HTML reports allow you to visualize the metrics of your project in a web browser.", + Icon: "šŸ“Š", + }, + } + + return reports, nil } func (v *HtmlReportGenerator) GenerateLanguagePage(template string, language string, currentView Analyzer.Aggregated, files []*pb.File, projectAggregated Analyzer.ProjectAggregated) error { diff --git a/src/Report/Json/JsonReportGenerator.go b/src/Report/Json/JsonReportGenerator.go index 8e1ddc8..a68f495 100644 --- a/src/Report/Json/JsonReportGenerator.go +++ b/src/Report/Json/JsonReportGenerator.go @@ -9,6 +9,7 @@ import ( "github.com/halleck45/ast-metrics/src/Analyzer" pb "github.com/halleck45/ast-metrics/src/NodeType" "github.com/halleck45/ast-metrics/src/Pkg/Cleaner" + "github.com/halleck45/ast-metrics/src/Report" ) type JsonReportGenerator struct { @@ -16,39 +17,47 @@ type JsonReportGenerator struct { } // This factory creates a new JsonReportGenerator -func NewJsonReportGenerator(ReportPath string) *JsonReportGenerator { +func NewJsonReportGenerator(ReportPath string) Report.Reporter { return &JsonReportGenerator{ ReportPath: ReportPath, } } // Generate generates a JSON report -func (j *JsonReportGenerator) Generate(files []*pb.File, projectAggregated Analyzer.ProjectAggregated) error { +func (j *JsonReportGenerator) Generate(files []*pb.File, projectAggregated Analyzer.ProjectAggregated) ([]Report.GeneratedReport, error) { if j.ReportPath == "" { - return nil + return nil, nil } report := j.buildReport(projectAggregated) err := Cleaner.CleanVal(report) if err != nil { - return fmt.Errorf("can not clean report err: %s", err.Error()) + return nil, fmt.Errorf("can not clean report err: %s", err.Error()) } // This code serializes the results to JSON jsonReport, err := json.Marshal(report) if err != nil { - return fmt.Errorf("can not serialize report to JSON err: %s", err.Error()) + return nil, fmt.Errorf("can not serialize report to JSON err: %s", err.Error()) } // This code writes the JSON report to a file err = ioutil.WriteFile(j.ReportPath, jsonReport, os.ModePerm) if err != nil { - return fmt.Errorf("can not save report to path %s err: %s", j.ReportPath, err.Error()) + return nil, fmt.Errorf("can not save report to path %s err: %s", j.ReportPath, err.Error()) } - return nil + reports := []Report.GeneratedReport{ + { + Path: j.ReportPath, + Type: "file", + Description: "The JSON report allows scripts to parse the results", + Icon: "šŸ“„", + }, + } + return reports, nil } // The buildReport creates a JSON report using the diff --git a/src/Report/Json/JsonReportGenerator_test.go b/src/Report/Json/JsonReportGenerator_test.go index c2f934f..91a5095 100644 --- a/src/Report/Json/JsonReportGenerator_test.go +++ b/src/Report/Json/JsonReportGenerator_test.go @@ -27,7 +27,7 @@ func TestGenerateJson(t *testing.T) { }, } - err := generator.Generate(files, projectAggregated) + _, err := generator.Generate(files, projectAggregated) // Check if the error is nil assert.Nil(t, err) diff --git a/src/Report/Markdown/MardownReportGenerator_test.go b/src/Report/Markdown/MardownReportGenerator_test.go index 735f7ef..54bfa7a 100644 --- a/src/Report/Markdown/MardownReportGenerator_test.go +++ b/src/Report/Markdown/MardownReportGenerator_test.go @@ -38,7 +38,7 @@ func TestGenerate(t *testing.T) { files := []*pb.File{} projectAggregated := Analyzer.ProjectAggregated{} - err := generator.Generate(files, projectAggregated) + _, err := generator.Generate(files, projectAggregated) if tt.expectError { if err == nil { @@ -71,7 +71,7 @@ func TestGenerateWithTemplateFiles(t *testing.T) { // Create a temporary template file ioutil.WriteFile("/tmp/templates/index.md", []byte("Test template"), 0644) - err := generator.Generate(files, projectAggregated) + _, err := generator.Generate(files, projectAggregated) if err != nil { t.Errorf("Did not expect an error but got: %v", err) diff --git a/src/Report/Markdown/MarkdownReportGenerator.go b/src/Report/Markdown/MarkdownReportGenerator.go index 523a789..545e77b 100644 --- a/src/Report/Markdown/MarkdownReportGenerator.go +++ b/src/Report/Markdown/MarkdownReportGenerator.go @@ -12,6 +12,7 @@ import ( "github.com/halleck45/ast-metrics/src/Analyzer" pb "github.com/halleck45/ast-metrics/src/NodeType" + "github.com/halleck45/ast-metrics/src/Report" ) var ( @@ -24,36 +25,36 @@ type MarkdownReportGenerator struct { ReportPath string } -func NewMarkdownReportGenerator(reportPath string) *MarkdownReportGenerator { +func NewMarkdownReportGenerator(reportPath string) Report.Reporter { return &MarkdownReportGenerator{ ReportPath: reportPath, } } -func (v *MarkdownReportGenerator) Generate(files []*pb.File, projectAggregated Analyzer.ProjectAggregated) error { +func (v *MarkdownReportGenerator) Generate(files []*pb.File, projectAggregated Analyzer.ProjectAggregated) ([]Report.GeneratedReport, error) { // Ensure report is required if v.ReportPath == "" { - return nil + return nil, nil } // copy the templates from embed, to temporary folder templateDir := fmt.Sprintf("%s/templates", os.TempDir()) err := os.MkdirAll(templateDir, os.ModePerm) if err != nil { - return err + return nil, err } for _, file := range []string{"index.md"} { // read the file content, err := content.ReadFile(fmt.Sprintf("templates/%s", file)) if err != nil { - return err + return nil, err } // write the file to temporary folder (/tmp) err = os.WriteFile(fmt.Sprintf("%s/%s", templateDir, file), content, 0644) if err != nil { - return err + return nil, err } } @@ -68,20 +69,20 @@ func (v *MarkdownReportGenerator) Generate(files []*pb.File, projectAggregated A tpl, err := pongo2.DefaultSet.FromFile("index.md") if err != nil { log.Error(err) - return err + return nil, err } // Render it, passing projectAggregated and files as context out, err := tpl.Execute(pongo2.Context{"projectAggregated": projectAggregated, "files": files}) if err != nil { log.Error(err) - return err + return nil, err } // Write the result to the file file, err := os.Create(v.ReportPath) if err != nil { log.Error(err) - return err + return nil, err } defer file.Close() file.WriteString(out) @@ -90,10 +91,18 @@ func (v *MarkdownReportGenerator) Generate(files []*pb.File, projectAggregated A err = os.RemoveAll(templateDir) if err != nil { log.Error(err) - return err + return nil, err } - return nil + reports := []Report.GeneratedReport{ + { + Path: v.ReportPath, + Type: "file", + Description: "The markdown report is useful for CI/CD pipelines, displaying the results in a human-readable format.", + Icon: "šŸ“„", + }, + } + return reports, nil } diff --git a/src/Report/Reporter.go b/src/Report/Reporter.go new file mode 100644 index 0000000..2685fa9 --- /dev/null +++ b/src/Report/Reporter.go @@ -0,0 +1,10 @@ +package Report + +import ( + "github.com/halleck45/ast-metrics/src/Analyzer" + pb "github.com/halleck45/ast-metrics/src/NodeType" +) + +type Reporter interface { + Generate(files []*pb.File, projectAggregated Analyzer.ProjectAggregated) ([]GeneratedReport, error) +}