/** * [Gitea #107] Decide whether a multi-line paste looks like source code, so the * composer can OFFER to turn it into a code block (never converts on its own). * Pure and unit-tested; tuned to keep prose, lists, quotes and URL lists out. */ export type CodeGuess = { /** Offer "Paste as code block?" */ isCode: boolean; /** Language for `class="language-…"`, only when fairly sure. */ lang?: string; }; const KEYWORDS = [ /\bfunction\b/, /\bconst\b/, /\blet\b/, /\bvar\b/, /\breturn\b/, /\bimport\b/, /\bexport\b/, /\bclass\b/, /\bdef\b/, /\bfn\b/, /\bpub\b/, /\bstruct\b/, /\bif \(/, /\bfor \(/, /\bwhile \(/, /#include\b/, /\bSELECT\b/i, /\bFROM\b/i, /<\?php/, /<\//, ]; const MONO_HTML = /font-family:[^;"]*(mono|courier|consolas|menlo)|]|]/i; const indentOf = (line: string): number => { const m = /^[ \t]*/.exec(line); return m ? m[0].replace(/\t/g, ' ').length : 0; }; export function guessLanguage(text: string): string | undefined { if ( /^\s*(SELECT|INSERT|UPDATE|DELETE|CREATE TABLE)\b/im.test(text) && /\bFROM\b|\bINTO\b|\bSET\b|\(/i.test(text) ) return 'sql'; if (/\bpub fn\b|\bfn \w+\(.*\)\s*(->|\{)|\blet mut\b/.test(text)) return 'rust'; if (/^\s*#include\s*[<"]/m.test(text)) return 'c'; if ( /^\s*def \w+\(.*\):\s*$/m.test(text) || (/^\s*(from \w+ )?import \w+\s*$/m.test(text) && /:\s*$/m.test(text)) ) return 'python'; if (/:\s*(string|number|boolean)\b|\binterface \w+|\btype \w+ =/.test(text) && /[{;]/.test(text)) return 'ts'; if (/\b(const|let|var)\b|=>/.test(text) && /[{;]/.test(text)) return 'js'; if (/^\s*<\?php/m.test(text)) return 'php'; return undefined; } export function looksLikeCode(text: string, html?: string): CodeGuess { const lines = text.replace(/\r\n?/g, '\n').split('\n'); const nonEmpty = lines.filter((l) => l.trim() !== ''); if (nonEmpty.length < 3) return { isCode: false }; const n = nonEmpty.length; const share = (pred: (l: string) => boolean) => nonEmpty.filter(pred).length / n; // ── Prose guards ── if (share((l) => /^\s*>/.test(l)) > 0.5) return { isCode: false }; // quoted reply if (nonEmpty.every((l) => /^\s*(<)?https?:\/\/\S+(>)?\s*$/.test(l))) return { isCode: false }; const avgLen = nonEmpty.reduce((sum, l) => sum + l.length, 0) / n; if (avgLen > 90 && share((l) => (l.match(/[.!?] /g) ?? []).length >= 2) > 0.5) { return { isCode: false }; } if (share((l) => /^[\p{L}\p{N}\s'’",.!?-]*$/u.test(l)) > 0.6) return { isCode: false }; if (share((l) => /^\s*([-*+]|\d+[.)])\s+\S/.test(l)) > 0.6) return { isCode: false }; // list // ── Signals: need two ── let signals = 0; const indented = share((l) => /^( {2,}|\t)/.test(l)); let depthChanges = 0; for (let i = 1; i < nonEmpty.length; i += 1) { if (indentOf(nonEmpty[i]) !== indentOf(nonEmpty[i - 1])) depthChanges += 1; } if (indented >= 0.4 && depthChanges >= 2) signals += 1; if (share((l) => /[;{}):,]\s*$/.test(l)) >= 0.3) signals += 1; if ( nonEmpty.filter((l) => /=>|->|::|\(\)|[^=!<>]=[^=]/.test(l)).length >= 2 && /[{}[\]]/.test(text) ) { signals += 1; } if (KEYWORDS.filter((k) => k.test(text)).length >= 2) signals += 1; if (html && MONO_HTML.test(html)) signals += 1; // SQL has few symbols: lines that open with a clause keyword are its shape. if ( share((l) => /^\s*(SELECT|FROM|WHERE|(LEFT |RIGHT |INNER )?JOIN|ORDER BY|GROUP BY|HAVING|INSERT|VALUES|UPDATE|SET|DELETE|LIMIT|AND|OR)\b/.test( l, ), ) >= 0.5 ) { signals += 1; } if (signals < 2) return { isCode: false }; return { isCode: true, lang: guessLanguage(text) }; }