1c1df7f953
- 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
537 lines
13 KiB
Go
537 lines
13 KiB
Go
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, ",")
|
|
}
|