112 lines
3.6 KiB
TypeScript
112 lines
3.6 KiB
TypeScript
/**
|
||||
|
|
* [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)|<pre[\s>]|<code[\s>]/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) };
|
|||
|
|
}
|