--===========================================================================-- -- -- -- System.Text.TemplateString -- -- -- --===========================================================================-- --===========================================================================-- -- Author : kurapica125@outlook.com -- -- URL : http://github.com/kurapica/PLoop -- -- Create Date : 2020/10/03 -- -- Update Date : 2020/10/03 -- -- Version : 1.0.0 -- --===========================================================================-- PLoop(function(_ENV) namespace "System.Text" --- The template string render type __Sealed__() __AutoIndex__() enum "TemplateStringRenderType" { "RecordLine", "StaticText", "NewLine", "LuaCode", "Expression", } --- Represents the template string loader, used to analyze the template string -- to generate the template string operations, so the template string will be -- converted to a class that can be used to generate the result string based on -- the data. __Sealed__() interface "ITemplateStringLoader" (function(_ENV) extend "Iterable" export { yield = coroutine.yield, RCT_RecordLine = TemplateStringRenderType.RecordLine, RCT_StaticText = TemplateStringRenderType.StaticText, RCT_NewLine = TemplateStringRenderType.NewLine, } ----------------------------------------------------------------------- -- method -- ----------------------------------------------------------------------- __Iterator__() function GetIterator(self, reader) return self:ParseLines(reader) end --- Parse the lines and yield all content with type __Abstract__() function ParseLines(self, reader) for line in reader:ReadLines() do line = line:gsub("%s+$", "") yield(RCT_RecordLine, line) yield(RCT_StaticText, line) yield(RCT_NewLine) end end end) --- The default implementation or the template string loader __Sealed__() class "DefaultTemplateStringLoader" (function(_ENV) extend "ITemplateStringLoader" --[=============================[ Rules I. Full-Line : Using : @> lua code @ keyword + other lua code Example : @local x = math.random(100) @if x > 50
Above 50
@elseBelow 50
@end II. In-Line : Using : @expression Example : @x @(x+y:%3d) @"test" @[[test]] @self.Data[1].Name:lower() --]=============================] export { yield = coroutine.yield, loadstring = _G.loadstring or _G.load, safeset = Toolset.safeset, RCT_RecordLine = TemplateStringRenderType.RecordLine, RCT_StaticText = TemplateStringRenderType.StaticText, RCT_NewLine = TemplateStringRenderType.NewLine, RCT_LuaCode = TemplateStringRenderType.LuaCode, RCT_Expression = TemplateStringRenderType.Expression, _LuaKeyWords = { ["break"] = true, ["do"] = true, ["else"] = true, ["for"] = true, ["if"] = true, ["elseif"] = true, ["return"] = true, ["repeat"] = true, ["while"] = true, ["until"] = true, ["end"] = true, ["function"] = true, ["local"] = true, }, } local _PrefixMap = { ["("] = false, ["["] = false, ["{"] = false, ["'"] = false, ['"'] = false, ["+"] = false, ["-"] = false, ["\\"] = System.Text.XmlEntity.Encode, } ------------------------------- -- Helper ------------------------------- -- Get expression from the line local function parseExpression(line, startp) local parseCnt local code = "" local parsePrintTemp= function(word) code = code .. word return "" end -- Find the start startp = line:find("%S", startp) if not startp then return end -- Check the prefix local prest, prend = line:find("^%p", startp) local prefix if prest and prend then prefix = line:sub(prest, prend) if not _PrefixMap[prefix] then prefix = nil else startp = line:find("%S", prend + 1) if not startp then return end end end -- Check the expression line = line:sub(startp) if line:find("^%(") then -- Just take the expression inside the brackets line = line:gsub("^%b()", parsePrintTemp) elseif line:find("^[+-]?%s*[%d%.]") then -- First word is a number if line:find("^[+-]?%s*0[xX]") then -- Hex [+-]?0x[0-9a-fA-F]* line = line:gsub("^[+-]?%s*0x[0-9a-fA-F]*", parsePrintTemp) elseif line:find("^[+-]?%s*%d") then -- [+-]?%d+%.?%d*[eE]?%-?%d* line = line:gsub("^([+-]?%s*%d+%.?%d*)([eE]?)(%-?%d*)", function(start, e, tail) if #e > 0 then code= code .. start .. e .. tail else code= code .. start end end) else -- [+-]?%.%d*[eE]?%-?%d* line = line:gsub("^([+-]?%s*%.%d*)([eE]?)(%-?%d*)", function(start, e, tail) if #e > 0 then code = code .. start .. e .. tail else code = code .. start end end) end elseif line:find("^'") then -- The word is a string line = line:gsub("^%b''", parsePrintTemp) elseif line:find('^"') then -- The word is a string line = line:gsub('^%b""', parsePrintTemp) elseif line:find("^%[=*%[") then -- The word is a string line = line:gsub("^%b[]", parsePrintTemp) elseif line:find("^[%w_]") then -- variable first line = line:gsub("^([%w_]+)%s*", parsePrintTemp) -- parse the remain expression while line ~= "" do local head = line:sub(1, 1) parseCnt = 0 if head == "." or head == ":" then line, parseCnt = line:gsub("^([%.:][_%a][%w_]*)%s*", parsePrintTemp) elseif head == "{" then line, parseCnt = line:gsub("^(%b{})%s*", parsePrintTemp) elseif head == "(" then line, parseCnt = line:gsub("^(%b())%s*", parsePrintTemp) elseif head == "[" then line, parseCnt = line:gsub("^(%b[])%s*", parsePrintTemp) end if parseCnt == 0 then break end end end if code ~= "" then -- Check format local prev, fmt = code:match("^%((.+):(%%[^%(]+)%)$") local rcode = prev and ("(" .. prev .. ")") or code if loadstring("return " .. rcode) then return rcode, startp + #code - 1, fmt, prefix end end end local function parsePageLine(reader, line) local cnt -- Full Line --- @-- comment -- No multi-line comment supported, -- since can also be used without @ if line:find("^%s*@%s*%-%-.*$") then return end --- @> full line line, cnt = line:gsub("^%s*@>(.*)$", "%1") if cnt > 0 then yield(RCT_LuaCode, line) return end --- @ keyword if _LuaKeyWords[line:match("^%s*@%s*(%w+)")] then line = line:gsub("^%s*@%s*(%w+)(.*)$", "%1%2") yield(RCT_LuaCode, line) return end --- Lua block if line:find("^%s*@%s*{$") then local prevSpace = line:match("^%s*") line = reader:ReadLine() while line do yield(RCT_RecordLine, line) line = line:gsub("%s+$", "") if line:find("^%s*}$") and line:match("^%s*") == prevSpace then break else yield(RCT_LuaCode, line) end line = reader:ReadLine() end if not line then error("'@{' must be ended with '}'.") end return end -- Inline local startp = 1 local pos = line:find("@", 1, true) while pos do local chr = line:match("%S", pos + 1) if chr == "@" then -- Skip next @ pos = line:find("@", pos + 1, true) else -- expression local exp, endp, format, prefix = parseExpression(line, pos + 1) if exp then --- The previous text should be output directly local prev = line:sub(startp, pos - 1) if prev and prev ~= "" then prev = prev:gsub("@@", "@") yield(RCT_StaticText, prev) end startp = pos -- Expression yield(RCT_Expression, exp, format, prefix and _PrefixMap[prefix]) -- Continue startp = endp + 1 pos = endp end end pos = line:find("@", pos + 1, true) end local last = line:sub(startp) if last and last ~= "" then last = last:gsub("@@", "@") yield(RCT_StaticText, last) end yield(RCT_NewLine) end ----------------------------------------------------------------------- -- static property -- ----------------------------------------------------------------------- --- The prefix to function map like `\=>Encode`, so the `@\test => Encode(test)` __Indexer__(struct { __base = NEString, function (val, onlyvalid) if not val:match("^%p$") then return onlyvalid or "%s must all be punctuation" end end }) __Static__() property "Prefix" { type = Callable, set = function(self, prefix, map) if _PrefixMap[prefix] == nil then _PrefixMap = safeset(_PrefixMap, prefix, map) end end, } ----------------------------------------------------------------------- -- method -- ----------------------------------------------------------------------- function ParseLines(self, reader) local line = reader:ReadLine() while line do line = line:gsub("%s+$", "") yield(RCT_RecordLine, line) if line ~= "" then parsePageLine(reader, line) end line = reader:ReadLine() end end end) __Sealed__() __Final__() class "TemplateString" (function (_ENV) export { with = with, loadstring = _G.loadstring or _G.load, loadsnippet = Toolset.loadsnippet, pcall = pcall, error = error, assert = assert, tonumber = tonumber, tostring = tostring, random = math.random, strlower = string.lower, strformat = string.format, strfind = string.find, strsub = string.sub, strgsub = string.gsub, tblconcat = table.concat, tinsert = table.insert, tremove = table.remove, setmetatable = setmetatable, safeset = Toolset.safeset, _PL_tostring = Toolset.tostring or tostring, setfenv = _G.setfenv or _G.debug and debug.setfenv or Toolset.fakefunc, META_ITEMS = { __index = _G }, RCT_RecordLine = TemplateStringRenderType.RecordLine, RCT_StaticText = TemplateStringRenderType.StaticText, RCT_NewLine = TemplateStringRenderType.NewLine, RCT_LuaCode = TemplateStringRenderType.LuaCode, RCT_Expression = TemplateStringRenderType.Expression, StringReader, StringWriter, DefaultTemplateStringLoader, TextWriter, } --- Convert the error to the real position local function raiseError(self, err) err = tostring(err) local line, msg = err:match("%b[]:(%d+):(.-)$") line = tonumber(line) if line and self.CodeLineMap[line] then err = ("template string:%d: %s"):format(self.CodeLineMap[line], msg) end error(err, 0) end local function prepareGenerator(self, items) for k, v in pairs(self.APIs) do items[k] = v end setmetatable(items, META_ITEMS) local func, err = pcall(self.Generator) if not func then raiseError(self, err) else func = err end setfenv(func, items) return func end ----------------------------------------------------------------------- -- property -- ----------------------------------------------------------------------- --- The template string property "TemplateString" { type = String } --- The code line map property "CodeLineMap" { set = false, default = Toolset.newtable } --- The apis could be used in the template string property "APIs" { set = false, default = function() return { _PL_tostring = function(val) return val == nil and "" or _PL_tostring(val) end } end } --- The function used to return the generator of the result property "Generator" { } ----------------------------------------------------------------------- -- constructor -- ----------------------------------------------------------------------- __Arguments__{ NEString, -ITemplateStringLoader/nil } function __ctor(self, template, loader) with(StringReader(template))(function(reader) reader.DiscardIndents = true self.TemplateString = template local recordCount = 0 local definition = { "return function(_ENV, writer)" } local engine = (loader or DefaultTemplateStringLoader)() local apis = self.APIs local temp = {} local wNewLine = [[writer:WriteLine()]] local wStaticText = [[writer:Write(%q)]] local wExpression = [[writer:Write(_PL_tostring(%s))]] local wWrapExp = [[writer:Write(%s(_PL_tostring(%s)))]] local wFmtExpression = [[writer:Write((%q):format(_PL_tostring(%s)))]] local wFmtWrapExp = [[writer:Write(%s((%q):format(_PL_tostring(%s))))]] local sourceCount = 1 local defineCount = 1 local lineMap = self.CodeLineMap local pushcode = function (line) defineCount = defineCount + 1 definition[defineCount] = line sourceCount = sourceCount + 1 lineMap[sourceCount] = recordCount end for ty, ct, fmt, wrap in engine:GetIterator(reader) do if ty == RCT_RecordLine then recordCount = recordCount + 1 elseif ty == RCT_LuaCode then pushcode(ct) elseif ty == RCT_StaticText then if ct ~= "" then pushcode(wStaticText:format(ct)) end elseif ty == RCT_NewLine then pushcode(wNewLine) elseif ty == RCT_Expression then if fmt then if wrap then if not temp[wrap] then local name = "_PL_" .. strformat("%04X", random(0xffff)) while apis[name] do name = "_PL_" .. strformat("%04X", random(0xffff)) end temp[wrap] = name apis[name] = wrap end pushcode(wFmtWrapExp:format(temp[wrap], fmt, ct)) else pushcode(wFmtExpression:format(fmt, ct)) end else if wrap then if not temp[wrap] then local name = "_PL_" .. strformat("%04X", random(0xffff)) while apis[name] do name = "_PL_" .. strformat("%04X", random(0xffff)) end temp[wrap] = name apis[name] = wrap end pushcode(wWrapExp:format(temp[wrap], ct)) else pushcode(wExpression:format(ct)) end end end end tinsert(definition, "end") definition = tblconcat(definition, "\n") local ok, err = loadsnippet(definition, "template") if not ok then raiseError(self, err) end self.Generator = ok end) end ----------------------------------------------------------------------- -- meta-method -- ----------------------------------------------------------------------- __Arguments__{ RawTable/nil } function __call(self, items) items = items or {} local func = prepareGenerator(self, items) return with(StringWriter())(function(writer) func(items, writer) return writer end, function(err) raiseError(self, err) end).Result end __Arguments__{ TextWriter, RawTable/nil } function __call(self, writer, items) items = items or {} local func = prepareGenerator(self, items) with(writer)(function(writer) func(items, writer) return writer end, function(err) raiseError(self, err) end) end __Arguments__{ Callable, RawTable/nil } function __call(self, write, items) items = items or {} local func = prepareGenerator(self, items) with(TextWriter{ Write = function(self, ...) return write(...) end })(function(writer) func(items, writer) end, function(err) raiseError(self, err) end) end end) end)