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
45 changes: 45 additions & 0 deletions bindings/go/scip/project_root.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package scip

import "net/url"

// ProjectRootToLocalPath converts an Index.Metadata.ProjectRoot value into a
// local filesystem path.
//
// It handles the Windows form "file://X:\..." (where X is a drive letter)
// which net/url.Parse rejects with `invalid port ":\..." after host`, because
// it parses "X:" as a host:port pattern. See issue #282.
//
// For all other inputs it preserves the previous behavior of using
// url.Parse(rawProjectRoot).Path.
func ProjectRootToLocalPath(rawProjectRoot string) (string, error) {
if isWindowsFileURIWithDriveLetter(rawProjectRoot) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can we invert this flow and try url.Parse(rawProjectRoot) first, then fall back only if parsing fails with the known Windows drive-root shape?
Right now the special-case runs before url.Parse, so it catches inputs that previously parsed successfully. For example, file://x:123/share previously produced path /share, but this helper would now return x:123/share. That conflicts with the comment below saying all other inputs preserve url.Parse(rawProjectRoot).Path.

// Strip the "file://" prefix; the remainder is the Windows path.
// ponytail: only the "file://X:..." form is handled here; the
// canonical "file:///X:/..." form parses fine via url.Parse and is
// out of scope for this fix.
return rawProjectRoot[len("file://"):], nil

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This should probably run through url.PathUnescape before returning. The normal url.Parse(...).Path branch gives callers the decoded path, but this branch returns the raw URI suffix. A root like file://D:%5Cdev%5Cmy%20project would currently become D:%5Cdev%5Cmy%20project instead of D:\dev\my project.

}
u, err := url.Parse(rawProjectRoot)
if err != nil {
return "", err
}
return u.Path, nil
}

// isWindowsFileURIWithDriveLetter reports whether s matches "file://X:..."
// where X is an ASCII letter and the position after the scheme is NOT a third
// slash (which would indicate the canonical "file:///..." form).
func isWindowsFileURIWithDriveLetter(s string) bool {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This predicate seems too broad for the stated fix. It accepts any file://<letter>: prefix, including drive-relative paths like file://D:relative, which previously failed parsing and are not absolute project roots.
Could we require the character after the drive colon to be a path separator (\ or /), or otherwise only enter this fallback after url.Parse has failed? That keeps the workaround scoped to the issue shape.

const prefix = "file://"
if len(s) < len(prefix)+2 {
return false
}
if s[:len(prefix)] != prefix {
return false
}
c := s[len(prefix)]
if !(c >= 'a' && c <= 'z') && !(c >= 'A' && c <= 'Z') {
return false
}
return s[len(prefix)+1] == ':'
}
42 changes: 42 additions & 0 deletions bindings/go/scip/project_root_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package scip

import "testing"

func TestProjectRootToLocalPath(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
// Regression for https://github.com/scip-code/scip/issues/282 —
// Windows ProjectRoot of the form "file://X:\..." used to fail
// url.Parse with "invalid port" because "X:" looked like host:port.
{
name: "windows file uri with drive letter and backslashes",
in: `file://D:\dev\project`,
want: `D:\dev\project`,
},
// Unix/macOS forms must keep their existing behavior unchanged.
{
name: "unix file uri",
in: "file:///test/project",
want: "/test/project",
},
{
name: "unix file uri single slash",
in: "file:/root",
want: "/root",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := ProjectRootToLocalPath(tc.in)
if err != nil {
t.Fatalf("ProjectRootToLocalPath(%q) unexpected error: %v", tc.in, err)
}
if got != tc.want {
t.Fatalf("ProjectRootToLocalPath(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
6 changes: 2 additions & 4 deletions bindings/go/scip/testutil/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"errors"
"fmt"
"log"
"net/url"
"os"
"path/filepath"
"sort"
Expand All @@ -22,18 +21,17 @@ func FormatSnapshots(
customProjectRoot string,
) ([]*scip.SourceFile, error) {
var result []*scip.SourceFile
projectRootUrl, err := url.Parse(index.Metadata.ProjectRoot)
localSourcesRoot, err := scip.ProjectRootToLocalPath(index.Metadata.ProjectRoot)
if err != nil {
return nil, err
}

localSourcesRoot := projectRootUrl.Path
if customProjectRoot != "" {
localSourcesRoot = customProjectRoot
} else if _, err := os.Stat(localSourcesRoot); errors.Is(err, os.ErrNotExist) {
cwd, _ := os.Getwd()
log.Printf("Project root [%s] doesn't exist, using current working directory [%s] instead. "+
"To override this behaviour, use --project-root=<folder> option directly", projectRootUrl.Path, cwd)
"To override this behaviour, use --project-root=<folder> option directly", localSourcesRoot, cwd)
localSourcesRoot = cwd
}

Expand Down
8 changes: 4 additions & 4 deletions cmd/scip/stats.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"encoding/json"
"fmt"
"log"
"net/url"
"os"
"path/filepath"

Expand Down Expand Up @@ -153,18 +152,19 @@ func percentile(buf []float64, percent float64) int32 {

func countLinesOfCode(index *scip.Index, customProjectRoot string) (*gocloc.Result, error) {
var localSource string
root, err := url.Parse(index.Metadata.ProjectRoot)
rootPath, err := scip.ProjectRootToLocalPath(index.Metadata.ProjectRoot)
if err != nil {
return nil, fmt.Errorf("failed to parse Index.Metadata.ProjectRoot as a URI %s: %w", index.Metadata.ProjectRoot, err)
}
if customProjectRoot != "" {
localSource = customProjectRoot
} else {
_, err := os.Stat(root.Path)
localSource = rootPath
_, err := os.Stat(rootPath)
if errors.Is(err, os.ErrNotExist) {
cwd, _ := os.Getwd()
log.Printf("Project root [%s] doesn't exist, using current working directory [%s] instead. "+
"To override this behaviour, specify --project-root=<folder> option", root.Path, cwd)
"To override this behaviour, specify --project-root=<folder> option", rootPath, cwd)
localSource = cwd
} else if err != nil {
return nil, err
Expand Down
42 changes: 42 additions & 0 deletions cmd/scip/stats_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package main

import (
"testing"

"github.com/scip-code/scip/bindings/go/scip"
)

// Regression for https://github.com/scip-code/scip/issues/282.
//
// Before the fix, countLinesOfCode called url.Parse on Index.Metadata.ProjectRoot
// directly. A Windows ProjectRoot of the form "file://X:\..." was rejected by
// url.Parse with `invalid port ":\..." after host` (it parses "X:" as
// host:port), so `scip stats` errored before reading any document.
//
// Exercising countLinesOfCode keeps the externally visible seam covered:
// scip stats -> statsMain -> countStatistics -> countLinesOfCode.
func TestCountLinesOfCode_WindowsFileURI(t *testing.T) {
idx := &scip.Index{
Metadata: &scip.Metadata{ProjectRoot: `file://D:\dev\scip-issue-282-does-not-exist`},
}
if _, err := countLinesOfCode(idx, ""); err != nil {
t.Fatalf("countLinesOfCode returned error for Windows file URI: %v", err)
}
}

// Regression for the localSource bug surfaced in the #282 review: when
// customProjectRoot is empty and the parsed rootPath exists on disk,
// localSource was left empty and the subsequent os.Stat("") returned
// `stat : no such file or directory`. This drives the "rootPath exists"
// branch on both platforms by using t.TempDir() as the ProjectRoot:
// - POSIX: "file:///tmp/xxx" is parsed by url.Parse.
// - Windows: "file://C:\Users\..." is handled by ProjectRootToLocalPath.
func TestCountLinesOfCode_RootPathExists(t *testing.T) {
tmp := t.TempDir()
idx := &scip.Index{
Metadata: &scip.Metadata{ProjectRoot: "file://" + tmp},
}
if _, err := countLinesOfCode(idx, ""); err != nil {
t.Fatalf("countLinesOfCode returned error when rootPath exists: %v", err)
}
}