strip source quote-aware and resolve dynamic class references

- strip # comments with quote-state tracking so a hash in a string no
  longer truncates the line
- skip heredoc bodies in every @(EOT) form so documentation text is not
  indexed as a class reference
- take every literal name out of a selector, following a bare variable
  through the assignments in its own file
- record a class reference that stays unresolved against its file, with
  any literal prefix, instead of dropping it
- read a class name case insensitively in a title or type reference
- tag each edge as a declaration or an ordering reference
This commit is contained in:
2026-09-12 17:18:56 +10:00
parent 6551ab9f13
commit 1c1df7f953
6 changed files with 873 additions and 228 deletions
+68 -225
View File
@@ -17,7 +17,6 @@ import (
var manifestRoots = []string{"site", "modules"}
var (
commentRe = regexp.MustCompile(`(?m)#.*$`)
// include/contain/require are functions taking a list of names; the
// argument list is parsed rather than matched so every name is seen.
declFuncRe = regexp.MustCompile(`(?:^|[^\w:$.])(?:include|contain|require)`)
@@ -26,8 +25,11 @@ var (
classParamRe = regexp.MustCompile(`\bclass\s*\{`)
classRefRe = regexp.MustCompile(`\bClass\s*\[`)
classNameRe = regexp.MustCompile(`^(?:::)?[a-z][a-z0-9_]*(?:::[a-z0-9_]+)*$`)
typeNameRe = regexp.MustCompile(`^(?:::)?[A-Z][a-zA-Z0-9_]*(?:::[A-Z][a-zA-Z0-9_]*)*$`)
defineUseRe = regexp.MustCompile(`(?m)(?:^|[^\w:$.])(?:::)?([a-z][a-z0-9_]*(?:::[a-z0-9_]+)+)\s*\{`)
// a class name is case insensitive in a type reference or a resource
// title, so Class['Mysql::server'] and include Mysql::Server both name
// mysql::server.
anyCaseNameRe = regexp.MustCompile(`^(?:::)?[A-Za-z][A-Za-z0-9_]*(?:::[A-Za-z0-9_]+)*$`)
defineUseRe = regexp.MustCompile(`(?m)(?:^|[^\w:$.])(?:::)?([a-z][a-z0-9_]*(?:::[a-z0-9_]+)+)\s*\{`)
// $mod::params::thing reads a class variable, which is a dependency on the
// class that declares it.
scopeVarRe = regexp.MustCompile(`\$(?:::)?([a-z][a-z0-9_]*(?:::[a-z0-9_]+)+)::[a-z_][a-z0-9_]*`)
@@ -45,8 +47,8 @@ var (
type Index struct {
Root string
// includedBy maps a class name to the classes that declare it.
includedBy map[string]map[string]bool
// includedBy maps a class name to the classes that reference it, and how.
includedBy map[string]map[string]refKind
// moduleClasses maps a module name to the classes it defines.
moduleClasses map[string][]string
// assetOwners maps "<module>/<path>" to the classes referencing it.
@@ -57,18 +59,34 @@ type Index struct {
hieraExclude map[string][]string
// known holds every class this repo defines.
known map[string]bool
// unresolved maps a manifest file to the class references in it that no
// static resolution could name.
unresolved map[string][]UnresolvedRef
// unresolvedIncluders records, for each unresolved reference, the classes
// whose manifest holds it, so a class the reference might have named still
// reaches them.
unresolvedIncluders []UnresolvedIncluder
}
// UnresolvedIncluder is an unresolved class reference together with the classes
// declared by the manifest holding it. Any class the reference could have named
// is treated as reaching those classes, because a dropped edge loses hosts.
type UnresolvedIncluder struct {
Ref UnresolvedRef
Classes []string
}
// BuildIndex walks the control repo at root and indexes it.
func BuildIndex(root string) (*Index, error) {
idx := &Index{
Root: root,
includedBy: map[string]map[string]bool{},
includedBy: map[string]map[string]refKind{},
moduleClasses: map[string][]string{},
assetOwners: map[string][]string{},
hieraInclude: map[string][]string{},
hieraExclude: map[string][]string{},
known: map[string]bool{},
unresolved: map[string][]UnresolvedRef{},
}
for _, base := range manifestRoots {
@@ -131,21 +149,35 @@ func (i *Index) addManifest(rel, full string) error {
if err != nil {
return fmt.Errorf("reading %s: %w", rel, err)
}
body := commentRe.ReplaceAllString(string(data), "")
body := stripSource(string(data))
self := map[string]bool{}
for _, name := range declared {
self[name] = true
}
for _, ref := range references(body) {
refs, unresolved := scanReferences(body)
for ref, kind := range refs {
if self[ref] {
continue
}
if i.includedBy[ref] == nil {
i.includedBy[ref] = map[string]bool{}
i.includedBy[ref] = map[string]refKind{}
}
for _, name := range declared {
i.includedBy[ref][name] = true
if old, ok := i.includedBy[ref][name]; !ok || kind > old {
i.includedBy[ref][name] = kind
}
}
}
for idx := range unresolved {
unresolved[idx].File = rel
}
if len(unresolved) > 0 && len(declared) > 0 {
i.unresolved[rel] = unresolved
for _, ref := range unresolved {
i.unresolvedIncluders = append(i.unresolvedIncluders,
UnresolvedIncluder{Ref: ref, Classes: declared})
}
}
@@ -155,220 +187,6 @@ func (i *Index) addManifest(rel, full string) error {
return nil
}
// references extracts every class or defined-type name a manifest body names,
// across all the declaration forms this repo uses.
func references(body string) []string {
seen := map[string]bool{}
add := func(name string) {
if name != "" && name != "class" && name != "define" {
seen[name] = true
}
}
// include/contain/require: every name in the argument list.
for _, loc := range declFuncRe.FindAllStringIndex(body, -1) {
args, ok := callArguments(body, loc[1])
if !ok {
continue
}
for _, name := range classNames(args) {
add(name)
}
}
// class { ['a', 'b']: } declares one resource per title.
for _, loc := range classParamRe.FindAllStringIndex(body, -1) {
for _, name := range classNames(resourceTitles(body, loc[1])) {
add(name)
}
}
// Class['a', 'b'] is an ordering reference to each named class.
for _, loc := range classRefRe.FindAllStringIndex(body, -1) {
inner, _, ok := balanced(body, loc[1]-1, '[', ']')
if !ok {
continue
}
for _, name := range classNames(inner) {
add(name)
}
}
for _, re := range []*regexp.Regexp{inheritsRe, defineUseRe, scopeVarRe, createResRe} {
for _, m := range re.FindAllStringSubmatch(body, -1) {
add(strings.TrimPrefix(m[1], "::"))
}
}
// Resource references (Profiles::Foo::Bar['x']) name a defined type in
// capitalised form.
for _, m := range resourceRefRe.FindAllStringSubmatch(body, -1) {
add(strings.ToLower(m[1]))
}
out := make([]string, 0, len(seen))
for name := range seen {
out = append(out, name)
}
sort.Strings(out)
return out
}
// callArguments returns the argument text of the function call whose name ends
// at i, in either the parenthesised or the bare statement form. It reports
// false when the keyword is not a call at all, such as a require => attribute.
func callArguments(s string, i int) (string, bool) {
if i >= len(s) {
return "", false
}
if s[i] != '(' && s[i] != ' ' && s[i] != '\t' {
return "", false
}
for i < len(s) && (s[i] == ' ' || s[i] == '\t') {
i++
}
if i >= len(s) {
return "", false
}
if s[i] == '(' {
inner, _, ok := balanced(s, i, '(', ')')
return inner, ok
}
if s[i] == '=' || s[i] == ',' || s[i] == ';' || s[i] == '\n' {
return "", false
}
depth := 0
for j := i; j < len(s); j++ {
switch c := s[j]; {
case c == '\'' || c == '"':
j = skipString(s, j) - 1
case c == '(' || c == '[' || c == '{':
depth++
case c == ')' || c == ']' || c == '}':
if depth == 0 {
return s[i:j], true
}
depth--
case c == ';' && depth == 0:
return s[i:j], true
case c == '\n' && depth == 0 && !continued(s[i:j]):
return s[i:j], true
}
}
return s[i:], true
}
// classNames pulls every class name out of a comma-separated argument list,
// descending into array literals and unwrapping quotes.
func classNames(args string) []string {
var out []string
for _, arg := range splitArgs(args) {
arg = strings.TrimSpace(arg)
if arg == "" {
continue
}
if arg[0] == '[' {
if inner, _, ok := balanced(arg, 0, '[', ']'); ok {
out = append(out, classNames(inner)...)
}
continue
}
if len(arg) > 1 && (arg[0] == '\'' || arg[0] == '"') && arg[len(arg)-1] == arg[0] {
arg = arg[1 : len(arg)-1]
}
switch {
case classNameRe.MatchString(arg):
out = append(out, strings.TrimPrefix(arg, "::"))
case typeNameRe.MatchString(arg):
// Class[Apache::Ssl] names the class in type form.
out = append(out, strings.ToLower(strings.TrimPrefix(arg, "::")))
}
}
return out
}
// splitArgs splits an argument list on its top-level commas.
func splitArgs(s string) []string {
var out []string
depth, start := 0, 0
for j := 0; j < len(s); j++ {
switch c := s[j]; c {
case '\'', '"':
j = skipString(s, j) - 1
case '(', '[', '{':
depth++
case ')', ']', '}':
depth--
case ',':
if depth == 0 {
out = append(out, s[start:j])
start = j + 1
}
}
}
return append(out, s[start:])
}
// resourceTitles returns the title expression of a resource body whose opening
// brace ends at i, i.e. everything up to the colon that closes the title.
func resourceTitles(s string, i int) string {
depth := 0
for j := i; j < len(s); j++ {
switch c := s[j]; c {
case '\'', '"':
j = skipString(s, j) - 1
case '(', '[', '{':
depth++
case ')', ']', '}':
if depth == 0 {
return ""
}
depth--
case ':':
if depth == 0 {
return s[i:j]
}
}
}
return ""
}
// balanced returns the text between the delimiter at i and its match.
func balanced(s string, i int, opener, closer byte) (string, int, bool) {
depth := 0
for j := i; j < len(s); j++ {
switch c := s[j]; c {
case '\'', '"':
j = skipString(s, j) - 1
case opener:
depth++
case closer:
depth--
if depth == 0 {
return s[i+1 : j], j + 1, true
}
}
}
return "", len(s), false
}
// skipString returns the index just past the quoted string starting at i.
func skipString(s string, i int) int {
quote := s[i]
for j := i + 1; j < len(s); j++ {
switch s[j] {
case '\\':
j++
case quote:
return j + 1
}
}
return len(s)
}
// continued reports whether an argument list so far ends on a comma, meaning
// the list carries on over the newline.
func continued(s string) bool {
s = strings.TrimRight(s, " \t\r")
return strings.HasSuffix(s, ",")
}
func assetReferences(body string) []string {
var out []string
for _, m := range assetRe.FindAllStringSubmatch(body, -1) {
@@ -431,7 +249,7 @@ func stringList(v any) []string {
return out
}
// IncludedBy returns the classes that declare the given class, sorted.
// IncludedBy returns the classes that reference the given class, sorted.
func (i *Index) IncludedBy(class string) []string {
names := make([]string, 0, len(i.includedBy[class]))
for name := range i.includedBy[class] {
@@ -441,6 +259,31 @@ func (i *Index) IncludedBy(class string) []string {
return names
}
// EdgeNote describes an edge that is not a declaration, so an explanation can
// say why the class is reached. It is empty for a plain declaration.
func (i *Index) EdgeNote(class, parent string) string {
if i.includedBy[class][parent] == refDeclare {
return ""
}
return " (ordering or data reference, not a declaration)"
}
// UnresolvedIncluders returns the unresolved references that could have named
// the given class, matched on whatever literal prefix was recoverable.
func (i *Index) UnresolvedIncluders(class string) []UnresolvedIncluder {
var out []UnresolvedIncluder
for _, u := range i.unresolvedIncluders {
if strings.HasPrefix(class, u.Ref.Prefix) {
out = append(out, u)
}
}
return out
}
// UnresolvedIn returns the class references in a manifest file that no static
// resolution could name.
func (i *Index) UnresolvedIn(file string) []UnresolvedRef { return i.unresolved[file] }
// ModuleClasses returns every class the module defines, sorted.
func (i *Index) ModuleClasses(module string) []string { return i.moduleClasses[module] }
+16 -3
View File
@@ -2,13 +2,26 @@ package impact
import (
"reflect"
"sort"
"testing"
)
// refs runs a manifest body through the same comment stripping addManifest
// does, then extracts its references.
// refs runs a manifest body through the same stripping addManifest does, then
// extracts the class names it resolves.
func refs(body string) []string {
return references(commentRe.ReplaceAllString(body, ""))
names, _ := scanReferences(stripSource(body))
out := make([]string, 0, len(names))
for name := range names {
out = append(out, name)
}
sort.Strings(out)
return out
}
// unresolvedRefs returns the references the scan could not reduce to names.
func unresolvedRefs(body string) []UnresolvedRef {
_, out := scanReferences(stripSource(body))
return out
}
func TestReferencesReadsWholeArgumentList(t *testing.T) {
+536
View File
@@ -0,0 +1,536 @@
package impact
import (
"regexp"
"strings"
)
// refKind separates a construct that applies a class from one that only names
// it for ordering or to read a value out of it.
type refKind int
const (
refOrdering refKind = iota
refDeclare
)
// UnresolvedRef is a class reference the parser saw but could not reduce to
// literal names. Dropping one silently loses hosts, so it is recorded against
// its file and reported instead.
type UnresolvedRef struct {
File string `json:"file"`
Form string `json:"form"`
Expr string `json:"expr"`
Prefix string `json:"prefix,omitempty"`
}
// reserved are bare words an argument list or selector arm can hold that are
// values rather than class names.
var reserved = map[string]bool{
"undef": true, "true": true, "false": true, "default": true,
"class": true, "define": true,
}
// maxVarDepth caps how far a bare variable is followed through same-file
// assignments before the reference is called unresolved.
const maxVarDepth = 4
// assignRe finds `$name = ` while skipping `==`, `=~` and `=>`.
var assignRe = regexp.MustCompile(`(?:^|[^\w$.])\$([a-z_][a-zA-Z0-9_]*)\s*=[^=~>]`)
type refScan struct {
refs map[string]refKind
unresolved []UnresolvedRef
vars map[string]string
depth int
}
// scanReferences extracts every class or defined-type name a manifest body
// names, plus every class reference it could not resolve to literal names.
func scanReferences(body string) (map[string]refKind, []UnresolvedRef) {
s := &refScan{refs: map[string]refKind{}, vars: assignments(body)}
// include/contain/require: every name in the argument list.
for _, loc := range declFuncRe.FindAllStringIndex(body, -1) {
args, ok := callArguments(body, loc[1])
if !ok {
continue
}
s.args(callName(body[loc[0]:loc[1]]), refDeclare, args)
}
// class { ['a', 'b']: } declares one resource per title.
for _, loc := range classParamRe.FindAllStringIndex(body, -1) {
s.args("class {}", refDeclare, resourceTitles(body, loc[1]))
}
// Class['a', 'b'] names classes that are declared elsewhere; it orders
// against them rather than applying them.
for _, loc := range classRefRe.FindAllStringIndex(body, -1) {
inner, _, ok := balanced(body, loc[1]-1, '[', ']')
if !ok {
continue
}
s.args("Class[]", refOrdering, inner)
}
for _, re := range []*regexp.Regexp{inheritsRe, defineUseRe, createResRe} {
for _, m := range re.FindAllStringSubmatch(body, -1) {
s.add(m[1], refDeclare)
}
}
// A $mod::params::thing read and a Mod::Type['x'] reference both depend on
// another class without declaring it.
for _, m := range scopeVarRe.FindAllStringSubmatch(body, -1) {
s.add(m[1], refOrdering)
}
for _, m := range resourceRefRe.FindAllStringSubmatch(body, -1) {
s.add(strings.ToLower(m[1]), refOrdering)
}
return s.refs, dedupeUnresolved(s.unresolved)
}
// callName trims the separator declFuncRe matched ahead of the function name.
func callName(match string) string {
return strings.TrimLeftFunc(match, func(r rune) bool { return r < 'a' || r > 'z' })
}
func (s *refScan) add(name string, kind refKind) {
name = strings.TrimPrefix(name, "::")
if name == "" || reserved[name] {
return
}
if old, ok := s.refs[name]; !ok || kind > old {
s.refs[name] = kind
}
}
func (s *refScan) args(form string, kind refKind, args string) {
for _, arg := range splitArgs(args) {
s.arg(form, kind, arg)
}
}
// arg reduces one argument expression to class names, descending into arrays,
// parentheses and selector arms. Anything it cannot reduce becomes an
// unresolved reference rather than nothing.
func (s *refScan) arg(form string, kind refKind, arg string) {
arg = strings.TrimSpace(arg)
if arg == "" {
return
}
switch arg[0] {
case '[':
s.nested(form, kind, arg, '[', ']')
case '(':
s.nested(form, kind, arg, '(', ')')
default:
if arms, ok := selectorArms(arg); ok {
s.selector(form, kind, arg, arms)
return
}
s.literal(form, kind, arg)
}
}
func (s *refScan) nested(form string, kind refKind, arg string, opener, closer byte) {
inner, _, ok := balanced(arg, 0, opener, closer)
if !ok {
s.unresolvedAdd(form, arg, "")
return
}
s.args(form, kind, inner)
}
// selector takes every literal name out of every arm, which is strictly better
// than widening: a selector over role names lists all the classes it can pick.
func (s *refScan) selector(form string, kind refKind, arg, arms string) {
for _, arm := range splitArgs(arms) {
_, value, ok := cutRocket(arm)
if !ok {
if strings.TrimSpace(arm) != "" {
s.unresolvedAdd(form, arg, "")
}
continue
}
s.arg(form, kind, value)
}
}
func (s *refScan) literal(form string, kind refKind, arg string) {
lit, quoted := unquote(arg)
switch {
case classNameRe.MatchString(lit):
s.add(lit, kind)
case anyCaseNameRe.MatchString(lit):
// Class[Apache::Ssl] names the class in type form.
s.add(strings.ToLower(lit), kind)
case quoted:
// "profile::role_${role}" is a name only the compiler can finish.
s.unresolvedAdd(form, arg, literalPrefix(lit))
case strings.HasPrefix(arg, "$"):
s.variable(form, kind, arg)
default:
s.unresolvedAdd(form, arg, "")
}
}
// variable follows a bare $name through the assignments in the same file, and
// gives up loudly when that leads nowhere.
func (s *refScan) variable(form string, kind refKind, arg string) {
expr, ok := s.vars[strings.TrimPrefix(strings.TrimPrefix(arg, "$"), "::")]
if !ok || s.depth >= maxVarDepth {
s.unresolvedAdd(form, arg, "")
return
}
before := len(s.refs) + len(s.unresolved)
s.depth++
s.arg(form, kind, expr)
s.depth--
if len(s.refs)+len(s.unresolved) == before {
s.unresolvedAdd(form, arg, "")
}
}
func (s *refScan) unresolvedAdd(form, expr, prefix string) {
s.unresolved = append(s.unresolved, UnresolvedRef{Form: form, Expr: collapse(expr), Prefix: prefix})
}
func dedupeUnresolved(in []UnresolvedRef) []UnresolvedRef {
if len(in) == 0 {
return nil
}
seen := map[UnresolvedRef]bool{}
out := in[:0]
for _, u := range in {
if seen[u] {
continue
}
seen[u] = true
out = append(out, u)
}
return out
}
// literalPrefix returns the part of an interpolated name that is fixed text,
// so the report can say what family of classes was missed.
func literalPrefix(s string) string {
i := strings.Index(s, "$")
if i <= 0 {
return ""
}
return s[:i]
}
// collapse folds whitespace and clips an expression to a length that reads in
// a report.
func collapse(s string) string {
out := strings.Join(strings.Fields(s), " ")
if len(out) > 80 {
out = out[:80] + "..."
}
return out
}
func unquote(s string) (string, bool) {
if len(s) > 1 && (s[0] == '\'' || s[0] == '"') && s[len(s)-1] == s[0] {
return s[1 : len(s)-1], true
}
return s, false
}
// selectorArms returns the arm text of a `<expr> ? { ... }` selector.
func selectorArms(s string) (string, bool) {
depth := 0
for j := 0; j < len(s); j++ {
switch c := s[j]; c {
case '\'', '"':
j = skipString(s, j) - 1
case '(', '[', '{':
depth++
case ')', ']', '}':
depth--
case '?':
if depth != 0 {
continue
}
k := j + 1
for k < len(s) && isSpace(s[k]) {
k++
}
if k < len(s) && s[k] == '{' {
inner, _, ok := balanced(s, k, '{', '}')
return inner, ok
}
}
}
return "", false
}
// cutRocket splits a selector arm on its top-level `=>`.
func cutRocket(s string) (string, string, bool) {
depth := 0
for j := 0; j < len(s); j++ {
switch c := s[j]; c {
case '\'', '"':
j = skipString(s, j) - 1
case '(', '[', '{':
depth++
case ')', ']', '}':
depth--
case '=':
if depth == 0 && j+1 < len(s) && s[j+1] == '>' {
return s[:j], s[j+2:], true
}
}
}
return "", "", false
}
// assignments maps a variable assigned in this file to the expression it was
// assigned, so `$p = $x ? {...}; include $p` still yields names.
func assignments(body string) map[string]string {
out := map[string]string{}
for _, loc := range assignRe.FindAllStringSubmatchIndex(body, -1) {
name := body[loc[2]:loc[3]]
if _, seen := out[name]; seen {
continue
}
out[name] = statementExpr(body, loc[1]-1)
}
return out
}
// statementExpr reads one expression starting at i, ending at the top-level
// semicolon, newline or closing delimiter that terminates the statement.
func statementExpr(s string, i int) string {
for i < len(s) && (s[i] == ' ' || s[i] == '\t') {
i++
}
depth := 0
for j := i; j < len(s); j++ {
switch c := s[j]; {
case c == '\'' || c == '"':
j = skipString(s, j) - 1
case c == '(' || c == '[' || c == '{':
depth++
case c == ')' || c == ']' || c == '}':
if depth == 0 {
return s[i:j]
}
depth--
case c == ';' && depth == 0:
return s[i:j]
case c == '\n' && depth == 0 && !continued(s[i:j]):
return s[i:j]
}
}
return s[i:]
}
// stripSource blanks out # comments and heredoc bodies. Both are text the
// class scan must not read: a `#` inside a string is not a comment, and an
// `include` in heredoc documentation is not an edge. Blanking rather than
// deleting keeps the line structure the statement scan relies on.
func stripSource(src string) string {
out := []byte(src)
blank := func(from, to int) {
for k := from; k < to && k < len(out); k++ {
if out[k] != '\n' {
out[k] = ' '
}
}
}
var pending []string
for i := 0; i < len(src); {
switch c := src[i]; {
case c == '\'' || c == '"':
i = skipString(src, i)
case c == '#':
j := i
for j < len(src) && src[j] != '\n' {
j++
}
blank(i, j)
i = j
case c == '@' && i+1 < len(src) && src[i+1] == '(':
tag, next, ok := heredocTag(src, i+1)
if !ok {
i++
continue
}
pending = append(pending, tag)
i = next
case c == '\n':
i++
for _, tag := range pending {
i = blankHeredoc(src, blank, i, tag)
}
pending = pending[:0]
default:
i++
}
}
return string(out)
}
// heredocTag reads the tag out of @(EOT), @("EOT"), @(EOT/L) and @(EOT:json/nt).
func heredocTag(s string, i int) (string, int, bool) {
j := i + 1
for j < len(s) && s[j] != ')' && s[j] != '\n' {
j++
}
if j >= len(s) || s[j] != ')' {
return "", i + 1, false
}
spec := s[i+1 : j]
if k := strings.IndexAny(spec, ":/"); k >= 0 {
spec = spec[:k]
}
spec = strings.Trim(strings.TrimSpace(spec), `"'`)
if spec == "" {
return "", j + 1, false
}
return spec, j + 1, true
}
// blankHeredoc blanks the lines from i up to and including the line that ends
// the heredoc, and returns the index just past it.
func blankHeredoc(s string, blank func(from, to int), i int, tag string) int {
for i < len(s) {
end := strings.IndexByte(s[i:], '\n')
if end < 0 {
blank(i, len(s))
return len(s)
}
line := s[i : i+end]
blank(i, i+end)
i += end + 1
if isHeredocEnd(line, tag) {
return i
}
}
return i
}
// isHeredocEnd matches the terminator line, with its optional `|` indentation
// marker and `-` trailing-newline marker.
func isHeredocEnd(line, tag string) bool {
t := strings.TrimSpace(line)
t = strings.TrimSpace(strings.TrimPrefix(t, "|"))
t = strings.TrimSpace(strings.TrimPrefix(t, "-"))
return t == tag
}
func isSpace(c byte) bool { return c == ' ' || c == '\t' || c == '\n' || c == '\r' }
// callArguments returns the argument text of the function call whose name ends
// at i, in either the parenthesised or the bare statement form. It reports
// false when the keyword is not a call at all, such as a require => attribute.
func callArguments(s string, i int) (string, bool) {
if i >= len(s) {
return "", false
}
if s[i] != '(' && s[i] != ' ' && s[i] != '\t' {
return "", false
}
for i < len(s) && (s[i] == ' ' || s[i] == '\t') {
i++
}
if i >= len(s) {
return "", false
}
if s[i] == '(' {
inner, _, ok := balanced(s, i, '(', ')')
return inner, ok
}
if s[i] == '=' || s[i] == ',' || s[i] == ';' || s[i] == '\n' {
return "", false
}
return statementExpr(s, i), true
}
// splitArgs splits an argument list on its top-level commas.
func splitArgs(s string) []string {
var out []string
depth, start := 0, 0
for j := 0; j < len(s); j++ {
switch c := s[j]; c {
case '\'', '"':
j = skipString(s, j) - 1
case '(', '[', '{':
depth++
case ')', ']', '}':
depth--
case ',':
if depth == 0 {
out = append(out, s[start:j])
start = j + 1
}
}
}
return append(out, s[start:])
}
// resourceTitles returns the title expression of a resource body whose opening
// brace ends at i, i.e. everything up to the colon that closes the title.
func resourceTitles(s string, i int) string {
depth := 0
for j := i; j < len(s); j++ {
switch c := s[j]; c {
case '\'', '"':
j = skipString(s, j) - 1
case '(', '[', '{':
depth++
case ')', ']', '}':
if depth == 0 {
return ""
}
depth--
case ':':
if depth == 0 {
return s[i:j]
}
}
}
return ""
}
// balanced returns the text between the delimiter at i and its match.
func balanced(s string, i int, opener, closer byte) (string, int, bool) {
depth := 0
for j := i; j < len(s); j++ {
switch c := s[j]; c {
case '\'', '"':
j = skipString(s, j) - 1
case opener:
depth++
case closer:
depth--
if depth == 0 {
return s[i+1 : j], j + 1, true
}
}
}
return "", len(s), false
}
// skipString returns the index just past the quoted string starting at i.
func skipString(s string, i int) int {
quote := s[i]
for j := i + 1; j < len(s); j++ {
switch s[j] {
case '\\':
j++
case quote:
return j + 1
}
}
return len(s)
}
// continued reports whether an argument list so far ends on a comma, meaning
// the list carries on over the newline.
func continued(s string) bool {
s = strings.TrimRight(s, " \t\r")
return strings.HasSuffix(s, ",")
}
+249
View File
@@ -0,0 +1,249 @@
package impact
import (
"reflect"
"testing"
)
func TestScanSelectorArgumentKeepsEveryBranch(t *testing.T) {
tests := []struct {
name string
body string
want []string
}{
{
name: "selector as the argument",
body: "class p {\n include $facts['role'] ? { 'web' => 'profile::web', default => 'profile::base' }\n}\n",
want: []string{"profile::base", "profile::web"},
},
{
name: "selector reached through a variable",
body: "class p {\n $profile = $facts['role'] ? { 'web' => 'profile::web', default => 'profile::base' }\n" +
" include $profile\n}\n",
want: []string{"profile::base", "profile::web"},
},
{
name: "selector inside a parenthesised call",
body: "class p {\n include(($x ? { default => 'profile::sel' }))\n}\n",
want: []string{"profile::sel"},
},
{
name: "selector arm holding an array",
body: "class p {\n include $x ? { 'a' => ['profile::a', 'profile::b'], default => 'profile::c' }\n}\n",
want: []string{"profile::a", "profile::b", "profile::c"},
},
{
name: "undef arm contributes nothing",
body: "class p {\n include $x ? { 'a' => 'profile::a', default => undef }\n}\n",
want: []string{"profile::a"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := refs(tc.body); !reflect.DeepEqual(got, tc.want) {
t.Errorf("references = %v, want %v", got, tc.want)
}
if got := unresolvedRefs(tc.body); len(got) != 0 {
t.Errorf("a fully resolved selector reported %v", got)
}
})
}
}
func TestScanHashInsideAStringIsNotAComment(t *testing.T) {
tests := []struct {
name string
body string
want []string
}{
{
name: "double quoted",
body: "class p {\n $x = \"port #1\" ; include profile::afterhash\n}\n",
want: []string{"profile::afterhash"},
},
{
name: "single quoted",
body: "class p {\n $x = 'colour #fff' ; include profile::aftercolour\n}\n",
want: []string{"profile::aftercolour"},
},
{
name: "url fragment",
body: "class p {\n $u = 'https://h/p#frag'\n include profile::afterurl\n}\n",
want: []string{"profile::afterurl"},
},
{
name: "multi line string",
body: "class p {\n $s = \"#!/bin/sh\nexit 0\"\n include profile::afterscript\n}\n",
want: []string{"profile::afterscript"},
},
{
name: "a real comment is still stripped",
body: "class p {\n include profile::live # include profile::dead\n}\n",
want: []string{"profile::live"},
},
{
name: "a hash in a comment after a string",
body: "class p {\n $x = 'a' # ticket #42 include profile::dead\n include profile::live\n}\n",
want: []string{"profile::live"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := refs(tc.body); !reflect.DeepEqual(got, tc.want) {
t.Errorf("references = %v, want %v", got, tc.want)
}
})
}
}
func TestScanSkipsHeredocBodies(t *testing.T) {
body := "class p {\n" +
" $a = @(EOT)\n include profile::plain\n | EOT\n" +
" $b = @(\"EOT\")\n include profile::quoted\n |-EOT\n" +
" $c = @(EOT/L)\n include profile::escapes\n EOT\n" +
" $d = @(EOT:json/nt)\n include profile::syntax\n - EOT\n" +
" include profile::real\n}\n"
if got, want := refs(body), []string{"profile::real"}; !reflect.DeepEqual(got, want) {
t.Errorf("references = %v, want %v", got, want)
}
if got := unresolvedRefs(body); len(got) != 0 {
t.Errorf("heredoc scan reported %v", got)
}
}
func TestScanHeredocBodyDoesNotSwallowFollowingCode(t *testing.T) {
// An unbalanced brace and quote in the body must not leak into the scan.
body := "class p {\n $a = @(EOF)\n { \"unclosed ' ]]] \n EOF\n include profile::after\n}\n"
if got, want := refs(body), []string{"profile::after"}; !reflect.DeepEqual(got, want) {
t.Errorf("references = %v, want %v", got, want)
}
}
func TestScanReportsInterpolatedClassName(t *testing.T) {
body := "class p {\n include \"profile::role_${role}\"\n}\n"
if got := refs(body); len(got) != 0 {
t.Errorf("an interpolated name yielded %v, want nothing resolved", got)
}
got := unresolvedRefs(body)
want := []UnresolvedRef{{Form: "include", Expr: `"profile::role_${role}"`, Prefix: "profile::role_"}}
if !reflect.DeepEqual(got, want) {
t.Errorf("unresolved = %#v, want %#v", got, want)
}
}
func TestScanReportsUnresolvableArguments(t *testing.T) {
tests := []struct {
name string
body string
want UnresolvedRef
}{
{
name: "bare variable",
body: "class p {\n include $unknown\n}\n",
want: UnresolvedRef{Form: "include", Expr: "$unknown"},
},
{
name: "variable assigned from a function",
body: "class p {\n $c = lookup('profile')\n include $c\n}\n",
want: UnresolvedRef{Form: "include", Expr: "lookup('profile')"},
},
{
name: "function call argument",
body: "class p {\n contain lookup('profile')\n}\n",
want: UnresolvedRef{Form: "contain", Expr: "lookup('profile')"},
},
{
name: "class resource with a computed title",
body: "class p {\n class { $title: }\n}\n",
want: UnresolvedRef{Form: "class {}", Expr: "$title"},
},
{
name: "interpolated class reference",
body: "class p {\n Class[\"profile::${x}\"] -> Service['s']\n}\n",
want: UnresolvedRef{Form: "Class[]", Expr: `"profile::${x}"`, Prefix: "profile::"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := unresolvedRefs(tc.body)
if len(got) != 1 || got[0] != tc.want {
t.Errorf("unresolved = %#v, want [%#v]", got, tc.want)
}
})
}
}
func TestScanCaseInsensitiveClassTitle(t *testing.T) {
// Class names are case insensitive in a resource title, so a mixed-case
// one must resolve rather than widen.
body := "class p {\n service { 's': require => Class['Mysql::server'] }\n}\n"
if got, want := refs(body), []string{"mysql::server"}; !reflect.DeepEqual(got, want) {
t.Errorf("references = %v, want %v", got, want)
}
if got := unresolvedRefs(body); len(got) != 0 {
t.Errorf("unresolved = %v", got)
}
}
func TestScanSurvivesMalformedInput(t *testing.T) {
bodies := []string{
"",
"class p {",
"class p {\n include [foo, bar\n",
"class p {\n include 'foo\n",
"class p {\n include \"foo::${\n",
"class p {\n include (\n",
"class p {\n Class[\n",
"class p {\n class { : }\n}\n",
"class p {\n include $x ? {\n",
"class p {\n include $x ? { 'a' => }\n}\n",
"class p {\n $a = @(EOT)\n no terminator here\n",
"@(",
"@()\n",
"#",
"$",
"))))]]]}}}",
"include " + "a::b, " + "include include include",
"class p {\n include [[a::b, [c::d, e::f]], g::h]\n}\n",
}
for _, body := range bodies {
names, unresolved := scanReferences(stripSource(body))
_, _ = names, unresolved
}
// The nested-array case must still resolve every name.
got := refs("class p {\n include [[a::b, [c::d, e::f]], g::h]\n}\n")
want := []string{"a::b", "c::d", "e::f", "g::h"}
if !reflect.DeepEqual(got, want) {
t.Errorf("nested arrays = %v, want %v", got, want)
}
}
func TestStripSourcePreservesLineCount(t *testing.T) {
// The statement scan reads newlines, so stripping must not move them.
body := "class p {\n $a = @(EOT)\n body\n EOT\n include foo # bar\n}\n"
stripped := stripSource(body)
if len(stripped) != len(body) {
t.Fatalf("stripped length %d, want %d", len(stripped), len(body))
}
for i := range body {
if body[i] == '\n' && stripped[i] != '\n' {
t.Fatalf("newline at %d was stripped", i)
}
}
}
func FuzzScanReferences(f *testing.F) {
f.Add("class p {\n include foo::bar\n}\n")
f.Add("class p {\n include $x ? { 'a' => 'b::c', default => undef }\n}\n")
f.Add("class p {\n $a = @(EOT)\n include x::y\n EOT\n}\n")
f.Add("class p {\n include \"a::${b}\" # '\n}\n")
f.Add("))))]]]}}}@(#'\"")
f.Fuzz(func(t *testing.T, body string) {
stripped := stripSource(body)
if len(stripped) != len(body) {
t.Fatalf("stripSource changed length: %d -> %d", len(body), len(stripped))
}
scanReferences(stripped)
})
}
@@ -5,5 +5,6 @@ class profiles::base {
class { 'profiles::viaclass': }
include profiles::cycle::a
include profiles::listed::a, profiles::listed::b
include profiles::dynamic
profiles::defined::thing { 'x': }
}
@@ -0,0 +1,3 @@
class profiles::dynamic {
include "profiles::dyn_${facts['role']}"
}