@phroun/paged-buffer
Version:
High-performance buffer system for editing massive files with intelligent memory management and undo/redo capabilities
986 lines • 42.8 kB
JavaScript
"use strict";
/**
* @fileoverview Line and Marks Manager - Page coordinate-based marks with CORRECTED logic
* @description Manages line positions and named marks using page coordinates for efficiency
* @author Jeffrey R. Day
* @version 2.1.1 - Fixed mark update logic for deletions and page operations
*/
const logger = require('./logger');
/**
* @typedef {Array<string|number>} MarkTuple
* @property {string} 0 - The name of the mark.
* @property {number} 1 - The absolute address of the mark.
*/
/**
* @typedef {Array<string|number>} RelativeMarkTuple
* @property {string} 0 - The name of the mark.
* @property {number} 1 - The relative address of the mark.
*/
/**
* Represents the result of line-related operations
*/
class LineOperationResult {
constructor(lineNumber, byteStart, byteEnd, marks = [], isExact = true) {
this.lineNumber = lineNumber; // 1-based line number
this.byteStart = byteStart; // Start byte address (exact or page boundary)
this.byteEnd = byteEnd; // End byte address (exact or page boundary)
this.length = byteEnd - byteStart; // Line length in bytes
this.marks = marks; // Marks within this line
this.isExact = isExact; // true = exact line bounds, false = page bounds
}
}
/**
* Represents extracted content with marks
*/
class ExtractedContent {
constructor(data, marks = []) {
this.data = data; // Buffer containing the extracted data
this.marks = marks; // Array of {name, relativeOffset} for marks in the content
}
}
/**
* Page coordinate-based marks and line manager
*/
class LineAndMarksManager {
constructor(virtualPageManager) {
this.vpm = virtualPageManager;
// Page coordinate marks storage (global only)
this.globalMarks = new Map(); // markName -> [pageId, offset]
this.pageToMarks = new Map(); // pageId -> Set<markName> (for performance)
}
// =================== INTERNAL COORDINATE METHODS ===================
/**
* Convert virtual address to page coordinates
* @param {number} virtualAddress - Virtual buffer address
* @returns {Array} - [pageId, offset]
* @private
*/
_virtualToPageCoord(virtualAddress) {
// Handle address at the very end of buffer
const totalSize = this.vpm.getTotalSize();
if (virtualAddress === totalSize && totalSize > 0) {
const allPages = this.vpm.addressIndex.getAllPages();
if (allPages.length > 0) {
const lastPage = allPages[allPages.length - 1];
return [lastPage.pageId, lastPage.virtualSize];
}
}
const descriptor = this.vpm.addressIndex.findPageAt(virtualAddress);
if (!descriptor) {
throw new Error(`No page found for virtual address ${virtualAddress}`);
}
const offset = virtualAddress - descriptor.virtualStart;
return [descriptor.pageId, offset];
}
/**
* Convert page coordinates to virtual address
* @param {string} pageId - Page identifier
* @param {number} offset - Offset within page
* @returns {number} - Virtual buffer address
* @private
*/
_pageCoordToVirtual(pageId, offset) {
const descriptor = this.vpm.addressIndex.pages.find(p => p.pageId === pageId);
if (!descriptor) {
throw new Error(`Page ${pageId} not found`);
}
return descriptor.virtualStart + offset;
}
/**
* Set mark using page coordinates
* @param {string} markName - Name of the mark
* @param {string} pageId - Page identifier
* @param {number} offset - Offset within page
* @private
*/
_setMarkByCoord(markName, pageId, offset) {
// Remove from old page index if exists
const oldCoord = this.globalMarks.get(markName);
if (oldCoord) {
this._removeFromPageIndex(markName, oldCoord[0]);
}
// Set new coordinates (using array for performance)
this.globalMarks.set(markName, [pageId, offset]);
// Update page index
if (!this.pageToMarks.has(pageId)) {
this.pageToMarks.set(pageId, new Set());
}
this.pageToMarks.get(pageId).add(markName);
}
/**
* Remove mark from page index
* @param {string} markName - Name of the mark
* @param {string} pageId - Page identifier
* @private
*/
_removeFromPageIndex(markName, pageId) {
const markSet = this.pageToMarks.get(pageId);
if (markSet) {
markSet.delete(markName);
if (markSet.size === 0) {
this.pageToMarks.delete(pageId);
}
}
}
/**
* Get all mark names in a specific page
* @param {string} pageId - Page identifier
* @returns {Set<string>} - Set of mark names
* @private
*/
_getMarkNamesInPage(pageId) {
return this.pageToMarks.get(pageId) || new Set();
}
/**
* Helper method to update mark coordinate and page index
* @param {string} markName - Name of the mark
* @param {Array} coord - Coordinate array to update in place [pageId, offset]
* @param {Array} newCoord - New coordinate [pageId, offset]
* @private
*/
_updateMarkCoordinate(markName, coord, newCoord) {
const [oldPageId] = coord;
const [newPageId, newOffset] = newCoord;
// Update the coordinate in place
coord[0] = newPageId;
coord[1] = newOffset;
// Update page index if page changed
if (oldPageId !== newPageId) {
// Remove from old page index
this._removeFromPageIndex(markName, oldPageId);
// Add to new page index
if (!this.pageToMarks.has(newPageId)) {
this.pageToMarks.set(newPageId, new Set());
}
this.pageToMarks.get(newPageId).add(markName);
}
}
// =================== PAGE STRUCTURE UPDATE OPERATIONS (Page Coordinate Based) ===================
// These handle page splits/merges - only called by VPM for structural changes
/**
* Handle page split - transfer marks to appropriate pages
* @param {string} originalPageId - Original page being split
* @param {string} newPageId - New page created from split
* @param {number} splitOffset - Offset within original page where split occurred
*/
handlePageSplit(originalPageId, newPageId, splitOffset) {
const markNames = this.pageToMarks.get(originalPageId);
if (!markNames)
return;
const marksToMove = [];
// Find marks that need to move to the new page
for (const markName of markNames) {
const coord = this.globalMarks.get(markName);
const [_pageId, offset] = coord;
if (offset >= splitOffset) {
marksToMove.push(markName);
}
}
// Move marks to new page
for (const markName of marksToMove) {
const coord = this.globalMarks.get(markName);
coord[0] = newPageId; // Update pageId
coord[1] -= splitOffset; // Adjust offset
// Update page index
this.pageToMarks.get(originalPageId).delete(markName);
if (!this.pageToMarks.has(newPageId)) {
this.pageToMarks.set(newPageId, new Set());
}
this.pageToMarks.get(newPageId).add(markName);
}
}
/**
* Handle page merge - transfer marks from absorbed page
* @param {string} absorbedPageId - Page being absorbed/deleted
* @param {string} targetPageId - Page absorbing the content
* @param {number} insertOffset - Offset in target page where absorbed content starts
*/
handlePageMerge(absorbedPageId, targetPageId, insertOffset) {
const markNames = this.pageToMarks.get(absorbedPageId);
if (!markNames)
return;
// Move all marks from absorbed page to target page
for (const markName of markNames) {
const coord = this.globalMarks.get(markName);
coord[0] = targetPageId; // Update pageId
coord[1] = insertOffset + coord[1]; // Adjust offset
// Update page index
if (!this.pageToMarks.has(targetPageId)) {
this.pageToMarks.set(targetPageId, new Set());
}
this.pageToMarks.get(targetPageId).add(markName);
}
// Remove absorbed page from index
this.pageToMarks.delete(absorbedPageId);
}
/**
* Validate and clean up orphaned marks
* @returns {Array<string>} - Names of orphaned marks that were removed
*/
validateAndCleanupMarks() {
const orphanedMarks = [];
for (const [markName, coord] of this.globalMarks) {
const [pageId, offset] = coord;
// Check if page still exists
const descriptor = this.vpm.addressIndex.pages.find(p => p.pageId === pageId);
if (!descriptor) {
orphanedMarks.push(markName);
continue;
}
// Check if offset is within page bounds
if (offset > descriptor.virtualSize) {
// Try to move mark to next page
const nextPage = this._findNextPage(descriptor);
if (nextPage) {
coord[0] = nextPage.pageId;
coord[1] = 0; // Move to start of next page
// Update page index
this._removeFromPageIndex(markName, pageId);
if (!this.pageToMarks.has(nextPage.pageId)) {
this.pageToMarks.set(nextPage.pageId, new Set());
}
this.pageToMarks.get(nextPage.pageId).add(markName);
}
else {
// No next page - clamp to end of current page
coord[1] = Math.max(0, descriptor.virtualSize - 1);
}
}
}
// Remove orphaned marks
for (const markName of orphanedMarks) {
this.removeMark(markName);
}
return orphanedMarks;
}
/**
* Find the next page after the given page
* @param {PageDescriptor} currentPage - Current page descriptor
* @returns {PageDescriptor|null} - Next page or null
* @private
*/
_findNextPage(currentPage) {
const pages = this.vpm.addressIndex.getAllPages();
const currentIndex = pages.findIndex(p => p.pageId === currentPage.pageId);
return currentIndex >= 0 && currentIndex < pages.length - 1 ? pages[currentIndex + 1] : null;
}
// =================== PUBLIC MARKS API ===================
/**
* Set a named mark at a virtual address
* @param {string} markName - Name of the mark
* @param {number} virtualAddress - Virtual buffer address
*/
setMark(markName, virtualAddress) {
const totalSize = this.vpm.getTotalSize();
if (virtualAddress < 0 || virtualAddress > totalSize) {
throw new Error(`Mark address ${virtualAddress} is out of range`);
}
// Handle the special case of marking at the very end of the buffer
if (virtualAddress === totalSize) {
// Find the last page or create one if empty
const allPages = this.vpm.addressIndex.getAllPages();
if (allPages.length > 0) {
const lastPage = allPages[allPages.length - 1];
this._setMarkByCoord(markName, lastPage.pageId, lastPage.virtualSize);
return;
}
}
const coord = this._virtualToPageCoord(virtualAddress);
this._setMarkByCoord(markName, coord[0], coord[1]);
}
/**
* Get the virtual address of a named mark
* @param {string} markName - Name of the mark
* @returns {number|null} - Virtual address or null if not found
*/
getMark(markName) {
const coord = this.globalMarks.get(markName);
if (!coord)
return null;
try {
return this._pageCoordToVirtual(coord[0], coord[1]);
}
catch (error) {
// Page might have been deleted - mark is orphaned
return null;
}
}
/**
* Remove a named mark
* @param {string} markName - Name of the mark
* @returns {boolean} - True if mark was found and removed
*/
removeMark(markName) {
const coord = this.globalMarks.get(markName);
if (!coord)
return false;
// Remove from page index
this._removeFromPageIndex(markName, coord[0]);
// Remove from global registry
this.globalMarks.delete(markName);
return true;
}
/**
* Get all marks between two virtual addresses
* @param {number} startAddress - Start address (inclusive)
* @param {number} endAddress - End address (inclusive)
* @returns {Array<MarkTuple>} - Name and absolute address of each mark
*/
getMarksInRange(startAddress, endAddress) {
const result = [];
for (const [markName, coord] of this.globalMarks) {
try {
const virtualAddress = this._pageCoordToVirtual(coord[0], coord[1]);
if (virtualAddress >= startAddress && virtualAddress <= endAddress) {
result.push([markName, virtualAddress]);
}
}
catch (error) {
// Skip orphaned marks
continue;
}
}
return result.sort((a, b) => a[1] - b[1]);
}
/**
* Get all marks in the buffer
* @returns {Array<MarkTuple>} - Name and absolute address of all marks
*/
getAllMarks() {
const result = [];
for (const [markName, coord] of this.globalMarks) {
try {
const virtualAddress = this._pageCoordToVirtual(coord[0], coord[1]);
result.push([markName, virtualAddress]);
}
catch (error) {
// Skip orphaned marks
continue;
}
}
return result.sort((a, b) => a[1] - b[1]);
}
/**
* Get information about marks in content that will be deleted
* This reports what marks were in the deleted content (for paste operations)
* but does NOT remove the marks - they get consolidated to deletion start
* @param {number} startAddress - Start address
* @param {number} endAddress - End address
* @returns {Array<RelativeMarkTuple>} - Relative mark info for deleted content
*/
getMarksInDeletedContent(startAddress, endAddress) {
const marksInfo = [];
for (const [markName, coord] of this.globalMarks) {
try {
const virtualAddress = this._pageCoordToVirtual(coord[0], coord[1]);
if (virtualAddress >= startAddress && virtualAddress < endAddress) {
marksInfo.push([markName, virtualAddress - startAddress]);
}
}
catch (error) {
// Skip orphaned marks
continue;
}
}
return marksInfo.sort((a, b) => a[1] - b[1]);
}
/**
* Remove marks from a range entirely (for true extraction/cut operations)
* This actually removes marks from the buffer - used when marks should disappear
* @param {number} startAddress - Start address
* @param {number} endAddress - End address
* @returns {Array<RelativeMarkTuple>} - Relative info for removed marks
*/
removeMarksFromRange(startAddress, endAddress) {
const removed = [];
const marksToRemove = [];
for (const [markName, coord] of this.globalMarks) {
try {
const virtualAddress = this._pageCoordToVirtual(coord[0], coord[1]);
if (virtualAddress >= startAddress && virtualAddress < endAddress) {
removed.push([markName, virtualAddress - startAddress]);
marksToRemove.push(markName);
}
}
catch (error) {
// Mark is orphaned - remove it
marksToRemove.push(markName);
}
}
// Actually remove the marks
for (const markName of marksToRemove) {
this.removeMark(markName);
}
return removed.sort((a, b) => a[1] - b[1]);
}
/**
* Insert marks from relative positions (for insert operations)
* @param {number} insertAddress - Address where content was inserted
* @param {Array<RelativeMarkTuple>} marks - Relative marks to insert
*/
insertMarksFromRelative(insertAddress, marks) {
for (const markData of marks) {
const virtualAddress = insertAddress + markData[1];
this.setMark(markData[0], virtualAddress);
}
}
// =================== PERSISTENCE API ===================
/**
* Get all marks as a key-value object with virtual addresses (for persistence)
* @returns {Object} - Object mapping mark names to virtual addresses
*/
getAllMarksForPersistence() {
const result = {};
for (const [markName, coord] of this.globalMarks) {
try {
const virtualAddress = this._pageCoordToVirtual(coord[0], coord[1]);
result[markName] = virtualAddress;
}
catch (error) {
// Skip orphaned marks
continue;
}
}
return result;
}
/**
* Set marks from a key-value object (for persistence)
* Updates/overwrites conflicting marks, retains others
* @param {Object} marksObject - Object mapping mark names to virtual addresses
*/
setMarksFromPersistence(marksObject) {
for (const [markName, virtualAddress] of Object.entries(marksObject)) {
if (typeof virtualAddress === 'number' && virtualAddress >= 0) {
try {
this.setMark(markName, virtualAddress);
}
catch (error) {
// Skip invalid addresses
continue;
}
}
}
}
/**
* Clear all marks
*/
clearAllMarks() {
this.globalMarks.clear();
this.pageToMarks.clear();
}
// =================== ENHANCED OPERATIONS WITH MARKS ===================
/**
* Enhanced getBytes that includes marks in the result
* @param {number} start - Start address
* @param {number} end - End address
* @param {boolean} includeMarks - Whether to include marks in result
* @returns {Promise<Buffer|ExtractedContent>} - Data or data with marks
*/
async getBytesWithMarks(start, end, includeMarks = false) {
const data = await this.vpm.readRange(start, end);
if (!includeMarks) {
return data;
}
const marks = this.getMarksInRange(start, end - 1);
const relativeMarks = marks.map(mark => ({
name: mark.name,
relativeOffset: mark.address - start
}));
return new ExtractedContent(data, relativeMarks);
}
/**
* CORRECTED: Enhanced insertBytes - handles marks correctly with page operations
* @param {number} position - Insert position
* @param {Buffer} data - Data to insert
* @param {Array<RelativeMarkTuple>} marks -Relative marks to insert
*/
async insertBytesWithMarks(position, data, marks = []) {
logger.debug(`[DEBUG] insertBytesWithMarks: position=${position}, dataLen=${data.length}`);
logger.debug('[DEBUG] Marks before operation:', this.getAllMarks());
// STEP 1: Capture marks that need to be shifted (AFTER insertion point, not AT)
const marksToShift = [];
for (const [markName, coord] of this.globalMarks) {
try {
const markVirtualPos = this._pageCoordToVirtual(coord[0], coord[1]);
if (markVirtualPos > position) { // FIXED: Only marks AFTER insertion point get shifted
marksToShift.push({ name: markName, originalPos: markVirtualPos });
}
}
catch (error) {
// Skip invalid marks
continue;
}
}
logger.debug('[DEBUG] Marks to shift:', marksToShift);
// STEP 2: Let VPM handle the insertion first (including any page splits)
await this.vpm.insertAt(position, data);
logger.debug('[DEBUG] Marks after VPM insertAt:', this.getAllMarks());
// STEP 3: Now update the captured marks to their new positions
for (const markInfo of marksToShift) {
const newPos = markInfo.originalPos + data.length;
try {
this.setMark(markInfo.name, newPos);
}
catch (error) {
logger.warn(`Failed to update mark ${markInfo.name} to position ${newPos}: ${error.message}`);
}
}
logger.debug('[DEBUG] Marks after shifting:', this.getAllMarks());
// STEP 4: Insert new marks
if (marks.length > 0) {
this.insertMarksFromRelative(position, marks);
logger.debug('[DEBUG] Marks after inserting new marks:', this.getAllMarks());
}
}
/**
* CORRECTED: Enhanced deleteBytes - reports marks in deleted content but consolidates them
* @param {number} start - Start address
* @param {number} end - End address
* @param {boolean} reportMarks - Whether to report marks that were in deleted content
* @returns {Promise<ExtractedContent>} - Deleted data with optional marks report
*/
async deleteBytesWithMarks(start, end, reportMarks = false) {
logger.debug(`[DEBUG] deleteBytesWithMarks: start=${start}, end=${end}, reportMarks=${reportMarks}`);
logger.debug('[DEBUG] Marks before operation:', this.getAllMarks());
// STEP 1: If requested, get info about marks in the deleted content (for paste operations)
let marksInDeletedContent = [];
if (reportMarks) {
marksInDeletedContent = this.getMarksInDeletedContent(start, end);
logger.debug('[DEBUG] Marks in deleted content (for reporting):', marksInDeletedContent);
}
// STEP 2: Update marks for the content change (this will move marks in deletion range to deletion start)
this.updateMarksAfterModification(start, end - start, 0);
logger.debug('[DEBUG] Marks after consolidating to deletion start:', this.getAllMarks());
// STEP 3: Let VPM handle the actual deletion (VPM will handle any page structure changes)
const deletedData = await this.vpm.deleteRange(start, end);
logger.debug('[DEBUG] Marks after VPM deleteRange:', this.getAllMarks());
// Return deleted data with marks report (if requested)
return new ExtractedContent(deletedData, marksInDeletedContent);
}
/**
* Enhanced overwriteBytes with marks support
* @param {number} position - Overwrite position
* @param {Buffer} data - New data
* @param {Array<RelativeMarkTuple>} marks - Relative marks to insert
* @returns {Promise<ExtractedContent>} - Overwritten data with marks info
*/
async overwriteBytesWithMarks(position, data, marks = []) {
const endPosition = Math.min(position + data.length, this.vpm.getTotalSize());
const originalSize = endPosition - position;
const netSizeChange = data.length - originalSize;
logger.debug(`[DEBUG] overwriteBytesWithMarks: position=${position}, dataLen=${data.length}, originalSize=${originalSize}, netChange=${netSizeChange}`);
logger.debug('[DEBUG] Marks before operation:', this.getAllMarks());
// Get overwritten data before modification
const overwrittenData = await this.vpm.readRange(position, endPosition);
// Handle marks based on the type of overwrite
let marksInOverwrittenContent = [];
if (data.length < originalSize) {
// Content is shrinking - report marks that will be in the removed portion
marksInOverwrittenContent = this.getMarksInDeletedContent(position + data.length, endPosition);
}
// Capture marks that need to be shifted (after the overwrite region)
const marksToShift = [];
if (netSizeChange !== 0) {
for (const [markName, coord] of this.globalMarks) {
try {
const markVirtualPos = this._pageCoordToVirtual(coord[0], coord[1]);
if (markVirtualPos >= endPosition) {
marksToShift.push({ name: markName, originalPos: markVirtualPos });
}
}
catch (error) {
// Skip invalid marks
continue;
}
}
}
// If content is shrinking, consolidate marks in the removed portion
if (data.length < originalSize) {
this.updateMarksAfterModification(position + data.length, originalSize - data.length, 0);
}
logger.debug('[DEBUG] Marks after consolidation, before VPM:', this.getAllMarks());
// Let VPM handle the actual overwrite (VPM will handle any page structure changes)
await this.vpm.deleteRange(position, endPosition);
await this.vpm.insertAt(position, data);
logger.debug('[DEBUG] Marks after VPM operations:', this.getAllMarks());
// Update marks that were after the overwrite region
for (const markInfo of marksToShift) {
const newPos = markInfo.originalPos + netSizeChange;
try {
this.setMark(markInfo.name, newPos);
}
catch (error) {
logger.warn(`Failed to update mark ${markInfo.name} to position ${newPos}: ${error.message}`);
}
}
// Insert new marks
if (marks.length > 0) {
this.insertMarksFromRelative(position, marks);
logger.debug('[DEBUG] Marks after inserting new marks:', this.getAllMarks());
}
return new ExtractedContent(overwrittenData, marksInOverwrittenContent);
}
/**
* CORRECTED: Update marks after a modification using virtual addresses
* This method handles logical mark movement for content changes
* @param {number} virtualStart - Start of modification
* @param {number} deletedBytes - Bytes deleted
* @param {number} insertedBytes - Bytes inserted
*/
updateMarksAfterModification(virtualStart, deletedBytes, insertedBytes) {
const virtualEnd = virtualStart + deletedBytes;
const netChange = insertedBytes - deletedBytes;
logger.debug(`[DEBUG] updateMarksAfterModification: start=${virtualStart}, deleted=${deletedBytes}, inserted=${insertedBytes}, netChange=${netChange}`);
// Create a list of marks to update (avoid modifying map during iteration)
const marksToUpdate = [];
for (const [markName, coord] of this.globalMarks) {
try {
const markVirtualPos = this._pageCoordToVirtual(coord[0], coord[1]);
marksToUpdate.push({ name: markName, virtualPos: markVirtualPos, coord }); // bespoke three part object for this task
logger.debug(`[DEBUG] Mark ${markName} at position ${markVirtualPos}`);
}
catch (error) {
// Mark coordinate is invalid - skip for now, don't remove yet
logger.warn(`Mark ${markName} has invalid coordinates, skipping update`);
continue;
}
}
// Update marks based on their position relative to the modification
for (const mark of marksToUpdate) {
const { name, virtualPos, coord } = mark;
if (virtualPos < virtualStart) {
// Mark before modification - no change
logger.debug(`[DEBUG] Mark ${name}: before modification, no change`);
continue;
}
else if (virtualPos === virtualStart) {
// CORRECTED: Mark exactly at modification start
if (deletedBytes === 0) {
// Pure insertion at this point - mark stays at insertion point
logger.debug(`[DEBUG] Mark ${name}: at insertion point, stays put`);
continue;
}
else {
// Deletion starting at this point - mark stays at deletion start
logger.debug(`[DEBUG] Mark ${name}: at deletion start, stays put`);
continue;
}
}
else if (deletedBytes > 0 && virtualPos > virtualStart && virtualPos < virtualEnd) {
// CORRECTED: Mark within deleted region - move to deletion start (don't remove!)
logger.debug(`[DEBUG] Mark ${name}: within deletion range [${virtualStart}, ${virtualEnd}), moving to deletion start`);
try {
const newCoord = this._virtualToPageCoord(virtualStart);
this._updateMarkCoordinate(name, coord, newCoord);
}
catch (error) {
logger.warn(`Failed to move mark ${name} to deletion start: ${error.message}`);
// Don't remove the mark, leave it where it is
}
}
else if (virtualPos >= virtualEnd) {
// CORRECTED: Mark after deletion end - shift by net change
logger.debug(`[DEBUG] Mark ${name}: after modification, shifting by ${netChange} (${virtualPos} + ${netChange} = ${virtualPos + netChange})`);
try {
const newVirtualPos = virtualPos + netChange;
const newCoord = this._virtualToPageCoord(newVirtualPos);
this._updateMarkCoordinate(name, coord, newCoord);
}
catch (error) {
logger.warn(`Failed to shift mark ${name}: ${error.message}`);
// Don't remove the mark, leave it where it is
}
}
else {
logger.debug(`[DEBUG] Mark ${name}: no condition matched - virtualPos=${virtualPos}, virtualStart=${virtualStart}, virtualEnd=${virtualEnd}, deletedBytes=${deletedBytes}`);
}
}
// Invalidate line caches after mark updates
this.invalidateLineCaches();
}
// =================== LINE TRACKING (UNCHANGED) ===================
/**
* Invalidate line caches (called when buffer content changes)
*/
invalidateLineCaches() {
this.invalidatePageLineCaches();
}
/**
* Invalidate line caches in pages (called when buffer content changes)
*/
invalidatePageLineCaches() {
// Mark all page line caches as invalid in the VPM
for (const descriptor of this.vpm.addressIndex.getAllPages()) {
descriptor.lineInfoCached = false;
}
}
/**
* Ensure page containing address is loaded (ASYNC)
* @param {number} address - Byte address to load
* @returns {Promise<boolean>} - True if page was loaded successfully
*/
async seekAddress(address) {
if (address < 0 || address > this.vpm.getTotalSize()) {
return false;
}
const descriptor = this.vpm.addressIndex.findPageAt(address);
if (!descriptor) {
return false;
}
try {
await this.vpm._ensurePageLoaded(descriptor);
return true;
}
catch (error) {
return false;
}
}
/**
* Get the total number of lines in the buffer (SYNCHRONOUS)
* @returns {number} - Total line count
*/
getTotalLineCount() {
const totalSize = this.vpm.getTotalSize();
if (totalSize === 0) {
return 1; // Empty content has 1 line
}
let lineCount = 1; // Start with first line
for (const descriptor of this.vpm.addressIndex.getAllPages()) {
if (descriptor.virtualSize === 0) {
continue; // Skip empty pages
}
// Use cached newline count if available
if (descriptor.lineInfoCached) {
lineCount += descriptor.newlineCount;
}
else if (this.vpm && this.vpm.pageCache.has(descriptor.pageId)) {
// Page is loaded - count newlines and cache the result
const pageInfo = this.vpm.pageCache.get(descriptor.pageId);
pageInfo.ensureLineCacheValid();
descriptor.cacheLineInfo(pageInfo);
lineCount += descriptor.newlineCount;
}
else {
// Page not loaded and no cached count - we can't know exactly
// This is a limitation of keeping it synchronous
// For now, assume worst case of 0 newlines in unloaded pages
// (Total will be underestimated but won't crash)
}
}
return lineCount;
}
/**
* Get line information by line number (SYNCHRONOUS)
* @param {number} lineNumber - Line number (1-based)
* @returns {LineOperationResult|null} - Line info or null if not found
*/
getLineInfo(lineNumber) {
if (lineNumber < 1) {
return null;
}
const totalSize = this.vpm.getTotalSize();
if (totalSize === 0) {
return lineNumber === 1 ?
new LineOperationResult(1, 0, 0, [], true) : null;
}
let currentLine = 1;
for (const descriptor of this.vpm.addressIndex.getAllPages()) {
if (descriptor.virtualSize === 0) {
continue;
}
const pageEndAddress = descriptor.virtualStart + descriptor.virtualSize;
// Count lines in this page
let pageLinesCount = 0;
let exactPositions = null;
if (this.vpm.pageCache.has(descriptor.pageId)) {
// Page is loaded - get exact line positions
const pageInfo = this.vpm.pageCache.get(descriptor.pageId);
pageInfo.ensureLineCacheValid();
descriptor.cacheLineInfo(pageInfo);
pageLinesCount = descriptor.newlineCount;
exactPositions = pageInfo.newlinePositions;
}
else if (descriptor.lineInfoCached) {
// Use cached count
pageLinesCount = descriptor.newlineCount;
}
// Check if target line is in this page
if (lineNumber >= currentLine && lineNumber < currentLine + pageLinesCount + 1) {
if (exactPositions && lineNumber < currentLine + pageLinesCount) {
// Target line ends with a newline in this page - exact position
const lineIndex = lineNumber - currentLine;
const lineStart = lineIndex === 0 ? descriptor.virtualStart :
descriptor.virtualStart + exactPositions[lineIndex - 1] + 1;
const lineEnd = descriptor.virtualStart + exactPositions[lineIndex] + 1;
const marks = this.getMarksInRange(lineStart, lineEnd - 1);
return new LineOperationResult(lineNumber, lineStart, lineEnd, marks, true);
}
else if (exactPositions) {
// Target line is the last line in this page (no trailing newline)
const lastNewlinePos = exactPositions.length > 0 ?
descriptor.virtualStart + exactPositions[exactPositions.length - 1] + 1 :
descriptor.virtualStart;
const lineStart = exactPositions.length > 0 ? lastNewlinePos : descriptor.virtualStart;
const lineEnd = pageEndAddress;
const marks = this.getMarksInRange(lineStart, lineEnd - 1);
return new LineOperationResult(lineNumber, lineStart, lineEnd, marks, true);
}
else {
// Page not loaded - return page boundaries as approximation
const marks = this.getMarksInRange(descriptor.virtualStart, pageEndAddress - 1);
return new LineOperationResult(lineNumber, descriptor.virtualStart, pageEndAddress, marks, false);
}
}
currentLine += pageLinesCount;
}
// Line not found or beyond end
return null;
}
/**
* Get information about multiple lines at once (SYNCHRONOUS)
* @param {number} startLine - Start line number (1-based, inclusive)
* @param {number} endLine - End line number (1-based, inclusive)
* @returns {LineOperationResult[]} - Array of line info
*/
getMultipleLines(startLine, endLine) {
const result = [];
const clampedStart = Math.max(1, startLine);
const clampedEnd = Math.max(clampedStart, endLine);
for (let lineNum = clampedStart; lineNum <= clampedEnd; lineNum++) {
const lineInfo = this.getLineInfo(lineNum);
if (lineInfo) {
result.push(lineInfo);
}
else {
break; // No more lines
}
}
return result;
}
/**
* Convert virtual byte address to line number (SYNCHRONOUS)
* @param {number} virtualAddress - Virtual buffer address
* @returns {number} - Line number (1-based) or 0 if invalid address
*/
getLineNumberFromAddress(virtualAddress) {
if (virtualAddress < 0) {
return 0;
}
const totalSize = this.vpm.getTotalSize();
if (virtualAddress > totalSize) {
return 0;
}
if (totalSize === 0) {
return 1; // Empty buffer has line 1
}
let currentLine = 1;
for (const descriptor of this.vpm.addressIndex.getAllPages()) {
if (descriptor.virtualSize === 0) {
continue;
}
// Check if address is in this page
if (virtualAddress >= descriptor.virtualStart && virtualAddress < descriptor.virtualEnd) {
if (this.vpm.pageCache.has(descriptor.pageId)) {
// Page is loaded - get exact line
const pageInfo = this.vpm.pageCache.get(descriptor.pageId);
pageInfo.ensureLineCacheValid();
descriptor.cacheLineInfo(pageInfo);
const relativeAddress = virtualAddress - descriptor.virtualStart;
let linesInPage = 0;
for (const nlPos of pageInfo.newlinePositions) {
if (relativeAddress <= nlPos) {
break;
}
linesInPage++;
}
return currentLine + linesInPage;
}
else {
// Page not loaded - return start of page's line range
return currentLine;
}
}
// Count lines in this page and continue
if (this.vpm.pageCache.has(descriptor.pageId)) {
const pageInfo = this.vpm.pageCache.get(descriptor.pageId);
pageInfo.ensureLineCacheValid();
descriptor.cacheLineInfo(pageInfo);
currentLine += descriptor.newlineCount;
}
else if (descriptor.lineInfoCached) {
currentLine += descriptor.newlineCount;
}
}
return currentLine; // Address is at end of buffer
}
/**
* Convert line/character position to absolute byte position (SYNCHRONOUS)
* @param {Object} pos - {line, character} (both 1-based, character = byte offset in line)
* @returns {number} - Absolute byte position
*/
lineCharToBytePosition(pos) {
const lineInfo = this.getLineInfo(pos.line);
if (!lineInfo) {
return this.vpm.getTotalSize();
}
const character = pos.character - 1; // Convert to 0-based
if (character <= 0) {
return lineInfo.byteStart;
}
// Simple byte arithmetic - character position is byte offset within line
const targetByte = lineInfo.byteStart + character;
// Clamp to line boundaries
return Math.min(targetByte, lineInfo.byteEnd - 1);
}
/**
* Convert absolute byte position to line/character position (SYNCHRONOUS)
* @param {number} bytePos - Absolute byte position
* @returns {Object} - {line, character} (both 1-based, character = byte offset in line + 1)
*/
byteToLineCharPosition(bytePos) {
const lineNumber = this.getLineNumberFromAddress(bytePos);
if (lineNumber === 0) {
return { line: 1, character: 1 };
}
const lineInfo = this.getLineInfo(lineNumber);
if (!lineInfo) {
return { line: lineNumber, character: 1 };
}
const byteOffsetInLine = bytePos - lineInfo.byteStart;
// Simple byte arithmetic - character position is byte offset + 1 (for 1-based indexing)
return { line: lineNumber, character: byteOffsetInLine + 1 };
}
/**
* Get memory usage statistics
* @returns {Object} - Memory usage info
*/
getMemoryStats() {
// Calculate marks memory more accurately
let marksMemory = 0;
for (const [markName, coord] of this.globalMarks) {
marksMemory += markName.length * 2; // String storage (UTF-16)
marksMemory += 16; // Array overhead
marksMemory += coord[0].length * 2; // pageId string
marksMemory += 8; // offset number
}
// Add page index memory
for (const markSet of this.pageToMarks.values()) {
marksMemory += markSet.size * 16; // Set overhead per mark
}
return {
globalMarksCount: this.globalMarks.size,
pageIndexSize: this.pageToMarks.size,
totalLines: -1, // No longer cached globally
lineStartsCacheSize: 0, // No global cache
lineStartsCacheValid: true, // Always valid since no cache
estimatedMarksMemory: marksMemory,
estimatedLinesCacheMemory: 0 // No global cache
};
}
}
module.exports = {
LineAndMarksManager,
LineOperationResult,
ExtractedContent
};
//# sourceMappingURL=line-marks-manager.js.map