diff --git a/README.md b/README.md index 9cceed8..0e3ccb6 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/outline.go b/outline.go index 2d190a9..9523255 100644 --- a/outline.go +++ b/outline.go @@ -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 } diff --git a/shebang.go b/shebang.go new file mode 100644 index 0000000..6f27805 --- /dev/null +++ b/shebang.go @@ -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, "'\"\\$`") +} diff --git a/shebang_test.go b/shebang_test.go new file mode 100644 index 0000000..f694b74 --- /dev/null +++ b/shebang_test.go @@ -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) + } + }) + } +}