diff --git a/package-lock.json b/package-lock.json index ccf033e..5327674 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "snipsync", - "version": "1.10.0", + "version": "1.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "snipsync", - "version": "1.10.0", + "version": "1.11.0", "license": "MIT", "dependencies": { "@octokit/rest": "^18.12.0", diff --git a/src/Sync.js b/src/Sync.js index 685ca50..cde1d3f 100644 --- a/src/Sync.js +++ b/src/Sync.js @@ -15,7 +15,6 @@ const { writeEnd, } = require("./common"); const { writeFile, unlink } = require("fs"); -const dedent = require("dedent"); const path = require("path"); const arrayBuffToBuff = require("arraybuffer-to-buffer"); const anzip = require("anzip"); @@ -25,6 +24,9 @@ const progress = require("cli-progress"); const glob = require("glob"); const { type } = require("os"); +// Deindenting dependencies +const { deindentByCommonPrefix, SENSITIVE_INDENT_EXTS } = require("./deindent"); + // Convert dependency functions to return promises const writeAsync = promisify(writeFile); const unlinkAsync = promisify(unlink); @@ -56,10 +58,21 @@ class Snippet { lines.push(textline); } if (config.select !== undefined) { - const selectedLines = selectLines(config.select, this.lines, this.ext); - lines.push(...selectedLines); + let snippetLines = selectLines(config.select, this.lines, this.ext); + + if (config.enable_code_dedenting && !SENSITIVE_INDENT_EXTS.has(this.ext) && snippetLines.length) { + snippetLines = deindentByCommonPrefix(snippetLines); + } + + lines.push(...snippetLines); } else if(!config.startPattern && !config.endPattern ) { - lines.push(...this.lines); + let snippetLines = [...this.lines]; + + if (config.enable_code_dedenting && !SENSITIVE_INDENT_EXTS.has(this.ext) && snippetLines.length) { + snippetLines = deindentByCommonPrefix(snippetLines); + } + + lines.push(...snippetLines); } else { // use the patterns to grab the content specified. @@ -67,10 +80,15 @@ class Snippet { const match = this.lines.join("\n").match(pattern); if (match !== null) { - let filteredLines = match[1].split("\n"); - lines.push(...filteredLines); + let snippetLines = match[1].split("\n"); + + if (config.enable_code_dedenting && !SENSITIVE_INDENT_EXTS.has(this.ext) && snippetLines.length) { + snippetLines = deindentByCommonPrefix(snippetLines); + } + + lines.push(...snippetLines); + } } - } if (config.enable_code_block) { lines.push(markdownCodeTicks); @@ -136,13 +154,8 @@ class File { this.lines = []; } // fileString converts the array of lines into a string - fileString(dedentCode = false) { + fileString() { let lines = `${this.lines.join("\n")}\n`; - - if (dedentCode) { - lines = dedent(lines); - } - return lines; } } @@ -467,7 +480,7 @@ class Sync { for (const file of files) { await writeAsync( file.fullpath, - file.fileString(this.config.features.enable_code_dedenting) + file.fileString() ); this.progress.increment(); } @@ -530,15 +543,24 @@ function extractWriteIDAndConfig(line) { function overwriteConfig(current, extracted) { let config = {}; - config.enable_source_link = - extracted?.enable_source_link ?? true - ? current.enable_source_link - : extracted.enable_source_link; + // use snippet override if present, otherwise use global default + if (extracted && 'enable_source_link' in extracted) { + config.enable_source_link = extracted.enable_source_link; + } else { + config.enable_source_link = current.enable_source_link; + } + + if (extracted && 'enable_code_block' in extracted) { + config.enable_code_block = extracted.enable_code_block; + } else { + config.enable_code_block = current.enable_code_block; + } - config.enable_code_block = - extracted?.enable_code_block ?? true - ? current.enable_code_block - : extracted.enable_code_block; + if (extracted && 'enable_code_dedenting' in extracted) { + config.enable_code_dedenting = extracted.enable_code_dedenting; + } else { + config.enable_code_dedenting = current.enable_code_dedenting || false; + } if (extracted?.highlightedLines ?? undefined) { config.highlights = extracted.highlightedLines; diff --git a/src/config.js b/src/config.js index 8dc41de..420e400 100644 --- a/src/config.js +++ b/src/config.js @@ -37,7 +37,7 @@ module.exports.readConfig = (logger, file="") => { // Disable code block dedenting by default if not specified if (!Object.prototype.hasOwnProperty.call(cfg.features, 'enable_code_dedenting')) { - cfg['features']['enable_code_dedenting'] = false; + cfg['features']['enable_code_dedenting'] = true; } return cfg; diff --git a/src/deindent.js b/src/deindent.js new file mode 100644 index 0000000..418fd3d --- /dev/null +++ b/src/deindent.js @@ -0,0 +1,37 @@ +function commonIndentPrefix(lines) { + const nonEmpty = lines.filter(l => l.trim().length > 0); + if (nonEmpty.length === 0) {return "";} + + // Treat lines that are only closing tokens (possibly multiple) as "closers". + // Examples matched: "}", ")", "]", "});", "],", "})", "));", etc., with optional spaces. + const CLOSING_ONLY = /^\s*[\])}]+(?:[;,])?\s*$/; + const RUBY_END = /^\s*end\b\s*$/; + const isClosingOnly = (s) => CLOSING_ONLY.test(s) || RUBY_END.test(s); + + // Ignore closers when computing the common indent + const pool = nonEmpty.filter(l => !isClosingOnly(l.trim())); + const candidates = pool.length ? pool : nonEmpty; + + const prefixes = candidates.map(l => (l.match(/^[\t ]*/)?.[0] || "")); + let prefix = prefixes[0] || ""; + for (let i = 1; i < prefixes.length; i++) { + let j = 0; + while (j < prefix.length && j < prefixes[i].length && prefix[j] === prefixes[i][j]) {j++;} + prefix = prefix.slice(0, j); + if (!prefix) {break;} + } + return prefix; +} + +function deindentByCommonPrefix(lines) { + const prefix = commonIndentPrefix(lines); + if (!prefix) { + return lines.slice(); + } + const re = new RegExp("^" + prefix.replace(/[\t ]/g, m => (m === "\t" ? "\\t" : " "))); + return lines.map(l => (l.startsWith(prefix) ? l.replace(re, "") : l)); +} + +const SENSITIVE_INDENT_EXTS = new Set(['make', 'mk', 'Makefile', 'diff']); + +module.exports = { commonIndentPrefix, deindentByCommonPrefix, SENSITIVE_INDENT_EXTS }; diff --git a/test/deindent.test.js b/test/deindent.test.js new file mode 100644 index 0000000..891d32a --- /dev/null +++ b/test/deindent.test.js @@ -0,0 +1,94 @@ +const { deindentByCommonPrefix, commonIndentPrefix } = require('../src/deindent'); + +describe('deindentByCommonPrefix', () => { + test('strips common leading spaces from all lines', () => { + const input = [ + ' foo();', + ' bar();', + ]; + const output = deindentByCommonPrefix(input); + expect(output).toEqual(['foo();', 'bar();']); + }); + + test('preserves relative indentation inside the snippet', () => { + const input = [ + ' if (x) {', + ' doSomething();', + ' }', + ]; + const output = deindentByCommonPrefix(input); + expect(output).toEqual([ + 'if (x) {', + ' doSomething();', + '}', + ]); + }); + + test('does not dedent when common indent is zero (hanging indent case)', () => { + const input = [ + ' a++;', + 'b = a;', + ]; + const output = deindentByCommonPrefix(input); + // Nothing stripped + expect(output).toEqual(input); + }); + + test('ignores closing-only head lines when computing indent', () => { + const input = [ + '});', + ' doSomething();', + ]; + const output = deindentByCommonPrefix(input); + expect(output).toEqual([ + '});', // unchanged + 'doSomething();', // dedented + ]); + }); + + test('handles empty lines gracefully', () => { + const input = [ + '', + ' foo();', + '', + ' bar();', + ]; + const output = deindentByCommonPrefix(input); + expect(output).toEqual([ + '', + 'foo();', + '', + 'bar();', + ]); + }); + + test('returns a shallow copy when there is nothing to dedent', () => { + const input = ['foo();', 'bar();']; + const output = deindentByCommonPrefix(input); + expect(output).toEqual(input); + expect(output).not.toBe(input); // new array, not the same reference + }); +}); + +describe('commonIndentPrefix', () => { + test('computes the correct common indent', () => { + const input = [ + ' foo();', + ' bar();', + ]; + expect(commonIndentPrefix(input)).toBe(' '); + }); + + test('returns empty string when lines have different starting indents and at least one substantive line is flush left', () => { + const input = [ + ' foo();', + 'bar();', + ]; + expect(commonIndentPrefix(input)).toBe(''); + }); + + test('returns empty string for all-empty input', () => { + expect(commonIndentPrefix([])).toBe(''); + expect(commonIndentPrefix(['', ''])).toBe(''); + }); +}); diff --git a/test/fixtures/dedent.md b/test/fixtures/dedent.md index c90b9ed..fee75e5 100644 --- a/test/fixtures/dedent.md +++ b/test/fixtures/dedent.md @@ -1,2 +1,10 @@ +What if there is preceding text at zero indentation? Does it handle this well? + + +For example, this paragraph starts at flush-left. + +- However, certain items should not be dedented + - For example, this list item on another level +- Another list item \ No newline at end of file diff --git a/test/sync.test.js b/test/sync.test.js index 73d2967..64e7c8d 100644 --- a/test/sync.test.js +++ b/test/sync.test.js @@ -158,56 +158,139 @@ test('uses regex patterns to pare down snippet inserted into a file', async() => }); -test('Do not dedent snippets when option is false', async() => { +test('Dedent keeps relative indentation inside the snippet', async () => { + fs.copyFileSync(`${fixturesPath}/dedent.md`, `${testEnvPath}/dedent.md`); - fs.copyFileSync(`${fixturesPath}/dedent.md`,`${testEnvPath}/dedent.md`); + cfg.origins = [{ owner: 'temporalio', repo: 'samples-typescript' }]; + cfg.features.enable_code_dedenting = true; + + const synctron = new Sync(cfg, logger); + await synctron.run(); + + const text = fs.readFileSync(`${testEnvPath}/dedent.md`, 'utf8'); + + // Grab the first fenced code block contents + const m = text.match(/```[^\n]*\n([\s\S]*?)\n```/); + expect(m).toBeTruthy(); + + const bodyLines = m[1].split('\n').filter(l => l.length > 0); + const indents = bodyLines.map(l => (l.match(/^[ \t]*/)?.[0].length ?? 0)); + const minIndent = Math.min(...indents); + + // With dedent enabled, the common left padding is removed: + expect(minIndent).toBe(0); + + // But relative indentation remains for inner lines (at least one line still indented): + expect(bodyLines.some(l => /^[ \t]+\S/.test(l))).toBe(true); +}); + +test('Regex-selected regions are dedented after start/end pattern slicing', async () => { + fs.copyFileSync(`${fixturesPath}/regex_index.md`, `${testEnvPath}/regex_index.md`); cfg.origins = [ + { owner: 'temporalio', repo: 'money-transfer-project-template-go' }, { owner: 'temporalio', repo: 'samples-typescript' }, ]; + cfg.features.enable_code_dedenting = true; + + const synctron = new Sync(cfg, logger); + await synctron.run(); + + const text = fs.readFileSync(`${testEnvPath}/regex_index.md`, 'utf8'); + + // First fenced block (per your fixture) + const m = text.match(/```[^\n]*\n([\s\S]*?)\n```/); + expect(m).toBeTruthy(); + const bodyLines = m[1].split('\n').filter(l => l.length > 0); + const indents = bodyLines.map(l => (l.match(/^[ \t]*/)?.[0].length ?? 0)); + const minIndent = Math.min(...indents); + // After slicing by start/end patterns, we still dedent the selected region: + expect(minIndent).toBe(0); + + // Keep original regex expectations for content sanity + expect(text).not.toMatch(/import type \* as activities/); + expect(text).toMatch(/const \{ greet/); + expect(text).not.toMatch(/export async function example/); +}); + +test('No dedent when option is false (snippet stays indented; other content unchanged)', async () => { + fs.copyFileSync(`${fixturesPath}/dedent.md`, `${testEnvPath}/dedent.md`); + + cfg.origins = [{ owner: 'temporalio', repo: 'samples-typescript' }]; cfg.features.enable_code_dedenting = false; const synctron = new Sync(cfg, logger); await synctron.run(); - let data = fs.readFileSync(`${testEnvPath}/dedent.md`, 'utf8'); - data = data.split("\n"); + const text = fs.readFileSync(`${testEnvPath}/dedent.md`, 'utf8'); - /* - * The code will start on the 4th line, as the 1st is the comment, second is the file link - * and the third is the code fence. - * The fourth line should have two spaces on the first line, as they should not be stripped. - */ - expect(data[3]).toMatch(/^\s\s/); + // Grab the first code block’s first line + const m = text.match(/```[^\n]*\n([\s\S]*?)\n```/); + expect(m).toBeTruthy(); + const firstCodeLine = m[1].split('\n')[0]; -}); + // With dedent OFF, snippet should still start with two spaces + expect(firstCodeLine).toMatch(/^\s{2}\S/); -test('Dedent snippets when option is set', async() => { + // Prose paragraph stays flush-left + expect(text).toMatch(/\nFor example, this paragraph starts at flush-left\./); - fs.copyFileSync(`${fixturesPath}/dedent.md`,`${testEnvPath}/dedent.md`); + // Nested list item keeps its two leading spaces + expect(text).toMatch(/\n\s{2}- For example, this list item on another level/); +}); - cfg.origins = [ - { owner: 'temporalio', repo: 'samples-typescript' }, - ]; +test('Dedent when option is true (should only affect snippet; OTHER CONTENT UNCHANGED)', async () => { + fs.copyFileSync(`${fixturesPath}/dedent.md`, `${testEnvPath}/dedent.md`); + cfg.origins = [{ owner: 'temporalio', repo: 'samples-typescript' }]; cfg.features.enable_code_dedenting = true; const synctron = new Sync(cfg, logger); await synctron.run(); - let data = fs.readFileSync(`${testEnvPath}/dedent.md`, 'utf8'); - data = data.split("\n"); + const text = fs.readFileSync(`${testEnvPath}/dedent.md`, 'utf8'); + + const m = text.match(/```[^\n]*\n([\s\S]*?)\n```/); + expect(m).toBeTruthy(); + const firstCodeLine = m[1].split('\n')[0]; - /* - * The code will start on the 4th line, as the 1st is the comment, second is the file link - * and the third is the code fence. - * The fourth line should NOT have two spaces at the start of the line, as they should be stripped. - */ - expect(data[3]).not.toMatch(/^\s/); + // EXPECTED (desired behavior): snippet becomes flush-left + expect(firstCodeLine).toMatch(/^\S/); + // EXPECTED (desired behavior): prose unchanged + expect(text).toMatch(/\nFor example, this paragraph starts at flush-left\./); + + // EXPECTED (desired behavior): nested list item should remain indented + // This is what will FAIL with the current file-level dedent implementation, + // since it also strips indentation from list lines. + expect(text).toMatch(/\n\s{2}- For example, this list item on another level/); }); +test('Dedent works without fences (enable_code_block=false)', async () => { + fs.copyFileSync(`${fixturesPath}/dedent.md`, `${testEnvPath}/dedent.md`); + + cfg.origins = [{ owner: 'temporalio', repo: 'samples-typescript' }]; + cfg.features.enable_code_block = false; // no ``` + cfg.features.enable_code_dedenting = true; // dedent ON + + const synctron = new Sync(cfg, logger); + await synctron.run(); + + const text = fs.readFileSync(`${testEnvPath}/dedent.md`, 'utf8'); + + // Extract snippet region between markers (simple, inline) + const m = text.match(/\n([\s\S]*?)\n/); + expect(m).toBeTruthy(); + const bodyLines = m[1].split('\n').filter(l => l.length > 0); + + // Same invariants: min indent is 0; at least one line still indented + const indents = bodyLines.map(l => (l.match(/^[ \t]*/)?.[0].length ?? 0)); + expect(Math.min(...indents)).toBe(0); + expect(bodyLines.some(l => /^[ \t]+\S/.test(l))).toBe(true); +}); + + test('Per snippet selectedLines configuration', async() => { cfg.origins = [