diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c1a19c1..9e8b698 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,4 +16,14 @@ jobs: cache: npm - run: npm ci - run: npm run build - - run: npm test + - run: npm run test:coverage + + - name: Coverage summary + if: always() + run: | + if [ -f coverage/coverage-summary.json ]; then + LINE=$(jq -r '.total.lines.pct' coverage/coverage-summary.json) + echo "## Code Coverage" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Line coverage: ${LINE}%**" >> $GITHUB_STEP_SUMMARY + fi diff --git a/.gitignore b/.gitignore index c060b27..b7bd457 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules main.js *.js.map data.json +coverage diff --git a/package-lock.json b/package-lock.json index 5b451c3..7fd237e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,12 +9,73 @@ "version": "1.0.1", "devDependencies": { "@types/node": "^20.11.0", + "@vitest/coverage-v8": "^4.1.7", "esbuild": "^0.28.0", - "obsidian": "*", + "obsidian": "latest", "typescript": "^5.3.0", "vitest": "^4.1.7" } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@codemirror/state": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz", @@ -516,6 +577,16 @@ "node": ">=18" } }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -523,6 +594,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@marijn/find-cluster-break": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", @@ -897,6 +979,37 @@ "@types/estree": "*" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.7.tgz", + "integrity": "sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.7", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.7", + "vitest": "4.1.7" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "4.1.7", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", @@ -1020,6 +1133,18 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", + "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -1157,6 +1282,69 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -1428,6 +1616,34 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/moment": { "version": "2.29.4", "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", @@ -1573,6 +1789,19 @@ "@rolldown/binding-win32-x64-msvc": "1.0.2" } }, + "node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -1612,6 +1841,19 @@ "license": "MIT", "peer": true }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", diff --git a/package.json b/package.json index 162dab9..a95a97d 100644 --- a/package.json +++ b/package.json @@ -7,10 +7,12 @@ "dev": "node esbuild.config.mjs", "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" }, "devDependencies": { "@types/node": "^20.11.0", + "@vitest/coverage-v8": "^4.1.7", "esbuild": "^0.28.0", "obsidian": "latest", "typescript": "^5.3.0", diff --git a/src/__tests__/convert.test.ts b/src/__tests__/convert.test.ts index 24134af..9bba153 100644 --- a/src/__tests__/convert.test.ts +++ b/src/__tests__/convert.test.ts @@ -82,4 +82,49 @@ describe("convertSelection", () => { expect(editor.getValue()).toBe(doc); }); + + it("does nothing when the selection is empty", () => { + const doc = "│ A │ B │\n│ 1 │ 2 │"; + const editor = new FakeEditor(doc, { line: 0, ch: 5 }, { line: 0, ch: 5 }); + + convertSelection(editor as unknown as Editor); + + expect(editor.getValue()).toBe(doc); + }); + + it("trims the trailing line when the selection ends at column 0 of the next line", () => { + // Mimics a triple-click drag: selection extends through "\n" onto a new line. + const doc = + "┌──────┬──────┐\n" + + "│ A │ B │\n" + + "├──────┼──────┤\n" + + "│ 1 │ 2 │\n" + + "└──────┴──────┘\n" + + "after"; + const editor = new FakeEditor(doc, { line: 0, ch: 0 }, { line: 5, ch: 0 }); + + convertSelection(editor as unknown as Editor); + + expect(editor.getValue()).toBe( + "| A | B |\n" + "| - | - |\n" + "| 1 | 2 |\n" + "after" + ); + }); + + it("preserves text before and after a table embedded mid-document", () => { + const doc = + "before\n" + + "┌──────┬──────┐\n" + + "│ A │ B │\n" + + "├──────┼──────┤\n" + + "│ 1 │ 2 │\n" + + "└──────┴──────┘\n" + + "after"; + const editor = new FakeEditor(doc, { line: 1, ch: 0 }, { line: 5, ch: 15 }); + + convertSelection(editor as unknown as Editor); + + expect(editor.getValue()).toBe( + "before\n" + "| A | B |\n" + "| - | - |\n" + "| 1 | 2 |\n" + "after" + ); + }); }); diff --git a/src/__tests__/detect.test.ts b/src/__tests__/detect.test.ts new file mode 100644 index 0000000..8f93863 --- /dev/null +++ b/src/__tests__/detect.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from "vitest"; +import { looksLikeTable } from "../detect"; + +describe("looksLikeTable", () => { + it("detects Unicode box-drawing characters", () => { + expect(looksLikeTable("│ a │ b │")).toBe(true); + expect(looksLikeTable("┌──┬──┐")).toBe(true); + expect(looksLikeTable("└──┴──┘")).toBe(true); + expect(looksLikeTable("├──┼──┤")).toBe(true); + }); + + it("detects ASCII borders with dashes and equals", () => { + expect(looksLikeTable("+----+----+")).toBe(true); + expect(looksLikeTable("+====+====+")).toBe(true); + }); + + it("rejects plain prose", () => { + expect(looksLikeTable("Just some regular text.")).toBe(false); + expect(looksLikeTable("Two\nlines, no table.")).toBe(false); + }); + + it("rejects a plain Markdown table without box-drawing or +-+ borders", () => { + const md = "| a | b |\n| - | - |\n| 1 | 2 |"; + expect(looksLikeTable(md)).toBe(false); + }); +}); diff --git a/src/__tests__/formatter.test.ts b/src/__tests__/formatter.test.ts index bba4743..f1e2fb2 100644 --- a/src/__tests__/formatter.test.ts +++ b/src/__tests__/formatter.test.ts @@ -30,6 +30,11 @@ describe("looksLikeIdentifier", () => { expect(looksLikeIdentifier("admin")).toBe(false); expect(looksLikeIdentifier("hello")).toBe(false); }); + + it("detects colon-separated identifiers", () => { + expect(looksLikeIdentifier("Foo:Bar")).toBe(true); + expect(looksLikeIdentifier("namespace:type")).toBe(true); + }); }); describe("formatMarkdownTable", () => { @@ -99,6 +104,30 @@ describe("formatMarkdownTable", () => { expect(result).toContain("fixed via ShowableScope"); }); + it("does not wrap header values in backticks even when they look like identifiers", () => { + const table: ParsedTable = { + headers: ["MyClass", "Note"], + rows: [["plain", "text"]], + }; + + const result = formatMarkdownTable(table); + const headerLine = result.split("\n")[0]; + + expect(headerLine).toContain("MyClass"); + expect(headerLine).not.toContain("`MyClass`"); + }); + + it("formats a single-row table", () => { + const table: ParsedTable = { + headers: ["A", "B"], + rows: [["1", "2"]], + }; + + expect(formatMarkdownTable(table)).toBe( + "| A | B |\n| - | - |\n| 1 | 2 |" + ); + }); + it("handles empty cells", () => { const table: ParsedTable = { headers: ["A", "B"], diff --git a/src/__tests__/parser.test.ts b/src/__tests__/parser.test.ts index 13f9c07..18c5261 100644 --- a/src/__tests__/parser.test.ts +++ b/src/__tests__/parser.test.ts @@ -131,6 +131,75 @@ describe("parseTable", () => { ]); }); + it("parses an ASCII table with = borders", () => { + const input = `+======+======+ +| Name | Age | ++======+======+ +| Eve | 22 | ++======+======+`; + + const result = parseTable(input); + + expect(result).not.toBeNull(); + expect(result!.headers).toEqual(["Name", "Age"]); + expect(result!.rows).toEqual([["Eve", "22"]]); + }); + + it("parses an existing Markdown pipe table", () => { + const input = `| Name | Age | +| ---- | --- | +| Dana | 31 | +| Erik | 28 |`; + + const result = parseTable(input); + + expect(result).not.toBeNull(); + expect(result!.headers).toEqual(["Name", "Age"]); + expect(result!.rows).toEqual([ + ["Dana", "31"], + ["Erik", "28"], + ]); + }); + + it("drops rows whose column count differs from the header", () => { + const input = `│ A │ B │ +│ 1 │ 2 │ +│ x │ y │ z │ +│ 3 │ 4 │`; + + const result = parseTable(input); + + expect(result).not.toBeNull(); + expect(result!.headers).toEqual(["A", "B"]); + expect(result!.rows).toEqual([ + ["1", "2"], + ["3", "4"], + ]); + }); + + it("handles mixed │ and | delimiters across lines", () => { + const input = `│ A │ B │ +| 1 | 2 |`; + + const result = parseTable(input); + + expect(result).not.toBeNull(); + expect(result!.headers).toEqual(["A", "B"]); + expect(result!.rows).toEqual([["1", "2"]]); + }); + + it("tolerates blank lines between data rows when no border is present", () => { + const input = `│ A │ B │ + +│ 1 │ 2 │`; + + const result = parseTable(input); + + expect(result).not.toBeNull(); + expect(result!.headers).toEqual(["A", "B"]); + expect(result!.rows).toEqual([["1", "2"]]); + }); + it("handles a three-column table", () => { const input = `┌──────┬──────┬──────┐ │ A │ B │ C │ diff --git a/src/detect.ts b/src/detect.ts new file mode 100644 index 0000000..4a2e2b2 --- /dev/null +++ b/src/detect.ts @@ -0,0 +1,8 @@ +const TABLE_HINT = /[│┌┬┐├┼┤└┴┘─]/; + +// Quick heuristic gate used by main.ts before the more expensive parseTable. +// True for ASCII box-drawing tables and +---+ / +===+ ruled tables; false for +// plain prose and plain Markdown pipe tables (which need no conversion). +export function looksLikeTable(text: string): boolean { + return TABLE_HINT.test(text) || /\+[-=]+\+/.test(text); +} diff --git a/src/main.ts b/src/main.ts index cb47aff..b7292a5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,11 +1,6 @@ import { Editor, Menu, Plugin } from "obsidian"; import { convertSelection } from "./convert"; - -const TABLE_HINT = /[│┌┬┐├┼┤└┴┘─]/; - -function looksLikeTable(text: string): boolean { - return TABLE_HINT.test(text) || /\+[-=]+\+/.test(text); -} +import { looksLikeTable } from "./detect"; export default class TableBeautifierPlugin extends Plugin { onload() { diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..f38fa9a --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + coverage: { + provider: "v8", + include: ["src/**/*.ts"], + exclude: ["src/**/__tests__/**"], + reporter: ["text", "html", "json-summary"], + }, + }, +});