diff --git a/README.md b/README.md index 56f2e1a..418109f 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,31 @@ with the body elided and gaps marked by `⋮----`. `Outline(src []byte, filename string) (string, bool)` compresses one file. The second return is false if the language is not supported. +`Imports(src []byte, filename string) ([]Import, bool)` extracts module imports, +their source-language form, named imports, local aliases, and one-based source +lines. A statement containing both default and named imports returns one value +for each form. + +`Refs(src []byte, filename string, receivers []string) ([]Ref, bool)` extracts +direct member accesses on the supplied receiver identifiers. This lets callers +pass the local aliases returned by `Imports` without collecting unrelated +member expressions from the file. + +```go +imports, ok := outline.Imports(src, "app.py") +if !ok { + return +} + +refs, _ := outline.Refs(src, "app.py", []string{"flask", "f"}) +``` + +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. + `Pack(root string, opts Options) (*Result, error)` walks `root`, applies `.gitignore` plus a built-in ignore list (vendored deps, build output, lockfiles), skips binaries and oversized files, and outlines what it can. diff --git a/import.go b/import.go new file mode 100644 index 0000000..b73067a --- /dev/null +++ b/import.go @@ -0,0 +1,389 @@ +package outline + +import ( + "strings" + + ts "github.com/odvcencio/gotreesitter" +) + +const 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. +func Imports(src []byte, filename string) ([]Import, bool) { + l, tree, ok := parseSource(src, filename) + if !ok { + return nil, false + } + defer tree.Release() + + switch l.name { + case "go": + return goImports(src, l.language, tree.RootNode()), true + case "ruby": + return rubyImports(src, l.language, tree.RootNode()), true + case "python": + return pythonImports(src, l.language, tree.RootNode()), true + case "javascript", "typescript": + return javascriptImports(src, l.language, tree.RootNode()), true + case "rust": + return rustImports(src, l.language, tree.RootNode()), true + case "php": + return phpImports(src, l.language, tree.RootNode()), true + case "elixir": + return elixirImports(src, l.language, tree.RootNode()), true + default: + return nil, false + } +} + +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": + imports = append(imports, pythonModuleImports(src, language, node)...) + case "import_from_statement", "future_import_statement": + if imported, ok := pythonFromImport(src, language, node); ok { + imports = append(imports, imported) + } + } + }) + return imports +} + +func pythonModuleImports(src []byte, language *ts.Language, statement *ts.Node) []Import { + imports := make([]Import, 0, statement.NamedChildCount()) + for i := range statement.NamedChildCount() { + child := statement.NamedChild(i) + module := "" + alias := "" + switch child.Type(language) { + case "aliased_import": + if child.NamedChildCount() > 0 { + module = child.NamedChild(0).Text(src) + } + if child.NamedChildCount() > 1 { + alias = child.NamedChild(child.NamedChildCount() - 1).Text(src) + } + case "dotted_name", "identifier": + module = child.Text(src) + } + if module == "" { + continue + } + if alias == "" { + alias, _, _ = strings.Cut(module, ".") + } + imports = append(imports, Import{ + Module: module, + Kind: ImportModule, + Names: []Name{{Alias: alias}}, + Line: sourceLine(statement), + }) + } + return imports +} + +func pythonFromImport( + src []byte, + language *ts.Language, + statement *ts.Node, +) (Import, bool) { + if statement.NamedChildCount() == 0 { + return Import{}, false + } + moduleNode := statement.NamedChild(0) + module := moduleNode.Text(src) + firstName := 1 + if statement.Type(language) == "future_import_statement" { + module = "__future__" + firstName = 0 + } + if module == "" { + return Import{}, false + } + + imported := Import{Module: module, Kind: ImportNamed, Line: sourceLine(statement)} + for i := firstName; i < statement.NamedChildCount(); i++ { + child := statement.NamedChild(i) + switch child.Type(language) { + case "wildcard_import": + imported.Kind = ImportWildcard + imported.Names = nil + return imported, true + case "aliased_import": + if child.NamedChildCount() == 0 { + continue + } + name := Name{Name: child.NamedChild(0).Text(src)} + if child.NamedChildCount() > 1 { + name.Alias = child.NamedChild(child.NamedChildCount() - 1).Text(src) + } + imported.Names = append(imported.Names, name) + case "dotted_name", "identifier": + imported.Names = append(imported.Names, Name{Name: child.Text(src)}) + } + } + return imported, len(imported.Names) > 0 +} + +func javascriptImports(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": + imports = append(imports, javascriptImportStatement(src, language, node)...) + case "variable_declarator": + if imported, ok := javascriptRequireDeclarator(src, language, node); ok { + imports = append(imports, imported) + } + case javascriptCallExpression: + if imported, ok := javascriptStandaloneRequire(src, language, node); ok { + imports = append(imports, imported) + } + } + }) + return imports +} + +func javascriptImportStatement( + src []byte, + language *ts.Language, + statement *ts.Node, +) []Import { + var clause *ts.Node + var module string + for i := range statement.NamedChildCount() { + child := statement.NamedChild(i) + switch child.Type(language) { + case "import_clause": + clause = child + case "string": + module = sourceString(child.Text(src)) + } + } + if module == "" { + return nil + } + line := sourceLine(statement) + if clause == nil { + return []Import{{Module: module, Kind: ImportSideEffect, Line: line}} + } + + var imports []Import + for i := range clause.NamedChildCount() { + child := clause.NamedChild(i) + switch child.Type(language) { + case "identifier": + imports = append(imports, Import{ + Module: module, + Kind: ImportDefault, + Names: []Name{{Alias: child.Text(src)}}, + Line: line, + }) + case "namespace_import": + if child.NamedChildCount() > 0 { + imports = append(imports, Import{ + Module: module, + Kind: ImportNamespace, + Names: []Name{{Alias: child.NamedChild(child.NamedChildCount() - 1).Text(src)}}, + Line: line, + }) + } + case "named_imports": + names := javascriptNamedImports(src, language, child) + if len(names) > 0 { + imports = append(imports, Import{Module: module, Kind: ImportNamed, Names: names, Line: line}) + } + } + } + return imports +} + +func javascriptNamedImports(src []byte, language *ts.Language, imports *ts.Node) []Name { + var names []Name + for i := range imports.NamedChildCount() { + specifier := imports.NamedChild(i) + if specifier.Type(language) != "import_specifier" || specifier.NamedChildCount() == 0 { + continue + } + name := Name{Name: specifier.NamedChild(0).Text(src)} + if specifier.NamedChildCount() > 1 { + name.Alias = specifier.NamedChild(specifier.NamedChildCount() - 1).Text(src) + } + names = append(names, name) + } + return names +} + +func javascriptRequireDeclarator( + src []byte, + language *ts.Language, + declarator *ts.Node, +) (Import, bool) { + nameNode := declarator.ChildByFieldName("name", language) + valueNode := declarator.ChildByFieldName("value", language) + if nameNode == nil && declarator.NamedChildCount() > 0 { + nameNode = declarator.NamedChild(0) + } + if valueNode == nil && declarator.NamedChildCount() > 1 { + valueNode = declarator.NamedChild(1) + } + module, member, ok := javascriptRequireValue(src, language, valueNode) + if !ok || nameNode == nil { + return Import{}, false + } + + imported := Import{Module: module, Line: sourceLine(declarator)} + switch nameNode.Type(language) { + case "identifier": + alias := nameNode.Text(src) + if member != "" { + imported.Kind = ImportNamed + imported.Names = []Name{{Name: member, Alias: alias}} + } else { + imported.Kind = ImportModule + imported.Names = []Name{{Alias: alias}} + } + case "object_pattern": + imported.Kind = ImportNamed + imported.Names = javascriptObjectPatternNames(src, language, nameNode) + default: + return Import{}, false + } + return imported, len(imported.Names) > 0 +} + +func javascriptObjectPatternNames(src []byte, language *ts.Language, pattern *ts.Node) []Name { + var names []Name + for i := range pattern.NamedChildCount() { + child := pattern.NamedChild(i) + switch child.Type(language) { + case "shorthand_property_identifier_pattern", "identifier": + names = append(names, Name{Name: child.Text(src)}) + case "pair_pattern": + if child.NamedChildCount() == 0 { + continue + } + name := Name{Name: child.NamedChild(0).Text(src)} + if child.NamedChildCount() > 1 { + name.Alias = child.NamedChild(child.NamedChildCount() - 1).Text(src) + } + names = append(names, name) + } + } + return names +} + +func javascriptRequireValue( + src []byte, + language *ts.Language, + value *ts.Node, +) (module, member string, ok bool) { + if value == nil { + return "", "", false + } + if value.Type(language) == javascriptCallExpression { + module, ok = javascriptRequireCall(src, language, value) + return module, "", ok + } + if value.Type(language) != "member_expression" || value.NamedChildCount() < 2 { + return "", "", false + } + object := value.ChildByFieldName("object", language) + property := value.ChildByFieldName("property", language) + if object == nil { + object = value.NamedChild(0) + } + if property == nil { + property = value.NamedChild(value.NamedChildCount() - 1) + } + module, ok = javascriptRequireCall(src, language, object) + if !ok { + return "", "", false + } + return module, property.Text(src), true +} + +func javascriptStandaloneRequire( + src []byte, + language *ts.Language, + call *ts.Node, +) (Import, bool) { + module, ok := javascriptRequireCall(src, language, call) + if !ok || javascriptRequireHandledByDeclarator(src, language, call) { + return Import{}, false + } + if parent := call.Parent(); parent != nil && parent.Type(language) == "member_expression" { + object := parent.ChildByFieldName("object", language) + if object == nil && parent.NamedChildCount() > 0 { + object = parent.NamedChild(0) + } + if object == call { + property := parent.ChildByFieldName("property", language) + if property == nil && parent.NamedChildCount() > 1 { + property = parent.NamedChild(parent.NamedChildCount() - 1) + } + if property != nil { + return Import{ + Module: module, + Kind: ImportNamed, + Names: []Name{{Name: property.Text(src)}}, + Line: sourceLine(call), + }, true + } + } + } + return Import{Module: module, Kind: ImportSideEffect, Line: sourceLine(call)}, true +} + +func javascriptRequireHandledByDeclarator(src []byte, language *ts.Language, call *ts.Node) bool { + declarator := ancestorNode(call, "variable_declarator", language) + if declarator == nil { + return false + } + value := declarator.ChildByFieldName("value", language) + if value == nil && declarator.NamedChildCount() > 1 { + value = declarator.NamedChild(1) + } + _, _, ok := javascriptRequireValue(src, language, value) + return ok +} + +func javascriptRequireCall(src []byte, language *ts.Language, call *ts.Node) (string, bool) { + if call == nil || call.Type(language) != javascriptCallExpression || call.NamedChildCount() < minimumMemberChildren { + return "", false + } + function := call.ChildByFieldName("function", language) + arguments := call.ChildByFieldName("arguments", language) + if function == nil { + function = call.NamedChild(0) + } + if arguments == nil { + arguments = call.NamedChild(1) + } + if function.Type(language) != "identifier" || function.Text(src) != "require" || arguments == nil { + return "", false + } + for i := range arguments.NamedChildCount() { + argument := arguments.NamedChild(i) + if argument.Type(language) == "string" { + module := sourceString(argument.Text(src)) + return module, module != "" + } + } + return "", false +} + +func sourceString(value string) string { + if len(value) < quotedStringOverhead { + return "" + } + first := value[0] + last := value[len(value)-1] + if first == last && (first == '\'' || first == '"' || first == '`') { + return value[1 : len(value)-1] + } + return value +} diff --git a/import_hyrum.go b/import_hyrum.go new file mode 100644 index 0000000..da2f22b --- /dev/null +++ b/import_hyrum.go @@ -0,0 +1,241 @@ +package outline + +import ( + "strings" + + ts "github.com/odvcencio/gotreesitter" +) + +func goImports(src []byte, language *ts.Language, root *ts.Node) []Import { + var imports []Import + walkNamed(root, func(node *ts.Node) { + if node.Type(language) != "import_spec" { + return + } + var module string + var alias string + for i := range node.NamedChildCount() { + child := node.NamedChild(i) + switch child.Type(language) { + case "interpreted_string_literal", "raw_string_literal": + module = sourceString(child.Text(src)) + case "package_identifier": + alias = child.Text(src) + case "blank_identifier": + alias = "_" + case "dot": + alias = "." + } + } + if module == "" { + return + } + imported := Import{Module: module, Kind: ImportModule, Line: sourceLine(node)} + switch alias { + case "_": + imported.Kind = ImportSideEffect + case ".": + imported.Kind = ImportWildcard + case "": + default: + imported.Names = []Name{{Alias: alias}} + } + imports = append(imports, imported) + }) + return imports +} + +func rubyImports(src []byte, language *ts.Language, root *ts.Node) []Import { + var imports []Import + walkNamed(root, func(node *ts.Node) { + if node.Type(language) != "call" || node.NamedChildCount() < 2 { + return + } + function := node.NamedChild(0) + if function.Type(language) != "identifier" || function.Text(src) != "require" { + return + } + arguments := node.NamedChild(1) + moduleNode := firstDescendantType(arguments, language, "string") + if moduleNode == nil { + return + } + module := sourceString(moduleNode.Text(src)) + if module != "" { + imports = append(imports, Import{Module: module, Kind: ImportSideEffect, Line: sourceLine(node)}) + } + }) + return imports +} + +func rustImports(src []byte, language *ts.Language, root *ts.Node) []Import { + var imports []Import + walkNamed(root, func(node *ts.Node) { + switch node.Type(language) { + case "use_declaration": + if imported, ok := rustUseImport(node.Text(src), sourceLine(node)); ok { + imports = append(imports, imported) + } + case "extern_crate_declaration": + if imported, ok := rustExternCrateImport(node.Text(src), sourceLine(node)); ok { + imports = append(imports, imported) + } + } + }) + return imports +} + +func rustUseImport(value string, line int) (Import, bool) { + rest, ok := afterWord(value, "use") + if !ok { + return Import{}, false + } + rest = strings.TrimSpace(strings.TrimSuffix(rest, ";")) + module, tail, hasTail := strings.Cut(rest, "::") + module = strings.TrimSpace(module) + if module == "" { + return Import{}, false + } + if !hasTail { + name, alias := splitImportAlias(module) + if name == "" { + return Import{}, false + } + imported := Import{Module: name, Kind: ImportModule, Names: []Name{{Alias: name}}, Line: line} + if alias != "" { + imported.Names[0].Alias = alias + } + return imported, true + } + + tail = strings.TrimSpace(tail) + if tail == "*" { + return Import{Module: module, Kind: ImportWildcard, Line: line}, true + } + if strings.HasPrefix(tail, "{") && strings.HasSuffix(tail, "}") { + items := strings.Split(strings.TrimSpace(tail[1:len(tail)-1]), ",") + names := make([]Name, 0, len(items)) + for _, item := range items { + name, alias := splitImportAlias(strings.TrimSpace(item)) + if name != "" { + names = append(names, Name{Name: name, Alias: alias}) + } + } + if len(names) == 0 { + return Import{}, false + } + return Import{Module: module, Kind: ImportNamed, Names: names, Line: line}, true + } + name, alias := splitImportAlias(tail) + if name == "" { + return Import{}, false + } + return Import{ + Module: module, + Kind: ImportNamed, + Names: []Name{{Name: name, Alias: alias}}, + Line: line, + }, true +} + +func rustExternCrateImport(value string, line int) (Import, bool) { + rest, ok := afterWord(value, "crate") + if !ok { + return Import{}, false + } + name, alias := splitImportAlias(strings.TrimSpace(strings.TrimSuffix(rest, ";"))) + if name == "" { + return Import{}, false + } + if alias == "" { + alias = name + } + return Import{Module: name, Kind: ImportModule, Names: []Name{{Alias: alias}}, Line: line}, true +} + +func splitImportAlias(value string) (string, string) { + name, alias, found := strings.Cut(strings.TrimSpace(value), " as ") + if !found { + return name, "" + } + return strings.TrimSpace(name), strings.TrimSpace(alias) +} + +func afterWord(value, word string) (string, bool) { + for _, prefix := range []string{word + " ", "pub " + word + " "} { + if index := strings.Index(value, prefix); index >= 0 { + return value[index+len(prefix):], true + } + } + return "", false +} + +func phpImports(src []byte, language *ts.Language, root *ts.Node) []Import { + var imports []Import + walkNamed(root, func(node *ts.Node) { + if node.Type(language) != "namespace_use_clause" { + return + } + value := strings.TrimSpace(node.Text(src)) + name, alias := splitCaseInsensitiveAlias(value) + name = strings.TrimPrefix(name, `\`) + if name == "" { + return + } + if alias == "" { + parts := strings.Split(name, `\`) + alias = parts[len(parts)-1] + } + imports = append(imports, Import{ + Module: name, + Kind: ImportModule, + Names: []Name{{Alias: alias}}, + Line: sourceLine(node), + }) + }) + return imports +} + +func splitCaseInsensitiveAlias(value string) (string, string) { + lower := strings.ToLower(value) + index := strings.LastIndex(lower, " as ") + if index < 0 { + return value, "" + } + return strings.TrimSpace(value[:index]), strings.TrimSpace(value[index+4:]) +} + +func elixirImports(src []byte, language *ts.Language, root *ts.Node) []Import { + var imports []Import + walkNamed(root, func(node *ts.Node) { + if node.Type(language) != "call" || node.NamedChildCount() < 2 { + return + } + function := node.NamedChild(0) + if function.Type(language) != "identifier" { + return + } + form := function.Text(src) + if form != "alias" && form != "import" && form != "require" && form != "use" { + return + } + arguments := node.NamedChild(1) + aliases := descendantTexts(src, language, arguments, "alias") + if len(aliases) == 0 { + return + } + module := aliases[0] + imported := Import{Module: module, Kind: ImportModule, Line: sourceLine(node)} + if form == "import" { + imported.Kind = ImportWildcard + } else { + alias := module[strings.LastIndex(module, ".")+1:] + if len(aliases) > 1 { + alias = aliases[len(aliases)-1] + } + imported.Names = []Name{{Alias: alias}} + } + imports = append(imports, imported) + }) + return imports +} diff --git a/import_test.go b/import_test.go new file mode 100644 index 0000000..ed1358a --- /dev/null +++ b/import_test.go @@ -0,0 +1,242 @@ +package outline + +import ( + "reflect" + "testing" +) + +func TestPythonImports(t *testing.T) { + t.Parallel() + src := []byte(`import flask +import yaml as y +import flask.json +from werkzeug.http import ( + parse_authorization_header as parse, + dump_header, +) +from flask import * +`) + want := []Import{ + {Module: "flask", Kind: ImportModule, Names: []Name{{Alias: "flask"}}, Line: 1}, + {Module: "yaml", Kind: ImportModule, Names: []Name{{Alias: "y"}}, Line: 2}, + {Module: "flask.json", Kind: ImportModule, Names: []Name{{Alias: "flask"}}, Line: 3}, + { + Module: "werkzeug.http", + Kind: ImportNamed, + Names: []Name{ + {Name: "parse_authorization_header", Alias: "parse"}, + {Name: "dump_header"}, + }, + Line: 4, + }, + {Module: "flask", Kind: ImportWildcard, Line: 8}, + } + + got, ok := Imports(src, "app.py") + if !ok { + t.Fatal("Imports() supported = false") + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("Imports() = %#v, want %#v", got, want) + } +} + +func TestPythonFutureAndRelativeImports(t *testing.T) { + t.Parallel() + src := []byte(`from __future__ import annotations +from .local import thing +from ..pkg.mod import value as local +`) + want := []Import{ + {Module: "__future__", Kind: ImportNamed, Names: []Name{{Name: "annotations"}}, Line: 1}, + {Module: ".local", Kind: ImportNamed, Names: []Name{{Name: "thing"}}, Line: 2}, + {Module: "..pkg.mod", Kind: ImportNamed, Names: []Name{{Name: "value", Alias: "local"}}, Line: 3}, + } + + got, ok := Imports(src, "app.py") + if !ok { + t.Fatal("Imports() supported = false") + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("Imports() = %#v, want %#v", got, want) + } +} + +func TestJavascriptImports(t *testing.T) { + t.Parallel() + src := []byte(`import ws from "ws"; +import { Server, WebSocket as Socket } from "ws"; +import * as WS from "ws"; +import "side-effect"; +const { OPEN, Server: WSServer } = require("ws"); +const Receiver = require("dep").Server; +const direct = require("dep2"); +require("dep3"); +const wrapped = consume(require("dep4")); +`) + want := []Import{ + {Module: "ws", Kind: ImportDefault, Names: []Name{{Alias: "ws"}}, Line: 1}, + { + Module: "ws", + Kind: ImportNamed, + Names: []Name{ + {Name: "Server"}, + {Name: "WebSocket", Alias: "Socket"}, + }, + Line: 2, + }, + {Module: "ws", Kind: ImportNamespace, Names: []Name{{Alias: "WS"}}, Line: 3}, + {Module: "side-effect", Kind: ImportSideEffect, Line: 4}, + { + Module: "ws", + Kind: ImportNamed, + Names: []Name{ + {Name: "OPEN"}, + {Name: "Server", Alias: "WSServer"}, + }, + Line: 5, + }, + {Module: "dep", Kind: ImportNamed, Names: []Name{{Name: "Server", Alias: "Receiver"}}, Line: 6}, + {Module: "dep2", Kind: ImportModule, Names: []Name{{Alias: "direct"}}, Line: 7}, + {Module: "dep3", Kind: ImportSideEffect, Line: 8}, + {Module: "dep4", Kind: ImportSideEffect, Line: 9}, + } + + got, ok := Imports(src, "app.js") + if !ok { + t.Fatal("Imports() supported = false") + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("Imports() = %#v, want %#v", got, want) + } +} + +func TestTypescriptImports(t *testing.T) { + t.Parallel() + src := []byte(`import ws, { Server as WSServer } from "ws"; +import type { WebSocket } from "ws"; +import * as WS from "ws"; +`) + want := []Import{ + {Module: "ws", Kind: ImportDefault, Names: []Name{{Alias: "ws"}}, Line: 1}, + {Module: "ws", Kind: ImportNamed, Names: []Name{{Name: "Server", Alias: "WSServer"}}, Line: 1}, + {Module: "ws", Kind: ImportNamed, Names: []Name{{Name: "WebSocket"}}, Line: 2}, + {Module: "ws", Kind: ImportNamespace, Names: []Name{{Alias: "WS"}}, Line: 3}, + } + + for _, filename := range []string{"app.ts", "app.tsx"} { + got, ok := Imports(src, filename) + if !ok { + t.Fatalf("Imports(%q) supported = false", filename) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("Imports(%q) = %#v, want %#v", filename, got, want) + } + } +} + +func TestImportsSupportResult(t *testing.T) { + t.Parallel() + if got, ok := Imports([]byte("value = 1\n"), "app.py"); !ok || len(got) != 0 { + t.Fatalf("supported empty Imports() = %#v, %v", got, ok) + } + if got, ok := Imports([]byte("value = 1\n"), "app.txt"); ok || got != nil { + t.Fatalf("unsupported Imports() = %#v, %v", got, ok) + } +} + +func TestHyrumLanguageImports(t *testing.T) { + t.Parallel() + tests := []struct { + filename string + src string + want []Import + }{ + { + filename: "main.go", + src: `package main +import ( + "github.com/x/y" + alias "github.com/x/y/sub" + _ "github.com/x/side-effect" + . "github.com/x/wildcard" +) +`, + want: []Import{ + {Module: "github.com/x/y", Kind: ImportModule, Line: 3}, + {Module: "github.com/x/y/sub", Kind: ImportModule, Names: []Name{{Alias: "alias"}}, Line: 4}, + {Module: "github.com/x/side-effect", Kind: ImportSideEffect, Line: 5}, + {Module: "github.com/x/wildcard", Kind: ImportWildcard, Line: 6}, + }, + }, + { + filename: "app.rb", + src: "require \"octokit\"\nrequire 'octokit/client'\n", + want: []Import{ + {Module: "octokit", Kind: ImportSideEffect, Line: 1}, + {Module: "octokit/client", Kind: ImportSideEffect, Line: 2}, + }, + }, + { + filename: "lib.rs", + src: `use serde::{Deserialize, Serialize as Ser}; +use tokio_util::codec; +extern crate old_crate as old; +`, + want: []Import{ + { + Module: "serde", + Kind: ImportNamed, + Names: []Name{ + {Name: "Deserialize"}, + {Name: "Serialize", Alias: "Ser"}, + }, + Line: 1, + }, + {Module: "tokio_util", Kind: ImportNamed, Names: []Name{{Name: "codec"}}, Line: 2}, + {Module: "old_crate", Kind: ImportModule, Names: []Name{{Alias: "old"}}, Line: 3}, + }, + }, + { + filename: "app.php", + src: `