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
3 changes: 2 additions & 1 deletion cmd/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package cmd
import (
"errors"
"fmt"
"github.com/daveshanley/vacuum/loader"
"log/slog"
"net/http"
"time"
Expand Down Expand Up @@ -116,7 +117,7 @@ func GetDashboardCommand() *cobra.Command {
}
}

reportOrSpec, err := LoadFileAsReportOrSpecWithClient(args[0], httpClient)
reportOrSpec, err := loader.LoadFileAsReportOrSpecWithClient(args[0], httpClient)
if err != nil {
message := fmt.Sprintf("Failed to load file: %v", err)
style := createResultBoxStyle(color.RGBRed, color.RGBDarkRed)
Expand Down
18 changes: 17 additions & 1 deletion cmd/language_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ package cmd
import (
"fmt"
"log/slog"
"path/filepath"

languageserver "github.com/daveshanley/vacuum/language-server"
"github.com/daveshanley/vacuum/logging"
Expand All @@ -31,6 +32,16 @@ IDE and start linting your OpenAPI documents in real-time.`,
handler := logging.NewBufferedLogHandler(bufferedLogger)
logger := slog.New(handler)

var mainSpecPath string
if len(args) == 0 {
mainSpecPath = ""
} else {
var err error
mainSpecPath, err = filepath.Abs(args[0])

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If someone passes a remote spec URL, it becomes a bogus local path. This is despite the loader/http-client changes supporting remote loading.

if err != nil {
return fmt.Errorf("failed to resolve path: %w", err)
}
}
// extract flags
rulesetFlag, _ := cmd.Flags().GetString("ruleset")
functionsFlag, _ := cmd.Flags().GetString("functions")
Expand Down Expand Up @@ -99,6 +110,7 @@ IDE and start linting your OpenAPI documents in real-time.`,
}

lfr := utils.LintFileRequest{
MainSpecPath: mainSpecPath,
BaseFlag: baseFlag,
Remote: remoteFlag,
SkipCheckFlag: skipCheckFlag,
Expand All @@ -115,7 +127,11 @@ IDE and start linting your OpenAPI documents in real-time.`,
HTTPClientConfig: httpClientConfig,
}

return languageserver.NewServer(GetVersion(), &lfr).Run()
server, err := languageserver.NewServer(GetVersion(), &lfr)
if err != nil {
return err
}
return server.Run()
},
}
cmd.Flags().Bool("ignore-array-circle-ref", false, "Ignore circular array references")
Expand Down
3 changes: 2 additions & 1 deletion cmd/lint_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package cmd
import (
"errors"
"fmt"
"github.com/daveshanley/vacuum/loader"
"log/slog"
"net/http"
"os"
Expand Down Expand Up @@ -138,7 +139,7 @@ func runLint(cmd *cobra.Command, args []string) error {
}

// try to load the file as either a report or spec (supports URLs)
reportOrSpec, err := LoadFileAsReportOrSpecWithClient(fileName, httpClient)
reportOrSpec, err := loader.LoadFileAsReportOrSpecWithClient(fileName, httpClient)
if err != nil {
if !flags.SilentFlag {
fmt.Printf("\033[31mUnable to load file '%s': %v\033[0m\n", fileName, err)
Expand Down
126 changes: 4 additions & 122 deletions cmd/report_loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,134 +5,16 @@ package cmd

import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"

"github.com/daveshanley/vacuum/model"
"github.com/daveshanley/vacuum/loader"
vacuum_report "github.com/daveshanley/vacuum/vacuum-report"
"os"
)

// ReportLoadResult contains the results of attempting to load a file as either
// a pre-compiled vacuum report or raw OpenAPI spec
type ReportLoadResult struct {
// If the file was a pre-compiled report
IsReport bool
Report *vacuum_report.VacuumReport

// The raw spec bytes (either from file or extracted from report)
SpecBytes []byte

// The filename/path for display
FileName string

// Pre-processed results if from a report
ResultSet *model.RuleResultSet
}

// LoadFileAsReportOrSpec attempts to load a file as either a pre-compiled vacuum report
// or as a raw OpenAPI specification. It returns a ReportLoadResult with all the necessary
// data for either case. Supports both local file paths and remote URLs (http/https).
func LoadFileAsReportOrSpec(filePath string) (*ReportLoadResult, error) {
return LoadFileAsReportOrSpecWithClient(filePath, nil)
}

// LoadFileAsReportOrSpecWithClient attempts to load a file as either a pre-compiled vacuum report
// or as a raw OpenAPI specification with optional HTTP client for TLS configuration.
// Supports both local file paths and remote URLs (http/https).
func LoadFileAsReportOrSpecWithClient(filePath string, httpClient *http.Client) (*ReportLoadResult, error) {
result := &ReportLoadResult{
FileName: filePath,
}

var bytes []byte
var err error

// Check if the path is a URL
if strings.HasPrefix(filePath, "http://") || strings.HasPrefix(filePath, "https://") {
bytes, err = fetchRemoteSpec(filePath, httpClient)
if err != nil {
return nil, fmt.Errorf("failed to fetch remote spec '%s': %w", filePath, err)
}
} else {
// Get the absolute path for consistent handling of local files
absPath, pathErr := filepath.Abs(filePath)
if pathErr != nil {
return nil, fmt.Errorf("failed to resolve path: %w", pathErr)
}

bytes, err = os.ReadFile(absPath)
if err != nil {
return nil, fmt.Errorf("failed to read file '%s': %w", filePath, err)
}
}

// Try to parse as a vacuum report
vacuumReport, parseErr := vacuum_report.CheckFileForVacuumReport(bytes)
if parseErr != nil {
// File was read but isn't a report - treat as spec
result.SpecBytes = bytes
result.IsReport = false
return result, nil
}

// Check if it's actually a report
if vacuumReport != nil && vacuumReport.ResultSet != nil {
result.IsReport = true
result.Report = vacuumReport
result.ResultSet = vacuumReport.ResultSet

// Extract spec bytes from the report if available
if vacuumReport.SpecInfo != nil && vacuumReport.SpecInfo.SpecBytes != nil {
result.SpecBytes = *vacuumReport.SpecInfo.SpecBytes
}

// Use the original filename from the report's execution if available
if vacuumReport.Execution != nil && vacuumReport.Execution.SpecFileName != "" {
result.FileName = vacuumReport.Execution.SpecFileName
}

return result, nil
}

// Not a report, treat as regular spec file
result.SpecBytes = bytes
result.IsReport = false
return result, nil
}

// fetchRemoteSpec downloads a spec file from a remote URL
func fetchRemoteSpec(url string, httpClient *http.Client) ([]byte, error) {
if httpClient == nil {
httpClient = http.DefaultClient
}

req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}

// Add a reasonable user agent
req.Header.Set("User-Agent", "vacuum-linter/1.0")

resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("server returned status %d: %s", resp.StatusCode, resp.Status)
}

bytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}

return bytes, nil
func LoadFileAsReportOrSpec(filePath string) (*loader.ReportLoadResult, error) {
return loader.LoadFileAsReportOrSpecWithClient(filePath, nil)
}

// LoadReportOnly attempts to load a file specifically as a vacuum report.
Expand Down
58 changes: 45 additions & 13 deletions language-server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ package languageserver

import (
"fmt"
"github.com/daveshanley/vacuum/loader"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -56,6 +58,7 @@ type ServerState struct {
documentStore *DocumentStore
lintRequest *utils.LintFileRequest
rulesetSelector RulesetSelector
httpClient *http.Client

// Configuration layers (in order of increasing priority)
baseConfig *LSPConfig // From command-line flags (immutable after init)
Expand All @@ -82,7 +85,7 @@ type ServerState struct {
notifyMu sync.RWMutex
}

func NewServer(version string, lintRequest *utils.LintFileRequest) *ServerState {
func NewServer(version string, lintRequest *utils.LintFileRequest) (*ServerState, error) {
handler := protocol.Handler{}
server := glspserv.NewServer(&handler, serverName, true)

Expand All @@ -108,11 +111,20 @@ func NewServer(version string, lintRequest *utils.LintFileRequest) *ServerState
if lintRequest.ExtensionRefs {
baseConfig.ExtensionRefs = boolPtr(true)
}
var httpClient *http.Client
var err error
if utils.ShouldUseCustomHTTPClient(lintRequest.HTTPClientConfig) {
httpClient, err = utils.CreateCustomHTTPClient(lintRequest.HTTPClientConfig)
if err != nil {
return nil, fmt.Errorf("failed to create HTTP client: %w", err)
}
}

state := &ServerState{
server: server,
lintRequest: lintRequest,
documentStore: newDocumentStore(),
httpClient: httpClient,
logger: logger,
baseConfig: baseConfig,
}
Expand Down Expand Up @@ -248,7 +260,7 @@ func NewServer(version string, lintRequest *utils.LintFileRequest) *ServerState
return nil
}

return state
return state, nil
}

// NewServerWithRulesetSelector creates a new instance of the language server with
Expand All @@ -263,10 +275,13 @@ func NewServer(version string, lintRequest *utils.LintFileRequest) *ServerState
// Want to enable OWASP rules for only a specific server?
// Check the value of servers[0].url and return the rules including the OWASP ruleset
// for your specific super secure sever url.
func NewServerWithRulesetSelector(version string, lintRequest *utils.LintFileRequest, selector RulesetSelector) *ServerState {
state := NewServer(version, lintRequest)
func NewServerWithRulesetSelector(version string, lintRequest *utils.LintFileRequest, selector RulesetSelector) (*ServerState, error) {
state, err := NewServer(version, lintRequest)
if err != nil {
return nil, err
}
state.rulesetSelector = selector
return state
return state, nil
}

func (s *ServerState) Run() error {
Expand All @@ -280,11 +295,21 @@ func (s *ServerState) Run() error {
func (s *ServerState) runDiagnostic(doc *Document, notify glsp.NotifyFunc) {
// Copy document data while holding read lock to avoid data race
doc.mu.RLock()
content := doc.Content
uri := doc.URI
doc.mu.RUnlock()
currentPath := strings.TrimPrefix(uri, "file://")

var specFileName string
var content []byte
if s.lintRequest.MainSpecPath != "" {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stops using the editor buffer whenever MainSpecPath is set and reloads the primary spec from disk every time.

This breaks normal LSP behavior, any unsaved edits in the main spec or a referenced file will not affect diagnostics until the file is saved.

specFileName = s.lintRequest.MainSpecPath
reportLoadResult, _ := loader.LoadFileAsReportOrSpecWithClient(specFileName, s.httpClient)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ignores the loader error and then dereferences reportLoadResult. If the root spec is missing, unreadable, or a remote fetch fails, the LSP will panic during diagnostics.

content = reportLoadResult.SpecBytes
} else {
specFileName = currentPath
content = []byte(doc.Content)
}

specFileName := strings.TrimPrefix(uri, "file://")
doc.mu.RUnlock()

// Copy config data while holding config lock to avoid data race
s.configMu.RLock()
Expand All @@ -299,7 +324,7 @@ func (s *ServerState) runDiagnostic(doc *Document, notify glsp.NotifyFunc) {
// Build the rule execution config with copied values
ruleExec := &motor.RuleSetExecution{
RuleSet: s.lintRequest.SelectedRS,
Spec: []byte(content),
Spec: content,
SpecFileName: specFileName,
Timeout: time.Duration(s.lintRequest.TimeoutFlag) * time.Second,
NodeLookupTimeout: time.Duration(s.lintRequest.LookupTimeoutFlag) * time.Millisecond,
Expand Down Expand Up @@ -337,7 +362,7 @@ func (s *ServerState) runDiagnostic(doc *Document, notify glsp.NotifyFunc) {
}
filteredResults := utils.FilterIgnoredResultsWithOptions(result.Results, ignoredResults, ignoreOptions)
result.Results = filteredResults
diagnostics := ConvertResultsIntoDiagnostics(result)
diagnostics := ConvertResultsIntoDiagnostics(result, s.lintRequest.MainSpecPath, currentPath)

notify(protocol.ServerTextDocumentPublishDiagnostics, protocol.PublishDiagnosticsParams{
URI: uri,
Expand All @@ -346,12 +371,19 @@ func (s *ServerState) runDiagnostic(doc *Document, notify glsp.NotifyFunc) {
}()
}

func ConvertResultsIntoDiagnostics(result *motor.RuleSetExecutionResult) []protocol.Diagnostic {
func ConvertResultsIntoDiagnostics(result *motor.RuleSetExecutionResult, mainSpecPath string, currentPath string) []protocol.Diagnostic {
diagnostics := []protocol.Diagnostic{}

for _, vacuumResult := range result.Results {
diagnostics = append(diagnostics, ConvertResultIntoDiagnostic(&vacuumResult))

var source string
if vacuumResult.Origin == nil {
source = mainSpecPath
} else {
source = vacuumResult.Origin.AbsoluteLocation
}
if mainSpecPath == "" || filepath.Clean(source) == filepath.Clean(currentPath) {
diagnostics = append(diagnostics, ConvertResultIntoDiagnostic(&vacuumResult))
}
}
return diagnostics
}
Expand Down
Loading
Loading