github.com/aretext/aretext@v1.3.0/syntax/languages/makefile.go (about)

     1  package languages
     2  
     3  import (
     4  	"io"
     5  
     6  	"github.com/aretext/aretext/syntax/parser"
     7  )
     8  
     9  const (
    10  	// This is for variable and function expansions.
    11  	// Examples:
    12  	//   $(VAR)
    13  	//   ${VAR}
    14  	//   $(func arg1 arg2)
    15  	makefileTokenRoleVariable = parser.TokenRoleCustom1
    16  
    17  	// This is for special patterns in targets or prereqs.
    18  	// Example:
    19  	//    %.o: %.c
    20  	makefileTokenRolePattern = parser.TokenRoleCustom2
    21  )
    22  
    23  type makefileParseState uint8
    24  
    25  const (
    26  	// Top-level is the initial state.
    27  	makefileTopLevelParseState = makefileParseState(iota)
    28  
    29  	// Rule prereq when we're in the "prerequisites" section of a rule.
    30  	//
    31  	// Example:
    32  	//   foo: bar baz # "bar" and "baz" are the prereqs.
    33  	makefileRulePrereqParseState
    34  
    35  	// Recipe is the section of a rule with shell commands.
    36  	//
    37  	// Example:
    38  	//   foo: bar baz
    39  	//     echo "hello"  # <-- this is the recipe.
    40  	makefileRecipeCmdParseState
    41  
    42  	// AssignmentVal is the value assigned to a variable.
    43  	//
    44  	// Example:
    45  	//   X = abc <-- "abc" is the assignment val state.
    46  	makefileAssignmentValParseState
    47  )
    48  
    49  func (s makefileParseState) Equals(other parser.State) bool {
    50  	otherState, ok := other.(makefileParseState)
    51  	return ok && s == otherState
    52  }
    53  
    54  // MakefileParseFunc returns a parse func for GNU Makefiles.
    55  // See https://www.gnu.org/software/make/manual/make.html
    56  // especially section 3.8 "How Makefiles are Parsed"
    57  func MakefileParseFunc() parser.Func {
    58  	// From top-level, if we see ":" then we must be in a rule,
    59  	// so start parsing the prereqs and then parse the following tab-indented
    60  	// lines as recipe commands.
    61  	parseRuleSeparator := matchState(
    62  		makefileTopLevelParseState,
    63  		consumeString(":").
    64  			ThenNot(consumeString("=")).
    65  			Map(setState(makefileRulePrereqParseState)))
    66  
    67  	// Recipe command are tab-indented, or following rule prereqs separated by a semicolon.
    68  	// If the start of the recipe command has "@", treat that as an operator
    69  	// meaning "do not echo this line".
    70  	//
    71  	// Treating every tab-indented line as part of a recipe isn't completely accurate,
    72  	// since technically it's missing the target/prereqs, but I believe other editors
    73  	// also make this assumption.
    74  	parseAtOp := consumeString("@").
    75  		Map(recognizeToken(parser.TokenRoleOperator))
    76  
    77  	parseTabIndent := consumeString("\n\t").
    78  		ThenMaybe(parseAtOp).
    79  		Map(setState(makefileRecipeCmdParseState))
    80  
    81  	parseSemicolonInRule := matchState(
    82  		makefileRulePrereqParseState,
    83  		consumeString(";").
    84  			ThenMaybe(consumeRunesLike(func(r rune) bool { return r == ' ' || r == '\t' })).
    85  			ThenMaybe(parseAtOp).
    86  			Map(setState(makefileRecipeCmdParseState)))
    87  
    88  	// Handle backslash line continuation everywhere except in recipe command.
    89  	// This consumes the backslash and newline, then stays in the same state
    90  	// rather than transitioning back to top-level.
    91  	parseBackslashLineContinuation := matchStates(
    92  		[]parser.State{makefileRulePrereqParseState, makefileRecipeCmdParseState, makefileAssignmentValParseState},
    93  		consumeString(`\`).
    94  			ThenMaybe(consumeRunesLike(func(r rune) bool { return r == ' ' || r == '\t' })).
    95  			Then(consumeString("\n")))
    96  
    97  	// A newline NOT followed by a tab transitions back to top-level state.
    98  	parseBackToTopLevel := matchStates(
    99  		[]parser.State{makefileRulePrereqParseState, makefileRecipeCmdParseState, makefileAssignmentValParseState},
   100  		consumeString("\n").
   101  			Map(setState(makefileTopLevelParseState)))
   102  
   103  	// Parse some keywords only at top-level.
   104  	parseTopLevelKeywords := matchState(
   105  		makefileTopLevelParseState,
   106  		consumeLongestMatchingOption([]string{
   107  			"ifeq", "ifneq", "ifdef", "ifndef", "else", "endif",
   108  			"export", "unexport", "override",
   109  			"include", "-include", "define", "endef",
   110  		}).Map(recognizeToken(parser.TokenRoleKeyword)))
   111  
   112  	// Parse comments everywhere except in recipe commands.
   113  	parseComment := matchStates(
   114  		[]parser.State{makefileTopLevelParseState, makefileRulePrereqParseState},
   115  		makefileCommentParseFunc())
   116  
   117  	// Parse assign operators everywhere except in recipe commands.
   118  	parseAssignOp := matchStates(
   119  		[]parser.State{makefileTopLevelParseState, makefileRulePrereqParseState},
   120  		consumeLongestMatchingOption([]string{"=", ":=", "::=", ":::=", "?=", "+=", "!="}).
   121  			Map(recognizeToken(parser.TokenRoleOperator)).
   122  			Map(setState(makefileAssignmentValParseState)))
   123  
   124  	// Parse escaped dollar sign ($$).
   125  	// This must come before any rule that parses "$" as a prefix
   126  	// to ensure that "$$" isn't highlighted.
   127  	parseEscapedDollarSign := consumeString("$$")
   128  
   129  	// Parse automatic variables in all states.
   130  	parseAutomaticVariables := consumeLongestMatchingOption([]string{
   131  		"$@", "$%", "$<", "$?", "$^", "$+", "$|", "$*",
   132  	}).Map(recognizeToken(makefileTokenRoleVariable))
   133  
   134  	// Parse patterns only in rule target and prereqs (NOT in assignment).
   135  	parseRulePattern := matchStates(
   136  		[]parser.State{makefileTopLevelParseState, makefileRulePrereqParseState},
   137  		consumeLongestMatchingOption([]string{
   138  			"%", "%D", "%F", "+", "+D", "+F",
   139  		}).Map(recognizeToken(makefileTokenRolePattern)))
   140  
   141  	// Parse expansions (functions and variables) in all states.
   142  	parseExpansion := consumeString("$").
   143  		Then(makefileExpansionParseFunc()).
   144  		Map(recognizeToken(makefileTokenRoleVariable))
   145  
   146  	return initialState(
   147  		makefileTopLevelParseState,
   148  		parseRuleSeparator.
   149  			Or(parseTabIndent).
   150  			Or(parseSemicolonInRule).
   151  			Or(parseBackslashLineContinuation).
   152  			Or(parseBackToTopLevel).
   153  			Or(parseComment).
   154  			Or(parseAssignOp).
   155  			Or(parseEscapedDollarSign).
   156  			Or(parseAutomaticVariables).
   157  			Or(parseRulePattern).
   158  			Or(parseExpansion).
   159  			Or(parseTopLevelKeywords))
   160  }
   161  
   162  // makefileCommentParseFunc parses a comment in a makefile.
   163  // Comments are just "# ..." to end of line, but do NOT consume
   164  // the line feed, since that determines state transitions.
   165  func makefileCommentParseFunc() parser.Func {
   166  	return func(iter parser.TrackingRuneIter, state parser.State) parser.Result {
   167  		var numConsumed uint64
   168  
   169  		startRune, err := iter.NextRune()
   170  		if err != nil || startRune != '#' {
   171  			return parser.FailedResult
   172  		}
   173  		numConsumed++
   174  
   175  		for {
   176  			r, err := iter.NextRune()
   177  			if err == io.EOF {
   178  				break
   179  			} else if err != nil {
   180  				return parser.FailedResult
   181  			}
   182  
   183  			if r == '\n' {
   184  				// Do NOT consume the line feed.
   185  				break
   186  			}
   187  
   188  			numConsumed++
   189  		}
   190  
   191  		return parser.Result{
   192  			NumConsumed: numConsumed,
   193  			ComputedTokens: []parser.ComputedToken{
   194  				{
   195  					Length: numConsumed,
   196  					Role:   parser.TokenRoleComment,
   197  				},
   198  			},
   199  			NextState: state,
   200  		}
   201  	}
   202  }
   203  
   204  // makefileExpansionParseFunc handles variable and function expansions.
   205  //
   206  // Examples:
   207  //
   208  //	${VAR}
   209  //	$(VAR)
   210  //	$(subst $(space),$(comma),$(foo))
   211  func makefileExpansionParseFunc() parser.Func {
   212  	return func(iter parser.TrackingRuneIter, state parser.State) parser.Result {
   213  		var n uint64
   214  
   215  		// Open delimiter.
   216  		startRune, err := iter.NextRune()
   217  		if err != nil || !(startRune == '{' || startRune == '(') {
   218  			return parser.FailedResult
   219  		}
   220  		n++
   221  
   222  		// Maintain a stack of open delimiters so we can check when they're closed.
   223  		stack := []rune{startRune}
   224  
   225  		// Consume runes until the stack is empty.
   226  		for len(stack) > 0 {
   227  			stackTop := stack[len(stack)-1]
   228  
   229  			r, err := iter.NextRune()
   230  			if err != nil {
   231  				return parser.FailedResult
   232  			}
   233  			n++
   234  
   235  			if r == '{' || r == '(' {
   236  				// Push open delimiter to stack.
   237  				stack = append(stack, r)
   238  			} else if (stackTop == '{' && r == '}') || (stackTop == '(' && r == ')') {
   239  				// Found close delimiter matching last open delimiter, so pop from stack.
   240  				stack = stack[0 : len(stack)-1]
   241  			}
   242  		}
   243  
   244  		return parser.Result{
   245  			NumConsumed: n,
   246  			ComputedTokens: []parser.ComputedToken{
   247  				{Length: n},
   248  			},
   249  			NextState: state,
   250  		}
   251  	}
   252  }