diff --git a/README.md b/README.md index 418109f..798d3ea 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,8 @@ For both functions, false means the language is unsupported or parsing did not complete, including a parse timeout. A true result with an empty slice means the language is supported but the file contains no matches. Import and reference extraction currently cover Go, Ruby, Python, JavaScript, -TypeScript/TSX, Rust, PHP, and Elixir. +TypeScript/TSX, Rust, PHP, Elixir, Dart, Swift, Haskell, Perl, Lua, +R, Julia, OCaml, Crystal, Nim, Zig, and D. `Pack(root string, opts Options) (*Result, error)` walks `root`, applies `.gitignore` plus a built-in ignore list (vendored deps, build output, diff --git a/import.go b/import.go index b73067a..74afdba 100644 --- a/import.go +++ b/import.go @@ -6,7 +6,10 @@ import ( ts "github.com/odvcencio/gotreesitter" ) -const javascriptCallExpression = "call_expression" +const ( + importStatement = "import_statement" + javascriptCallExpression = "call_expression" +) // Imports returns structured imports from one source file. The second return // is false when the file's language or syntax tree is unsupported. @@ -32,6 +35,30 @@ func Imports(src []byte, filename string) ([]Import, bool) { return phpImports(src, l.language, tree.RootNode()), true case "elixir": return elixirImports(src, l.language, tree.RootNode()), true + case "dart": + return dartImports(src, l.language, tree.RootNode()), true + case "swift": + return swiftImports(src, l.language, tree.RootNode()), true + case "haskell": + return haskellImports(src, l.language, tree.RootNode()), true + case "perl": + return perlImports(src, l.language, tree.RootNode()), true + case "lua": + return luaImports(src, l.language, tree.RootNode()), true + case "r": + return rImports(src, l.language, tree.RootNode()), true + case "julia": + return juliaImports(src, l.language, tree.RootNode()), true + case "ocaml": + return ocamlImports(src, l.language, tree.RootNode()), true + case "crystal": + return crystalImports(src, l.language, tree.RootNode()), true + case "nim": + return nimImports(src, l.language, tree.RootNode()), true + case "zig": + return zigImports(src, l.language, tree.RootNode()), true + case "d": + return dImports(src, l.language, tree.RootNode()), true default: return nil, false } @@ -41,7 +68,7 @@ func pythonImports(src []byte, language *ts.Language, root *ts.Node) []Import { var imports []Import walkNamed(root, func(node *ts.Node) { switch node.Type(language) { - case "import_statement": + case importStatement: imports = append(imports, pythonModuleImports(src, language, node)...) case "import_from_statement", "future_import_statement": if imported, ok := pythonFromImport(src, language, node); ok { @@ -132,7 +159,7 @@ func javascriptImports(src []byte, language *ts.Language, root *ts.Node) []Impor var imports []Import walkNamed(root, func(node *ts.Node) { switch node.Type(language) { - case "import_statement": + case importStatement: imports = append(imports, javascriptImportStatement(src, language, node)...) case "variable_declarator": if imported, ok := javascriptRequireDeclarator(src, language, node); ok { diff --git a/import_registry.go b/import_registry.go new file mode 100644 index 0000000..80c3f33 --- /dev/null +++ b/import_registry.go @@ -0,0 +1,589 @@ +package outline + +import ( + "strings" + + ts "github.com/odvcencio/gotreesitter" +) + +func dartImports(src []byte, language *ts.Language, root *ts.Node) []Import { + var imports []Import + walkNamed(root, func(node *ts.Node) { + if node.Type(language) != "library_import" { + return + } + specification := firstDescendantType(node, language, "import_specification") + if specification == nil { + return + } + + alias := directChildText(src, language, specification, "identifier") + var names []Name + for i := range specification.NamedChildCount() { + child := specification.NamedChild(i) + if child.Type(language) != "combinator" { + continue + } + hidden := strings.HasPrefix(strings.TrimSpace(child.Text(src)), "hide ") + if !hidden { + for _, value := range directChildTexts(src, language, child, "identifier") { + names = append(names, Name{Name: value}) + } + } + } + + for _, literal := range descendantTexts(src, language, specification, "string_literal") { + module := sourceString(literal) + if module == "" { + continue + } + imported := Import{Module: module, Kind: ImportWildcard, Line: sourceLine(node)} + switch { + case alias != "": + imported.Kind = ImportNamespace + imported.Names = []Name{{Alias: alias}} + case len(names) > 0: + imported.Kind = ImportNamed + imported.Names = names + } + imports = append(imports, imported) + } + }) + return imports +} + +func swiftImports(src []byte, language *ts.Language, root *ts.Node) []Import { + var imports []Import + walkNamed(root, func(node *ts.Node) { + if node.Type(language) != "import_declaration" { + return + } + value := node.Text(src) + index := strings.Index(value, "import ") + if index < 0 { + return + } + fields := strings.Fields(value[index+len("import "):]) + if len(fields) == 0 { + return + } + if swiftDeclarationImport(fields[0]) && len(fields) > 1 { + index := strings.LastIndex(fields[1], ".") + if index > 0 && index < len(fields[1])-1 { + imports = append(imports, Import{ + Module: fields[1][:index], + Kind: ImportNamed, + Names: []Name{{Name: fields[1][index+1:]}}, + Line: sourceLine(node), + }) + } + return + } + module := fields[0] + imports = append(imports, Import{ + Module: module, + Kind: ImportModule, + Names: []Name{{Alias: module}}, + Line: sourceLine(node), + }) + }) + return imports +} + +func swiftDeclarationImport(value string) bool { + switch value { + case "typealias", "struct", "class", "enum", "protocol", "let", "var", "func", "operator": + return true + default: + return false + } +} + +func haskellImports(src []byte, language *ts.Language, root *ts.Node) []Import { + var imports []Import + walkNamed(root, func(node *ts.Node) { + if node.Type(language) != "import" { + return + } + moduleNode := node.ChildByFieldName("module", language) + if moduleNode == nil { + return + } + module := strings.TrimSpace(moduleNode.Text(src)) + imported := Import{Module: module, Kind: ImportWildcard, Line: sourceLine(node)} + if strings.Contains(node.Text(src), "qualified") { + alias := module + if aliasNode := node.ChildByFieldName("alias", language); aliasNode != nil { + alias = strings.TrimSpace(aliasNode.Text(src)) + } + imported.Kind = ImportNamespace + imported.Names = []Name{{Alias: alias}} + } else if namesNode := node.ChildByFieldName("names", language); namesNode != nil && + !strings.Contains(node.Text(src), "hiding") { + for _, value := range directChildTexts(src, language, namesNode, "import_name") { + name := strings.TrimPrefix(strings.TrimPrefix(value, "type "), "pattern ") + if name != "" { + imported.Names = append(imported.Names, Name{Name: name}) + } + } + if len(imported.Names) > 0 { + imported.Kind = ImportNamed + } + } + imports = append(imports, imported) + }) + return imports +} + +func perlImports(src []byte, language *ts.Language, root *ts.Node) []Import { + var imports []Import + walkNamed(root, func(node *ts.Node) { + switch node.Type(language) { + case "use_statement": + moduleNode := node.ChildByFieldName("module", language) + if moduleNode == nil || moduleNode.Type(language) != "package" { + return + } + imported := Import{Module: moduleNode.Text(src), Kind: ImportWildcard, Line: sourceLine(node)} + if firstDescendantType(node, language, "stub_expression") != nil { + imported.Kind = ImportSideEffect + } else if words := firstDescendantType(node, language, "quoted_word_list"); words != nil { + content := firstDescendantType(words, language, "string_content") + if content != nil { + for _, name := range strings.Fields(content.Text(src)) { + imported.Names = append(imported.Names, Name{Name: name}) + } + } + if len(imported.Names) > 0 { + imported.Kind = ImportNamed + } + } + imports = append(imports, imported) + case "require_expression": + if module := perlRequiredModule(src, language, node); module != "" { + imports = append(imports, Import{Module: module, Kind: ImportSideEffect, Line: sourceLine(node)}) + } + } + }) + return imports +} + +func perlRequiredModule(src []byte, language *ts.Language, node *ts.Node) string { + if bareword := firstDescendantType(node, language, "bareword"); bareword != nil { + return bareword.Text(src) + } + if literal := firstDescendantType(node, language, "interpolated_string_literal"); literal != nil { + return sourceString(literal.Text(src)) + } + return "" +} + +func luaImports(src []byte, language *ts.Language, root *ts.Node) []Import { + var imports []Import + walkNamed(root, func(node *ts.Node) { + if node.Type(language) != "function_call" { + return + } + name := node.ChildByFieldName("name", language) + if name == nil || name.Type(language) != "identifier" || name.Text(src) != "require" { + return + } + content := firstDescendantType(node.ChildByFieldName("arguments", language), language, "string_content") + if content == nil || content.Text(src) == "" { + return + } + imported := Import{Module: content.Text(src), Kind: ImportSideEffect, Line: sourceLine(node)} + parent := node.Parent() + if parent != nil && parent.Type(language) == "expression_list" && parent.NamedChildCount() == 1 { + assignment := ancestorNode(node, "assignment_statement", language) + if assignment == nil { + imports = append(imports, imported) + return + } + variables := firstDescendantType(assignment, language, "variable_list") + if variables != nil && variables.NamedChildCount() == 1 { + aliasNode := variables.NamedChild(0) + if aliasNode.Type(language) == "identifier" { + imported.Kind = ImportModule + imported.Names = []Name{{Alias: aliasNode.Text(src)}} + } + } + } + imports = append(imports, imported) + }) + return imports +} + +func rImports(src []byte, language *ts.Language, root *ts.Node) []Import { + var imports []Import + walkNamed(root, func(node *ts.Node) { + switch node.Type(language) { + case "call": + if imported, ok := rLoadImport(src, language, node); ok { + imports = append(imports, imported) + } + case "namespace_operator": + if imported, ok := rNamespaceImport(src, language, node); ok { + imports = append(imports, imported) + } + } + }) + return imports +} + +func rLoadImport(src []byte, language *ts.Language, call *ts.Node) (Import, bool) { + function := call.ChildByFieldName("function", language) + if function == nil || function.Type(language) != "identifier" { + return Import{}, false + } + form := function.Text(src) + if form != "library" && form != "require" && form != "requireNamespace" { + return Import{}, false + } + arguments := call.ChildByFieldName("arguments", language) + argument := firstDescendantType(arguments, language, "argument") + if argument == nil || argument.NamedChildCount() == 0 { + return Import{}, false + } + value := argument.NamedChild(0) + module := value.Text(src) + if value.Type(language) == "string" { + module = sourceString(module) + } + if module == "" { + return Import{}, false + } + imported := Import{Module: module, Kind: ImportWildcard, Line: sourceLine(call)} + if form == "requireNamespace" { + imported.Kind = ImportModule + imported.Names = []Name{{Alias: module}} + } + return imported, true +} + +func rNamespaceImport(src []byte, language *ts.Language, node *ts.Node) (Import, bool) { + module := node.ChildByFieldName("lhs", language) + member := node.ChildByFieldName("rhs", language) + if module == nil || member == nil || + module.Type(language) != "identifier" || member.Type(language) != "identifier" { + return Import{}, false + } + return Import{ + Module: module.Text(src), + Kind: ImportNamed, + Names: []Name{{Name: member.Text(src)}}, + Line: sourceLine(node), + }, true +} + +func juliaImports(src []byte, language *ts.Language, root *ts.Node) []Import { + var imports []Import + walkNamed(root, func(node *ts.Node) { + if node.Type(language) != "using_statement" && node.Type(language) != importStatement { + return + } + for i := range node.NamedChildCount() { + imports = append(imports, juliaImportNode(src, language, node.NamedChild(i), sourceLine(node))...) + } + }) + return imports +} + +func juliaImportNode(src []byte, language *ts.Language, node *ts.Node, line int) []Import { + switch node.Type(language) { + case "identifier": + module := node.Text(src) + return []Import{{Module: module, Kind: ImportModule, Names: []Name{{Alias: module}}, Line: line}} + case "import_alias": + if node.NamedChildCount() < minimumMemberChildren { + return nil + } + return []Import{{ + Module: node.NamedChild(0).Text(src), + Kind: ImportModule, + Names: []Name{{Alias: node.NamedChild(1).Text(src)}}, + Line: line, + }} + case "selected_import": + return juliaSelectedImport(src, language, node, line) + default: + return nil + } +} + +func juliaSelectedImport(src []byte, language *ts.Language, node *ts.Node, line int) []Import { + if node.NamedChildCount() < minimumMemberChildren { + return nil + } + module := node.NamedChild(0).Text(src) + imported := Import{Module: module, Kind: ImportNamed, Line: line} + for i := 1; i < node.NamedChildCount(); i++ { + child := node.NamedChild(i) + name := Name{} + if child.Type(language) == "import_alias" && child.NamedChildCount() > 1 { + name.Name = child.NamedChild(0).Text(src) + name.Alias = child.NamedChild(1).Text(src) + } else { + name.Name = child.Text(src) + } + if name.Name != "" { + imported.Names = append(imported.Names, name) + } + } + if len(imported.Names) == 0 { + return nil + } + return []Import{imported} +} + +func ocamlImports(src []byte, language *ts.Language, root *ts.Node) []Import { + var imports []Import + walkNamed(root, func(node *ts.Node) { + switch node.Type(language) { + case "open_module", "include_module": + module := node.ChildByFieldName("module", language) + if module != nil { + imports = append(imports, Import{ + Module: module.Text(src), + Kind: ImportWildcard, + Line: sourceLine(node), + }) + } + case "module_binding": + if imported, ok := ocamlModuleBinding(src, language, node); ok { + imports = append(imports, imported) + } + } + }) + return imports +} + +func ocamlModuleBinding(src []byte, language *ts.Language, binding *ts.Node) (Import, bool) { + body := binding.ChildByFieldName("body", language) + if body == nil || body.Type(language) != "module_path" { + return Import{}, false + } + alias := directChildText(src, language, binding, "module_name") + if alias == "" { + return Import{}, false + } + return Import{ + Module: body.Text(src), + Kind: ImportModule, + Names: []Name{{Alias: alias}}, + Line: sourceLine(binding), + }, true +} + +func crystalImports(src []byte, language *ts.Language, root *ts.Node) []Import { + var imports []Import + walkNamed(root, func(node *ts.Node) { + if node.Type(language) != "require" { + return + } + literal := firstDescendantType(node, language, "string") + if literal == nil { + return + } + module := sourceString(literal.Text(src)) + if module != "" { + imports = append(imports, Import{Module: module, Kind: ImportSideEffect, Line: sourceLine(node)}) + } + }) + return imports +} + +func nimImports(src []byte, language *ts.Language, root *ts.Node) []Import { + var imports []Import + walkNamed(root, func(node *ts.Node) { + switch node.Type(language) { + case importStatement: + list := firstDescendantType(node, language, "expression_list") + if list == nil { + return + } + for i := range list.NamedChildCount() { + imports = append(imports, nimImportExpression(src, language, list.NamedChild(i), sourceLine(node))...) + } + case "import_from_statement": + if imported, ok := nimFromImport(src, language, node); ok { + imports = append(imports, imported) + } + } + }) + return imports +} + +func nimImportExpression(src []byte, language *ts.Language, node *ts.Node, line int) []Import { + value := strings.TrimSpace(node.Text(src)) + if module, alias, ok := strings.Cut(value, " as "); ok { + return []Import{{ + Module: strings.TrimSpace(module), + Kind: ImportModule, + Names: []Name{{Alias: strings.TrimSpace(alias)}}, + Line: line, + }} + } + if group := firstDescendantType(node, language, "array_construction"); group != nil { + prefix := node.NamedChild(0).Text(src) + var imports []Import + for _, name := range directChildTexts(src, language, group, "identifier") { + imports = append(imports, Import{ + Module: prefix + "/" + name, + Kind: ImportModule, + Names: []Name{{Alias: name}}, + Line: line, + }) + } + return imports + } + if value == "" { + return nil + } + return []Import{{ + Module: value, + Kind: ImportModule, + Names: []Name{{Alias: moduleBase(value)}}, + Line: line, + }} +} + +func nimFromImport(src []byte, language *ts.Language, node *ts.Node) (Import, bool) { + module := node.ChildByFieldName("module", language) + list := firstDescendantType(node, language, "expression_list") + if module == nil || list == nil { + return Import{}, false + } + imported := Import{Module: module.Text(src), Kind: ImportNamed, Line: sourceLine(node)} + for i := range list.NamedChildCount() { + value := list.NamedChild(i).Text(src) + name, alias := splitImportAlias(value) + if name != "" { + imported.Names = append(imported.Names, Name{Name: name, Alias: alias}) + } + } + return imported, len(imported.Names) > 0 +} + +func zigImports(src []byte, language *ts.Language, root *ts.Node) []Import { + var imports []Import + walkNamed(root, func(node *ts.Node) { + if node.Type(language) != "builtin_function" { + return + } + builtin := firstDescendantType(node, language, "builtin_identifier") + content := firstDescendantType(node, language, "string_content") + if builtin == nil || builtin.Text(src) != "@import" || content == nil || content.Text(src) == "" { + return + } + imported := Import{Module: content.Text(src), Kind: ImportSideEffect, Line: sourceLine(node)} + parent := node.Parent() + if parent != nil && parent.Type(language) == "variable_declaration" { + declaration := parent + alias := directChildText(src, language, declaration, "identifier") + if alias != "" { + imported.Kind = ImportModule + imported.Names = []Name{{Alias: alias}} + } + } + imports = append(imports, imported) + }) + return imports +} + +func dImports(src []byte, language *ts.Language, root *ts.Node) []Import { + var imports []Import + walkNamed(root, func(node *ts.Node) { + if node.Type(language) != "import_declaration" { + return + } + var importedNodes []*ts.Node + var names []Name + for i := range node.NamedChildCount() { + child := node.NamedChild(i) + switch child.Type(language) { + case "imported": + importedNodes = append(importedNodes, child) + case "import_bind": + if name, ok := dImportName(src, child); ok { + names = append(names, name) + } + } + } + for _, importedNode := range importedNodes { + if imported, ok := dImportedModule(src, language, node, importedNode, names); ok { + imports = append(imports, imported) + } + } + }) + return imports +} + +func dImportedModule( + src []byte, + language *ts.Language, + declaration *ts.Node, + importedNode *ts.Node, + names []Name, +) (Import, bool) { + moduleNode := firstDescendantType(importedNode, language, "module_fqn") + if moduleNode == nil { + return Import{}, false + } + module := moduleNode.Text(src) + imported := Import{Module: module, Kind: ImportWildcard, Line: sourceLine(declaration)} + if alias := importedNode.ChildByFieldName("alias", language); alias != nil { + imported.Kind = ImportModule + imported.Names = []Name{{Alias: alias.Text(src)}} + } else if len(names) > 0 { + imported.Kind = ImportNamed + imported.Names = names + } else if strings.HasPrefix(strings.TrimSpace(declaration.Text(src)), "static import") { + imported.Kind = ImportModule + imported.Names = []Name{{Alias: module}} + } + return imported, true +} + +func dImportName(src []byte, node *ts.Node) (Name, bool) { + if node.NamedChildCount() == 0 { + return Name{}, false + } + if node.NamedChildCount() == 1 { + return Name{Name: node.NamedChild(0).Text(src)}, true + } + return Name{ + Name: node.NamedChild(node.NamedChildCount() - 1).Text(src), + Alias: node.NamedChild(0).Text(src), + }, true +} + +func directChildText(src []byte, language *ts.Language, node *ts.Node, nodeType string) string { + values := directChildTexts(src, language, node, nodeType) + if len(values) == 0 { + return "" + } + return values[0] +} + +func directChildTexts(src []byte, language *ts.Language, node *ts.Node, nodeType string) []string { + if node == nil { + return nil + } + var values []string + for i := range node.NamedChildCount() { + child := node.NamedChild(i) + if child.Type(language) == nodeType { + values = append(values, child.Text(src)) + } + } + return values +} + +func moduleBase(module string) string { + module = strings.TrimSpace(module) + if index := strings.LastIndexAny(module, "/."); index >= 0 { + return module[index+1:] + } + return module +} diff --git a/import_registry_test.go b/import_registry_test.go new file mode 100644 index 0000000..9e59315 --- /dev/null +++ b/import_registry_test.go @@ -0,0 +1,218 @@ +package outline + +import ( + "reflect" + "testing" +) + +func TestRegistryLanguageImports(t *testing.T) { + t.Parallel() + tests := []struct { + filename string + src string + want []Import + }{ + { + filename: "app.dart", + src: `import 'package:http/http.dart' as http; +import 'package:collection/collection.dart' show DeepCollectionEquality, IterableExtension; +import 'package:foo/foo.dart' hide Internal; +import 'package:lazy/lazy.dart' deferred as lazy; +`, + want: []Import{ + {Module: "package:http/http.dart", Kind: ImportNamespace, Names: []Name{{Alias: "http"}}, Line: 1}, + { + Module: "package:collection/collection.dart", + Kind: ImportNamed, + Names: []Name{ + {Name: "DeepCollectionEquality"}, + {Name: "IterableExtension"}, + }, + Line: 2, + }, + {Module: "package:foo/foo.dart", Kind: ImportWildcard, Line: 3}, + {Module: "package:lazy/lazy.dart", Kind: ImportNamespace, Names: []Name{{Alias: "lazy"}}, Line: 4}, + }, + }, + { + filename: "App.swift", + src: `import Alamofire +@testable import XCTest +import struct Foundation.Date +import func Darwin.sqrt +`, + want: []Import{ + {Module: "Alamofire", Kind: ImportModule, Names: []Name{{Alias: "Alamofire"}}, Line: 1}, + {Module: "XCTest", Kind: ImportModule, Names: []Name{{Alias: "XCTest"}}, Line: 2}, + {Module: "Foundation", Kind: ImportNamed, Names: []Name{{Name: "Date"}}, Line: 3}, + {Module: "Darwin", Kind: ImportNamed, Names: []Name{{Name: "sqrt"}}, Line: 4}, + }, + }, + { + filename: "App.hs", + src: `module App where + +import Data.List +import qualified Data.Text as T +import Data.Map (Map, lookup) +import Data.Set hiding (map) +`, + want: []Import{ + {Module: "Data.List", Kind: ImportWildcard, Line: 3}, + {Module: "Data.Text", Kind: ImportNamespace, Names: []Name{{Alias: "T"}}, Line: 4}, + {Module: "Data.Map", Kind: ImportNamed, Names: []Name{{Name: "Map"}, {Name: "lookup"}}, Line: 5}, + {Module: "Data.Set", Kind: ImportWildcard, Line: 6}, + }, + }, + { + filename: "App.pm", + src: `use JSON::MaybeXS qw(encode_json decode_json); +use Mojo::UserAgent; +use Foo::Bar (); +require HTTP::Tiny; +require "Path/Tiny.pm"; +`, + want: []Import{ + { + Module: "JSON::MaybeXS", + Kind: ImportNamed, + Names: []Name{{Name: "encode_json"}, {Name: "decode_json"}}, + Line: 1, + }, + {Module: "Mojo::UserAgent", Kind: ImportWildcard, Line: 2}, + {Module: "Foo::Bar", Kind: ImportSideEffect, Line: 3}, + {Module: "HTTP::Tiny", Kind: ImportSideEffect, Line: 4}, + {Module: "Path/Tiny.pm", Kind: ImportSideEffect, Line: 5}, + }, + }, + { + filename: "app.lua", + src: `local json = require("cjson") +local inspect = require "inspect" +require("side_effect") +M.json = require("assigned_field") +`, + want: []Import{ + {Module: "cjson", Kind: ImportModule, Names: []Name{{Alias: "json"}}, Line: 1}, + {Module: "inspect", Kind: ImportModule, Names: []Name{{Alias: "inspect"}}, Line: 2}, + {Module: "side_effect", Kind: ImportSideEffect, Line: 3}, + {Module: "assigned_field", Kind: ImportSideEffect, Line: 4}, + }, + }, + { + filename: "app.R", + src: `library(dplyr) +require("ggplot2") +requireNamespace("jsonlite") +x <- dplyr::filter(data, value > 1) +y <- jsonlite:::simplify +`, + want: []Import{ + {Module: "dplyr", Kind: ImportWildcard, Line: 1}, + {Module: "ggplot2", Kind: ImportWildcard, Line: 2}, + {Module: "jsonlite", Kind: ImportModule, Names: []Name{{Alias: "jsonlite"}}, Line: 3}, + {Module: "dplyr", Kind: ImportNamed, Names: []Name{{Name: "filter"}}, Line: 4}, + {Module: "jsonlite", Kind: ImportNamed, Names: []Name{{Name: "simplify"}}, Line: 5}, + }, + }, + { + filename: "app.jl", + src: `using DataFrames +using CSV: File, Rows +import JSON +import HTTP: get as fetch +import StatsBase as Stats +`, + want: []Import{ + {Module: "DataFrames", Kind: ImportModule, Names: []Name{{Alias: "DataFrames"}}, Line: 1}, + {Module: "CSV", Kind: ImportNamed, Names: []Name{{Name: "File"}, {Name: "Rows"}}, Line: 2}, + {Module: "JSON", Kind: ImportModule, Names: []Name{{Alias: "JSON"}}, Line: 3}, + {Module: "HTTP", Kind: ImportNamed, Names: []Name{{Name: "get", Alias: "fetch"}}, Line: 4}, + {Module: "StatsBase", Kind: ImportModule, Names: []Name{{Alias: "Stats"}}, Line: 5}, + }, + }, + { + filename: "app.ml", + src: `open Core +open! Base +include Foo +module J = Yojson.Safe +`, + want: []Import{ + {Module: "Core", Kind: ImportWildcard, Line: 1}, + {Module: "Base", Kind: ImportWildcard, Line: 2}, + {Module: "Foo", Kind: ImportWildcard, Line: 3}, + {Module: "Yojson.Safe", Kind: ImportModule, Names: []Name{{Alias: "J"}}, Line: 4}, + }, + }, + { + filename: "app.cr", + src: "require \"json\"\nrequire \"http/client\"\nrequire \"./local\"\n", + want: []Import{ + {Module: "json", Kind: ImportSideEffect, Line: 1}, + {Module: "http/client", Kind: ImportSideEffect, Line: 2}, + {Module: "./local", Kind: ImportSideEffect, Line: 3}, + }, + }, + { + filename: "app.nim", + src: `import strutils +import std/[sequtils, tables] +import chronicles as log +from json import parseJson, JsonNode +`, + want: []Import{ + {Module: "strutils", Kind: ImportModule, Names: []Name{{Alias: "strutils"}}, Line: 1}, + {Module: "std/sequtils", Kind: ImportModule, Names: []Name{{Alias: "sequtils"}}, Line: 2}, + {Module: "std/tables", Kind: ImportModule, Names: []Name{{Alias: "tables"}}, Line: 2}, + {Module: "chronicles", Kind: ImportModule, Names: []Name{{Alias: "log"}}, Line: 3}, + {Module: "json", Kind: ImportNamed, Names: []Name{{Name: "parseJson"}, {Name: "JsonNode"}}, Line: 4}, + }, + }, + { + filename: "app.zig", + src: `const std = @import("std"); +const clap = @import("clap"); +_ = @import("side_effect"); +`, + want: []Import{ + {Module: "std", Kind: ImportModule, Names: []Name{{Alias: "std"}}, Line: 1}, + {Module: "clap", Kind: ImportModule, Names: []Name{{Alias: "clap"}}, Line: 2}, + {Module: "side_effect", Kind: ImportSideEffect, Line: 3}, + }, + }, + { + filename: "app.d", + src: `import std.stdio; +import io = std.file; +import std.algorithm : map, mapped = filter; +static import vibe.data.json; +`, + want: []Import{ + {Module: "std.stdio", Kind: ImportWildcard, Line: 1}, + {Module: "std.file", Kind: ImportModule, Names: []Name{{Alias: "io"}}, Line: 2}, + { + Module: "std.algorithm", + Kind: ImportNamed, + Names: []Name{{Name: "map"}, {Name: "filter", Alias: "mapped"}}, + Line: 3, + }, + {Module: "vibe.data.json", Kind: ImportModule, Names: []Name{{Alias: "vibe.data.json"}}, Line: 4}, + }, + }, + } + + for _, test := range tests { + test := test + t.Run(test.filename, func(t *testing.T) { + t.Parallel() + got, ok := Imports([]byte(test.src), test.filename) + if !ok { + t.Fatal("Imports() supported = false") + } + if !reflect.DeepEqual(got, test.want) { + t.Fatalf("Imports() = %#v, want %#v", got, test.want) + } + }) + } +} diff --git a/ref.go b/ref.go index 16f32d8..1213502 100644 --- a/ref.go +++ b/ref.go @@ -35,6 +35,48 @@ func Refs(src []byte, filename string, receivers []string) ([]Ref, bool) { refs = phpRefs(src, l.language, tree.RootNode(), wanted) case "elixir": refs = memberRefs(src, l.language, tree.RootNode(), wanted, "dot", "right", "alias") + case "dart": + refs = dartRefs(src, l.language, tree.RootNode(), wanted) + case "swift": + refs = swiftRefs(src, l.language, tree.RootNode(), wanted) + case "haskell": + refs = haskellRefs(src, l.language, tree.RootNode(), wanted) + case "perl": + refs = memberRefsWithFields( + src, l.language, tree.RootNode(), wanted, + "method_call_expression", "invocant", "method", "bareword", + ) + case "lua": + refs = memberRefsWithFields( + src, l.language, tree.RootNode(), wanted, + "dot_index_expression", "table", "field", "identifier", + ) + case "r": + refs = memberRefsWithFields( + src, l.language, tree.RootNode(), wanted, + "namespace_operator", "lhs", "rhs", "identifier", + ) + case "julia": + refs = memberRefsWithFields( + src, l.language, tree.RootNode(), wanted, + "field_expression", "value", "", "identifier", + ) + case "ocaml": + refs = ocamlRefs(src, l.language, tree.RootNode(), wanted) + case "crystal": + refs = memberRefsWithFields( + src, l.language, tree.RootNode(), wanted, + "call", "receiver", "method", "constant", + ) + case "nim": + refs = memberRefsWithFields( + src, l.language, tree.RootNode(), wanted, + "dot_expression", "left", "right", "identifier", + ) + case "zig": + refs = memberRefs(src, l.language, tree.RootNode(), wanted, "field_expression", "member", "identifier") + case "d": + refs = dRefs(src, l.language, tree.RootNode(), wanted) default: return nil, false } @@ -49,14 +91,36 @@ func memberRefs( nodeType string, memberField string, receiverType string, +) []Ref { + return memberRefsWithFields( + src, language, root, wanted, + nodeType, "object", memberField, receiverType, + ) +} + +func memberRefsWithFields( + src []byte, + language *ts.Language, + root *ts.Node, + wanted map[string]bool, + nodeType string, + receiverField string, + memberField string, + receiverType string, ) []Ref { var refs []Ref walkNamed(root, func(node *ts.Node) { if node.Type(language) != nodeType || node.NamedChildCount() < 2 { return } - receiver := node.ChildByFieldName("object", language) - member := node.ChildByFieldName(memberField, language) + receiver := node.ChildByFieldName(receiverField, language) + var member *ts.Node + if memberField != "" { + member = node.ChildByFieldName(memberField, language) + if member == nil { + return + } + } if receiver == nil { receiver = node.NamedChild(0) } diff --git a/ref_registry.go b/ref_registry.go new file mode 100644 index 0000000..85cdfbb --- /dev/null +++ b/ref_registry.go @@ -0,0 +1,115 @@ +package outline + +import ( + "strings" + + ts "github.com/odvcencio/gotreesitter" +) + +func dartRefs(src []byte, language *ts.Language, root *ts.Node, wanted map[string]bool) []Ref { + var refs []Ref + walkNamed(root, func(node *ts.Node) { + for i := 0; i+1 < node.NamedChildCount(); i++ { + receiver := node.NamedChild(i) + selector := node.NamedChild(i + 1) + if receiver.Type(language) != "identifier" || + selector.Type(language) != "selector" || + !wanted[receiver.Text(src)] { + continue + } + member := firstDescendantType(selector, language, "identifier") + if member != nil { + refs = append(refs, Ref{ + Receiver: receiver.Text(src), + Member: member.Text(src), + Line: sourceLine(member), + }) + } + } + }) + return refs +} + +func swiftRefs(src []byte, language *ts.Language, root *ts.Node, wanted map[string]bool) []Ref { + var refs []Ref + walkNamed(root, func(node *ts.Node) { + if node.Type(language) != "navigation_expression" { + return + } + receiver := node.ChildByFieldName("target", language) + suffix := node.ChildByFieldName("suffix", language) + if receiver == nil || suffix == nil || !wanted[receiver.Text(src)] { + return + } + member := firstDescendantType(suffix, language, "simple_identifier") + if member != nil { + refs = append(refs, Ref{ + Receiver: receiver.Text(src), + Member: member.Text(src), + Line: sourceLine(member), + }) + } + }) + return refs +} + +func haskellRefs(src []byte, language *ts.Language, root *ts.Node, wanted map[string]bool) []Ref { + var refs []Ref + walkNamed(root, func(node *ts.Node) { + if node.Type(language) != "qualified" { + return + } + module := node.ChildByFieldName("module", language) + member := node.ChildByFieldName("id", language) + if module == nil || member == nil { + return + } + receiver := strings.TrimSuffix(module.Text(src), ".") + if wanted[receiver] { + refs = append(refs, Ref{Receiver: receiver, Member: member.Text(src), Line: sourceLine(member)}) + } + }) + return refs +} + +func ocamlRefs(src []byte, language *ts.Language, root *ts.Node, wanted map[string]bool) []Ref { + var refs []Ref + walkNamed(root, func(node *ts.Node) { + if node.Type(language) != "value_path" { + return + } + module := firstDescendantType(node, language, "module_path") + member := firstDescendantType(node, language, "value_name") + if module != nil && member != nil && wanted[module.Text(src)] { + refs = append(refs, Ref{ + Receiver: module.Text(src), + Member: member.Text(src), + Line: sourceLine(member), + }) + } + }) + return refs +} + +func dRefs(src []byte, language *ts.Language, root *ts.Node, wanted map[string]bool) []Ref { + var refs []Ref + walkNamed(root, func(node *ts.Node) { + if node.Type(language) != "property_expression" { + return + } + value := node.Text(src) + index := strings.LastIndex(value, ".") + if index <= 0 || index == len(value)-1 { + return + } + receiver := strings.TrimSpace(value[:index]) + if wanted[receiver] { + refs = append(refs, Ref{ + Receiver: receiver, + Member: strings.TrimSpace(value[index+1:]), + Line: sourceLine(node), + }) + } + }) + return refs +} diff --git a/ref_registry_test.go b/ref_registry_test.go new file mode 100644 index 0000000..5f6a457 --- /dev/null +++ b/ref_registry_test.go @@ -0,0 +1,106 @@ +package outline + +import ( + "reflect" + "testing" +) + +func TestRegistryLanguageRefs(t *testing.T) { + t.Parallel() + tests := []struct { + filename string + src string + receivers []string + want []Ref + }{ + { + filename: "app.dart", + src: "void main() {\n http.get(uri);\n other.get(uri);\n}\n", + receivers: []string{"http"}, + want: []Ref{{Receiver: "http", Member: "get", Line: 2}}, + }, + { + filename: "App.swift", + src: "Alamofire.request(\"url\")\nOther.request(\"url\")\n", + receivers: []string{"Alamofire"}, + want: []Ref{{Receiver: "Alamofire", Member: "request", Line: 1}}, + }, + { + filename: "App.hs", + src: "module App where\nimport qualified Data.Text as T\nx = T.pack \"x\"\n", + receivers: []string{"T"}, + want: []Ref{{Receiver: "T", Member: "pack", Line: 3}}, + }, + { + filename: "App.pm", + src: "my $x = JSON::MaybeXS->new;\nmy $y = Other->new;\n", + receivers: []string{"JSON::MaybeXS"}, + want: []Ref{{Receiver: "JSON::MaybeXS", Member: "new", Line: 1}}, + }, + { + filename: "app.lua", + src: "json.decode(\"{}\")\nother.decode(\"{}\")\n", + receivers: []string{"json"}, + want: []Ref{{Receiver: "json", Member: "decode", Line: 1}}, + }, + { + filename: "app.R", + src: "x <- dplyr::filter(data)\ny <- other::filter(data)\n", + receivers: []string{"dplyr"}, + want: []Ref{{Receiver: "dplyr", Member: "filter", Line: 1}}, + }, + { + filename: "app.jl", + src: "x = DataFrames.DataFrame()\ny = Other.DataFrame()\n", + receivers: []string{"DataFrames"}, + want: []Ref{{Receiver: "DataFrames", Member: "DataFrame", Line: 1}}, + }, + { + filename: "app.ml", + src: "let x = J.from_string \"{}\"\nlet y = Other.from_string \"{}\"\n", + receivers: []string{"J"}, + want: []Ref{{Receiver: "J", Member: "from_string", Line: 1}}, + }, + { + filename: "app.cr", + src: "x = JSON.parse(\"{}\")\ny = Other.parse(\"{}\")\n", + receivers: []string{"JSON"}, + want: []Ref{{Receiver: "JSON", Member: "parse", Line: 1}}, + }, + { + filename: "app.nim", + src: "log.info \"hello\"\nother.info \"hello\"\n", + receivers: []string{"log"}, + want: []Ref{{Receiver: "log", Member: "info", Line: 1}}, + }, + { + filename: "app.zig", + src: "const x = std.debug;\nconst y = other.debug;\n", + receivers: []string{"std"}, + want: []Ref{{Receiver: "std", Member: "debug", Line: 1}}, + }, + { + filename: "app.d", + src: "void f() { io . readText(\"x\"); auto x = io.value; other.readText(\"x\"); }\n", + receivers: []string{"io"}, + want: []Ref{ + {Receiver: "io", Member: "readText", Line: 1}, + {Receiver: "io", Member: "value", Line: 1}, + }, + }, + } + + for _, test := range tests { + test := test + t.Run(test.filename, func(t *testing.T) { + t.Parallel() + got, ok := Refs([]byte(test.src), test.filename, test.receivers) + if !ok { + t.Fatal("Refs() supported = false") + } + if !reflect.DeepEqual(got, test.want) { + t.Fatalf("Refs() = %#v, want %#v", got, test.want) + } + }) + } +} diff --git a/ref_test.go b/ref_test.go index 121bedf..a407707 100644 --- a/ref_test.go +++ b/ref_test.go @@ -61,6 +61,24 @@ func TestRefsSupportResult(t *testing.T) { } } +func TestMemberRefsMissingField(t *testing.T) { + t.Parallel() + src := []byte("WS.Server;\n") + language, tree, ok := parseSource(src, "app.js") + if !ok { + t.Fatal("parseSource() supported = false") + } + defer tree.Release() + + got := memberRefsWithFields( + src, language.language, tree.RootNode(), map[string]bool{"WS": true}, + "member_expression", "object", "missing", "identifier", + ) + if len(got) != 0 { + t.Fatalf("memberRefsWithFields() = %#v, want no refs", got) + } +} + func TestHyrumLanguageRefs(t *testing.T) { t.Parallel() tests := []struct {