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
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,12 @@ path list.

`Supported(filename string) bool` reports whether a file's extension maps to a
language with an outlining query. `SupportedSource(src, filename)` also checks
source-based detection such as Ruby shebangs.
source-based detection such as Ruby shebangs. Ruby interpreter names must be
`ruby` or `ruby` followed by a numeric version, such as `ruby3.3`. Detection
checks the interpreter, not later arguments. For `env`, it recognizes literal
assignments and the `-i`, `-u`, `-C`, and `-S` options (including their long
forms), then checks the selected command. Unknown options, quoted words,
escapes, and variable expansion before the command are left unsupported.

`SetParseTimeout(d time.Duration)` overrides the per-file parse timeout
(default 1s). Must be called before the first `Outline` or `Pack` call.
Expand Down
14 changes: 2 additions & 12 deletions outline.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,18 +208,8 @@ func detectSource(src []byte, filename string) (*lang, bool) {
if l, ok := detect(filename); ok {
return l, true
}
line, _, _ := bytes.Cut(src, []byte{'\n'})
if !bytes.HasPrefix(line, []byte("#!")) {
return nil, false
}
for field := range bytes.FieldsSeq(line[2:]) {
name := field
if i := bytes.LastIndexByte(name, '/'); i >= 0 {
name = name[i+1:]
}
if bytes.HasPrefix(name, []byte("ruby")) {
return langs["ruby"], true
}
if rubyShebang(src) {
return langs["ruby"], true
}
return nil, false
}
Expand Down
80 changes: 80 additions & 0 deletions shebang.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package outline

import (
"bytes"
"path"
"regexp"
"strings"
)

var rubyInterpreter = regexp.MustCompile(`^ruby(?:[0-9]+(?:\.[0-9]+)*)?$`)

func rubyShebang(src []byte) bool {
line, _, _ := bytes.Cut(src, []byte{'\n'})
if !bytes.HasPrefix(line, []byte("#!")) {
return false
}
fields := strings.Fields(string(line[2:]))
if len(fields) == 0 || strings.HasSuffix(fields[0], "/") {
return false
}
interpreter := fields[0]
if path.Base(interpreter) == "env" {
interpreter = envInterpreter(fields[1:])
}
return !strings.HasSuffix(interpreter, "/") && rubyInterpreter.MatchString(path.Base(interpreter))
}

func envInterpreter(fields []string) string {
options := true
for len(fields) != 0 {
field := fields[0]
fields = fields[1:]
if !literalEnvWord(field) {
return ""
}
if options {
switch field {
case "--":
options = false
continue
case "-", "-i", "--ignore-environment", "-S", "--split-string":
continue
case "-u", "--unset", "-C", "--chdir":
if len(fields) == 0 || !literalEnvWord(fields[0]) {
return ""
}
fields = fields[1:]
continue
}
if strings.HasPrefix(field, "--unset=") || strings.HasPrefix(field, "--chdir=") {
continue
}
if split, ok := envSplitString(field); ok {
fields = append([]string{split}, fields...)
continue
}
}
if strings.HasPrefix(field, "-") {
return ""
}
if index := strings.IndexByte(field, '='); index > 0 {
options = false
continue
}
return field
}
return ""
}

func envSplitString(field string) (string, bool) {
if value, ok := strings.CutPrefix(field, "--split-string="); ok {
return value, true
}
return strings.CutPrefix(field, "-S")
}

func literalEnvWord(field string) bool {
// Quoting, escapes and expansion require more than whitespace splitting.
return field != "" && !strings.HasPrefix(field, "#") && !strings.ContainsAny(field, "'\"\\$`")
}
78 changes: 78 additions & 0 deletions shebang_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package outline_test

import (
"os"
"path/filepath"
"testing"

"github.com/git-pkgs/outline"
)

func TestBuildRubyShebangInterpreter(t *testing.T) {
for _, tc := range []struct {
name, line string
ruby bool
}{
{"direct", "#!/usr/bin/ruby", true},
{"direct argument", "#!/usr/bin/ruby -w", true},
{"numeric suffix", "#!/usr/bin/ruby3", true},
{"dotted suffix", "#!/usr/bin/ruby3.3", true},
{"env", "#!/usr/bin/env ruby", true},
{"env split", "#!/usr/bin/env -S ruby -w", true},
{"env attached split", "#!/usr/bin/env -Sruby -w", true},
{"env long split", "#!/usr/bin/env --split-string=ruby -w", true},
{"env assignment", "#!/usr/bin/env -S RUBYOPT=-w ruby", true},
{"env unset", "#!/usr/bin/env -S -u RUBYOPT ruby", true},
{"env chdir", "#!/usr/bin/env -S -C /tmp ruby", true},
{"env flags", "#!/usr/bin/env -S -i -- ruby", true},
{"env long options", "#!/usr/bin/env -S --ignore-environment --unset=RUBYOPT --chdir=/tmp ruby", true},
{"shell argument", "#!/bin/sh ruby", false},
{"env shell argument", "#!/usr/bin/env -S sh ruby", false},
{"env unset operand", "#!/usr/bin/env -S -u ruby sh", false},
{"env chdir operand", "#!/usr/bin/env -S -C ruby sh", false},
{"env assignment value", "#!/usr/bin/env -S LANGUAGE=ruby sh", false},
{"env ended options", "#!/usr/bin/env -S -- -u ruby", false},
{"env missing operand", "#!/usr/bin/env -S -u ruby", false},
{"env unknown option", "#!/usr/bin/env --unknown ruby", false},
{"env help", "#!/usr/bin/env --help ruby", false},
{"env quoted operand", "#!/usr/bin/env -S -u 'x ruby y' sh", false},
{"env expansion", "#!/usr/bin/env -S ${INTERPRETER} ruby", false},
{"env comment operand", "#!/usr/bin/env -S -u # ruby", false},
{"env directory", "#!/usr/bin/env/ ruby", false},
{"ruby directory", "#!/usr/bin/ruby/", false},
{"lookalike", "#!/usr/bin/ruby-not-an-interpreter", false},
{"suffix letters", "#!/usr/bin/ruby3helper", false},
{"suffix trailing dot", "#!/usr/bin/ruby3.", false},
{"suffix double dot", "#!/usr/bin/ruby3..3", false},
{"empty", "#!", false},
{"not first line", "\n#!/usr/bin/ruby", false},
{"tabs and CRLF", "#!\t/usr/bin/env\t-S\truby\r", true},
} {
t.Run(tc.name, func(t *testing.T) {
root := t.TempDir()
src := []byte(tc.line + "\nrequire 'json'\n")
if err := os.WriteFile(filepath.Join(root, "tool"), src, 0700); err != nil {
t.Fatal(err)
}
if got := outline.SupportedSource(src, "tool"); got != tc.ruby {
t.Errorf("SupportedSource = %v, want %v", got, tc.ruby)
}
graph, err := outline.Build(root, outline.Options{})
if err != nil {
t.Fatal(err)
}
if graph.Node(outline.FileID("tool")) == nil {
t.Fatal("missing file node")
}
var loaded bool
for _, edge := range graph.Edges {
if edge.From == outline.FileID("tool") && edge.To == outline.ModID("ruby", "json") && edge.Rel == outline.RelLoads {
loaded = true
}
}
if loaded != tc.ruby {
t.Errorf("Ruby json load = %v, want %v", loaded, tc.ruby)
}
})
}
}
Loading