package gitattributes
import (
"errors"
"io"
"strings"
)
const (
commentPrefix = "#"
eol = "\n"
macroPrefix = "[attr]"
)
var (
ErrMacroNotAllowed = errors.New("macro not allowed")
ErrInvalidAttributeName = errors.New("invalid attribute name")
)
type MatchAttribute struct {
Name string
Pattern Pattern
Attributes []Attribute
}
type attributeState byte
const (
attributeUnknown attributeState = 0
attributeSet attributeState = 1
attributeUnspecified attributeState = '!'
attributeUnset attributeState = '-'
attributeSetValue attributeState = '='
)
type Attribute interface {
Name() string
IsSet() bool
IsUnset() bool
IsUnspecified() bool
IsValueSet() bool
Value() string
String() string
}
type attribute struct {
name string
state attributeState
value string
}
func (a attribute) Name() string {
return a.name
}
func (a attribute) IsSet() bool {
return a.state == attributeSet
}
func (a attribute) IsUnset() bool {
return a.state == attributeUnset
}
func (a attribute) IsUnspecified() bool {
return a.state == attributeUnspecified
}
func (a attribute) IsValueSet() bool {
return a.state == attributeSetValue
}
func (a attribute) Value() string {
return a.value
}
func (a attribute) String() string {
switch a.state {
case attributeSet:
return a.name + ": set"
case attributeUnset:
return a.name + ": unset"
case attributeUnspecified:
return a.name + ": unspecified"
default:
return a.name + ": " + a.value
}
}
// ReadAttributes reads patterns and attributes from the gitattributes format.
func ReadAttributes(r io.Reader, domain []string, allowMacro bool) (attributes []MatchAttribute, err error) {
data, err := io.ReadAll(r)
if err != nil {
return nil, err
}
for _, line := range strings.Split(string(data), eol) {
attribute, err := ParseAttributesLine(line, domain, allowMacro)
if err != nil {
return attributes, err
}
if len(attribute.Name) == 0 {
continue
}
attributes = append(attributes, attribute)
}
return attributes, nil
}
// ParseAttributesLine parses a gitattribute line, extracting path pattern and
// attributes.
func ParseAttributesLine(line string, domain []string, allowMacro bool) (m MatchAttribute, err error) {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, commentPrefix) || len(line) == 0 {
return
}
name, unquoted := unquote(line)
attrs := strings.Fields(unquoted)
if len(name) == 0 {
name = attrs[0]
attrs = attrs[1:]
}
var macro bool
macro, name, err = checkMacro(name, allowMacro)
if err != nil {
return
}
m.Name = name
m.Attributes = make([]Attribute, 0, len(attrs))
for _, attrName := range attrs {
attr := attribute{
name: attrName,
state: attributeSet,
}
// ! and - prefixes
state := attributeState(attr.name[0])
if state == attributeUnspecified || state == attributeUnset {
attr.state = state
attr.name = attr.name[1:]
}
kv := strings.SplitN(attrName, "=", 2)
if len(kv) == 2 {
attr.name = kv[0]
attr.value = kv[1]
attr.state = attributeSetValue
}
if !validAttributeName(attr.name) {
return m, ErrInvalidAttributeName
}
m.Attributes = append(m.Attributes, attr)
}
if !macro {
m.Pattern = ParsePattern(name, domain)
}
return
}
func checkMacro(name string, allowMacro bool) (macro bool, macroName string, err error) {
if !strings.HasPrefix(name, macroPrefix) {
return false, name, nil
}
if !allowMacro {
return true, name, ErrMacroNotAllowed
}
macroName = name[len(macroPrefix):]
if !validAttributeName(macroName) {
return true, name, ErrInvalidAttributeName
}
return true, macroName, nil
}
func validAttributeName(name string) bool {
if len(name) == 0 || name[0] == '-' {
return false
}
for _, ch := range name {
if !(ch == '-' || ch == '.' || ch == '_' ||
('0' <= ch && ch <= '9') ||
('a' <= ch && ch <= 'z') ||
('A' <= ch && ch <= 'Z')) {
return false
}
}
return true
}
func unquote(str string) (string, string) {
if str[0] != '"' {
return "", str
}
for i := 1; i < len(str); i++ {
switch str[i] {
case '\\':
i++
case '"':
return str[1:i], str[i+1:]
}
}
return "", str
}