/* * Copyright 2025 the original author or authors. *
* Licensed under the Moderne Source Available License (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at *
* https://docs.moderne.io/licensing/moderne-source-available-license *
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {Comment, J, TextComment} from "../java";
import {create as produce} from "mutative";
/**
* Gets the effective last whitespace from a Space.
* When there are comments, the last whitespace is the suffix of the last comment.
* When there are no comments, it's the whitespace property.
*/
export function lastWhitespace(space: J.Space): string {
if (space.comments.length > 0) {
return space.comments[space.comments.length - 1].suffix;
}
return space.whitespace;
}
/**
* Replaces the effective last whitespace in a Space using a transform function.
* When there are comments, updates the suffix of the last comment.
* When there are no comments, updates the whitespace property.
*
* @param space The Space to modify (Mutative draft)
* @param transform Function that receives the current last whitespace and returns the new value
*/
export function replaceLastWhitespace(space: J.Space, transform: (ws: string) => string): J.Space {
return produce(space, draft => {
if (draft.comments.length > 0) {
const lastComment = draft.comments[draft.comments.length - 1];
lastComment.suffix = transform(lastComment.suffix);
} else {
draft.whitespace = transform(draft.whitespace);
}
});
}
/**
* Strips leading spaces and tabs from a string (but not newlines).
*/
export function stripLeadingIndent(s: string): string {
return s.replace(/^[ \t]*/, '');
}
/**
* Replaces the indentation after the last newline in a whitespace string.
* If there's no newline, returns just the new indent.
*/
export function replaceIndentAfterLastNewline(ws: string, newIndent: string): string {
const lastNewline = ws.lastIndexOf("\n");
return ws.substring(0, lastNewline + 1) + newIndent;
}
/**
* Checks if a Space contains any newlines (in whitespace or comment suffixes).
*/
export function spaceContainsNewline(space: J.Space | undefined): boolean {
if (!space) return false;
if (space.whitespace.includes("\n")) return true;
return space.comments.some(c => c.suffix.includes("\n"));
}
const isTextComment = (comment: Comment): comment is TextComment => comment.kind === J.Kind.TextComment;
/**
* Re-indents a line's leading whitespace when the enclosing construct moves from `oldMargin` to
* `newMargin`, preserving whatever indentation the line adds beyond the margin.
*/
function shiftIndent(indent: string, oldMargin: string, newMargin: string): string {
if (indent.startsWith(oldMargin)) {
return newMargin + indent.substring(oldMargin.length);
}
// The shift below counts characters, which stand in for columns only while all three indents
// use a single whitespace character; without a tab width, a line mixing them stays put.
if (new Set(indent + oldMargin + newMargin).size > 1) {
return indent;
}
const delta = newMargin.length - oldMargin.length;
return delta < 0 ? indent.substring(Math.min(-delta, indent.length)) : newMargin.substring(0, delta) + indent;
}
function reindentComment(comment: TextComment, oldMargin: string, newMargin: string): TextComment {
// A blank line has no content to align, and indenting it would only add trailing whitespace.
const text = comment.text.replace(/\n([ \t]*)/g, (match, indent: string, offset: number, full: string) =>
"\n\r".includes(full[offset + match.length]) ? match : "\n" + shiftIndent(indent, oldMargin, newMargin));
return text === comment.text ? comment : {...comment, text};
}
/**
* The indentation of the line a comment opens on, or `undefined` when the preceding whitespace
* holds no newline — the comment then trails other tokens and its column is not knowable here.
*/
function marginOf(precedingWhitespace: string): string | undefined {
const lastNewline = precedingWhitespace.lastIndexOf("\n");
return lastNewline < 0 ? undefined : precedingWhitespace.substring(lastNewline + 1);
}
/**
* Normalizes indentation in an entire Space: whitespace and comment suffixes take the target
* indent, and the interior lines of a multi-line comment shift with it, so they stay aligned with
* the opening delimiter that the surrounding whitespace positions.
*
* @param space The Space to normalize
* @param targetIndent The indentation to use after newlines
* @returns The normalized Space, or the original if unchanged
*/
export function normalizeSpaceIndent(space: J.Space, targetIndent: string): J.Space {
let changed = false;
// Normalize whitespace
let newWhitespace = space.whitespace;
if (space.whitespace.includes("\n")) {
newWhitespace = replaceIndentAfterLastNewline(space.whitespace, targetIndent);
changed = changed || newWhitespace !== space.whitespace;
}
// Normalize comment suffixes and multi-line comment interiors
const newComments = space.comments.map((comment, i) => {
const margin = marginOf(i === 0 ? space.whitespace : space.comments[i - 1].suffix);
let result: Comment = margin !== undefined && margin !== targetIndent && isTextComment(comment) ?
reindentComment(comment, margin, targetIndent) : comment;
changed = changed || result !== comment;
if (result.suffix.includes("\n")) {
const newSuffix = replaceIndentAfterLastNewline(result.suffix, targetIndent);
if (newSuffix !== result.suffix) {
changed = true;
result = {...result, suffix: newSuffix};
}
}
return result;
});
if (!changed) {
return space;
}
return {
...space,
whitespace: newWhitespace,
comments: newComments
};
}
/**
* Handles element removal from lists while preserving LST formatting.
* Automatically applies prefixes from removed elements to the next kept element,
* handling whitespace and comment preservation.
*
* @example
* const formatter = new ElementRemovalFormatter