Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,28 @@ untyped.
is removed, including `@param mixed`. Matching ignores union member order and
FQN vs short name, so `@param Foo|Bar` and `@param \App\Bar|\App\Foo` both drop.

**String literal docblocks** - when a parameter only ever receives a few plain
string literals (2 to 10 distinct ones), it gets `string` plus a `@param` with
the exact values:

```php
$this->compareScore(7, 'eq');
$this->compareScore(8, 'neq');
```

```diff
+/**
+ * @param 'eq'|'neq' $operator
+ */
-public function compareScore(int $score, $operator)
+public function compareScore(int $score, string $operator)
{
}
```

Any non-literal string (a constant, `sprintf()`, interpolation) skips the
docblock, as does an existing `@param` for that parameter.

**Colored, informative output** - a live progress bar per phase, colored `--dry`
diffs, and a summary of the added types grouped by category (scalar, object,
array, union). Colors respect `NO_COLOR` and disable on non-TTY output.
Expand Down
63 changes: 51 additions & 12 deletions internal/aggregate/aggregate.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,19 @@ import (
type Resolved struct {
Types []string // one or more source type keywords or "object:Fqcn", never "null"
Nullable bool
Literals []string // sorted string literal values, set only for a plain string type, see literalValues
}

const (
minLiterals = 2
maxLiterals = 10
)

// group collects everything observed for one parameter position.
type group struct {
types map[string]struct{}
literals map[string]struct{}
nonLiteralStrings bool
}

// Types holds resolved parameter types keyed for fast lookup during apply.
Expand All @@ -38,17 +51,17 @@ func (t Types) Function(name string, position int) (Resolved, bool) {
// Resolve groups records per parameter into a resolved type: the observed types
// as a union, with null captured as nullability rather than a member.
func Resolve(records []collect.Record) Types {
methodTypes := map[string]map[string]struct{}{}
functionTypes := map[string]map[string]struct{}{}
methodTypes := map[string]*group{}
functionTypes := map[string]*group{}

for _, record := range records {
if record.IsFunction {
key := functionKey(record.Name, record.Position)
addType(functionTypes, key, record.Type)
addRecord(functionTypes, key, record)
continue
}
key := methodKey(record.Class, record.Name, record.Position)
addType(methodTypes, key, record.Type)
addRecord(methodTypes, key, record)
}

return Types{
Expand All @@ -57,12 +70,12 @@ func Resolve(records []collect.Record) Types {
}
}

func resolveGroups(groups map[string]map[string]struct{}) map[string]Resolved {
func resolveGroups(groups map[string]*group) map[string]Resolved {
resolved := map[string]Resolved{}

for key, typeSet := range groups {
types := make([]string, 0, len(typeSet))
for typeName := range typeSet {
for key, group := range groups {
types := make([]string, 0, len(group.types))
for typeName := range group.types {
types = append(types, typeName)
}
sort.Strings(types)
Expand All @@ -78,17 +91,43 @@ func resolveGroups(groups map[string]map[string]struct{}) map[string]Resolved {
if len(members) == 0 {
continue
}
resolved[key] = Resolved{Types: members, Nullable: nullable}
resolved[key] = Resolved{Types: members, Nullable: nullable, Literals: literalValues(members, group)}
}

return resolved
}

func addType(groups map[string]map[string]struct{}, key, typeName string) {
// literalValues returns the string literals passed into a plain string
// parameter, when every string argument was a literal and there are a few
// distinct ones - an enum-like set worth a `'a'|'b'` doc type.
func literalValues(members []string, group *group) []string {
if len(members) != 1 || members[0] != "string" || group.nonLiteralStrings {
return nil
}
if len(group.literals) < minLiterals || len(group.literals) > maxLiterals {
return nil
}
literals := make([]string, 0, len(group.literals))
for literal := range group.literals {
literals = append(literals, literal)
}
sort.Strings(literals)
return literals
}

func addRecord(groups map[string]*group, key string, record collect.Record) {
if groups[key] == nil {
groups[key] = map[string]struct{}{}
groups[key] = &group{types: map[string]struct{}{}, literals: map[string]struct{}{}}
}
groups[key].types[record.Type] = struct{}{}
if record.Type != "string" {
return
}
if record.IsLiteral {
groups[key].literals[record.Literal] = struct{}{}
return
}
groups[key][typeName] = struct{}{}
groups[key].nonLiteralStrings = true
}

func methodKey(class, method string, position int) string {
Expand Down
55 changes: 55 additions & 0 deletions internal/aggregate/aggregate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,58 @@ func TestResolveFunctionAndPositionIsolation(t *testing.T) {
t.Errorf("position 1: got %+v ok=%v", second, ok)
}
}

func TestResolveLiterals(t *testing.T) {
literal := func(value string) collect.Record {
return collect.Record{Class: "A", Name: "m", Type: "string", IsLiteral: true, Literal: value}
}

tests := []struct {
name string
records []collect.Record
want string // literals joined by "|"
}{
{
name: "distinct literals sorted and deduplicated",
records: []collect.Record{literal("neq"), literal("eq"), literal("neq")},
want: "eq|neq",
},
{
name: "literals with null",
records: []collect.Record{literal("eq"), literal("neq"), {Class: "A", Name: "m", Type: "null"}},
want: "eq|neq",
},
{
name: "single literal is skipped",
records: []collect.Record{literal("eq"), literal("eq")},
want: "",
},
{
name: "more than ten literals are skipped",
records: []collect.Record{
literal("a"), literal("b"), literal("c"), literal("d"), literal("e"), literal("f"),
literal("g"), literal("h"), literal("i"), literal("j"), literal("k"),
},
want: "",
},
{
name: "non-literal string drops the literals",
records: []collect.Record{literal("eq"), literal("neq"), {Class: "A", Name: "m", Type: "string"}},
want: "",
},
{
name: "union with another type drops the literals",
records: []collect.Record{literal("eq"), literal("neq"), {Class: "A", Name: "m", Type: "int"}},
want: "",
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
resolved, _ := aggregate.Resolve(test.records).Method("A", "m", 0)
if got := strings.Join(resolved.Literals, "|"); got != test.want {
t.Errorf("got %q want %q", got, test.want)
}
})
}
}
40 changes: 40 additions & 0 deletions internal/apply/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
package apply

import (
"slices"
"strings"

"github.com/rectorphp/argtyper/internal/aggregate"
Expand Down Expand Up @@ -65,20 +66,27 @@ func (a *applier) walk(node ast.Vertex, class *ast.StmtClass) {
func (a *applier) applyFunction(function *ast.StmtFunction) {
name := phpast.ShortName(function.Name)
added := map[string]string{}
docs := map[string]string{}
var order []string
for position, paramNode := range function.Params {
param, ok := paramNode.(*ast.Parameter)
if !ok || !typeable(param) {
continue
}
if resolved, ok := a.types.Function(name, position); ok {
added[phpast.VariableName(param.Var)] = a.setType(param, resolved)
if docType := literalDocType(param, resolved); docType != "" {
docs[phpast.VariableName(param.Var)] = docType
order = append(order, phpast.VariableName(param.Var))
}
continue
}
if resolved, ok := a.defaultType(param, "", ""); ok {
added[phpast.VariableName(param.Var)] = a.setType(param, resolved)
}
}
phpast.StripRedundantDocParams(function, added)
phpast.AddDocParams(function, docs, order)
}

func (a *applier) applyMethod(method *ast.StmtClassMethod, class *ast.StmtClass) {
Expand All @@ -98,20 +106,27 @@ func (a *applier) applyMethod(method *ast.StmtClassMethod, class *ast.StmtClass)
}

added := map[string]string{}
docs := map[string]string{}
var order []string
for position, paramNode := range method.Params {
param, ok := paramNode.(*ast.Parameter)
if !ok || !typeable(param) {
continue
}
if resolved, ok := a.types.Method(className, name, position); ok {
added[phpast.VariableName(param.Var)] = a.setType(param, resolved)
if docType := literalDocType(param, resolved); docType != "" {
docs[phpast.VariableName(param.Var)] = docType
order = append(order, phpast.VariableName(param.Var))
}
continue
}
if resolved, ok := a.defaultType(param, className, classFQCN); ok {
added[phpast.VariableName(param.Var)] = a.setType(param, resolved)
}
}
phpast.StripRedundantDocParams(method, added)
phpast.AddDocParams(method, docs, order)
}

// defaultType infers a parameter type from its literal default value, so
Expand All @@ -134,6 +149,31 @@ func (a *applier) defaultType(param *ast.Parameter, enclosing, enclosingFQCN str
return aggregate.Resolved{Types: []string{typeName}}, true
}

// literalDocType returns a `'a'|'b'` doc type for a parameter that only ever
// receives a few string literals. Empty when there are none, or when a default
// value falls outside them.
func literalDocType(param *ast.Parameter, resolved aggregate.Resolved) string {
if len(resolved.Literals) == 0 {
return ""
}
if param.DefaultValue != nil && !hasNullDefault(param) {
value, ok := phpast.StringLiteral(param.DefaultValue)
if !ok || !slices.Contains(resolved.Literals, value) {
return ""
}
}

members := make([]string, len(resolved.Literals))
for i, literal := range resolved.Literals {
members[i] = "'" + literal + "'"
}
docType := strings.Join(members, "|")
if resolved.Nullable || hasNullDefault(param) {
docType += "|null"
}
return docType
}

// objectFQCN qualifies the class of a `new X()` or `X::CASE` default value,
// mapping self/static to the enclosing class. Empty when it cannot be resolved.
func (a *applier) objectFQCN(expr ast.Vertex, enclosingFQCN string) string {
Expand Down
65 changes: 65 additions & 0 deletions internal/apply/apply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,71 @@ func TestApply(t *testing.T) {
target: "<?php\nnamespace App;\n\nuse App\\Entity\\Lead;\n\nfinal class Repo {\n public function save($entity) {}\n public function go(Lead $lead) { $this->save($lead); }\n}",
want: "<?php\nnamespace App;\n\nuse App\\Entity\\Lead;\n\nfinal class Repo {\n public function save(\\App\\Entity\\Lead $entity) {}\n public function go(Lead $lead) { $this->save($lead); }\n}",
},
{
name: "adds string literals doc when only literals are passed",
target: "<?php\nfinal class A {\n public function compare(int $score, $operator) {}\n public function go() { $this->compare(7, 'eq'); $this->compare(8, 'neq'); $this->compare(6, 'gt'); }\n}",
want: "<?php\nfinal class A {\n /**\n * @param 'eq'|'gt'|'neq' $operator\n */\n public function compare(int $score, string $operator) {}\n public function go() { $this->compare(7, 'eq'); $this->compare(8, 'neq'); $this->compare(6, 'gt'); }\n}",
},
{
name: "adds string literals doc to an existing doc comment",
target: "<?php\nfinal class A {\n /**\n * @throws \\Exception\n */\n public function set($v) {}\n public function go() { $this->set('a'); $this->set('b'); }\n}",
want: "<?php\nfinal class A {\n /**\n * @throws \\Exception\n * @param 'a'|'b' $v\n */\n public function set(string $v) {}\n public function go() { $this->set('a'); $this->set('b'); }\n}",
},
{
name: "expands a single-line doc comment for string literals",
target: "<?php\nfinal class A {\n /** @return void */\n public function set($v) {}\n public function go() { $this->set('a'); $this->set('b'); }\n}",
want: "<?php\nfinal class A {\n /**\n * @return void\n * @param 'a'|'b' $v\n */\n public function set(string $v) {}\n public function go() { $this->set('a'); $this->set('b'); }\n}",
},
{
name: "replaces a redundant string doc with string literals",
target: "<?php\nfinal class A {\n /**\n * @param string $v\n */\n public function set($v) {}\n public function go() { $this->set('a'); $this->set('b'); }\n}",
want: "<?php\nfinal class A {\n /**\n * @param 'a'|'b' $v\n */\n public function set(string $v) {}\n public function go() { $this->set('a'); $this->set('b'); }\n}",
},
{
name: "keeps an existing param doc with description over string literals",
target: "<?php\nfinal class A {\n /**\n * @param string $v the value\n */\n public function set($v) {}\n public function go() { $this->set('a'); $this->set('b'); }\n}",
want: "<?php\nfinal class A {\n /**\n * @param string $v the value\n */\n public function set(string $v) {}\n public function go() { $this->set('a'); $this->set('b'); }\n}",
},
{
name: "adds nullable string literals doc",
target: "<?php\nfunction pick($v) {}",
callers: []string{
"<?php\npick('a');\npick('b');\npick(null);",
},
want: "<?php\n/**\n * @param 'a'|'b'|null $v\n */\nfunction pick(?string $v) {}",
},
{
name: "includes a string literal default that is passed too",
target: "<?php\nfunction pick($v = 'a') {}",
callers: []string{
"<?php\npick('a');\npick('b');",
},
want: "<?php\n/**\n * @param 'a'|'b' $v\n */\nfunction pick(string $v = 'a') {}",
},
{
name: "skips string literals doc when the default is not among them",
target: "<?php\nfunction pick($v = 'c') {}",
callers: []string{
"<?php\npick('a');\npick('b');",
},
want: "<?php\nfunction pick(string $v = 'c') {}",
},
{
name: "skips string literals doc when a non-literal string is passed",
target: "<?php\nfunction pick($v) {}",
callers: []string{
"<?php\npick('a');\npick('b');\npick(sprintf('%s', 'c'));",
},
want: "<?php\nfunction pick(string $v) {}",
},
{
name: "skips string literals doc on an already typed parameter",
target: "<?php\nfunction pick(string $v) {}",
callers: []string{
"<?php\npick('a');\npick('b');",
},
want: "<?php\nfunction pick(string $v) {}",
},
}

for _, test := range tests {
Expand Down
7 changes: 7 additions & 0 deletions internal/collect/collect.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ type Record struct {
Name string // method or function name
Position int // zero-based positional argument index
Type string // "int", "float", "string", "bool", "array", "null" or "object:Fqcn"
IsLiteral bool // true when the argument is a plain string literal, see phpast.StringLiteral
Literal string // the string literal value, when IsLiteral
}

// FromSource collects records from a single PHP source file. The symbols table
Expand Down Expand Up @@ -325,10 +327,15 @@ func (c *collector) record(args []ast.Vertex, base Record, sc scope) {
continue
}

literal, isLiteral := phpast.StringLiteral(arg.Expr)
for _, typeName := range c.argTypes(arg.Expr, sc) {
record := base
record.Position = position
record.Type = typeName
if isLiteral && typeName == "string" {
record.IsLiteral = true
record.Literal = literal
}
c.records = append(c.records, record)
}
}
Expand Down
Loading
Loading