--!native
--!optimize 2

local lookupValueToCharacter = buffer.create(64)
local lookupCharacterToValue = buffer.create(256)

local alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
local padding = string.byte("=")

for index = 1, 64 do
	local value = index - 1
	local character = string.byte(alphabet, index)

	buffer.writeu8(lookupValueToCharacter, value, character)
	buffer.writeu8(lookupCharacterToValue, character, value)
end

local function encode(input: buffer): buffer
	local inputLength = buffer.len(input)
	local inputChunks = math.ceil(inputLength / 3)

	local outputLength = inputChunks * 4
	local output = buffer.create(outputLength)

	-- Since we use readu32 and chunks are 3 bytes large, we can't read the last chunk here
	for chunkIndex = 1, inputChunks - 1 do
		local inputIndex = (chunkIndex - 1) * 3
		local outputIndex = (chunkIndex - 1) * 4

		local chunk = bit32.byteswap(buffer.readu32(input, inputIndex))

		-- 8 + 24 - (6 * index)
		local value1 = bit32.rshift(chunk, 26)
		local value2 = bit32.band(bit32.rshift(chunk, 20), 0b111111)
		local value3 = bit32.band(bit32.rshift(chunk, 14), 0b111111)
		local value4 = bit32.band(bit32.rshift(chunk, 8), 0b111111)

		buffer.writeu8(output, outputIndex, buffer.readu8(lookupValueToCharacter, value1))
		buffer.writeu8(output, outputIndex + 1, buffer.readu8(lookupValueToCharacter, value2))
		buffer.writeu8(output, outputIndex + 2, buffer.readu8(lookupValueToCharacter, value3))
		buffer.writeu8(output, outputIndex + 3, buffer.readu8(lookupValueToCharacter, value4))
	end

	local inputRemainder = inputLength % 3

	if inputRemainder == 1 then
		local chunk = buffer.readu8(input, inputLength - 1)

		local value1 = bit32.rshift(chunk, 2)
		local value2 = bit32.band(bit32.lshift(chunk, 4), 0b111111)

		buffer.writeu8(output, outputLength - 4, buffer.readu8(lookupValueToCharacter, value1))
		buffer.writeu8(output, outputLength - 3, buffer.readu8(lookupValueToCharacter, value2))
		buffer.writeu8(output, outputLength - 2, padding)
		buffer.writeu8(output, outputLength - 1, padding)
	elseif inputRemainder == 2 then
		local chunk =
			bit32.bor(bit32.lshift(buffer.readu8(input, inputLength - 2), 8), buffer.readu8(input, inputLength - 1))

		local value1 = bit32.rshift(chunk, 10)
		local value2 = bit32.band(bit32.rshift(chunk, 4), 0b111111)
		local value3 = bit32.band(bit32.lshift(chunk, 2), 0b111111)

		buffer.writeu8(output, outputLength - 4, buffer.readu8(lookupValueToCharacter, value1))
		buffer.writeu8(output, outputLength - 3, buffer.readu8(lookupValueToCharacter, value2))
		buffer.writeu8(output, outputLength - 2, buffer.readu8(lookupValueToCharacter, value3))
		buffer.writeu8(output, outputLength - 1, padding)
	elseif inputRemainder == 0 and inputLength ~= 0 then
		local chunk = bit32.bor(
			bit32.lshift(buffer.readu8(input, inputLength - 3), 16),
			bit32.lshift(buffer.readu8(input, inputLength - 2), 8),
			buffer.readu8(input, inputLength - 1)
		)

		local value1 = bit32.rshift(chunk, 18)
		local value2 = bit32.band(bit32.rshift(chunk, 12), 0b111111)
		local value3 = bit32.band(bit32.rshift(chunk, 6), 0b111111)
		local value4 = bit32.band(chunk, 0b111111)

		buffer.writeu8(output, outputLength - 4, buffer.readu8(lookupValueToCharacter, value1))
		buffer.writeu8(output, outputLength - 3, buffer.readu8(lookupValueToCharacter, value2))
		buffer.writeu8(output, outputLength - 2, buffer.readu8(lookupValueToCharacter, value3))
		buffer.writeu8(output, outputLength - 1, buffer.readu8(lookupValueToCharacter, value4))
	end

	return output
end

local function decode(input: buffer): buffer
	local inputLength = buffer.len(input)
	local inputChunks = math.ceil(inputLength / 4)

	-- TODO: Support input without padding
	local inputPadding = 0
	if inputLength ~= 0 then
		if buffer.readu8(input, inputLength - 1) == padding then
			inputPadding += 1
		end
		if buffer.readu8(input, inputLength - 2) == padding then
			inputPadding += 1
		end
	end

	local outputLength = inputChunks * 3 - inputPadding
	local output = buffer.create(outputLength)

	for chunkIndex = 1, inputChunks - 1 do
		local inputIndex = (chunkIndex - 1) * 4
		local outputIndex = (chunkIndex - 1) * 3

		local value1 = buffer.readu8(lookupCharacterToValue, buffer.readu8(input, inputIndex))
		local value2 = buffer.readu8(lookupCharacterToValue, buffer.readu8(input, inputIndex + 1))
		local value3 = buffer.readu8(lookupCharacterToValue, buffer.readu8(input, inputIndex + 2))
		local value4 = buffer.readu8(lookupCharacterToValue, buffer.readu8(input, inputIndex + 3))

		local chunk = bit32.bor(bit32.lshift(value1, 18), bit32.lshift(value2, 12), bit32.lshift(value3, 6), value4)

		local character1 = bit32.rshift(chunk, 16)
		local character2 = bit32.band(bit32.rshift(chunk, 8), 0b11111111)
		local character3 = bit32.band(chunk, 0b11111111)

		buffer.writeu8(output, outputIndex, character1)
		buffer.writeu8(output, outputIndex + 1, character2)
		buffer.writeu8(output, outputIndex + 2, character3)
	end

	if inputLength ~= 0 then
		local lastInputIndex = (inputChunks - 1) * 4
		local lastOutputIndex = (inputChunks - 1) * 3

		local lastValue1 = buffer.readu8(lookupCharacterToValue, buffer.readu8(input, lastInputIndex))
		local lastValue2 = buffer.readu8(lookupCharacterToValue, buffer.readu8(input, lastInputIndex + 1))
		local lastValue3 = buffer.readu8(lookupCharacterToValue, buffer.readu8(input, lastInputIndex + 2))
		local lastValue4 = buffer.readu8(lookupCharacterToValue, buffer.readu8(input, lastInputIndex + 3))

		local lastChunk = bit32.bor(
			bit32.lshift(lastValue1, 18),
			bit32.lshift(lastValue2, 12),
			bit32.lshift(lastValue3, 6),
			lastValue4
		)

		if inputPadding <= 2 then
			local lastCharacter1 = bit32.rshift(lastChunk, 16)
			buffer.writeu8(output, lastOutputIndex, lastCharacter1)

			if inputPadding <= 1 then
				local lastCharacter2 = bit32.band(bit32.rshift(lastChunk, 8), 0b11111111)
				buffer.writeu8(output, lastOutputIndex + 1, lastCharacter2)

				if inputPadding == 0 then
					local lastCharacter3 = bit32.band(lastChunk, 0b11111111)
					buffer.writeu8(output, lastOutputIndex + 2, lastCharacter3)
				end
			end
		end
	end

	return output
end

return {
	encode = encode,
	decode = decode,
}


--!native
--!optimize 2

local alphabeticData = require("./generated/alphabetic-data")
local graphemeData = require("./generated/grapheme-data")
local incbData = require("./generated/incb-data")
local numericData = require("./generated/numeric-data")
local sentenceData = require("./generated/sentence-data")
local types = require("./types")
local wordData = require("./generated/word-data")

export type Range = types.Range

local BYTES_PER_RANGE = 8
local CODEPOINT_MASK = 0x1FFFFF
local CATEGORY_SHIFT = 21

local GRAPHEME_ASCII = graphemeData.asciiData
local GRAPHEME_RANGES = graphemeData.rangeData

local INCB_ASCII = incbData.asciiData
local INCB_RANGES = incbData.rangeData
local INCB_CONSONANT = incbData.categories.Consonant
local INCB_LINKER = incbData.categories.Linker

local WORD_ASCII = wordData.asciiData
local WORD_RANGES = wordData.rangeData

local SENTENCE_ASCII = sentenceData.asciiData
local SENTENCE_RANGES = sentenceData.rangeData

local ALPHABETIC_ASCII = alphabeticData.asciiData
local ALPHABETIC_RANGES = alphabeticData.rangeData
local ALPHABETIC_CATEGORY = alphabeticData.categories.Alphabetic

local NUMERIC_ASCII = numericData.asciiData
local NUMERIC_RANGES = numericData.rangeData
local NUMERIC_CATEGORY = numericData.categories.Numeric

--- Sorts ranges in ascending order for deterministic packing.
local function sortRanges(ranges: { Range })
	table.sort(ranges, function(a, b)
		if a.from == b.from then
			if a.to == b.to then
				return a.category < b.category
			end

			return a.to < b.to
		end

		return a.from < b.from
	end)
end

--- Packs ranges into an 8-byte-per-range buffer: [from u32, to|category u32].
local function packRanges(ranges: { Range }): buffer
	sortRanges(ranges)

	local out = buffer.create(#ranges * BYTES_PER_RANGE)
	for index, range in ranges do
		local offset = (index - 1) * BYTES_PER_RANGE
		local to = bit32.band(range.to, CODEPOINT_MASK)
		local toAndCategory = bit32.bor(to, bit32.lshift(range.category, CATEGORY_SHIFT))

		buffer.writeu32(out, offset, range.from)
		buffer.writeu32(out, offset + 4, toAndCategory)
	end

	return out
end

--- Reads a packed range tuple from the buffer at 1-based index.
local function readRange(bufferData: buffer, index: number): (number, number, number)
	local offset = (index - 1) * BYTES_PER_RANGE
	local from = buffer.readu32(bufferData, offset)
	local toAndCategory = buffer.readu32(bufferData, offset + 4)
	local to = bit32.band(toAndCategory, CODEPOINT_MASK)
	local category = bit32.rshift(toAndCategory, CATEGORY_SHIFT)

	return from, to, category
end

--- Binary-searches the packed range buffer and returns the category for a codepoint.
local function lookupCategory(bufferData: buffer, codepoint: number): number
	local rangeCount = buffer.len(bufferData) // BYTES_PER_RANGE
	local low = 0
	local high = rangeCount - 1

	while low <= high do
		local mid = (low + high) // 2
		local offset = mid * BYTES_PER_RANGE
		local from = buffer.readu32(bufferData, offset)
		local toAndCategory = buffer.readu32(bufferData, offset + 4)
		local to = bit32.band(toAndCategory, CODEPOINT_MASK)

		if codepoint < from then
			high = mid - 1
		elseif codepoint > to then
			low = mid + 1
		else
			return bit32.rshift(toAndCategory, CATEGORY_SHIFT)
		end
	end

	return 0
end

--- Fast lookup using ASCII table (codepoints < 128), otherwise binary search.
local function lookupCategoryWithAscii(asciiData: buffer, rangeData: buffer, codepoint: number): number
	if codepoint < 128 then
		return buffer.readu8(asciiData, codepoint)
	end

	return lookupCategory(rangeData, codepoint)
end

--- Builds a 128-byte ASCII lookup table from packed ranges.
local function buildAsciiTable(bufferData: buffer, defaultCategory: number?): buffer
	local ascii = buffer.create(128)
	local fallback = defaultCategory or 0

	for codepoint = 0, 127 do
		local category = lookupCategory(bufferData, codepoint)
		if category == 0 then
			category = fallback
		end
		buffer.writeu8(ascii, codepoint, category)
	end

	return ascii
end

--- Returns true when ranges are sorted by start/end for validation.
local function rangesAreSorted(ranges: { Range }): boolean
	local lastFrom = -1
	local lastTo = -1

	for _, range in ranges do
		if range.from < lastFrom then
			return false
		end

		if range.from == lastFrom and range.to < lastTo then
			return false
		end

		lastFrom = range.from
		lastTo = range.to
	end

	return true
end

--- Looks up the grapheme break category for a codepoint.
local function lookupGraphemeCategory(codepoint: number): number
	return lookupCategoryWithAscii(GRAPHEME_ASCII, GRAPHEME_RANGES, codepoint)
end

--- Looks up the word break category for a codepoint.
local function lookupWordCategory(codepoint: number): number
	return lookupCategoryWithAscii(WORD_ASCII, WORD_RANGES, codepoint)
end

--- Looks up the sentence break category for a codepoint.
local function lookupSentenceCategory(codepoint: number): number
	return lookupCategoryWithAscii(SENTENCE_ASCII, SENTENCE_RANGES, codepoint)
end

--- Returns true if the codepoint is Alphabetic (per DerivedCoreProperties).
local function isAlphabetic(codepoint: number): boolean
	return lookupCategoryWithAscii(ALPHABETIC_ASCII, ALPHABETIC_RANGES, codepoint) == ALPHABETIC_CATEGORY
end

--- Returns true if the codepoint is General_Category N (Nd/Nl/No).
local function isNumeric(codepoint: number): boolean
	return lookupCategoryWithAscii(NUMERIC_ASCII, NUMERIC_RANGES, codepoint) == NUMERIC_CATEGORY
end

--- Returns true if the codepoint is alphabetic or numeric.
local function isAlphanumeric(codepoint: number): boolean
	return isAlphabetic(codepoint) or isNumeric(codepoint)
end

--- Returns true when a codepoint should mark a segment as word-like.
local function isWordLikeCodepoint(codepoint: number): boolean
	return isAlphanumeric(codepoint)
end

--- Checks whether a codepoint is an InCB=Consonant for GB9c.
local function isIndicConsonant(codepoint: number): boolean
	return lookupCategoryWithAscii(INCB_ASCII, INCB_RANGES, codepoint) == INCB_CONSONANT
end

--- Checks if a codepoint is an Indic linker/virama used in GB9c.
local function isIndicLinker(codepoint: number): boolean
	return lookupCategoryWithAscii(INCB_ASCII, INCB_RANGES, codepoint) == INCB_LINKER
end

return {
	BYTES_PER_RANGE = BYTES_PER_RANGE,
	CODEPOINT_MASK = CODEPOINT_MASK,
	CATEGORY_SHIFT = CATEGORY_SHIFT,

	buildAsciiTable = buildAsciiTable,
	isIndicConsonant = isIndicConsonant,
	isIndicLinker = isIndicLinker,
	lookupCategory = lookupCategory,
	lookupCategoryWithAscii = lookupCategoryWithAscii,
	lookupGraphemeCategory = lookupGraphemeCategory,
	lookupSentenceCategory = lookupSentenceCategory,
	lookupWordCategory = lookupWordCategory,
	isAlphanumeric = isAlphanumeric,
	packRanges = packRanges,
	rangesAreSorted = rangesAreSorted,
	readRange = readRange,
	isAlphabetic = isAlphabetic,
	isNumeric = isNumeric,
	isWordLikeCodepoint = isWordLikeCodepoint,
	sortRanges = sortRanges,
}


-- This file was @generated by .lute/generate.luau. Do not edit.
local base64 = require("../base64")
local rangeData = base64.decode(
	buffer.fromstring(
		"FQkAADkJIABNCQAATQlAAFgJAABfCSAAeAkAAH8JIACVCQAAqAkgAKoJAACwCSAAsgkAALIJIAC2CQAAuQkgAM0JAADNCUAA3AkAAN0JIADfCQAA3wkgAPAJAADxCSAAlQoAAKgKIACqCgAAsAogALIKAACzCiAAtQoAALkKIADNCgAAzQpAAPkKAAD5CiAAFQsAACgLIAAqCwAAMAsgADILAAAzCyAANQsAADkLIABNCwAATQtAAFwLAABdCyAAXwsAAF8LIABxCwAAcQsgABUMAAAoDCAAKgwAADkMIABNDAAATQxAAFgMAABaDCAAFQ0AADoNIABNDQAATQ1AAAAQAAAqECAAORAAADkQQAA/EAAAPxAgAFAQAABVECAAWhAAAF0QIABhEAAAYRAgAGUQAABmECAAbhAAAHAQIAB1EAAAgRAgAI4QAACOECAAgBcAALMXIADSFwAA0hdAACAaAABUGiAAYBoAAGAaQAALGwAADBsgABMbAAAzGyAARBsAAEQbQABFGwAATBsgAIMbAACgGyAAqxsAAKsbQACuGwAArxsgALsbAAC9GyAAiakAAIupIACPqQAAsqkgAMCpAADAqUAA4KkAAOSpIADnqQAA76kgAPqpAAD+qSAAYKoAAG+qIABxqgAAc6ogAHqqAAB6qiAAfqoAAH+qIADgqgAA6qogAPaqAAD2qkAAwKsAANqrIAAACgEAAAohABAKAQATCiEAFQoBABcKIQAZCgEANQohAD8KAQA/CkEAAxEBACYRIQAzEQEAMxFBAEQRAQBEESEARxEBAEcRIQCAEwEAiRMhAIsTAQCLEyEAjhMBAI4TIQCQEwEAtRMhANATAQDQE0EAABkBAAYZIQAJGQEACRkhAAwZAQATGSEAFRkBABYZIQAYGQEALxkhAD4ZAQA+GUEAABoBAAAaIQALGgEAMhohAEcaAQBHGkEAUBoBAFAaIQBcGgEAgxohAJkaAQCZGkEABB8BABAfIQASHwEAMx8hAEIfAQBCH0EA"
	)
)
local asciiData = base64.decode(
	buffer.fromstring(
		"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
	)
)

local categories = table.freeze({
	Any = 0,
	Consonant = 1,
	Linker = 2,
})

return table.freeze({
	categories = categories,
	rangeData = rangeData,
	asciiData = asciiData,
})


--!native
--!optimize 2

local core = require("./core")
local graphemeData = require("./generated/grapheme-data")
local types = require("./types")

type Segment = types.GraphemeSegment

local categories = graphemeData.categories
local GC_ANY = categories.Any
local GC_CR = categories.CR
local GC_CONTROL = categories.Control
local GC_EXTEND = categories.Extend
local GC_EXTENDED_PICTOGRAPHIC = categories.Extended_Pictographic
local GC_L = categories.L
local GC_LF = categories.LF
local GC_LV = categories.LV
local GC_LVT = categories.LVT
local GC_PREPEND = categories.Prepend
local GC_REGIONAL_INDICATOR = categories.Regional_Indicator
local GC_SPACINGMARK = categories.SpacingMark
local GC_T = categories.T
local GC_V = categories.V
local GC_ZWJ = categories.ZWJ

local INDIC_START = 0x0915

--- Segments text into extended grapheme clusters with byte offsets.
local function segment(input: string): { Segment }
	local segments = {}
	local segmentStart: number? = nil
	local catBefore = GC_ANY
	local risCount = 0
	local emojiBase = false
	local emojiZwj = false
	local consonant = false
	local linker = false
	local headCodepoint = 0

	for index, codepoint in utf8.codes(input) do
		if segmentStart == nil then
			segmentStart = index
			catBefore = core.lookupGraphemeCategory(codepoint)
			headCodepoint = codepoint
			emojiBase = catBefore == GC_EXTENDED_PICTOGRAPHIC
			emojiZwj = false
			continue
		end

		local catAfter = core.lookupGraphemeCategory(codepoint)
		local boundary = true

		-- GB3: CR × LF
		if catBefore == GC_CR then
			boundary = catAfter ~= GC_LF
		-- GB4, GB5: Break around controls.
		elseif
			catBefore == GC_CONTROL
			or catBefore == GC_LF
			or catAfter == GC_CR
			or catAfter == GC_CONTROL
			or catAfter == GC_LF
		then
			boundary = true
		else
			local noBreak = false

			-- GB9, GB9a, GB9b: Extend/ZWJ/SpacingMark and Prepend stay in cluster.
			if catAfter == GC_EXTEND or catAfter == GC_ZWJ or catAfter == GC_SPACINGMARK or catBefore == GC_PREPEND then
				noBreak = true
			-- GB11: ExtPic Extend* ZWJ × ExtPic
			elseif catBefore == GC_ZWJ and catAfter == GC_EXTENDED_PICTOGRAPHIC then
				boundary = not emojiZwj
			-- GB12, GB13: RI × RI
			elseif catBefore == GC_REGIONAL_INDICATOR and catAfter == GC_REGIONAL_INDICATOR then
				boundary = risCount % 2 == 1
				risCount += 1
			-- GB6: L × (L | V | LV | LVT)
			elseif catBefore == GC_L then
				boundary = not (catAfter == GC_L or catAfter == GC_V or catAfter == GC_LV or catAfter == GC_LVT)
			else
				-- GB7, GB8, GB9c: Hangul continuations and Indic conjuncts.
				noBreak = ((catBefore == GC_LV or catBefore == GC_V) and (catAfter == GC_V or catAfter == GC_T))
					or ((catBefore == GC_LVT or catBefore == GC_T) and catAfter == GC_T)
					or (catAfter == GC_ANY and consonant and linker and core.isIndicConsonant(codepoint))
			end

			if noBreak then
				boundary = false
			end
		end

		if boundary then
			table.insert(segments, {
				segment = string.sub(input, segmentStart, index - 1),
				index = segmentStart,
			})

			emojiBase = catAfter == GC_EXTENDED_PICTOGRAPHIC
			emojiZwj = false
			risCount = 0
			consonant = false
			linker = false
			segmentStart = index
			headCodepoint = codepoint
		else
			if codepoint >= INDIC_START then
				if not consonant and catBefore == GC_ANY then
					consonant = core.isIndicConsonant(headCodepoint)
				end

				if consonant then
					if catAfter == GC_EXTEND then
						linker = linker or core.isIndicLinker(codepoint)
					elseif catAfter ~= GC_ZWJ then
						linker = false
					end
				else
					linker = false
				end
			end

			if catAfter == GC_ZWJ then
				emojiZwj = emojiBase
				emojiBase = false
			elseif catAfter == GC_EXTENDED_PICTOGRAPHIC then
				emojiBase = true
				emojiZwj = false
			elseif catAfter == GC_EXTEND then
				emojiZwj = false
			else
				emojiBase = false
				emojiZwj = false
			end
		end

		catBefore = catAfter
	end

	if segmentStart == nil then
		return segments
	end

	table.insert(segments, {
		segment = string.sub(input, segmentStart),
		index = segmentStart,
	})

	return segments
end

--- Splits text into grapheme cluster strings.
local function split(input: string): { string }
	local segments = segment(input)
	local output = table.create(#segments)

	for index, entry in segments do
		output[index] = entry.segment
	end

	return output
end

--- Counts the number of grapheme clusters in text.
local function count(input: string): number
	return #segment(input)
end

return {
	graphemeSegments = segment,
	splitGraphemes = split,
	countGraphemes = count,
}


local core = require("@self/core")
local grapheme = require("@self/grapheme")
local graphemeData = require("@self/generated/grapheme-data")
local sentence = require("@self/sentence")
local sentenceData = require("@self/generated/sentence-data")
local types = require("@self/types")
local word = require("@self/word")
local wordData = require("@self/generated/word-data")

export type Segment = types.Segment
export type WordSegment = types.WordSegment
export type SentenceSegment = types.SentenceSegment

local segmenter = {}

--- Segments text into extended grapheme clusters.
function segmenter.graphemes(input: string): { Segment }
	return grapheme.graphemeSegments(input)
end

--- Splits text into grapheme cluster strings.
function segmenter.splitGraphemes(input: string): { string }
	return grapheme.splitGraphemes(input)
end

--- Counts grapheme clusters in the given text.
function segmenter.countGraphemes(input: string): number
	return grapheme.countGraphemes(input)
end

--- Looks up the Grapheme_Cluster_Break category for a codepoint.
function segmenter.graphemeCategory(codepoint: number): number
	return core.lookupGraphemeCategory(codepoint)
end

segmenter.GraphemeCategory = graphemeData.categories

--- Segments text into word boundary segments.
function segmenter.words(input: string): { WordSegment }
	return word.wordSegments(input)
end

--- Splits text into word boundary strings.
function segmenter.splitWords(input: string): { string }
	return word.splitWords(input)
end

--- Counts word boundary segments in the given text.
function segmenter.countWords(input: string): number
	return word.countWords(input)
end

--- Looks up the Word_Break category for a codepoint.
function segmenter.wordCategory(codepoint: number): number
	return core.lookupWordCategory(codepoint)
end

segmenter.WordCategory = wordData.categories

--- Segments text into sentence boundary segments.
function segmenter.sentences(input: string): { Segment }
	return sentence.sentenceSegments(input)
end

--- Splits text into sentence boundary strings.
function segmenter.splitSentences(input: string): { string }
	return sentence.splitSentences(input)
end

--- Counts sentence boundary segments in the given text.
function segmenter.countSentences(input: string): number
	return sentence.countSentences(input)
end

--- Looks up the Sentence_Break category for a codepoint.
function segmenter.sentenceCategory(codepoint: number): number
	return core.lookupSentenceCategory(codepoint)
end

segmenter.SentenceCategory = sentenceData.categories

return table.freeze(segmenter)


--!native
--!optimize 2

local core = require("./core")
local sentenceData = require("./generated/sentence-data")
local types = require("./types")

type Segment = types.SentenceSegment

local categories = sentenceData.categories

local SC_ATERM = categories.ATerm
local SC_CR = categories.CR
local SC_CLOSE = categories.Close
local SC_EXTEND = categories.Extend
local SC_FORMAT = categories.Format
local SC_LF = categories.LF
local SC_LOWER = categories.Lower
local SC_NUMERIC = categories.Numeric
local SC_OLETTER = categories.OLetter
local SC_SCONTINUE = categories.SContinue
local SC_STERM = categories.STerm
local SC_SEP = categories.Sep
local SC_SP = categories.Sp
local SC_UPPER = categories.Upper

local STATE_SOT = 0
local STATE_EOT = 1
local STATE_OTHER = 2
local STATE_CR = 3
local STATE_LF = 4
local STATE_SEP = 5
local STATE_ATERM = 6
local STATE_UPPERLOWER = 7
local STATE_CLOSEPLUS = 8
local STATE_SPPLUS = 9
local STATE_STERM = 10

type State = { number }

local function nextByteIndex(input: string, index: number): number
	local nextIndex = utf8.offset(input, 2, index)
	if nextIndex == nil then
		return #input + 1
	end
	return nextIndex
end

local function stateNext(state: State, category: number): State
	if state[4] == STATE_CLOSEPLUS and category == SC_CLOSE then
		return state
	end

	if state[4] == STATE_SPPLUS and category == SC_SP then
		return state
	end

	local nextPart = STATE_OTHER
	if category == SC_CR then
		nextPart = STATE_CR
	elseif category == SC_LF then
		nextPart = STATE_LF
	elseif category == SC_SEP then
		nextPart = STATE_SEP
	elseif category == SC_ATERM then
		nextPart = STATE_ATERM
	elseif category == SC_UPPER or category == SC_LOWER then
		nextPart = STATE_UPPERLOWER
	elseif category == SC_CLOSE then
		nextPart = STATE_CLOSEPLUS
	elseif category == SC_SP then
		nextPart = STATE_SPPLUS
	elseif category == SC_STERM then
		nextPart = STATE_STERM
	end

	return {
		state[2],
		state[3],
		state[4],
		nextPart,
	}
end

local function stateEnd(state: State): State
	return { state[2], state[3], state[4], STATE_EOT }
end

local function stateMatch1(state: State, part: number): boolean
	return state[4] == part
end

local function stateMatch2(state: State, part1: number, part2: number): boolean
	return state[3] == part1 and state[4] == part2
end

local function matchSB8(state: State, ahead: string): boolean
	local index = state[4] == STATE_SPPLUS and 3 or 4
	if state[index] == STATE_CLOSEPLUS then
		index -= 1
	end

	if state[index] ~= STATE_ATERM then
		return false
	end

	for _, codepoint in utf8.codes(ahead) do
		local nextCat = core.lookupSentenceCategory(codepoint)
		if nextCat == SC_LOWER then
			return true
		end

		if
			nextCat == SC_OLETTER
			or nextCat == SC_UPPER
			or nextCat == SC_SEP
			or nextCat == SC_CR
			or nextCat == SC_LF
			or nextCat == SC_STERM
			or nextCat == SC_ATERM
		then
			return false
		end
	end

	return false
end

local function matchSB8a(state: State): boolean
	local index = state[4] == STATE_SPPLUS and 3 or 4
	if state[index] == STATE_CLOSEPLUS then
		index -= 1
	end

	return state[index] == STATE_STERM or state[index] == STATE_ATERM
end

local function matchSB9(state: State): boolean
	local index = state[4] == STATE_CLOSEPLUS and 3 or 4
	return state[index] == STATE_STERM or state[index] == STATE_ATERM
end

local function matchSB11(state: State): boolean
	local index: number
	if state[4] == STATE_SEP or state[4] == STATE_CR or state[4] == STATE_LF then
		index = 3
	else
		index = 4
	end

	if state[index] == STATE_SPPLUS then
		index -= 1
	end

	if state[index] == STATE_CLOSEPLUS then
		index -= 1
	end

	return state[index] == STATE_STERM or state[index] == STATE_ATERM
end

--- Segments text into sentence boundary segments with byte offsets.
local function segment(input: string): { Segment }
	if input == "" then
		return {}
	end

	local breaks = {}
	local state: State = { STATE_SOT, STATE_SOT, STATE_SOT, STATE_SOT }
	local lastByte = 1

	for byteIndex, codepoint in utf8.codes(input) do
		local positionBefore = byteIndex
		local stateBefore = state
		local category = core.lookupSentenceCategory(codepoint)

		state = stateNext(state, category)

		local boundary = false
		local revertState = false

		repeat
			if stateMatch1(stateBefore, STATE_SOT) then
				boundary = true
				break
			end

			-- SB3: CR × LF
			if category == SC_LF and stateMatch1(stateBefore, STATE_CR) then
				break
			end

			-- SB4: Break after separators.
			if
				stateMatch1(stateBefore, STATE_SEP)
				or stateMatch1(stateBefore, STATE_CR)
				or stateMatch1(stateBefore, STATE_LF)
			then
				boundary = true
				break
			end

			-- SB5: Ignore Extend/Format.
			if category == SC_EXTEND or category == SC_FORMAT then
				revertState = true
				break
			end

			-- SB6: ATerm × Numeric.
			if category == SC_NUMERIC and stateMatch1(stateBefore, STATE_ATERM) then
				break
			end

			-- SB7: (Upper|Lower) ATerm × Upper.
			if category == SC_UPPER and stateMatch2(stateBefore, STATE_UPPERLOWER, STATE_ATERM) then
				break
			end

			-- SB8: ATerm Close* Sp* × Lower with lookahead.
			if matchSB8(stateBefore, string.sub(input, positionBefore)) then
				break
			end

			-- SB8a: SATerm Close* Sp* × (SContinue | SATerm).
			if
				(category == SC_SCONTINUE or category == SC_STERM or category == SC_ATERM)
				and matchSB8a(stateBefore)
			then
				break
			end

			-- SB9: SATerm Close* × (Close | Sp | Sep | CR | LF).
			if
				(
					category == SC_CLOSE
					or category == SC_SP
					or category == SC_SEP
					or category == SC_CR
					or category == SC_LF
				) and matchSB9(stateBefore)
			then
				break
			end

			-- SB10: SATerm Close* Sp* × (Sp | Sep | CR | LF).
			if
				(category == SC_SP or category == SC_SEP or category == SC_CR or category == SC_LF)
				and matchSB8a(stateBefore)
			then
				break
			end

			-- SB11: SATerm Close* Sp* Sep? ÷
			if matchSB11(stateBefore) then
				boundary = true
			end
		until true

		if revertState then
			state = stateBefore
		end

		if boundary then
			table.insert(breaks, positionBefore)
		end

		lastByte = nextByteIndex(input, byteIndex)
	end

	if not stateMatch1(state, STATE_SOT) and not stateMatch1(state, STATE_EOT) then
		state = stateEnd(state)
		table.insert(breaks, lastByte)
	end

	if #breaks < 2 then
		return {}
	end

	local segments = table.create(#breaks - 1)
	for index = 1, #breaks - 1 do
		local startIndex = breaks[index]
		local endIndex = breaks[index + 1] - 1
		segments[index] = {
			segment = string.sub(input, startIndex, endIndex),
			index = startIndex,
		}
	end

	return segments
end

--- Splits text into sentence boundary strings.
local function split(input: string): { string }
	local segments = segment(input)
	local output = table.create(#segments)

	for index, entry in segments do
		output[index] = entry.segment
	end

	return output
end

--- Counts the number of sentence boundary segments.
local function count(input: string): number
	local segments = segment(input)
	return #segments
end

return {
	sentenceSegments = segment,
	splitSentences = split,
	countSentences = count,
}


export type Range = {
	from: number,
	to: number,
	category: number,
}

export type Segment = {
	segment: string,
	index: number,
}

export type WordSegment = {
	segment: string,
	index: number,
	isWordLike: boolean,
}

export type SentenceSegment = Segment
export type GraphemeSegment = Segment

return nil


--!native
--!optimize 2

local core = require("./core")
local emojiData = require("./generated/emoji-data")
local types = require("./types")
local wordData = require("./generated/word-data")

type WordSegment = types.WordSegment

local categories = wordData.categories
local WC_ANY = categories.Any
local WC_ALETTER = categories.ALetter
local WC_CR = categories.CR
local WC_DOUBLE_QUOTE = categories.Double_Quote
local WC_EXTEND = categories.Extend
local WC_EXTENDNUMLET = categories.ExtendNumLet
local WC_FORMAT = categories.Format
local WC_HEBREW_LETTER = categories.Hebrew_Letter
local WC_KATAKANA = categories.Katakana
local WC_LF = categories.LF
local WC_MIDLETTER = categories.MidLetter
local WC_MIDNUM = categories.MidNum
local WC_MIDNUMLET = categories.MidNumLet
local WC_NEWLINE = categories.Newline
local WC_NUMERIC = categories.Numeric
local WC_REGIONAL_INDICATOR = categories.Regional_Indicator
local WC_SINGLE_QUOTE = categories.Single_Quote
local WC_WSEGSPACE = categories.WSegSpace
local WC_ZWJ = categories.ZWJ

local EMOJI_ASCII = emojiData.asciiData
local EMOJI_RANGES = emojiData.rangeData
local EMOJI_EXTENDED = emojiData.categories.Extended_Pictographic

local STATE_START = 0
local STATE_LETTER = 1
local STATE_HLETTER = 2
local STATE_NUMERIC = 3
local STATE_KATAKANA = 4
local STATE_EXTENDNUMLET = 5
local STATE_REGIONAL_HALF = 6
local STATE_REGIONAL_FULL = 7
local STATE_FORMAT = 8
local STATE_ZWJ = 9
local STATE_EMOJI = 10
local STATE_WSEGSPACE = 11

local FORMAT_ACCEPT_NONE = 0
local FORMAT_REQUIRE_LETTER = 1
local FORMAT_REQUIRE_HLETTER = 2
local FORMAT_ACCEPT_QLETTER = 3
local FORMAT_REQUIRE_NUMERIC = 4

local function isAscii(input: string): boolean
	return input:find("[\128-\255]") == nil
end

local function isAsciiDigit(byte: number): boolean
	return byte >= 0x30 and byte <= 0x39
end

local function isAsciiAlpha(byte: number): boolean
	return (byte >= 0x41 and byte <= 0x5A) or (byte >= 0x61 and byte <= 0x7A)
end

local function isAsciiAlnum(byte: number): boolean
	return isAsciiDigit(byte) or isAsciiAlpha(byte)
end

local function isAsciiCore(byte: number): boolean
	return isAsciiAlnum(byte) or byte == 0x5F
end

local function isAsciiInfix(byte: number, prev: number, next: number): boolean
	if (byte == 0x2E or byte == 0x2C or byte == 0x3B or byte == 0x27) and isAsciiDigit(prev) and isAsciiDigit(next) then
		return true
	end
	if (byte == 0x27 or byte == 0x2E or byte == 0x3A) and isAsciiAlpha(prev) and isAsciiAlpha(next) then
		return true
	end
	return false
end

local function nextByteIndex(input: string, index: number): number
	local nextIndex = utf8.offset(input, 2, index)
	if nextIndex == nil then
		return #input + 1
	end
	return nextIndex
end

local function peekCategory(input: string, index: number): number?
	if index > #input then
		return nil
	end
	local codepoint = utf8.codepoint(input, index)
	return core.lookupWordCategory(codepoint)
end

local function isExtendedPictographic(codepoint: number): boolean
	return core.lookupCategoryWithAscii(EMOJI_ASCII, EMOJI_RANGES, codepoint) == EMOJI_EXTENDED
end

local function segmentAscii(input: string): { WordSegment }
	local segments = {}
	local length = #input
	local index = 1

	while index <= length do
		local byte = string.byte(input, index)
		if byte == 0x20 then
			local start = index
			repeat
				index += 1
			until index > length or string.byte(input, index) ~= 0x20

			table.insert(segments, {
				segment = string.sub(input, start, index - 1),
				index = start,
				isWordLike = false,
			})
		elseif isAsciiCore(byte) then
			local start = index
			local wordLike = isAsciiAlnum(byte)
			local i = index + 1

			while i <= length do
				local current = string.byte(input, i)
				if isAsciiCore(current) then
					if isAsciiAlnum(current) then
						wordLike = true
					end
					i += 1
				elseif
					i + 1 <= length and isAsciiInfix(current, string.byte(input, i - 1), string.byte(input, i + 1))
				then
					i += 1
				else
					break
				end
			end

			table.insert(segments, {
				segment = string.sub(input, start, i - 1),
				index = start,
				isWordLike = wordLike,
			})
			index = i
		elseif byte == 0x0D and index < length and string.byte(input, index + 1) == 0x0A then
			table.insert(segments, {
				segment = string.sub(input, index, index + 1),
				index = index,
				isWordLike = false,
			})
			index += 2
		else
			table.insert(segments, {
				segment = string.sub(input, index, index),
				index = index,
				isWordLike = isAsciiAlnum(byte),
			})
			index += 1
		end
	end

	return segments
end

--- Segments text into word boundaries using the Unicode path (test-only).
local function segmentUnicode(input: string): { WordSegment }
	if input == "" then
		return {}
	end

	local segments = {}
	local length = #input
	local index = 1

	while index <= length do
		local segmentStart = index
		local segmentWordLike = false
		local state = STATE_START
		local formatType = FORMAT_ACCEPT_NONE
		local cat = WC_ANY
		local saveIndex = index
		local saveCat = WC_ANY
		local skippedFormatExtend = false
		local segmentEnd = length
		local nextStart = length + 1
		local segmentDone = false

		while index <= length do
			local currIndex = index
			local nextIndex = nextByteIndex(input, index)
			local currLen = nextIndex - currIndex
			local codepoint = utf8.codepoint(input, index)
			local wordLike = core.isWordLikeCodepoint(codepoint)
			local prevZwj = cat == WC_ZWJ
			cat = core.lookupWordCategory(codepoint)

			local takeCurrent = true
			local boundary = false
			local breakBefore = false
			local idx = currIndex

			if state ~= STATE_START and (cat == WC_EXTEND or cat == WC_FORMAT or cat == WC_ZWJ) then
				skippedFormatExtend = true
				index = nextIndex
				continue
			end

			if prevZwj and isExtendedPictographic(codepoint) then
				state = STATE_EMOJI
				index = nextIndex
				continue
			end

			if state == STATE_START then
				if cat == WC_CR then
					local nextCat = peekCategory(input, nextIndex)
					if nextCat == WC_LF then
						idx = nextIndex
					end
					boundary = true
				elseif cat == WC_ALETTER then
					state = STATE_LETTER
				elseif cat == WC_HEBREW_LETTER then
					state = STATE_HLETTER
				elseif cat == WC_NUMERIC then
					state = STATE_NUMERIC
				elseif cat == WC_KATAKANA then
					state = STATE_KATAKANA
				elseif cat == WC_EXTENDNUMLET then
					state = STATE_EXTENDNUMLET
				elseif cat == WC_REGIONAL_INDICATOR then
					state = STATE_REGIONAL_HALF
				elseif cat == WC_LF or cat == WC_NEWLINE then
					boundary = true
				elseif cat == WC_ZWJ then
					state = STATE_ZWJ
				elseif cat == WC_WSEGSPACE then
					state = STATE_WSEGSPACE
				else
					local peekCatValue = peekCategory(input, nextIndex)
					if peekCatValue == WC_FORMAT or peekCatValue == WC_EXTEND or peekCatValue == WC_ZWJ then
						state = STATE_FORMAT
						formatType = FORMAT_ACCEPT_NONE
						index = nextIndex
						continue
					end
					boundary = true
				end
			elseif state == STATE_WSEGSPACE then
				if cat == WC_WSEGSPACE and not skippedFormatExtend then
					state = STATE_WSEGSPACE
				else
					breakBefore = true
				end
			elseif state == STATE_LETTER or state == STATE_HLETTER then
				if cat == WC_ALETTER then
					state = STATE_LETTER
				elseif cat == WC_HEBREW_LETTER then
					state = STATE_HLETTER
				elseif cat == WC_NUMERIC then
					state = STATE_NUMERIC
				elseif cat == WC_EXTENDNUMLET then
					state = STATE_EXTENDNUMLET
				elseif state == STATE_HLETTER and cat == WC_DOUBLE_QUOTE then
					saveIndex = currIndex
					saveCat = cat
					state = STATE_FORMAT
					formatType = FORMAT_REQUIRE_HLETTER
				elseif state == STATE_HLETTER and cat == WC_SINGLE_QUOTE then
					state = STATE_FORMAT
					formatType = FORMAT_ACCEPT_QLETTER
				elseif cat == WC_MIDLETTER or cat == WC_MIDNUMLET or cat == WC_SINGLE_QUOTE then
					saveIndex = currIndex
					saveCat = cat
					state = STATE_FORMAT
					formatType = FORMAT_REQUIRE_LETTER
				else
					breakBefore = true
				end
			elseif state == STATE_NUMERIC then
				if cat == WC_NUMERIC then
					state = STATE_NUMERIC
				elseif cat == WC_ALETTER then
					state = STATE_LETTER
				elseif cat == WC_HEBREW_LETTER then
					state = STATE_HLETTER
				elseif cat == WC_EXTENDNUMLET then
					state = STATE_EXTENDNUMLET
				elseif cat == WC_MIDNUM or cat == WC_MIDNUMLET or cat == WC_SINGLE_QUOTE then
					saveIndex = currIndex
					saveCat = cat
					state = STATE_FORMAT
					formatType = FORMAT_REQUIRE_NUMERIC
				else
					breakBefore = true
				end
			elseif state == STATE_KATAKANA then
				if cat == WC_KATAKANA then
					state = STATE_KATAKANA
				elseif cat == WC_EXTENDNUMLET then
					state = STATE_EXTENDNUMLET
				else
					breakBefore = true
				end
			elseif state == STATE_EXTENDNUMLET then
				if cat == WC_EXTENDNUMLET then
					state = STATE_EXTENDNUMLET
				elseif cat == WC_ALETTER then
					state = STATE_LETTER
				elseif cat == WC_HEBREW_LETTER then
					state = STATE_HLETTER
				elseif cat == WC_NUMERIC then
					state = STATE_NUMERIC
				elseif cat == WC_KATAKANA then
					state = STATE_KATAKANA
				else
					breakBefore = true
				end
			elseif
				state == STATE_ZWJ
				or state == STATE_REGIONAL_FULL
				or state == STATE_EMOJI
				or (state == STATE_REGIONAL_HALF and cat ~= WC_REGIONAL_INDICATOR)
			then
				breakBefore = true
			elseif state == STATE_REGIONAL_HALF and cat == WC_REGIONAL_INDICATOR then
				state = STATE_REGIONAL_FULL
			elseif state == STATE_FORMAT then
				if formatType == FORMAT_REQUIRE_NUMERIC and cat == WC_NUMERIC then
					state = STATE_NUMERIC
				elseif
					(formatType == FORMAT_REQUIRE_LETTER or formatType == FORMAT_ACCEPT_QLETTER)
					and cat == WC_ALETTER
				then
					state = STATE_LETTER
				elseif
					cat == WC_HEBREW_LETTER
					and (
						formatType == FORMAT_REQUIRE_LETTER
						or formatType == FORMAT_ACCEPT_QLETTER
						or formatType == FORMAT_REQUIRE_HLETTER
					)
				then
					state = STATE_HLETTER
				elseif formatType == FORMAT_ACCEPT_NONE or formatType == FORMAT_ACCEPT_QLETTER then
					breakBefore = true
				else
					boundary = true
				end
			end

			if breakBefore then
				takeCurrent = false
				boundary = true
			end

			if boundary then
				if
					state == STATE_FORMAT
					and (
						formatType == FORMAT_REQUIRE_LETTER
						or formatType == FORMAT_REQUIRE_HLETTER
						or formatType == FORMAT_REQUIRE_NUMERIC
					)
				then
					idx = saveIndex
					cat = saveCat
					takeCurrent = false
				end

				if takeCurrent then
					if wordLike then
						segmentWordLike = true
					end
					idx = idx + currLen
				end

				segmentEnd = idx - 1
				nextStart = idx
				segmentDone = true
				break
			end

			if wordLike then
				segmentWordLike = true
			end
			index = nextIndex
		end

		if not segmentDone then
			if
				state == STATE_FORMAT
				and (
					formatType == FORMAT_REQUIRE_LETTER
					or formatType == FORMAT_REQUIRE_HLETTER
					or formatType == FORMAT_REQUIRE_NUMERIC
				)
			then
				segmentEnd = saveIndex - 1
				nextStart = saveIndex
			else
				segmentEnd = length
				nextStart = length + 1
			end
		end

		table.insert(segments, {
			segment = string.sub(input, segmentStart, segmentEnd),
			index = segmentStart,
			isWordLike = segmentWordLike,
		})

		index = nextStart
	end

	return segments
end

--- Segments text into word boundaries with word-like metadata.
local function segment(input: string): { WordSegment }
	if input == "" then
		return {}
	end
	if isAscii(input) then
		return segmentAscii(input)
	end
	return segmentUnicode(input)
end

--- Splits text into word boundary segments.
local function split(input: string): { string }
	local segments = segment(input)
	local output = table.create(#segments)

	for idx, entry in segments do
		output[idx] = entry.segment
	end

	return output
end

--- Counts the number of word boundary segments in text.
local function count(input: string): number
	return #segment(input)
end

return {
	wordSegments = segment,
	wordSegmentsUnicode = segmentUnicode,
	splitWords = split,
	countWords = count,
}


local fs = require("@std/fs")
local test = require("@std/test")

local base64 = require("@src/base64")
local core = require("@src/core")

local parseProperties = require("../.lute/lib/parse-properties").parseProperties
local parseTests = require("../.lute/lib/parse-tests").parseTestData
local emitModule = require("../.lute/lib/emit-module")

local function readUnicodeFile(relativePath: string): string
	local path = "unicode-data/" .. relativePath
	return fs.readFileToString(path)
end

test.suite("CodegenPipeline", function(suite)
	suite:case("base64RoundTrip", function(assert)
		local input = buffer.fromstring("Hello, Luau!")
		local encoded = base64.encode(input)
		local decoded = base64.decode(encoded)

		assert.eq(buffer.tostring(decoded), "Hello, Luau!")
	end)

	suite:case("packRangesAndLookup", function(assert)
		local data = readUnicodeFile("auxiliary/WordBreakProperty.txt")
		local props = parseProperties(data)

		assert.that(props.ALetter)
		assert.that(props.Numeric)
		assert.that(props.LF)
		assert.that(props.CR)

		local ranges = {}
		local function addRanges(name: string, category: number)
			for _, range in props[name] do
				table.insert(ranges, {
					from = range.from,
					to = range.to,
					category = category,
				})
			end
		end

		addRanges("ALetter", 1)
		addRanges("Numeric", 2)
		addRanges("LF", 3)
		addRanges("CR", 4)

		local packed = core.packRanges(ranges)
		assert.eq(core.lookupCategory(packed, string.byte("A")), 1)
		assert.eq(core.lookupCategory(packed, string.byte("0")), 2)
		assert.eq(core.lookupCategory(packed, 0x0A), 3)
		assert.eq(core.lookupCategory(packed, 0x0D), 4)

		local ascii = core.buildAsciiTable(packed)
		assert.eq(buffer.readu8(ascii, string.byte("A")), 1)
	end)

	suite:case("parseTestData", function(assert)
		local data = readUnicodeFile("auxiliary/GraphemeBreakTest.txt")
		local cases = parseTests(data)

		assert.that(#cases > 0)
		assert.that(#cases[1].expected > 0)
		assert.eq(cases[1].input, table.concat(cases[1].expected))
	end)

	suite:case("emitModuleFormat", function(assert)
		local packed = core.packRanges({
			{ from = 0x41, to = 0x5A, category = 1 },
		})
		local ascii = core.buildAsciiTable(packed)
		local content = emitModule.renderDataModule({
			categories = { "Any", "ALetter" },
			rangeBuffer = packed,
			asciiBuffer = ascii,
		})

		assert.that(content:find("categories", 1, true) ~= nil)
		assert.that(content:find("rangeData", 1, true) ~= nil)
	end)
end)


local test = require("@std/test")

local segmenter = require("@src")
local testData = require("./test-data")

test.suite("GraphemeBreakTest", function(suite)
	for index, entry in testData.grapheme do
		suite:case(`case_{index}`, function(assert)
			local result = segmenter.splitGraphemes(entry.input)
			assert.eq(entry.expected, result)
		end)
	end
end)

test.suite("GraphemeEdgeCases", function(suite)
	suite:case("prependDouble", function(assert)
		local input = " " .. utf8.char(0x0600, 0x0600) .. " "
		local parts = segmenter.splitGraphemes(input)

		assert.eq(2, #parts)
		assert.eq(" ", parts[1])
		assert.eq(utf8.char(0x0600, 0x0600) .. " ", parts[2])
	end)

	suite:case("prependThenSpaces", function(assert)
		local input = utf8.char(0x0600) .. "  "
		local parts = segmenter.splitGraphemes(input)

		assert.eq(2, #parts)
		assert.eq(utf8.char(0x0600) .. " ", parts[1])
		assert.eq(" ", parts[2])
	end)

	suite:case("indexOffsets", function(assert)
		local input = "a" .. utf8.char(0x0301) .. "b"
		local segments = segmenter.graphemes(input)

		assert.eq(2, #segments)
		assert.eq(1, segments[1].index)
		assert.eq(4, segments[2].index)
	end)

	suite:case("emojiZwjSequence", function(assert)
		local input = utf8.char(0x1F469, 0x200D, 0x1F4BB)
		local parts = segmenter.splitGraphemes(input)

		assert.eq(1, #parts)
		assert.eq(input, parts[1])
	end)

	suite:case("familyEmojiSequence", function(assert)
		local input = utf8.char(0x1F468, 0x200D, 0x1F467, 0x200D, 0x1F466)
		local parts = segmenter.splitGraphemes(input)

		assert.eq(1, #parts)
		assert.eq(input, parts[1])
	end)

	suite:case("doubleSkinTone", function(assert)
		local input = utf8.char(0x1F938, 0x1F3FE, 0x1F3FE)
		local parts = segmenter.splitGraphemes(input)

		assert.eq(1, #parts)
		assert.eq(input, parts[1])
	end)

	suite:case("zwjWithoutEmojiBase", function(assert)
		local input = utf8.char(0x61, 0x0301, 0x200D, 0x1F469)
		local parts = segmenter.splitGraphemes(input)

		assert.eq(2, #parts)
		assert.eq(utf8.char(0x61, 0x0301, 0x200D), parts[1])
		assert.eq(utf8.char(0x1F469), parts[2])
	end)

	suite:case("regionalIndicatorPairs", function(assert)
		local input = utf8.char(0x1F1FA, 0x1F1F8, 0x1F1E8, 0x1F1E6)
		local parts = segmenter.splitGraphemes(input)

		assert.eq(2, #parts)
	end)

	suite:case("indicConjunct", function(assert)
		local input = utf8.char(0x0915, 0x094D, 0x0915)
		local parts = segmenter.splitGraphemes(input)

		assert.eq(1, #parts)
	end)
end)


local test = require("@std/test")

local segmenter = require("@src")
local testData = require("./test-data")

test.suite("SentenceBreakTest", function(suite)
	for index, entry in testData.sentence do
		suite:case(`case_{index}`, function(assert)
			local result = segmenter.splitSentences(entry.input)
			assert.eq(entry.expected, result)
		end)
	end
end)

test.suite("SentenceEdgeCases", function(suite)
	suite:case("crlfPair", function(assert)
		local input = "\r\n"
		local parts = segmenter.splitSentences(input)

		assert.eq(1, #parts)
		assert.eq(input, parts[1])
	end)

	suite:case("decimalNumber", function(assert)
		local input = "3.14"
		local parts = segmenter.splitSentences(input)

		assert.eq(1, #parts)
		assert.eq(input, parts[1])
	end)

	suite:case("upperAtermUpper", function(assert)
		local input = "U.S."
		local parts = segmenter.splitSentences(input)

		assert.eq(1, #parts)
		assert.eq(input, parts[1])
	end)

	suite:case("atermLowerLookahead", function(assert)
		local input = "e.g. the"
		local parts = segmenter.splitSentences(input)

		assert.eq(1, #parts)
		assert.eq(input, parts[1])
	end)

	suite:case("terminalWithSpaces", function(assert)
		local input = "Hello!  Next"
		local parts = segmenter.splitSentences(input)

		assert.eq(2, #parts)
		assert.eq("Hello!  ", parts[1])
		assert.eq("Next", parts[2])
	end)
end)


local test = require("@std/test")

local segmenter = require("@src")
local testData = require("./test-data")
local types = require("@src/types")
local wordRuntime = require("@src/word")

local function collectWordLike(input: string): { string }
	local segments = segmenter.words(input)
	local output = {}
	for _, entry in segments do
		if entry.isWordLike then
			table.insert(output, entry.segment)
		end
	end
	return output
end

local function collectSegments(segments: { types.WordSegment }): { string }
	local output = table.create(#segments)
	for index, entry in segments do
		output[index] = entry.segment
	end
	return output
end

test.suite("WordBreakTest", function(suite)
	for index, entry in testData.word do
		suite:case(`case_{index}`, function(assert)
			local result = segmenter.splitWords(entry.input)
			assert.eq(entry.expected, result)
		end)
	end
end)

test.suite("WordEdgeCases", function(suite)
	suite:case("crlfPair", function(assert)
		local input = "\r\n"
		local parts = segmenter.splitWords(input)

		assert.eq(1, #parts)
		assert.eq(input, parts[1])
	end)

	suite:case("zwjEmojiSequence", function(assert)
		local input = utf8.char(0x1F469, 0x200D, 0x1F4BB)
		local segments = segmenter.words(input)

		assert.eq(1, #segments)
		assert.eq(true, segments[1].isWordLike == false)
	end)

	suite:case("hebrewQuote", function(assert)
		local input = "א׳ב"
		local parts = segmenter.splitWords(input)

		assert.eq(1, #parts)
	end)

	suite:case("midNumeric", function(assert)
		local input = "3.14"
		local parts = segmenter.splitWords(input)

		assert.eq(1, #parts)
	end)

	suite:case("regionalIndicatorPairs", function(assert)
		local input = utf8.char(0x1F1FA, 0x1F1F8, 0x1F1E8, 0x1F1E6)
		local parts = segmenter.splitWords(input)

		assert.eq(2, #parts)
	end)

	suite:case("longRegionalIndicatorChainEven", function(assert)
		local input = utf8.char(
			0x1F1E6,
			0x1F1EB,
			0x1F1E6,
			0x1F1FD,
			0x1F1E6,
			0x1F1F1,
			0x1F1E9,
			0x1F1FF,
			0x1F1E6,
			0x1F1F8,
			0x1F1E6,
			0x1F1E9,
			0x1F1E6,
			0x1F1F4
		)
		local parts = segmenter.splitWords(input)

		assert.eq(7, #parts)
		assert.eq(utf8.char(0x1F1E6, 0x1F1EB), parts[1])
		assert.eq(utf8.char(0x1F1E6, 0x1F1FD), parts[2])
		assert.eq(utf8.char(0x1F1E6, 0x1F1F1), parts[3])
		assert.eq(utf8.char(0x1F1E9, 0x1F1FF), parts[4])
		assert.eq(utf8.char(0x1F1E6, 0x1F1F8), parts[5])
		assert.eq(utf8.char(0x1F1E6, 0x1F1E9), parts[6])
		assert.eq(utf8.char(0x1F1E6, 0x1F1F4), parts[7])
	end)

	suite:case("longRegionalIndicatorChainOdd", function(assert)
		local input = utf8.char(
			0x1F1E6,
			0x1F1EB,
			0x1F1E6,
			0x1F1FD,
			0x1F1E6,
			0x1F1F1,
			0x1F1E9,
			0x1F1FF,
			0x1F1E6,
			0x1F1F8,
			0x1F1E6,
			0x1F1E9,
			0x1F1E6
		)
		local parts = segmenter.splitWords(input)

		assert.eq(7, #parts)
		assert.eq(utf8.char(0x1F1E6, 0x1F1EB), parts[1])
		assert.eq(utf8.char(0x1F1E6, 0x1F1FD), parts[2])
		assert.eq(utf8.char(0x1F1E6, 0x1F1F1), parts[3])
		assert.eq(utf8.char(0x1F1E9, 0x1F1FF), parts[4])
		assert.eq(utf8.char(0x1F1E6, 0x1F1F8), parts[5])
		assert.eq(utf8.char(0x1F1E6, 0x1F1E9), parts[6])
		assert.eq(utf8.char(0x1F1E6), parts[7])
	end)

	suite:case("regionalIndicatorWithLetters", function(assert)
		local input = utf8.char(0x1F1E6)
			.. "a"
			.. utf8.char(0x1F1EB, 0x1F1E6)
			.. utf8.char(0x1F1FD)
			.. "a"
			.. utf8.char(0x1F1E6, 0x1F1F1, 0x1F1E9, 0x1F1FF, 0x1F1E6, 0x1F1F8, 0x1F1E6, 0x1F1E9, 0x1F1E6)
		local parts = segmenter.splitWords(input)

		assert.eq(10, #parts)
		assert.eq(utf8.char(0x1F1E6), parts[1])
		assert.eq("a", parts[2])
		assert.eq(utf8.char(0x1F1EB, 0x1F1E6), parts[3])
		assert.eq(utf8.char(0x1F1FD), parts[4])
		assert.eq("a", parts[5])
		assert.eq(utf8.char(0x1F1E6, 0x1F1F1), parts[6])
		assert.eq(utf8.char(0x1F1E9, 0x1F1FF), parts[7])
		assert.eq(utf8.char(0x1F1E6, 0x1F1F8), parts[8])
		assert.eq(utf8.char(0x1F1E6, 0x1F1E9), parts[9])
		assert.eq(utf8.char(0x1F1E6), parts[10])
	end)

	suite:case("emojiWithModifier", function(assert)
		local input = utf8.char(0x1F60C, 0x1F44E, 0x1F3FC)
		local parts = segmenter.splitWords(input)

		assert.eq(2, #parts)
		assert.eq(utf8.char(0x1F60C), parts[1])
		assert.eq(utf8.char(0x1F44E, 0x1F3FC), parts[2])
	end)

	suite:case("wsegSpaceWithExtend", function(assert)
		local input = " " .. utf8.char(0x0301) .. " "
		local parts = segmenter.splitWords(input)

		assert.eq(2, #parts)
	end)

	suite:case("wordLikeFlag", function(assert)
		local segments = segmenter.words("hi!")

		assert.eq(true, segments[1].isWordLike)
		assert.eq(false, segments[2].isWordLike)
	end)

	suite:case("underscoreNotWordLike", function(assert)
		local segments = segmenter.words("_")

		assert.eq(1, #segments)
		assert.eq(false, segments[1].isWordLike)
	end)

	suite:case("asciiPunctuationSegments", function(assert)
		local input = "Hello, world!"
		local parts = segmenter.splitWords(input)

		assert.eq({ "Hello", ",", " ", "world", "!" }, parts)
	end)

	suite:case("asciiWordLikeExamples", function(assert)
		local input = "Hello, world! can't e.g. var1 123,456 foo_bar example.com 127.0.0.1:9090"
		local words = collectWordLike(input)

		assert.eq({
			"Hello",
			"world",
			"can't",
			"e.g",
			"var1",
			"123,456",
			"foo_bar",
			"example.com",
			"127.0.0.1",
			"9090",
		}, words)
	end)
end)

test.suite("AsciiFastPathParity", function(suite)
	suite:case("asciiSamples", function(assert)
		local samples = {
			"",
			"Hello, world!",
			"can't",
			"e.g.",
			"var1",
			"123,456",
			"foo_bar",
			"example.com",
			"127.0.0.1:9090",
			"Hello!  Next",
		}

		math.randomseed(12345)
		for _ = 1, 50 do
			local length = math.random(0, 40)
			local buffer = table.create(length)
			for index = 1, length do
				buffer[index] = string.char(math.random(0x20, 0x7E))
			end
			table.insert(samples, table.concat(buffer))
		end

		for _, sample in samples do
			local fast = collectSegments(wordRuntime.wordSegments(sample))
			local slow = collectSegments(wordRuntime.wordSegmentsUnicode(sample))
			assert.eq(fast, slow)
		end
	end)
end)


--[[
Copyright © 2024 Stephen Leitnick

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
]]
-- -----------------------------------------------------------------------------
--               Batched Yield-Safe Signal Implementation                     --
-- This is a Signal class which has effectively identical behavior to a       --
-- normal RBXScriptSignal, with the only difference being a couple extra      --
-- stack frames at the bottom of the stack trace when an error is thrown.     --
-- This implementation caches runner coroutines, so the ability to yield in   --
-- the signal handlers comes at minimal extra cost over a naive signal        --
-- implementation that either always or never spawns a thread.                --
--                                                                            --
-- License:                                                                   --
--   Licensed under the MIT license.                                          --
--                                                                            --
-- Authors:                                                                   --
--   stravant - July 31st, 2021 - Created the file.                           --
--   sleitnick - August 3rd, 2021 - Modified for Knit.                        --
-- -----------------------------------------------------------------------------

-- Signal types
export type Connection = {
	Disconnect: (self: Connection) -> (),
	Destroy: (self: Connection) -> (),
	Connected: boolean,
}

export type Signal<T...> = {
	Fire: (self: Signal<T...>, T...) -> (),
	FireDeferred: (self: Signal<T...>, T...) -> (),
	Connect: (self: Signal<T...>, fn: (T...) -> ()) -> Connection,
	Once: (self: Signal<T...>, fn: (T...) -> ()) -> Connection,
	DisconnectAll: (self: Signal<T...>) -> (),
	GetConnections: (self: Signal<T...>) -> { Connection },
	Destroy: (self: Signal<T...>) -> (),
	Wait: (self: Signal<T...>) -> T...,
}

-- The currently idle thread to run the next handler on
local freeRunnerThread = nil

-- Function which acquires the currently idle handler runner thread, runs the
-- function fn on it, and then releases the thread, returning it to being the
-- currently idle one.
-- If there was a currently idle runner thread already, that's okay, that old
-- one will just get thrown and eventually GCed.
local function acquireRunnerThreadAndCallEventHandler(fn, ...)
	local acquiredRunnerThread = freeRunnerThread
	freeRunnerThread = nil
	fn(...)
	-- The handler finished running, this runner thread is free again.
	freeRunnerThread = acquiredRunnerThread
end

-- Coroutine runner that we create coroutines of. The coroutine can be
-- repeatedly resumed with functions to run followed by the argument to run
-- them with.
local function runEventHandlerInFreeThread(...)
	acquireRunnerThreadAndCallEventHandler(...)
	while true do
		acquireRunnerThreadAndCallEventHandler(coroutine.yield())
	end
end

--[=[
	@within Signal
	@interface SignalConnection
	.Connected boolean
	.Disconnect (SignalConnection) -> ()

	Represents a connection to a signal.
	```lua
	local connection = signal:Connect(function() end)
	print(connection.Connected) --> true
	connection:Disconnect()
	print(connection.Connected) --> false
	```
]=]

-- Connection class
local Connection = {}
Connection.__index = Connection

function Connection:Disconnect()
	if not self.Connected then
		return
	end
	self.Connected = false

	-- Unhook the node, but DON'T clear it. That way any fire calls that are
	-- currently sitting on this node will be able to iterate forwards off of
	-- it, but any subsequent fire calls will not hit it, and it will be GCed
	-- when no more fire calls are sitting on it.
	if self._signal._handlerListHead == self then
		self._signal._handlerListHead = self._next
	else
		local prev = self._signal._handlerListHead
		while prev and prev._next ~= self do
			prev = prev._next
		end
		if prev then
			prev._next = self._next
		end
	end
end

Connection.Destroy = Connection.Disconnect

-- Make Connection strict
setmetatable(Connection, {
	__index = function(_tb, key)
		error(("Attempt to get Connection::%s (not a valid member)"):format(tostring(key)), 2)
	end,
	__newindex = function(_tb, key, _value)
		error(("Attempt to set Connection::%s (not a valid member)"):format(tostring(key)), 2)
	end,
})

--[=[
	@within Signal
	@type ConnectionFn (...any) -> ()

	A function connected to a signal.
]=]

--[=[
	@class Signal

	A Signal is a data structure that allows events to be dispatched
	and observed.

	This implementation is a direct copy of the de facto standard, [GoodSignal](https://devforum.roblox.com/t/lua-signal-class-comparison-optimal-goodsignal-class/1387063),
	with some added methods and typings.

	For example:
	```lua
	local signal = Signal.new()

	-- Subscribe to a signal:
	signal:Connect(function(msg)
		print("Got message:", msg)
	end)

	-- Dispatch an event:
	signal:Fire("Hello world!")
	```
]=]
local Signal = {}
Signal.__index = Signal

--[=[
	Constructs a new Signal

	@return Signal
]=]
function Signal.new<T...>(): Signal<T...>
	local self = setmetatable({
		_handlerListHead = false,
		_proxyHandler = nil,
		_yieldedThreads = nil,
	}, Signal)

	return self
end

--[=[
	Constructs a new Signal that wraps around an RBXScriptSignal.

	@param rbxScriptSignal RBXScriptSignal -- Existing RBXScriptSignal to wrap
	@return Signal

	For example:
	```lua
	local signal = Signal.Wrap(workspace.ChildAdded)
	signal:Connect(function(part) print(part.Name .. " added") end)
	Instance.new("Part").Parent = workspace
	```
]=]
function Signal.Wrap<T...>(rbxScriptSignal: RBXScriptSignal): Signal<T...>
	assert(
		typeof(rbxScriptSignal) == "RBXScriptSignal",
		"Argument #1 to Signal.Wrap must be a RBXScriptSignal; got " .. typeof(rbxScriptSignal)
	)

	local signal = Signal.new()
	signal._proxyHandler = rbxScriptSignal:Connect(function(...)
		signal:Fire(...)
	end)

	return signal
end

--[=[
	Checks if the given object is a Signal.

	@param obj any -- Object to check
	@return boolean -- `true` if the object is a Signal.
]=]
function Signal.Is(obj: any): boolean
	return type(obj) == "table" and getmetatable(obj) == Signal
end

--[=[
	@param fn ConnectionFn
	@return SignalConnection

	Connects a function to the signal, which will be called anytime the signal is fired.
	```lua
	signal:Connect(function(msg, num)
		print(msg, num)
	end)

	signal:Fire("Hello", 25)
	```
]=]
function Signal:Connect(fn)
	local connection = setmetatable({
		Connected = true,
		_signal = self,
		_fn = fn,
		_next = false,
	}, Connection)

	if self._handlerListHead then
		connection._next = self._handlerListHead
		self._handlerListHead = connection
	else
		self._handlerListHead = connection
	end

	return connection
end

--[=[
	@deprecated v1.3.0 -- Use `Signal:Once` instead.
	@param fn ConnectionFn
	@return SignalConnection
]=]
function Signal:ConnectOnce(fn)
	return self:Once(fn)
end

--[=[
	@param fn ConnectionFn
	@return SignalConnection

	Connects a function to the signal, which will be called the next time the signal fires. Once
	the connection is triggered, it will disconnect itself.
	```lua
	signal:Once(function(msg, num)
		print(msg, num)
	end)

	signal:Fire("Hello", 25)
	signal:Fire("This message will not go through", 10)
	```
]=]
function Signal:Once(fn)
	local connection
	local done = false

	connection = self:Connect(function(...)
		if done then
			return
		end

		done = true
		connection:Disconnect()
		fn(...)
	end)

	return connection
end

function Signal:GetConnections()
	local items = {}

	local item = self._handlerListHead
	while item do
		table.insert(items, item)
		item = item._next
	end

	return items
end

-- Disconnect all handlers. Since we use a linked list it suffices to clear the
-- reference to the head handler.
--[=[
	Disconnects all connections from the signal.
	```lua
	signal:DisconnectAll()
	```
]=]
function Signal:DisconnectAll()
	local item = self._handlerListHead
	while item do
		item.Connected = false
		item = item._next
	end
	self._handlerListHead = false

	local yieldedThreads = rawget(self, "_yieldedThreads")
	if yieldedThreads then
		for thread in yieldedThreads do
			if coroutine.status(thread) == "suspended" then
				warn(debug.traceback(thread, "signal disconnected; yielded thread cancelled", 2))
				task.cancel(thread)
			end
		end
		table.clear(self._yieldedThreads)
	end
end

-- Signal:Fire(...) implemented by running the handler functions on the
-- coRunnerThread, and any time the resulting thread yielded without returning
-- to us, that means that it yielded to the Roblox scheduler and has been taken
-- over by Roblox scheduling, meaning we have to make a new coroutine runner.
--[=[
	@param ... any

	Fire the signal, which will call all of the connected functions with the given arguments.
	```lua
	signal:Fire("Hello")

	-- Any number of arguments can be fired:
	signal:Fire("Hello", 32, {Test = "Test"}, true)
	```
]=]
function Signal:Fire(...)
	local item = self._handlerListHead
	while item do
		if item.Connected then
			if not freeRunnerThread then
				freeRunnerThread = coroutine.create(runEventHandlerInFreeThread)
			end
			task.spawn(freeRunnerThread, item._fn, ...)
		end
		item = item._next
	end
end

--[=[
	@param ... any

	Same as `Fire`, but uses `task.defer` internally & doesn't take advantage of thread reuse.
	```lua
	signal:FireDeferred("Hello")
	```
]=]
function Signal:FireDeferred(...)
	local item = self._handlerListHead
	while item do
		local conn = item
		task.defer(function(...)
			if conn.Connected then
				conn._fn(...)
			end
		end, ...)
		item = item._next
	end
end

--[=[
	@return ... any
	@yields

	Yields the current thread until the signal is fired, and returns the arguments fired from the signal.
	Yielding the current thread is not always desirable. If the desire is to only capture the next event
	fired, using `Once` might be a better solution.
	```lua
	task.spawn(function()
		local msg, num = signal:Wait()
		print(msg, num) --> "Hello", 32
	end)
	signal:Fire("Hello", 32)
	```
]=]
function Signal:Wait()
	local yieldedThreads = rawget(self, "_yieldedThreads")
	if not yieldedThreads then
		yieldedThreads = {}
		rawset(self, "_yieldedThreads", yieldedThreads)
	end

	local thread = coroutine.running()
	yieldedThreads[thread] = true

	self:Once(function(...)
		yieldedThreads[thread] = nil
		task.spawn(thread, ...)
	end)

	return coroutine.yield()
end

--[=[
	Cleans up the signal.

	Technically, this is only necessary if the signal is created using
	`Signal.Wrap`. Connections should be properly GC'd once the signal
	is no longer referenced anywhere. However, it is still good practice
	to include ways to strictly clean up resources. Calling `Destroy`
	on a signal will also disconnect all connections immediately.
	```lua
	signal:Destroy()
	```
]=]
function Signal:Destroy()
	self:DisconnectAll()

	local proxyHandler = rawget(self, "_proxyHandler")
	if proxyHandler then
		proxyHandler:Disconnect()
	end
end

-- Make signal strict
setmetatable(Signal, {
	__index = function(_tb, key)
		error(("Attempt to get Signal::%s (not a valid member)"):format(tostring(key)), 2)
	end,
	__newindex = function(_tb, key, _value)
		error(("Attempt to set Signal::%s (not a valid member)"):format(tostring(key)), 2)
	end,
})

return table.freeze({
	new = Signal.new,
	Wrap = Signal.Wrap,
	Is = Signal.Is,
})


local roblox = require("@lune/roblox")
local fs = require("@lune/fs")
local luau = require("@lune/luau")
local process = require("@lune/process")
local stdio = require("@lune/stdio")
local task = require("@lune/task")

local signal = require("mock-signal")

local testPlacePath = process.args[1]
local game = roblox.deserializePlace(fs.readFile(testPlacePath))

-- rUnit uses lemon signal
roblox.implementProperty("BindableEvent", "Event", function(get)
  return signal.new()
end)

local function tableJoin(...)
	local result = {}
	for i = 1, select("#", ...) do
		for k, v in select(i, ...) do
			result[k] = v
		end
	end
	return result
end

-- RuntimeLib uses :WaitForChild(), but tests don't need networking so :FindFirstChild() should be fine
roblox.implementMethod("Instance", "WaitForChild", function(self, ...)
	return self:FindFirstChild(...)
end)

-- Promise.lua indexes RunService.Heartbeat, but only uses it in Promise.defer and Promise.delay
roblox.implementProperty("RunService", "Heartbeat", function()
	return signal.new()
end, function() end)

roblox.implementMethod("RunService", "IsStudio", function()
	return true
end)

roblox.implementMethod("RunService", "IsServer", function()
	return false
end)

roblox.implementMethod("RunService", "IsClient", function()
	return true
end)

roblox.implementMethod("RunService", "IsRunning", function()
	return false
end)

roblox.implementMethod("DataModel", "IsLoaded", function()
	return true
end)

roblox.implementProperty("Players", "LocalPlayer", function()
	return roblox.Instance.new("Player")
end)

roblox.implementMethod("HttpService", "JSONEncode", function()
	return nil
end)

local robloxRequire

local function runRobloxScript(script: Script | LocalScript | ModuleScript)
	local callableFn = luau.load(luau.compile(script.Source), {
		debugName = script:GetFullName(),
		environment = tableJoin(roblox, {
			Instance = roblox.Instance,
			game = game,
			script = script,
			require = robloxRequire,
			tick = tick,
			task = task,
			debug = debug,
		})
	})

	return callableFn()
end

local requireCache = {}

function robloxRequire(moduleScript: ModuleScript)
	-- the same script instance sometimes gives a different ref
	-- unsure why, but using :GetFullName() fixes this for now
	local scriptPath = moduleScript:GetFullName()
	local cached = requireCache[scriptPath]
	if cached then
		return table.unpack(cached)
	end

	local result = table.pack(runRobloxScript(moduleScript))
	requireCache[scriptPath] = result
	return table.unpack(result)
end

runRobloxScript(game.ReplicatedStorage.Scripts["run-tests"])


import type { OnStart } from "@flamework/core";
import { BaseComponent } from "@flamework/components";
import { Janitor } from "@rbxts/janitor";

export default abstract class BaseButtonAnimation<A extends {} = {}, I extends GuiButton = GuiButton> extends BaseComponent<A, I> implements OnStart {
  protected readonly includeClick: boolean = true;
  protected readonly janitor = new Janitor;
  protected hovered = false;

  protected abstract active?(): void;
  protected abstract inactive?(): void;

  public onStart(): void {
    this.janitor.Add(this.instance.MouseEnter.Connect(() => {
      this.hovered = true;
      this.active?.();
    }));
    this.janitor.Add(this.instance.MouseLeave.Connect(() => {
      this.hovered = false;
      this.inactive?.();
    }));
    if (this.includeClick) {
      this.janitor.Add(this.instance.MouseButton1Down.Connect(() => {
        this.hovered = false;
        this.inactive?.();
      }));
      this.janitor.Add(this.instance.MouseButton1Up.Connect(() => {
        this.hovered = true;
        this.active?.();
      }));
    }
  }
}


import type { OnStart } from "@flamework/core";
import type { TweenInfoBuilder } from "@rbxts/builders";

import BaseButtonAnimation from "./base-button-animation";

export default abstract class ButtonTweenAnimation<A extends {} = {}, I extends GuiButton = GuiButton> extends BaseButtonAnimation<A, I> implements OnStart {
  protected abstract readonly tweenInfo: TweenInfo | TweenInfoBuilder;

  protected abstract active?(): void;
  protected abstract inactive?(): void;

  public onStart(): void {
    super.onStart();
  }
}


import { BaseComponent } from "@flamework/components";
import { Workspace as World } from "@rbxts/services";

const { rad } = math;

export class CameraComponent<A extends {} = {}> extends BaseComponent<A, Camera> {
  protected readonly offsets: CFrame[] = [];

  public toggle(on: boolean): void {
    World.CurrentCamera = on ? this.instance : World.CurrentCamera;
  }

  public setCFrame(cframe: CFrame): void {
    this.instance.CFrame = cframe;
  }

  public setPosition(position: Vector3): void {
    this.setCFrame(new CFrame(position));
  }

  public setOrientation(orientation: Vector3): void {
    this.setCFrame(this.instance.CFrame.mul(CFrame.Angles(rad(orientation.X), rad(orientation.Y), rad(orientation.Z))));
  }

  public lookAt(position: Vector3): void {
    this.setCFrame(CFrame.lookAt(this.instance.CFrame.Position, position));
  }
}


import { getChildrenOfType } from "@rbxts/instance-utility";
import { processDependency } from "@rbxts/flamework-meta-utils";
import Object from "@rbxts/object-utils";

import { createMappingDecorator, getInstanceAtPath } from "shared/utility/meta";

const { min } = math;

type ProceduralAnimationDecoratorArgs = [target: ProceduralAnimationTarget];
const [animationClassMap, ProceduralAnimation] = createMappingDecorator<BaseProceduralAnimation, never[], ProceduralAnimationDecoratorArgs>();
export { ProceduralAnimation };

export const enum ProceduralAnimationTarget {
  Camera,
  Model,
  Any
}

export abstract class BaseProceduralAnimation {
  protected abstract update(dt: number): Vector3;
  public abstract getCFrame(dt: number): CFrame;
}

function combineCFrames(cframes: CFrame[]): CFrame {
  return cframes.reduce((sum, cf) => sum.mul(cf), new CFrame);
}

export class ProceduralAnimationHost<I extends Camera | Model = Camera | Model> {
  private readonly cframeManipulators = {
    aim: new Instance("CFrameValue")
  };

  private readonly animations: [BaseProceduralAnimation, ProceduralAnimationDecoratorArgs][] = [];
  private readonly connectedToCamera: boolean;

  public constructor(
    private readonly target: I
  ) {
    this.connectedToCamera = this.target.IsA("Camera");
    for (const animationModule of getChildrenOfType(getInstanceAtPath("src/client/classes/procedural-animations")!, "ModuleScript"))
      require(animationModule);

    for (const [ProceduralAnimation, args] of Object.values(animationClassMap))
      processDependency(ProceduralAnimation, animation => this.animations.push([animation, args]));
  }

  public update(dt: number): CFrame {
    dt = min(dt, 1);
    const offset = this.getOffset(dt);
    const finalManipulatorOffset = combineCFrames(Object.values(this.cframeManipulators).map(manipulator => manipulator.Value));
    return offset.mul(finalManipulatorOffset);
  }

  private getOffset(dt: number): CFrame {
    const offsets: CFrame[] = [];
    for (const [animation, [instance]] of this.animations) {
      if (instance === ProceduralAnimationTarget.Camera && !this.connectedToCamera) continue;
      if (instance === ProceduralAnimationTarget.Model && this.connectedToCamera) continue;
      offsets.push(animation.getCFrame(dt));
    }

    return combineCFrames(offsets);
  }
}


import Iris from "@rbxts/iris";

import { Singleton } from "shared/decorators";
import { Spring } from "shared/classes/spring";
import { doubleSidedLimit } from "shared/utility/numbers";
import { ProceduralAnimation, BaseProceduralAnimation, ProceduralAnimationTarget } from "../procedural-animation-host";
import type { ControlPanelDropdownRenderer } from "shared/structs/control-panel";

import type { MouseController } from "client/controllers/mouse";

@Singleton()
@ProceduralAnimation(ProceduralAnimationTarget.Model)
export class MouseSwayAnimation extends BaseProceduralAnimation implements ControlPanelDropdownRenderer {
  private readonly horizontalDamping = 0.5;
  private readonly spring = new Spring(4, 25, 16, 3.5);
  private damping = 450;
  private limit = 0.018;

  public constructor(
    private readonly mouse: MouseController
  ) { super(); }

  public getCFrame(dt: number): CFrame {
    const movement = this.update(dt).div(this.damping);
    const rotationalMovement = movement.X / 2;

    return new CFrame(-movement.X / this.horizontalDamping, movement.Y * 1.25, 0)
      .mul(CFrame.Angles(-movement.Y * 1.25, -movement.X, rotationalMovement));
  }

  public renderControlPanelDropdown(): void {
    Iris.Tree(["Mouse Sway"]);
    {
      const dampingSlider = Iris.SliderNum(["Damping", 1, 1, 500], { number: Iris.State(this.damping) });
      if (dampingSlider.numberChanged())
        this.damping = dampingSlider.state.number.get();

      const limitSlider = Iris.SliderNum(["Limit", 0.005, 0.005, 0.15], { number: Iris.State(this.limit) });
      if (limitSlider.numberChanged())
        this.limit = limitSlider.state.number.get();

      this.spring.renderControlPanelDropdown();
    }
    Iris.End();
  }

  protected update(dt: number): Vector3 {
    const { X, Y } = this.mouse.getDelta().div(this.damping);
    const swayForce = new Vector3(
      doubleSidedLimit(X, this.limit),
      doubleSidedLimit(Y, this.limit),
      0
    );

    this.spring.shove(swayForce);
    return this.spring.update(dt).mul(1.25);
  }
}


import Wave from "@rbxts/wave";

import { Singleton } from "shared/decorators";
import { Spring } from "shared/classes/spring";
import { ProceduralAnimation, BaseProceduralAnimation, ProceduralAnimationTarget } from "../procedural-animation-host";

import type { CharacterController } from "client/controllers/character";

const { round, cos } = math;

const WAVE_DAMPING = 900;
const BASE_AMPLITUDE = 1.5;
const BASE_FREQUENCY = 16;
const BASE_VERTICAL_SHIFT = -1;

@Singleton()
@ProceduralAnimation(ProceduralAnimationTarget.Any)
export class WalkCycleAnimation extends BaseProceduralAnimation {
  private readonly spring = new Spring;
  private readonly sineWave = new Wave(BASE_AMPLITUDE, BASE_FREQUENCY, BASE_VERTICAL_SHIFT, 0, WAVE_DAMPING);
  private readonly cosineWave = new Wave(BASE_AMPLITUDE, BASE_FREQUENCY, BASE_VERTICAL_SHIFT, 1, WAVE_DAMPING, cos);
  private readonly damping = -6;
  private readonly minimumSpeed = 1; // if you want

  public constructor(
    private readonly character: CharacterController
  ) { super(); }

  public getCFrame(dt: number): CFrame {
    const movement = this.update(dt);
    return new CFrame(0, movement.Y, 0)
      .mul(CFrame.Angles(movement.Y, movement.X / 3.5, movement.Z));
  }

  protected update(dt: number): Vector3 {
    const root = this.character.getRoot();
    if (root === undefined)
      return this.spring.update(dt);

    const velocity = root.AssemblyLinearVelocity.mul(60);
    const walkSpeed = velocity.mul(new Vector3(1, 0, 1)).Magnitude;
    this.sineWave.frequency = round(walkSpeed) / 1.25;
    this.cosineWave.frequency = round(walkSpeed) / 1.25;

    const doNotApplyWave = walkSpeed === 0 || walkSpeed < this.minimumSpeed;
    const x = doNotApplyWave ? 0 : this.sineWave.update(1);
    const y = doNotApplyWave ? 0 : this.cosineWave.update(1);
    const force = new Vector3(x, y, x);

    this.spring.shove(force);
    return this.spring.update(dt).div(this.damping);
  }
}


import { Dependency, type OnRender } from "@flamework/core";
import { Component, type Components } from "@flamework/components";
import { Workspace as World } from "@rbxts/services";
import { $nameof } from "rbxts-transform-debug";

import { Player } from "client/utility";

import { CameraComponent } from "client/base-components/camera";
import type { CameraController } from "client/controllers/camera";
import type { CharacterController } from "client/controllers/character";

interface Attributes {
  readonly AerialCamera_Height: number;
}

@Component({
  tag: $nameof<AerialCamera>(),
  defaults: {
    AerialCamera_Height: 20
  }
})
export class AerialCamera extends CameraComponent<Attributes> implements OnRender {
  public static create(controller: CameraController): AerialCamera {
    const components = Dependency<Components>();
    const camera = World.CurrentCamera!.Clone();
    camera.CameraType = Enum.CameraType.Scriptable;
    camera.Name = $nameof<AerialCamera>();
    camera.Parent = controller.cameraStorage;

    return components.addComponent(camera);
  }

  public constructor(
    private readonly character: CharacterController
  ) { super(); }

  public onRender(dt: number): void {
    const root = this.character.getRoot();
    if (root === undefined) return;

    const position = root.Position.add(new Vector3(0, this.getHeight(), 0));
    this.setCFrame(CFrame.lookAt(position, root.Position));
  }

  public override toggle(on: boolean): void {
    super.toggle(on);
    Player.CameraMode = on ? Enum.CameraMode.Classic : Player.CameraMode;
  }

  private getHeight(): number | undefined {
    return this.attributes.AerialCamera_Height;
  }
}


import { Dependency } from "@flamework/core";
import { Component, type Components } from "@flamework/components";
import { Workspace as World } from "@rbxts/services";
import { $nameof } from "rbxts-transform-debug";

import { Player } from "client/utility";

import { CameraComponent } from "client/base-components/camera";
import type { CameraController } from "client/controllers/camera";

@Component({ tag: $nameof<DefaultCamera>() })
export class DefaultCamera extends CameraComponent {
  public static create(controller: CameraController): DefaultCamera {
    const components = Dependency<Components>();
    const camera = World.CurrentCamera!; // every other camera component should clone a new camera
    camera.Name = $nameof<DefaultCamera>();
    camera.Parent = controller.cameraStorage;

    return components.addComponent(camera);
  }

  public override toggle(on: boolean): void {
    super.toggle(on);
    Player.CameraMode = on ? Enum.CameraMode.Classic : Player.CameraMode;
  }
}


import { Dependency, type OnRender } from "@flamework/core";
import { Component, type Components } from "@flamework/components";
import { Workspace as World } from "@rbxts/services";
import { $nameof } from "rbxts-transform-debug";

import { ProceduralAnimationHost } from "client/classes/procedural-animation-host";

import { FirstPersonCamera } from "./first-person";
import type { CameraController } from "client/controllers/camera";
import type { MouseController } from "client/controllers/mouse";
import type { CharacterController } from "client/controllers/character";

@Component({ tag: $nameof<FirstPersonAnimatedCamera>() })
export class FirstPersonAnimatedCamera extends FirstPersonCamera implements OnRender {
  public readonly animator;

  public static create(controller: CameraController): FirstPersonAnimatedCamera {
    const components = Dependency<Components>();
    const camera = World.CurrentCamera!.Clone();
    camera.Name = $nameof<FirstPersonAnimatedCamera>();
    camera.Parent = controller.cameraStorage;

    return components.addComponent(camera);
  }

  public constructor(
    mouse: MouseController,
    character: CharacterController,
    private readonly camera: CameraController
  ) {
    super(mouse, character);
    this.animator = new ProceduralAnimationHost(this.instance);
  }

  public onRender(dt: number): void {
    super.onRender(dt);
    if (this.camera.currentName !== this.instance.Name) return;

    const offset = this.animator.update(dt);
    this.instance.CFrame = this.instance.CFrame.mul(offset);
  }
}


import { Dependency } from "@flamework/core";
import { Component, type Components } from "@flamework/components";
import { Workspace as World } from "@rbxts/services";
import { $nameof } from "rbxts-transform-debug";

import { Player } from "client/utility";

import { CameraComponent } from "client/base-components/camera";
import type { CameraController } from "client/controllers/camera";
import type { MouseController } from "client/controllers/mouse";
import type { CharacterController } from "client/controllers/character";

@Component({ tag: $nameof<FirstPersonCamera>() })
export class FirstPersonCamera extends CameraComponent {
  public static create(controller: CameraController): FirstPersonCamera {
    const components = Dependency<Components>();
    const camera = World.CurrentCamera!.Clone();
    camera.Name = $nameof<FirstPersonCamera>();
    camera.VRTiltAndRollEnabled = true;
    camera.Parent = controller.cameraStorage;

    return components.addComponent(camera);
  }

  public constructor(
    private readonly mouse: MouseController,
    protected readonly character: CharacterController
  ) { super(); }

  public onRender(dt: number): void {
    const character = this.character.get();
    if (character === undefined || this.character.getRoot() === undefined) return;
    this.instance.CameraSubject = character.Humanoid;
  }

  public override toggle(on: boolean): void {
    super.toggle(on);
    this.onRender(0);
    this.mouse.behavior(on ? Enum.MouseBehavior.LockCenter : this.mouse.behavior);
    Player.CameraMode = on ? Enum.CameraMode.LockFirstPerson : Player.CameraMode;
  }
}


import { Dependency } from "@flamework/core";
import { Component, type Components } from "@flamework/components";
import { Workspace as World } from "@rbxts/services";
import { $nameof } from "rbxts-transform-debug";

import { Player } from "client/utility";
import { CameraComponent } from "client/base-components/camera";
import type { CameraController } from "client/controllers/camera";

@Component({ tag: $nameof<FixedCamera>() })
export class FixedCamera extends CameraComponent {
  public static create(controller: CameraController): FixedCamera {
    const components = Dependency<Components>();
    const camera = World.CurrentCamera!.Clone();
    camera.CameraType = Enum.CameraType.Scriptable;
    camera.Name = $nameof<FixedCamera>();
    camera.VRTiltAndRollEnabled = true;
    camera.Parent = controller.cameraStorage;

    return components.addComponent(camera);
  }

  public override toggle(on: boolean): void {
    super.toggle(on);
    Player.CameraMode = on ? Enum.CameraMode.Classic : Player.CameraMode;
  }
}


import { Dependency, type OnRender } from "@flamework/core";
import { Component, type Components } from "@flamework/components";
import { Workspace as World } from "@rbxts/services";
import { $nameof } from "rbxts-transform-debug";

import { Player } from "client/utility";

import { CameraComponent } from "client/base-components/camera";
import type { CameraController } from "client/controllers/camera";
import type { CharacterController } from "client/controllers/character";

@Component({ tag: $nameof<FlyOnTheWallCamera>() })
export class FlyOnTheWallCamera extends CameraComponent implements OnRender {
  public static create(controller: CameraController): FlyOnTheWallCamera {
    const components = Dependency<Components>();
    const camera = World.CurrentCamera!.Clone();
    camera.CameraType = Enum.CameraType.Scriptable;
    camera.Name = $nameof<FlyOnTheWallCamera>();
    camera.Parent = controller.cameraStorage;

    return components.addComponent(camera);
  }

  public constructor(
    private readonly character: CharacterController
  ) { super(); }

  public onRender(dt: number): void {
    const root = this.character.getRoot();
    if (root === undefined) return;

    this.lookAt(root.Position);
  }

  public override toggle(on: boolean): void {
    super.toggle(on);
    Player.CameraMode = on ? Enum.CameraMode.Classic : Player.CameraMode;
  }
}


import { OnStart } from "@flamework/core";
import { Component } from "@flamework/components";
import { TweenInfoBuilder } from "@rbxts/builders";
import { tween } from "@rbxts/instance-utility";
import { $nameof } from "rbxts-transform-debug";

import ButtonTweenAnimation from "client/base-components/button-tween-animation";

interface Attributes {
  readonly GradientAnimation_OffsetGoal: Vector2;
  readonly GradientAnimation_Speed: number;
}

@Component({
  tag: $nameof<GradientAnimation>(),
  defaults: {
    GradientAnimation_OffsetGoal: 0.15,
    GradientAnimation_Speed: 0.1
  }
})
export class GradientAnimation extends ButtonTweenAnimation<Attributes, GuiButton & { UIGradient: UIGradient; }> implements OnStart {
  private readonly defaultOffset = this.instance.UIGradient.Offset;

  protected readonly tweenInfo = new TweenInfoBuilder()
    .SetEasingStyle(Enum.EasingStyle.Quad)
    .SetTime(this.attributes.GradientAnimation_Speed)
    .Build();

  public active(): void {
    tween(this.instance.UIGradient, this.tweenInfo, {
      Offset: this.attributes.GradientAnimation_OffsetGoal
    });
  }

  public inactive(): void {
    tween(this.instance.UIGradient, this.tweenInfo, {
      Offset: this.defaultOffset
    });
  }
}


import { OnStart } from "@flamework/core";
import { Component } from "@flamework/components";
import { TweenInfoBuilder } from "@rbxts/builders";
import { tween } from "@rbxts/instance-utility";
import { $nameof } from "rbxts-transform-debug";

import ButtonTweenAnimation from "client/base-components/button-tween-animation";

interface Attributes {
  readonly RotationAnimation_RotationGoal: number;
  readonly RotationAnimation_Speed: number;
}

@Component({
  tag: $nameof<RotationAnimation>(),
  defaults: {
    RotationAnimation_RotationGoal: 15,
    RotationAnimation_Speed: 0.35
  }
})
export class RotationAnimation extends ButtonTweenAnimation<Attributes> implements OnStart {
  private readonly defaultRotation = this.instance.Rotation;
  protected override readonly includeClick = false;

  protected readonly tweenInfo = new TweenInfoBuilder()
    .SetEasingStyle(Enum.EasingStyle.Quad)
    .SetTime(this.attributes.RotationAnimation_Speed)
    .Build();

  public active(): void {
    tween(this.instance, this.tweenInfo, {
      Rotation: this.attributes.RotationAnimation_RotationGoal
    });
  }

  public inactive(): void {
    tween(this.instance, this.tweenInfo, {
      Rotation: this.defaultRotation
    });
  }
}


import { OnStart } from "@flamework/core";
import { Component } from "@flamework/components";
import { TweenInfoBuilder } from "@rbxts/builders";
import { tween } from "@rbxts/instance-utility";
import { $nameof } from "rbxts-transform-debug";

import ButtonTweenAnimation from "client/base-components/button-tween-animation";

interface Attributes {
  readonly ScaleAnimation_ScaleIncrement: number;
  readonly ScaleAnimation_Speed: number;
}

@Component({
  tag: $nameof<ScaleAnimation>(),
  defaults: {
    ScaleAnimation_ScaleIncrement: 0.05,
    ScaleAnimation_Speed: 0.35
  }
})
export class ScaleAnimation extends ButtonTweenAnimation<Attributes> implements OnStart {
  private readonly scale = this.instance.FindFirstChildOfClass("UIScale") ?? new Instance("UIScale", this.instance);
  private readonly defaultScale = this.scale.Scale;
  private readonly scaleIncrement = this.attributes.ScaleAnimation_ScaleIncrement;

  protected readonly tweenInfo = new TweenInfoBuilder()
    .SetEasingStyle(Enum.EasingStyle.Sine)
    .SetTime(this.attributes.ScaleAnimation_Speed)
    .Build();

  protected inactive(): void {
    tween(this.scale, this.tweenInfo, {
      Scale: this.defaultScale
    });
  }

  protected active(): void {
    tween(this.scale, this.tweenInfo, {
      Scale: this.defaultScale + this.scaleIncrement
    });
  }
}


import { OnRender, OnStart } from "@flamework/core";
import { Component } from "@flamework/components";
import { $nameof } from "rbxts-transform-debug";

import { Spring } from "shared/classes/spring";

import BaseButtonAnimation from "client/base-components/base-button-animation";

interface Attributes {
  readonly SpringScaleAnimation_ScaleIncrement: number;
  readonly SpringScaleAnimation_Mass: number;
  readonly SpringScaleAnimation_Force: number;
  readonly SpringScaleAnimation_Damping: number;
  readonly SpringScaleAnimation_Speed: number;
}

@Component({
  tag: $nameof<SpringScaleAnimation>(),
  defaults: {
    SpringScaleAnimation_ScaleIncrement: 0.2,
    SpringScaleAnimation_Mass: 5,
    SpringScaleAnimation_Force: 100,
    SpringScaleAnimation_Damping: 3,
    SpringScaleAnimation_Speed: 7.5
  }
})
export class SpringScaleAnimation extends BaseButtonAnimation<Attributes> implements OnStart, OnRender {
  private readonly scale = this.instance.FindFirstChildOfClass("UIScale") ?? new Instance("UIScale", this.instance);
  private readonly defaultScale = this.scale.Scale;
  private readonly scaleIncrement = this.attributes.SpringScaleAnimation_ScaleIncrement;
  private readonly spring = new Spring(
    this.attributes.SpringScaleAnimation_Mass,
    this.attributes.SpringScaleAnimation_Force,
    this.attributes.SpringScaleAnimation_Damping,
    this.attributes.SpringScaleAnimation_Speed
  );

  protected active = undefined;
  protected inactive = undefined;

  public onRender(dt: number): void {
    if (this.hovered)
      this.spring.shove(new Vector3(this.scaleIncrement, 0, 0));

    const movement = this.spring.update(dt);
    this.scale.Scale = this.defaultScale + movement.X;
  }
}


import { OnStart } from "@flamework/core";
import { Component } from "@flamework/components";
import { TweenInfoBuilder } from "@rbxts/builders";
import { tween } from "@rbxts/instance-utility";
import { $nameof } from "rbxts-transform-debug";

import ButtonTweenAnimation from "client/base-components/button-tween-animation";

interface Attributes {
  readonly TransparencyAnimation_TransparencyGoal: number;
  readonly TransparencyAnimation_Speed: number;
}

@Component({
  tag: $nameof<TransparencyAnimation>(),
  defaults: {
    TransparencyAnimation_TransparencyGoal: 0.5,
    TransparencyAnimation_Speed: 0.35
  }
})
export class TransparencyAnimation extends ButtonTweenAnimation<Attributes> implements OnStart {
  private readonly defaultTransparency = this.instance.Transparency;
  protected override readonly includeClick = false;

  protected readonly tweenInfo = new TweenInfoBuilder()
    .SetEasingStyle(Enum.EasingStyle.Quad)
    .SetTime(this.attributes.TransparencyAnimation_Speed)
    .Build();

  public active(): void {
    tween(this.instance, this.tweenInfo, {
      BackgroundTransparency: this.attributes.TransparencyAnimation_TransparencyGoal
    });
  }

  public inactive(): void {
    tween(this.instance, this.tweenInfo, {
      BackgroundTransparency: this.defaultTransparency
    });
  }
}


import type { OnStart } from "@flamework/core";
import { Component } from "@flamework/components";
import { getChildrenOfType } from "@rbxts/instance-utility";
import { $nameof } from "rbxts-transform-debug";

import { PlayerGui } from "client/utility";

import DestroyableComponent from "shared/base-components/destroyable";

interface Attributes {
  PageRoute_Destination: string;
  PageRoute_Exclusive: boolean; // whether or not all other pages should be disabled when destination page is reached
}

@Component({
  tag: $nameof<PageRoute>(),
  ancestorWhitelist: [PlayerGui],
  defaults: {
    PageRoute_Exclusive: true
  }
})
export class PageRoute extends DestroyableComponent<Attributes, GuiButton> implements OnStart {
  public onStart(): void {
    this.janitor.Add(this.instance.MouseButton1Click.Connect(() => {
      const screen = this.instance.FindFirstAncestorOfClass("ScreenGui")!;
      const frames = getChildrenOfType(screen, "Frame");
      const destination = screen.WaitForChild<Frame>(this.attributes.PageRoute_Destination);
      if (this.attributes.PageRoute_Exclusive)
        for (const frame of frames) {
          if (frame === destination) continue;
          frame.Visible = false;
        }

      destination.Visible = true;
    }));
  }
}


import type { OnStart } from "@flamework/core";
import { Component } from "@flamework/components";
import { TweenInfoBuilder } from "@rbxts/builders";
import { getChildrenOfType, tween } from "@rbxts/instance-utility";
import { endsWith } from "@rbxts/string-utils";
import { $nameof } from "rbxts-transform-debug";

import { PlayerGui } from "client/utility";

import DestroyableComponent from "shared/base-components/destroyable";

interface Attributes {
  readonly SmoothListLayout_Speed: number;
}

@Component({
  tag: $nameof<SmoothListLayout>(),
  ancestorWhitelist: [PlayerGui],
  defaults: {
    SmoothListLayout_Speed: 0.2
  }
})
export class SmoothListLayout extends DestroyableComponent<Attributes, GuiObject & { UIListLayout: UIListLayout; }> implements OnStart {
  private readonly tweenInfo = new TweenInfoBuilder()
    .SetTime(this.attributes.SmoothListLayout_Speed)
    .Build();

  public onStart(): void {
    for (const child of getChildrenOfType(this.instance, "GuiObject"))
      this.janitor.Add(child.Destroying.Once(() => this.createTransition(child)));

    this.janitor.Add(this.instance.ChildAdded.Connect(child => {
      if (!child.IsA("GuiObject")) return;
      if (endsWith(child.Name, "_TransitionFrame")) return;
      this.janitor.Add(child.Destroying.Once(() => this.createTransition(child)));
    }));
  }

  private createTransition(child: GuiObject): void {
    const transitionFrame = new Instance("Frame");
    transitionFrame.Name = child.Name + "_TransitionFrame"; // preserve name sorting
    transitionFrame.BackgroundTransparency = 1;
    transitionFrame.Size = child.Size;
    transitionFrame.LayoutOrder = child.LayoutOrder;
    transitionFrame.Parent = this.instance;

    const goalSize = this.instance.UIListLayout.FillDirection === Enum.FillDirection.Vertical ?
      new UDim2(child.Size.X.Scale, child.Size.X.Offset, 0, 0)
      : new UDim2(0, 0, child.Size.Y.Scale, child.Size.Y.Offset);

    tween(transitionFrame, this.tweenInfo, { Size: goalSize })
      .Completed.Once(() => transitionFrame.Destroy());
  }
}


import type { OnStart } from "@flamework/core";
import { Component, BaseComponent } from "@flamework/components";
import { $git, $nameof } from "rbxts-transform-debug";

import type { LogStart } from "shared/hooks";
import { PlayerGui } from "client/utility";

@Component({
  tag: $nameof<VersionLabel>(),
  ancestorWhitelist: [PlayerGui]
})
export class VersionLabel extends BaseComponent<{}, TextLabel> implements OnStart, LogStart {
  public onStart(): void {
    const gitInfo = $git();
    this.instance.Text = `${gitInfo.LatestTag} (${gitInfo.Commit})`;
  }
}


import { Controller } from "@flamework/core";
import { Workspace as World, SoundService as Sound } from "@rbxts/services";

import { Events } from "client/network";
import { ReplicationOptions, SerializedReplicable } from "shared/classes/replicable";
import { Serializers } from "shared/network";
import { PlaySoundOptions } from "shared/structs/audio";
import type { AudioPacket } from "shared/structs/packets";

@Controller()
export class AudioController extends SerializedReplicable<AudioPacket> {
  public constructor() {
    super(Events.audio.played, Events.audio.replicate, Serializers.audio);
  }

  public play(sound: Sound, options?: PlaySoundOptions, replicationOptions?: ReplicationOptions): void {
    this.requestReplication({ sound, options }, replicationOptions);
  }

  public playNoReplication(sound: Sound, options?: PlaySoundOptions): void {
    this.replicate({ sound, options });
  }

  protected replicate({ sound: soundTemplate, options }: AudioPacket): void {
    const sound = soundTemplate.Clone();
    const toDestroy: Instance[] = [sound];
    if (options?.createContainerPart ?? false) {
      if (options?.parent === undefined) {
        const ignore = new Instance("Folder");
        ignore.Name = "Ignore";
        ignore.Parent = World;
      }

      const container = new Instance("Part", options?.parent ?? World.FindFirstChild("Ignore"));
      container.Transparency = 1;
      container.Anchored = true;
      container.CanCollide = false;
      container.Size = Vector3.one;
      container.Position = options?.position!;
      sound.Parent = container;
      toDestroy.push(container);
    } else
      sound.Parent = options?.parent ?? Sound;

    sound.Ended.Once(() => toDestroy.forEach(i => i.Destroy()));
    sound.Play();
  }
}