diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..a7f1484 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +# https://EditorConfig.org + +root = true + +[*] +charset = utf-8 +end_of_line = unset +indent_size = 2 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true +max_line_width = 64 diff --git a/justfile b/justfile index cdf5185..d9c36f5 100644 --- a/justfile +++ b/justfile @@ -5,3 +5,12 @@ default: unit_test: @nodemon --exec 'echo -en "\n\n\n\n------------- BUSTED -------------\n"; busted spec' -e 'lua' + +test TAG: + @busted spec --tags {{TAG}} + +tests: + @busted spec + +lint FILE='': + @stylua --column-width 65 {{FILE}} diff --git a/metalua/compiler/ast_to_src.lua b/metalua/compiler/ast_to_src.lua index 75f6509..26853d0 100644 --- a/metalua/compiler/ast_to_src.lua +++ b/metalua/compiler/ast_to_src.lua @@ -55,7 +55,9 @@ function M.new(seen_comments, w) -- Number of linebreaks _lines = 0, -- wrap length - wrap = w or 80 + wrap = w or 80, + -- Last source line number emitted (used to detect blank-line gaps) + _last_nonempty_src_line = 0 } return setmetatable(self, M) end @@ -190,6 +192,32 @@ function M:nltempindent(extra) self.current_indent = depth end +---------------------------------------------------------------- +--- Reset last non-empty source line to the current position +--- @param pos position|{line: integer}|{l: integer} +---------------------------------------------------------------- +function M:emptyline_gap_reset(pos) + local line = pos and (pos.line or pos.l) + -- never go backwards + if line and line > self._last_nonempty_src_line then + self._last_nonempty_src_line = line + end +end + +---------------------------------------------------------------- +--- Detect the length of emptyline sequence in the source, +--- that precedes the current position +--- @param pos position|{line: integer}|{l: integer} +--- @return integer +---------------------------------------------------------------- +function M:emptyline_gap_before(pos) + local line = pos and (pos.line or pos.l) + if line and line > self._last_nonempty_src_line then + return line - self._last_nonempty_src_line + end + return 0 +end + ---------------------------------------------------------------- --- Keywords, which are illegal as identifiers. ---------------------------------------------------------------- @@ -429,6 +457,13 @@ function M:node(node) if co.position == pos then --- comes _after_ a previous expression if co.position == 'last' then self:nl() end + --- if there was a 'gap' preceding the comment, + --- we preserve it by emitting exactly one empty line + if co.position == 'first' then + if self:emptyline_gap_before(co.first) >= 2 then + self:nl() + end + end --- preserve existing newlines local lines = string.lines(co.text) if co.multiline then @@ -493,11 +528,18 @@ function M:node(node) end --- comes _before_ the next expression if co.position == 'first' then self:nl() end + --- advance non-empty source line tracker to this comment's last line + self:emptyline_gap_reset(co.last) end end end show_comments('first') + -- advance non-empty source line tracker, + -- used for detecting emptyline gaps in the source + if node.lineinfo and node.lineinfo.first then + self:emptyline_gap_reset(node.lineinfo.first) + end if not node.tag then --- tagless block. self:list(node, self.nl) else @@ -515,6 +557,9 @@ function M:node(node) self:acc(" }") end end + if node.lineinfo and node.lineinfo.last then + self:emptyline_gap_reset(node.lineinfo.last) + end show_comments('last') end diff --git a/spec/ast_inputs.lua b/spec/ast_inputs.lua index 2954872..cf79b4a 100644 --- a/spec/ast_inputs.lua +++ b/spec/ast_inputs.lua @@ -495,6 +495,179 @@ local comments = { ' ', 'end', }), + + prep({ + '', + '-- emptyline before comment must be preserved', + 'function foo(bar)', + ' return ("no comment duplication should happen")', + 'end' + }), + + prep({ + '', + '', + '-- multiple emptylines before comment must be squashed', + }, { + '', + '-- multiple emptylines before comment must be squashed', + }) +} + +local emptylines = { + + -- standalone comment + + prep({'--- standalone comment does not enforce emptylines'}), + prep({'', + '--- standalone comment preserves emptyline before it' + }), + prep({ + '', + '', + '', + '--- standalone comment keeps just one emptyline before it' + },{ + '', + '--- standalone comment keeps just one emptyline before it' + }), + + -- standalone expression + + prep({ + 'print("standalone expression does not enforce emptylines")' + }), + prep({ + '', + '', + '', + 'print("standalone expression eliminates emptylines before it")' + }, { + 'print("standalone expression eliminates emptylines before it")' + }), + + --- expression+comment + + prep({ + '-- comment before expression does not enforce emptylines', + 'print("expectation: no emptylines are injected")', + }), + prep({ + '', + '', + '', + '-- comment before expression allows one emptyline before it', + 'print("expectation: exactly one emptyline is preserved")', + }, { + '', + '-- comment before expression allows one emptyline before it', + 'print("expectation: exactly one emptyline is preserved")', + }), + + -- expressions (multiple) + + prep({ + 'print("rule: no gap between adjacent statemets is enforced")', + 'print("expecting: no emptyline should be injected")' + }), + prep({ + 'print("rule: gap between adjacent statements is eliminated")', + '', + '', + '', + 'print("expecting: no emptylines preserved")' + }, { + 'print("rule: gap between adjacent statements is eliminated")', + 'print("expecting: no emptylines preserved")' + }), + + -- expressions (multiple) + comment + + prep({ + 'print("rule: comment between statements does not preserve gap")', + '', + '-- comment between statements does not preserve gap', + 'print("expecting: emptyline is not preserved")' + }, { + 'print("rule: comment between statements does not preserve gap")', + '-- comment between statements does not preserve gap', + 'print("expecting: emptyline is not preserved")' + }), + + -- standalone block + + prep({ + 'function rule()', + ' return ("standalone block does not emit emptylines")', + 'end' + }), + prep({ + '', + '', + '', + 'function rule()', + ' return ("standalone block erases emptylines before it")', + 'end' + }, { + 'function rule()', + ' return ("standalone block erases emptylines before it")', + 'end' + }), + + -- standalone block + comment + + prep({ + '-- comment before block', + 'function rule()', + ' return ("comment before block does not emit emptylines")', + 'end' + }), + prep({ + '', + '', + '', + '-- comment before block', + 'function rule()', + ' return ("comment before block keeps 1 emptyline before it")', + 'end' + }, { + '', + '-- comment before block', + 'function rule()', + ' return ("comment before block keeps 1 emptyline before it")', + 'end' + }), + + -- multiple blocks + comment in between + + prep({ + 'function rule()', + ' return ("no gaps between blocks are enforced")', + 'end', + '-- comment in between', + 'function expectation()', + ' return ("no emptylines are injected")', + 'end', + }), + prep({ + 'function rule()', + ' return ("no gaps between blocks are preserved")', + 'end', + '', + '-- comment in between', + '', + 'function expectation()', + ' return ("no emptylines are kept")', + 'end', + }, { + 'function rule()', + ' return ("no gaps between blocks are preserved")', + 'end', + '-- comment in between', + 'function expectation()', + ' return ("no emptylines are kept")', + 'end', + }) } local wrapping = { @@ -1199,12 +1372,14 @@ local todo = { prep('t = { c = 2 }'), prep('a = 1'), } + return { { 'basics', basics }, { 'operators', operators }, { 'functions', functions }, { 'self', self }, { 'comments', comments }, + { 'emptylines', emptylines }, { 'wrap', wrapping }, { 'canon', canon }, { 'full', full }, diff --git a/spec/ast_to_src_spec.lua b/spec/ast_to_src_spec.lua index ac629c8..df1f5a6 100644 --- a/spec/ast_to_src_spec.lua +++ b/spec/ast_to_src_spec.lua @@ -1,3 +1,5 @@ +local fmt = string.format + local describe = require("busted").describe local assert = require("busted").assert local it = require("busted").it @@ -5,7 +7,7 @@ local it = require("busted").it require("stringutils.string") local inputs = require("spec.ast_inputs") -local mlc = require('metalua.compiler').new() +local mlc = require("metalua.compiler").new() local w = 64 @@ -16,7 +18,7 @@ local parse_prot = function(code) return pcall(mlc.src_to_ast, mlc, c) end -describe('ast_to_src #src', function() +describe("ast_to_src #src", function() local function do_code(ast, seen_comments) local a2s = mlc:a2s(seen_comments, w) local code, comments = a2s:run(ast) @@ -33,14 +35,15 @@ describe('ast_to_src #src', function() return code, seen_comments end - describe('produces ASTs', function() + describe("produces ASTs", function() for _, test_t in pairs(inputs) do local tag = test_t[1] local tests = test_t[2] - describe('for ' .. tag, function() + describe("for #" .. tag, function() for i, tc in ipairs(tests) do local input = tc[1] local output = tc[2] + local testtag = fmt("%s_%s", tag, i) local ok, r = parse_prot(input) local result = {} @@ -61,15 +64,15 @@ describe('ast_to_src #src', function() end --- remove trailing newline - if result[#result] == '' then + if result[#result] == "" then table.remove(result) end - it('matches ' .. i, function() + it("matching rule #" .. testtag, function() assert.same(output, result) assert.is_true(parse_prot(output)) end) else - print('syntax error in input #' .. i) + print("syntax error in #" .. testtag) print(r) end end