├── yamlfmt.yml ├── .gitignore ├── ui ├── config.go ├── msgs.go ├── utils.go ├── ui.go ├── cmds.go ├── model.go ├── initial.go ├── styles.go ├── view.go ├── update.go └── results.go ├── server ├── static │ └── img │ │ └── favicon.ico ├── html │ ├── partials │ │ └── nav.tmpl │ ├── pages │ │ └── home.tmpl │ └── base.tmpl └── server.go ├── examples ├── README.md ├── rust │ └── README.md ├── python │ └── README.md └── go │ └── README.md ├── .github ├── scripts │ └── run.sh ├── workflows │ ├── vulncheck.yml │ ├── release.yml │ ├── scan.yml │ ├── pr.yml │ └── main.yml └── dependabot.yml ├── main.go ├── tsutils ├── testcode │ ├── scala │ │ ├── objects.txt │ │ ├── funcs.txt │ │ └── classes.txt │ ├── python.txt │ ├── go.txt │ ├── rust │ │ ├── funcs.txt │ │ └── types.txt │ └── scala.txt ├── results.go ├── types.go ├── tspy_test.go ├── file_utils.go ├── tsutils.go ├── tspython.go ├── tsgo_test.go ├── query.go ├── tssscala_test.go ├── file_utils_test.go ├── tsrust.go ├── tsrust_test.go ├── tsgo.go └── tsscala.go ├── internal └── utils │ ├── loading_bars.go │ └── page.go ├── cmd ├── utils.go ├── utils_test.go └── root.go ├── Makefile ├── LICENSE ├── .goreleaser.yml ├── .golangci.yml ├── go.mod ├── README.md ├── go.sum └── filepicker └── filepicker.go /yamlfmt.yml: -------------------------------------------------------------------------------- 1 | formatter: 2 | retain_line_breaks_single: true 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dstll 2 | .quickrun 3 | cosign.key 4 | justfile 5 | dstll-output 6 | -------------------------------------------------------------------------------- /ui/config.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | type Config struct { 4 | ViewFileCmd []string 5 | } 6 | -------------------------------------------------------------------------------- /server/static/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhth/dstll/HEAD/server/static/img/favicon.ico -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # dstll 2 | 3 | This directory holds example outputs from `dstll` when run on various kinds 4 | of code files. 5 | 6 | - [go](./go) 7 | - [python](./python) 8 | - [rust](./rust) 9 | -------------------------------------------------------------------------------- /.github/scripts/run.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # shellcheck disable=SC2046 4 | dstll $(git ls-files '**/*.go' | head -n 10) -p 5 | dstll write $(git ls-files '**/*.go' | head -n 10) -o /var/tmp/dstll 6 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/dhth/dstll/cmd" 7 | ) 8 | 9 | func main() { 10 | err := cmd.Execute() 11 | if err != nil { 12 | os.Exit(1) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tsutils/testcode/scala/objects.txt: -------------------------------------------------------------------------------- 1 | // a simple object 2 | 3 | object HelloWorld { 4 | def main(args: Array[String]): Unit = { 5 | println("Hello, world!") 6 | } 7 | } 8 | 9 | object Container { 10 | def apply[T](value: T): Container[T] = new Container(value) 11 | } 12 | -------------------------------------------------------------------------------- /ui/msgs.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import "github.com/dhth/dstll/tsutils" 4 | 5 | type hideHelpMsg struct{} 6 | 7 | type FileRead struct { 8 | contents string 9 | err error 10 | } 11 | 12 | type FileResultsReceivedMsg struct { 13 | result tsutils.Result 14 | } 15 | 16 | type ViewFileFinishedmsg struct { 17 | filePath string 18 | err error 19 | } 20 | -------------------------------------------------------------------------------- /tsutils/results.go: -------------------------------------------------------------------------------- 1 | package tsutils 2 | 3 | func GetResults(fPaths []string) []Result { 4 | resultsChan := make(chan Result) 5 | results := make([]Result, len(fPaths)) 6 | 7 | for _, fPath := range fPaths { 8 | go GetLayout(resultsChan, fPath) 9 | } 10 | 11 | for i := range fPaths { 12 | r := <-resultsChan 13 | results[i] = r 14 | } 15 | 16 | return results 17 | } 18 | -------------------------------------------------------------------------------- /server/html/partials/nav.tmpl: -------------------------------------------------------------------------------- 1 | {{ define "nav" }} 2 | 11 | {{ end }} 12 | 13 | -------------------------------------------------------------------------------- /tsutils/types.go: -------------------------------------------------------------------------------- 1 | package tsutils 2 | 3 | const ( 4 | nodeTypeIdentifier = "identifier" 5 | nodeTypeModifiers = "modifiers" 6 | nodeTypeTypeParameters = "type_parameters" 7 | nodeTypeParameters = "parameters" 8 | ) 9 | 10 | type Result struct { 11 | FPath string 12 | Results []string 13 | Err error 14 | } 15 | type FileType uint 16 | 17 | const ( 18 | FTNone FileType = iota 19 | FTGo 20 | FTPython 21 | FTRust 22 | FTScala 23 | ) 24 | -------------------------------------------------------------------------------- /internal/utils/loading_bars.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | ) 7 | 8 | func Loader(delay time.Duration, done <-chan struct{}) { 9 | dots := []string{ 10 | "⠷", 11 | "⠯", 12 | "⠟", 13 | "⠻", 14 | "⠽", 15 | "⠾", 16 | } 17 | for { 18 | select { 19 | case <-done: 20 | fmt.Print("\r") 21 | return 22 | default: 23 | for _, r := range dots { 24 | fmt.Printf("\rfetching %s ", r) 25 | time.Sleep(delay) 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /ui/utils.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | func RightPadTrim(s string, length int) string { 8 | if len(s) >= length { 9 | if length > 3 { 10 | return s[:length-3] + "..." 11 | } 12 | return s[:length] 13 | } 14 | return s + strings.Repeat(" ", length-len(s)) 15 | } 16 | 17 | func Trim(s string, length int) string { 18 | if len(s) >= length { 19 | if length > 3 { 20 | return s[:length-3] + "..." 21 | } 22 | return s[:length] 23 | } 24 | return s 25 | } 26 | -------------------------------------------------------------------------------- /.github/workflows/vulncheck.yml: -------------------------------------------------------------------------------- 1 | name: vulncheck 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: '0 2 * * 2,6' 7 | 8 | jobs: 9 | vulncheck: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v6 13 | - name: Set up Go 14 | uses: actions/setup-go@v6 15 | with: 16 | go-version-file: 'go.mod' 17 | - name: govulncheck 18 | run: | 19 | go install golang.org/x/vuln/cmd/govulncheck@latest 20 | govulncheck ./... 21 | -------------------------------------------------------------------------------- /internal/utils/page.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "errors" 4 | 5 | var ErrPageExceedsBounds = errors.New("page value exceeds bounds") 6 | 7 | func GetIndexRange(page, total, perPage int) (int, int, error) { 8 | if page < 1 { 9 | return -1, -1, ErrPageExceedsBounds 10 | } 11 | start := (page - 1) * perPage 12 | if start >= total { 13 | return -1, -1, ErrPageExceedsBounds 14 | } 15 | 16 | end := start + perPage 17 | if end >= total { 18 | return start, total, nil 19 | } 20 | return start, end, nil 21 | } 22 | -------------------------------------------------------------------------------- /cmd/utils.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | ) 9 | 10 | func expandTilde(path string, homeDir string) string { 11 | pathWithoutTilde, found := strings.CutPrefix(path, "~/") 12 | if !found { 13 | return path 14 | } 15 | return filepath.Join(homeDir, pathWithoutTilde) 16 | } 17 | 18 | func createDir(path string) error { 19 | _, err := os.Stat(path) 20 | if errors.Is(err, os.ErrNotExist) { 21 | mkDirErr := os.MkdirAll(path, 0o755) 22 | if mkDirErr != nil { 23 | return mkDirErr 24 | } 25 | } 26 | return nil 27 | } 28 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | groups: 8 | patch-updates: 9 | update-types: ["patch"] 10 | minor-updates: 11 | update-types: ["minor"] 12 | labels: 13 | - "dependencies" 14 | commit-message: 15 | prefix: "build" 16 | - package-ecosystem: "github-actions" 17 | directory: "/" 18 | schedule: 19 | interval: "monthly" 20 | labels: 21 | - "dependencies" 22 | commit-message: 23 | prefix: "ci" 24 | -------------------------------------------------------------------------------- /tsutils/tspy_test.go: -------------------------------------------------------------------------------- 1 | package tsutils 2 | 3 | import ( 4 | _ "embed" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | //go:embed testcode/python.txt 12 | var pyCode []byte 13 | 14 | func TestGetPyData(t *testing.T) { 15 | expected := []string{ 16 | "def say_something()", 17 | "def say_something(x)", 18 | "def say_something(x: str, y: str)", 19 | "def say_something() -> str", 20 | "def say_something(x: str, y: str) -> str", 21 | } 22 | 23 | got, err := getPyData(pyCode) 24 | 25 | require.NoError(t, err) 26 | assert.Equal(t, expected, got) 27 | } 28 | -------------------------------------------------------------------------------- /ui/ui.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | 8 | tea "github.com/charmbracelet/bubbletea" 9 | ) 10 | 11 | var errFailedToConfigureDebugging = errors.New("failed to configure debugging") 12 | 13 | func RenderUI(config Config) error { 14 | if len(os.Getenv("DEBUG")) > 0 { 15 | f, err := tea.LogToFile("debug.log", "debug") 16 | if err != nil { 17 | return fmt.Errorf("%w: %s", errFailedToConfigureDebugging, err.Error()) 18 | } 19 | defer f.Close() 20 | } 21 | 22 | p := tea.NewProgram(InitialModel(config), tea.WithAltScreen()) 23 | _, err := p.Run() 24 | if err != nil { 25 | return err 26 | } 27 | return nil 28 | } 29 | -------------------------------------------------------------------------------- /tsutils/testcode/python.txt: -------------------------------------------------------------------------------- 1 | # ==== FUNCS ==== 2 | 3 | # a function with no parameters or return type hint 4 | def say_something(): 5 | print("hola") 6 | 7 | # function with parameters but no return type hint 8 | def say_something(x): 9 | print("hola " + x) 10 | 11 | # a function with parameters with types but no return type hint 12 | def say_something(x: str, y: str): 13 | print(f"hola, {x} and {y}") 14 | 15 | # a function with return type hint but no parameters 16 | def say_something() -> str: 17 | return "hola" 18 | 19 | # a function with parameters and return type hints 20 | def say_something(x: str, y: str) -> str: 21 | return f"hola, {x} and {y}" 22 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | permissions: 9 | id-token: write 10 | 11 | jobs: 12 | release: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v6 16 | with: 17 | fetch-depth: 0 18 | - name: Set up Go 19 | uses: actions/setup-go@v6 20 | with: 21 | go-version-file: 'go.mod' 22 | - name: Build 23 | run: go build -v ./... 24 | - name: Test 25 | run: go test -v ./... 26 | - name: Release 27 | env: 28 | GITHUB_TOKEN: ${{secrets.GH_PAT}} 29 | run: | 30 | make release 31 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PACKAGE_NAME := github.com/dhth/dstll 2 | DOCKER_IMAGE = ghcr.io/goreleaser/goreleaser-cross:v1.25.0 3 | 4 | .PHONY: release-dry-run 5 | release-dry-run: 6 | @docker run \ 7 | --rm \ 8 | -e CGO_ENABLED=1 \ 9 | -v /var/run/docker.sock:/var/run/docker.sock \ 10 | -v `pwd`:/go/src/$(PACKAGE_NAME) \ 11 | -w /go/src/$(PACKAGE_NAME) \ 12 | $(DOCKER_IMAGE) \ 13 | build --clean --snapshot 14 | 15 | .PHONY: release 16 | release: 17 | @docker run \ 18 | --rm \ 19 | -e CGO_ENABLED=1 \ 20 | -e GITHUB_TOKEN=${GITHUB_TOKEN} \ 21 | -v /var/run/docker.sock:/var/run/docker.sock \ 22 | -v `pwd`:/go/src/$(PACKAGE_NAME) \ 23 | -w /go/src/$(PACKAGE_NAME) \ 24 | $(DOCKER_IMAGE) \ 25 | release --clean 26 | -------------------------------------------------------------------------------- /.github/workflows/scan.yml: -------------------------------------------------------------------------------- 1 | name: scan 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: '0 18 10 * *' 7 | 8 | jobs: 9 | virus-total: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v6 13 | - name: Set up Go 14 | uses: actions/setup-go@v6 15 | with: 16 | go-version-file: 'go.mod' 17 | - name: Build Binaries 18 | run: | 19 | make release-dry-run 20 | - name: List binaries 21 | run: | 22 | ls -lh ./dist/*/dstll 23 | - uses: dhth/composite-actions/.github/actions/scan-files@main 24 | with: 25 | files: './dist/*/dstll' 26 | vt-api-key: ${{ secrets.VT_API_KEY }} 27 | -------------------------------------------------------------------------------- /server/html/pages/home.tmpl: -------------------------------------------------------------------------------- 1 | {{define "title"}}Home{{end}} 2 | {{define "main"}} 3 |
4 |

Displaying {{ .NumFiles }} files on this page.

5 |
6 |
7 | {{range $fPath,$elements := .Results -}} 8 |
9 |

{{ $fPath }}

10 | {{range $elements -}} 11 |
12 | {{ . }} 13 |
14 | {{end}} 15 |
16 | {{end}} 17 |
18 | {{end}} 19 | -------------------------------------------------------------------------------- /server/html/base.tmpl: -------------------------------------------------------------------------------- 1 | {{define "base"}} 2 | 3 | 4 | 5 | 6 | dstll 7 | 8 | 9 | 10 |
11 |
12 |
13 |

dstll

14 |
15 |
16 | {{template "main" .}} 17 |
18 | {{template "nav" .}} 19 |
20 |
21 | 22 | 23 | {{end}} 24 | -------------------------------------------------------------------------------- /cmd/utils_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestExpandTilde(t *testing.T) { 10 | testCases := []struct { 11 | name string 12 | path string 13 | homeDir string 14 | expected string 15 | }{ 16 | { 17 | name: "a simple case", 18 | path: "~/some/path", 19 | homeDir: "/Users/trinity", 20 | expected: "/Users/trinity/some/path", 21 | }, 22 | { 23 | name: "path with no ~", 24 | path: "some/path", 25 | homeDir: "/Users/trinity", 26 | expected: "some/path", 27 | }, 28 | } 29 | 30 | for _, tt := range testCases { 31 | t.Run(tt.name, func(t *testing.T) { 32 | got := expandTilde(tt.path, tt.homeDir) 33 | 34 | assert.Equal(t, tt.expected, got) 35 | }) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /ui/cmds.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "os/exec" 5 | "time" 6 | 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/dhth/dstll/tsutils" 9 | ) 10 | 11 | func openFile(filePath string, cmd []string) tea.Cmd { 12 | openCmd := append(cmd, filePath) 13 | c := exec.Command(openCmd[0], openCmd[1:]...) 14 | return tea.ExecProcess(c, func(err error) tea.Msg { 15 | return ViewFileFinishedmsg{filePath, err} 16 | }) 17 | } 18 | 19 | func getFileResults(filePath string) tea.Cmd { 20 | return func() tea.Msg { 21 | resultsChan := make(chan tsutils.Result) 22 | go tsutils.GetLayout(resultsChan, filePath) 23 | 24 | result := <-resultsChan 25 | 26 | return FileResultsReceivedMsg{result} 27 | } 28 | } 29 | 30 | func hideHelp(interval time.Duration) tea.Cmd { 31 | return tea.Tick(interval, func(time.Time) tea.Msg { 32 | return hideHelpMsg{} 33 | }) 34 | } 35 | -------------------------------------------------------------------------------- /tsutils/file_utils.go: -------------------------------------------------------------------------------- 1 | package tsutils 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | ) 7 | 8 | var ( 9 | ErrFilePathIncorrect = errors.New("file path incorrect") 10 | ErrFileNameIncorrect = errors.New("file name incorrect") 11 | ) 12 | 13 | func getFileExtension(filePath string) (FileType, error) { 14 | fPathEls := strings.Split(filePath, ".") 15 | if len(fPathEls) < 2 { 16 | return FTNone, ErrFilePathIncorrect 17 | } 18 | var ft FileType 19 | switch fPathEls[len(fPathEls)-1] { 20 | case "go": 21 | ft = FTGo 22 | case "py": 23 | ft = FTPython 24 | case "rs": 25 | ft = FTRust 26 | case "scala": 27 | ft = FTScala 28 | default: 29 | return FTNone, ErrFilePathIncorrect 30 | } 31 | 32 | fNameEls := strings.Split(filePath, "/") 33 | if strings.Split(fNameEls[len(fNameEls)-1], ".")[0] == "" { 34 | return FTNone, ErrFileNameIncorrect 35 | } 36 | 37 | return ft, nil 38 | } 39 | -------------------------------------------------------------------------------- /tsutils/tsutils.go: -------------------------------------------------------------------------------- 1 | package tsutils 2 | 3 | import ( 4 | "context" 5 | 6 | ts "github.com/smacker/go-tree-sitter" 7 | ) 8 | 9 | func getGenericResult(fContent []byte, query string, language *ts.Language) ([]string, error) { 10 | parser := ts.NewParser() 11 | parser.SetLanguage(language) 12 | 13 | tree, err := parser.ParseCtx(context.Background(), nil, fContent) 14 | if err != nil { 15 | return nil, err 16 | } 17 | 18 | rootNode := tree.RootNode() 19 | 20 | q, err := ts.NewQuery([]byte(query), language) 21 | if err != nil { 22 | return nil, err 23 | } 24 | 25 | qc := ts.NewQueryCursor() 26 | 27 | qc.Exec(q, rootNode) 28 | 29 | var elements []string 30 | 31 | var result string 32 | for { 33 | tMatch, cOk := qc.NextMatch() 34 | if !cOk { 35 | break 36 | } 37 | if len(tMatch.Captures) != 1 { 38 | continue 39 | } 40 | result = tMatch.Captures[0].Node.Content(fContent) 41 | 42 | elements = append(elements, result) 43 | } 44 | return elements, nil 45 | } 46 | -------------------------------------------------------------------------------- /ui/model.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/charmbracelet/bubbles/viewport" 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/dhth/dstll/filepicker" 9 | ) 10 | 11 | type Pane uint 12 | 13 | const ( 14 | fileExplorerPane Pane = iota 15 | resultPane 16 | ) 17 | 18 | type Model struct { 19 | config Config 20 | resultVP viewport.Model 21 | resultVPReady bool 22 | resultsCache map[string]string 23 | filepicker filepicker.Model 24 | selectedFile string 25 | quitting bool 26 | activePane Pane 27 | terminalHeight int 28 | terminalWidth int 29 | message string 30 | showHelp bool 31 | noConstructsMsg string 32 | supportedFileTypes []string 33 | unsupportedFileMsg string 34 | fileExplorerPaneWidth int 35 | } 36 | 37 | func (m Model) Init() tea.Cmd { 38 | return tea.Batch( 39 | hideHelp(time.Minute*1), 40 | m.filepicker.Init(), 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /tsutils/testcode/scala/funcs.txt: -------------------------------------------------------------------------------- 1 | // a simple function 2 | def method1(): Unit = { 3 | println("Function 1") 4 | } 5 | 6 | // a function with arguments 7 | def method1(arg1: String, num: Int): Unit = { 8 | for (i <- 1 to num) { 9 | println(arg1) 10 | } 11 | } 12 | 13 | // a function with override modifier 14 | override def method1(arg1: String, num: Int): Unit = { 15 | for (i <- 1 to num) { 16 | println(arg1) 17 | } 18 | } 19 | 20 | // a function with private modifier 21 | private def method1(arg1: String, num: Int): Unit = { 22 | for (i <- 1 to num) { 23 | println(arg1) 24 | } 25 | } 26 | 27 | // a function with two modifiers 28 | override protected def method1(arg1: String, num: Int): Unit = { 29 | for (i <- 1 to num) { 30 | println(arg1) 31 | } 32 | } 33 | 34 | def pair[A, B](first: A, second: B): (A, B) = (first, second) 35 | 36 | def max[T <: Ordered[T]](list: List[T]): T = list.max 37 | 38 | def mapContainer[F[_], A, B](container: F[A])(func: A => B)(implicit functor: Functor[F]): F[B] = { 39 | functor.map(container)(func) 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Dhruv Thakur 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tsutils/testcode/scala/classes.txt: -------------------------------------------------------------------------------- 1 | // a simple class with 2 functions 2 | class MyClass { 3 | def classMethod1(): Unit = { 4 | println("Function 1") 5 | } 6 | } 7 | 8 | // a class with modifiers 9 | sealed abstract class KafkaConsumer {} 10 | 11 | // a class with modifiers and type params 12 | sealed abstract class Signature[+T] { self => 13 | final def show: String = mergeShow(new StringBuilder(30)).toString 14 | } 15 | 16 | // class within a class 17 | class OuterClass { 18 | class InnerClass { 19 | } 20 | } 21 | 22 | // a simple class with class parameters 23 | class MyClass(val name: String, val age: Int) { 24 | def greet(): Unit = { 25 | println(s"Hello, my name is $name and I am $age years old.") 26 | } 27 | } 28 | 29 | // a simple class with class parameters and an extends clause 30 | class MyExtendedClass(name: String, age: Int, val occupation: String) extends MyClass(name, age) { 31 | def introduce(): Unit = { 32 | println(s"I am a $occupation.") 33 | } 34 | } 35 | 36 | class MyHealthCheck extends HealthCheck[IO]("my-health-check") { 37 | override def check(): IO[HealthCheck.Result] = logger.info(l"Health Check") *> IO.pure(HealthCheck.Result.Healthy) 38 | } 39 | -------------------------------------------------------------------------------- /ui/initial.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/charmbracelet/lipgloss" 7 | "github.com/dhth/dstll/filepicker" 8 | ) 9 | 10 | func InitialModel(config Config) Model { 11 | fp := filepicker.New() 12 | supportedFT := []string{ 13 | ".go", 14 | ".py", 15 | ".rs", 16 | ".scala", 17 | } 18 | 19 | unsupportedFTMsg := "dstll will show constructs for the following file types:\n" 20 | for _, ft := range supportedFT { 21 | unsupportedFTMsg += fmt.Sprintf("%s\n", ft) 22 | } 23 | 24 | fpWidth := 40 25 | 26 | fp.AllowedTypes = supportedFT 27 | fp.AutoHeight = false 28 | fp.Width = fpWidth 29 | fp.Styles.Selected = fp.Styles.Selected.Foreground(lipgloss.Color(ActiveHeaderColor)) 30 | fp.Styles.Cursor = fp.Styles.Cursor.Foreground(lipgloss.Color(ActiveHeaderColor)) 31 | fp.Styles.DisabledFile = fp.Styles.DisabledFile.Foreground(lipgloss.Color(DisabledFileColor)) 32 | fp.Styles.Directory = fp.Styles.Directory.Foreground(lipgloss.Color(DirectoryColor)) 33 | 34 | m := Model{ 35 | config: config, 36 | filepicker: fp, 37 | resultsCache: make(map[string]string), 38 | noConstructsMsg: "No constructs found", 39 | supportedFileTypes: supportedFT, 40 | unsupportedFileMsg: unsupportedFTMsg, 41 | fileExplorerPaneWidth: fpWidth, 42 | showHelp: true, 43 | } 44 | 45 | return m 46 | } 47 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | release: 4 | draft: true 5 | 6 | before: 7 | hooks: 8 | - go mod tidy 9 | 10 | builds: 11 | - id: darwin-amd64 12 | main: ./ 13 | binary: dstll 14 | goarch: 15 | - amd64 16 | goos: 17 | - darwin 18 | env: 19 | - CC=o64-clang 20 | - CXX=o64-clang++ 21 | flags: 22 | - -trimpath 23 | 24 | - id: darwin-arm64 25 | main: ./ 26 | binary: dstll 27 | goarch: 28 | - arm64 29 | goos: 30 | - darwin 31 | env: 32 | - CC=oa64-clang 33 | - CXX=oa64-clang++ 34 | flags: 35 | - -trimpath 36 | 37 | - id: linux-amd64 38 | main: ./ 39 | binary: dstll 40 | goarch: 41 | - amd64 42 | goos: 43 | - linux 44 | env: 45 | - CC=x86_64-linux-gnu-gcc 46 | - CXX=x86_64-linux-gnu-g++ 47 | flags: 48 | - -trimpath 49 | 50 | - id: linux-arm64 51 | main: ./ 52 | binary: dstll 53 | goarch: 54 | - arm64 55 | goos: 56 | - linux 57 | env: 58 | - CC=aarch64-linux-gnu-gcc 59 | - CXX=aarch64-linux-gnu-g++ 60 | flags: 61 | - -trimpath 62 | 63 | brews: 64 | - name: dstll 65 | repository: 66 | owner: dhth 67 | name: homebrew-tap 68 | directory: Formula 69 | license: MIT 70 | homepage: "https://github.com/dhth/dstll" 71 | description: "dstll gives you a high level overview of code constructs" 72 | 73 | changelog: 74 | sort: asc 75 | filters: 76 | exclude: 77 | - "^docs:" 78 | - "^test:" 79 | - "^ci:" 80 | -------------------------------------------------------------------------------- /tsutils/tspython.go: -------------------------------------------------------------------------------- 1 | package tsutils 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | ts "github.com/smacker/go-tree-sitter" 8 | tspy "github.com/smacker/go-tree-sitter/python" 9 | ) 10 | 11 | func getPyData(fContent []byte) ([]string, error) { 12 | parser := ts.NewParser() 13 | parser.SetLanguage(tspy.GetLanguage()) 14 | 15 | tree, err := parser.ParseCtx(context.Background(), nil, fContent) 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | rootNode := tree.RootNode() 21 | q, err := ts.NewQuery([]byte(` 22 | (function_definition 23 | name: (identifier) @name 24 | parameters: (parameters)? @params 25 | return_type: (_)? @return-type 26 | ) 27 | `), tspy.GetLanguage()) 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | qc := ts.NewQueryCursor() 33 | 34 | qc.Exec(q, rootNode) 35 | 36 | var elements []string 37 | 38 | for { 39 | fMatch, cOk := qc.NextMatch() 40 | if !cOk { 41 | break 42 | } 43 | 44 | var fName string 45 | var fParams string 46 | var fReturnT string 47 | var fMatchedNode *ts.Node 48 | 49 | for _, capture := range fMatch.Captures { 50 | fMatchedNode = capture.Node 51 | 52 | switch fMatchedNode.Type() { 53 | case nodeTypeIdentifier: 54 | fName = fMatchedNode.Content(fContent) 55 | case nodeTypeParameters: 56 | fParams = fMatchedNode.Content(fContent) 57 | default: 58 | // TODO: This is not the best way to get the return type; find a better way 59 | fReturnT = " -> " + fMatchedNode.Content(fContent) 60 | } 61 | } 62 | 63 | elem := fmt.Sprintf("def %s%s%s", fName, fParams, fReturnT) 64 | 65 | elements = append(elements, elem) 66 | } 67 | return elements, nil 68 | } 69 | -------------------------------------------------------------------------------- /tsutils/testcode/go.txt: -------------------------------------------------------------------------------- 1 | // ==== TYPES ==== 2 | 3 | // a simple type 4 | type MyInt int 5 | 6 | // a struct 7 | type Person struct { 8 | Name string 9 | Age int 10 | } 11 | 12 | // an interface 13 | type Shape interface { 14 | Area() float64 15 | Perimeter() float64 16 | } 17 | 18 | // ==== FUNCS ==== 19 | 20 | // a function with no parameters 21 | func saySomething() { 22 | fmt.Println("hola") 23 | } 24 | 25 | // a function with parameters 26 | func saySomething(name string) { 27 | fmt.Printf("hola, %s", name) 28 | } 29 | 30 | // a function with parameters and a return type 31 | func saySomething(name string) string { 32 | return fmt.Sprintf("hola, %s", name) 33 | } 34 | 35 | // a function with multiple return type 36 | func getGenericResult(fContent []byte, query string, language *ts.Language) ([]string, error) { 37 | return nil, nil 38 | } 39 | 40 | // a function using type parameters 41 | func Clone[S ~[]E, E any](s S) S { 42 | return append(s[:0:0], s...) 43 | } 44 | 45 | // === METHODS === 46 | 47 | // a method with no parameters 48 | func (p person) saySomething() { 49 | fmt.Printf("%s says, hola", p.name) 50 | } 51 | 52 | // a method with parameters 53 | func (p person) saySomething(say string) { 54 | fmt.Printf("%s says, %s", p.name, say) 55 | } 56 | 57 | // a method with parameters and a return type 58 | func (p person) saySomething(say string) string { 59 | return fmt.Sprintf("%s says %s", p.name, say) 60 | } 61 | 62 | // a method with parameters and a multiple return type 63 | func (p person) saySomething(say string) (string, error) { 64 | return fmt.Sprintf("%s says %s", p.name, say), nil 65 | } 66 | 67 | // a method with type parameters and a return type 68 | func (s *slice[E, V]) Map(doSomething func(E) V) ([]E, error) { 69 | return nil, nil 70 | } 71 | -------------------------------------------------------------------------------- /tsutils/tsgo_test.go: -------------------------------------------------------------------------------- 1 | package tsutils 2 | 3 | import ( 4 | _ "embed" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | //go:embed testcode/go.txt 12 | var goCode []byte 13 | 14 | func TestGetGoFuncs(t *testing.T) { 15 | expected := []string{ 16 | "func saySomething()", 17 | "func saySomething(name string)", 18 | "func saySomething(name string) string", 19 | "func getGenericResult(fContent []byte, query string, language *ts.Language) ([]string, error)", 20 | "func Clone[S ~[]E, E any](s S) S", 21 | } 22 | resultChan := make(chan Result) 23 | go getGoFuncs(resultChan, goCode) 24 | 25 | got := <-resultChan 26 | 27 | require.NoError(t, got.Err) 28 | assert.Equal(t, expected, got.Results) 29 | } 30 | 31 | func TestGetGoTypes(t *testing.T) { 32 | expected := []string{ 33 | "type MyInt int", 34 | `type Person struct { 35 | Name string 36 | Age int 37 | }`, 38 | `type Shape interface { 39 | Area() float64 40 | Perimeter() float64 41 | }`, 42 | } 43 | 44 | resultChan := make(chan Result) 45 | go getGoTypes(resultChan, goCode) 46 | 47 | got := <-resultChan 48 | 49 | require.NoError(t, got.Err) 50 | assert.Equal(t, expected, got.Results) 51 | } 52 | 53 | func TestGetGoMethods(t *testing.T) { 54 | expected := []string{ 55 | "func (p person) saySomething()", 56 | "func (p person) saySomething(say string)", 57 | "func (p person) saySomething(say string) string", 58 | "func (p person) saySomething(say string) (string, error)", 59 | "func (s *slice[E, V]) Map(doSomething func(E) V) ([]E, error)", 60 | } 61 | 62 | resultChan := make(chan Result) 63 | go getGoMethods(resultChan, goCode) 64 | 65 | got := <-resultChan 66 | 67 | require.NoError(t, got.Err) 68 | assert.Equal(t, expected, got.Results) 69 | } 70 | -------------------------------------------------------------------------------- /tsutils/testcode/rust/funcs.txt: -------------------------------------------------------------------------------- 1 | // A basic function 2 | fn function_name() { 3 | println!("This is a basic function."); 4 | } 5 | 6 | // Function with generics 7 | fn generic_function(value: T) -> T { 8 | value 9 | } 10 | 11 | #[inline] 12 | pub fn public_function() -> i32 { 13 | 42 14 | } 15 | 16 | pub(crate) fn crate_function() -> i32 { 17 | 42 18 | } 19 | 20 | pub(super) fn parent_function() -> i32 { 21 | 42 22 | } 23 | 24 | pub(in crate::some_module) fn specific_module_function() -> i32 { 25 | 42 26 | } 27 | 28 | // Implementation block for the Table struct 29 | impl Table { 30 | // Associated function 31 | fn new(field1: i32, field2: String) -> Self { 32 | Table { field1, field2 } 33 | } 34 | 35 | // Method with explicit lifetimes 36 | fn apply_to<'a>(&'a self, data: &'a mut Table) -> &'a mut Table { 37 | data.field1 += self.field1; 38 | data.field2.push_str(&self.field2); 39 | data 40 | } 41 | 42 | // Method 43 | fn update(&mut self, field1: i32) { 44 | self.field1 = field1; 45 | } 46 | } 47 | 48 | // Implementation of a trait for a struct 49 | impl Drawable for Table { 50 | fn draw(&self) { 51 | println!( 52 | "Drawing Table with field1: {} and field2: {}", 53 | self.field1, self.field2 54 | ); 55 | } 56 | } 57 | 58 | // Implementation of a trait with generics for a struct 59 | impl Resizable for GenericStruct { 60 | fn resize(&mut self, value: T) { 61 | self.field = value; 62 | } 63 | } 64 | 65 | // Implementation block for a struct with generics 66 | impl GenericStruct { 67 | // Associated function 68 | fn new(field: T) -> Self { 69 | GenericStruct { field } 70 | } 71 | 72 | // Method 73 | fn get_field(&self) -> &T { 74 | &self.field 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /tsutils/query.go: -------------------------------------------------------------------------------- 1 | package tsutils 2 | 3 | import "os" 4 | 5 | func GetLayout(resultsChan chan<- Result, filePath string) { 6 | ext, err := getFileExtension(filePath) 7 | if err != nil { 8 | resultsChan <- Result{FPath: filePath, Err: err} 9 | return 10 | } 11 | 12 | fContent, err := os.ReadFile(filePath) 13 | if err != nil { 14 | resultsChan <- Result{FPath: filePath, Err: err} 15 | return 16 | } 17 | 18 | var elements []string 19 | switch ext { 20 | case FTGo: 21 | typeChan := make(chan Result) 22 | fnChan := make(chan Result) 23 | methodChan := make(chan Result) 24 | chans := []chan Result{typeChan, fnChan, methodChan} 25 | 26 | go getGoTypes(typeChan, fContent) 27 | go getGoFuncs(fnChan, fContent) 28 | go getGoMethods(methodChan, fContent) 29 | 30 | for _, ch := range chans { 31 | r := <-ch 32 | if r.Err == nil { 33 | elements = append(elements, r.Results...) 34 | } 35 | } 36 | case FTPython: 37 | elements, err = getPyData(fContent) 38 | case FTRust: 39 | typesChan := make(chan Result) 40 | fnChan := make(chan Result) 41 | chans := []chan Result{typesChan, fnChan} 42 | 43 | go getRustTypes(typesChan, fContent) 44 | go getRustFuncs(fnChan, fContent) 45 | 46 | for _, ch := range chans { 47 | r := <-ch 48 | if r.Err == nil { 49 | elements = append(elements, r.Results...) 50 | } 51 | } 52 | case FTScala: 53 | objectChan := make(chan Result) 54 | classChan := make(chan Result) 55 | fnChan := make(chan Result) 56 | 57 | chans := []chan Result{objectChan, classChan, fnChan} 58 | go getScalaObjects(objectChan, fContent) 59 | go getScalaClasses(classChan, fContent) 60 | go getScalaFunctions(fnChan, fContent) 61 | 62 | for _, ch := range chans { 63 | r := <-ch 64 | if r.Err == nil { 65 | elements = append(elements, r.Results...) 66 | } 67 | } 68 | default: 69 | return 70 | } 71 | if err != nil { 72 | resultsChan <- Result{FPath: filePath, Err: err} 73 | } else { 74 | resultsChan <- Result{FPath: filePath, Results: elements} 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /ui/styles.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "github.com/charmbracelet/lipgloss" 5 | ) 6 | 7 | const ( 8 | ActiveHeaderColor = "#fc5fa3" 9 | InactivePaneColor = "#d0a8ff" 10 | DisabledFileColor = "#6c7986" 11 | DirectoryColor = "#41a1c0" 12 | NoConstructsColor = "#fabd2f" 13 | UnsupportedFileColor = "#928374" 14 | CWDColor = "#d0a8ff" 15 | FilepathColor = "#fc5fa3" 16 | FilepathColorTUI = "#ffd166" 17 | TSElementColor = "#41a1c0" 18 | DividerColor = "#6c7986" 19 | DefaultForegroundColor = "#282828" 20 | ModeColor = "#fc5fa3" 21 | HelpMsgColor = "#83a598" 22 | FooterColor = "#7c6f64" 23 | HTMLBackgroundColor = "#1f1f24" 24 | ) 25 | 26 | var ( 27 | filePathStyle = lipgloss.NewStyle(). 28 | Foreground(lipgloss.Color(FilepathColor)) 29 | 30 | filePathStyleTUI = lipgloss.NewStyle(). 31 | Foreground(lipgloss.Color(FilepathColorTUI)) 32 | 33 | tsElementStyle = lipgloss.NewStyle(). 34 | Foreground(lipgloss.Color(TSElementColor)) 35 | 36 | dividerStyle = lipgloss.NewStyle(). 37 | Foreground(lipgloss.Color(DividerColor)) 38 | 39 | baseStyle = lipgloss.NewStyle(). 40 | PaddingLeft(1). 41 | PaddingRight(1). 42 | Foreground(lipgloss.Color(DefaultForegroundColor)) 43 | 44 | fileExplorerStyle = lipgloss.NewStyle(). 45 | Width(45). 46 | PaddingRight(2). 47 | PaddingBottom(1) 48 | 49 | activePaneHeaderStyle = baseStyle. 50 | Align(lipgloss.Left). 51 | Bold(true). 52 | Background(lipgloss.Color(ActiveHeaderColor)) 53 | 54 | inActivePaneHeaderStyle = activePaneHeaderStyle. 55 | Background(lipgloss.Color(InactivePaneColor)) 56 | 57 | unsupportedFileStyle = lipgloss.NewStyle(). 58 | Foreground(lipgloss.Color(UnsupportedFileColor)) 59 | 60 | cwdStyle = baseStyle. 61 | PaddingRight(0). 62 | Foreground(lipgloss.Color(CWDColor)) 63 | 64 | modeStyle = baseStyle. 65 | Align(lipgloss.Center). 66 | Bold(true). 67 | Background(lipgloss.Color(ModeColor)) 68 | 69 | helpMsgStyle = baseStyle. 70 | Bold(true). 71 | Foreground(lipgloss.Color(HelpMsgColor)) 72 | ) 73 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | enable: 4 | - errname 5 | - errorlint 6 | - goconst 7 | - nilerr 8 | - prealloc 9 | - predeclared 10 | - revive 11 | - rowserrcheck 12 | - sqlclosecheck 13 | - testifylint 14 | - thelper 15 | - unconvert 16 | - usestdlibvars 17 | - wastedassign 18 | settings: 19 | revive: 20 | rules: 21 | - name: blank-imports 22 | - name: context-as-argument 23 | arguments: 24 | - allowTypesBefore: '*testing.T' 25 | - name: context-keys-type 26 | - name: dot-imports 27 | - name: empty-block 28 | - name: error-naming 29 | - name: error-return 30 | - name: error-strings 31 | - name: errorf 32 | - name: exported 33 | - name: if-return 34 | - name: increment-decrement 35 | - name: indent-error-flow 36 | - name: package-comments 37 | - name: range 38 | - name: receiver-naming 39 | - name: redefines-builtin-id 40 | - name: superfluous-else 41 | - name: time-naming 42 | - name: unexported-return 43 | - name: unreachable-code 44 | - name: unused-parameter 45 | - name: var-declaration 46 | - name: unnecessary-stmt 47 | - name: deep-exit 48 | - name: confusing-naming 49 | - name: unused-receiver 50 | - name: unhandled-error 51 | arguments: 52 | - fmt.Print 53 | - fmt.Println 54 | - fmt.Fprint 55 | - fmt.Printf 56 | - fmt.Fprintf 57 | - fmt.Fprint 58 | - strings.Builder.WriteRune 59 | - strings.Builder.WriteString 60 | exclusions: 61 | generated: lax 62 | presets: 63 | - comments 64 | - common-false-positives 65 | - legacy 66 | - std-error-handling 67 | paths: 68 | - third_party$ 69 | - builtin$ 70 | - examples$ 71 | formatters: 72 | enable: 73 | - gofumpt 74 | exclusions: 75 | generated: lax 76 | paths: 77 | - third_party$ 78 | - builtin$ 79 | - examples$ 80 | -------------------------------------------------------------------------------- /tsutils/tssscala_test.go: -------------------------------------------------------------------------------- 1 | package tsutils 2 | 3 | import ( 4 | _ "embed" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | var ( 12 | //go:embed testcode/scala/funcs.txt 13 | scalaCodeFuncs []byte 14 | //go:embed testcode/scala/classes.txt 15 | scalaCodeClasses []byte 16 | //go:embed testcode/scala/objects.txt 17 | scalaCodeObjects []byte 18 | ) 19 | 20 | func TestGetScalaFunctions(t *testing.T) { 21 | expected := []string{ 22 | "def method1(): Unit", 23 | "def method1(arg1: String, num: Int): Unit", 24 | "override def method1(arg1: String, num: Int): Unit", 25 | "private def method1(arg1: String, num: Int): Unit", 26 | "override protected def method1(arg1: String, num: Int): Unit", 27 | "def pair[A, B](first: A, second: B): (A, B)", 28 | "def max[T <: Ordered[T]](list: List[T]): T", 29 | "def mapContainer[F[_], A, B](container: F[A])(func: A => B)(implicit functor: Functor[F]): F[B]", 30 | } 31 | 32 | resultChan := make(chan Result) 33 | go getScalaFunctions(resultChan, scalaCodeFuncs) 34 | 35 | got := <-resultChan 36 | 37 | require.NoError(t, got.Err) 38 | assert.Equal(t, expected, got.Results) 39 | } 40 | 41 | func TestGetScalaClasses(t *testing.T) { 42 | expected := []string{ 43 | "class MyClass", 44 | "sealed abstract class KafkaConsumer", 45 | "sealed abstract class Signature[+T]", 46 | "class OuterClass", 47 | "class InnerClass", 48 | "class MyClass(val name: String, val age: Int)", 49 | "class MyExtendedClass(name: String, age: Int, val occupation: String) extends MyClass(name, age)", 50 | `class MyHealthCheck extends HealthCheck[IO]("my-health-check")`, 51 | } 52 | 53 | resultChan := make(chan Result) 54 | go getScalaClasses(resultChan, scalaCodeClasses) 55 | 56 | got := <-resultChan 57 | require.NoError(t, got.Err) 58 | assert.Equal(t, expected, got.Results) 59 | } 60 | 61 | func TestGetScalaObjects(t *testing.T) { 62 | expected := []string{ 63 | "object HelloWorld", 64 | "object Container", 65 | } 66 | 67 | resultChan := make(chan Result) 68 | go getScalaObjects(resultChan, scalaCodeObjects) 69 | 70 | got := <-resultChan 71 | require.NoError(t, got.Err) 72 | assert.Equal(t, expected, got.Results) 73 | } 74 | -------------------------------------------------------------------------------- /tsutils/file_utils_test.go: -------------------------------------------------------------------------------- 1 | package tsutils 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestGetFileExtension(t *testing.T) { 10 | cases := []struct { 11 | name string 12 | filePath string 13 | expected FileType 14 | err error 15 | }{ 16 | // SUCCESSES 17 | { 18 | name: "a scala file", 19 | filePath: "dir/file.scala", 20 | expected: FTScala, 21 | }, 22 | { 23 | name: "a go file", 24 | filePath: "dir/file.go", 25 | expected: FTGo, 26 | }, 27 | { 28 | name: "a rust file", 29 | filePath: "dir/file.rs", 30 | expected: FTRust, 31 | }, 32 | { 33 | name: "a go file in a hidden directory", 34 | filePath: ".dir/file.go", 35 | expected: FTGo, 36 | }, 37 | 38 | // FAILURES 39 | { 40 | name: "an incorrect file path", 41 | filePath: "filewithoutextension", 42 | expected: FTNone, 43 | err: ErrFilePathIncorrect, 44 | }, 45 | { 46 | name: "an incorrect file path in dir", 47 | filePath: "dir/filewithoutextension", 48 | expected: FTNone, 49 | err: ErrFilePathIncorrect, 50 | }, 51 | { 52 | name: "a file with several dots", 53 | filePath: "dir/file.go.temp", 54 | expected: FTNone, 55 | err: ErrFilePathIncorrect, 56 | }, 57 | { 58 | name: "a dot file", 59 | filePath: ".file", 60 | expected: FTNone, 61 | err: ErrFilePathIncorrect, 62 | }, 63 | { 64 | name: "a file in a hidden directory", 65 | filePath: ".dir/file", 66 | expected: FTNone, 67 | err: ErrFilePathIncorrect, 68 | }, 69 | { 70 | name: "a go file without a name", 71 | filePath: ".go", 72 | expected: FTNone, 73 | err: ErrFileNameIncorrect, 74 | }, 75 | { 76 | name: "a go file without a name in a hidden dir", 77 | filePath: "dir1/.dir2/.go", 78 | expected: FTNone, 79 | err: ErrFileNameIncorrect, 80 | }, 81 | } 82 | 83 | for _, tt := range cases { 84 | t.Run(tt.name, func(t *testing.T) { 85 | got, err := getFileExtension(tt.filePath) 86 | 87 | if tt.err == nil { 88 | assert.Equal(t, tt.expected, got) 89 | assert.NoError(t, err) 90 | } else { 91 | assert.Equal(t, tt.err, err) 92 | } 93 | }) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /tsutils/testcode/scala.txt: -------------------------------------------------------------------------------- 1 | // ==== FUNCS ==== 2 | 3 | // a simple function 4 | def method1(): Unit = { 5 | println("Function 1") 6 | } 7 | 8 | // a function with arguments 9 | def method1(arg1: String, num: Int): Unit = { 10 | for (i <- 1 to num) { 11 | println(arg1) 12 | } 13 | } 14 | 15 | // a function with override modifier 16 | override def method1(arg1: String, num: Int): Unit = { 17 | for (i <- 1 to num) { 18 | println(arg1) 19 | } 20 | } 21 | 22 | // a function with private modifier 23 | private def method1(arg1: String, num: Int): Unit = { 24 | for (i <- 1 to num) { 25 | println(arg1) 26 | } 27 | } 28 | 29 | // a function with two modifiers 30 | override protected def method1(arg1: String, num: Int): Unit = { 31 | for (i <- 1 to num) { 32 | println(arg1) 33 | } 34 | } 35 | 36 | def pair[A, B](first: A, second: B): (A, B) = (first, second) 37 | 38 | def max[T <: Ordered[T]](list: List[T]): T = list.max 39 | 40 | def mapContainer[F[_], A, B](container: F[A])(func: A => B)(implicit functor: Functor[F]): F[B] = { 41 | functor.map(container)(func) 42 | } 43 | 44 | 45 | // === CLASSES === 46 | 47 | // a simple class with 2 functions 48 | class MyClass { 49 | def classMethod1(): Unit = { 50 | println("Function 1") 51 | } 52 | } 53 | 54 | // a class with modifiers 55 | sealed abstract class KafkaConsumer {} 56 | 57 | // a class with modifiers and type params 58 | sealed abstract class Signature[+T] { self => 59 | final def show: String = mergeShow(new StringBuilder(30)).toString 60 | } 61 | 62 | // class within a class 63 | class OuterClass { 64 | class InnerClass { 65 | } 66 | } 67 | 68 | // a simple class with class parameters 69 | class MyClass(val name: String, val age: Int) { 70 | def greet(): Unit = { 71 | println(s"Hello, my name is $name and I am $age years old.") 72 | } 73 | } 74 | 75 | // a simple class with class parameters and an extends clause 76 | class MyExtendedClass(name: String, age: Int, val occupation: String) extends MyClass(name, age) { 77 | def introduce(): Unit = { 78 | println(s"I am a $occupation.") 79 | } 80 | } 81 | 82 | class MyHealthCheck extends HealthCheck[IO]("my-health-check") { 83 | override def check(): IO[HealthCheck.Result] = logger.info(l"Health Check") *> IO.pure(HealthCheck.Result.Healthy) 84 | } 85 | 86 | 87 | // === OBJECTS === 88 | 89 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/dhth/dstll 2 | 3 | go 1.25.5 4 | 5 | require ( 6 | github.com/alecthomas/chroma v0.10.0 7 | github.com/charmbracelet/bubbles v0.21.0 8 | github.com/charmbracelet/bubbletea v1.3.10 9 | github.com/charmbracelet/lipgloss v1.1.0 10 | github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82 11 | github.com/spf13/cobra v1.10.1 12 | github.com/spf13/pflag v1.0.10 13 | github.com/spf13/viper v1.21.0 14 | github.com/stretchr/testify v1.11.1 15 | ) 16 | 17 | require ( 18 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 19 | github.com/charmbracelet/colorprofile v0.3.2 // indirect 20 | github.com/charmbracelet/x/ansi v0.10.1 // indirect 21 | github.com/charmbracelet/x/cellbuf v0.0.13 // indirect 22 | github.com/charmbracelet/x/term v0.2.1 // indirect 23 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 24 | github.com/dlclark/regexp2 v1.11.5 // indirect 25 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 26 | github.com/fsnotify/fsnotify v1.9.0 // indirect 27 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect 28 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 29 | github.com/lucasb-eyer/go-colorful v1.3.0 // indirect 30 | github.com/mattn/go-isatty v0.0.20 // indirect 31 | github.com/mattn/go-localereader v0.0.1 // indirect 32 | github.com/mattn/go-runewidth v0.0.17 // indirect 33 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 34 | github.com/muesli/cancelreader v0.2.2 // indirect 35 | github.com/muesli/termenv v0.16.0 // indirect 36 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect 37 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 38 | github.com/rivo/uniseg v0.4.7 // indirect 39 | github.com/sagikazarmark/locafero v0.11.0 // indirect 40 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect 41 | github.com/spf13/afero v1.15.0 // indirect 42 | github.com/spf13/cast v1.10.0 // indirect 43 | github.com/subosito/gotenv v1.6.0 // indirect 44 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 45 | go.yaml.in/yaml/v3 v3.0.4 // indirect 46 | golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect 47 | golang.org/x/sys v0.36.0 // indirect 48 | golang.org/x/text v0.29.0 // indirect 49 | gopkg.in/yaml.v3 v3.0.1 // indirect 50 | ) 51 | -------------------------------------------------------------------------------- /ui/view.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/charmbracelet/lipgloss" 8 | ) 9 | 10 | func (m Model) View() string { 11 | if m.quitting { 12 | return "" 13 | } 14 | 15 | var content string 16 | var footer string 17 | 18 | var statusBar string 19 | if m.message != "" { 20 | statusBar = RightPadTrim(m.message, m.terminalWidth) 21 | } 22 | 23 | // It seems that using terminal color codes in the viewport is leaking some 24 | // information into the file picker pane, resulting in some lines in it 25 | // being colored under some circumstances. This color reset pane fixes that. 26 | // Not the most elegant solution, but seems to be doing the job. 27 | var colorResetPane string 28 | if m.resultVPReady { 29 | colorResetPane = strings.Repeat("\033[0m\n", m.filepicker.Height-1) 30 | colorResetPane += "\033[0m" 31 | } 32 | 33 | fileExplorerStyle.GetWidth() 34 | switch m.activePane { 35 | case fileExplorerPane: 36 | fExplorer := lipgloss.JoinVertical(lipgloss.Left, "\n "+activePaneHeaderStyle.Render("Files")+"\n\n"+fileExplorerStyle.Render(m.filepicker.View())) 37 | resultView := lipgloss.JoinVertical(lipgloss.Left, "\n"+inActivePaneHeaderStyle.Render("Results")+"\n\n"+m.resultVP.View()) 38 | content = lipgloss.JoinHorizontal(lipgloss.Top, colorResetPane, fExplorer, resultView) 39 | case resultPane: 40 | fExplorer := lipgloss.JoinVertical(lipgloss.Left, "\n "+inActivePaneHeaderStyle.Render("Files")+"\n\n"+fileExplorerStyle.Render(m.filepicker.View())) 41 | resultView := lipgloss.JoinVertical(lipgloss.Left, "\n"+activePaneHeaderStyle.Render("Results")+"\n\n"+m.resultVP.View()) 42 | content = lipgloss.JoinHorizontal(lipgloss.Top, colorResetPane, fExplorer, resultView) 43 | } 44 | 45 | var cwdBar string 46 | if m.filepicker.CurrentDirectory != "." { 47 | cwdBar = cwdStyle.Render(fmt.Sprintf("cwd: %s", m.filepicker.CurrentDirectory)) 48 | } 49 | 50 | footerStyle := lipgloss.NewStyle(). 51 | Foreground(lipgloss.Color(DefaultForegroundColor)). 52 | Background(lipgloss.Color(FooterColor)) 53 | 54 | var helpMsg string 55 | if m.showHelp { 56 | helpMsg = " " + helpMsgStyle.Render(": switch focus between panes; v/space: view file") 57 | } 58 | 59 | footerStr := fmt.Sprintf("%s%s", 60 | modeStyle.Render("dstll"), 61 | helpMsg, 62 | ) 63 | footer = footerStyle.Render(footerStr) 64 | 65 | return lipgloss.JoinVertical(lipgloss.Left, 66 | content, 67 | cwdBar, 68 | statusBar, 69 | footer, 70 | ) 71 | } 72 | -------------------------------------------------------------------------------- /tsutils/tsrust.go: -------------------------------------------------------------------------------- 1 | package tsutils 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | ts "github.com/smacker/go-tree-sitter" 8 | tsrust "github.com/smacker/go-tree-sitter/rust" 9 | ) 10 | 11 | const ( 12 | rustNodeTypeVisibilityModifier = "visibility_modifier" 13 | rustNodeTypeTypeParameters = "type_parameters" 14 | rustNodeTypeParameters = "parameters" 15 | ) 16 | 17 | func getRustTypes(resultChan chan<- Result, fContent []byte) { 18 | query := `[ 19 | (struct_item) @struct 20 | (enum_item) @enum 21 | (type_item) @type_item 22 | (trait_item) @trait 23 | (union_item) @union 24 | ]` 25 | results, err := getGenericResult(fContent, query, tsrust.GetLanguage()) 26 | resultChan <- Result{Results: results, Err: err} 27 | } 28 | 29 | func getRustFuncs(resultChan chan<- Result, fContent []byte) { 30 | parser := ts.NewParser() 31 | parser.SetLanguage(tsrust.GetLanguage()) 32 | 33 | tree, err := parser.ParseCtx(context.Background(), nil, fContent) 34 | if err != nil { 35 | resultChan <- Result{Err: err} 36 | return 37 | } 38 | 39 | rootNode := tree.RootNode() 40 | 41 | q, err := ts.NewQuery([]byte(` 42 | (function_item 43 | (visibility_modifier)? @visibility 44 | name: (_) @identifier 45 | type_parameters: (_)? @type_parameters 46 | parameters: (_)? @parameter_list 47 | return_type: (_)? @return_type 48 | ) 49 | `), tsrust.GetLanguage()) 50 | if err != nil { 51 | resultChan <- Result{Err: err} 52 | return 53 | } 54 | 55 | qc := ts.NewQueryCursor() 56 | 57 | qc.Exec(q, rootNode) 58 | 59 | var elements []string 60 | 61 | for { 62 | fMatch, cOk := qc.NextMatch() 63 | if !cOk { 64 | break 65 | } 66 | 67 | var visibilityModifier string 68 | var fName string 69 | var fTParams string 70 | var fParams string 71 | var fReturnT string 72 | var fMatchedNode *ts.Node 73 | 74 | for _, capture := range fMatch.Captures { 75 | fMatchedNode = capture.Node 76 | 77 | switch fMatchedNode.Type() { 78 | case rustNodeTypeVisibilityModifier: 79 | visibilityModifier = fMatchedNode.Content(fContent) + " " 80 | case nodeTypeIdentifier: 81 | fName = fMatchedNode.Content(fContent) 82 | case rustNodeTypeTypeParameters: 83 | fTParams = fMatchedNode.Content(fContent) 84 | case rustNodeTypeParameters: 85 | fParams = fMatchedNode.Content(fContent) 86 | default: 87 | // TODO: This is not the best way to get the return type; find a better way 88 | fReturnT = " -> " + fMatchedNode.Content(fContent) 89 | } 90 | } 91 | 92 | elem := fmt.Sprintf("%sfn %s%s%s%s", visibilityModifier, fName, fTParams, fParams, fReturnT) 93 | 94 | elements = append(elements, elem) 95 | } 96 | resultChan <- Result{Results: elements} 97 | } 98 | -------------------------------------------------------------------------------- /ui/update.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "bytes" 5 | 6 | "github.com/alecthomas/chroma/quick" 7 | "github.com/charmbracelet/bubbles/viewport" 8 | tea "github.com/charmbracelet/bubbletea" 9 | ) 10 | 11 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 12 | var cmds []tea.Cmd 13 | m.message = "" 14 | 15 | switch msg := msg.(type) { 16 | case tea.KeyMsg: 17 | switch msg.String() { 18 | case "ctrl+c", "q": 19 | m.quitting = true 20 | return m, tea.Quit 21 | case "tab", "shift+tab": 22 | if m.activePane == fileExplorerPane { 23 | m.activePane = resultPane 24 | } else { 25 | m.activePane = fileExplorerPane 26 | m.resultVP.GotoTop() 27 | } 28 | case "v", " ": 29 | if len(m.config.ViewFileCmd) > 0 { 30 | if m.activePane == fileExplorerPane { 31 | if m.filepicker.IsCurrentAFile { 32 | cmds = append(cmds, openFile(m.filepicker.Current, m.config.ViewFileCmd)) 33 | } 34 | } 35 | } else { 36 | m.message = "you haven't configured view_file_command, run dstll -help to learn more" 37 | } 38 | } 39 | case tea.WindowSizeMsg: 40 | m.terminalWidth = msg.Width 41 | m.terminalHeight = msg.Height 42 | m.filepicker.Height = msg.Height - 8 43 | 44 | if !m.resultVPReady { 45 | m.resultVP = viewport.New(msg.Width-m.fileExplorerPaneWidth-10, msg.Height-8) 46 | m.resultVPReady = true 47 | } else { 48 | m.resultVP.Width = msg.Width - m.fileExplorerPaneWidth - 10 49 | m.resultVP.Height = msg.Height - 8 50 | } 51 | case hideHelpMsg: 52 | m.showHelp = false 53 | case FileRead: 54 | if msg.err != nil { 55 | m.message = msg.err.Error() 56 | } else { 57 | m.resultVP.SetContent(msg.contents) 58 | } 59 | case FileResultsReceivedMsg: 60 | if msg.result.Err != nil { 61 | m.message = msg.result.Err.Error() 62 | } else { 63 | if len(msg.result.Results) == 0 { 64 | m.resultVP.SetContent(m.noConstructsMsg) 65 | m.resultsCache[msg.result.FPath] = m.noConstructsMsg 66 | } else { 67 | s := "👉 " + filePathStyleTUI.Render(msg.result.FPath) + "\n\n" 68 | for _, elem := range msg.result.Results { 69 | var b bytes.Buffer 70 | err := quick.Highlight(&b, elem, msg.result.FPath, "terminal16m", "xcode-dark") 71 | if err != nil { 72 | s += tsElementStyle.Render(elem) 73 | } else { 74 | s += b.String() 75 | } 76 | s += "\n\n" 77 | } 78 | m.resultVP.SetContent(s) 79 | m.resultsCache[msg.result.FPath] = s 80 | } 81 | } 82 | } 83 | 84 | var cmd tea.Cmd 85 | switch m.activePane { 86 | case fileExplorerPane: 87 | m.filepicker, cmd = m.filepicker.Update(msg) 88 | cmds = append(cmds, cmd) 89 | if m.filepicker.CanSelect(m.filepicker.Current) { 90 | m.selectedFile = m.filepicker.Current 91 | resultFromCache, ok := m.resultsCache[m.filepicker.Current] 92 | if !ok { 93 | cmds = append(cmds, getFileResults(m.filepicker.Current)) 94 | } else { 95 | m.resultVP.SetContent(resultFromCache) 96 | } 97 | } else { 98 | m.resultVP.SetContent(unsupportedFileStyle.Render(m.unsupportedFileMsg)) 99 | } 100 | case resultPane: 101 | m.resultVP, cmd = m.resultVP.Update(msg) 102 | cmds = append(cmds, cmd) 103 | } 104 | 105 | return m, tea.Batch(cmds...) 106 | } 107 | -------------------------------------------------------------------------------- /tsutils/testcode/rust/types.txt: -------------------------------------------------------------------------------- 1 | // === ALIASES === 2 | 3 | type MyTypeAlias = i32; 4 | 5 | // === UNIONS ==== 6 | 7 | union MyUnion { 8 | i: i32, 9 | f: f32, 10 | } 11 | 12 | // === STRUCTS === 13 | 14 | struct Empty; 15 | struct Unit; 16 | struct Color(i32, i32, i32); 17 | 18 | struct Table { 19 | field1: i32, 20 | field2: String, 21 | } 22 | 23 | struct GenericStruct { 24 | field: T, 25 | } 26 | 27 | #[derive(Debug)] 28 | pub struct Point { 29 | pub x: i32, 30 | y: i32, 31 | } 32 | 33 | pub(crate) struct Point { 34 | x: i32, 35 | y: i32, 36 | } 37 | 38 | pub(super) struct Point { 39 | x: i32, 40 | y: i32, 41 | } 42 | 43 | pub(in crate::some_module) struct Point { 44 | x: i32, 45 | y: i32, 46 | } 47 | 48 | // ==== ENUMS ==== 49 | 50 | enum Direction { 51 | North, 52 | South, 53 | East, 54 | West, 55 | } 56 | 57 | pub enum Option { 58 | Some(T), 59 | None, 60 | } 61 | 62 | pub(crate) enum Option { 63 | Some(T), 64 | None, 65 | } 66 | 67 | pub(super) enum Option { 68 | Some(T), 69 | None, 70 | } 71 | 72 | pub(in crate::some_module) enum Option { 73 | Some(T), 74 | None, 75 | } 76 | 77 | // Enum with Named Fields 78 | enum Message { 79 | Quit, 80 | Move { x: i32, y: i32 }, 81 | Write(String), 82 | ChangeColor { r: u8, g: u8, b: u8 }, 83 | } 84 | 85 | // Enum with Tuple-like Variants 86 | enum Result { 87 | Ok(T), 88 | Err(E), 89 | } 90 | 91 | // Enum with Generics 92 | enum GenericEnum { 93 | Value(T), 94 | Nothing, 95 | } 96 | 97 | // Enum with Lifetimes 98 | enum RefEnum<'a, T> { 99 | Borrowed(&'a T), 100 | Owned(T), 101 | } 102 | 103 | // === TRAITS ==== 104 | 105 | trait Drawable { 106 | fn draw(&self); 107 | } 108 | 109 | // Trait with generics 110 | trait Resizable { 111 | fn resize(&mut self, value: T); 112 | } 113 | 114 | trait BasicTrait { 115 | fn required_method(&self); 116 | } 117 | 118 | // Trait with Associated Constants 119 | trait TraitWithConstants { 120 | const CONSTANT: u32; 121 | } 122 | 123 | // Trait with Associated Types 124 | trait TraitWithTypes { 125 | type ItemType; 126 | } 127 | 128 | // Trait with Provided Methods 129 | trait TraitWithProvidedMethods { 130 | fn provided_method(&self) { 131 | println!("This is a provided method."); 132 | } 133 | } 134 | 135 | // Trait with Associated Functions 136 | trait TraitWithAssociatedFunctions { 137 | fn associated_function() -> Self; 138 | } 139 | 140 | // Comprehensive Trait with All Facets 141 | trait ComprehensiveTrait { 142 | // Associated constant 143 | const CONSTANT: u32; 144 | 145 | // Associated type 146 | type ItemType; 147 | 148 | // Required method 149 | fn required_method2(&self); 150 | 151 | // Provided method 152 | fn provided_method2(&self) { 153 | println!("This is a provided method."); 154 | } 155 | 156 | // Associated function 157 | fn associated_function2() -> Self; 158 | } 159 | 160 | // Trait with Generic Parameters 161 | trait GenericTrait { 162 | fn generic_method(&self, value: T); 163 | } 164 | 165 | // Trait with Lifetimes 166 | trait LifetimeTrait<'a> { 167 | fn lifetime_method(&self, value: &'a str); 168 | } 169 | -------------------------------------------------------------------------------- /ui/results.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | 9 | "github.com/dhth/dstll/tsutils" 10 | ) 11 | 12 | type writeResult struct { 13 | path string 14 | err error 15 | } 16 | 17 | func ShowResults(results []tsutils.Result, trimPrefix string, plain bool) { 18 | switch plain { 19 | case true: 20 | printPlainOutput(results, trimPrefix) 21 | case false: 22 | printColorOutput(results, trimPrefix) 23 | } 24 | } 25 | 26 | func printColorOutput(results []tsutils.Result, trimPrefix string) { 27 | for i, result := range results { 28 | if result.Err != nil { 29 | continue 30 | } 31 | 32 | if len(result.Results) == 0 { 33 | continue 34 | } 35 | 36 | if trimPrefix != "" { 37 | fmt.Println("👉 " + filePathStyle.Render(strings.TrimPrefix(result.FPath, trimPrefix))) 38 | } else { 39 | fmt.Println("👉 " + filePathStyle.Render(result.FPath)) 40 | } 41 | fmt.Println() 42 | 43 | var r []string 44 | for _, elem := range result.Results { 45 | r = append(r, tsElementStyle.Render(elem)) 46 | } 47 | fmt.Println(strings.Join(r, "\n\n")) 48 | 49 | if i < len(results)-1 { 50 | fmt.Printf("\n%s\n\n", dividerStyle.Render(strings.Repeat(".", 80))) 51 | } 52 | } 53 | } 54 | 55 | func printPlainOutput(results []tsutils.Result, trimPrefix string) { 56 | for i, result := range results { 57 | if result.Err != nil { 58 | continue 59 | } 60 | 61 | if len(result.Results) == 0 { 62 | continue 63 | } 64 | 65 | if trimPrefix != "" { 66 | fmt.Println("-> " + strings.TrimPrefix(result.FPath, trimPrefix)) 67 | } else { 68 | fmt.Println("-> " + result.FPath) 69 | } 70 | fmt.Println() 71 | 72 | fmt.Println(strings.Join(result.Results, "\n\n")) 73 | 74 | if i < len(results)-1 { 75 | fmt.Printf("\n%s\n\n", strings.Repeat(".", 80)) 76 | } 77 | } 78 | } 79 | 80 | func writeToFile(resultsChan chan<- writeResult, path string, contents []string) { 81 | dir := filepath.Dir(path) 82 | err := os.MkdirAll(dir, 0o755) 83 | if err != nil { 84 | resultsChan <- writeResult{path, err} 85 | return 86 | } 87 | 88 | file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) 89 | if err != nil { 90 | resultsChan <- writeResult{path, err} 91 | return 92 | } 93 | defer file.Close() 94 | 95 | _, err = file.WriteString(strings.Join(contents, "\n") + "\n") 96 | resultsChan <- writeResult{path, err} 97 | } 98 | 99 | func WriteResults(results []tsutils.Result, outDir string, quiet bool) { 100 | resultsChan := make(chan writeResult) 101 | var successes []string 102 | errors := make(map[string]error) 103 | 104 | counter := 0 105 | for _, result := range results { 106 | if len(result.Results) == 0 { 107 | continue 108 | } 109 | outPath := filepath.Join(outDir, result.FPath) 110 | go writeToFile(resultsChan, outPath, result.Results) 111 | counter++ 112 | } 113 | 114 | for i := 0; i < counter; i++ { 115 | r := <-resultsChan 116 | if r.err != nil { 117 | errors[r.path] = r.err 118 | } else { 119 | successes = append(successes, r.path) 120 | } 121 | } 122 | 123 | errorList := make([]string, len(errors)) 124 | c := 0 125 | for p, e := range errors { 126 | errorList[c] = fmt.Sprintf("%s: %s", p, e.Error()) 127 | c++ 128 | } 129 | 130 | if !quiet { 131 | if len(successes) > 0 { 132 | fmt.Printf("The following files were written:\n%s\n", strings.Join(successes, "\n")) 133 | } 134 | } 135 | 136 | if len(errorList) > 0 { 137 | if !quiet { 138 | if len(successes) > 0 { 139 | fmt.Print("\n---\n\n") 140 | } 141 | } 142 | fmt.Fprintf(os.Stderr, "The following errors were encountered:\n%s\n", strings.Join(errorList, "\n")) 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /tsutils/tsrust_test.go: -------------------------------------------------------------------------------- 1 | package tsutils 2 | 3 | import ( 4 | _ "embed" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | var ( 12 | //go:embed testcode/rust/funcs.txt 13 | rustCodeFuncs []byte 14 | //go:embed testcode/rust/types.txt 15 | rustCodeTypes []byte 16 | ) 17 | 18 | func TestGetRustTypes(t *testing.T) { 19 | expected := []string{ 20 | "type MyTypeAlias = i32;", 21 | `union MyUnion { 22 | i: i32, 23 | f: f32, 24 | }`, 25 | "struct Empty;", 26 | "struct Unit;", 27 | "struct Color(i32, i32, i32);", 28 | `struct Table { 29 | field1: i32, 30 | field2: String, 31 | }`, 32 | `struct GenericStruct { 33 | field: T, 34 | }`, 35 | `pub struct Point { 36 | pub x: i32, 37 | y: i32, 38 | }`, 39 | `pub(crate) struct Point { 40 | x: i32, 41 | y: i32, 42 | }`, 43 | `pub(super) struct Point { 44 | x: i32, 45 | y: i32, 46 | }`, 47 | `pub(in crate::some_module) struct Point { 48 | x: i32, 49 | y: i32, 50 | }`, 51 | `enum Direction { 52 | North, 53 | South, 54 | East, 55 | West, 56 | }`, 57 | `pub enum Option { 58 | Some(T), 59 | None, 60 | }`, 61 | `pub(crate) enum Option { 62 | Some(T), 63 | None, 64 | }`, 65 | `pub(super) enum Option { 66 | Some(T), 67 | None, 68 | }`, 69 | `pub(in crate::some_module) enum Option { 70 | Some(T), 71 | None, 72 | }`, 73 | `enum Message { 74 | Quit, 75 | Move { x: i32, y: i32 }, 76 | Write(String), 77 | ChangeColor { r: u8, g: u8, b: u8 }, 78 | }`, 79 | `enum Result { 80 | Ok(T), 81 | Err(E), 82 | }`, 83 | `enum GenericEnum { 84 | Value(T), 85 | Nothing, 86 | }`, 87 | `enum RefEnum<'a, T> { 88 | Borrowed(&'a T), 89 | Owned(T), 90 | }`, 91 | `trait Drawable { 92 | fn draw(&self); 93 | }`, 94 | `trait Resizable { 95 | fn resize(&mut self, value: T); 96 | }`, 97 | `trait BasicTrait { 98 | fn required_method(&self); 99 | }`, 100 | `trait TraitWithConstants { 101 | const CONSTANT: u32; 102 | }`, 103 | `trait TraitWithTypes { 104 | type ItemType; 105 | }`, 106 | `trait TraitWithProvidedMethods { 107 | fn provided_method(&self) { 108 | println!("This is a provided method."); 109 | } 110 | }`, 111 | `trait TraitWithAssociatedFunctions { 112 | fn associated_function() -> Self; 113 | }`, 114 | `trait ComprehensiveTrait { 115 | // Associated constant 116 | const CONSTANT: u32; 117 | 118 | // Associated type 119 | type ItemType; 120 | 121 | // Required method 122 | fn required_method2(&self); 123 | 124 | // Provided method 125 | fn provided_method2(&self) { 126 | println!("This is a provided method."); 127 | } 128 | 129 | // Associated function 130 | fn associated_function2() -> Self; 131 | }`, 132 | `trait GenericTrait { 133 | fn generic_method(&self, value: T); 134 | }`, 135 | 136 | `trait LifetimeTrait<'a> { 137 | fn lifetime_method(&self, value: &'a str); 138 | }`, 139 | } 140 | 141 | resultChan := make(chan Result) 142 | go getRustTypes(resultChan, rustCodeTypes) 143 | 144 | got := <-resultChan 145 | 146 | require.NoError(t, got.Err) 147 | assert.Equal(t, expected, got.Results) 148 | } 149 | 150 | func TestGetRustFuncs(t *testing.T) { 151 | expected := []string{ 152 | "fn function_name()", 153 | "fn generic_function(value: T) -> T", 154 | "pub fn public_function() -> i32", 155 | "pub(crate) fn crate_function() -> i32", 156 | "pub(super) fn parent_function() -> i32", 157 | "pub(in crate::some_module) fn specific_module_function() -> i32", 158 | "fn new(field1: i32, field2: String) -> Self", 159 | "fn apply_to<'a>(&'a self, data: &'a mut Table) -> &'a mut Table", 160 | "fn update(&mut self, field1: i32)", 161 | "fn draw(&self)", 162 | "fn resize(&mut self, value: T)", 163 | "fn new(field: T) -> Self", 164 | "fn get_field(&self) -> &T", 165 | } 166 | 167 | resultChan := make(chan Result) 168 | go getRustFuncs(resultChan, rustCodeFuncs) 169 | 170 | got := <-resultChan 171 | 172 | require.NoError(t, got.Err) 173 | assert.Equal(t, expected, got.Results) 174 | } 175 | -------------------------------------------------------------------------------- /examples/rust/README.md: -------------------------------------------------------------------------------- 1 | # dstll rust code 2 | 3 | Running `dstll` in the [ripgrep][1] repo gives the following output: 4 | 5 | ``` 6 | $ dstll $(git ls-files '**.rs' | head -n 3 ) -p 7 | 8 | -> build.rs 9 | 10 | fn main() 11 | 12 | fn set_windows_exe_options() 13 | 14 | fn set_git_revision_hash() 15 | 16 | ................................................................................ 17 | 18 | -> crates/cli/src/escape.rs 19 | 20 | pub fn escape(bytes: &[u8]) -> String 21 | 22 | pub fn escape_os(string: &OsStr) -> String 23 | 24 | pub fn unescape(s: &str) -> Vec 25 | 26 | pub fn unescape_os(string: &OsStr) -> Vec 27 | 28 | fn b(bytes: &'static [u8]) -> Vec 29 | 30 | fn empty() 31 | 32 | fn backslash() 33 | 34 | fn nul() 35 | 36 | fn nl() 37 | 38 | fn tab() 39 | 40 | fn carriage() 41 | 42 | fn nothing_simple() 43 | 44 | fn nothing_hex0() 45 | 46 | fn nothing_hex1() 47 | 48 | fn nothing_hex2() 49 | 50 | fn invalid_utf8() 51 | 52 | ................................................................................ 53 | 54 | -> crates/cli/src/decompress.rs 55 | 56 | pub struct DecompressionMatcherBuilder { 57 | /// The commands for each matching glob. 58 | commands: Vec, 59 | /// Whether to include the default matching rules. 60 | defaults: bool, 61 | } 62 | 63 | struct DecompressionCommand { 64 | /// The glob that matches this command. 65 | glob: String, 66 | /// The command or binary name. 67 | bin: PathBuf, 68 | /// The arguments to invoke with the command. 69 | args: Vec, 70 | } 71 | 72 | pub struct DecompressionMatcher { 73 | /// The set of globs to match. Each glob has a corresponding entry in 74 | /// `commands`. When a glob matches, the corresponding command should be 75 | /// used to perform out-of-process decompression. 76 | globs: GlobSet, 77 | /// The commands for each matching glob. 78 | commands: Vec, 79 | } 80 | 81 | pub struct DecompressionReaderBuilder { 82 | matcher: DecompressionMatcher, 83 | command_builder: CommandReaderBuilder, 84 | } 85 | 86 | pub struct DecompressionReader { 87 | rdr: Result, 88 | } 89 | 90 | fn default() -> DecompressionMatcherBuilder 91 | 92 | pub fn new() -> DecompressionMatcherBuilder 93 | 94 | pub fn build(&self) -> Result 95 | 96 | pub fn defaults(&mut self, yes: bool) -> &mut DecompressionMatcherBuilder 97 | 98 | pub fn associate( 99 | &mut self, 100 | glob: &str, 101 | program: P, 102 | args: I, 103 | ) -> &mut DecompressionMatcherBuilder 104 | 105 | pub fn try_associate( 106 | &mut self, 107 | glob: &str, 108 | program: P, 109 | args: I, 110 | ) -> Result<&mut DecompressionMatcherBuilder, CommandError> 111 | 112 | fn default() -> DecompressionMatcher 113 | 114 | pub fn new() -> DecompressionMatcher 115 | 116 | pub fn command>(&self, path: P) -> Option 117 | 118 | pub fn has_command>(&self, path: P) -> bool 119 | 120 | pub fn new() -> DecompressionReaderBuilder 121 | 122 | pub fn build>( 123 | &self, 124 | path: P, 125 | ) -> Result 126 | 127 | pub fn matcher( 128 | &mut self, 129 | matcher: DecompressionMatcher, 130 | ) -> &mut DecompressionReaderBuilder 131 | 132 | pub fn get_matcher(&self) -> &DecompressionMatcher 133 | 134 | pub fn async_stderr( 135 | &mut self, 136 | yes: bool, 137 | ) -> &mut DecompressionReaderBuilder 138 | 139 | pub fn new>( 140 | path: P, 141 | ) -> Result 142 | 143 | fn new_passthru(path: &Path) -> Result 144 | 145 | pub fn close(&mut self) -> io::Result<()> 146 | 147 | fn read(&mut self, buf: &mut [u8]) -> io::Result 148 | 149 | pub fn resolve_binary>( 150 | prog: P, 151 | ) -> Result 152 | 153 | fn try_resolve_binary>( 154 | prog: P, 155 | ) -> Result 156 | 157 | fn is_exe(path: &Path) -> bool 158 | 159 | fn default_decompression_commands() -> Vec 160 | 161 | fn add(glob: &str, args: &[&str], cmds: &mut Vec) 162 | ``` 163 | 164 | [1]: https://github.com/BurntSushi/ripgrep 165 | -------------------------------------------------------------------------------- /examples/python/README.md: -------------------------------------------------------------------------------- 1 | # dstll python code 2 | 3 | Running `dstll` in the [flask][1] repo gives the following output: 4 | 5 | ``` 6 | $ dstll $(git ls-files src/flask/**/*.py | head -n 3 ) -p 7 | 8 | -> src/flask/__init__.py 9 | 10 | def __getattr__(name: str) -> t.Any 11 | 12 | ................................................................................ 13 | 14 | -> src/flask/app.py 15 | 16 | def _make_timedelta(value: timedelta | int | None) -> timedelta | None 17 | 18 | def __init__( 19 | self, 20 | import_name: str, 21 | static_url_path: str | None = None, 22 | static_folder: str | os.PathLike[str] | None = "static", 23 | static_host: str | None = None, 24 | host_matching: bool = False, 25 | subdomain_matching: bool = False, 26 | template_folder: str | os.PathLike[str] | None = "templates", 27 | instance_path: str | None = None, 28 | instance_relative_config: bool = False, 29 | root_path: str | None = None, 30 | ) 31 | 32 | def get_send_file_max_age(self, filename: str | None) -> int | None 33 | 34 | def send_static_file(self, filename: str) -> Response 35 | 36 | def open_resource(self, resource: str, mode: str = "rb") -> t.IO[t.AnyStr] 37 | 38 | def open_instance_resource(self, resource: str, mode: str = "rb") -> t.IO[t.AnyStr] 39 | 40 | def create_jinja_environment(self) -> Environment 41 | 42 | def create_url_adapter(self, request: Request | None) -> MapAdapter | None 43 | 44 | def raise_routing_exception(self, request: Request) -> t.NoReturn 45 | 46 | def update_template_context(self, context: dict[str, t.Any]) -> None 47 | 48 | def make_shell_context(self) -> dict[str, t.Any] 49 | 50 | def run( 51 | self, 52 | host: str | None = None, 53 | port: int | None = None, 54 | debug: bool | None = None, 55 | load_dotenv: bool = True, 56 | **options: t.Any, 57 | ) -> None 58 | 59 | def test_client(self, use_cookies: bool = True, **kwargs: t.Any) -> FlaskClient 60 | 61 | def test_cli_runner(self, **kwargs: t.Any) -> FlaskCliRunner 62 | 63 | def handle_http_exception( 64 | self, e: HTTPException 65 | ) -> HTTPException | ft.ResponseReturnValue 66 | 67 | def handle_user_exception( 68 | self, e: Exception 69 | ) -> HTTPException | ft.ResponseReturnValue 70 | 71 | def handle_exception(self, e: Exception) -> Response 72 | 73 | def log_exception( 74 | self, 75 | exc_info: (tuple[type, BaseException, TracebackType] | tuple[None, None, None]), 76 | ) -> None 77 | 78 | def dispatch_request(self) -> ft.ResponseReturnValue 79 | 80 | def full_dispatch_request(self) -> Response 81 | 82 | def finalize_request( 83 | self, 84 | rv: ft.ResponseReturnValue | HTTPException, 85 | from_error_handler: bool = False, 86 | ) -> Response 87 | 88 | def make_default_options_response(self) -> Response 89 | 90 | def ensure_sync(self, func: t.Callable[..., t.Any]) -> t.Callable[..., t.Any] 91 | 92 | def async_to_sync( 93 | self, func: t.Callable[..., t.Coroutine[t.Any, t.Any, t.Any]] 94 | ) -> t.Callable[..., t.Any] 95 | 96 | def url_for( 97 | self, 98 | /, 99 | endpoint: str, 100 | *, 101 | _anchor: str | None = None, 102 | _method: str | None = None, 103 | _scheme: str | None = None, 104 | _external: bool | None = None, 105 | **values: t.Any, 106 | ) -> str 107 | 108 | def make_response(self, rv: ft.ResponseReturnValue) -> Response 109 | 110 | def preprocess_request(self) -> ft.ResponseReturnValue | None 111 | 112 | def process_response(self, response: Response) -> Response 113 | 114 | def do_teardown_request( 115 | self, 116 | exc: BaseException | None = _sentinel, # type: ignore[assignment] 117 | ) -> None 118 | 119 | def do_teardown_appcontext( 120 | self, 121 | exc: BaseException | None = _sentinel, # type: ignore[assignment] 122 | ) -> None 123 | 124 | def app_context(self) -> AppContext 125 | 126 | def request_context(self, environ: WSGIEnvironment) -> RequestContext 127 | 128 | def test_request_context(self, *args: t.Any, **kwargs: t.Any) -> RequestContext 129 | 130 | def wsgi_app( 131 | self, environ: WSGIEnvironment, start_response: StartResponse 132 | ) -> cabc.Iterable[bytes] 133 | 134 | def __call__( 135 | self, environ: WSGIEnvironment, start_response: StartResponse 136 | ) -> cabc.Iterable[bytes] 137 | ``` 138 | 139 | [1]: https://github.com/pallets/flask 140 | -------------------------------------------------------------------------------- /examples/go/README.md: -------------------------------------------------------------------------------- 1 | # dstll go code 2 | 3 | Running `dstll` in the [go][1] repo gives the following output: 4 | 5 | ``` 6 | $ dstll $(git ls-files src/io/**/*.go | grep -v '_test.go' | head -n 3) -p 7 | 8 | -> src/io/fs/glob.go 9 | 10 | type GlobFS interface { 11 | FS 12 | 13 | // Glob returns the names of all files matching pattern, 14 | // providing an implementation of the top-level 15 | // Glob function. 16 | Glob(pattern string) ([]string, error) 17 | } 18 | 19 | func Glob(fsys FS, pattern string) (matches []string, err error) 20 | 21 | func globWithLimit(fsys FS, pattern string, depth int) (matches []string, err error) 22 | 23 | func cleanGlobPath(path string) string 24 | 25 | func glob(fs FS, dir, pattern string, matches []string) (m []string, e error) 26 | 27 | func hasMeta(path string) bool 28 | 29 | ................................................................................ 30 | 31 | -> src/io/fs/format.go 32 | 33 | func FormatFileInfo(info FileInfo) string 34 | 35 | func FormatDirEntry(dir DirEntry) string 36 | 37 | ................................................................................ 38 | 39 | -> src/io/fs/fs.go 40 | 41 | type FS interface { 42 | // Open opens the named file. 43 | // 44 | // When Open returns an error, it should be of type *PathError 45 | // with the Op field set to "open", the Path field set to name, 46 | // and the Err field describing the problem. 47 | // 48 | // Open should reject attempts to open names that do not satisfy 49 | // ValidPath(name), returning a *PathError with Err set to 50 | // ErrInvalid or ErrNotExist. 51 | Open(name string) (File, error) 52 | } 53 | 54 | type File interface { 55 | Stat() (FileInfo, error) 56 | Read([]byte) (int, error) 57 | Close() error 58 | } 59 | 60 | type DirEntry interface { 61 | // Name returns the name of the file (or subdirectory) described by the entry. 62 | // This name is only the final element of the path (the base name), not the entire path. 63 | // For example, Name would return "hello.go" not "home/gopher/hello.go". 64 | Name() string 65 | 66 | // IsDir reports whether the entry describes a directory. 67 | IsDir() bool 68 | 69 | // Type returns the type bits for the entry. 70 | // The type bits are a subset of the usual FileMode bits, those returned by the FileMode.Type method. 71 | Type() FileMode 72 | 73 | // Info returns the FileInfo for the file or subdirectory described by the entry. 74 | // The returned FileInfo may be from the time of the original directory read 75 | // or from the time of the call to Info. If the file has been removed or renamed 76 | // since the directory read, Info may return an error satisfying errors.Is(err, ErrNotExist). 77 | // If the entry denotes a symbolic link, Info reports the information about the link itself, 78 | // not the link's target. 79 | Info() (FileInfo, error) 80 | } 81 | 82 | type ReadDirFile interface { 83 | File 84 | 85 | // ReadDir reads the contents of the directory and returns 86 | // a slice of up to n DirEntry values in directory order. 87 | // Subsequent calls on the same file will yield further DirEntry values. 88 | // 89 | // If n > 0, ReadDir returns at most n DirEntry structures. 90 | // In this case, if ReadDir returns an empty slice, it will return 91 | // a non-nil error explaining why. 92 | // At the end of a directory, the error is io.EOF. 93 | // (ReadDir must return io.EOF itself, not an error wrapping io.EOF.) 94 | // 95 | // If n <= 0, ReadDir returns all the DirEntry values from the directory 96 | // in a single slice. In this case, if ReadDir succeeds (reads all the way 97 | // to the end of the directory), it returns the slice and a nil error. 98 | // If it encounters an error before the end of the directory, 99 | // ReadDir returns the DirEntry list read until that point and a non-nil error. 100 | ReadDir(n int) ([]DirEntry, error) 101 | } 102 | 103 | type FileInfo interface { 104 | Name() string // base name of the file 105 | Size() int64 // length in bytes for regular files; system-dependent for others 106 | Mode() FileMode // file mode bits 107 | ModTime() time.Time // modification time 108 | IsDir() bool // abbreviation for Mode().IsDir() 109 | Sys() any // underlying data source (can return nil) 110 | } 111 | 112 | type FileMode uint32 113 | 114 | type PathError struct { 115 | Op string 116 | Path string 117 | Err error 118 | } 119 | 120 | func ValidPath(name string) bool 121 | 122 | func errInvalid() error 123 | 124 | func errPermission() error 125 | 126 | func errExist() error 127 | 128 | func errNotExist() error 129 | 130 | func errClosed() error 131 | 132 | func (m FileMode) String() string 133 | 134 | func (m FileMode) IsDir() bool 135 | 136 | func (m FileMode) IsRegular() bool 137 | 138 | func (m FileMode) Perm() FileMode 139 | 140 | func (m FileMode) Type() FileMode 141 | 142 | func (e *PathError) Error() string 143 | 144 | func (e *PathError) Unwrap() error 145 | 146 | func (e *PathError) Timeout() bool 147 | ``` 148 | 149 | [1]: https://https://github.com/golang/go.com/pallets/flask 150 | -------------------------------------------------------------------------------- /tsutils/tsgo.go: -------------------------------------------------------------------------------- 1 | package tsutils 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | ts "github.com/smacker/go-tree-sitter" 8 | tsgo "github.com/smacker/go-tree-sitter/golang" 9 | ) 10 | 11 | func getGoFuncs(resultChan chan<- Result, fContent []byte) { 12 | parser := ts.NewParser() 13 | parser.SetLanguage(tsgo.GetLanguage()) 14 | 15 | tree, err := parser.ParseCtx(context.Background(), nil, fContent) 16 | if err != nil { 17 | resultChan <- Result{Err: err} 18 | return 19 | } 20 | 21 | rootNode := tree.RootNode() 22 | 23 | q, err := ts.NewQuery([]byte(` 24 | (function_declaration 25 | name: (identifier) @name 26 | type_parameters: (_)? @type-params 27 | parameters: (_)? @params 28 | result: (_)? @return-type 29 | ) 30 | `), tsgo.GetLanguage()) 31 | if err != nil { 32 | resultChan <- Result{Err: err} 33 | return 34 | } 35 | 36 | qc := ts.NewQueryCursor() 37 | 38 | qc.Exec(q, rootNode) 39 | 40 | var elements []string 41 | 42 | for { 43 | fMatch, cOk := qc.NextMatch() 44 | if !cOk { 45 | break 46 | } 47 | 48 | var fName string 49 | var fTParams string 50 | var fParams string 51 | var fReturnT string 52 | var fMatchedNode *ts.Node 53 | 54 | parametersSeen := false 55 | for _, capture := range fMatch.Captures { 56 | fMatchedNode = capture.Node 57 | 58 | switch fMatchedNode.Type() { 59 | case nodeTypeIdentifier: 60 | fName = fMatchedNode.Content(fContent) 61 | case "type_parameter_list": 62 | fTParams = fMatchedNode.Content(fContent) 63 | case "parameter_list": 64 | if parametersSeen { 65 | fReturnT = " " + fMatchedNode.Content(fContent) 66 | } else { 67 | fParams = fMatchedNode.Content(fContent) 68 | parametersSeen = true 69 | } 70 | default: 71 | // TODO: This is not the best way to get the return type; find a better way 72 | fReturnT = " " + fMatchedNode.Content(fContent) 73 | } 74 | } 75 | 76 | elem := fmt.Sprintf("func %s%s%s%s", fName, fTParams, fParams, fReturnT) 77 | 78 | elements = append(elements, elem) 79 | } 80 | resultChan <- Result{Results: elements} 81 | } 82 | 83 | func getGoTypes(resultChan chan<- Result, fContent []byte) { 84 | parser := ts.NewParser() 85 | parser.SetLanguage(tsgo.GetLanguage()) 86 | 87 | tree, err := parser.ParseCtx(context.Background(), nil, fContent) 88 | if err != nil { 89 | resultChan <- Result{Err: err} 90 | return 91 | } 92 | 93 | rootNode := tree.RootNode() 94 | 95 | q, err := ts.NewQuery([]byte(` 96 | (type_declaration) @type-dec 97 | `), tsgo.GetLanguage()) 98 | if err != nil { 99 | resultChan <- Result{Err: err} 100 | return 101 | } 102 | 103 | qc := ts.NewQueryCursor() 104 | 105 | qc.Exec(q, rootNode) 106 | 107 | var elements []string 108 | 109 | var typeDec string 110 | for { 111 | tMatch, cOk := qc.NextMatch() 112 | if !cOk { 113 | break 114 | } 115 | if len(tMatch.Captures) != 1 { 116 | continue 117 | } 118 | typeDec = tMatch.Captures[0].Node.Content(fContent) 119 | 120 | elements = append(elements, typeDec) 121 | } 122 | resultChan <- Result{Results: elements} 123 | } 124 | 125 | func getGoMethods(resultChan chan<- Result, fContent []byte) { 126 | parser := ts.NewParser() 127 | parser.SetLanguage(tsgo.GetLanguage()) 128 | 129 | tree, err := parser.ParseCtx(context.Background(), nil, fContent) 130 | if err != nil { 131 | resultChan <- Result{Err: err} 132 | return 133 | } 134 | 135 | rootNode := tree.RootNode() 136 | 137 | q, err := ts.NewQuery([]byte(` 138 | (method_declaration 139 | receiver: (parameter_list) @rec 140 | name: (field_identifier) @name 141 | parameters: (_)? @params 142 | result: (_)? @return-type 143 | ) 144 | `), tsgo.GetLanguage()) 145 | if err != nil { 146 | resultChan <- Result{Err: err} 147 | return 148 | } 149 | 150 | qc := ts.NewQueryCursor() 151 | 152 | qc.Exec(q, rootNode) 153 | 154 | var elements []string 155 | 156 | for { 157 | fMatch, cOk := qc.NextMatch() 158 | if !cOk { 159 | break 160 | } 161 | 162 | var fRec string 163 | var fName string 164 | var fParams string 165 | var fReturnT string 166 | var fMatchedNode *ts.Node 167 | 168 | receiverQueried := false 169 | parametersSeen := false 170 | for _, capture := range fMatch.Captures { 171 | fMatchedNode = capture.Node 172 | 173 | switch fMatchedNode.Type() { 174 | case "field_identifier": 175 | fName = fMatchedNode.Content(fContent) 176 | case "parameter_list": 177 | if !receiverQueried { 178 | fRec = fMatchedNode.Content(fContent) 179 | receiverQueried = true 180 | } else if !parametersSeen { 181 | fParams = fMatchedNode.Content(fContent) 182 | parametersSeen = true 183 | } else { 184 | fReturnT = " " + fMatchedNode.Content(fContent) 185 | } 186 | default: 187 | // TODO: This is not the best way to get the return type; find a better way 188 | fReturnT = " " + fMatchedNode.Content(fContent) 189 | } 190 | } 191 | 192 | elem := fmt.Sprintf("func %s %s%s%s", fRec, fName, fParams, fReturnT) 193 | 194 | elements = append(elements, elem) 195 | } 196 | resultChan <- Result{Results: elements} 197 | } 198 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: pr 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | changes: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | pull-requests: read 11 | outputs: 12 | code: ${{ steps.filter.outputs.code }} 13 | deps: ${{ steps.filter.outputs.deps }} 14 | release: ${{ steps.filter.outputs.release }} 15 | workflows: ${{ steps.filter.outputs.workflows }} 16 | yml: ${{ steps.filter.outputs.yml }} 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v6 20 | - uses: dorny/paths-filter@v3 21 | id: filter 22 | with: 23 | filters: | 24 | code: 25 | - "cmd/**" 26 | - "internal/**" 27 | - "tests/**" 28 | - "**/*.go" 29 | - "go.*" 30 | - ".golangci.yml" 31 | - "main.go" 32 | - ".github/actions/**" 33 | - ".github/workflows/pr.yml" 34 | deps: 35 | - "go.mod" 36 | - "go.sum" 37 | - ".github/workflows/pr.yml" 38 | release: 39 | - ".goreleaser.yml" 40 | - ".github/workflows/pr.yml" 41 | workflows: 42 | - ".github/workflows/**.yml" 43 | yml: 44 | - "**.yml" 45 | - "**.yaml" 46 | 47 | lint: 48 | needs: changes 49 | if: ${{ needs.changes.outputs.code == 'true' }} 50 | runs-on: ubuntu-latest 51 | steps: 52 | - uses: actions/checkout@v6 53 | - name: Set up Go 54 | uses: actions/setup-go@v6 55 | with: 56 | go-version-file: 'go.mod' 57 | - name: golangci-lint 58 | uses: golangci/golangci-lint-action@v9 59 | with: 60 | version: v2.4 61 | 62 | build: 63 | needs: changes 64 | if: ${{ needs.changes.outputs.code == 'true' }} 65 | strategy: 66 | matrix: 67 | os: [ubuntu-latest, macos-latest] 68 | runs-on: ${{ matrix.os }} 69 | steps: 70 | - uses: actions/checkout@v6 71 | - name: Set up Go 72 | uses: actions/setup-go@v6 73 | with: 74 | go-version-file: 'go.mod' 75 | - name: go build 76 | run: go build -v ./... 77 | 78 | test: 79 | needs: changes 80 | if: ${{ needs.changes.outputs.code == 'true' }} 81 | runs-on: ubuntu-latest 82 | steps: 83 | - uses: actions/checkout@v6 84 | - name: Set up Go 85 | uses: actions/setup-go@v6 86 | with: 87 | go-version-file: 'go.mod' 88 | - name: go test 89 | run: go test -v ./... 90 | 91 | run: 92 | needs: changes 93 | if: ${{ needs.changes.outputs.code == 'true' }} 94 | strategy: 95 | matrix: 96 | os: [ubuntu-latest, macos-latest] 97 | runs-on: ${{ matrix.os }} 98 | steps: 99 | - uses: actions/checkout@v6 100 | - name: Set up Go 101 | uses: actions/setup-go@v6 102 | with: 103 | go-version-file: 'go.mod' 104 | - name: go install 105 | run: go install . 106 | - name: run 107 | run: ./.github/scripts/run.sh 108 | 109 | lint-yaml: 110 | needs: changes 111 | if: ${{ needs.changes.outputs.yml == 'true' }} 112 | runs-on: ubuntu-latest 113 | steps: 114 | - name: Checkout 115 | uses: actions/checkout@v6 116 | - uses: dhth/composite-actions/.github/actions/lint-yaml@main 117 | 118 | lint-workflows: 119 | needs: changes 120 | if: ${{ needs.changes.outputs.workflows == 'true' }} 121 | runs-on: ubuntu-latest 122 | steps: 123 | - name: Checkout 124 | uses: actions/checkout@v6 125 | - uses: dhth/composite-actions/.github/actions/lint-actions@main 126 | 127 | release-check: 128 | needs: changes 129 | if: ${{ needs.changes.outputs.release == 'true' }} 130 | runs-on: ubuntu-latest 131 | steps: 132 | - uses: actions/checkout@v6 133 | with: 134 | fetch-depth: 0 135 | - name: Run release dry run 136 | run: | 137 | make release-dry-run 138 | 139 | dstlled-diff: 140 | needs: changes 141 | if: ${{ needs.changes.outputs.code == 'true' }} 142 | runs-on: ubuntu-latest 143 | permissions: 144 | contents: read 145 | pull-requests: write 146 | steps: 147 | - uses: actions/checkout@v6 148 | with: 149 | fetch-depth: 0 150 | - id: get-dstlled-diff 151 | uses: dhth/dstlled-diff-action@0ab616345f8816e9046fdefec81b14ada815aaca # v0.2.0 152 | with: 153 | pattern: '**.go' 154 | starting-commit: ${{ github.event.pull_request.base.sha }} 155 | ending-commit: ${{ github.event.pull_request.head.sha }} 156 | post-comment-on-pr: 'true' 157 | 158 | vulncheck: 159 | needs: changes 160 | if: ${{ needs.changes.outputs.deps == 'true' }} 161 | runs-on: ubuntu-latest 162 | steps: 163 | - uses: actions/checkout@v6 164 | - name: Set up Go 165 | uses: actions/setup-go@v6 166 | with: 167 | go-version-file: 'go.mod' 168 | - name: govulncheck 169 | run: | 170 | go install golang.org/x/vuln/cmd/govulncheck@latest 171 | govulncheck ./... 172 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'main' 7 | 8 | jobs: 9 | changes: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: read 13 | outputs: 14 | code: ${{ steps.filter.outputs.code }} 15 | deps: ${{ steps.filter.outputs.deps }} 16 | release: ${{ steps.filter.outputs.release }} 17 | workflows: ${{ steps.filter.outputs.workflows }} 18 | yml: ${{ steps.filter.outputs.yml }} 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v6 22 | - uses: dorny/paths-filter@v3 23 | id: filter 24 | with: 25 | filters: | 26 | code: 27 | - "cmd/**" 28 | - "internal/**" 29 | - "tests/**" 30 | - "**/*.go" 31 | - "go.*" 32 | - ".golangci.yml" 33 | - "main.go" 34 | - ".github/actions/**" 35 | - ".github/workflows/pr.yml" 36 | deps: 37 | - "go.mod" 38 | - "go.sum" 39 | - ".github/workflows/main.yml" 40 | release: 41 | - ".goreleaser.yml" 42 | - ".github/workflows/pr.yml" 43 | workflows: 44 | - ".github/workflows/**.yml" 45 | yml: 46 | - "**.yml" 47 | - "**.yaml" 48 | 49 | lint: 50 | needs: changes 51 | if: ${{ needs.changes.outputs.code == 'true' }} 52 | runs-on: ubuntu-latest 53 | steps: 54 | - uses: actions/checkout@v6 55 | - name: Set up Go 56 | uses: actions/setup-go@v6 57 | with: 58 | go-version-file: 'go.mod' 59 | - name: golangci-lint 60 | uses: golangci/golangci-lint-action@v9 61 | with: 62 | version: v2.4 63 | 64 | build: 65 | needs: changes 66 | if: ${{ needs.changes.outputs.code == 'true' }} 67 | strategy: 68 | matrix: 69 | os: [ubuntu-latest, macos-latest] 70 | runs-on: ${{ matrix.os }} 71 | steps: 72 | - uses: actions/checkout@v6 73 | - name: Set up Go 74 | uses: actions/setup-go@v6 75 | with: 76 | go-version-file: 'go.mod' 77 | - name: go build 78 | run: go build -v ./... 79 | 80 | test: 81 | needs: changes 82 | if: ${{ needs.changes.outputs.code == 'true' }} 83 | runs-on: ubuntu-latest 84 | steps: 85 | - uses: actions/checkout@v6 86 | - name: Set up Go 87 | uses: actions/setup-go@v6 88 | with: 89 | go-version-file: 'go.mod' 90 | - name: go test 91 | run: go test -v ./... 92 | 93 | run: 94 | needs: changes 95 | if: ${{ needs.changes.outputs.code == 'true' }} 96 | strategy: 97 | matrix: 98 | os: [ubuntu-latest, macos-latest] 99 | runs-on: ${{ matrix.os }} 100 | steps: 101 | - uses: actions/checkout@v6 102 | - name: Set up Go 103 | uses: actions/setup-go@v6 104 | with: 105 | go-version-file: 'go.mod' 106 | - name: go install 107 | run: go install . 108 | - name: run 109 | run: ./.github/scripts/run.sh 110 | 111 | lint-yaml: 112 | needs: changes 113 | if: ${{ needs.changes.outputs.yml == 'true' }} 114 | runs-on: ubuntu-latest 115 | steps: 116 | - name: Checkout 117 | uses: actions/checkout@v6 118 | - uses: dhth/composite-actions/.github/actions/lint-yaml@main 119 | 120 | lint-workflows: 121 | needs: changes 122 | if: ${{ needs.changes.outputs.workflows == 'true' }} 123 | runs-on: ubuntu-latest 124 | steps: 125 | - name: Checkout 126 | uses: actions/checkout@v6 127 | - uses: dhth/composite-actions/.github/actions/lint-actions@main 128 | 129 | release-check: 130 | needs: changes 131 | if: ${{ needs.changes.outputs.release == 'true' }} 132 | runs-on: ubuntu-latest 133 | steps: 134 | - uses: actions/checkout@v6 135 | with: 136 | fetch-depth: 0 137 | - name: Run release dry run 138 | run: | 139 | make release-dry-run 140 | 141 | dstlled-diff: 142 | needs: changes 143 | if: ${{ needs.changes.outputs.code == 'true' }} 144 | runs-on: ubuntu-latest 145 | permissions: 146 | contents: read 147 | pull-requests: write 148 | steps: 149 | - uses: actions/checkout@v6 150 | with: 151 | fetch-depth: 0 152 | - id: get-dstlled-diff 153 | uses: dhth/dstlled-diff-action@0ab616345f8816e9046fdefec81b14ada815aaca # v0.2.0 154 | with: 155 | pattern: '**.go' 156 | starting-commit: ${{ github.event.pull_request.base.sha }} 157 | ending-commit: ${{ github.event.pull_request.head.sha }} 158 | post-comment-on-pr: 'true' 159 | 160 | vulncheck: 161 | needs: changes 162 | if: ${{ needs.changes.outputs.deps == 'true' }} 163 | runs-on: ubuntu-latest 164 | steps: 165 | - uses: actions/checkout@v6 166 | - name: Set up Go 167 | uses: actions/setup-go@v6 168 | with: 169 | go-version-file: 'go.mod' 170 | - name: govulncheck 171 | run: | 172 | go install golang.org/x/vuln/cmd/govulncheck@latest 173 | govulncheck ./... 174 | -------------------------------------------------------------------------------- /tsutils/tsscala.go: -------------------------------------------------------------------------------- 1 | package tsutils 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | ts "github.com/smacker/go-tree-sitter" 8 | tsscala "github.com/smacker/go-tree-sitter/scala" 9 | ) 10 | 11 | func getScalaClasses(resultChan chan<- Result, fContent []byte) { 12 | parser := ts.NewParser() 13 | parser.SetLanguage(tsscala.GetLanguage()) 14 | 15 | tree, err := parser.ParseCtx(context.Background(), nil, fContent) 16 | if err != nil { 17 | resultChan <- Result{Err: err} 18 | return 19 | } 20 | 21 | rootNode := tree.RootNode() 22 | 23 | classQuery, err := ts.NewQuery([]byte(` 24 | (class_definition 25 | (modifiers)? @mod 26 | name: (identifier) @class-name 27 | type_parameters: (type_parameters)? @typeparams 28 | class_parameters: (class_parameters)? @cparams 29 | extend: (extends_clause)? @extends-clause 30 | ) 31 | `), tsscala.GetLanguage()) 32 | if err != nil { 33 | resultChan <- Result{Err: err} 34 | return 35 | } 36 | 37 | classQueryCur := ts.NewQueryCursor() 38 | 39 | classQueryCur.Exec(classQuery, rootNode) 40 | 41 | var elements []string 42 | 43 | for { 44 | classMatch, cOk := classQueryCur.NextMatch() 45 | 46 | if !cOk { 47 | break 48 | } 49 | 50 | var cModifiers string 51 | var cName string 52 | var cTypeParams string 53 | var cParams string 54 | var cExtendsCl string 55 | var cMatchedNode *ts.Node 56 | for _, capture := range classMatch.Captures { 57 | cMatchedNode = capture.Node 58 | 59 | switch cMatchedNode.Type() { 60 | case nodeTypeModifiers: 61 | cModifiers = cMatchedNode.Content(fContent) + " " 62 | case nodeTypeIdentifier: 63 | cName = cMatchedNode.Content(fContent) 64 | case "type_parameters": 65 | cTypeParams = cMatchedNode.Content(fContent) 66 | case "class_parameters": 67 | cParams = cMatchedNode.Content(fContent) 68 | case "extends_clause": 69 | cExtendsCl = " " + cMatchedNode.Content(fContent) 70 | } 71 | } 72 | 73 | elem := fmt.Sprintf("%sclass %s%s%s%s", cModifiers, cName, cTypeParams, cParams, cExtendsCl) 74 | elements = append(elements, elem) 75 | } 76 | resultChan <- Result{Results: elements} 77 | } 78 | 79 | func getScalaObjects(resultChan chan<- Result, fContent []byte) { 80 | parser := ts.NewParser() 81 | parser.SetLanguage(tsscala.GetLanguage()) 82 | 83 | tree, err := parser.ParseCtx(context.Background(), nil, fContent) 84 | if err != nil { 85 | resultChan <- Result{Err: err} 86 | return 87 | } 88 | 89 | rootNode := tree.RootNode() 90 | 91 | objectQuery, err := ts.NewQuery([]byte(` 92 | (object_definition 93 | name: (identifier) @name 94 | extend: (extends_clause)? @extends-clause 95 | ) 96 | `), tsscala.GetLanguage()) 97 | if err != nil { 98 | resultChan <- Result{Err: err} 99 | return 100 | } 101 | 102 | objectQueryCur := ts.NewQueryCursor() 103 | 104 | objectQueryCur.Exec(objectQuery, rootNode) 105 | 106 | var elements []string 107 | 108 | for { 109 | objectMatch, cOk := objectQueryCur.NextMatch() 110 | 111 | if !cOk { 112 | break 113 | } 114 | 115 | var oName string 116 | var oExtendsCl string 117 | var oMatchedNode *ts.Node 118 | for _, capture := range objectMatch.Captures { 119 | oMatchedNode = capture.Node 120 | 121 | switch oMatchedNode.Type() { 122 | case nodeTypeIdentifier: 123 | oName = oMatchedNode.Content(fContent) 124 | case "extends_clause": 125 | oExtendsCl = " " + oMatchedNode.Content(fContent) 126 | } 127 | } 128 | 129 | elem := fmt.Sprintf("object %s%s", oName, oExtendsCl) 130 | 131 | elements = append(elements, elem) 132 | } 133 | resultChan <- Result{Results: elements} 134 | } 135 | 136 | func getScalaFunctions(resultChan chan<- Result, fContent []byte) { 137 | parser := ts.NewParser() 138 | parser.SetLanguage(tsscala.GetLanguage()) 139 | 140 | tree, err := parser.ParseCtx(context.Background(), nil, fContent) 141 | if err != nil { 142 | resultChan <- Result{Err: err} 143 | return 144 | } 145 | 146 | rootNode := tree.RootNode() 147 | funcQuery, err := ts.NewQuery([]byte(` 148 | (function_definition 149 | (modifiers)? @access-modifier 150 | name: (identifier) @fname 151 | type_parameters: (type_parameters)? @type-params 152 | (parameters)+ @fparams 153 | return_type: (_)? @return-type 154 | ) 155 | `), tsscala.GetLanguage()) 156 | if err != nil { 157 | resultChan <- Result{Err: err} 158 | return 159 | } 160 | funcQueryCur := ts.NewQueryCursor() 161 | 162 | funcQueryCur.Exec(funcQuery, rootNode) 163 | 164 | var elements []string 165 | 166 | for { 167 | funcMatch, fOk := funcQueryCur.NextMatch() 168 | 169 | if !fOk { 170 | break 171 | } 172 | 173 | var fAccessModifier string 174 | var fIdentifer string 175 | var fTParams string 176 | var fParams string 177 | var fReturnType string 178 | 179 | for _, capture := range funcMatch.Captures { 180 | fMatchedNode := capture.Node 181 | switch fMatchedNode.Type() { 182 | case nodeTypeModifiers: 183 | fAccessModifier = fMatchedNode.Content(fContent) + " " 184 | case nodeTypeIdentifier: 185 | fIdentifer = fMatchedNode.Content(fContent) 186 | case nodeTypeTypeParameters: 187 | fTParams = fMatchedNode.Content(fContent) 188 | case nodeTypeParameters: 189 | fParams += fMatchedNode.Content(fContent) 190 | default: 191 | // TODO: This is not the best way to get the return type; find a better way 192 | fReturnType = ": " + fMatchedNode.Content(fContent) 193 | } 194 | } 195 | 196 | elem := fmt.Sprintf("%sdef %s%s%s%s", fAccessModifier, fIdentifer, fTParams, fParams, fReturnType) 197 | elements = append(elements, elem) 198 | } 199 | resultChan <- Result{Results: elements} 200 | } 201 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 |

dstll

3 |

4 | Build Status 5 | Vulnerability Check 6 | Latest release 7 | Commits since latest release 8 |

9 |

10 | 11 | `dstll` *(short for "distill")* gives you a high level overview of various 12 | "constructs" in your code. 13 | 14 |

15 | Usage 16 |

17 | 18 | Motivation 19 | --- 20 | 21 | Sometimes, you want to quickly understand how a project is organized. It could 22 | be a new repo you're working on, or a specific part of a project you're 23 | unfamiliar with. When given a list of files you're curious about, `dstll` shows 24 | you a list of signatures representing various "code constructs" found in those 25 | files, such as functions, methods, classes, traits, interfaces, objects, type 26 | aliases, enums, etc. 27 | 28 | 📜 Languages supported 29 | --- 30 | 31 | - ![go](https://img.shields.io/badge/go-grey?logo=go) 32 | - ![python](https://img.shields.io/badge/python-grey?logo=python) 33 | - ![rust](https://img.shields.io/badge/rust-grey?logo=rust) 34 | - ![scala 2](https://img.shields.io/badge/scala-grey?logo=scala) 35 | - more to come 36 | 37 | 💾 Installation 38 | --- 39 | 40 | **go**: 41 | 42 | ```sh 43 | go install github.com/dhth/dstll@latest 44 | ``` 45 | 46 | Or get the binary directly from a 47 | [release](https://github.com/dhth/dstll/releases). Read more about verifying the 48 | authenticity of released artifacts [here](#-verifying-release-artifacts). 49 | 50 | ⚡️ Usage 51 | --- 52 | 53 | ```bash 54 | # print findings to stdout 55 | dstll [PATH ...] 56 | 57 | # write findings to a directory 58 | dstll write [PATH ...] -o /var/tmp/findings 59 | 60 | # serve findings via a web server 61 | dstll serve [PATH ...] -o /var/tmp/findings 62 | 63 | # open TUI 64 | dstll tui 65 | ``` 66 | 67 |

68 | Usage 69 |

70 | 71 |

72 | Usage 73 |

74 | 75 |

76 | Usage 77 |

78 | 79 | 🛠️ Configuration 80 | --- 81 | 82 | Create a configuration file that looks like the following. By default, 83 | `dstll` will look for this file at `~/.config/dstll/dstll.yml`. 84 | 85 | ```toml 86 | view-file-command = ["your", "command"] 87 | # for example, ["bat", "--style", "plain", "--paging", "always"] 88 | # will run 'bat --style plain --paging always ' 89 | ``` 90 | 91 | Δ dstlled-diff 92 | --- 93 | 94 | `dstll` can be used to generate specialized diffs that only compare changes in 95 | signatures of "code constructs" between two git revisions. This functionality is 96 | available as a Github Action via [dstlled-diff][2]. 97 | 98 | Examples 99 | --- 100 | 101 | Running `dstll` in the [scala][1] repo gives the following output: 102 | 103 | ``` 104 | $ dstll $(git ls-files src/compiler/scala/tools/tasty | head -n 3) 105 | 106 | -> src/compiler/scala/tools/tasty/ErasedTypeRef.scala 107 | 108 | object ErasedTypeRef 109 | 110 | class ErasedTypeRef(qualifiedName: TypeName, arrayDims: Int) 111 | 112 | def apply(tname: TastyName): ErasedTypeRef 113 | 114 | def name(qual: TastyName, tname: SimpleName, isModule: Boolean) 115 | 116 | def specialised(qual: TastyName, terminal: String, isModule: Boolean, arrayDims: Int = 0): ErasedTypeRef 117 | 118 | ................................................................................ 119 | 120 | -> src/compiler/scala/tools/tasty/Attributes.scala 121 | 122 | object Attributes 123 | 124 | private class ConcreteAttributes(val isJava: Boolean) extends Attributes 125 | 126 | ................................................................................ 127 | 128 | -> src/compiler/scala/tools/tasty/AttributeUnpickler.scala 129 | 130 | object AttributeUnpickler 131 | 132 | def attributes(reader: TastyReader): Attributes 133 | ``` 134 | 135 | More examples can be found [here](./examples). 136 | 137 | [1]: https://github.com/scala/scala 138 | [2]: https://github.com/dhth/dstlled-diff-action 139 | 140 | 🔐 Verifying release artifacts 141 | --- 142 | 143 | In case you get the `dstll` binary directly from a [release][2], you may want to 144 | verify its authenticity. Checksums are applied to all released artifacts. Steps 145 | to verify (replace `A.B.C` in the commands listed below with the version you 146 | want): 147 | 148 | 1. Download the checksum file and the compressed archive you want, and validate 149 | its checksum: 150 | 151 | ```shell 152 | curl -sSLO https://github.com/dhth/dstll/releases/download/vA.B.C/dstll_A.B.C_checksums.txt 153 | curl -sSLO https://github.com/dhth/dstll/releases/download/vA.B.C/dstll_A.B.C_linux_amd64.tar.gz 154 | sha256sum --ignore-missing -c dstll_A.B.C_checksums.txt 155 | ``` 156 | 157 | 2. If checksum validation goes through, uncompress the archive: 158 | 159 | ```shell 160 | tar -xzf dstll_A.B.C_linux_amd64.tar.gz 161 | ./dstll -h 162 | # profit! 163 | ``` 164 | -------------------------------------------------------------------------------- /server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "embed" 7 | "errors" 8 | "fmt" 9 | htemplate "html/template" 10 | "log" 11 | "net" 12 | "net/http" 13 | "os" 14 | "os/signal" 15 | "strconv" 16 | "strings" 17 | "syscall" 18 | "text/template" 19 | "time" 20 | 21 | "github.com/alecthomas/chroma/quick" 22 | "github.com/dhth/dstll/internal/utils" 23 | "github.com/dhth/dstll/tsutils" 24 | ) 25 | 26 | const ( 27 | startPort = 8100 28 | endPort = 8500 29 | minResultsForLoadingBar = 400 30 | resultsPerPage = 10 31 | ) 32 | 33 | const ( 34 | backgroundColor = "#1f1f24" 35 | filepathColor = "#ffd166" 36 | headerColor = "#f15bb5" 37 | navigationColor = "#00bbf9" 38 | activePageColor = "#f15bb5" 39 | ) 40 | 41 | var ErrNoPortOpen = errors.New("no open port found") 42 | 43 | //go:embed html 44 | var tplFolder embed.FS 45 | 46 | type htmlData struct { 47 | Results map[string][]htemplate.HTML 48 | Pages []int 49 | CurrentPage int 50 | NumFiles int 51 | BackgroundColor string 52 | HeaderColor string 53 | FilepathColor string 54 | NavigationColor string 55 | ActivePageColor string 56 | } 57 | 58 | func serveResults(files []string) func(w http.ResponseWriter, r *http.Request) { 59 | return func(w http.ResponseWriter, r *http.Request) { 60 | page := r.PathValue("page") 61 | pageNum := 1 62 | var err error 63 | 64 | if page != "" { 65 | pageNum, err = strconv.Atoi(page) 66 | if err != nil { 67 | log.Printf("Got bad page: %v", page) 68 | http.Error(w, "Bad page", http.StatusBadRequest) 69 | return 70 | } 71 | } 72 | 73 | startIndex, endIndex, err := utils.GetIndexRange(pageNum, len(files), resultsPerPage) 74 | if err != nil { 75 | http.Error(w, fmt.Sprintf("Bad page: %q", err), http.StatusBadRequest) 76 | return 77 | } 78 | 79 | results := getResults(files[startIndex:endIndex]) 80 | numPages := len(files) / resultsPerPage 81 | 82 | if len(files)%resultsPerPage != 0 { 83 | numPages++ 84 | } 85 | 86 | pages := make([]int, numPages) 87 | 88 | for i := range numPages { 89 | pages[i] = i + 1 90 | } 91 | 92 | res1 := htmlData{ 93 | Results: results, 94 | Pages: pages, 95 | CurrentPage: pageNum, 96 | NumFiles: len(results), 97 | BackgroundColor: backgroundColor, 98 | HeaderColor: headerColor, 99 | FilepathColor: filepathColor, 100 | NavigationColor: navigationColor, 101 | ActivePageColor: activePageColor, 102 | } 103 | 104 | w.Header().Add("Server", "Go") 105 | 106 | files := []string{ 107 | "html/base.tmpl", 108 | "html/partials/nav.tmpl", 109 | "html/pages/home.tmpl", 110 | } 111 | 112 | ts, err := template.ParseFS(tplFolder, files...) 113 | if err != nil { 114 | log.Printf("Error getting template: %v", err) 115 | http.Error(w, "Internal Server Error", http.StatusInternalServerError) 116 | return 117 | } 118 | 119 | err = ts.ExecuteTemplate(w, "base", res1) 120 | if err != nil { 121 | log.Printf("Error executing template: %v", err) 122 | http.Error(w, "Internal Server Error", http.StatusInternalServerError) 123 | return 124 | } 125 | } 126 | } 127 | 128 | func getResults(fPaths []string) map[string][]htemplate.HTML { 129 | resultsChan := make(chan tsutils.Result) 130 | results := make(map[string][]htemplate.HTML) 131 | 132 | for _, fPath := range fPaths { 133 | go tsutils.GetLayout(resultsChan, fPath) 134 | } 135 | 136 | for range fPaths { 137 | r := <-resultsChan 138 | if r.Err != nil { 139 | continue 140 | } 141 | 142 | if len(r.Results) == 0 { 143 | continue 144 | } 145 | 146 | htmlResults := make([]htemplate.HTML, len(r.Results)) 147 | 148 | elementsCombined := strings.Join(r.Results, "\n\n") 149 | var b bytes.Buffer 150 | 151 | err := quick.Highlight(&b, elementsCombined, r.FPath, "html", "xcode-dark") 152 | if err != nil { 153 | htmlResults = append(htmlResults, htemplate.HTML(fmt.Sprintf("

%s

", elementsCombined))) 154 | } else { 155 | htmlResults = append(htmlResults, htemplate.HTML(b.String())) 156 | } 157 | results[r.FPath] = htmlResults 158 | } 159 | 160 | return results 161 | } 162 | 163 | func Start(fPaths []string) { 164 | mux := http.NewServeMux() 165 | 166 | mux.HandleFunc("GET /{$}", serveResults(fPaths)) 167 | mux.HandleFunc("GET /page/{page}", serveResults(fPaths)) 168 | 169 | port, err := findOpenPort(startPort, endPort) 170 | if err != nil { 171 | fmt.Printf("Couldn't find an open port between %d-%d", startPort, endPort) 172 | } 173 | server := &http.Server{ 174 | Addr: fmt.Sprintf("127.0.0.1:%d", port), 175 | Handler: mux, 176 | } 177 | 178 | go func() { 179 | fmt.Printf("Starting server. Open http://%s/ in your browser.\n", server.Addr) 180 | err := server.ListenAndServe() 181 | if !errors.Is(err, http.ErrServerClosed) { 182 | fmt.Printf("Error running server: %q", err) 183 | } 184 | }() 185 | 186 | sigChan := make(chan os.Signal, 1) 187 | signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) 188 | <-sigChan 189 | 190 | shutDownCtx, shutDownRelease := context.WithTimeout(context.Background(), time.Second*3) 191 | defer shutDownRelease() 192 | 193 | err = server.Shutdown(shutDownCtx) 194 | if err != nil { 195 | fmt.Printf("Error shutting down: %v\nTrying forceful shutdown\n", err) 196 | closeErr := server.Close() 197 | if closeErr != nil { 198 | fmt.Printf("Forceful shutdown failed: %v\n", closeErr) 199 | } else { 200 | fmt.Printf("Forceful shutdown successful\n") 201 | } 202 | } 203 | fmt.Printf("\nbye 👋\n") 204 | } 205 | 206 | func findOpenPort(startPort, endPort int) (int, error) { 207 | for port := startPort; port <= endPort; port++ { 208 | address := fmt.Sprintf("127.0.0.1:%d", port) 209 | listener, err := net.Listen("tcp", address) 210 | if err == nil { 211 | defer listener.Close() 212 | return port, nil 213 | } 214 | } 215 | return 0, ErrNoPortOpen 216 | } 217 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | // "bufio" 5 | "errors" 6 | "fmt" 7 | "io/fs" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | 12 | server "github.com/dhth/dstll/server" 13 | "github.com/dhth/dstll/tsutils" 14 | "github.com/dhth/dstll/ui" 15 | "github.com/spf13/cobra" 16 | "github.com/spf13/pflag" 17 | "github.com/spf13/viper" 18 | ) 19 | 20 | const ( 21 | configFileName = "dstll/dstll.toml" 22 | envPrefix = "DSTLL" 23 | ) 24 | 25 | var ( 26 | errCouldntGetHomeDir = errors.New("couldn't get home directory") 27 | errCouldntGetConfigDir = errors.New("couldn't get config directory") 28 | errConfigFileExtIncorrect = errors.New("config file must be a TOML file") 29 | errConfigFileDoesntExist = errors.New("config file does not exist") 30 | errCouldntCreateDirectory = errors.New("could not create directory") 31 | errNoPathsProvided = errors.New("no file paths provided") 32 | ) 33 | 34 | func Execute() error { 35 | rootCmd, err := NewRootCommand() 36 | if err != nil { 37 | return err 38 | } 39 | 40 | err = rootCmd.Execute() 41 | return err 42 | } 43 | 44 | func NewRootCommand() (*cobra.Command, error) { 45 | var ( 46 | trimPrefix string 47 | fPaths []string 48 | userHomeDir string 49 | configFilePath string 50 | configPathFull string 51 | 52 | // root 53 | plainOutput bool 54 | 55 | // write 56 | writeOutputDir string 57 | writeQuiet bool 58 | 59 | // tui 60 | tuiViewFileCmdInput []string 61 | tuiViewFileCmd []string 62 | ) 63 | 64 | rootCmd := &cobra.Command{ 65 | Use: "dstll [PATH ...]", 66 | Short: "dstll gives you a high level overview of various constructs in your code", 67 | Long: `dstll gives you a high level overview of various constructs in your code. 68 | 69 | Its findings can be printed to stdout, written to files, or presented via a TUI/web interface. 70 | `, 71 | SilenceUsage: true, 72 | Args: cobra.MinimumNArgs(1), 73 | PersistentPreRunE: func(cmd *cobra.Command, args []string) error { 74 | configPathFull = expandTilde(configFilePath, userHomeDir) 75 | 76 | if filepath.Ext(configPathFull) != ".toml" { 77 | return errConfigFileExtIncorrect 78 | } 79 | _, err := os.Stat(configPathFull) 80 | 81 | fl := cmd.Flags() 82 | if fl != nil { 83 | cf := fl.Lookup("config-path") 84 | if cf != nil && cf.Changed && errors.Is(err, fs.ErrNotExist) { 85 | return errConfigFileDoesntExist 86 | } 87 | } 88 | 89 | var v *viper.Viper 90 | v, err = initializeConfig(cmd, configPathFull) 91 | if err != nil { 92 | return err 93 | } 94 | 95 | calledAs := cmd.CalledAs() 96 | if calledAs == "tui" { 97 | // pretty ugly hack to get around the fact that 98 | // v.GetStringSlice("view_file_command") always seems to prioritize the config file 99 | if len(tuiViewFileCmdInput) > 0 && len(tuiViewFileCmdInput[0]) > 0 && !strings.HasPrefix(tuiViewFileCmdInput[0], "[") { 100 | tuiViewFileCmd = tuiViewFileCmdInput 101 | } else { 102 | tuiViewFileCmd = v.GetStringSlice("view-file-command") 103 | } 104 | return nil 105 | } 106 | 107 | fPaths = args 108 | if len(fPaths) == 0 { 109 | return errNoPathsProvided 110 | } 111 | 112 | return nil 113 | }, 114 | Run: func(_ *cobra.Command, _ []string) { 115 | results := tsutils.GetResults(fPaths) 116 | if len(results) == 0 { 117 | return 118 | } 119 | 120 | ui.ShowResults(results, trimPrefix, plainOutput) 121 | }, 122 | } 123 | 124 | writeCmd := &cobra.Command{ 125 | Use: "write [PATH ...]", 126 | Short: "Write findings to files", 127 | Args: cobra.MinimumNArgs(1), 128 | RunE: func(_ *cobra.Command, _ []string) error { 129 | results := tsutils.GetResults(fPaths) 130 | if len(results) == 0 { 131 | return nil 132 | } 133 | 134 | err := createDir(writeOutputDir) 135 | if err != nil { 136 | return fmt.Errorf("%w: %s", errCouldntCreateDirectory, err.Error()) 137 | } 138 | 139 | ui.WriteResults(results, writeOutputDir, writeQuiet) 140 | return nil 141 | }, 142 | } 143 | 144 | tuiCmd := &cobra.Command{ 145 | Use: "tui", 146 | Short: "Open dstll TUI", 147 | Args: cobra.NoArgs, 148 | RunE: func(_ *cobra.Command, _ []string) error { 149 | config := ui.Config{ 150 | ViewFileCmd: tuiViewFileCmd, 151 | } 152 | return ui.RenderUI(config) 153 | }, 154 | } 155 | 156 | serveCmd := &cobra.Command{ 157 | Use: "serve [PATH ...]", 158 | Short: "Serve findings via a web server", 159 | Args: cobra.MinimumNArgs(1), 160 | Run: func(_ *cobra.Command, _ []string) { 161 | server.Start(fPaths) 162 | }, 163 | } 164 | 165 | userHomeDir, err := os.UserHomeDir() 166 | if err != nil { 167 | return nil, fmt.Errorf("%w: %s", errCouldntGetHomeDir, err.Error()) 168 | } 169 | 170 | userConfigDir, err := os.UserConfigDir() 171 | if err != nil { 172 | return nil, fmt.Errorf("%w: %s", errCouldntGetConfigDir, err.Error()) 173 | } 174 | 175 | var emptySlice []string 176 | defaultConfigPath := filepath.Join(userConfigDir, configFileName) 177 | 178 | // rootCmd.Flags().StringSliceVarP(&fPaths, "files", "f", emptySlice, "paths of files to run dstll on") 179 | rootCmd.Flags().StringVarP(&configFilePath, "config-path", "c", defaultConfigPath, "location of dstll's config file") 180 | rootCmd.Flags().StringVarP(&trimPrefix, "trim-prefix", "t", "", "prefix to trim from the file path") 181 | rootCmd.Flags().BoolVarP(&plainOutput, "plain", "p", false, "output plain text") 182 | 183 | writeCmd.Flags().StringVarP(&writeOutputDir, "output-dir", "o", "dstll-output", "directory to write findings in") 184 | writeCmd.Flags().BoolVarP(&writeQuiet, "quiet", "q", false, "suppress output") 185 | 186 | tuiCmd.Flags().StringSliceVar(&tuiViewFileCmdInput, "view-file-command", emptySlice, "command to use to view, eg. --view-file-command='bat,--style,plain,--paging,always'") 187 | 188 | rootCmd.AddCommand(writeCmd) 189 | rootCmd.AddCommand(tuiCmd) 190 | rootCmd.AddCommand(serveCmd) 191 | 192 | rootCmd.CompletionOptions.DisableDefaultCmd = true 193 | 194 | return rootCmd, nil 195 | } 196 | 197 | func initializeConfig(cmd *cobra.Command, configFile string) (*viper.Viper, error) { 198 | v := viper.New() 199 | 200 | v.SetConfigName(filepath.Base(configFile)) 201 | v.SetConfigType("toml") 202 | v.AddConfigPath(filepath.Dir(configFile)) 203 | 204 | err := v.ReadInConfig() 205 | if err != nil && !errors.As(err, &viper.ConfigFileNotFoundError{}) { 206 | return v, err 207 | } 208 | 209 | v.SetEnvPrefix(envPrefix) 210 | v.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) 211 | v.AutomaticEnv() 212 | 213 | err = bindFlags(cmd, v) 214 | if err != nil { 215 | return v, err 216 | } 217 | 218 | return v, nil 219 | } 220 | 221 | func bindFlags(cmd *cobra.Command, v *viper.Viper) error { 222 | var err error 223 | cmd.Flags().VisitAll(func(f *pflag.Flag) { 224 | configName := strings.ReplaceAll(f.Name, "-", "_") 225 | 226 | if !f.Changed && v.IsSet(configName) { 227 | val := v.Get(configName) 228 | fErr := cmd.Flags().Set(f.Name, fmt.Sprintf("%v", val)) 229 | if fErr != nil { 230 | err = fErr 231 | return 232 | } 233 | } 234 | }) 235 | return err 236 | } 237 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek= 2 | github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s= 3 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 4 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 5 | github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= 6 | github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= 7 | github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= 8 | github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= 9 | github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI= 10 | github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI= 11 | github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 12 | github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 13 | github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= 14 | github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= 15 | github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= 16 | github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 17 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 18 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 19 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 20 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 21 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 22 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 23 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 24 | github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= 25 | github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= 26 | github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 27 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 28 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 29 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 30 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 31 | github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= 32 | github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 33 | github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= 34 | github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 35 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 36 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 37 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 38 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 39 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 40 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 41 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 42 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 43 | github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= 44 | github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 45 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 46 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 47 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 48 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 49 | github.com/mattn/go-runewidth v0.0.17 h1:78v8ZlW0bP43XfmAfPsdXcoNCelfMHsDmd/pkENfrjQ= 50 | github.com/mattn/go-runewidth v0.0.17/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 51 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 52 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 53 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 54 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 55 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 56 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 57 | github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= 58 | github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 59 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 60 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 61 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 62 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 63 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 64 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 65 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 66 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 67 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 68 | github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= 69 | github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= 70 | github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82 h1:6C8qej6f1bStuePVkLSFxoU22XBS165D3klxlzRg8F4= 71 | github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82/go.mod h1:xe4pgH49k4SsmkQq5OT8abwhWmnzkhpgnXeekbx2efw= 72 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= 73 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= 74 | github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= 75 | github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= 76 | github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= 77 | github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= 78 | github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= 79 | github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= 80 | github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 81 | github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= 82 | github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 83 | github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= 84 | github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= 85 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 86 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 87 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 88 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 89 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 90 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 91 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 92 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 93 | go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= 94 | go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 95 | golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI= 96 | golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= 97 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 98 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 99 | golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= 100 | golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 101 | golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= 102 | golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= 103 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 104 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 105 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 106 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 107 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 108 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 109 | -------------------------------------------------------------------------------- /filepicker/filepicker.go: -------------------------------------------------------------------------------- 1 | // A modded version of https://github.com/charmbracelet/bubbles/blob/master/filepicker/filepicker.go 2 | package filepicker 3 | 4 | import ( 5 | "os" 6 | "path/filepath" 7 | "sort" 8 | "strings" 9 | "sync" 10 | 11 | "github.com/charmbracelet/bubbles/key" 12 | tea "github.com/charmbracelet/bubbletea" 13 | "github.com/charmbracelet/lipgloss" 14 | ) 15 | 16 | var ( 17 | lastID int 18 | idMtx sync.Mutex 19 | ) 20 | 21 | // IsHidden reports whether a file is hidden or not. 22 | func IsHidden(file string) (bool, error) { 23 | return strings.HasPrefix(file, "."), nil 24 | } 25 | 26 | // Return the next ID we should use on the Model. 27 | func nextID() int { 28 | idMtx.Lock() 29 | defer idMtx.Unlock() 30 | lastID++ 31 | return lastID 32 | } 33 | 34 | // New returns a new filepicker model with default styling and key bindings. 35 | func New() Model { 36 | return Model{ 37 | id: nextID(), 38 | CurrentDirectory: ".", 39 | cursor: ">", 40 | AllowedTypes: []string{}, 41 | selected: 0, 42 | ShowHidden: false, 43 | DirAllowed: false, 44 | FileAllowed: true, 45 | AutoHeight: true, 46 | Height: 0, 47 | Width: 40, 48 | max: 0, 49 | min: 0, 50 | selectedStack: newStack(), 51 | minStack: newStack(), 52 | maxStack: newStack(), 53 | KeyMap: DefaultKeyMap(), 54 | Styles: DefaultStyles(), 55 | } 56 | } 57 | 58 | type errorMsg struct { 59 | err error 60 | } 61 | 62 | type readDirMsg struct { 63 | id int 64 | entries []os.DirEntry 65 | } 66 | 67 | const ( 68 | marginBottom = 5 69 | fileSizeWidth = 7 70 | paddingLeft = 2 71 | ) 72 | 73 | // KeyMap defines key bindings for each user action. 74 | type KeyMap struct { 75 | GoToTop key.Binding 76 | GoToLast key.Binding 77 | Down key.Binding 78 | Up key.Binding 79 | PageUp key.Binding 80 | PageDown key.Binding 81 | Back key.Binding 82 | Open key.Binding 83 | } 84 | 85 | // DefaultKeyMap defines the default keybindings. 86 | func DefaultKeyMap() KeyMap { 87 | return KeyMap{ 88 | GoToTop: key.NewBinding(key.WithKeys("g"), key.WithHelp("g", "first")), 89 | GoToLast: key.NewBinding(key.WithKeys("G"), key.WithHelp("G", "last")), 90 | Down: key.NewBinding(key.WithKeys("j", "down", "ctrl+n"), key.WithHelp("j", "down")), 91 | Up: key.NewBinding(key.WithKeys("k", "up", "ctrl+p"), key.WithHelp("k", "up")), 92 | PageUp: key.NewBinding(key.WithKeys("K", "pgup"), key.WithHelp("pgup", "page up")), 93 | PageDown: key.NewBinding(key.WithKeys("J", "pgdown"), key.WithHelp("pgdown", "page down")), 94 | Back: key.NewBinding(key.WithKeys("h", "backspace", "left", "esc"), key.WithHelp("h", "back")), 95 | Open: key.NewBinding(key.WithKeys("l", "right", "enter"), key.WithHelp("l", "open")), 96 | } 97 | } 98 | 99 | // Styles defines the possible customizations for styles in the file picker. 100 | type Styles struct { 101 | DisabledCursor lipgloss.Style 102 | Cursor lipgloss.Style 103 | Symlink lipgloss.Style 104 | Directory lipgloss.Style 105 | File lipgloss.Style 106 | DisabledFile lipgloss.Style 107 | Permission lipgloss.Style 108 | Selected lipgloss.Style 109 | DisabledSelected lipgloss.Style 110 | FileSize lipgloss.Style 111 | EmptyDirectory lipgloss.Style 112 | } 113 | 114 | // DefaultStyles defines the default styling for the file picker. 115 | func DefaultStyles() Styles { 116 | return DefaultStylesWithRenderer(lipgloss.DefaultRenderer()) 117 | } 118 | 119 | // DefaultStylesWithRenderer defines the default styling for the file picker, 120 | // with a given Lip Gloss renderer. 121 | func DefaultStylesWithRenderer(r *lipgloss.Renderer) Styles { 122 | return Styles{ 123 | DisabledCursor: r.NewStyle().Foreground(lipgloss.Color("247")), 124 | Cursor: r.NewStyle().Foreground(lipgloss.Color("212")), 125 | Symlink: r.NewStyle().Foreground(lipgloss.Color("36")), 126 | Directory: r.NewStyle().Foreground(lipgloss.Color("99")), 127 | File: r.NewStyle(), 128 | DisabledFile: r.NewStyle().Foreground(lipgloss.Color("243")), 129 | DisabledSelected: r.NewStyle().Foreground(lipgloss.Color("247")), 130 | Permission: r.NewStyle().Foreground(lipgloss.Color("244")), 131 | Selected: r.NewStyle().Foreground(lipgloss.Color("212")).Bold(true), 132 | FileSize: r.NewStyle().Foreground(lipgloss.Color("240")).Width(fileSizeWidth).Align(lipgloss.Right), 133 | EmptyDirectory: r.NewStyle().Foreground(lipgloss.Color("240")).PaddingLeft(paddingLeft).SetString("Bummer. No Files Found."), 134 | } 135 | } 136 | 137 | // Model represents a file picker. 138 | type Model struct { 139 | id int 140 | 141 | // Path is the path which the user has selected with the file picker. 142 | Path string 143 | Current string 144 | IsCurrentAFile bool 145 | 146 | // CurrentDirectory is the directory that the user is currently in. 147 | CurrentDirectory string 148 | 149 | // AllowedTypes specifies which file types the user may select. 150 | // If empty the user may select any file. 151 | AllowedTypes []string 152 | 153 | KeyMap KeyMap 154 | files []os.DirEntry 155 | ShowHidden bool 156 | DirAllowed bool 157 | FileAllowed bool 158 | 159 | FileSelected string 160 | selected int 161 | selectedStack stack 162 | 163 | min int 164 | max int 165 | maxStack stack 166 | minStack stack 167 | 168 | Height int 169 | Width int 170 | AutoHeight bool 171 | 172 | cursor string 173 | Styles Styles 174 | } 175 | 176 | type stack struct { 177 | Push func(int) 178 | Pop func() int 179 | Length func() int 180 | } 181 | 182 | func newStack() stack { 183 | slice := make([]int, 0) 184 | return stack{ 185 | Push: func(i int) { 186 | slice = append(slice, i) 187 | }, 188 | Pop: func() int { 189 | res := slice[len(slice)-1] 190 | slice = slice[:len(slice)-1] 191 | return res 192 | }, 193 | Length: func() int { 194 | return len(slice) 195 | }, 196 | } 197 | } 198 | 199 | func (m *Model) pushView(selected, minItem, maxItem int) { 200 | m.selectedStack.Push(selected) 201 | m.minStack.Push(minItem) 202 | m.maxStack.Push(maxItem) 203 | } 204 | 205 | func (m *Model) popView() (int, int, int) { 206 | return m.selectedStack.Pop(), m.minStack.Pop(), m.maxStack.Pop() 207 | } 208 | 209 | func (m Model) readDir(path string, showHidden bool) tea.Cmd { 210 | return func() tea.Msg { 211 | dirEntries, err := os.ReadDir(path) 212 | if err != nil { 213 | return errorMsg{err} 214 | } 215 | 216 | sort.Slice(dirEntries, func(i, j int) bool { 217 | if dirEntries[i].IsDir() == dirEntries[j].IsDir() { 218 | return dirEntries[i].Name() < dirEntries[j].Name() 219 | } 220 | return dirEntries[i].IsDir() 221 | }) 222 | 223 | if showHidden { 224 | return readDirMsg{id: m.id, entries: dirEntries} 225 | } 226 | 227 | var sanitizedDirEntries []os.DirEntry 228 | for _, dirEntry := range dirEntries { 229 | isHidden, _ := IsHidden(dirEntry.Name()) 230 | if isHidden { 231 | continue 232 | } 233 | sanitizedDirEntries = append(sanitizedDirEntries, dirEntry) 234 | } 235 | return readDirMsg{id: m.id, entries: sanitizedDirEntries} 236 | } 237 | } 238 | 239 | // Init initializes the file picker model. 240 | func (m Model) Init() tea.Cmd { 241 | return m.readDir(m.CurrentDirectory, m.ShowHidden) 242 | } 243 | 244 | func (m *Model) setCurrent() { 245 | f := m.files[m.selected] 246 | m.IsCurrentAFile = !f.IsDir() 247 | m.Current = filepath.Join(m.CurrentDirectory, f.Name()) 248 | } 249 | 250 | // Update handles user interactions within the file picker model. 251 | func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { 252 | switch msg := msg.(type) { 253 | case readDirMsg: 254 | if msg.id != m.id { 255 | break 256 | } 257 | m.files = msg.entries 258 | m.max = maxInt(m.max, m.Height-1) 259 | 260 | m.setCurrent() 261 | 262 | case tea.WindowSizeMsg: 263 | if m.AutoHeight { 264 | m.Height = msg.Height - marginBottom 265 | } 266 | m.max = m.Height - 1 267 | case tea.KeyMsg: 268 | switch { 269 | case key.Matches(msg, m.KeyMap.GoToTop): 270 | m.selected = 0 271 | m.min = 0 272 | m.max = m.Height - 1 273 | 274 | m.setCurrent() 275 | case key.Matches(msg, m.KeyMap.GoToLast): 276 | m.selected = len(m.files) - 1 277 | m.min = len(m.files) - m.Height 278 | m.max = len(m.files) - 1 279 | 280 | m.setCurrent() 281 | case key.Matches(msg, m.KeyMap.Down): 282 | m.selected++ 283 | if m.selected >= len(m.files) { 284 | m.selected = len(m.files) - 1 285 | } 286 | if m.selected > m.max { 287 | m.min++ 288 | m.max++ 289 | } 290 | m.setCurrent() 291 | case key.Matches(msg, m.KeyMap.Up): 292 | m.selected-- 293 | if m.selected < 0 { 294 | m.selected = 0 295 | } 296 | if m.selected < m.min { 297 | m.min-- 298 | m.max-- 299 | } 300 | m.setCurrent() 301 | case key.Matches(msg, m.KeyMap.PageDown): 302 | m.selected += m.Height 303 | if m.selected >= len(m.files) { 304 | m.selected = len(m.files) - 1 305 | } 306 | m.min += m.Height 307 | m.max += m.Height 308 | 309 | if m.max >= len(m.files) { 310 | m.max = len(m.files) - 1 311 | m.min = m.max - m.Height 312 | } 313 | m.setCurrent() 314 | case key.Matches(msg, m.KeyMap.PageUp): 315 | m.selected -= m.Height 316 | if m.selected < 0 { 317 | m.selected = 0 318 | } 319 | m.min -= m.Height 320 | m.max -= m.Height 321 | 322 | if m.min < 0 { 323 | m.min = 0 324 | m.max = m.min + m.Height 325 | } 326 | m.setCurrent() 327 | case key.Matches(msg, m.KeyMap.Back): 328 | m.CurrentDirectory = filepath.Dir(m.CurrentDirectory) 329 | if m.selectedStack.Length() > 0 { 330 | m.selected, m.min, m.max = m.popView() 331 | } else { 332 | m.selected = 0 333 | m.min = 0 334 | m.max = m.Height - 1 335 | } 336 | return m, m.readDir(m.CurrentDirectory, m.ShowHidden) 337 | case key.Matches(msg, m.KeyMap.Open): 338 | if len(m.files) == 0 { 339 | break 340 | } 341 | 342 | f := m.files[m.selected] 343 | info, err := f.Info() 344 | if err != nil { 345 | break 346 | } 347 | isSymlink := info.Mode()&os.ModeSymlink != 0 348 | isDir := f.IsDir() 349 | 350 | if isSymlink { 351 | symlinkPath, _ := filepath.EvalSymlinks(filepath.Join(m.CurrentDirectory, f.Name())) 352 | info, err := os.Stat(symlinkPath) 353 | if err != nil { 354 | break 355 | } 356 | if info.IsDir() { 357 | isDir = true 358 | } 359 | } 360 | 361 | if (!isDir && m.FileAllowed) || (isDir && m.DirAllowed) { 362 | // Select the current path as the selection 363 | m.Path = filepath.Join(m.CurrentDirectory, f.Name()) 364 | } 365 | 366 | if !isDir { 367 | break 368 | } 369 | 370 | m.CurrentDirectory = filepath.Join(m.CurrentDirectory, f.Name()) 371 | m.pushView(m.selected, m.min, m.max) 372 | m.selected = 0 373 | m.min = 0 374 | m.max = m.Height - 1 375 | return m, m.readDir(m.CurrentDirectory, m.ShowHidden) 376 | } 377 | } 378 | return m, nil 379 | } 380 | 381 | // View returns the view of the file picker. 382 | func (m Model) View() string { 383 | if len(m.files) == 0 { 384 | return m.Styles.EmptyDirectory.Height(m.Height).MaxHeight(m.Height).String() 385 | } 386 | var s strings.Builder 387 | 388 | for i, f := range m.files { 389 | if i < m.min || i > m.max { 390 | continue 391 | } 392 | 393 | var symlinkPath string 394 | info, _ := f.Info() 395 | isSymlink := info.Mode()&os.ModeSymlink != 0 396 | name := f.Name() 397 | 398 | if isSymlink { 399 | symlinkPath, _ = filepath.EvalSymlinks(filepath.Join(m.CurrentDirectory, name)) 400 | } 401 | 402 | disabled := !m.CanSelect(name) && !f.IsDir() 403 | 404 | if m.selected == i { 405 | selected := "" 406 | selected += " " + name 407 | if isSymlink { 408 | selected += " → " + symlinkPath 409 | } 410 | if disabled { 411 | s.WriteString(m.Styles.DisabledSelected.Render(trim(m.cursor+selected, m.Width))) 412 | } else { 413 | s.WriteString(m.Styles.Selected.Render(trim(m.cursor+selected, m.Width))) 414 | } 415 | s.WriteRune('\n') 416 | continue 417 | } 418 | 419 | style := m.Styles.File 420 | if f.IsDir() { 421 | style = m.Styles.Directory 422 | } else if isSymlink { 423 | style = m.Styles.Symlink 424 | } else if disabled { 425 | style = m.Styles.DisabledFile 426 | } 427 | 428 | fileName := name 429 | if isSymlink { 430 | fileName += " → " + symlinkPath 431 | } 432 | s.WriteString(style.Render(trim(" "+fileName, m.Width))) 433 | s.WriteRune('\n') 434 | } 435 | 436 | for i := lipgloss.Height(s.String()); i <= m.Height; i++ { 437 | s.WriteRune('\n') 438 | } 439 | 440 | return s.String() 441 | } 442 | 443 | // DidSelectDisabledFile returns whether a user tried to select a disabled file 444 | // (on this msg). This is necessary only if you would like to warn the user that 445 | // they tried to select a disabled file. 446 | func (m Model) DidSelectDisabledFile(msg tea.Msg) (bool, string) { 447 | didSelect, path := m.didSelectFile(msg) 448 | if didSelect && !m.CanSelect(path) { 449 | return true, path 450 | } 451 | return false, "" 452 | } 453 | 454 | func (m Model) didSelectFile(msg tea.Msg) (bool, string) { 455 | if len(m.files) == 0 { 456 | return false, "" 457 | } 458 | switch msg.(type) { 459 | case tea.KeyMsg: 460 | // If the msg does not match the Select keymap then this could not have been a selection. 461 | 462 | // The key press was a selection, let's confirm whether the current file could 463 | // be selected or used for navigating deeper into the stack. 464 | f := m.files[m.selected] 465 | info, err := f.Info() 466 | if err != nil { 467 | return false, "" 468 | } 469 | isSymlink := info.Mode()&os.ModeSymlink != 0 470 | isDir := f.IsDir() 471 | 472 | if isSymlink { 473 | symlinkPath, _ := filepath.EvalSymlinks(filepath.Join(m.CurrentDirectory, f.Name())) 474 | info, err := os.Stat(symlinkPath) 475 | if err != nil { 476 | break 477 | } 478 | if info.IsDir() { 479 | isDir = true 480 | } 481 | } 482 | 483 | if (!isDir && m.FileAllowed) || (isDir && m.DirAllowed) && m.Path != "" { 484 | return true, m.Path 485 | } 486 | 487 | // If the msg was not a KeyMsg, then the file could not have been selected this iteration. 488 | // Only a KeyMsg can select a file. 489 | default: 490 | return false, "" 491 | } 492 | return false, "" 493 | } 494 | 495 | func (m Model) CanSelect(file string) bool { 496 | if len(m.AllowedTypes) <= 0 { 497 | return true 498 | } 499 | 500 | for _, ext := range m.AllowedTypes { 501 | if strings.HasSuffix(file, ext) { 502 | return true 503 | } 504 | } 505 | return false 506 | } 507 | 508 | func maxInt(a, b int) int { 509 | if a > b { 510 | return a 511 | } 512 | return b 513 | } 514 | 515 | func trim(s string, length int) string { 516 | if len(s) >= length { 517 | if length > 3 { 518 | return s[:length-3] + "..." 519 | } 520 | return s[:length] 521 | } 522 | return s 523 | } 524 | --------------------------------------------------------------------------------