diff --git a/lua/flutter-tools/commands.lua b/lua/flutter-tools/commands.lua index 957828f..30caf02 100644 --- a/lua/flutter-tools/commands.lua +++ b/lua/flutter-tools/commands.lua @@ -11,6 +11,7 @@ local job_runner = lazy.require("flutter-tools.runners.job_runner") ---@module " local debugger_runner = lazy.require("flutter-tools.runners.debugger_runner") ---@module "flutter-tools.runners.debugger_runner" local path = lazy.require("flutter-tools.utils.path") ---@module "flutter-tools.utils.path" local dev_log = lazy.require("flutter-tools.log") ---@module "flutter-tools.log" +local parser = lazy.require("flutter-tools.utils.yaml_parser") local M = {} @@ -196,15 +197,43 @@ local function get_cwd(project_conf) return lsp.get_lsp_root_dir() end +--@return table? +local function parse_yaml(str) + local ok, yaml = pcall(parser.parse, str) + if not ok then return nil end + return yaml +end + ---@param cwd string local function has_flutter_dependency_in_pubspec(cwd) - local pubspec = vim.fn.glob(path.join(cwd, "pubspec.yaml")) - if pubspec == "" then return false end - local pubspec_content = vim.fn.readfile(pubspec) + -- As this plugin is tailored for flutter projects, + -- we assume that the project is a flutter project. + local default_has_flutter_dependency = true + local pubspec_path = vim.fn.glob(path.join(cwd, "pubspec.yaml")) + if pubspec_path == "" then return default_has_flutter_dependency end + local pubspec_content = vim.fn.readfile(pubspec_path) local joined_content = table.concat(pubspec_content, "\n") - - local flutter_dependency = string.match(joined_content, "flutter:\n[%s\t]*sdk:[%s\t]*flutter") - return flutter_dependency ~= nil + local pubspec = parse_yaml(joined_content) + if not pubspec then return default_has_flutter_dependency end + --https://github.com/Dart-Code/Dart-Code/blob/43914cd2709d77668e19a4edf3500f996d5c307b/src/shared/utils/fs.ts#L183 + return ( + pubspec.dependencies + and ( + pubspec.dependencies.flutter + or pubspec.dependencies.flutter_test + or pubspec.dependencies.sky_engine + or pubspec.dependencies.flutter_goldens + ) + ) + or ( + pubspec.devDependencies + and ( + pubspec.devDependencies.flutter + or pubspec.devDependencies.flutter_test + or pubspec.devDependencies.sky_engine + or pubspec.devDependencies.flutter_goldens + ) + ) end ---@param opts RunOpts diff --git a/lua/flutter-tools/utils/yaml_parser.lua b/lua/flutter-tools/utils/yaml_parser.lua new file mode 100644 index 0000000..94b314d --- /dev/null +++ b/lua/flutter-tools/utils/yaml_parser.lua @@ -0,0 +1,719 @@ +-- SOURCE: https://github.com/peposso/lua-tinyyaml +------------------------------------------------------------------------------- +-- tinyyaml - YAML subset parser +------------------------------------------------------------------------------- + +local table = table +local string = string +local schar = string.char +local ssub, gsub = string.sub, string.gsub +local sfind, smatch = string.find, string.match +local tinsert, tremove = table.insert, table.remove +local setmetatable = setmetatable +local pairs = pairs +local type = type +local tonumber = tonumber +local math = math +local getmetatable = getmetatable +local error = error + +local UNESCAPES = { + ["0"] = "\x00", + z = "\x00", + N = "\x85", + a = "\x07", + b = "\x08", + t = "\x09", + n = "\x0a", + v = "\x0b", + f = "\x0c", + r = "\x0d", + e = "\x1b", + ["\\"] = "\\", +} + +------------------------------------------------------------------------------- +-- utils +local function select(list, pred) + local selected = {} + for i = 0, #list do + local v = list[i] + if v and pred(v, i) then tinsert(selected, v) end + end + return selected +end + +local function startswith(haystack, needle) return ssub(haystack, 1, #needle) == needle end + +local function ltrim(str) return smatch(str, "^%s*(.-)$") end + +local function rtrim(str) return smatch(str, "^(.-)%s*$") end + +------------------------------------------------------------------------------- +-- Implementation. +-- +local class = { __meta = {} } +function class.__meta.__call(cls, ...) + local self = setmetatable({}, cls) + if cls.__init then cls.__init(self, ...) end + return self +end + +function class.def(base, typ, cls) + base = base or class + local mt = { __metatable = base, __index = base } + for k, v in pairs(base.__meta) do + mt[k] = v + end + cls = setmetatable(cls or {}, mt) + cls.__index = cls + cls.__metatable = cls + cls.__type = typ + cls.__meta = mt + return cls +end + +local types = { + null = class:def("null"), + map = class:def("map"), + omap = class:def("omap"), + pairs = class:def("pairs"), + set = class:def("set"), + seq = class:def("seq"), + timestamp = class:def("timestamp"), +} + +local Null = types.null +function Null.__tostring() return "yaml.null" end +function Null.isnull(v) + if v == nil then return true end + if type(v) == "table" and getmetatable(v) == Null then return true end + return false +end +local null = Null() + +function types.timestamp:__init(y, m, d, h, i, s, f, z) + self.year = tonumber(y) + self.month = tonumber(m) + self.day = tonumber(d) + self.hour = tonumber(h or 0) + self.minute = tonumber(i or 0) + self.second = tonumber(s or 0) + if type(f) == "string" and sfind(f, "^%d+$") then + self.fraction = tonumber(f) * math.pow(10, 3 - #f) + elseif f then + self.fraction = f + else + self.fraction = 0 + end + self.timezone = z +end + +function types.timestamp:__tostring() + return string.format( + "%04d-%02d-%02dT%02d:%02d:%02d.%03d%s", + self.year, + self.month, + self.day, + self.hour, + self.minute, + self.second, + self.fraction, + self:gettz() + ) +end + +function types.timestamp:gettz() + if not self.timezone then return "" end + if self.timezone == 0 then return "Z" end + local sign = self.timezone > 0 + local z = sign and self.timezone or -self.timezone + local zh = math.floor(z) + local zi = (z - zh) * 60 + return string.format("%s%02d:%02d", sign and "+" or "-", zh, zi) +end + +local function countindent(line) + local _, j = sfind(line, "^%s+") + if not j then return 0, line end + return j, ssub(line, j + 1) +end + +local function parsestring(line, stopper) + stopper = stopper or "" + local q = ssub(line, 1, 1) + if q == " " or q == "\t" then return parsestring(ssub(line, 2)) end + if q == "'" then + local i = sfind(line, "'", 2, true) + if not i then return nil, line end + return ssub(line, 2, i - 1), ssub(line, i + 1) + end + if q == '"' then + local i, buf = 2, "" + while i < #line do + local c = ssub(line, i, i) + if c == "\\" then + local n = ssub(line, i + 1, i + 1) + if UNESCAPES[n] ~= nil then + buf = buf .. UNESCAPES[n] + elseif n == "x" then + local h = ssub(i + 2, i + 3) + if sfind(h, "^[0-9a-fA-F]$") then + buf = buf .. schar(tonumber(h, 16)) + i = i + 2 + else + buf = buf .. "x" + end + else + buf = buf .. n + end + i = i + 1 + elseif c == q then + break + else + buf = buf .. c + end + i = i + 1 + end + return buf, ssub(line, i + 1) + end + if q == "{" or q == "[" then -- flow style + return nil, line + end + if q == "|" or q == ">" then -- block + return nil, line + end + if q == "-" or q == ":" then + if ssub(line, 2, 2) == " " or #line == 1 then return nil, line end + end + local buf = "" + while #line > 0 do + local c = ssub(line, 1, 1) + if sfind(stopper, c, 1, true) then + break + elseif c == ":" and (ssub(line, 2, 2) == " " or #line == 1) then + break + elseif c == "#" and (ssub(buf, #buf, #buf) == " ") then + break + else + buf = buf .. c + end + line = ssub(line, 2) + end + return rtrim(buf), line +end + +local function isemptyline(line) return line == "" or sfind(line, "^%s*$") or sfind(line, "^%s*#") end + +local function equalsline(line, needle) + return startswith(line, needle) and isemptyline(ssub(line, #needle + 1)) +end + +local function checkdupekey(map, key) + if map[key] ~= nil then + -- print("found a duplicate key '"..key.."' in line: "..line) + local suffix = 1 + while map[key .. "_" .. suffix] do + suffix = suffix + 1 + end + key = key .. "_" .. suffix + end + return key +end + +local function parseflowstyle(line, lines) + local stack = {} + while true do + if #line == 0 then + if #lines == 0 then + break + else + line = tremove(lines, 1) + end + end + local c = ssub(line, 1, 1) + if c == "#" then + line = "" + elseif c == " " or c == "\t" or c == "\r" or c == "\n" then + line = ssub(line, 2) + elseif c == "{" or c == "[" then + tinsert(stack, { v = {}, t = c }) + line = ssub(line, 2) + elseif c == ":" then + local s = tremove(stack) + tinsert(stack, { v = s.v, t = ":" }) + line = ssub(line, 2) + elseif c == "," then + local value = tremove(stack) + if value.t == ":" or value.t == "{" or value.t == "[" then error() end + if stack[#stack].t == ":" then + -- map + local key = tremove(stack) + key.v = checkdupekey(stack[#stack].v, key.v) + stack[#stack].v[key.v] = value.v + elseif stack[#stack].t == "{" then + -- set + stack[#stack].v[value.v] = true + elseif stack[#stack].t == "[" then + -- seq + tinsert(stack[#stack].v, value.v) + end + line = ssub(line, 2) + elseif c == "}" then + if stack[#stack].t == "{" then + if #stack == 1 then break end + stack[#stack].t = "}" + line = ssub(line, 2) + else + line = "," .. line + end + elseif c == "]" then + if stack[#stack].t == "[" then + if #stack == 1 then break end + stack[#stack].t = "]" + line = ssub(line, 2) + else + line = "," .. line + end + else + local s, rest = parsestring(line, ",{}[]") + if not s then error("invalid flowstyle line: " .. line) end + tinsert(stack, { v = s, t = "s" }) + line = rest + end + end + return stack[1].v, line +end + +local function parseblockstylestring(line, lines, indent) + if #lines == 0 then error("failed to find multi-line scalar content") end + local s = {} + local firstindent = -1 + local endline = -1 + for i = 1, #lines do + local ln = lines[i] + local idt = countindent(ln) + if idt <= indent then break end + if ln == "" then + tinsert(s, "") + else + if firstindent == -1 then + firstindent = idt + elseif idt < firstindent then + break + end + tinsert(s, ssub(ln, firstindent + 1)) + end + endline = i + end + + local striptrailing = true + local sep = "\n" + local newlineatend = true + if line == "|" then + striptrailing = true + sep = "\n" + newlineatend = true + elseif line == "|+" then + striptrailing = false + sep = "\n" + newlineatend = true + elseif line == "|-" then + striptrailing = true + sep = "\n" + newlineatend = false + elseif line == ">" then + striptrailing = true + sep = " " + newlineatend = true + elseif line == ">+" then + striptrailing = false + sep = " " + newlineatend = true + elseif line == ">-" then + striptrailing = true + sep = " " + newlineatend = false + else + error("invalid blockstyle string:" .. line) + end + local eonl = 0 + for i = #s, 1, -1 do + if s[i] == "" then + tremove(s, i) + eonl = eonl + 1 + end + end + if striptrailing then eonl = 0 end + if newlineatend then eonl = eonl + 1 end + for i = endline, 1, -1 do + tremove(lines, i) + end + return table.concat(s, sep) .. string.rep("\n", eonl) +end + +local function parsetimestamp(line) + local _, p1, y, m, d = sfind(line, "^(%d%d%d%d)%-(%d%d)%-(%d%d)") + if not p1 then return nil, line end + if p1 == #line then return types.timestamp(y, m, d), "" end + local _, p2, h, i, s = sfind(line, "^[Tt ](%d+):(%d+):(%d+)", p1 + 1) + if not p2 then return types.timestamp(y, m, d), ssub(line, p1 + 1) end + if p2 == #line then return types.timestamp(y, m, d, h, i, s), "" end + local _, p3, f = sfind(line, "^%.(%d+)", p2 + 1) + if not p3 then + p3 = p2 + f = 0 + end + local zc = ssub(line, p3 + 1, p3 + 1) + local _, p4, zs, z = sfind(line, "^ ?([%+%-])(%d+)", p3 + 1) + if p4 then + z = tonumber(z) + local _, p5, zi = sfind(line, "^:(%d+)", p4 + 1) + if p5 then z = z + tonumber(zi) / 60 end + z = zs == "-" and -tonumber(z) or tonumber(z) + elseif zc == "Z" then + p4 = p3 + 1 + z = 0 + else + p4 = p3 + z = false + end + return types.timestamp(y, m, d, h, i, s, f, z), ssub(line, p4 + 1) +end + +local function parsescalar(line, lines, indent) + line = ltrim(line) + line = gsub(line, "^%s*#.*$", "") -- comment only -> '' + line = gsub(line, "^%s*", "") -- trim head spaces + + if line == "" or line == "~" then return null end + + local ts, _ = parsetimestamp(line) + if ts then return ts end + + local s, _ = parsestring(line) + -- startswith quote ... string + -- not startswith quote ... maybe string + if s and (startswith(line, '"') or startswith(line, "'")) then return s end + + if startswith("!", line) then -- unexpected tagchar + error("unsupported line: " .. line) + end + + if equalsline(line, "{}") then return {} end + if equalsline(line, "[]") then return {} end + + if startswith(line, "{") or startswith(line, "[") then return parseflowstyle(line, lines) end + + if startswith(line, "|") or startswith(line, ">") then + return parseblockstylestring(line, lines, indent) + end + + -- Regular unquoted string + line = gsub(line, "%s*#.*$", "") -- trim tail comment + local v = line + if v == "null" or v == "Null" or v == "NULL" then + return null + elseif v == "true" or v == "True" or v == "TRUE" then + return true + elseif v == "false" or v == "False" or v == "FALSE" then + return false + elseif v == ".inf" or v == ".Inf" or v == ".INF" then + return math.huge + elseif v == "+.inf" or v == "+.Inf" or v == "+.INF" then + return math.huge + elseif v == "-.inf" or v == "-.Inf" or v == "-.INF" then + return -math.huge + elseif v == ".nan" or v == ".NaN" or v == ".NAN" then + return 0 / 0 + elseif sfind(v, "^[%+%-]?[0-9]+$") or sfind(v, "^[%+%-]?[0-9]+%.$") then + return tonumber(v) -- : int + elseif sfind(v, "^[%+%-]?[0-9]+%.[0-9]+$") then + return tonumber(v) + end + return s or v +end + +local parsemap -- : func + +local function parseseq(line, lines, indent) + local seq = setmetatable({}, types.seq) + if line ~= "" then error() end + while #lines > 0 do + -- Check for a new document + line = lines[1] + if startswith(line, "---") then + while #lines > 0 and not startswith(lines, "---") do + tremove(lines, 1) + end + return seq + end + + -- Check the indent level + local level = countindent(line) + if level < indent then + return seq + elseif level > indent then + error("found bad indenting in line: " .. line) + end + + local i, j = sfind(line, "%-%s+") + if not i then + i, j = sfind(line, "%-$") + if not i then return seq end + end + local rest = ssub(line, j + 1) + + if sfind(rest, "^[^'\"%s]*:") then + -- Inline nested hash + local indent2 = j + lines[1] = string.rep(" ", indent2) .. rest + tinsert(seq, parsemap("", lines, indent2)) + elseif sfind(rest, "^%-%s+") then + -- Inline nested seq + local indent2 = j + lines[1] = string.rep(" ", indent2) .. rest + tinsert(seq, parseseq("", lines, indent2)) + elseif isemptyline(rest) then + tremove(lines, 1) + if #lines == 0 then + tinsert(seq, null) + return seq + end + if sfind(lines[1], "^%s*%-") then + local nextline = lines[1] + local indent2 = countindent(nextline) + if indent2 == indent then + -- Null seqay entry + tinsert(seq, null) + else + tinsert(seq, parseseq("", lines, indent2)) + end + else + -- - # comment + -- key: value + local nextline = lines[1] + local indent2 = countindent(nextline) + tinsert(seq, parsemap("", lines, indent2)) + end + elseif rest then + -- Array entry with a value + tremove(lines, 1) + tinsert(seq, parsescalar(rest, lines)) + end + end + return seq +end + +local function parseset(line, lines, indent) + if not isemptyline(line) then error("not seq line: " .. line) end + local set = setmetatable({}, types.set) + while #lines > 0 do + -- Check for a new document + line = lines[1] + if startswith(line, "---") then + while #lines > 0 and not startswith(lines, "---") do + tremove(lines, 1) + end + return set + end + + -- Check the indent level + local level = countindent(line) + if level < indent then + return set + elseif level > indent then + error("found bad indenting in line: " .. line) + end + + local i, j = sfind(line, "%?%s+") + if not i then + i, j = sfind(line, "%?$") + if not i then return set end + end + local rest = ssub(line, j + 1) + + if sfind(rest, "^[^'\"%s]*:") then + -- Inline nested hash + local indent2 = j + lines[1] = string.rep(" ", indent2) .. rest + set[parsemap("", lines, indent2)] = true + elseif sfind(rest, "^%s+$") then + tremove(lines, 1) + if #lines == 0 then + tinsert(set, null) + return set + end + if sfind(lines[1], "^%s*%?") then + local indent2 = countindent(lines[1]) + if indent2 == indent then + -- Null array entry + set[null] = true + else + set[parseseq("", lines, indent2)] = true + end + end + elseif rest then + tremove(lines, 1) + set[parsescalar(rest, lines)] = true + else + error("failed to classify line: " .. line) + end + end + return set +end + +function parsemap(line, lines, indent) + if not isemptyline(line) then error("not map line: " .. line) end + local map = setmetatable({}, types.map) + while #lines > 0 do + -- Check for a new document + line = lines[1] + if startswith(line, "---") then + while #lines > 0 and not startswith(lines, "---") do + tremove(lines, 1) + end + return map + end + + -- Check the indent level + local level, _ = countindent(line) + if level < indent then + return map + elseif level > indent then + error("found bad indenting in line: " .. line) + end + + -- Find the key + local key + local s, rest = parsestring(line) + + -- Quoted keys + if s and startswith(rest, ":") then + local sc = parsescalar(s, {}, 0) + if sc and type(sc) ~= "string" then + key = sc + else + key = s + end + line = ssub(rest, 2) + else + error("failed to classify line: " .. line) + end + + key = checkdupekey(map, key) + line = ltrim(line) + + if ssub(line, 1, 1) == "!" then + -- ignore type + local rh = ltrim(ssub(line, 3)) + local typename = smatch(rh, "^!?[^%s]+") + line = ltrim(ssub(rh, #typename + 1)) + end + + if not isemptyline(line) then + tremove(lines, 1) + line = ltrim(line) + map[key] = parsescalar(line, lines, indent) + else + -- An indent + tremove(lines, 1) + if #lines == 0 then + map[key] = null + return map + end + if sfind(lines[1], "^%s*%-") then + local indent2 = countindent(lines[1]) + map[key] = parseseq("", lines, indent2) + elseif sfind(lines[1], "^%s*%?") then + local indent2 = countindent(lines[1]) + map[key] = parseset("", lines, indent2) + else + local indent2 = countindent(lines[1]) + if indent >= indent2 then + -- Null hash entry + map[key] = null + else + map[key] = parsemap("", lines, indent2) + end + end + end + end + return map +end + +-- : (list)->dict +local function parsedocuments(lines) + lines = select(lines, function(s) return not isemptyline(s) end) + + if sfind(lines[1], "^%%YAML") then tremove(lines, 1) end + + local root = {} + local in_document = false + while #lines > 0 do + local line = lines[1] + -- Do we have a document header? + local docright + if sfind(line, "^%-%-%-") then + -- Handle scalar documents + docright = ssub(line, 4) + tremove(lines, 1) + in_document = true + end + if docright then + if not sfind(docright, "^%s+$") and not sfind(docright, "^%s+#") then + tinsert(root, parsescalar(docright, lines)) + end + elseif #lines == 0 or startswith(line, "---") then + -- A naked document + tinsert(root, null) + while #lines > 0 and not sfind(lines[1], "---") do + tremove(lines, 1) + end + in_document = false + -- XXX The final '-+$' is to look for -- which ends up being an + -- error later. + elseif not in_document and #root > 0 then + -- only the first document can be explicit + error("parse error: " .. line) + elseif sfind(line, "^%s*%-") then + -- An array at the root + tinsert(root, parseseq("", lines, 0)) + elseif sfind(line, "^%s*[^%s]") then + -- A hash at the root + local level = countindent(line) + tinsert(root, parsemap("", lines, level)) + else + -- Shouldn't get here. @lines have whitespace-only lines + -- stripped, and previous match is a line with any + -- non-whitespace. So this clause should only be reachable via + -- a perlbug where \s is not symmetric with \S + + -- uncoverable statement + error("parse error: " .. line) + end + end + if #root > 1 and Null.isnull(root[1]) then + tremove(root, 1) + return root + end + return root +end + +--- Parse yaml string into table. +local function parse(source) + local lines = {} + for line in string.gmatch(source .. "\n", "(.-)\r?\n") do + tinsert(lines, line) + end + + local docs = parsedocuments(lines) + if #docs == 1 then return docs[1] end + + return docs +end + +return { + version = 0.1, + parse = parse, +}