diff --git a/resources/licenses/language-haskell/LICENSE.txt b/resources/licenses/language-haskell/LICENSE.txt new file mode 100644 index 00000000000..d071fb9ef84 --- /dev/null +++ b/resources/licenses/language-haskell/LICENSE.txt @@ -0,0 +1,30 @@ +Copyright (c) 2017 Justus Adam + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Justus Adam nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/resources/licenses/language-haskell/UPSTREAM.txt b/resources/licenses/language-haskell/UPSTREAM.txt new file mode 100644 index 00000000000..131f49c16f2 --- /dev/null +++ b/resources/licenses/language-haskell/UPSTREAM.txt @@ -0,0 +1,4 @@ +Source: https://github.com/haskell/language-haskell +Version: 3.8.0 (commit 417bdaf7d997b8561d8d52066120faca395a1ec7) +Grammar: syntaxes/haskell.YAML-tmLanguage, converted to JSON without changing its content. +License: BSD-3-Clause; the full upstream license is in LICENSE.txt. diff --git a/src/renderer/src/lib/language-detect.test.ts b/src/renderer/src/lib/language-detect.test.ts index b8cd6e45353..96b1ca0e689 100644 --- a/src/renderer/src/lib/language-detect.test.ts +++ b/src/renderer/src/lib/language-detect.test.ts @@ -2,6 +2,15 @@ import { describe, expect, it } from 'vitest' import { detectLanguage } from './language-detect' describe('detectLanguage', () => { + it.each([ + 'src/Main.hs', + 'src/Main.hsig', + 'src/Main.hs-boot', + 'C:\\src\\MAIN.HS', + '/remote folder/Main.hsig' + ])('maps Haskell source %s to the haskell language id', (filePath) => { + expect(detectLanguage(filePath)).toBe('haskell') + }) it('maps .vue files to the custom vue language id', () => { expect(detectLanguage('src/components/App.vue')).toBe('vue') }) diff --git a/src/renderer/src/lib/language-detect.ts b/src/renderer/src/lib/language-detect.ts index b90b5a36ae2..405f4ea98ea 100644 --- a/src/renderer/src/lib/language-detect.ts +++ b/src/renderer/src/lib/language-detect.ts @@ -94,6 +94,8 @@ const EXT_TO_LANGUAGE: Record = { '.erl': 'erlang', '.hrl': 'erlang', '.hs': 'haskell', + '.hsig': 'haskell', + '.hs-boot': 'haskell', '.clj': 'clojure', // Why: Monaco registers Solidity under the id 'sol'; 'solidity' is only an alias. '.sol': 'sol', diff --git a/src/renderer/src/lib/monaco-languages/register-haskell.test.ts b/src/renderer/src/lib/monaco-languages/register-haskell.test.ts new file mode 100644 index 00000000000..59e4b2aa151 --- /dev/null +++ b/src/renderer/src/lib/monaco-languages/register-haskell.test.ts @@ -0,0 +1,93 @@ +// @vitest-environment happy-dom +import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { loadHaskellTextMateGrammar, registerHaskellLanguage } from './register-haskell' +import type { TextMateTokensProviderOptions } from './textmate-token-provider' +import type * as TextMateProvider from './textmate-token-provider' +import { loadNodeOniguruma } from './textmate-oniguruma-fixture' + +const providerRequests = vi.hoisted(() => vi.fn()) +const disposers: (() => void)[] = [] + +vi.mock('./textmate-token-provider', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createTextMateTokensProvider: (options: TextMateTokensProviderOptions) => { + providerRequests() + return actual.createTextMateTokensProvider({ ...options, loadOniguruma: loadNodeOniguruma }) + } + } +}) + +beforeAll(() => { + const registration = vi.spyOn(monaco.languages, 'register') + const configuration = vi.spyOn(monaco.languages, 'setLanguageConfiguration') + const factory = vi.spyOn(monaco.languages, 'registerTokensProviderFactory') + registerHaskellLanguage(monaco) + expect(providerRequests).not.toHaveBeenCalled() + registerHaskellLanguage(monaco) + expect(registration).toHaveBeenCalledTimes(1) + for (const call of [...configuration.mock.results, ...factory.mock.results]) { + if (call.type === 'return') { + disposers.push(() => call.value.dispose()) + } + } +}) + +afterAll(() => { + for (const dispose of disposers.splice(0).toReversed()) { + dispose() + } + vi.restoreAllMocks() +}) + +describe('Haskell through the installed Monaco tokenizer', () => { + it('lazily colors recognized source, types, numbers, strings, and comments', async () => { + const source = [ + 'module Main where', + '-- comment', + 'answer :: Int', + 'answer = 42', + 'main = putStrLn "hello"' + ].join('\n') + await monaco.editor.colorize(source, 'haskell', {}) + expect(providerRequests).toHaveBeenCalledTimes(1) + const tokens = monaco.editor.tokenize(source, 'haskell') + const scopes = (line: number) => tokens[line].map((token) => token.type) + + expect(scopes(0)).toContain('keyword.other.module.haskell') + expect(new Set(scopes(1))).toEqual(new Set(['comment.haskell'])) + expect(scopes(2)).toEqual(expect.arrayContaining(['type.identifier.haskell', 'type.haskell'])) + expect(scopes(3)).toContain('number.haskell') + expect(scopes(4)).toContain('string.quoted.double.haskell') + expect(monaco.languages.getLanguages().find(({ id }) => id === 'haskell')?.extensions).toEqual([ + '.hs', + '.hsig', + '.hs-boot' + ]) + }) + + it('keeps nested comments active and returns to code after both closers', async () => { + const source = [ + '{- outer {- inner', + '-} still outer', + '-}', + 'answer = 42', + 'text = "-- literal"' + ].join('\n') + await monaco.editor.colorize(source, 'haskell', {}) + const tokens = monaco.editor.tokenize(source, 'haskell') + + expect(new Set(tokens[0].map((token) => token.type))).toEqual(new Set(['comment.haskell'])) + expect(new Set(tokens[1].map((token) => token.type))).toEqual(new Set(['comment.haskell'])) + expect(tokens[3].map((token) => token.type)).toContain('number.haskell') + expect(tokens[3].map((token) => token.type)).not.toContain('comment.haskell') + expect(tokens[4].map((token) => token.type)).toContain('string.quoted.double.haskell') + expect(tokens[4].map((token) => token.type)).not.toContain('comment.haskell') + }) + + it('does not load a grammar for an unrelated scope', async () => { + await expect(loadHaskellTextMateGrammar('source.python')).resolves.toBeNull() + }) +}) diff --git a/src/renderer/src/lib/monaco-languages/register-haskell.ts b/src/renderer/src/lib/monaco-languages/register-haskell.ts new file mode 100644 index 00000000000..16b92806a2e --- /dev/null +++ b/src/renderer/src/lib/monaco-languages/register-haskell.ts @@ -0,0 +1,74 @@ +import type * as Monaco from 'monaco-editor' +import type { IRawGrammar } from 'vscode-textmate' +import { registerTextMateLanguage } from './textmate-language-registration' + +const HASKELL_SCOPE = 'source.haskell' + +export const haskellLanguageConfiguration: Monaco.languages.LanguageConfiguration = { + comments: { lineComment: '--', blockComment: ['{-', '-}'] }, + brackets: [ + ['{', '}'], + ['[', ']'], + ['(', ')'] + ], + autoClosingPairs: [ + { open: '{', close: '}' }, + { open: '[', close: ']' }, + { open: '(', close: ')' }, + { open: '"', close: '"', notIn: ['string', 'comment'] } + ], + surroundingPairs: [ + { open: '{', close: '}' }, + { open: '[', close: ']' }, + { open: '(', close: ')' }, + { open: '"', close: '"' }, + { open: "'", close: "'" } + ] +} + +function mapHaskellTokenScopes(scopes: readonly string[]): string { + if (scopes.some((scope) => scope.startsWith('comment.'))) { + return 'comment.haskell' + } + const scope = scopes.at(-1) ?? HASKELL_SCOPE + if (scope.startsWith('constant.numeric.')) { + return 'number.haskell' + } + if (scope.startsWith('constant.character.escape.')) { + return 'string.escape.haskell' + } + if (scope.startsWith('entity.name.function.')) { + return 'type.identifier.haskell' + } + if (scope.startsWith('storage.type.')) { + return 'type.haskell' + } + if (scope.startsWith('variable.other.generic-type.')) { + return 'variable.parameter.haskell' + } + return scope +} + +export async function loadHaskellTextMateGrammar(scopeName: string): Promise { + if (scopeName !== HASKELL_SCOPE) { + return null + } + // BSD-3-Clause grammar; provenance and license ship in resources/licenses/language-haskell. + const grammarModule = await import('./textmate-grammars/haskell.tmLanguage.json') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: TextMate adds the required $self/$base repository entries; real tokenizer tests validate this generated upstream grammar. + return grammarModule.default as unknown as IRawGrammar +} + +export function registerHaskellLanguage(monaco: Pick): void { + registerTextMateLanguage(monaco, { + language: { + id: 'haskell', + extensions: ['.hs', '.hsig', '.hs-boot'], + aliases: ['Haskell', 'haskell'] + }, + configuration: haskellLanguageConfiguration, + scopeName: HASKELL_SCOPE, + loadGrammar: loadHaskellTextMateGrammar, + mapTokenScopes: mapHaskellTokenScopes + }) +} diff --git a/src/renderer/src/lib/monaco-languages/textmate-grammars/haskell.tmLanguage.json b/src/renderer/src/lib/monaco-languages/textmate-grammars/haskell.tmLanguage.json new file mode 100644 index 00000000000..804aad899c5 --- /dev/null +++ b/src/renderer/src/lib/monaco-languages/textmate-grammars/haskell.tmLanguage.json @@ -0,0 +1,2639 @@ +{ + "fileTypes": ["hs", "hs-boot", "hsig"], + "keyEquivalent": "^~H", + "name": "Haskell", + "patterns": [ + { + "include": "#liquid_haskell" + }, + { + "include": "#comment_like" + }, + { + "include": "#numeric_literals" + }, + { + "include": "#string_literal" + }, + { + "include": "#char_literal" + }, + { + "match": "(?\n (?:\n [\\p{Lu}\\p{Lt}][\\p{Ll}_\\p{Lu}\\p{Lt}\\p{Lm}\\p{Lo}\\p{Nd}']*\n | \\(\\s*\n :[\\p{S}\\p{P}&&[^(),;\\[\\]`{}_\"']]*\n \\s*\\)\n )\n (?:\\s*,\\s*\\g)?\n)\n\\s+(::|∷)(?![\\p{S}\\p{P}&&[^(),;\\[\\]`{}_\"'']])\n", + "beginCaptures": { + "2": { + "name": "keyword.other.pattern.haskell" + }, + "3": { + "patterns": [ + { + "include": "#comma" + }, + { + "include": "#data_constructor" + } + ] + }, + "4": { + "name": "keyword.operator.double-colon.haskell" + } + }, + "end": "(?x) # Detect end of pattern type definition by decreasing indentation:\n (?=\\}|;) # Explicit indentation\n |^(?! # Implicit indentation: end match on newline *unless* the new line is either:\n \\1\\s+\\S # - more indented, or\n | \\s* # - starts with whitespace, followed by:\n (?: $ # - the end of the line (i.e. empty line), or\n |\\{-[^@] # - the start of a block comment, or\n |--+ # - the start of a single-line comment.\n (?![\\p{S}\\p{P}&&[^(),;\\[\\]{}`_\"']]).*$) # non-symbol\n # The double dash may not be followed by other operator characters\n # (then it would be an operator, not a comment)\n )\n", + "patterns": [ + { + "include": "#type_signature" + } + ] + }, + { + "name": "meta.declaration.pattern.haskell", + "begin": "(?:^(\\s*)|(?[\\p{Lu}\\p{Lt}][\\p{Ll}_\\p{Lu}\\p{Lt}\\p{Lm}\\p{Lo}\\p{Nd}']*(\\.\\g)?)", + "name": "entity.name.namespace.haskell" + }, + "pragma": { + "begin": "\\{-#", + "end": "#-\\}", + "name": "meta.preprocessor.haskell", + "patterns": [ + { + "include": "#comments" + }, + { + "include": "#string_literal" + }, + { + "begin": "(?xi) \\b(?\n \\(\n (?:\n \\g # Recurse first: consume nested parens before trying non-paren chars\n | [^()]++ # Non-parens (possessive: no backtracking into consumed chars)\n )*\n \\)\n )\n |\n \\{-(?:[^-]|-(?!\\}))*-\\}\n )*)\n (?|⇒)\n (?![\\p{S}\\p{P}&&[^(),;\\[\\]`{}_\"']])\n", + "captures": { + "1": { + "patterns": [ + { + "include": "#comment_like" + }, + { + "include": "#forall" + }, + { + "begin": "(?='?\\()", + "end": "(?=\\))", + "patterns": [ + { + "include": "#type_signature" + } + ] + }, + { + "begin": "(?='?\\[)", + "end": "(?=\\])", + "patterns": [ + { + "include": "#type_signature" + } + ] + }, + { + "match": "(?x)\n (\\S*)\\s*\n (?\n (?:\n [\\p{Ll}_\\p{Lm}\\p{Lo}][\\p{Ll}_\\p{Lu}\\p{Lt}\\p{Lm}\\p{Lo}\\p{Nd}']*\\#*\n | \\(\\s*\n (?!--+\\))\n [\\p{S}\\p{P}&&[^(),:;\\[\\]`{}_\"']]\n [\\p{S}\\p{P}&&[^(),;\\[\\]`{}_\"']]*\n \\s*\\)\n )\n (?:\\s*,\\s*\\g)?\n )\n \\s*(?)\n ((<-|←)|(=)|(-<|↢)|(-<<|⤛))\n # non-symbolic character\n ([(),;\\[\\]`{}_\"']|[^\\p{S}\\p{P}])\n )\n # Decreasing indentation:\n |(?=\\}|;) # Explicit indentation\n |^(?! # Implicit indentation: end match on newline *unless* the new line is either:\n \\1\\s+\\S # - more indented, or\n | \\s* # - starts with whitespace, followed by:\n (?: $ # - the end of the line (i.e. empty line), or\n |\\{-[^@] # - the start of a block comment, or\n |--+ # - the start of a single-line comment.\n (?![\\p{S}\\p{P}&&[^(),;\\[\\]{}`_\"']]).*$) # non-symbol\n # The double dash may not be followed by other operator characters\n # (then it would be an operator, not a comment)\n )\n" + }, + "adt_constructor": { + "patterns": [ + { + "include": "#comment_like" + }, + { + "begin": "(?x)\n (?\n \\( # Opening parenthesis\n (?:\n \\g # Recurse first: consume nested parens before trying non-paren chars\n | [^\\(\\)]++ # Non-parentheses (possessive: no backtracking into consumed chars)\n )*\n \\) # Closing parenthesis\n )\n )\n # Type inside balanced brackets\n | ('? # Optional promotion tick\n (?\n \\[ # Opening bracket\n (?:\n \\g # Recurse first: consume nested brackets before trying non-bracket chars\n | [^\\[\\]]++ # Non-brackets (possessive: no backtracking into consumed chars)\n )*\n \\] # Closing bracket\n )\n )\n )\n # Then either\n \\s*\n # - a symbolic infix constructor, or\n (?:(?\"\n (?!\n (?: # Match allowed intermediate tokens between the word and the '=>'\n # Note: we don't use inner '+' quantifier, to avoid excessive backtracking.\n\n # Valid characters: letters, digits, allowed symbols, spaces, and tabs\n [\\p{Ll}_\\p{Lu}\\p{Lt}\\p{Lm}\\p{Lo}\\p{Nd}'\\., \\t]\n\n # OR a newline, but ONLY if strictly followed by indentation (tabs or spaces)\n # (This prevents the lookahead from bleeding into unindented top-level declarations)\n | \\r?\\n[ \\t]+\n\n # OR a multiline block comment: {- ... -}\n | \\{-(?:[^-]|-(?!\\}))*-\\}\n\n )*\n\n # ... and a constraint arrow '=>' (not part of a larger custom operator such as '==>')\n (?|⇒)\n (?![\\p{S}\\p{P}&&[^(),;\\[\\]`{}_\"']])\n )\n | # - a symbolic (prefix) data constructor\n (\\()\\s*(:[\\p{S}\\p{P}&&[^(),;\\[\\]`{}_\"']]*)\\s*(\\))\n | # Otherwise, try to fail early to avoid excessive backtracking (https://github.com/haskell/language-haskell/issues/161)\n # Fail when detecting a lowercase identifier and then something not starting with a tick or colon\n (?=\\b(?|⇒", + "name": "keyword.operator.big-arrow.haskell" + }, + { + "include": "#string_literal" + }, + { + "match": "'[^']'", + "name": "invalid" + }, + { + "include": "#type_application" + }, + { + "include": "#reserved_symbol" + }, + { + "include": "#type_operator" + }, + { + "include": "#type_constructor" + }, + { + "begin": "(\\()(#)", + "end": "(#)(\\))", + "beginCaptures": { + "1": { + "name": "punctuation.paren.haskell" + }, + "2": { + "name": "keyword.operator.hash.haskell" + } + }, + "endCaptures": { + "1": { + "name": "keyword.operator.hash.haskell" + }, + "2": { + "name": "punctuation.paren.haskell" + } + }, + "patterns": [ + { + "include": "#comma" + }, + { + "include": "#type_signature" + } + ] + }, + { + "begin": "(')?(\\()", + "end": "(\\))", + "beginCaptures": { + "1": { + "name": "keyword.operator.promotion.haskell" + }, + "2": { + "name": "punctuation.paren.haskell" + } + }, + "endCaptures": { + "1": { + "name": "punctuation.paren.haskell" + } + }, + "patterns": [ + { + "include": "#comma" + }, + { + "include": "#type_signature" + } + ] + }, + { + "begin": "(')?(\\[)", + "end": "(\\])", + "beginCaptures": { + "1": { + "name": "keyword.operator.promotion.haskell" + }, + "2": { + "name": "punctuation.bracket.haskell" + } + }, + "endCaptures": { + "1": { + "name": "punctuation.bracket.haskell" + } + }, + "patterns": [ + { + "include": "#comma" + }, + { + "include": "#type_signature" + } + ] + }, + { + "include": "#type_variable" + } + ] + }, + "double_colon": { + "match": "\\s*(::|∷)(?![\\p{S}\\p{P}&&[^(),;\\[\\]`{}_\"']])\\s*", + "captures": { + "1": { + "name": "keyword.operator.double-colon.haskell" + } + } + }, + "start_type_signature": { + "patterns": [ + { + "begin": "^(\\s*)(::|∷)(?![\\p{S}\\p{P}&&[^(),;\\[\\]{}`_\"']])\\s*", + "beginCaptures": { + "2": { + "name": "keyword.operator.double-colon.haskell" + } + }, + "end": "(?x)\n # End type annotation when seeing one of:\n (?=\n \\#?\\) # closing parenthesis\n |\\] # closing bracket\n |, # comma\n |(?)\n (?)\n (?|→)\n |(-<|↢)\n |(-<<|⤛)\n |(>-|⤚)\n |(>>-|⤜)\n )\n (?![\\p{S}\\p{P}&&[^(),;\\[\\]`{}_\"'']])", + "captures": { + "1": { + "name": "keyword.operator.double-dot.haskell" + }, + "2": { + "name": "keyword.operator.colon.haskell" + }, + "3": { + "name": "keyword.operator.eq.haskell" + }, + "4": { + "name": "keyword.operator.lambda.haskell" + }, + "5": { + "name": "keyword.operator.pipe.haskell" + }, + "6": { + "name": "keyword.operator.arrow.left.haskell" + }, + "7": { + "name": "keyword.operator.arrow.haskell" + }, + "8": { + "name": "keyword.operator.arrow.left.tail.haskell" + }, + "9": { + "name": "keyword.operator.arrow.left.tail.double.haskell" + }, + "10": { + "name": "keyword.operator.arrow.tail.haskell" + }, + "11": { + "name": "keyword.operator.arrow.tail.double.haskell" + } + } + }, + { + "match": "(?x)\n # Require a preceding final operator character\n (?<=[\\p{Ll}_\\p{Lu}\\p{Lt}\\p{Lm}\\p{Lo}\\p{Nd}\\p{S}\\p{P}&&[^\\#(,;\\[`{]])\n (\\#+)\n # Disallow a following operator character, but allow parentheses\n # to handle e.g. 'Solo#(..)'.\n (?![\\p{Ll}_\\p{Lu}\\p{Lt}\\p{Lm}\\p{Lo}\\p{Nd}\\p{S}\\p{P}&&[^(),;\\[\\]`{}]])", + "captures": { + "1": { + "name": "keyword.operator.postfix.hash.haskell" + } + } + }, + { + "match": "(?x)\n (?<=[\\p{Ll}_\\p{Lu}\\p{Lt}\\p{Lm}\\p{Lo}\\p{Nd}'\\)\\}\\]]) # Require closing characters\n (@)\n (?=[\\p{Ll}_\\p{Lu}\\p{Lt}\\p{Lm}\\p{Lo}\\p{Nd}'\\(\\[\\{]) # Require opening character", + "captures": { + "1": { + "name": "keyword.operator.infix.tight.at.haskell" + } + } + }, + { + "match": "(?x)\n (?|→)", + "beginCaptures": { + "1": { + "name": "keyword.other.forall.haskell" + }, + "2": { + "name": "keyword.other.forall.haskell" + } + }, + "endCaptures": { + "1": { + "name": "keyword.operator.period.haskell" + }, + "2": { + "name": "keyword.operator.arrow.haskell" + } + }, + "patterns": [ + { + "include": "#comment_like" + }, + { + "include": "#type_variable" + }, + { + "include": "#type_signature" + } + ] + }, + "string_literal": { + "begin": "\"", + "beginCaptures": { + "0": { + "name": "punctuation.definition.string.begin.haskell" + } + }, + "end": "\"", + "endCaptures": { + "0": { + "name": "punctuation.definition.string.end.haskell" + } + }, + "name": "string.quoted.double.haskell", + "patterns": [ + { + "include": "#string_escapes" + }, + { + "begin": "\\\\(?=[\\p{Zs}\\t\\v\\f])", + "beginCaptures": { + "0": { + "name": "constant.character.escape.begin.haskell" + } + }, + "end": "(?<=[\\s\\p{Zs}])\\\\|(?=\")", + "endCaptures": { + "0": { + "name": "constant.character.escape.end.haskell" + } + }, + "patterns": [ + { + "begin": "(?=[^\\s\\\\])", + "end": "(?=\")", + "patterns": [ + { + "match": "[^\\s\\\\\"]+", + "name": "invalid.illegal.character-not-allowed-here.haskell" + } + ] + } + ] + }, + { + "begin": "\\\\$", + "beginCaptures": { + "0": { + "name": "constant.character.escape.begin.haskell" + } + }, + "end": "\\\\|(?=[^\\s\\\\])", + "endCaptures": { + "0": { + "name": "constant.character.escape.end.haskell" + } + } + } + ] + }, + "char_literal": { + "captures": { + "1": { + "name": "punctuation.definition.string.begin.haskell" + }, + "2": { + "name": "constant.character.escape.haskell" + }, + "3": { + "name": "constant.character.escape.octal.haskell" + }, + "4": { + "name": "constant.character.escape.hexadecimal.haskell" + }, + "5": { + "name": "constant.character.escape.control.haskell" + }, + "6": { + "name": "punctuation.definition.string.end.haskell" + } + }, + "match": "(?x)\n (? type TextMateTokensProvider = Monaco.languages.TokensProvider type TextMateTokenProviderModule = { createTextMateTokensProvider: typeof createTextMateTokensProviderType @@ -15,6 +15,7 @@ export type TextMateLanguageRegistration = { configuration?: Monaco.languages.LanguageConfiguration scopeName: string loadGrammar: TextMateGrammarLoader + mapTokenScopes?: (scopes: readonly string[]) => string loadProviderModule?: () => Promise } @@ -48,7 +49,8 @@ export function registerTextMateLanguage( )().then(({ createTextMateTokensProvider }) => createTextMateTokensProvider({ scopeName: registration.scopeName, - loadGrammar: registration.loadGrammar + loadGrammar: registration.loadGrammar, + ...(registration.mapTokenScopes ? { mapTokenScopes: registration.mapTokenScopes } : {}) }) ) return tokensProviderPromise diff --git a/src/renderer/src/lib/monaco-languages/textmate-oniguruma-fixture.ts b/src/renderer/src/lib/monaco-languages/textmate-oniguruma-fixture.ts new file mode 100644 index 00000000000..b41a3729b43 --- /dev/null +++ b/src/renderer/src/lib/monaco-languages/textmate-oniguruma-fixture.ts @@ -0,0 +1,16 @@ +import { createRequire } from 'node:module' +import { readFile } from 'node:fs/promises' +import { createOnigScanner, createOnigString, loadWASM } from 'vscode-oniguruma' +import type { IOnigLib } from 'vscode-textmate' + +const require = createRequire(import.meta.url) +let nodeOnigurumaPromise: Promise | undefined + +export function loadNodeOniguruma(): Promise { + nodeOnigurumaPromise ??= (async () => { + const bytes = await readFile(require.resolve('vscode-oniguruma/release/onig.wasm')) + await loadWASM(new Uint8Array(bytes)) + return { createOnigScanner, createOnigString } + })() + return nodeOnigurumaPromise +} diff --git a/src/renderer/src/lib/monaco-languages/textmate-token-provider.test.ts b/src/renderer/src/lib/monaco-languages/textmate-token-provider.test.ts index 86b5d0c8604..7c1d3dff6a9 100644 --- a/src/renderer/src/lib/monaco-languages/textmate-token-provider.test.ts +++ b/src/renderer/src/lib/monaco-languages/textmate-token-provider.test.ts @@ -1,30 +1,9 @@ -import { createRequire } from 'node:module' -import { readFile } from 'node:fs/promises' import { describe, expect, it } from 'vitest' -import { createOnigScanner, createOnigString, loadWASM } from 'vscode-oniguruma' -import type { IOnigLib, IRawGrammar } from 'vscode-textmate' +import type { IRawGrammar } from 'vscode-textmate' import nimGrammar from './textmate-grammars/nim.tmLanguage.json' import { loadTypstTextMateGrammar } from './register-typst' import { createTextMateTokensProvider } from './textmate-token-provider' - -const require = createRequire(import.meta.url) - -let nodeOnigurumaPromise: Promise | undefined - -async function loadNodeOniguruma(): Promise { - nodeOnigurumaPromise ??= (async () => { - const wasmPath = require.resolve('vscode-oniguruma/release/onig.wasm') - const wasmBytes = await readFile(wasmPath) - const wasmBuffer = wasmBytes.buffer.slice( - wasmBytes.byteOffset, - wasmBytes.byteOffset + wasmBytes.byteLength - ) - await loadWASM(wasmBuffer) - return { createOnigScanner, createOnigString } - })() - - return nodeOnigurumaPromise -} +import { loadNodeOniguruma } from './textmate-oniguruma-fixture' describe('createTextMateTokensProvider', () => { it('tokenizes Nim with the vendored TextMate grammar', async () => { diff --git a/src/renderer/src/lib/monaco-languages/textmate-token-provider.ts b/src/renderer/src/lib/monaco-languages/textmate-token-provider.ts index ace5ca9966e..3b28e3983ab 100644 --- a/src/renderer/src/lib/monaco-languages/textmate-token-provider.ts +++ b/src/renderer/src/lib/monaco-languages/textmate-token-provider.ts @@ -10,6 +10,7 @@ export type TextMateGrammarLoader = (scopeName: string) => Promise string loadOniguruma?: () => Promise } @@ -47,7 +48,8 @@ class TextMateTokenizerState implements Monaco.languages.IState { function createTokensProvider( grammar: IGrammar, - fallbackScopeName: string + fallbackScopeName: string, + mapTokenScopes?: (scopes: readonly string[]) => string ): TextMateTokensProvider { return { getInitialState() { @@ -64,7 +66,7 @@ function createTokensProvider( startIndex: token.startIndex, // Why: Monaco themes match a single token scope; TextMate returns a // scope stack, and the final entry is the most specific reusable one. - scopes: token.scopes.at(-1) ?? fallbackScopeName + scopes: mapTokenScopes?.(token.scopes) ?? token.scopes.at(-1) ?? fallbackScopeName })) } } @@ -94,5 +96,5 @@ export async function createTextMateTokensProvider( throw new Error(`No TextMate grammar registered for scope ${options.scopeName}`) } - return createTokensProvider(grammar, options.scopeName) + return createTokensProvider(grammar, options.scopeName, options.mapTokenScopes) } diff --git a/src/renderer/src/lib/monaco-setup.ts b/src/renderer/src/lib/monaco-setup.ts index 5c2c69d24cb..f3357a4d3fc 100644 --- a/src/renderer/src/lib/monaco-setup.ts +++ b/src/renderer/src/lib/monaco-setup.ts @@ -10,6 +10,7 @@ import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker' import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker' import { registerAstroLanguage } from './monaco-languages/register-astro' import { registerJsonlLanguage } from './monaco-languages/register-jsonl' +import { registerHaskellLanguage } from './monaco-languages/register-haskell' import { registerNimLanguage } from './monaco-languages/register-nim' import { registerShellMarkdownAliases } from './monaco-languages/register-shell-markdown-aliases' import { registerSvelteLanguage } from './monaco-languages/register-svelte' @@ -83,6 +84,7 @@ runMonacoSetupSteps([ ['Svelte language registration', () => registerSvelteLanguage(monaco)], ['Astro language registration', () => registerAstroLanguage(monaco)], ['Nim language registration', () => registerNimLanguage(monaco)], + ['Haskell language registration', () => registerHaskellLanguage(monaco)], ['Typst language registration', () => registerTypstLanguage(monaco)], ['JSONL language registration', () => registerJsonlLanguage(monaco)], ['shell Markdown alias registration', () => registerShellMarkdownAliases(monaco)], diff --git a/tests/e2e/editor-haskell-highlighting.spec.ts b/tests/e2e/editor-haskell-highlighting.spec.ts new file mode 100644 index 00000000000..a387a48b992 --- /dev/null +++ b/tests/e2e/editor-haskell-highlighting.spec.ts @@ -0,0 +1,99 @@ +import { writeFileSync } from 'node:fs' +import path from 'node:path' +import type { Locator } from '@stablyai/playwright-test' +import { detectLanguage } from '../../src/renderer/src/lib/language-detect' +import { test, expect } from './helpers/orca-app' +import { + cleanupGoldenWorktree, + createGoldenWorktree, + openGoldenSourceControl +} from './helpers/golden-source-control' +import { waitForSessionReady } from './helpers/store' + +const source = [ + 'module Main where', + '-- A comment', + 'answer :: Int', + 'answer = 42', + 'main = putStrLn "hello"', + '' +].join('\n') + +async function colors(editor: Locator): Promise> { + return editor.locator('.view-lines:not(.line-delete)').evaluate((lines) => { + const colorOf = (text: string) => { + const span = [...lines.querySelectorAll('span[class*="mtk"]')].find((entry) => + entry.textContent?.includes(text) + ) + return span ? getComputedStyle(span).color : null + } + return { + keyword: colorOf('module'), + comment: colorOf('comment'), + type: colorOf('Int'), + number: colorOf('42'), + string: colorOf('hello') + } + }) +} + +async function expectHighlighted(editor: Locator): Promise { + await expect(editor.locator('.view-lines:not(.line-delete)')).toContainText('module Main where') + await expect + .poll( + async () => { + const painted = await colors(editor) + return ( + Object.values(painted).every((color) => color !== null) && + new Set(Object.values(painted)).size >= 4 + ) + }, + { timeout: 25_000 } + ) + .toBe(true) +} + +test('highlights recognized Haskell source and an untracked file diff', async ({ + orcaPage, + testRepoPath, + registerPostElectronShutdownCleanup +}, testInfo) => { + const fixture = createGoldenWorktree(testRepoPath, 'haskell-highlighting') + registerPostElectronShutdownCleanup(async () => cleanupGoldenWorktree(testRepoPath, fixture)) + const filePath = path.join(fixture.worktreePath, 'Main.hs') + writeFileSync(filePath, source) + + await waitForSessionReady(orcaPage) + await openGoldenSourceControl(orcaPage, testRepoPath, fixture) + await orcaPage.evaluate( + ({ filePath, language }) => { + const store = window.__store + const worktreeId = store?.getState().activeWorktreeId + if (!store || !worktreeId) { + throw new Error('Haskell fixture worktree unavailable') + } + store + .getState() + .openFile({ filePath, relativePath: 'Main.hs', worktreeId, language, mode: 'edit' }) + }, + { filePath, language: detectLanguage(filePath) } + ) + const sourceEditor = orcaPage.locator('.monaco-editor').first() + await expectHighlighted(sourceEditor) + await expect + .poll(() => orcaPage.evaluate(() => window.__monacoEditorE2E?.snapshot().valueLength)) + .toBe(source.length) + await orcaPage.screenshot({ path: testInfo.outputPath('haskell-source.png') }) + + await orcaPage + .locator('[data-testid="source-control-entry"]') + .filter({ hasText: 'Main.hs' }) + .click() + await expectHighlighted(orcaPage.locator('.modified-in-monaco-diff-editor')) + await orcaPage.screenshot({ path: testInfo.outputPath('haskell-diff.png') }) + + await orcaPage.evaluate(() => window.__store?.getState().updateSettings({ theme: 'dark' })) + const darkDiff = orcaPage.locator('.modified-in-monaco-diff-editor.vs-dark') + await expectHighlighted(darkDiff) + await orcaPage.screenshot({ path: testInfo.outputPath('haskell-diff-dark.png') }) +})