diff --git a/README.md b/README.md index cf678b7..1937f65 100644 --- a/README.md +++ b/README.md @@ -14,11 +14,11 @@ If you ever paste something that has many unicodes, Gremlins flag them. To repla ## Features -- Flags invisible or unusual Unicode characters, mixed indentation, and optionally typographic punctuation (curly quotation marks, en dashes, and em dashes). See details below. +- Flags invisible or unusual Unicode characters, mixed indentation, malformed list indentation when enabled, and optionally typographic punctuation (curly quotation marks, en dashes, and em dashes). See details below. - Shows an icon beside every visible line containing a gremlin (optionally, you can click the icon to fix the character immediatly) - Works in Source mode and Live Preview. -## The plugin checks three groups of rules. +## The plugin checks four groups of rules. ### 1. Invisible or potentially dangerous Unicode - enabled by default @@ -49,11 +49,19 @@ Bidirectional controls and invisible characters are highlighted because they can It detects indentation at the beginning of a line containing **both tabs and spaces**. -When fixed, indentation made from tabs followed by stray spaces keeps its tabs and removes the trailing spaces. Other mixed indentation becomes spaces while preserving its visual width, assuming four-column tab stops. +When fixed, indentation made from tabs followed by stray spaces keeps its tabs and removes the trailing spaces. Other mixed indentation becomes spaces while preserving its visual width using Obsidian's current indent visual width. It does **not** flag indentation made entirely from tabs or entirely from spaces. -### 3. Typographic punctuation - disabled by default +### 3. List indentation - disabled by default + +It detects space-indented Markdown list items whose indentation is not a multiple of Obsidian's current **Indent visual width** setting. Unordered, ordered, and task list items are supported; indented prose and code are ignored. + +When fixed, indentation is rounded to the nearest level, with ties rounded to the deeper level. For a four-space indent width, one space becomes no indentation, two or three spaces become four, five spaces becomes four, and six or seven spaces become eight. + +Fixes preserve the line's indentation style: space-indented list items remain spaces, while tab-led mixed indentation keeps its tabs. + +### 4. Typographic punctuation - disabled by default | Unicode | Character | Optional replacement | |---|---|---| diff --git a/manifest.json b/manifest.json index c8c58ab..d21daaa 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "gremlins", "name": "Gremlins", - "version": "0.2.4", + "version": "0.3.0", "minAppVersion": "1.5.0", "description": "Reveal invisible Unicode characters and suspicious indentation in notes.", "author": "xRyul", diff --git a/package-lock.json b/package-lock.json index 8e8bf94..8d17533 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "obsidian-gremlins", - "version": "0.2.4", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "obsidian-gremlins", - "version": "0.2.4", + "version": "0.3.0", "license": "MIT", "devDependencies": { + "@codemirror/language": "6.12.4", "@codemirror/state": "6.5.0", "@codemirror/view": "6.38.6", "@eslint/js": "9.39.4", @@ -27,6 +28,21 @@ "node": ">=18.18.0" } }, + "node_modules/@codemirror/language": { + "version": "6.12.4", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz", + "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, "node_modules/@codemirror/state": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz", @@ -771,6 +787,33 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, "node_modules/@marijn/find-cluster-break": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz", diff --git a/package.json b/package.json index ba8858b..cbb7f0b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsidian-gremlins", - "version": "0.2.4", + "version": "0.3.0", "description": "Reveal invisible Unicode characters and suspicious indentation in Obsidian notes.", "main": "main.js", "type": "module", @@ -28,6 +28,7 @@ "url": "https://github.com/xRyul/gremlins/issues" }, "devDependencies": { + "@codemirror/language": "6.12.4", "@codemirror/state": "6.5.0", "@codemirror/view": "6.38.6", "@eslint/js": "9.39.4", diff --git a/src/detect.ts b/src/detect.ts index 3988993..7fcebc5 100644 --- a/src/detect.ts +++ b/src/detect.ts @@ -2,9 +2,13 @@ import { GREMLIN_DEFINITIONS_BY_CODE_POINT } from './characters.ts'; import type { GremlinsSettings } from './settings-model.ts'; import type { GremlinDefinition, GremlinMatch } from './types.ts'; +const DEFAULT_INDENT_SIZE = 4; +const MARKDOWN_LIST_ITEM = /^( +)(?=(?:[-+*]|\d+[.)])(?:[\t ]|$))/; + export function detectGremlins( text: string, settings: GremlinsSettings, + indentSize = DEFAULT_INDENT_SIZE, ): GremlinMatch[] { const matches: GremlinMatch[] = []; const lines = text.split('\n'); @@ -12,7 +16,9 @@ export function detectGremlins( for (let line = 0; line < lines.length; line += 1) { const lineText = lines[line] ?? ''; - matches.push(...detectLineGremlins(lineText, lineFrom, line, settings)); + matches.push( + ...detectLineGremlins(lineText, lineFrom, line, settings, indentSize), + ); lineFrom += lineText.length + 1; } @@ -24,8 +30,11 @@ export function detectLineGremlins( lineFrom: number, line: number, settings: GremlinsSettings, + indentSize = DEFAULT_INDENT_SIZE, + isListItem = true, ): GremlinMatch[] { const matches: GremlinMatch[] = []; + const effectiveIndentSize = normalizeIndentSize(indentSize); if (settings.showMixedIndentation) { const indentation = /^[\t ]+/.exec(text)?.[0]; @@ -44,6 +53,26 @@ export function detectLineGremlins( } } + if (settings.showListIndentation && isListItem) { + const indentation = MARKDOWN_LIST_ITEM.exec(text)?.[1]; + if ( + indentation && + indentation.length % effectiveIndentSize !== 0 + ) { + matches.push({ + codePoint: null, + count: indentation.length, + from: lineFrom, + kind: 'list-indentation', + line, + name: 'list indentation', + severity: 'warning', + to: lineFrom + indentation.length, + zeroWidth: false, + }); + } + } + for (let index = 0; index < text.length; ) { const codePoint = text.codePointAt(index); if (codePoint === undefined) { @@ -89,3 +118,9 @@ function isDefinitionEnabled( ? settings.showTypographicCharacters : settings.showDangerousCharacters; } + +function normalizeIndentSize(indentSize: number) { + return Number.isInteger(indentSize) && indentSize > 0 + ? indentSize + : DEFAULT_INDENT_SIZE; +} diff --git a/src/editor-extension.ts b/src/editor-extension.ts index 7da9bb8..ef08550 100644 --- a/src/editor-extension.ts +++ b/src/editor-extension.ts @@ -22,6 +22,10 @@ import { isGremlinFixable, } from './fix.ts'; import { findGremlinAtPosition } from './match-position.ts'; +import { + isMarkdownListItem, + markdownSyntaxTreeChanged, +} from './markdown-context.ts'; import { GREMLIN_ICON_ID } from './gremlin-icon.ts'; import { formatGremlinTooltip, @@ -94,7 +98,12 @@ export function createGremlinsEditorExtension( } update(update: ViewUpdate) { - if (update.docChanged || update.viewportChanged) { + if ( + update.docChanged || + update.viewportChanged || + update.startState.tabSize !== update.state.tabSize || + markdownSyntaxTreeChanged(update.startState, update.state) + ) { this.refresh(update.view); } } @@ -189,6 +198,8 @@ function buildVisibleGremlins( line.from, line.number - 1, settings, + view.state.tabSize, + isMarkdownListItem(view.state, line.text, line.from), ); matches.push(...lineMatches); @@ -262,6 +273,7 @@ function fixGremlinsOnLine( lineMatches, line.text, line.from, + view.state.tabSize, ); if (changes.length === 0) { return true; @@ -315,6 +327,7 @@ function decorationClasses(match: GremlinMatch) { `gremlins-severity-${match.severity}`, match.zeroWidth ? 'gremlins-zero-width' : 'gremlins-visible-width', match.kind === 'mixed-indentation' ? 'gremlins-mixed-indentation' : '', + match.kind === 'list-indentation' ? 'gremlins-list-indentation' : '', ] .filter(Boolean) .join(' '); diff --git a/src/fix.ts b/src/fix.ts index fd03331..129d1d7 100644 --- a/src/fix.ts +++ b/src/fix.ts @@ -1,7 +1,7 @@ import { GREMLIN_DEFINITIONS_BY_CODE_POINT } from './characters.ts'; import type { GremlinMatch } from './types.ts'; -const MARKDOWN_TAB_SIZE = 4; +const DEFAULT_INDENT_SIZE = 4; export interface GremlinFixChange { from: number; @@ -11,7 +11,7 @@ export interface GremlinFixChange { export function isGremlinFixable(match: GremlinMatch) { return ( - match.kind === 'mixed-indentation' || + match.kind !== 'character' || GREMLIN_DEFINITIONS_BY_CODE_POINT.has(match.codePoint) ); } @@ -20,8 +20,10 @@ export function buildGremlinFixChanges( matches: readonly GremlinMatch[], lineText: string, lineFrom: number, + indentSize = DEFAULT_INDENT_SIZE, ): GremlinFixChange[] { const changes: GremlinFixChange[] = []; + const effectiveIndentSize = normalizeIndentSize(indentSize); for (const match of matches) { if (match.kind === 'character') { @@ -44,7 +46,10 @@ export function buildGremlinFixChanges( ); changes.push({ from: match.from, - insert: normalizeMixedIndentation(indentation), + insert: + match.kind === 'mixed-indentation' + ? normalizeMixedIndentation(indentation, effectiveIndentSize) + : normalizeListIndentation(indentation, effectiveIndentSize), to: match.to, }); } @@ -52,23 +57,41 @@ export function buildGremlinFixChanges( return changes; } -function normalizeMixedIndentation(indentation: string) { +function normalizeMixedIndentation( + indentation: string, + indentSize: number, +) { if (/^\t+ +$/.test(indentation)) { return indentation.replace(/ +$/, ''); } - return ' '.repeat(indentationWidth(indentation)); + return ' '.repeat(indentationWidth(indentation, indentSize)); } -function indentationWidth(indentation: string) { +function normalizeListIndentation( + indentation: string, + indentSize: number, +) { + const width = indentationWidth(indentation, indentSize); + const normalizedWidth = Math.round(width / indentSize) * indentSize; + return ' '.repeat(normalizedWidth); +} + +function indentationWidth(indentation: string, indentSize: number) { let width = 0; for (const character of indentation) { width = character === '\t' - ? width + MARKDOWN_TAB_SIZE - (width % MARKDOWN_TAB_SIZE) + ? width + indentSize - (width % indentSize) : width + 1; } return width; } + +function normalizeIndentSize(indentSize: number) { + return Number.isInteger(indentSize) && indentSize > 0 + ? indentSize + : DEFAULT_INDENT_SIZE; +} diff --git a/src/main.ts b/src/main.ts index 46d250e..2757032 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,4 @@ -import type { Extension } from '@codemirror/state'; +import type { EditorState, Extension } from '@codemirror/state'; import { addIcon, Notice, @@ -12,6 +12,7 @@ import { createGremlinsEditorExtension } from './editor-extension.ts'; import { buildGremlinFixChanges } from './fix.ts'; import { findGremlinAtPosition } from './match-position.ts'; import { GREMLIN_ICON_ID, GREMLIN_ICON_SVG } from './gremlin-icon.ts'; +import { isMarkdownListItem } from './markdown-context.ts'; import { formatGremlinTooltip } from './presentation.ts'; import { DEFAULT_SETTINGS, @@ -19,6 +20,8 @@ import { } from './settings-model.ts'; import { GremlinsSettingTab } from './settings-tab.ts'; +const DEFAULT_INDENT_SIZE = 4; + export default class GremlinsPlugin extends Plugin { settings: GremlinsSettings = DEFAULT_SETTINGS; private readonly editorExtensions: Extension[] = []; @@ -80,13 +83,29 @@ export default class GremlinsPlugin extends Plugin { const cursor = editor.getCursor(); const lineText = editor.getLine(cursor.line); + const editorState = getEditorState(editor); + const indentSize = getEditorIndentSize(editorState); + const isListItem = editorState + ? isMarkdownListItem( + editorState, + lineText, + editor.posToOffset({ ch: 0, line: cursor.line }), + ) + : false; const matches = detectLineGremlins( lineText, 0, cursor.line, this.settings, + indentSize, + isListItem, + ); + const changes = buildGremlinFixChanges( + matches, + lineText, + 0, + indentSize, ); - const changes = buildGremlinFixChanges(matches, lineText, 0); if (changes.length === 0) { return false; } @@ -110,11 +129,23 @@ export default class GremlinsPlugin extends Plugin { private inspectGremlinAtCursor(editor: Editor, checking: boolean) { const cursor = editor.getCursor(); + const lineText = editor.getLine(cursor.line); + const editorState = getEditorState(editor); + const indentSize = getEditorIndentSize(editorState); + const isListItem = editorState + ? isMarkdownListItem( + editorState, + lineText, + editor.posToOffset({ ch: 0, line: cursor.line }), + ) + : false; const matches = detectLineGremlins( - editor.getLine(cursor.line), + lineText, 0, cursor.line, this.settings, + indentSize, + isListItem, ); const match = findGremlinAtPosition(matches, cursor.ch, 1) ?? @@ -129,3 +160,14 @@ export default class GremlinsPlugin extends Plugin { return true; } } + +function getEditorState(editor: Editor) { + return (editor as Editor & { cm?: { state?: EditorState } }).cm?.state ?? null; +} + +function getEditorIndentSize(state: EditorState | null) { + const tabSize = state?.tabSize; + return tabSize && Number.isInteger(tabSize) + ? tabSize + : DEFAULT_INDENT_SIZE; +} diff --git a/src/markdown-context.ts b/src/markdown-context.ts new file mode 100644 index 0000000..6f825ab --- /dev/null +++ b/src/markdown-context.ts @@ -0,0 +1,34 @@ +import { syntaxTree } from '@codemirror/language'; +import type { EditorState } from '@codemirror/state'; + +type MarkdownSyntaxNode = ReturnType['topNode']; + +const LIST_MARKER_NODE = /(?:^|_)formatting-list(?:_|$)/; + +export function markdownSyntaxTreeChanged( + startState: EditorState, + state: EditorState, +) { + return syntaxTree(startState) !== syntaxTree(state); +} + +export function isMarkdownListItem( + state: EditorState, + lineText: string, + lineFrom: number, +) { + const indentationLength = /^[\t ]*/.exec(lineText)?.[0].length ?? 0; + let node: MarkdownSyntaxNode | null = syntaxTree(state).resolveInner( + lineFrom + indentationLength, + 1, + ); + + while (node) { + if (LIST_MARKER_NODE.test(node.name)) { + return true; + } + node = node.parent; + } + + return false; +} diff --git a/src/presentation.ts b/src/presentation.ts index 43c1c83..fe6a68d 100644 --- a/src/presentation.ts +++ b/src/presentation.ts @@ -17,6 +17,10 @@ export function formatGremlinTooltip(match: GremlinMatch) { return `Mixed indentation · Leading indentation contains both tabs and spaces · ${severity}`; } + if (match.kind === 'list-indentation') { + return `List indentation · ${match.count} leading ${pluralizeSpaces(match.count)} do not match the configured indent width · ${severity}`; + } + const count = match.count > 1 ? `${match.count} ` : ''; const name = `${match.name}${match.count > 1 ? 's' : ''}`; return `${count}${name} · Unicode ${formatCodePoint(match.codePoint)} · ${severity}`; @@ -32,6 +36,10 @@ export function highestSeverity( ); } +function pluralizeSpaces(count: number) { + return count === 1 ? 'space' : 'spaces'; +} + function capitalize(value: string) { return value.charAt(0).toUpperCase() + value.slice(1); } diff --git a/src/settings-model.ts b/src/settings-model.ts index 14de3c7..c739d44 100644 --- a/src/settings-model.ts +++ b/src/settings-model.ts @@ -2,6 +2,7 @@ export interface GremlinsSettings { enableClickToFix: boolean; showDangerousCharacters: boolean; showGutterIcons: boolean; + showListIndentation: boolean; showMixedIndentation: boolean; showTypographicCharacters: boolean; } @@ -10,6 +11,7 @@ export const DEFAULT_SETTINGS: GremlinsSettings = { enableClickToFix: false, showDangerousCharacters: true, showGutterIcons: true, + showListIndentation: false, showMixedIndentation: true, showTypographicCharacters: false, }; diff --git a/src/settings-tab.ts b/src/settings-tab.ts index 6d931cc..fcfd72e 100644 --- a/src/settings-tab.ts +++ b/src/settings-tab.ts @@ -52,6 +52,19 @@ export class GremlinsSettingTab extends PluginSettingTab { ), ); + new Setting(containerEl) + .setName('List indentation') + .setDesc( + "Highlight space-indented Markdown list items whose indentation is not a multiple of Obsidian's indent visual width.", + ) + .addToggle((toggle) => + toggle + .setValue(this.controller.settings.showListIndentation) + .onChange((value) => + this.updateSettings({ showListIndentation: value }), + ), + ); + new Setting(containerEl) .setName('Typographic punctuation') .setDesc( diff --git a/src/types.ts b/src/types.ts index e50bc45..62ef941 100644 --- a/src/types.ts +++ b/src/types.ts @@ -35,6 +35,19 @@ export interface MixedIndentationGremlinMatch { zeroWidth: false; } +export interface ListIndentationGremlinMatch { + codePoint: null; + count: number; + from: number; + kind: 'list-indentation'; + line: number; + name: 'list indentation'; + severity: 'warning'; + to: number; + zeroWidth: false; +} + export type GremlinMatch = | CharacterGremlinMatch + | ListIndentationGremlinMatch | MixedIndentationGremlinMatch; diff --git a/styles.css b/styles.css index c9e8d07..21e46e2 100644 --- a/styles.css +++ b/styles.css @@ -38,7 +38,8 @@ color: var(--color-blue); } -.gremlins-mixed-indentation { +.gremlins-mixed-indentation, +.gremlins-list-indentation { background-image: repeating-linear-gradient( 135deg, transparent 0, diff --git a/tests/detect.test.ts b/tests/detect.test.ts index 6e18638..77dc4ce 100644 --- a/tests/detect.test.ts +++ b/tests/detect.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { detectGremlins } from '../src/detect.ts'; +import { detectGremlins, detectLineGremlins } from '../src/detect.ts'; import { DEFAULT_SETTINGS } from '../src/settings-model.ts'; describe('detectGremlins', () => { @@ -110,4 +110,98 @@ describe('detectGremlins', () => { assert.deepEqual(matches, []); }); + + it('flags list items indented by one, two, or three spaces when enabled', () => { + const matches = detectGremlins( + ' - one\n * two\n 1. three\n - four\n\t- tab', + { + ...DEFAULT_SETTINGS, + showListIndentation: true, + }, + 4, + ); + + assert.deepEqual( + matches.map(({ count, kind, line }) => ({ count, kind, line })), + [ + { count: 1, kind: 'list-indentation', line: 0 }, + { count: 2, kind: 'list-indentation', line: 1 }, + { count: 3, kind: 'list-indentation', line: 2 }, + ], + ); + }); + + it('flags invalid indentation at deeper list levels', () => { + const matches = detectGremlins( + ' - five\n - six\n - seven\n - eight', + { + ...DEFAULT_SETTINGS, + showListIndentation: true, + }, + 4, + ); + + assert.deepEqual( + matches.map(({ count, line }) => ({ count, line })), + [ + { count: 5, line: 0 }, + { count: 6, line: 1 }, + { count: 7, line: 2 }, + ], + ); + }); + + it('uses the current Obsidian indent width for list indentation', () => { + const matches = detectGremlins( + ' - one\n - two\n - three\n - four', + { + ...DEFAULT_SETTINGS, + showListIndentation: true, + }, + 2, + ); + + assert.deepEqual( + matches.map(({ count, line }) => ({ count, line })), + [ + { count: 1, line: 0 }, + { count: 3, line: 2 }, + ], + ); + }); + + it('does not treat indented non-list content as malformed list indentation', () => { + const matches = detectGremlins( + ' prose\n ```ts\n > quote\n code()', + { + ...DEFAULT_SETTINGS, + showListIndentation: true, + }, + 4, + ); + + assert.deepEqual(matches, []); + }); + + + it('respects the Markdown parser when a list-looking line is not a list', () => { + const matches = detectLineGremlins( + ' - literal code', + 0, + 0, + { + ...DEFAULT_SETTINGS, + showListIndentation: true, + }, + 4, + false, + ); + + assert.deepEqual(matches, []); + }); + it('keeps list-indentation warnings disabled by default', () => { + const matches = detectGremlins(' - nested', DEFAULT_SETTINGS, 4); + + assert.deepEqual(matches, []); + }); }); diff --git a/tests/fix.test.ts b/tests/fix.test.ts index e235b01..e22aee4 100644 --- a/tests/fix.test.ts +++ b/tests/fix.test.ts @@ -27,6 +27,20 @@ function characterMatch( }; } +function listIndentationMatch(count: number): GremlinMatch { + return { + codePoint: null, + count, + from: 20, + kind: 'list-indentation', + line: 2, + name: 'list indentation', + severity: 'warning', + to: 20 + count, + zeroWidth: false, + }; +} + describe('isGremlinFixable', () => { it('provides an automatic fix for every defined gremlin character', () => { for (const definition of GREMLIN_DEFINITIONS) { @@ -37,6 +51,10 @@ describe('isGremlinFixable', () => { ); } }); + + it('provides an automatic fix for malformed list indentation', () => { + assert.equal(isGremlinFixable(listIndentationMatch(2)), true); + }); }); describe('buildGremlinFixChanges', () => { @@ -162,6 +180,59 @@ describe('buildGremlinFixChanges', () => { ]); }); + it('rounds list indentation to the nearest four-space level', () => { + const cases = [ + { indentation: ' ', normalized: '' }, + { indentation: ' ', normalized: ' ' }, + { indentation: ' ', normalized: ' ' }, + { indentation: ' ', normalized: ' ' }, + { indentation: ' ', normalized: ' ' }, + { indentation: ' ', normalized: ' ' }, + ]; + + for (const { indentation, normalized } of cases) { + const match = listIndentationMatch(indentation.length); + + assert.deepEqual( + buildGremlinFixChanges( + [match], + `${indentation}- Item`, + 20, + 4, + ), + [{ from: 20, insert: normalized, to: 20 + indentation.length }], + ); + } + }); + + it('uses the current indent width while preserving space indentation', () => { + const match = listIndentationMatch(3); + + assert.deepEqual( + buildGremlinFixChanges([match], ' - Item', 20, 2), + [{ from: 20, insert: ' ', to: 23 }], + ); + }); + + it('uses the current indent width when measuring mixed indentation', () => { + const match: GremlinMatch = { + codePoint: null, + count: 2, + from: 20, + kind: 'mixed-indentation', + line: 2, + name: 'mixed indentation', + severity: 'warning', + to: 22, + zeroWidth: false, + }; + + assert.deepEqual( + buildGremlinFixChanges([match], ' \tItem', 20, 2), + [{ from: 20, insert: ' ', to: 22 }], + ); + }); + it('removes zero-width, joining, and bidi controls', () => { const line = `a\u200b\u200c\u202eb`; diff --git a/tests/presentation.test.ts b/tests/presentation.test.ts index b29bc0f..f364840 100644 --- a/tests/presentation.test.ts +++ b/tests/presentation.test.ts @@ -51,6 +51,23 @@ describe('presentation helpers', () => { ); }); + it('describes malformed list indentation without a Unicode code point', () => { + assert.equal( + formatGremlinTooltip({ + codePoint: null, + count: 2, + from: 0, + kind: 'list-indentation', + line: 0, + name: 'list indentation', + severity: 'warning', + to: 2, + zeroWidth: false, + }), + 'List indentation · 2 leading spaces do not match the configured indent width · Warning', + ); + }); + it('selects the highest severity for a gutter marker', () => { assert.equal(highestSeverity(['info', 'error', 'warning']), 'error'); assert.equal(highestSeverity(['info', 'warning']), 'warning'); diff --git a/tests/settings.test.ts b/tests/settings.test.ts index 9cf4083..7ef3473 100644 --- a/tests/settings.test.ts +++ b/tests/settings.test.ts @@ -7,4 +7,8 @@ describe('gremlins settings', () => { it('keeps click-to-fix disabled by default', () => { assert.equal(DEFAULT_SETTINGS.enableClickToFix, false); }); + + it('keeps list-indentation warnings disabled by default', () => { + assert.equal(DEFAULT_SETTINGS.showListIndentation, false); + }); }); diff --git a/versions.json b/versions.json index 74b104a..e051c26 100644 --- a/versions.json +++ b/versions.json @@ -4,5 +4,6 @@ "0.2.1": "1.5.0", "0.2.2": "1.5.0", "0.2.3": "1.5.0", - "0.2.4": "1.5.0" + "0.2.4": "1.5.0", + "0.3.0": "1.5.0" }