diff --git a/Makefile b/Makefile index 27b2c30295..b1fdfce6cc 100644 --- a/Makefile +++ b/Makefile @@ -661,6 +661,10 @@ generate-gitignore: ## update gitignore files generate-images: | node_modules ## generate images cd tools && node generate-images.ts $(TAGS) +.PHONY: generate-codemirror-languages +generate-codemirror-languages: | node_modules ## generate codemirror-languages.json + node tools/generate-codemirror-languages.ts + .PHONY: generate-manpage generate-manpage: ## generate manpage @[ -f gitea ] || make backend diff --git a/assets/codemirror-languages.json b/assets/codemirror-languages.json new file mode 100644 index 0000000000..0e4639c8ba --- /dev/null +++ b/assets/codemirror-languages.json @@ -0,0 +1,1287 @@ +[ + { + "name": "APL", + "extensions": [ + "apl", + "dyalog" + ], + "filenames": [] + }, + { + "name": "ASN.1", + "extensions": [ + "asn", + "asn1" + ], + "filenames": [] + }, + { + "name": "Brainfuck", + "extensions": [ + "b", + "bf" + ], + "filenames": [] + }, + { + "name": "C", + "extensions": [ + "c", + "cats", + "h", + "idc" + ], + "filenames": [] + }, + { + "name": "C#", + "extensions": [ + "cs", + "cake", + "csx", + "linq" + ], + "filenames": [] + }, + { + "name": "C++", + "extensions": [ + "cpp", + "c++", + "cc", + "cp", + "cppm", + "cxx", + "h", + "h++", + "hh", + "hpp", + "hxx", + "inl", + "ino", + "ipp", + "ixx", + "re", + "tcc", + "tpp", + "txx" + ], + "filenames": [] + }, + { + "name": "Clojure", + "extensions": [ + "clj", + "bb", + "boot", + "cl2", + "cljc", + "cljs", + "cljscm", + "cljx", + "hic" + ], + "filenames": [ + "riemann.config" + ] + }, + { + "name": "CMake", + "extensions": [ + "cmake" + ], + "filenames": [ + "CMakeLists.txt" + ] + }, + { + "name": "Cobol", + "extensions": [ + "cob", + "cbl", + "ccp", + "cobol", + "cpy" + ], + "filenames": [] + }, + { + "name": "CoffeeScript", + "extensions": [ + "coffee", + "_coffee", + "cake", + "cjsx", + "iced" + ], + "filenames": [ + "Cakefile" + ] + }, + { + "name": "Common Lisp", + "extensions": [ + "lisp", + "asd", + "cl", + "l", + "lsp", + "ny", + "podsl", + "sexp" + ], + "filenames": [] + }, + { + "name": "CQL", + "extensions": [ + "cql" + ], + "filenames": [] + }, + { + "name": "Crystal", + "extensions": [ + "cr" + ], + "filenames": [] + }, + { + "name": "CSS", + "extensions": [ + "css" + ], + "filenames": [] + }, + { + "name": "Cypher", + "extensions": [ + "cyp", + "cypher" + ], + "filenames": [] + }, + { + "name": "Cython", + "extensions": [ + "pyx", + "pxd", + "pxi" + ], + "filenames": [] + }, + { + "name": "D", + "extensions": [ + "d", + "di" + ], + "filenames": [] + }, + { + "name": "Dart", + "extensions": [ + "dart" + ], + "filenames": [] + }, + { + "name": "diff", + "extensions": [ + "diff", + "patch" + ], + "filenames": [] + }, + { + "name": "Dockerfile", + "extensions": [ + "dockerfile", + "containerfile" + ], + "filenames": [ + "Containerfile", + "Dockerfile" + ] + }, + { + "name": "Dylan", + "extensions": [ + "dylan", + "dyl", + "intr", + "lid" + ], + "filenames": [] + }, + { + "name": "EBNF", + "extensions": [ + "ebnf" + ], + "filenames": [] + }, + { + "name": "ECL", + "extensions": [ + "ecl", + "eclxml" + ], + "filenames": [] + }, + { + "name": "edn", + "extensions": [ + "edn" + ], + "filenames": [] + }, + { + "name": "Eiffel", + "extensions": [ + "e" + ], + "filenames": [] + }, + { + "name": "Elm", + "extensions": [ + "elm" + ], + "filenames": [] + }, + { + "name": "Erlang", + "extensions": [ + "erl", + "app", + "es", + "escript", + "hrl", + "xrl", + "yrl" + ], + "filenames": [ + "Emakefile", + "rebar.config", + "rebar.config.lock", + "rebar.lock" + ] + }, + { + "name": "F#", + "extensions": [ + "fs", + "fsi", + "fsx" + ], + "filenames": [] + }, + { + "name": "Factor", + "extensions": [ + "factor" + ], + "filenames": [ + ".factor-boot-rc", + ".factor-rc" + ] + }, + { + "name": "Forth", + "extensions": [ + "fth", + "4th", + "f", + "for", + "forth", + "fr", + "frt", + "fs" + ], + "filenames": [] + }, + { + "name": "Fortran", + "extensions": [ + "f", + "f77", + "for", + "fpp" + ], + "filenames": [] + }, + { + "name": "Gherkin", + "extensions": [ + "feature", + "story" + ], + "filenames": [] + }, + { + "name": "Go", + "extensions": [ + "go" + ], + "filenames": [] + }, + { + "name": "Groovy", + "extensions": [ + "groovy", + "grt", + "gtpl", + "gvy" + ], + "filenames": [ + "Jenkinsfile" + ] + }, + { + "name": "Haskell", + "extensions": [ + "hs", + "hs-boot", + "hsc" + ], + "filenames": [] + }, + { + "name": "Haxe", + "extensions": [ + "hx", + "hxsl" + ], + "filenames": [] + }, + { + "name": "HTML", + "extensions": [ + "html", + "hta", + "htm", + "inc", + "xht", + "xhtml" + ], + "filenames": [] + }, + { + "name": "HTTP", + "extensions": [ + "http" + ], + "filenames": [] + }, + { + "name": "HXML", + "extensions": [ + "hxml" + ], + "filenames": [] + }, + { + "name": "IDL", + "extensions": [ + "pro", + "dlm" + ], + "filenames": [] + }, + { + "name": "Java", + "extensions": [ + "java", + "jav", + "jsh" + ], + "filenames": [] + }, + { + "name": "JavaScript", + "extensions": [ + "js", + "cjs", + "jsx", + "mjs" + ], + "filenames": [ + "Jakefile" + ] + }, + { + "name": "Jinja", + "extensions": [ + "jinja", + "j2", + "jinja2" + ], + "filenames": [] + }, + { + "name": "JSON", + "extensions": [ + "json", + "4DForm", + "4DProject", + "avsc", + "geojson", + "gltf", + "har", + "ice", + "JSON-tmLanguage", + "jsonl", + "mcmeta", + "sarif", + "tact", + "tfstate", + "topojson", + "webapp", + "webmanifest", + "yy", + "yyp" + ], + "filenames": [ + ".all-contributorsrc", + ".arcconfig", + ".auto-changelog", + ".c8rc", + ".htmlhintrc", + ".imgbotconfig", + ".nycrc", + ".tern-config", + ".tern-project", + ".watchmanconfig", + "MODULE.bazel.lock", + "Package.resolved", + "Pipfile.lock", + "bun.lock", + "composer.lock", + "deno.lock", + "flake.lock", + "mcmod.info" + ] + }, + { + "name": "Julia", + "extensions": [ + "jl" + ], + "filenames": [] + }, + { + "name": "Kotlin", + "extensions": [ + "kt", + "ktm", + "kts" + ], + "filenames": [] + }, + { + "name": "LaTeX", + "extensions": [ + "tex", + "aux", + "bbx", + "cbx", + "cls", + "dtx", + "ins", + "lbx", + "ltx", + "mkii", + "mkiv", + "mkvi", + "sty", + "toc" + ], + "filenames": [] + }, + { + "name": "LESS", + "extensions": [ + "less" + ], + "filenames": [] + }, + { + "name": "Liquid", + "extensions": [ + "liquid" + ], + "filenames": [] + }, + { + "name": "LiveScript", + "extensions": [ + "ls", + "_ls" + ], + "filenames": [ + "Slakefile" + ] + }, + { + "name": "Lua", + "extensions": [ + "lua", + "nse", + "p8", + "pd_lua", + "rbxs", + "rockspec", + "wlua" + ], + "filenames": [ + ".luacheckrc" + ] + }, + { + "name": "Markdown", + "extensions": [ + "md", + "livemd", + "markdown", + "mdown", + "mdwn", + "mkd", + "mkdn", + "mkdown", + "ronn", + "scd", + "workbook" + ], + "filenames": [ + "contents.lr" + ] + }, + { + "name": "Modelica", + "extensions": [ + "mo" + ], + "filenames": [] + }, + { + "name": "Nginx", + "extensions": [ + "nginx", + "nginxconf", + "vhost" + ], + "filenames": [ + "nginx.conf" + ] + }, + { + "name": "NSIS", + "extensions": [ + "nsi", + "nsh" + ], + "filenames": [] + }, + { + "name": "Objective-C", + "extensions": [ + "m", + "h" + ], + "filenames": [] + }, + { + "name": "Objective-C++", + "extensions": [ + "mm" + ], + "filenames": [] + }, + { + "name": "OCaml", + "extensions": [ + "ml", + "eliom", + "eliomi", + "ml4", + "mli", + "mll", + "mly" + ], + "filenames": [] + }, + { + "name": "Oz", + "extensions": [ + "oz" + ], + "filenames": [] + }, + { + "name": "Pascal", + "extensions": [ + "pas", + "dfm", + "dpr", + "inc", + "lpr", + "pascal", + "pp" + ], + "filenames": [] + }, + { + "name": "Perl", + "extensions": [ + "pl", + "al", + "perl", + "ph", + "plx", + "pm", + "psgi", + "t" + ], + "filenames": [ + ".latexmkrc", + "Makefile.PL", + "Rexfile", + "ack", + "cpanfile", + "latexmkrc" + ] + }, + { + "name": "PHP", + "extensions": [ + "php", + "aw", + "ctp", + "php3", + "php4", + "php5", + "phps", + "phpt" + ], + "filenames": [ + ".php", + ".php_cs", + ".php_cs.dist", + "Phakefile" + ] + }, + { + "name": "PLSQL", + "extensions": [ + "pls", + "bdy", + "ddl", + "fnc", + "pck", + "pkb", + "pks", + "plb", + "plsql", + "prc", + "spc", + "sql", + "tpb", + "tps", + "trg", + "vw" + ], + "filenames": [] + }, + { + "name": "PowerShell", + "extensions": [ + "ps1", + "psd1", + "psm1" + ], + "filenames": [] + }, + { + "name": "Properties files", + "extensions": [ + "ini", + "cfg", + "cnf", + "dof", + "lektorproject", + "prefs", + "pro", + "properties", + "url" + ], + "filenames": [ + ".buckconfig", + ".coveragerc", + ".flake8", + ".pylintrc", + "HOSTS", + "buildozer.spec", + "hosts", + "pylintrc", + "vlcrc" + ] + }, + { + "name": "ProtoBuf", + "extensions": [ + "proto" + ], + "filenames": [] + }, + { + "name": "Pug", + "extensions": [ + "jade", + "pug" + ], + "filenames": [] + }, + { + "name": "Puppet", + "extensions": [ + "pp" + ], + "filenames": [ + "Modulefile" + ] + }, + { + "name": "Python", + "extensions": [ + "py", + "gyp", + "gypi", + "lmi", + "py3", + "pyde", + "pyi", + "pyp", + "pyt", + "pyw", + "rpy", + "tac", + "wsgi", + "xpy" + ], + "filenames": [ + ".gclient", + "DEPS", + "SConscript", + "SConstruct", + "wscript" + ] + }, + { + "name": "R", + "extensions": [ + "r", + "rd", + "rsx" + ], + "filenames": [ + ".Rprofile", + "expr-dist" + ] + }, + { + "name": "RPM Spec", + "extensions": [ + "spec" + ], + "filenames": [] + }, + { + "name": "Ruby", + "extensions": [ + "rb", + "builder", + "eye", + "gemspec", + "god", + "jbuilder", + "mspec", + "pluginspec", + "podspec", + "prawn", + "rabl", + "rake", + "rbi", + "rbuild", + "rbw", + "rbx", + "ru", + "ruby", + "thor", + "watchr" + ], + "filenames": [ + ".irbrc", + ".pryrc", + ".simplecov", + "Appraisals", + "Berksfile", + "Brewfile", + "Buildfile", + "Capfile", + "Dangerfile", + "Deliverfile", + "Fastfile", + "Gemfile", + "Guardfile", + "Jarfile", + "Mavenfile", + "Podfile", + "Puppetfile", + "Rakefile", + "Snapfile", + "Steepfile", + "Thorfile", + "Vagrantfile", + "buildfile" + ] + }, + { + "name": "Rust", + "extensions": [ + "rs" + ], + "filenames": [] + }, + { + "name": "SAS", + "extensions": [ + "sas" + ], + "filenames": [] + }, + { + "name": "Sass", + "extensions": [ + "sass" + ], + "filenames": [] + }, + { + "name": "Scala", + "extensions": [ + "scala", + "kojo", + "sbt", + "sc" + ], + "filenames": [] + }, + { + "name": "Scheme", + "extensions": [ + "scm", + "sch", + "sld", + "sls", + "sps", + "ss" + ], + "filenames": [] + }, + { + "name": "SCSS", + "extensions": [ + "scss" + ], + "filenames": [] + }, + { + "name": "Shell", + "extensions": [ + "sh", + "bash", + "bats", + "command", + "ksh", + "sbatch", + "slurm", + "tmux", + "tool", + "trigger", + "zsh", + "zsh-theme" + ], + "filenames": [ + ".bash_aliases", + ".bash_functions", + ".bash_history", + ".bash_logout", + ".bash_profile", + ".bashrc", + ".cshrc", + ".envrc", + ".flaskenv", + ".kshrc", + ".login", + ".profile", + ".tmux.conf", + ".xinitrc", + ".xsession", + ".zlogin", + ".zlogout", + ".zprofile", + ".zshenv", + ".zshrc", + "9fs", + "PKGBUILD", + "bash_aliases", + "bash_logout", + "bash_profile", + "bashrc", + "cshrc", + "gradlew", + "kshrc", + "login", + "man", + "mvnw", + "profile", + "tmux.conf", + "xinitrc", + "xsession", + "zlogin", + "zlogout", + "zprofile", + "zshenv", + "zshrc" + ] + }, + { + "name": "Sieve", + "extensions": [ + "sieve" + ], + "filenames": [] + }, + { + "name": "Smalltalk", + "extensions": [ + "st", + "cs" + ], + "filenames": [] + }, + { + "name": "SPARQL", + "extensions": [ + "sparql", + "rq" + ], + "filenames": [] + }, + { + "name": "SQL", + "extensions": [ + "sql", + "ddl", + "inc", + "mysql", + "prc", + "tab", + "udf", + "viw" + ], + "filenames": [] + }, + { + "name": "Squirrel", + "extensions": [ + "nut" + ], + "filenames": [] + }, + { + "name": "Stylus", + "extensions": [ + "styl" + ], + "filenames": [] + }, + { + "name": "Swift", + "extensions": [ + "swift" + ], + "filenames": [] + }, + { + "name": "SystemVerilog", + "extensions": [ + "sv", + "svh", + "vh" + ], + "filenames": [] + }, + { + "name": "Tcl", + "extensions": [ + "tcl", + "adp", + "sdc", + "tm", + "xdc" + ], + "filenames": [ + "owh", + "starfield" + ] + }, + { + "name": "Textile", + "extensions": [ + "textile" + ], + "filenames": [] + }, + { + "name": "TOML", + "extensions": [ + "toml" + ], + "filenames": [ + "Cargo.lock", + "Cargo.toml.orig", + "Gopkg.lock", + "Pipfile", + "mise.local.lock", + "mise.lock", + "pdm.lock", + "poetry.lock", + "uv.lock" + ] + }, + { + "name": "TSX", + "extensions": [ + "tsx" + ], + "filenames": [] + }, + { + "name": "Turtle", + "extensions": [ + "ttl" + ], + "filenames": [] + }, + { + "name": "TypeScript", + "extensions": [ + "ts", + "cts", + "mts" + ], + "filenames": [] + }, + { + "name": "VBScript", + "extensions": [ + "vbs" + ], + "filenames": [] + }, + { + "name": "Verilog", + "extensions": [ + "v", + "veo" + ], + "filenames": [] + }, + { + "name": "VHDL", + "extensions": [ + "vhdl", + "vhd", + "vhf", + "vhi", + "vho", + "vhs", + "vht", + "vhw" + ], + "filenames": [] + }, + { + "name": "Vue", + "extensions": [ + "vue" + ], + "filenames": [] + }, + { + "name": "WebAssembly", + "extensions": [ + "wast", + "wat" + ], + "filenames": [] + }, + { + "name": "XML", + "extensions": [ + "xml", + "adml", + "admx", + "ant", + "axaml", + "axml", + "builds", + "ccproj", + "ccxml", + "clixml", + "cproject", + "cscfg", + "csdef", + "csl", + "csproj", + "ct", + "depproj", + "dita", + "ditamap", + "ditaval", + "dotsettings", + "filters", + "fsproj", + "fxml", + "glade", + "gml", + "gmx", + "gpx", + "grxml", + "gst", + "hzp", + "icls", + "iml", + "ivy", + "jelly", + "kml", + "launch", + "mdpolicy", + "mjml", + "mm", + "mod", + "mojo", + "mxml", + "natvis", + "ncl", + "ndproj", + "nproj", + "nuspec", + "odd", + "osm", + "pkgproj", + "pluginspec", + "proj", + "props", + "ps1xml", + "psc1", + "pt", + "pubxml", + "qhelp", + "rdf", + "res", + "resx", + "rs", + "rss", + "sch", + "scxml", + "sfproj", + "shproj", + "slnx", + "srdf", + "storyboard", + "sublime-snippet", + "sw", + "targets", + "tml", + "typ", + "ui", + "urdf", + "ux", + "vbproj", + "vcxproj", + "vsixmanifest", + "vssettings", + "vstemplate", + "vxml", + "wixproj", + "workflow", + "wsdl", + "wsf", + "wxi", + "wxl", + "wxs", + "x3d", + "xacro", + "xaml", + "xib", + "xlf", + "xliff", + "xmi", + "xmp", + "xproj", + "xsd", + "xspec", + "xul", + "zcml" + ], + "filenames": [ + ".classpath", + ".cproject", + ".project", + "App.config", + "NuGet.config", + "Settings.StyleCop", + "Web.Debug.config", + "Web.Release.config", + "Web.config", + "packages.config" + ] + }, + { + "name": "XQuery", + "extensions": [ + "xquery", + "xq", + "xql", + "xqm", + "xqy" + ], + "filenames": [] + }, + { + "name": "YAML", + "extensions": [ + "yml", + "mir", + "reek", + "rviz", + "sublime-syntax", + "syntax", + "yaml", + "yaml-tmlanguage" + ], + "filenames": [ + ".clang-format", + ".clang-tidy", + ".clangd", + ".gemrc", + "CITATION.cff", + "glide.lock", + "pixi.lock", + "yarn.lock" + ] + } +] diff --git a/eslint.config.ts b/eslint.config.ts index 29016ed808..91adc06e19 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -570,8 +570,6 @@ export default defineConfig([ 'no-redeclare': [0], // must be disabled for typescript overloads 'no-regex-spaces': [2], 'no-restricted-exports': [0], - 'no-restricted-globals': [2, ...restrictedGlobals], - 'no-restricted-properties': [2, ...restrictedProperties], 'no-restricted-imports': [2, {paths: [ {name: 'jquery', message: 'Use the global $ instead', allowTypeImports: true}, ]}], @@ -1022,5 +1020,9 @@ export default defineConfig([ { files: ['web_src/**/*'], languageOptions: {globals: {...globals.browser, ...globals.jquery}}, + rules: { + 'no-restricted-globals': [2, ...restrictedGlobals], + 'no-restricted-properties': [2, ...restrictedProperties], + }, }, ]); diff --git a/tools/generate-codemirror-languages.ts b/tools/generate-codemirror-languages.ts new file mode 100755 index 0000000000..05891298f6 --- /dev/null +++ b/tools/generate-codemirror-languages.ts @@ -0,0 +1,82 @@ +#!/usr/bin/env node +import {load as parseYaml} from 'js-yaml'; +import {writeFile} from 'node:fs/promises'; +import {languages as cmLanguages} from '@codemirror/language-data'; + +const linguistUrl = 'https://raw.githubusercontent.com/github-linguist/linguist/main/lib/linguist/languages.yml'; + +// Linguist names that don't match the corresponding @codemirror/language-data name. +const renames: Record = { + 'COBOL': 'Cobol', + 'Diff': 'diff', + 'INI': 'Properties files', + 'Less': 'LESS', + 'Protocol Buffer': 'ProtoBuf', + 'TeX': 'LaTeX', +}; + +// Per-language extensions to drop. Use only for extensions that would actively collide +// with another language (e.g. .inc claimed by both PHP and C++) or where the syntax is +// genuinely incompatible with the CodeMirror mode (e.g. .csh vs sh). +const excludeExt: Record = { + 'C++': ['inc'], + 'INI': ['frm'], + 'JavaScript': ['_js', 'bones', 'es', 'es6', 'frag', 'gs', 'jake', 'javascript', 'jsb', 'jscad', 'jsfl', 'jslib', 'jsm', 'jspre', 'jss', 'njs', 'pac', 'sjs', 'ssjs', 'xsjs', 'xsjslib'], + 'Lua': ['fcgi'], + 'PHP': ['fcgi', 'inc'], + 'Perl': ['cgi', 'fcgi'], + 'Python': ['cgi', 'fcgi', 'spec'], + 'Ruby': ['fcgi', 'spec'], + 'Shell': ['cgi', 'csh', 'fcgi'], + 'XML': ['inc', 'jsproj', 'tmpl', 'ts', 'tsx'], +}; + +type LinguistEntry = { + type: string; + extensions?: string[]; + filenames?: string[]; +}; + +type CmLanguage = { + name: string; + extensions: string[]; + filenames: string[]; +}; + +async function main() { + const res = await fetch(linguistUrl); + if (!res.ok) throw new Error(`fetch ${linguistUrl} failed: ${res.status}`); + const linguist = parseYaml(await res.text()) as Record; + + const cmNames = new Set(cmLanguages.map((l) => l.name)); + const out: CmLanguage[] = []; + for (const [linguistName, entry] of Object.entries(linguist)) { + const cmName = renames[linguistName] ?? linguistName; + if (!cmNames.has(cmName)) continue; + const exExt = new Set(excludeExt[linguistName]); + // CodeMirror's matchFilename uses /\.([^.]+)$/ to extract the suffix, so multi-dot + // extensions like ".cmake.in" cannot match as extensions and are dropped here. + const extensions = (entry.extensions ?? []) + .map((e) => e.replace(/^\./, '')) + .filter((e) => !e.includes('.') && !exExt.has(e)); + const filenames = entry.filenames ?? []; + out.push({ + name: cmName, + extensions: Array.from(new Set(extensions)), + filenames: Array.from(new Set(filenames)), + }); + } + + out.sort((a, b) => a.name.localeCompare(b.name)); + + const outPath = new URL('../assets/codemirror-languages.json', import.meta.url); + await writeFile(outPath, `${JSON.stringify(out, null, 2)}\n`); + console.info(`wrote ${out.length} languages to ${outPath.pathname}`); +} + +try { + await main(); +} catch (err) { + console.error(err); + process.exit(1); +} diff --git a/web_src/js/modules/codeeditor/main.test.ts b/web_src/js/modules/codeeditor/main.test.ts new file mode 100644 index 0000000000..40a3590d35 --- /dev/null +++ b/web_src/js/modules/codeeditor/main.test.ts @@ -0,0 +1,84 @@ +import {buildLanguageDescriptions, importCodemirror} from './main.ts'; + +test('matchFilename — language detection covers extended rules', async () => { + const cm = await importCodemirror(); + const list = buildLanguageDescriptions(cm); + const match = (filename: string) => + cm.language.LanguageDescription.matchFilename(list, filename)?.name; + + expect(match('.bashrc')).toBe('Shell'); + expect(match('.zshrc')).toBe('Shell'); + expect(match('.envrc')).toBe('Shell'); + expect(match('foo.zsh')).toBe('Shell'); + expect(match('foo.bats')).toBe('Shell'); + expect(match('PKGBUILD')).toBe('Shell'); + + expect(match('.gitconfig')).toBe('Properties files'); + expect(match('.editorconfig')).toBe('Properties files'); + expect(match('.npmrc')).toBe('Properties files'); + expect(match('foo.cfg')).toBe('Properties files'); + expect(match('foo.conf')).toBe('Properties files'); + expect(match('nginx.conf')).toBe('Nginx'); + + expect(match('Cargo.lock')).toBe('TOML'); + expect(match('Pipfile')).toBe('TOML'); + expect(match('poetry.lock')).toBe('TOML'); + + expect(match('Containerfile')).toBe('Dockerfile'); + expect(match('Containerfile.test')).toBe('Dockerfile'); + expect(match('Dockerfile')).toBe('Dockerfile'); + expect(match('Dockerfile.dev')).toBe('Dockerfile'); + + expect(match('Brewfile')).toBe('Ruby'); + expect(match('Vagrantfile')).toBe('Ruby'); + expect(match('Gemfile')).toBe('Ruby'); + expect(match('foo.gemspec')).toBe('Ruby'); + expect(match('foo.rake')).toBe('Ruby'); + expect(match('foo.ru')).toBe('Ruby'); + + expect(match('foo.psgi')).toBe('Perl'); + expect(match('foo.pyi')).toBe('Python'); + expect(match('Snakefile')).toBe('Python'); + + expect(match('foo.webmanifest')).toBe('JSON'); + expect(match('foo.geojson')).toBe('JSON'); + expect(match('composer.lock')).toBe('JSON'); + expect(match('bun.lock')).toBe('JSON'); + + expect(match('foo.tcc')).toBe('C++'); + expect(match('foo.tpp')).toBe('C++'); + expect(match('foo.cppm')).toBe('C++'); + expect(match('foo.ixx')).toBe('C++'); + + expect(match('foo.xhtml')).toBe('HTML'); + expect(match('foo.jsh')).toBe('Java'); + expect(match('.Rprofile')).toBe('R'); + + expect(match('Makefile')).toBe('Makefile'); + expect(match('Makefile.am')).toBe('Makefile'); + expect(match('BSDmakefile')).toBe('Makefile'); + expect(match('GNUmakefile')).toBe('Makefile'); + expect(match('foo.mk')).toBe('Makefile'); + + expect(match('.env')).toBe('Dotenv'); + expect(match('.env.local')).toBe('Dotenv'); + + expect(match('foo.md')).toBe('Markdown'); + expect(match('foo.mdown')).toBe('Markdown'); + expect(match('foo.json5')).toBe('JSON5'); + expect(match('tsconfig.json')).toBe('JSON'); + + // Smoke tests for languages that already worked, to guard against regressions. + expect(match('foo.go')).toBe('Go'); + expect(match('foo.rs')).toBe('Rust'); + expect(match('foo.ts')).toBe('TypeScript'); + expect(match('foo.py')).toBe('Python'); + expect(match('foo.html')).toBe('HTML'); + expect(match('foo.css')).toBe('CSS'); + expect(match('foo.lua')).toBe('Lua'); + + // Genuinely ambiguous extensions left unhighlighted on purpose. + expect(match('foo.cgi')).toBeUndefined(); + expect(match('foo.fcgi')).toBeUndefined(); + expect(match('foo.fish')).toBeUndefined(); +}); diff --git a/web_src/js/modules/codeeditor/main.ts b/web_src/js/modules/codeeditor/main.ts index e847f357cb..be8992ce1d 100644 --- a/web_src/js/modules/codeeditor/main.ts +++ b/web_src/js/modules/codeeditor/main.ts @@ -7,6 +7,7 @@ import type {PaletteCommand} from './command-palette.ts'; import {contextMenu, collectSymbols, selectAllOccurrences} from './context-menu.ts'; import {createJsonLinter, createSyntaxErrorLinter} from './linter.ts'; import {clickableUrls, goToDefinitionAt, trimTrailingWhitespaceFromView} from './utils.ts'; +import linguistLanguages from '../../../../assets/codemirror-languages.json' with {type: 'json'}; import type {LanguageDescription, LanguageSupport} from '@codemirror/language'; import type {Compartment, Extension} from '@codemirror/state'; import type {EditorView, ViewUpdate} from '@codemirror/view'; @@ -43,7 +44,7 @@ export type CodemirrorEditor = { export type CodemirrorModules = Awaited>; -async function importCodemirror() { +export async function importCodemirror() { const [autocomplete, commands, language, languageData, lint, search, state, view, highlight, indentMarkers, vscodeKeymap] = await Promise.all([ import('@codemirror/autocomplete'), import('@codemirror/commands'), @@ -60,6 +61,81 @@ async function importCodemirror() { return {autocomplete, commands, language, languageData, lint, search, state, view, highlight, indentMarkers, vscodeKeymap}; } +const manualFilenames: Record = { + 'Properties files': ['.editorconfig', '.gitconfig', '.npmrc'], + 'Python': ['Snakefile'], +}; +const manualExtensions: Record = { + 'Properties files': ['conf'], +}; +const handledByCustomEntry = new Set(['Dockerfile', 'Markdown']); +const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +const filenameUnion = (filenames: string[]) => + filenames.length ? new RegExp(`^(${filenames.map(escapeRegex).join('|')})$`) : undefined; + +export function buildLanguageDescriptions(cm: CodemirrorModules): LanguageDescription[] { + const markdown = linguistLanguages.find((l) => l.name === 'Markdown'); + const dockerfile = linguistLanguages.find((l) => l.name === 'Dockerfile'); + const list: LanguageDescription[] = [ + ...buildBaseLanguages(cm), + cm.language.LanguageDescription.of({ + name: 'Markdown', extensions: markdown?.extensions ?? ['md', 'markdown', 'mkd'], + load: async () => (await import('@codemirror/lang-markdown')).markdown({codeLanguages: list}), + }), + cm.language.LanguageDescription.of({ + name: 'Dockerfile', extensions: dockerfile?.extensions ?? ['dockerfile', 'containerfile'], + filename: /^(Containerfile|Dockerfile)(\..+)?$/i, + load: async () => new cm.language.LanguageSupport(cm.language.StreamLanguage.define((await import('@codemirror/legacy-modes/mode/dockerfile')).dockerFile)), + }), + cm.language.LanguageDescription.of({ + name: 'Elixir', extensions: ['ex', 'exs'], + load: async () => (await import('codemirror-lang-elixir')).elixir(), + }), + cm.language.LanguageDescription.of({ + name: 'Nix', extensions: ['nix'], + load: async () => (await import('@replit/codemirror-lang-nix')).nix(), + }), + cm.language.LanguageDescription.of({ + name: 'Svelte', extensions: ['svelte'], + load: async () => (await import('@replit/codemirror-lang-svelte')).svelte(), + }), + cm.language.LanguageDescription.of({ + name: 'Makefile', extensions: ['mk', 'mak', 'make'], filename: /^(GNU|BSD)?[Mm]akefile(\..+)?$/, + load: async () => new cm.language.LanguageSupport(cm.language.StreamLanguage.define((await import('@codemirror/legacy-modes/mode/shell')).shell)), + }), + cm.language.LanguageDescription.of({ + name: 'Dotenv', extensions: ['env'], filename: /^\.env(\..*)?$/, + load: async () => new cm.language.LanguageSupport(cm.language.StreamLanguage.define((await import('@codemirror/legacy-modes/mode/shell')).shell)), + }), + cm.language.LanguageDescription.of({ + name: 'JSON5', extensions: ['json5', 'jsonc'], + load: async () => (await import('@codemirror/lang-json')).json(), + }), + ]; + return list; +} + +let baseLanguagesCache: LanguageDescription[] | null = null; +function buildBaseLanguages(cm: CodemirrorModules): LanguageDescription[] { + if (baseLanguagesCache) return baseLanguagesCache; + const loadByName = new Map( + cm.languageData.languages.map((l: LanguageDescription) => [l.name, l.load.bind(l)]), + ); + const overrides = linguistLanguages + .filter((l) => loadByName.has(l.name) && !handledByCustomEntry.has(l.name)) + .map((l) => cm.language.LanguageDescription.of({ + name: l.name, + extensions: [...l.extensions, ...(manualExtensions[l.name] ?? [])], + filename: filenameUnion([...l.filenames, ...(manualFilenames[l.name] ?? [])]), + load: loadByName.get(l.name)!, + })); + const overrideNames = new Set(overrides.map((o) => o.name)); + const fallback = cm.languageData.languages.filter( + (l: LanguageDescription) => !overrideNames.has(l.name) && !handledByCustomEntry.has(l.name), + ); + return baseLanguagesCache = [...overrides, ...fallback]; +} + function togglePreviewDisplay(previewable: boolean): void { // FIXME: here and below, the selector is too broad, it should only query in the editor related scope const previewTab = document.querySelector('a[data-tab="preview"]'); @@ -85,38 +161,7 @@ export async function createCodeEditor(textarea: HTMLTextAreaElement, filenameIn const previewableExts = new Set(config.previewableExtensions || []); const lineWrapExts = config.lineWrapExtensions || []; const cm = await importCodemirror(); - - const languageDescriptions: LanguageDescription[] = [ - ...cm.languageData.languages.filter((l: LanguageDescription) => l.name !== 'Markdown'), - cm.language.LanguageDescription.of({ - name: 'Markdown', extensions: ['md', 'markdown', 'mkd'], - load: async () => (await import('@codemirror/lang-markdown')).markdown({codeLanguages: languageDescriptions}), - }), - cm.language.LanguageDescription.of({ - name: 'Elixir', extensions: ['ex', 'exs'], - load: async () => (await import('codemirror-lang-elixir')).elixir(), - }), - cm.language.LanguageDescription.of({ - name: 'Nix', extensions: ['nix'], - load: async () => (await import('@replit/codemirror-lang-nix')).nix(), - }), - cm.language.LanguageDescription.of({ - name: 'Svelte', extensions: ['svelte'], - load: async () => (await import('@replit/codemirror-lang-svelte')).svelte(), - }), - cm.language.LanguageDescription.of({ - name: 'Makefile', filename: /^(GNUm|M|m)akefile$/, - load: async () => new cm.language.LanguageSupport(cm.language.StreamLanguage.define((await import('@codemirror/legacy-modes/mode/shell')).shell)), - }), - cm.language.LanguageDescription.of({ - name: 'Dotenv', extensions: ['env'], filename: /^\.env(\..*)?$/, - load: async () => new cm.language.LanguageSupport(cm.language.StreamLanguage.define((await import('@codemirror/legacy-modes/mode/shell')).shell)), - }), - cm.language.LanguageDescription.of({ - name: 'JSON5', extensions: ['json5', 'jsonc'], - load: async () => (await import('@codemirror/lang-json')).json(), - }), - ]; + const languageDescriptions = buildLanguageDescriptions(cm); const matchedLang = cm.language.LanguageDescription.matchFilename(languageDescriptions, config.filename); const container = document.createElement('div');