From e5e50bafe3b64ac6d1b7f7f4fa99dc21ed88f868 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Wed, 7 Oct 2026 00:13:04 +0200 Subject: [PATCH 1/2] Add isolated Swift highlighting Interpolated, multi-line, and raw strings with nested quotes in interpolations, regex literals vs division, nested block comments, contextual keywords, attributes, and directives. Co-Authored-By: Claude Fable 5.1 --- src/languages/swift.ts | 88 ++++++++++ test/swift.test.ts | 376 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 464 insertions(+) create mode 100644 src/languages/swift.ts create mode 100644 test/swift.test.ts diff --git a/src/languages/swift.ts b/src/languages/swift.ts new file mode 100644 index 0000000..f5f6a9f --- /dev/null +++ b/src/languages/swift.ts @@ -0,0 +1,88 @@ +import { defineLanguage, type TokenRange } from '../core.js' +import { collectPatternRanges } from '../internal/patterns.js' + +const patterns = [ + { className: 'meta', regex: /^#!.*|#\w+/gm }, + { className: 'attr', regex: /@\w+/g }, + { className: 'variable', regex: /\$\w+/g }, + // Argument and parameter labels; the lookbehind keeps ternaries and switch cases plain. + { className: 'property', regex: /\b(?=[a-z]\w*:)(?<=[(,]\s*(?:\w+[ \t]+)?)\w+/g }, + // Contextual words are keywords only before declarations, types, or as accessors. + { + className: 'keyword', + regex: /(?=?]+/g }, +] satisfies Parameters[1] + +// `(? = [] + // Comments; string openers (closed by stringEnd); `#/…/#` regexes, multi-line only when `#/` ends + // the line and running to the line (or input) end when unterminated so retries stay linear; + // bare `/re/` only where an operand is expected, never across lines. + const lexical = /(\/\/.*|\/\*)|(? = [] + let open: RegExpExecArray | null + do { + const top = stack.at(-1) ?? ')' + const hole = top[0] === ')' + const char = code[index] + quote.lastIndex = index + if (hole && (open = quote.exec(code))) { + stack.push(open[2] + open[1]) + index = quote.lastIndex + } else if (char === '\n' && !(hole ? stack.at(-2)! : top).startsWith('"""')) { + // A line break ends every single-line string, and the holes inside it, down to the nearest `"""`. + while (stack.length && !stack.at(-1)!.startsWith('"""')) stack.pop() + if (stack.length) index++ + } else if (hole) { + if (char === '(') stack.push(stack.pop() + ')') + else if (char === ')' && stack.pop()!.length > 1) stack.push(top.slice(1)) + index++ + } else if (code.startsWith(top, index)) { + stack.pop() + index += top.length + } else { + const hashes = top.replace(/"+/, '') + if (char === '\\' && code.startsWith(hashes, index + 1)) { + index += hashes.length + 1 + if (code[index] === '(') stack.push(')') + if (code[index] !== '\n') index++ + } else index++ + } + } while (stack.length && index < code.length) + return Math.min(index, code.length) +} diff --git a/test/swift.test.ts b/test/swift.test.ts new file mode 100644 index 0000000..4fff0f6 --- /dev/null +++ b/test/swift.test.ts @@ -0,0 +1,376 @@ +import { describe, expect, it } from 'vitest' +import { createHighlighter } from '../src/core' +import { swift } from '../src/languages/swift' +import { tokenClasses } from './token-classes' + +const highlighter = createHighlighter({ languages: [swift] }) +const classes = (code: string, text: string) => tokenClasses(highlighter, code, text, 'swift') + +const showcase = `import SwiftUI + +/// Loads items from the API. +/// \`\`\`swift +/// let model = ItemsModel() // it's "easy" +/// \`\`\` +@MainActor +@Observable +final class ItemsModel { + private(set) var items: [Item] = [] + weak var delegate: Delegate? + + func load(_ type: T.Type, from url: URL) async throws -> [T] { + let (data, _) = try await URLSession.shared.data(from: url) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + throw APIError.badStatus(code: 0xFF) + } + print("Loaded \\(items.count) items, first: \\(items.first?.name ?? "none")") + return try JSONDecoder().decode([T].self, from: data) + } +} + +struct ContentView: View { + @State private var count = 0 + @Environment(\\.dismiss) private var dismiss + @Binding var isOn: Bool + + var body: some View { + VStack(spacing: 12) { + Text("Count: \\(count)") + .font(.title) + Button("Increment") { count += 1 } + Toggle("On", isOn: $isOn) + ForEach(items, id: \\.id) { item in + Text(item.name) + } + } + .padding() + .onAppear { + withAnimation(.spring) { count = 0 } + } + } +} + +#Preview { + ContentView(isOn: .constant(true)) +} +` + +describe('Swift documentation syntax', () => { + it('highlights a realistic SwiftUI and async/await sample', () => { + const code = showcase + expect(classes(code, 'import SwiftUI')).toEqual(['keyword', undefined, 'type']) + expect(classes(code, '/// Loads items from the API.')).toEqual(['comment']) + expect(classes(code, '/// let model = ItemsModel() // it\'s "easy"')).toEqual(['comment']) + expect(classes(code, '@MainActor')).toEqual(['attr']) + expect(classes(code, 'final class ItemsModel {')).toEqual(['keyword', undefined, 'keyword', undefined, 'type', undefined]) + expect(classes(code, 'private(set) var items: [Item] = []')).toEqual(['keyword', undefined, 'keyword', undefined, 'keyword', undefined, 'type', undefined, 'operator', undefined]) + expect(classes(code, 'weak var delegate: Delegate?')).toEqual(['keyword', undefined, 'keyword', undefined, 'type', 'operator']) + expect(classes(code, 'func load(_ type: T.Type, from url: URL)')).toEqual([ + 'keyword', undefined, 'function', 'operator', undefined, 'type', 'operator', undefined, 'property', undefined, 'type', undefined, 'property', undefined, + ]) + expect(classes(code, 'async throws -> [T]')).toEqual(['keyword', undefined, 'keyword', undefined, 'operator', undefined]) + expect(classes(code, 'try await URLSession.shared.data(from: url)')).toEqual([ + 'keyword', undefined, 'keyword', undefined, 'type', undefined, 'property', undefined, 'function', undefined, 'property', undefined, + ]) + expect(classes(code, 'as? HTTPURLResponse')).toEqual(['keyword', 'operator', undefined, 'type']) + expect(classes(code, 'http.statusCode == 200 else')).toEqual([undefined, 'property', undefined, 'operator', undefined, 'number', undefined, 'keyword']) + expect(classes(code, 'throw APIError.badStatus(code: 0xFF)')).toEqual(['keyword', undefined, 'type', undefined, 'function', undefined, 'property', undefined, 'number', undefined]) + expect(classes(code, '"Loaded \\(items.count) items, first: \\(items.first?.name ?? "none")"')).toEqual(['string']) + expect(classes(code, '[T].self, from: data')).toEqual([undefined, 'keyword', undefined, 'property', undefined]) + expect(classes(code, 'struct ContentView: View {')).toEqual(['keyword', undefined, 'type', undefined, 'type', undefined]) + expect(classes(code, '@State private var count = 0')).toEqual(['attr', undefined, 'keyword', undefined, 'keyword', undefined, 'operator', undefined, 'number']) + expect(classes(code, '@Environment(\\.dismiss)')).toEqual(['attr', undefined, 'property', undefined]) + expect(classes(code, 'var body: some View {')).toEqual(['keyword', undefined, 'keyword', undefined, 'type', undefined]) + expect(classes(code, 'VStack(spacing: 12) {')).toEqual(['type', undefined, 'property', undefined, 'number', undefined]) + expect(classes(code, 'Text("Count: \\(count)")')).toEqual(['type', undefined, 'string', undefined]) + expect(classes(code, '.font(.title)')).toEqual([undefined, 'function', undefined, 'property', undefined]) + expect(classes(code, 'count += 1 }')).toEqual([undefined, 'operator', undefined, 'number', undefined]) + expect(classes(code, 'isOn: $isOn)')).toEqual(['property', undefined, 'variable', undefined]) + expect(classes(code, 'id: \\.id) { item in')).toEqual(['property', undefined, 'property', undefined, 'keyword']) + expect(classes(code, '.padding()')).toEqual([undefined, 'function', undefined]) + expect(classes(code, '.onAppear {')).toEqual([undefined, 'function', undefined]) + expect(classes(code, 'withAnimation(.spring)')).toEqual(['function', undefined, 'property', undefined]) + expect(classes(code, '#Preview {')).toEqual(['meta', undefined]) + expect(classes(code, '.constant(true)')).toEqual([undefined, 'function', undefined, 'literal', undefined]) + }) + + it('finds the end of interpolated strings with nested quotes, parens, and closures', () => { + const code = [ + 'let a = "\\(dict["key"] ?? "none")" + tail', + 'let b = "\\(items.map { "<\\($0)>" }.joined(separator: ", "))" + tail', + 'let c = "count: \\(a + (b * 2))" + tail', + 'let d = "say \\"hi\\" // not a comment" + tail', + 'let e = "url: https://example.com/a?b=c" + tail', + 'let f = "" + "x" + tail', + ].join('\n') + expect(classes(code, '"\\(dict["key"] ?? "none")" + tail')).toEqual(['string', undefined, 'operator', undefined]) + expect(classes(code, '"\\(items.map { "<\\($0)>" }.joined(separator: ", "))" + tail')).toEqual(['string', undefined, 'operator', undefined]) + expect(classes(code, '"count: \\(a + (b * 2))" + tail')).toEqual(['string', undefined, 'operator', undefined]) + expect(classes(code, '"say \\"hi\\" // not a comment" + tail')).toEqual(['string', undefined, 'operator', undefined]) + expect(classes(code, '"url: https://example.com/a?b=c" + tail')).toEqual(['string', undefined, 'operator', undefined]) + expect(classes(code, '"" + "x" + tail')).toEqual(['string', undefined, 'operator', undefined, 'string', undefined, 'operator', undefined]) + }) + + it('keeps multi-line strings open until the closing delimiter', () => { + const code = [ + 'let text = """', + ' He said "hi" and "" and \\"""', + ' \\(dict["key"] ?? "none") // still text', + ' \\(format(', + ' value', + ' ))', + ' """', + 'let after = 1', + ].join('\n') + expect(classes(code, '"""\n He said')).toEqual(['string']) + expect(classes(code, '// still text')).toEqual(['string']) + expect(classes(code, '))\n """')).toEqual(['string']) + expect(classes(code, 'let after = 1')).toEqual(['keyword', undefined, 'operator', undefined, 'number']) + const nested = 'let a = """\n \\(f("""\n \\(g("x\n", (1))) )\n """))\n """\nlet b = 2' + expect(classes(nested, '"""\n \\(f(')).toEqual(['string']) + expect(classes(nested, '"""))\n """')).toEqual(['string']) + expect(classes(nested, 'let b = 2')).toEqual(['keyword', undefined, 'operator', undefined, 'number']) + }) + + it('stops unterminated single-line strings at the line break', () => { + const code = 'let a = "unterminated\nlet b = 1\nlet c = "x \\(foo("y"\nlet d = 2' + expect(classes(code, '"unterminated')).toEqual(['string']) + expect(classes(code, 'let b = 1')).toEqual(['keyword', undefined, 'operator', undefined, 'number']) + expect(classes(code, '"x \\(foo("y"')).toEqual(['string']) + expect(classes(code, 'let d = 2')).toEqual(['keyword', undefined, 'operator', undefined, 'number']) + }) + + it('matches raw string hash counts and raw interpolation', () => { + const code = [ + 'let a = #"a "quoted" \\n not an escape"# + tail', + 'let b = ##"has "# inside"## + tail', + 'let c = #"value: \\#(f("x")) literal: \\(x)"# + tail', + 'let d = #"""', + ' contains """ and "# and \\#(real("y"))', + ' """# + tail', + 'let e = #"ends with backslash \\"# + tail', + ].join('\n') + expect(classes(code, '#"a "quoted" \\n not an escape"# + tail')).toEqual(['string', undefined, 'operator', undefined]) + expect(classes(code, '##"has "# inside"## + tail')).toEqual(['string', undefined, 'operator', undefined]) + expect(classes(code, '#"value: \\#(f("x")) literal: \\(x)"# + tail')).toEqual(['string', undefined, 'operator', undefined]) + expect(classes(code, 'contains """ and "#')).toEqual(['string']) + expect(classes(code, '"""# + tail')).toEqual(['string', undefined, 'operator', undefined]) + expect(classes(code, '#"ends with backslash \\"# + tail')).toEqual(['string', undefined, 'operator', undefined]) + }) + + it('separates regex literals from division', () => { + const code = [ + 'let a = /ab+c/', + 'let b = #/[a-z]+"/"/# + tail', + 'let c = #/', + ' (? \\w+ ) # "quoted"', + '/#', + 'let m = line.firstMatch(of: /\\d+/)', + 'func f() -> Regex { return /x+/ }', + 'let ratio = total / Double(count)', + 'x /= 2', + 'let half = (a + b) / 2 / 3', + 'let e = items.reduce(1, /) + 2', + 'let g = #/unterminated', + 'let h = 1', + ].join('\n') + expect(classes(code, '/ab+c/')).toEqual(['string']) + expect(classes(code, '#/[a-z]+"/"/# + tail')).toEqual(['string', undefined, 'operator', undefined]) + expect(classes(code, '#/\n (? \\w+ ) # "quoted"\n/#')).toEqual(['string']) + expect(classes(code, '(of: /\\d+/)')).toEqual([undefined, 'property', undefined, 'string', undefined]) + expect(classes(code, 'return /x+/ }')).toEqual(['keyword', undefined, 'string', undefined]) + expect(classes(code, 'total / Double(count)')).toEqual([undefined, 'operator', undefined, 'type', undefined]) + expect(classes(code, 'x /= 2')).toEqual([undefined, 'operator', undefined, 'number']) + expect(classes(code, '(a + b) / 2 / 3')).toEqual([undefined, 'operator', undefined, 'operator', undefined, 'number', undefined, 'operator', undefined, 'number']) + expect(classes(code, '(1, /) + 2')).toEqual([undefined, 'number', undefined, 'operator', undefined, 'operator', undefined, 'number']) + expect(classes(code, '#/unterminated')).toEqual(['string']) + expect(classes(code, 'let h = 1')).toEqual(['keyword', undefined, 'operator', undefined, 'number']) + }) + + it('nests block comments and keeps delimiters inside comments and strings', () => { + const code = [ + '/* outer /* inner "quote" */ it\'s still comment */ let a = 1', + '/** Doc with `code` and "quotes" */', + 'let url = "https://example.com/path" // trailing "comment"', + 'let s = "/* not a comment */"', + '/* unterminated /* nested */', + 'let hidden = 2', + ].join('\n') + expect(classes(code, '/* outer /* inner "quote" */ it\'s still comment */ let')).toEqual(['comment', undefined, 'keyword']) + expect(classes(code, '/** Doc with `code` and "quotes" */')).toEqual(['comment']) + expect(classes(code, '"https://example.com/path" // trailing "comment"')).toEqual(['string', undefined, 'comment']) + expect(classes(code, '"/* not a comment */"')).toEqual(['string']) + expect(classes(code, '/* unterminated /* nested */\nlet hidden = 2')).toEqual(['comment']) + }) + + it('marks attributes, directives, and freestanding macros', () => { + const code = [ + '#if os(iOS)', + 'let x = 1', + '#elseif DEBUG', + '#else', + '#endif', + '@available(iOS 17, *)', + '@resultBuilder struct Builder {}', + 'func run(completion: @escaping () -> Void) {}', + 'if #available(iOS 17, *) { }', + 'let sel = #selector(tap)', + '@Test func check() { #expect(a == b); let v = try #require(value) }', + ].join('\n') + expect(classes(code, '#if os(iOS)')).toEqual(['meta', undefined, 'function', undefined]) + expect(classes(code, '#elseif DEBUG')).toEqual(['meta', undefined]) + expect(classes(code, '#endif')).toEqual(['meta']) + expect(classes(code, '@available(iOS 17, *)')).toEqual(['attr', undefined, 'number', undefined, 'operator', undefined]) + expect(classes(code, '@resultBuilder struct')).toEqual(['attr', undefined, 'keyword']) + expect(classes(code, '@escaping () -> Void')).toEqual(['attr', undefined, 'operator', undefined, 'type']) + expect(classes(code, 'if #available(')).toEqual(['keyword', undefined, 'meta', undefined]) + expect(classes(code, '#selector(tap)')).toEqual(['meta', undefined]) + expect(classes(code, '#expect(a == b)')).toEqual(['meta', undefined, 'operator', undefined]) + expect(classes(code, 'try #require(value)')).toEqual(['keyword', undefined, 'meta', undefined]) + }) + + it('keeps contextual keywords plain when they are identifiers or members', () => { + const code = [ + 'let package = Package(name: "Lib", dependencies: [.package(url: "https://x.y/z.git", from: "1.0.0")])', + 'switch style { case .default: break; default: break }', + 'style = .default; door.open; let copy = x.copy()', + 'var set = Set(); set.insert(1); let open = optional ?? lazy', + 'let `default` = 1; let `self` = 2', + 'for each in items { }', + ].join('\n') + expect(classes(code, 'let package = Package(name: "Lib"')).toEqual(['keyword', undefined, 'operator', undefined, 'type', undefined, 'property', undefined, 'string']) + expect(classes(code, '.package(url:')).toEqual([undefined, 'function', undefined, 'property', undefined]) + expect(classes(code, 'case .default: break; default:')).toEqual(['keyword', undefined, 'property', undefined, 'keyword', undefined, 'keyword', undefined]) + expect(classes(code, 'style = .default; door.open; let copy = x.copy()')).toEqual([ + undefined, 'operator', undefined, 'property', undefined, 'property', undefined, 'keyword', undefined, 'operator', undefined, 'function', undefined, + ]) + expect(classes(code, 'var set = Set(); set.insert')).toEqual(['keyword', undefined, 'operator', undefined, 'type', 'operator', 'type', 'operator', undefined, 'function']) + expect(classes(code, 'let open = optional ?? lazy')).toEqual(['keyword', undefined, 'operator', undefined, 'operator', undefined]) + expect(classes(code, '`default` = 1; let `self` = 2')).toEqual([undefined, 'operator', undefined, 'number', undefined, 'keyword', undefined, 'operator', undefined, 'number']) + expect(classes(code, 'for each in items')).toEqual(['keyword', undefined, 'keyword', undefined]) + }) + + it('keeps declaration modifiers, accessors, and self-like words as keywords', () => { + const code = [ + 'open class Base { open override func run() {} }', + 'package func helper() {}', + 'public internal(set) var count = 0; fileprivate(set) var a = 0', + 'var name: String { get set }', + 'var value: Int { get async throws { 0 } }', + 'var score = 0 { willSet { } didSet { } }', + 'func make() -> some View { body }', + 'let shape: any Shape = Circle()', + 'async let task = fetch()', + 'lazy var cache = [String: Int]()', + 'required init?(coder: NSCoder) { super.init(coder: coder) }', + 'let type = Foo.self; let kp = \\.self; let s = Self.init()', + 'actor Counter { nonisolated func id() {} }', + 'indirect enum Tree { case node(Tree) }', + 'handler = { [weak self] value in self?.update(value) }', + ].join('\n') + expect(classes(code, 'open class Base')).toEqual(['keyword', undefined, 'keyword', undefined, 'type']) + expect(classes(code, 'open override func run()')).toEqual(['keyword', undefined, 'keyword', undefined, 'keyword', undefined, 'function', undefined]) + expect(classes(code, 'package func helper')).toEqual(['keyword', undefined, 'keyword', undefined, 'function']) + expect(classes(code, 'public internal(set) var')).toEqual(['keyword', undefined, 'keyword', undefined, 'keyword', undefined, 'keyword']) + expect(classes(code, 'fileprivate(set) var')).toEqual(['keyword', undefined, 'keyword', undefined, 'keyword']) + expect(classes(code, '{ get set }')).toEqual([undefined, 'keyword', undefined, 'keyword', undefined]) + expect(classes(code, '{ get async throws {')).toEqual([undefined, 'keyword', undefined, 'keyword', undefined, 'keyword', undefined]) + expect(classes(code, 'willSet { } didSet')).toEqual(['keyword', undefined, 'keyword']) + expect(classes(code, 'some View { body }')).toEqual(['keyword', undefined, 'type', undefined]) + expect(classes(code, 'any Shape = Circle()')).toEqual(['keyword', undefined, 'type', undefined, 'operator', undefined, 'type', undefined]) + expect(classes(code, 'async let task')).toEqual(['keyword', undefined, 'keyword', undefined]) + expect(classes(code, 'lazy var cache')).toEqual(['keyword', undefined, 'keyword', undefined]) + expect(classes(code, 'required init?(coder: NSCoder)')).toEqual(['keyword', undefined, 'keyword', 'operator', undefined, 'property', undefined, 'type', undefined]) + expect(classes(code, 'super.init(coder: coder)')).toEqual(['keyword', undefined, 'keyword', undefined, 'property', undefined]) + expect(classes(code, 'Foo.self; let kp = \\.self; let s = Self.init()')).toEqual([ + 'type', undefined, 'keyword', undefined, 'keyword', undefined, 'operator', undefined, 'keyword', undefined, 'keyword', undefined, 'operator', undefined, 'keyword', undefined, 'keyword', undefined, + ]) + expect(classes(code, 'actor Counter { nonisolated func')).toEqual(['keyword', undefined, 'type', undefined, 'keyword', undefined, 'keyword']) + expect(classes(code, 'indirect enum Tree')).toEqual(['keyword', undefined, 'keyword', undefined, 'type']) + expect(classes(code, '[weak self] value in self?.update(value)')).toEqual([ + undefined, 'keyword', undefined, 'keyword', undefined, 'keyword', undefined, 'keyword', 'operator', undefined, 'function', undefined, + ]) + }) + + it('classifies numbers, ranges, tuples, and operators', () => { + for (const number of ['1_000_000', '0xFF', '0b1010', '0o17', '1.5e-3', '2.5E+10', '0x1.8p3', '0xFFp-2', '0xaBc', '1E3', '3.14', '42']) { + expect(classes(`let n = ${number}`, number), number).toEqual(['number']) + } + const code = 'let r = 0..<10; let c = 1...5; let t = point.0; let v = 1.2.3; let x = a1 ?? b\nlet ok = a !== b && c >= d || !e; let z = x &+ y; let p = q?.name' + expect(classes(code, '0..<10')).toEqual(['number', 'operator', 'number']) + expect(classes(code, '1...5')).toEqual(['number', 'operator', 'number']) + expect(classes(code, 'point.0')).toEqual([undefined]) + expect(classes(code, 'v = 1.2.3')).toEqual([undefined, 'operator', undefined]) + expect(classes(code, 'a1 ?? b')).toEqual([undefined, 'operator', undefined]) + expect(classes(code, 'a !== b && c >= d || !e')).toEqual([undefined, 'operator', undefined, 'operator', undefined, 'operator', undefined, 'operator', undefined, 'operator', undefined]) + expect(classes(code, 'x &+ y')).toEqual([undefined, 'operator', undefined]) + expect(classes(code, 'q?.name')).toEqual([undefined, 'operator', undefined, 'property']) + }) + + it('separates labels, ternaries, closures, and declarations', () => { + const code = [ + 'let sum = cond ? a : b', + 'f(a ? b : c, label: d)', + 'func greet(person name: String, _ count: Int = 1) {}', + 'let names = users.map { user in user.name }.sorted { $0 < $1 }', + 'if list.isEmpty { return }', + 'for item in model.items { }', + 'typealias Handler = (Result) -> Void', + 'enum API { case v1 }', + 'protocol Shape { associatedtype Value: Numeric }', + 'extension Array where Element: Equatable { }', + 'let café = "naïve"; let ÿ = 1', + 'infix operator <>: AdditionPrecedence', + 'let ids = users.filter { !$0.isEmpty }.sorted(by: <)', + ].join('\n') + expect(classes(code, 'cond ? a : b')).toEqual([undefined, 'operator', undefined]) + expect(classes(code, 'f(a ? b : c, label: d)')).toEqual(['function', undefined, 'operator', undefined, 'property', undefined]) + expect(classes(code, 'greet(person name: String, _ count: Int = 1)')).toEqual([ + 'function', undefined, 'property', undefined, 'type', undefined, 'property', undefined, 'type', undefined, 'operator', undefined, 'number', undefined, + ]) + expect(classes(code, 'users.map { user in user.name }.sorted { $0 < $1 }')).toEqual([ + undefined, 'function', undefined, 'keyword', undefined, 'property', undefined, 'function', undefined, 'variable', undefined, 'operator', undefined, 'variable', undefined, + ]) + expect(classes(code, 'list.isEmpty { return }')).toEqual([undefined, 'property', undefined, 'keyword', undefined]) + expect(classes(code, 'model.items { }')).toEqual([undefined, 'property', undefined]) + expect(classes(code, 'typealias Handler = (Result) -> Void')).toEqual([ + 'keyword', undefined, 'type', undefined, 'operator', undefined, 'type', 'operator', 'type', undefined, 'type', 'operator', undefined, 'operator', undefined, 'type', + ]) + expect(classes(code, 'enum API { case v1 }')).toEqual(['keyword', undefined, 'type', undefined, 'keyword', undefined]) + expect(classes(code, 'associatedtype Value: Numeric')).toEqual(['keyword', undefined, 'type', undefined, 'type']) + expect(classes(code, 'extension Array where Element')).toEqual(['keyword', undefined, 'type', undefined, 'keyword', undefined, 'type']) + expect(classes(code, 'let café = "naïve"; let ÿ = 1')).toEqual(['keyword', undefined, 'operator', undefined, 'string', undefined, 'keyword', undefined, 'operator', undefined, 'number']) + expect(classes(code, 'infix operator <>: AdditionPrecedence')).toEqual(['keyword', undefined, 'keyword', undefined, 'operator', undefined, 'type']) + expect(classes(code, 'users.filter { !$0.isEmpty }.sorted(by: <)')).toEqual([ + undefined, 'function', undefined, 'operator', 'variable', undefined, 'property', undefined, 'function', undefined, 'property', undefined, 'operator', undefined, + ]) + }) + + it('highlights Package.swift manifests', () => { + const code = `// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "Networking", + platforms: [.iOS(.v17), .macOS(.v14)], + products: [.library(name: "Networking", targets: ["Networking"])], + dependencies: [ + .package(url: "https://github.com/apple/swift-log.git", from: "1.5.0"), + ], + targets: [.target(name: "Networking"), .testTarget(name: "NetworkingTests", dependencies: ["Networking"])] +) +` + expect(classes(code, '// swift-tools-version: 5.9')).toEqual(['comment']) + expect(classes(code, 'let package = Package(')).toEqual(['keyword', undefined, 'operator', undefined, 'type', undefined]) + expect(classes(code, 'platforms: [.iOS(.v17)')).toEqual(['property', undefined, 'function', undefined, 'property', undefined]) + expect(classes(code, '.package(url: "https://github.com/apple/swift-log.git", from: "1.5.0")')).toEqual([ + undefined, 'function', undefined, 'property', undefined, 'string', undefined, 'property', undefined, 'string', undefined, + ]) + expect(classes(code, '.testTarget(name:')).toEqual([undefined, 'function', undefined, 'property', undefined]) + }) + + it('registers only the swift name', () => { + expect(highlighter.normalizeLanguage('swift')).toBe('swift') + expect(createHighlighter({ languages: [] }).normalizeLanguage('swift')).toBe('plaintext') + }) +}) From 0b8d42989f40dadbfd1d62e632a8a4ca830b7fb9 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Wed, 7 Oct 2026 00:13:04 +0200 Subject: [PATCH 2/2] Register Swift with size, benchmark, and docs coverage Adds Swift to the default entry and language barrel, with a size and benchmark profile, a representative fixture, docs tables, and a minor changeset. The all-language convenience entry grows from 16.82 KB to 17.73 KB gzip and its budget rises to 18.0 KB; core and existing selective profiles are unchanged. Co-Authored-By: Claude Fable 5.1 --- .changeset/swift.md | 5 +++++ README.md | 4 ++-- docs/guides/performance.md | 6 +++--- docs/installation.md | 2 +- docs/language-support.md | 1 + docs/reference/default-entry.md | 2 +- docs/reference/languages.md | 3 ++- docs/test-strategy.md | 6 +++--- scripts/bench.mjs | 6 ++++++ scripts/language-utils.mjs | 1 + scripts/measure-size.mjs | 11 ++++++++++- .../references/languages.md | 1 + src/index.ts | 3 +++ src/languages/index.ts | 1 + test/fixtures.ts | 6 ++++++ test/real-doc-fixtures.test.ts | 2 +- 16 files changed, 47 insertions(+), 13 deletions(-) create mode 100644 .changeset/swift.md diff --git a/.changeset/swift.md b/.changeset/swift.md new file mode 100644 index 0000000..8ae4a4e --- /dev/null +++ b/.changeset/swift.md @@ -0,0 +1,5 @@ +--- +"@tanstack/highlight": minor +--- + +Add an isolated Swift language definition. Core and existing selective bundles are unchanged. diff --git a/README.md b/README.md index 36cc574..787ad11 100644 --- a/README.md +++ b/README.md @@ -195,7 +195,7 @@ Available themes: Aurora X, Dracula, GitHub Dark, GitHub Light, Gruvbox Dark, Gr ## Languages -`apache`, `cmake`, `cpp`, `csharp`, `css`, `dart`, `diff`, `dockerfile`, `ejs`, `env`, `go`, `html`, `http`, `java`, `js`, `json`, `jsx`, `kotlin`, `lua`, `markdown`, `mermaid`, `nginx`, `perl`, `php`, `plaintext`, `python`, `ruby`, `rust`, `scheme`, `shell`, `sql`, `svelte`, `toml`, `ts`, `tsrx`, `tsx`, `vue`, and `yaml`. +`apache`, `cmake`, `cpp`, `csharp`, `css`, `dart`, `diff`, `dockerfile`, `ejs`, `env`, `go`, `html`, `http`, `java`, `js`, `json`, `jsx`, `kotlin`, `lua`, `markdown`, `mermaid`, `nginx`, `perl`, `php`, `plaintext`, `python`, `ruby`, `rust`, `scheme`, `shell`, `sql`, `svelte`, `swift`, `toml`, `ts`, `tsrx`, `tsx`, `vue`, and `yaml`. Each language is available from `@tanstack/highlight/languages/`. The aggregate `@tanstack/highlight/languages` entry can tree-shake, while direct subpaths make isolation explicit. Importing only core helpers from the root entry also removes unused language registrations in a compatible bundler. @@ -218,7 +218,7 @@ Local browser bundles, minified with esbuild and compressed independently. KB us | Core + TSX | 10.11 KB | 4.26 KB | 3.90 KB | | Octane MDX + TypeScript | 13.86 KB | 5.59 KB | 5.17 KB | | Nine-language docs set | 16.04 KB | 6.20 KB | 5.66 KB | -| All 38 languages | 49.99 KB | 16.82 KB | 15.19 KB | +| All 39 languages | 52.74 KB | 17.73 KB | 15.93 KB | The following comparison was measured before the 1.0 property-context correction. Re-run the comparison commands below for current timings. diff --git a/docs/guides/performance.md b/docs/guides/performance.md index 36a659d..9bf4fce 100644 --- a/docs/guides/performance.md +++ b/docs/guides/performance.md @@ -8,7 +8,7 @@ CI measures selective browser bundles and highlighting performance on real docum ## Bundle profiles -`pnpm run size` builds twenty-five browser profiles with esbuild and measures minified, gzip, and Brotli bytes independently. It also checks that helper, adapter, and selective language imports retain only the requested modules. +`pnpm run size` builds twenty-six browser profiles with esbuild and measures minified, gzip, and Brotli bytes independently. It also checks that helper, adapter, and selective language imports retain only the requested modules. | Profile | Registered languages | Current gzip | CI budget | | --- | --- | ---: | ---: | @@ -16,7 +16,7 @@ CI measures selective browser bundles and highlighting performance on real docum | TSX | TSX | 4.26 KB | 4.35 KB | | Octane | TypeScript plus Octane MDX adapter | 5.59 KB | 5.7 KB | | Docs | CSS, HTML, JS, JSON, JSX, Markdown, Shell, TS, TSX | 6.20 KB | 6.3 KB | -| All | All 38 definitions | 16.82 KB | 17.1 KB | +| All | All 39 definitions | 17.73 KB | 18.0 KB | KB uses 1,000 bytes. Core helpers imported from the root tree-shake to the same engine size. The standalone theme helper is 695 gzip bytes. @@ -26,7 +26,7 @@ Selective profiles are the primary metric. The all-language profile exists to pr The committed corpus contains 334 real code fences sampled from TanStack documentation, with up to twenty samples per normalized language. -`pnpm run bench` measures tokenization, HTML, Markdown, HAST, line numbers, long decorated blocks, and dedicated C#, C++, CMake, Dart, Java, Kotlin, Lua, Perl, PHP, Ruby, and Rust samples. Each profile reports the median of three samples after two warmup passes, with a 1.2 second CI budget. The main highlighting profile processes at least 10,000 blocks. +`pnpm run bench` measures tokenization, HTML, Markdown, HAST, line numbers, long decorated blocks, and dedicated C#, C++, CMake, Dart, Java, Kotlin, Lua, Perl, PHP, Ruby, Rust, and Swift samples. Each profile reports the median of three samples after two warmup passes, with a 1.2 second CI budget. The main highlighting profile processes at least 10,000 blocks. A local before-and-after review used the same minified bundle settings, fixtures, and benchmark harness on macOS arm64 with Node 24.15.0: diff --git a/docs/installation.md b/docs/installation.md index 27b44f5..30bf9f9 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -60,7 +60,7 @@ export const highlighter = createHighlighter({ }) ``` -The root entry is useful for prototypes, server-only scripts, or sites where the roughly 17 KB gzip all-language build is acceptable: +The root entry is useful for prototypes, server-only scripts, or sites where the roughly 18 KB gzip all-language build is acceptable: ```ts import { highlight } from '@tanstack/highlight' diff --git a/docs/language-support.md b/docs/language-support.md index 62bbcb2..bf63dee 100644 --- a/docs/language-support.md +++ b/docs/language-support.md @@ -42,6 +42,7 @@ Every language is an isolated definition imported from `@tanstack/highlight/lang | Shell | `shell` | `bash`, `sh`, `zsh`, `cmd`, `console` | Heredocs, parameter expansion, comment boundaries | | SQL | `sql` | - | Strings, comments, common SQL clauses | | Svelte | `svelte` | - | Markup plus optional script/style and expression delegation | +| Swift | `swift` | - | Interpolated, multi-line, and raw strings with nested quotes, regex literals vs division, nested block comments, contextual keywords, attributes and directives | | TOML | `toml` | - | Strings, comments, tables, properties | | TypeScript | `ts` | `typescript`, `angular-ts` | JavaScript scanner plus TypeScript keywords/types | | TSRX | `tsrx` | `octane` | TypeScript, contextual JSX, Octane component shorthand and template directives | diff --git a/docs/reference/default-entry.md b/docs/reference/default-entry.md index 3827e28..fa507a3 100644 --- a/docs/reference/default-entry.md +++ b/docs/reference/default-entry.md @@ -43,7 +43,7 @@ Returns the canonical registered language name for a name or alias. Names are tr function listLanguages(): Array ``` -Returns the 38 canonical language names registered in `defaultHighlighter`. +Returns the 39 canonical language names registered in `defaultHighlighter`. ### `tokenize` diff --git a/docs/reference/languages.md b/docs/reference/languages.md index 3cf6f09..bd26d4b 100644 --- a/docs/reference/languages.md +++ b/docs/reference/languages.md @@ -53,6 +53,7 @@ const highlighter = createHighlighter({ | `shell` | `@tanstack/highlight/languages/shell` | `bash`, `sh`, `zsh`, `cmd`, `console` | | `sql` | `@tanstack/highlight/languages/sql` | None | | `svelte` | `@tanstack/highlight/languages/svelte` | None | +| `swift` | `@tanstack/highlight/languages/swift` | None | | `toml` | `@tanstack/highlight/languages/toml` | None | | `ts` | `@tanstack/highlight/languages/ts` | `typescript`, `angular-ts` | | `tsrx` | `@tanstack/highlight/languages/tsrx` | `octane` | @@ -60,6 +61,6 @@ const highlighter = createHighlighter({ | `vue` | `@tanstack/highlight/languages/vue` | None | | `yaml` | `@tanstack/highlight/languages/yaml` | `yml` | -`@tanstack/highlight/languages` re-exports `apache`, `cmake`, `cpp`, `csharp`, `css`, `dart`, `diff`, `dockerfile`, `ejs`, `env`, `go`, `html`, `http`, `java`, `js`, `json`, `jsx`, `kotlin`, `lua`, `markdown`, `mermaid`, `nginx`, `perl`, `php`, `plaintext`, `python`, `ruby`, `rust`, `scheme`, `shell`, `sql`, `svelte`, `toml`, `ts`, `tsrx`, `tsx`, `vue`, and `yaml`. The barrel is convenient but individual subpaths make bundle intent explicit. +`@tanstack/highlight/languages` re-exports `apache`, `cmake`, `cpp`, `csharp`, `css`, `dart`, `diff`, `dockerfile`, `ejs`, `env`, `go`, `html`, `http`, `java`, `js`, `json`, `jsx`, `kotlin`, `lua`, `markdown`, `mermaid`, `nginx`, `perl`, `php`, `plaintext`, `python`, `ruby`, `rust`, `scheme`, `shell`, `sql`, `svelte`, `swift`, `toml`, `ts`, `tsrx`, `tsx`, `vue`, and `yaml`. The barrel is convenient but individual subpaths make bundle intent explicit. See the [language support matrix](../language-support) for the context-aware behavior and current scope of each registration. diff --git a/docs/test-strategy.md b/docs/test-strategy.md index 4237776..8df900b 100644 --- a/docs/test-strategy.md +++ b/docs/test-strategy.md @@ -19,7 +19,7 @@ The suite protects the package's actual product boundary: valid code commonly pu ## Size Profiles -`pnpm run size` checks twenty-five independent browser profiles, including root helpers, language barrel imports, adapters, and themes. Each has minified, gzip, and Brotli budgets. The main highlighter profiles are: +`pnpm run size` checks twenty-six independent browser profiles, including root helpers, language barrel imports, adapters, and themes. Each has minified, gzip, and Brotli budgets. The main highlighter profiles are: | Profile | Languages | Gzip budget | | --- | --- | ---: | @@ -27,13 +27,13 @@ The suite protects the package's actual product boundary: valid code commonly pu | TSX | TSX | 4.35 KB | | Octane | TypeScript plus Octane MDX adapter | 5.7 KB | | Docs | CSS, HTML, JS, JSON, JSX, Markdown, Shell, TS, TSX | 6.3 KB | -| All | All 38 definitions | 17.1 KB | +| All | All 39 definitions | 18.0 KB | The selective profiles are the primary product metric. The all-language profile protects the convenience entry from unbounded growth. Bundle graphs reject unexpected language or theme code. Package tests repeat isolation checks through public exports after building. ## Throughput -`pnpm run bench` measures highlighting, tokenization, Markdown, HAST, line numbers, long numbered blocks, long decorated blocks, and dedicated C#, C++, CMake, Dart, Java, Kotlin, Lua, Perl, PHP, Ruby, and Rust samples. Timings use the median of three samples after two warmup passes. Each profile has a 1.2 second CI budget; the main highlighting profile processes at least 10,000 blocks. +`pnpm run bench` measures highlighting, tokenization, Markdown, HAST, line numbers, long numbered blocks, long decorated blocks, and dedicated C#, C++, CMake, Dart, Java, Kotlin, Lua, Perl, PHP, Ruby, Rust, and Swift samples. Timings use the median of three samples after two warmup passes. Each profile has a 1.2 second CI budget; the main highlighting profile processes at least 10,000 blocks. `pnpm run compare:sugar-high` compares the overlapping JS/TS/JSX/TSX use case. `pnpm run compare:shiki` compares all supported fixtures. These are directional measurements, not claims of equivalent grammar depth. diff --git a/scripts/bench.mjs b/scripts/bench.mjs index f8f7b97..9e56deb 100644 --- a/scripts/bench.mjs +++ b/scripts/bench.mjs @@ -116,6 +116,12 @@ try { observe: (result) => result.html.length, targetBlocks: 10_000, }, + swift: { + fixtures: [{ rawLang: 'swift', code: '@available(iOS 15, *)\nlet greeting = "Hi \\(user.name) \\(dict["key"] ?? "none")"\nlet raw = #"C:\\path "quoted" \\#(name)"#\n/* outer /* nested */ comment */\n#if DEBUG\nlet n = 0x1F + 1_000 + 1.5e3\n#endif' }], + run: (fixture) => highlight(fixture.code, { lang: fixture.rawLang }), + observe: (result) => result.html.length, + targetBlocks: 10_000, + }, tokenize: { fixtures, run: (fixture) => tokenize(fixture.code, { lang: fixture.rawLang }), diff --git a/scripts/language-utils.mjs b/scripts/language-utils.mjs index 39dad9c..b7b4d36 100644 --- a/scripts/language-utils.mjs +++ b/scripts/language-utils.mjs @@ -34,6 +34,7 @@ export const supportedLanguages = [ 'shell', 'sql', 'svelte', + 'swift', 'toml', 'ts', 'tsrx', diff --git a/scripts/measure-size.mjs b/scripts/measure-size.mjs index 2deefd1..25b996b 100644 --- a/scripts/measure-size.mjs +++ b/scripts/measure-size.mjs @@ -170,13 +170,22 @@ const profiles = { languages: ['rust'], limits: { minified: 6_800, gzip: 3_200, brotli: 2_950 }, }, + swift: { + source: ` + import { createHighlighter } from './src/core.ts' + import { swift } from './src/languages/swift.ts' + globalThis.highlighter = createHighlighter({ languages: [swift] }) + `, + languages: ['swift'], + limits: { minified: 7_450, gzip: 3_550, brotli: 3_250 }, + }, all: { source: ` import { defaultHighlighter } from './src/index.ts' globalThis.highlighter = defaultHighlighter `, languages: 'all', - limits: { minified: 50_400, gzip: 17_100, brotli: 15_500 }, + limits: { minified: 53_150, gzip: 18_000, brotli: 16_200 }, }, reactAdapter: { source: `export * from './src/react.ts'`, diff --git a/skills/configure-selective-highlighting/references/languages.md b/skills/configure-selective-highlighting/references/languages.md index 81b375b..5650227 100644 --- a/skills/configure-selective-highlighting/references/languages.md +++ b/skills/configure-selective-highlighting/references/languages.md @@ -36,6 +36,7 @@ Import only the definitions the application registers. | Shell | `shell` | `@tanstack/highlight/languages/shell` | `bash`, `sh`, `zsh`, `cmd`, `console` | | SQL | `sql` | `@tanstack/highlight/languages/sql` | - | | Svelte | `svelte` | `@tanstack/highlight/languages/svelte` | - | +| Swift | `swift` | `@tanstack/highlight/languages/swift` | - | | TOML | `toml` | `@tanstack/highlight/languages/toml` | - | | TypeScript | `ts` | `@tanstack/highlight/languages/ts` | `typescript`, `angular-ts` | | TSRX | `tsrx` | `@tanstack/highlight/languages/tsrx` | `octane` | diff --git a/src/index.ts b/src/index.ts index 17e2a87..452f91f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -37,6 +37,7 @@ import { scheme } from './languages/scheme.js' import { shell } from './languages/shell.js' import { sql } from './languages/sql.js' import { svelte } from './languages/svelte.js' +import { swift } from './languages/swift.js' import { toml } from './languages/toml.js' import { ts } from './languages/ts.js' import { tsrx } from './languages/tsrx.js' @@ -77,6 +78,7 @@ export type HighlightLanguage = | 'shell' | 'sql' | 'svelte' + | 'swift' | 'toml' | 'ts' | 'tsrx' @@ -157,6 +159,7 @@ export const allLanguages = [ shell, sql, svelte, + swift, toml, ts, tsrx, diff --git a/src/languages/index.ts b/src/languages/index.ts index 83f875d..07d06f4 100644 --- a/src/languages/index.ts +++ b/src/languages/index.ts @@ -30,6 +30,7 @@ export { scheme } from './scheme.js' export { shell } from './shell.js' export { sql } from './sql.js' export { svelte } from './svelte.js' +export { swift } from './swift.js' export { toml } from './toml.js' export { ts } from './ts.js' export { tsrx } from './tsrx.js' diff --git a/test/fixtures.ts b/test/fixtures.ts index 94ad8dd..6198d7e 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -74,6 +74,12 @@ export const languageFixtures: Array = [ code: 'use std::fmt;\n#[derive(Debug)]\nstruct Point { x: i32 }\nfn main() {\n let count = 42; // total\n println!("Hello {}", count);\n}', expectedClasses: ['th-keyword', 'th-type', 'th-function', 'th-number', 'th-operator', 'th-comment', 'th-string'], }, + { + lang: 'swift', + normalized: 'swift', + code: 'import Foundation\n@MainActor\nfunc greet(name: String) -> String {\n let count = 42 // total\n return "Hello \\(name)"\n}', + expectedClasses: ['th-keyword', 'th-type', 'th-attr', 'th-function', 'th-number', 'th-operator', 'th-comment', 'th-string'], + }, { lang: 'octane', normalized: 'tsrx', diff --git a/test/real-doc-fixtures.test.ts b/test/real-doc-fixtures.test.ts index 9689f98..bdad25b 100644 --- a/test/real-doc-fixtures.test.ts +++ b/test/real-doc-fixtures.test.ts @@ -11,7 +11,7 @@ type DocFixture = { } const fixtures = fixtureData.fixtures as Array -const languagesWithoutRealDocFixtures = new Set(['go', 'cpp', 'cmake', 'php', 'csharp', 'dart', 'java', 'kotlin', 'lua', 'perl', 'ruby', 'rust']) +const languagesWithoutRealDocFixtures = new Set(['go', 'cpp', 'cmake', 'php', 'csharp', 'dart', 'java', 'kotlin', 'lua', 'perl', 'ruby', 'rust', 'swift']) describe('real TanStack docs fixtures', () => { it('covers normalized language targets available in TanStack docs', () => {