--[[
The MIT License (MIT)

Copyright (c) 2007 - 2018 Hisham Muhammad.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

Original: https://github.com/keplerproject/luacov/6e6232d766f051d9668dab785582451bfd69ad17/master/src/luacov/linescanner.lua
]]

local LineScanner = {}
LineScanner.__index = LineScanner

function LineScanner:new()
	return setmetatable(
		{
			first = true,
			comment = false,
			after_function = false,
			enabled = true
		},
		self
	)
end

-- Raw version of string.gsub
local function replace(s, old, new)
	old = old:gsub("%p", "%%%0")
	new = new:gsub("%%", "%%%%")
	return (s:gsub(old, new))
end

local fixups = {
	{"=", " ?= ?"}, -- '=' may be surrounded by spaces
	{"(", " ?%( ?"}, -- '(' may be surrounded by spaces
	{")", " ?%) ?"}, -- ')' may be surrounded by spaces
	{"<FULLID>", "x ?[%[%.]? ?[ntfx0']* ?%]?"}, -- identifier, possibly indexed once
	{"<IDS>", "x ?, ?x[x, ]*"}, -- at least two comma-separated identifiers
	{"<FIELDNAME>", "%[? ?[ntfx0']+ ?%]?"}, -- field, possibly like ["this"]
	{"<PARENS>", "[ %(]*"} -- optional opening parentheses
}

-- Utility function to make patterns more readable
local function fixup(pat)
	for _, fixup_pair in ipairs(fixups) do
		pat = replace(pat, fixup_pair[1], fixup_pair[2])
	end

	return pat
end

--- Lines that are always excluded from accounting
local any_hits_exclusions = {
	"", -- Empty line
	"end[,; %)]*", -- Single "end"
	"else", -- Single "else"
	"repeat", -- Single "repeat"
	"do", -- Single "do"
	"if", -- Single "if"
	"then", -- Single "then"
	"while t do", -- "while true do" generates no code
	"if t then", -- "if true then" generates no code
	"local x", -- "local var"
	fixup "local x=", -- "local var ="
	fixup "local <IDS>", -- "local var1, ..., varN"
	fixup "local <IDS>=", -- "local var1, ..., varN ="
	"local function x" -- "local function f (arg1, ..., argN)"
}

--- Lines that are only excluded from accounting when they have 0 hits
local zero_hits_exclusions = {
	"[ntfx0',= ]+,", -- "var1 var2," multi columns table stuff
	"{ ?} ?,", -- Empty table before comma leaves no trace in tables and calls
	fixup "<FIELDNAME>=.+[,;]", -- "[123] = 23," "['foo'] = "asd","
	fixup "<FIELDNAME>=function", -- "[123] = function(...)"
	fixup "<FIELDNAME>=<PARENS>'", -- "[123] = [[", possibly with opening parens
	"return function", -- "return function(arg1, ..., argN)"
	"function", -- "function(arg1, ..., argN)"
	"[ntfx0]", -- Single token expressions leave no trace in tables, function calls and sometimes assignments
	"''", -- Same for strings
	"{ ?}", -- Same for empty tables
	fixup "<FULLID>", -- Same for local variables indexed once
	fixup "local x=function", -- "local a = function(arg1, ..., argN)"
	fixup "local x=<PARENS>'", -- "local a = [[", possibly with opening parens
	fixup "local x=(<PARENS>", -- "local a = (", possibly with several parens
	fixup "local <IDS>=(<PARENS>", -- "local a, b = (", possibly with several parens
	fixup "local x=n", -- "local a = nil; local b = nil" produces no trace for the second statement
	fixup "<FULLID>=<PARENS>'", -- "a.b = [[", possibly with opening parens
	fixup "<FULLID>=function", -- "a = function(arg1, ..., argN)"
	"} ?,", -- "}," generates no trace if the table ends with a key-value pair
	"} ?, ?function", -- same with "}, function(...)"
	"break", -- "break" generates no trace in Lua 5.2+
	"{", -- "{" opening table
	"}?[ %)]*", -- optional closing paren, possibly with several closing parens
	"[ntf0']+ ?}[ %)]*" -- a constant at the end of a table, possibly with closing parens (for LuaJIT)
}

local function excluded(exclusions, line)
	for _, e in ipairs(exclusions) do
		if line:match("^ *" .. e .. " *$") then
			return true
		end
	end

	return false
end

function LineScanner:find(pattern)
	return self.line:find(pattern, self.i)
end

-- Skips string literal with quote stored as self.quote.
-- @return boolean indicating success.
function LineScanner:skip_string()
	-- Look for closing quote, possibly after even number of backslashes.
	local _, quote_i = self:find("^(\\*)%1" .. self.quote)
	if not quote_i then
		_, quote_i = self:find("[^\\](\\*)%1" .. self.quote)
	end

	if quote_i then
		self.i = quote_i + 1
		self.quote = nil
		table.insert(self.simple_line_buffer, "'")
		return true
	else
		return false
	end
end

-- Skips long string literal with equal signs stored as self.equals.
-- @return boolean indicating success.
function LineScanner:skip_long_string()
	local _, bracket_i = self:find("%]" .. self.equals .. "%]")

	if bracket_i then
		self.i = bracket_i + 1
		self.equals = nil

		if self.comment then
			self.comment = false
		else
			table.insert(self.simple_line_buffer, "'")
		end

		return true
	else
		return false
	end
end

-- Skips function arguments.
-- @return boolean indicating success.
function LineScanner:skip_args()
	local _, paren_i = self:find("%)")

	if paren_i then
		self.i = paren_i + 1
		self.args = nil
		return true
	else
		return false
	end
end

function LineScanner:skip_whitespace()
	local next_i = self:find("%S") or #self.line + 1

	if next_i ~= self.i then
		self.i = next_i
		table.insert(self.simple_line_buffer, " ")
	end
end

function LineScanner:skip_number()
	if self:find("^0[xX]") then
		self.i = self.i + 2
	end

	local _
	_, _, self.i = self:find("^[%x%.]*()")

	if self:find("^[eEpP][%+%-]") then
		-- Skip exponent, too.
		self.i = self.i + 2
		_, _, self.i = self:find("^[%x%.]*()")
	end

	-- Skip LuaJIT number suffixes (i, ll, ull).
	_, _, self.i = self:find("^[iull]*()")
	table.insert(self.simple_line_buffer, "0")
end

local keywords = {["nil"] = "n", ["true"] = "t", ["false"] = "f"}

for _, keyword in ipairs(
	{
		"and",
		"break",
		"do",
		"else",
		"elseif",
		"end",
		"for",
		"function",
		"goto",
		"if",
		"in",
		"local",
		"not",
		"or",
		"repeat",
		"return",
		"then",
		"until",
		"while"
	}
) do
	keywords[keyword] = keyword
end

function LineScanner:skip_name()
	-- It is guaranteed that the first character matches "%a_".
	local _, _, name = self:find("^([%w_]*)")
	self.i = self.i + #name

	if keywords[name] then
		name = keywords[name]
	else
		name = "x"
	end

	table.insert(self.simple_line_buffer, name)

	if name == "function" then
		-- This flag indicates that the next pair of parentheses (function args) must be skipped.
		self.after_function = true
	end
end

-- Source lines can be explicitly ignored using `enable` and `disable` inline options.
-- An inline option is a simple comment: `-- luacov: enable` or `-- luacov: disable`.
-- Inline option parsing is not whitespace sensitive.
-- All lines starting from a line containing `disable` option and up to a line containing `enable`
-- option (or end of file) are excluded.

function LineScanner:check_inline_options(comment_body)
	if comment_body:find("^%s*luacov:%s*enable%s*$") then
		self.enabled = true
	elseif comment_body:find("^%s*luacov:%s*disable%s*$") then
		self.enabled = false
	end
end

-- Consumes and analyzes a line.
-- @return boolean indicating whether line must be excluded.
-- @return boolean indicating whether line must be excluded if not hit.
function LineScanner:consume(line)
	if self.first then
		self.first = false

		if line:match("^#!") then
			-- Ignore Unix hash-bang magic line.
			return true, true
		end
	end

	self.line = line
	-- As scanner goes through the line, it puts its simplified parts into buffer.
	-- Punctuation is preserved. Whitespace is replaced with single space.
	-- Literal strings are replaced with "''", so that a string literal
	-- containing special characters does not confuse exclusion rules.
	-- Numbers are replaced with "0".
	-- Identifiers are replaced with "x".
	-- Literal keywords (nil, true and false) are replaced with "n", "t" and "f",
	-- other keywords are preserved.
	-- Function declaration arguments are removed.
	self.simple_line_buffer = {}
	self.i = 1

	while self.i <= #line do
		-- One iteration of this loop handles one token, where
		-- string literal start and end are considered distinct tokens.
		if self.quote then
			if not self:skip_string() then
				-- String literal ends on another line.
				break
			end
		elseif self.equals then
			if not self:skip_long_string() then
				-- Long string literal or comment ends on another line.
				break
			end
		elseif self.args then
			if not self:skip_args() then
				-- Function arguments end on another line.
				break
			end
		else
			self:skip_whitespace()

			if self:find("^%.%d") then
				self.i = self.i + 1
			end

			if self:find("^%d") then
				self:skip_number()
			elseif self:find("^[%a_]") then
				self:skip_name()
			else
				if self:find("^%-%-") then
					self.comment = true
					self.i = self.i + 2
				end

				local _, bracket_i, equals = self:find("^%[(=*)%[")
				if equals then
					self.i = bracket_i + 1
					self.equals = equals

					if not self.comment then
						table.insert(self.simple_line_buffer, "'")
					end
				elseif self.comment then
					-- Simple comment, check if it contains inline options and skip line.
					self.comment = false
					local comment_body = self.line:sub(self.i)
					self:check_inline_options(comment_body)
					break
				else
					local char = line:sub(self.i, self.i)

					if char == "." then
						-- Dot can't be saved as one character because of
						-- ".." and "..." tokens and the fact that number literals
						-- can start with one.
						local _, _, dots = self:find("^(%.*)")
						self.i = self.i + #dots
						table.insert(self.simple_line_buffer, dots)
					else
						self.i = self.i + 1

						if char == "'" or char == '"' then
							table.insert(self.simple_line_buffer, "'")
							self.quote = char
						elseif self.after_function and char == "(" then
							-- This is the opening parenthesis of function declaration args.
							self.after_function = false
							self.args = true
						else
							-- Save other punctuation literally.
							-- This inserts an empty string when at the end of line,
							-- which is fine.
							table.insert(self.simple_line_buffer, char)
						end
					end
				end
			end
		end
	end

	if not self.enabled then
		-- Disabled by inline options, always exclude the line.
		return true, true
	end

	local simple_line = table.concat(self.simple_line_buffer)
	return excluded(any_hits_exclusions, simple_line), excluded(zero_hits_exclusions, simple_line)
end

return LineScanner
