UNPKG

eslint-plugin-obsidianmd

Version:

Validates guidelines for Obsidian plugins

399 lines (398 loc) 15.8 kB
import { DEFAULT_BRANDS } from "./brands.js"; import { DEFAULT_ACRONYMS } from "./acronyms.js"; // Default configuration for sentence case rules const defaultSentenceCaseConfig = {}; // Resolves rule options to configuration values export function resolveSentenceCaseConfig(options) { const config = options[0] ?? defaultSentenceCaseConfig; const { allowAutoFix, ...rest } = config; const evaluatorOptions = rest; return { config, evaluatorOptions, allowAutoFix: allowAutoFix === true, }; } // Creates a reporter function for sentence case violations with caching and auto-fix support export function createSentenceCaseReporter(params) { const cache = new Map(); return (node, value) => { const cached = cache.get(value); const result = cached ?? evaluateSentenceCase(value, params.evaluatorOptions); if (!cached) { cache.set(value, result); } if (result.ok || !result.suggestion) { return; } const suggestion = result.suggestion; if (process.env.OBSIDIANMD_DEBUG_SENTENCE_CASE === "1") { console.debug(params.debugLabel, { value, suggestion }); } if (!params.allowAutoFix) { params.context.report({ node, messageId: params.messageId, }); return; } params.context.report({ node, messageId: params.messageId, fix(fixer) { const customReplacement = params.formatReplacement?.(node, suggestion); const replacement = customReplacement ?? JSON.stringify(suggestion); return fixer.replaceText(node, replacement); }, }); }; } // Type guard to check if an object has a physicalFilename property function hasPhysicalFilename(value) { return typeof value === "object" && value !== null && "physicalFilename" in value; } // Normalizes file paths by converting backslashes to forward slashes function normalizePath(filePath) { return filePath.replace(/\\/g, "/"); } // Retrieves the filename from the ESLint rule context export function getContextFilename(context) { if (hasPhysicalFilename(context) && typeof context.physicalFilename === "string") { return context.physicalFilename; } const filename = context.getFilename(); return typeof filename === "string" ? filename : null; } // Checks if a file path matches the pattern for English locale files export function isEnglishLocalePath(filePath, extensions) { const normalized = normalizePath(filePath); const normalizedExtensions = extensions.map((ext) => ext.replace(/^\./, "")); const extensionPattern = normalizedExtensions.map(escapeRegExp).join("|"); const regex = new RegExp(`(?:^|/)en(?:[._-][^/]+)?(?:/.*)?\\.(?:${extensionPattern})$`, "i"); return regex.test(normalized); } // Type guard to check if an AST node is an expression node export function isExpressionNode(node) { if (!node) return false; switch (node.type) { case "ArrayPattern": case "ObjectPattern": case "AssignmentPattern": case "RestElement": case "TSParameterProperty": return false; default: return true; } } // Unwraps TypeScript type assertions and non-null assertions to get the underlying expression export function unwrapExpression(node) { let current = node; while (current) { if (current.type === "TSAsExpression" || current.type === "TSSatisfiesExpression" || current.type === "TSNonNullExpression") { current = current.expression; continue; } break; } return isExpressionNode(current) ? current : null; } // Escapes special regex characters in a string for safe use in RegExp patterns function escapeRegExp(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } // Checks if a character is alphabetic (A-Z or a-z) function isAlpha(ch) { return /[A-Za-z]/.test(ch); } // Detects if text appears to be a file path function looksLikePath(text) { // Check for relative paths starting with ./ or ../ // Examples: "./config.json", "../src/main.ts" if (/^\.{1,2}\//.test(text)) return true; // Check for paths with slashes and file extensions // Examples: "src/main.ts", "C:\\Users\\file.txt", "/home/user/doc.pdf" if (/[\\/]/.test(text) && /\.[a-z0-9]{1,4}(\b|$)/i.test(text)) return true; return false; } // Determines if a string should be skipped from sentence case validation export function isSkippableString(text) { if (!text) return true; // Inline code or HTML tags // Examples: "Use `npm install`", "<b>Bold text</b>", "</div>" if (text.includes("`") || /<\/?[a-z][^>]*>/i.test(text)) return true; // Template placeholders // Examples: "Hello ${name}", "Welcome {user}", "File %s saved", "%1$s items" if (/(\$\{[^}]+\}|\{[^}]+\}|%\d*\$?s|%s)/.test(text)) return true; // File paths if (looksLikePath(text)) return true; // Keyboard shortcuts // Examples: "Ctrl+S", "Cmd + A", "Alt+Tab", "⌘+C", "Shift + Enter" if (/(Ctrl|Cmd|Alt|Shift|Option|⌘|⌥|⌃|⇧)\s*\+\s*[A-Za-z]/.test(text)) return true; // Version numbers // Examples: "v1.2.3", "2.0.0", "1.0-beta", "3_2_1" if (/^v?\d+(?:[._-]\d+)+$/.test(text)) return true; // All-caps identifiers // Examples: "API_KEY", "MAX_SIZE", "HTTP_200" if (/^[A-Z0-9_]+$/.test(text)) return true; return false; } // Normalizes and prepares options with defaults for sentence case evaluation function normalizeOptions(options) { const brands = (options?.brands ?? DEFAULT_BRANDS).slice().sort((a, b) => b.length - a.length); const acronyms = new Set((options?.acronyms ?? DEFAULT_ACRONYMS).map((s) => s.toUpperCase())); const ignoreWords = new Set((options?.ignoreWords ?? []).map((s) => s)); const ignoreRegex = (options?.ignoreRegex ?? []).map((p) => new RegExp(p)); const mode = options?.mode ?? "loose"; const enforceCamelCaseLower = options?.enforceCamelCaseLower === true; return { brands, acronyms, ignoreWords, ignoreRegex, mode, enforceCamelCaseLower }; } // Tests if text matches any of the ignore regex patterns function shouldIgnoreByRegex(text, regexes) { for (const regex of regexes) { regex.lastIndex = 0; const matches = regex.test(text); regex.lastIndex = 0; if (matches) { return true; } } return false; } // Finds all occurrences of a brand name (case-insensitive) and records their ranges and canonical casing function collectBrandMatches(sentence, brands) { const matches = []; for (const brand of brands) { const pattern = new RegExp(`(^|[^A-Za-z0-9])(${escapeRegExp(brand)})(?=$|[^A-Za-z0-9])`, "gi"); let m; while ((m = pattern.exec(sentence))) { const start = m.index + m[1].length; const end = start + brand.length; if (matches.some((existing) => !(end <= existing.start || start >= existing.end))) { continue; } matches.push({ start, end, canonical: brand }); } } matches.sort((a, b) => a.start - b.start); return matches; } // Merges overlapping or adjacent character ranges into single ranges function mergeRanges(ranges) { if (ranges.length === 0) return []; const sorted = ranges.slice().sort((a, b) => a[0] - b[0]); const merged = [sorted[0].slice()]; for (let i = 1; i < sorted.length; i++) { const last = merged[merged.length - 1]; const cur = sorted[i]; if (cur[0] <= last[1]) { last[1] = Math.max(last[1], cur[1]); } else { merged.push(cur.slice()); } } return merged; } // Checks if a character index falls within any of the given ranges function rangeContains(ranges, index) { return ranges.some((r) => index >= r[0] && index < r[1]); } // Detects if a word uses camelCase or PascalCase naming convention function isCamelOrPascal(word) { // Has uppercase after first char and contains lowercase // Examples: "camelCase", "PascalCase", "myVariableName" but NOT "ALLCAPS" or "lowercase" return /[A-Z]/.test(word.slice(1)) && /[a-z]/.test(word); } // Replaces characters in an array at a specific position with a replacement string function writeToken(chars, start, replacement, length) { for (let i = 0; i < length; i++) { chars[start + i] = replacement[i] ?? chars[start + i]; } } // Applies sentence case transformation to a single sentence function transformSentence(sentence, opts) { // Find all brand occurrences to preserve their casing const brandMatches = collectBrandMatches(sentence, opts.brands); const brandRanges = mergeRanges(brandMatches.map((match) => [match.start, match.end])); const chars = sentence.split(""); // Capitalize first letter of sentence unless it's in a brand let firstAlpha = -1; for (let i = 0; i < chars.length; i++) { if (isAlpha(chars[i])) { firstAlpha = i; break; } } if (firstAlpha >= 0 && !rangeContains(brandRanges, firstAlpha)) { // Find the token containing firstAlpha const tokenRe = /[A-Za-z0-9][A-Za-z0-9.\-]*/g; let tokenStart = firstAlpha; let tokenEnd = firstAlpha + 1; let tm; while ((tm = tokenRe.exec(sentence))) { const s = tm.index; const e = s + tm[0].length; if (firstAlpha >= s && firstAlpha < e) { tokenStart = s; tokenEnd = e; break; } } const firstToken = sentence.slice(tokenStart, tokenEnd); const upperToken = firstToken.toUpperCase(); const hasLeadingContent = sentence.slice(0, firstAlpha).trim().length > 0; if (opts.acronyms.has(upperToken)) { writeToken(chars, tokenStart, upperToken, firstToken.length); } else if (!opts.enforceCamelCaseLower && opts.mode === "loose" && isCamelOrPascal(firstToken)) { // Preserve camelCase/PascalCase tokens when loose mode allows it } else if (hasLeadingContent) { for (let j = tokenStart; j < tokenEnd; j++) { chars[j] = firstToken[j - tokenStart].toLowerCase(); } } else { chars[firstAlpha] = chars[firstAlpha].toUpperCase(); for (let j = firstAlpha + 1; j < tokenEnd; j++) { chars[j] = chars[j].toLowerCase(); } } } // Process remaining tokens const tokenRe = /[A-Za-z0-9][A-Za-z0-9.\-]*/g; let m; while ((m = tokenRe.exec(sentence))) { const start = m.index; const token = m[0]; const end = start + token.length; // Skip if token overlaps brand if (brandMatches.some((match) => !(end <= match.start || start >= match.end))) continue; // Skip first token if it's the first alpha token (already handled above) if (firstAlpha >= 0 && start <= firstAlpha && firstAlpha < end) continue; const upperToken = token.toUpperCase(); if (opts.acronyms.has(upperToken)) { writeToken(chars, start, upperToken, token.length); continue; } const dottedMatch = token.match(/^([A-Za-z](?:\.[A-Za-z])+)(\.)?$/); if (dottedMatch) { const base = dottedMatch[1]; const trailingDot = dottedMatch[2] ?? ""; const parts = base.split("."); const allUpper = parts.every((part) => part === part.toUpperCase()); if (allUpper) { const canonical = parts.map((part) => part.toUpperCase()).join(".") + trailingDot; writeToken(chars, start, canonical, token.length); continue; } } // Ignore configured words if (opts.ignoreWords.has(token)) continue; const hasHyphen = token.includes("-"); // Handle hyphenated words if (hasHyphen) { const hyphenParts = token.split("-"); const newParts = hyphenParts.map((part) => { const partUpper = part.toUpperCase(); if (opts.acronyms.has(partUpper)) return partUpper; // Preserve brand casing if (opts.brands.includes(part)) return part; // Lowercase other parts return part.toLowerCase(); }); const newToken = newParts.join("-"); if (newToken !== token) { for (let i = 0; i < token.length; i++) { chars[start + i] = newToken[i] ?? chars[start + i]; } } continue; } // Check if token should be preserved if (opts.brands.includes(token)) continue; // In loose mode, preserve CamelCase unless enforceCamelCaseLower is set if (!opts.enforceCamelCaseLower && opts.mode === "loose" && isCamelOrPascal(token)) continue; // Lowercase the token const newToken = token.toLowerCase(); if (newToken !== token) { for (let i = 0; i < token.length; i++) { chars[start + i] = newToken[i] ?? chars[start + i]; } } } // Restore canonical brand casing for (const match of brandMatches) { writeToken(chars, match.start, match.canonical, match.canonical.length); } return chars.join(""); } // Generates a sentence case suggestion for text using normalized options function sentenceCaseSuggestionWithOptions(text, opts) { if (!text.trim()) return text; if (shouldIgnoreByRegex(text, opts.ignoreRegex)) return text; const segments = []; let lastIndex = 0; const delimiterRe = /([.?!]+)(\s+|$)/g; let match; while ((match = delimiterRe.exec(text))) { const punctStart = match.index; const punct = match[1]; const whitespace = match[2] ?? ""; const core = text.slice(lastIndex, punctStart); segments.push({ core, punct, whitespace }); lastIndex = delimiterRe.lastIndex; } if (lastIndex < text.length) { const remainder = text.slice(lastIndex); const trailingWhitespaceMatch = remainder.match(/\s+$/); const whitespace = trailingWhitespaceMatch ? trailingWhitespaceMatch[0] : ""; const core = whitespace ? remainder.slice(0, -whitespace.length) : remainder; segments.push({ core, punct: "", whitespace }); } const transformed = segments .map(({ core, punct, whitespace }) => { const out = transformSentence(core, opts); return out + punct + whitespace; }) .join(""); return transformed; } // Generates a sentence case suggestion for text export function sentenceCaseSuggestion(text, options) { const opts = normalizeOptions(options); return sentenceCaseSuggestionWithOptions(text, opts); } // Evaluates if text follows sentence case rules and provides a suggestion if not export function evaluateSentenceCase(text, options) { if (isSkippableString(text)) return { ok: true }; const opts = normalizeOptions(options); if (shouldIgnoreByRegex(text, opts.ignoreRegex)) return { ok: true }; const suggestion = sentenceCaseSuggestionWithOptions(text, opts); return { ok: suggestion === text, suggestion: suggestion === text ? undefined : suggestion }; }