read .editorconfig and apply it on open + save (T-29)

New lib/src/editor/editorconfig.dart: a dependency-free INI parser, an
EditorConfig-flavour glob matcher (*, **, ?, [seq], {a,b}, {m..n}), and
directory-walk resolution honouring root=true with nearest-file / last-section
precedence. EditorRegistry resolves the config when a buffer opens (exposed on
the buffer JSON for the UI) and applies end_of_line / trim_trailing_whitespace /
insert_final_newline on save, reconciling the in-memory buffer when the text
changes. 100% line coverage on the new file.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 08:48:47 +02:00
co-authored by Claude Opus 4.8
parent 48974d2a29
commit 55c66601c3
8 changed files with 804 additions and 3 deletions
@@ -944,3 +944,5 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by,
DONE: Primitive (ClideAnchoredOverlay + ClideMenu + ClideMenuListController + ClideTypeahead) shipped and exported. Migrated: T-275 permission picker, menu bar, theme picker (ClideMenu), @-mention + slash typeaheads (ClideTypeahead). Quick-open intentionally left bespoke (centred persistent widget, no shared shape) — see D-88 closing amendment. Modal session/project/branch pickers stay on DialogRouter by design.', NULL, '2026-06-09 06:19:33', '2026-06-09 06:19:33', '2026-06-09 06:19:33', NULL, 'b06e0da4e5e9034b278ce6ee038d9735', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-286', 'status', 'in_progress', 'done', NULL, '2026-06-09 06:19:36', '2026-06-09 06:19:36', '2026-06-09 06:19:36', NULL, '758a6290901bf0cea2725db0269c6a68', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-278', 'status', 'backlog', 'ready', NULL, '2026-06-09 06:33:31', '2026-06-09 06:33:31', '2026-06-09 06:33:31', NULL, 'a2ce57ac3dd7c550d5bd3ef6de579e31', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-29', 'status', 'ready', 'in_progress', NULL, '2026-06-09 06:34:31', '2026-06-09 06:34:31', '2026-06-09 06:34:31', NULL, '58f6b717659a5cdba9de5af300add3be', 1) ON CONFLICT(hash) DO NOTHING;
+10
View File
@@ -2393,3 +2393,13 @@ DONE: Primitive (ClideAnchoredOverlay + ClideMenu + ClideMenuListController + Cl
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-286', 'epic', 'T-276', 'Anchored popover + menu primitive; migrate anchored surfaces', 'Extract a clide-owned anchored-popover + menu widget primitive (lib/widgets/, no Material) and migrate every anchored surface onto it. Nine surfaces hand-roll the same anchored-overlay + row-list + barrier + keyboard-nav pattern. Two layers: ClideAnchoredOverlay (positioning/lifecycle: LayerLink/follower or centered, barrier, Esc, focus capture, autoFlip) + ClideMenu / ClideMenuListController (rows + reusable nav). Modal DialogRouter pickers (session/project/branch) stay modal. Built on T-275''s permission-mode picker first, then migrate menu bar, theme picker, @-mention, slash typeahead, quick-open. See decision (architecture domain) + plan. Children: primitive, T-275 picker, one per migration.
DONE: Primitive (ClideAnchoredOverlay + ClideMenu + ClideMenuListController + ClideTypeahead) shipped and exported. Migrated: T-275 permission picker, menu bar, theme picker (ClideMenu), @-mention + slash typeaheads (ClideTypeahead). Quick-open intentionally left bespoke (centred persistent widget, no shared shape) see D-88 closing amendment. Modal session/project/branch pickers stay on DialogRouter by design.', 'done', 'medium', NULL, NULL, NULL, '2026-06-08 16:30:27', '2026-06-09 06:19:36', NULL, 'e6c9a33aa6d9ca9802fc93bd2b526845', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-278', 'bug', 'T-276', 'Slash-command typeahead breaks on ''-''; Tab-to-complete is flaky', 'Two issues in the Claude composer slash-command typeahead (T-152/T-153, lib/builtin/claude/src/slash_commands.dart + claude_composer.dart):
1. Typing a ''-'' character breaks the typeahead. Many command names contain hyphens (e.g. clear-context-style names, custom commands), but typing ''-'' appears to drop/empty the suggestion list or mis-parse the active query. Suspect activeSlashQuery''s token run or filterSlashCommands prefix matching not handling ''-'' (or the composer treating ''-'' as a boundary).
2. Tab to accept the highlighted suggestion responds flakily sometimes it completes, sometimes nothing happens. Suspect a focus/key-handling race between the composer''s key handler and the typeahead overlay, or Tab being consumed by focus traversal before the accept intent fires.
Repro: open the composer, type ''/'' then a command fragment; (a) include a ''-'' and watch the list; (b) arrow-select an item and press Tab repeatedly.
Acceptance: ''-'' is treated as a normal command-name character (suggestions keep filtering through hyphens); Tab reliably completes the highlighted suggestion every time (insert via completeSlash). Add/extend unit tests in slash_commands_test.dart for hyphenated queries and a composer widget test for Tab-accept.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-08 09:20:00', '2026-06-09 06:33:31', NULL, 'f3d2e1c7b6efdaf6bc426282cf3275dc', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-29', 'story', 'T-4', 'editor respects .editorconfig settings', 'When a file is opened in the editor, read .editorconfig from the workspace root and apply: indent_style, indent_size, max_line_length (ruler/wrap guide), end_of_line, trim_trailing_whitespace, insert_final_newline. Parse the INI format ourselves (small, no dep). Glob matching per the EditorConfig spec.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-04-22 20:17:26', '2026-06-09 06:34:31', NULL, '9c6056aa6b5b5eabf0df7792c6c636f6', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
+6
View File
@@ -18,6 +18,12 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Added
- **The editor reads `.editorconfig`.** Opening a file resolves the
workspace's `.editorconfig` rules (own INI parser + glob matcher, directory
walk with `root = true` and nearest-wins precedence — no new dependency), and
saving applies `end_of_line`, `trim_trailing_whitespace`, and
`insert_final_newline`. The resolved indent/ruler settings ride along on the
buffer for the editor surface to honour. (T-29)
- **Permission-mode control beside the Claude composer.** An icon-only,
per-mode-coloured button opens a menu of the safe modes (default ·
accept-edits · plan); `bypass` shows disabled. The status-bar mode is now a
+9
View File
@@ -6,6 +6,8 @@
/// transitions.
library;
import 'editorconfig.dart';
class Selection {
const Selection({required this.start, required this.end});
@@ -43,6 +45,7 @@ class EditorBuffer {
required this.content,
Selection? selection,
this.dirty = false,
this.editorConfig = EditorConfig.empty,
}) : selection = selection ?? const Selection.collapsed(0);
/// Stable daemon-local id (`b_1`, `b_2`, …).
@@ -67,12 +70,18 @@ class EditorBuffer {
/// `editor.save` (or `files.save` in future).
bool dirty;
/// Resolved `.editorconfig` properties for this file (T-29). Drives the
/// editor's indent/ruler rendering on the UI side and the save-time
/// normalization daemon-side. [EditorConfig.empty] when nothing applies.
EditorConfig editorConfig;
Map<String, Object?> toJson() => {
'id': id,
'path': path,
'length': content.length,
'selection': selection.toJson(),
'dirty': dirty,
'editorConfig': editorConfig.toJson(),
};
/// Full snapshot including [content] — for `editor.read` / tests /
+446
View File
@@ -0,0 +1,446 @@
/// EditorConfig support (T-29) — read `.editorconfig` from the workspace and
/// resolve the properties that apply to a given file.
///
/// We parse the INI-ish format and match section globs ourselves (no
/// dependency, per the prefer-zero-deps rule). Resolution walks from the file's
/// directory up to the workspace root, honouring `root = true` to stop the
/// ascent, with nearer files and later sections winning on conflict — the
/// precedence the EditorConfig spec defines.
///
/// Flutter-free by construction: this runs daemon-side under `dart test`
/// alongside [EditorRegistry], so it imports only `dart:io` + core.
library;
import 'dart:io';
/// The resolved, typed EditorConfig properties for one file. A `null` field
/// means "no opinion" — the editor keeps its default and changes nothing.
class EditorConfig {
const EditorConfig({
this.indentStyle,
this.indentSize,
this.tabWidth,
this.endOfLine,
this.maxLineLength,
this.trimTrailingWhitespace,
this.insertFinalNewline,
});
/// `tab` or `space`.
final String? indentStyle;
/// Columns per indent level. Follows [tabWidth] when the file says
/// `indent_size = tab`.
final int? indentSize;
/// Width of a tab character.
final int? tabWidth;
/// `lf`, `crlf`, or `cr`.
final String? endOfLine;
/// Ruler / wrap-guide column. Null when unset or `off`.
final int? maxLineLength;
final bool? trimTrailingWhitespace;
final bool? insertFinalNewline;
static const empty = EditorConfig();
bool get isEmpty =>
indentStyle == null &&
indentSize == null &&
tabWidth == null &&
endOfLine == null &&
maxLineLength == null &&
trimTrailingWhitespace == null &&
insertFinalNewline == null;
/// The line terminator [endOfLine] names, or null when unset.
String? get eolString => switch (endOfLine) {
'lf' => '\n',
'crlf' => '\r\n',
'cr' => '\r',
_ => null,
};
/// Only the set keys, for the IPC buffer payload. Empty map when [isEmpty].
Map<String, Object?> toJson() => {
if (indentStyle != null) 'indent_style': indentStyle,
if (indentSize != null) 'indent_size': indentSize,
if (tabWidth != null) 'tab_width': tabWidth,
if (endOfLine != null) 'end_of_line': endOfLine,
if (maxLineLength != null) 'max_line_length': maxLineLength,
if (trimTrailingWhitespace != null) 'trim_trailing_whitespace': trimTrailingWhitespace,
if (insertFinalNewline != null) 'insert_final_newline': insertFinalNewline,
};
/// Build from a merged raw property map (keys already lowercased). Applies
/// the spec's `indent_size`/`tab_width` cross-defaulting. A property whose
/// value is `unset` (or unparseable for its type) resolves to null.
factory EditorConfig.fromProps(Map<String, String> p) {
String? lc(String k) {
final v = p[k];
if (v == null) return null;
final t = v.trim().toLowerCase();
return t == 'unset' ? null : t;
}
final indentStyle = _oneOf(lc('indent_style'), const {'tab', 'space'});
int? tabWidth = _posInt(lc('tab_width'));
final rawIndent = lc('indent_size');
int? indentSize;
if (rawIndent == 'tab') {
indentSize = tabWidth;
} else {
indentSize = _posInt(rawIndent);
}
// tab_width defaults to indent_size; indent_size (for tabs) defaults to
// tab_width — the reciprocal defaulting from the spec.
tabWidth ??= indentSize;
if (indentStyle == 'tab') indentSize ??= tabWidth;
final maxRaw = lc('max_line_length');
final maxLineLength = (maxRaw == 'off') ? null : _posInt(maxRaw);
return EditorConfig(
indentStyle: indentStyle,
indentSize: indentSize,
tabWidth: tabWidth,
endOfLine: _oneOf(lc('end_of_line'), const {'lf', 'crlf', 'cr'}),
maxLineLength: maxLineLength,
trimTrailingWhitespace: _bool(lc('trim_trailing_whitespace')),
insertFinalNewline: _bool(lc('insert_final_newline')),
);
}
static String? _oneOf(String? v, Set<String> allowed) => (v != null && allowed.contains(v)) ? v : null;
static int? _posInt(String? v) {
if (v == null) return null;
final n = int.tryParse(v);
return (n != null && n > 0) ? n : null;
}
static bool? _bool(String? v) => switch (v) {
'true' => true,
'false' => false,
_ => null,
};
}
/// Resolve the EditorConfig for [relPath] (a workspace-relative, `/`-separated
/// path) against the `.editorconfig` files under [workspaceRoot].
///
/// Walks from the file's directory up to (and including) the workspace root,
/// stopping once a file declares `root = true`. Never throws — an unreadable or
/// malformed file is skipped, so a broken `.editorconfig` can't wedge a file
/// open or save.
EditorConfig resolveEditorConfig(Directory workspaceRoot, String relPath) {
final rel = relPath.replaceAll('\\', '/').replaceAll(RegExp(r'^/+'), '');
final segs = rel.split('/');
final dirSegs = segs.sublist(0, segs.length - 1);
final sep = Platform.pathSeparator;
// Nearest-first, so we can stop the ascent at root=true.
final collected = <_ConfigFile>[];
for (var k = dirSegs.length; k >= 0; k--) {
final dirSegments = dirSegs.sublist(0, k);
final filePath = [workspaceRoot.path, ...dirSegments, '.editorconfig'].join(sep);
final file = File(filePath);
if (!file.existsSync()) continue;
_ConfigFile parsed;
try {
// The path of the queried file relative to THIS .editorconfig's dir.
parsed = _parseIni(file.readAsStringSync(), segs.sublist(k).join('/'));
} catch (_) {
continue; // unreadable/odd file → skip
}
collected.add(parsed);
if (parsed.isRoot) break;
}
// Apply farthest-first so the nearest file's sections win; within a file,
// later matching sections override earlier ones.
final props = <String, String>{};
for (final cfg in collected.reversed) {
for (final section in cfg.sections) {
if (_globMatches(section.glob, cfg.relForMatch)) {
props.addAll(section.props);
}
}
}
return EditorConfig.fromProps(props);
}
/// Apply the on-save text fixes [cfg] requests: end-of-line normalization,
/// trailing-whitespace trimming, and final-newline insertion/removal. Returns
/// the content unchanged where [cfg] has no opinion.
String applyEditorConfigOnSave(String content, EditorConfig cfg) {
if (content.isEmpty) return content;
var out = content;
// Trim trailing spaces/tabs before any line break or end-of-string. Done
// first and EOL-agnostically so it composes with the EOL rewrite below.
if (cfg.trimTrailingWhitespace == true) {
out = out.replaceAll(RegExp(r'[ \t]+(?=\r\n|\r|\n|$)'), '');
}
final eol = cfg.eolString;
if (eol != null) {
out = out.replaceAll(RegExp(r'\r\n|\r|\n'), eol);
}
if (cfg.insertFinalNewline == true) {
if (out.isNotEmpty && !out.endsWith('\n') && !out.endsWith('\r')) {
out += eol ?? _detectEol(out) ?? '\n';
}
} else if (cfg.insertFinalNewline == false) {
out = out.replaceAll(RegExp(r'(\r\n|\r|\n)+$'), '');
}
return out;
}
String? _detectEol(String s) {
final m = RegExp(r'\r\n|\r|\n').firstMatch(s);
return m?.group(0);
}
// ---------------------------------------------------------------------------
// INI parsing
// ---------------------------------------------------------------------------
class _ConfigFile {
_ConfigFile({required this.isRoot, required this.sections, required this.relForMatch});
final bool isRoot;
final List<_Section> sections;
/// Path of the queried file relative to this config file's directory.
final String relForMatch;
}
class _Section {
_Section(this.glob) : props = {};
final String glob;
final Map<String, String> props;
}
_ConfigFile _parseIni(String text, String relForMatch) {
var isRoot = false;
final sections = <_Section>[];
_Section? current;
for (var line in text.split('\n')) {
line = line.trim();
if (line.isEmpty || line.startsWith('#') || line.startsWith(';')) continue;
if (line.startsWith('[') && line.endsWith(']') && line.length >= 2) {
current = _Section(line.substring(1, line.length - 1));
sections.add(current);
continue;
}
final eq = line.indexOf('=');
if (eq < 0) continue;
final key = line.substring(0, eq).trim().toLowerCase();
final value = line.substring(eq + 1).trim();
if (current == null) {
// Preamble: only `root` is meaningful at the top of the file.
if (key == 'root') isRoot = value.toLowerCase() == 'true';
} else {
current.props[key] = value;
}
}
return _ConfigFile(isRoot: isRoot, sections: sections, relForMatch: relForMatch);
}
// ---------------------------------------------------------------------------
// Glob matching (EditorConfig flavour)
// ---------------------------------------------------------------------------
final Map<String, RegExp> _globCache = {};
/// Whether the EditorConfig section [glob] matches [path] (the file relative to
/// the config file's directory, `/`-separated).
bool _globMatches(String glob, String path) {
final re = _globCache.putIfAbsent(glob, () => RegExp('^${_globToRegex(glob)}\$'));
return re.hasMatch(path);
}
/// Translate an EditorConfig glob to a regex body (unanchored).
///
/// Supports `*` (any run of non-separators), `**` (any run, separators
/// included), `?` (one non-separator), `[seq]`/`[!seq]` character classes,
/// `{a,b,c}` alternation, and `{m..n}` numeric ranges. A glob with no `/` may
/// match in any subdirectory; one with a `/` is anchored to the config dir.
String _globToRegex(String glob) {
// A pattern containing no separator matches the file in any directory.
// One that does is anchored to the config file's directory; a leading
// slash is just that anchor and is dropped.
final hasSlash = glob.contains('/');
var g = glob;
if (g.startsWith('/')) g = g.substring(1);
final prefix = hasSlash ? '' : '(?:.*/)?';
return prefix + _translate(g);
}
String _translate(String pat) {
final sb = StringBuffer();
var i = 0;
final n = pat.length;
while (i < n) {
final c = pat[i];
if (c == '*') {
if (i + 1 < n && pat[i + 1] == '*') {
// `**/` collapses the trailing slash so zero directories also match.
if (i + 2 < n && pat[i + 2] == '/') {
sb.write('(?:.*/)?');
i += 3;
} else {
sb.write('.*');
i += 2;
}
} else {
sb.write('[^/]*');
i += 1;
}
} else if (c == '?') {
sb.write('[^/]');
i += 1;
} else if (c == '[') {
i = _translateClass(pat, i, sb);
} else if (c == '{') {
i = _translateBrace(pat, i, sb);
} else if (c == '\\' && i + 1 < n) {
sb.write(RegExp.escape(pat[i + 1]));
i += 2;
} else {
sb.write(RegExp.escape(c));
i += 1;
}
}
return sb.toString();
}
/// Translate a `[...]` character class starting at [start] (`pat[start] == '['`).
/// Returns the index just past the closing `]`. Falls back to a literal `[`
/// when the class is unterminated.
int _translateClass(String pat, int start, StringBuffer sb) {
final n = pat.length;
var j = start + 1;
var negate = false;
if (j < n && (pat[j] == '!' || pat[j] == '^')) {
negate = true;
j++;
}
final body = StringBuffer();
var closed = false;
while (j < n) {
final c = pat[j];
if (c == ']') {
closed = true;
break;
}
if (c == '\\' && j + 1 < n) {
body.write(RegExp.escape(pat[j + 1]));
j += 2;
continue;
}
// Inside a class only `\` and `]` are special to us; keep ranges (a-z) as is.
body.write(c == '^' || c == '[' ? '\\$c' : c);
j++;
}
if (!closed) {
sb.write(RegExp.escape('['));
return start + 1;
}
sb.write('[${negate ? '^' : ''}${body.toString()}]');
return j + 1; // past ']'
}
/// Translate a `{...}` group starting at [start] (`pat[start] == '{'`).
/// Handles `{a,b,c}` alternation and `{m..n}` numeric ranges; an unterminated
/// or single-item brace is emitted literally. Returns the index past `}`.
int _translateBrace(String pat, int start, StringBuffer sb) {
final close = _matchingBrace(pat, start);
if (close < 0) {
sb.write(RegExp.escape('{'));
return start + 1;
}
final inner = pat.substring(start + 1, close);
// Numeric range {m..n}.
final range = RegExp(r'^(-?\d+)\.\.(-?\d+)$').firstMatch(inner);
if (range != null) {
final lo = int.parse(range.group(1)!);
final hi = int.parse(range.group(2)!);
final from = lo <= hi ? lo : hi;
final to = lo <= hi ? hi : lo;
// Cap the expansion; huge ranges fall back to a generic integer match.
if (to - from <= 4096) {
final alts = [for (var k = from; k <= to; k++) '$k'].map(RegExp.escape).join('|');
sb.write('(?:$alts)');
} else {
sb.write(r'(?:-?\d+)');
}
return close + 1;
}
final parts = _splitTopLevel(inner);
if (parts.length <= 1) {
// Not a real alternation (`{` with no top-level comma) — literal braces.
sb.write(RegExp.escape('{'));
sb.write(_translate(inner));
sb.write(RegExp.escape('}'));
return close + 1;
}
sb.write('(?:${parts.map(_translate).join('|')})');
return close + 1;
}
int _matchingBrace(String pat, int open) {
var depth = 0;
for (var j = open; j < pat.length; j++) {
final c = pat[j];
if (c == '\\') {
j++;
continue;
}
if (c == '{') depth++;
if (c == '}') {
depth--;
if (depth == 0) return j;
}
}
return -1;
}
/// Split [s] on commas that are not nested inside `{}` or `[]`.
List<String> _splitTopLevel(String s) {
final out = <String>[];
final buf = StringBuffer();
var brace = 0;
var bracket = 0;
for (var j = 0; j < s.length; j++) {
final c = s[j];
if (c == '\\' && j + 1 < s.length) {
buf.write(c);
buf.write(s[j + 1]);
j++;
continue;
}
if (c == '{') brace++;
if (c == '}') brace--;
if (c == '[') bracket++;
if (c == ']') bracket--;
if (c == ',' && brace == 0 && bracket == 0) {
out.add(buf.toString());
buf.clear();
} else {
buf.write(c);
}
}
out.add(buf.toString());
return out;
}
+34 -3
View File
@@ -12,6 +12,7 @@ import 'dart:io';
import '../ipc/envelope.dart';
import '../panes/event_sink.dart';
import 'buffer.dart';
import 'editorconfig.dart';
class EditorRegistry {
EditorRegistry({
@@ -52,7 +53,12 @@ class EditorRegistry {
}
final id = 'b_${_nextId++}';
final buf = EditorBuffer(id: id, path: path, content: content);
final buf = EditorBuffer(
id: id,
path: path,
content: content,
editorConfig: resolveEditorConfig(workspaceRoot, path),
);
_buffers[id] = buf;
_pathToId[path] = id;
@@ -144,12 +150,37 @@ class EditorRegistry {
});
}
/// Persist [id] to disk. Clears the dirty flag on success.
/// Persist [id] to disk. Applies the buffer's `.editorconfig` save fixes
/// (EOL, trailing-whitespace, final-newline) first, and — when those changed
/// the text — reconciles the in-memory buffer + UI so disk and buffer agree.
/// Clears the dirty flag on success.
Future<bool> save(String id) async {
final buf = _buffers[id];
if (buf == null) return false;
final normalized = applyEditorConfigOnSave(buf.content, buf.editorConfig);
final changed = normalized != buf.content;
final absolute = _absolutePathOf(buf.path);
await File(absolute).writeAsString(buf.content);
await File(absolute).writeAsString(normalized);
if (changed) {
buf.content = normalized;
buf.selection = Selection(
start: buf.selection.start.clamp(0, normalized.length),
end: buf.selection.end.clamp(0, normalized.length),
);
// Re-broadcast so the UI reloads the normalized text (the editor.edited
// handler re-reads the buffer); emitted before editor.saved clears dirty.
buf.dirty = false;
_emit('editor.edited', {
'id': id,
'kind': 'replace',
'length': normalized.length,
'selection': buf.selection.toJson(),
});
}
buf.dirty = false;
_emit('editor.saved', {'id': id, 'path': buf.path});
return true;
+250
View File
@@ -0,0 +1,250 @@
import 'dart:io';
import 'package:clide/src/editor/editorconfig.dart';
import 'package:test/test.dart';
void main() {
group('EditorConfig.fromProps', () {
test('parses the common indent + newline keys', () {
final c = EditorConfig.fromProps({
'indent_style': 'space',
'indent_size': '2',
'end_of_line': 'lf',
'trim_trailing_whitespace': 'true',
'insert_final_newline': 'true',
'max_line_length': '100',
});
expect(c.indentStyle, 'space');
expect(c.indentSize, 2);
expect(c.tabWidth, 2); // defaults to indent_size
expect(c.endOfLine, 'lf');
expect(c.trimTrailingWhitespace, isTrue);
expect(c.insertFinalNewline, isTrue);
expect(c.maxLineLength, 100);
});
test('indent_size = tab follows tab_width', () {
final c = EditorConfig.fromProps({'indent_style': 'tab', 'indent_size': 'tab', 'tab_width': '4'});
expect(c.indentSize, 4);
expect(c.tabWidth, 4);
});
test('tab indent with no indent_size defaults to tab_width', () {
final c = EditorConfig.fromProps({'indent_style': 'tab', 'tab_width': '8'});
expect(c.indentSize, 8);
});
test('max_line_length = off and unset values resolve to null', () {
final c = EditorConfig.fromProps({'max_line_length': 'off', 'indent_style': 'unset', 'insert_final_newline': 'unset'});
expect(c.maxLineLength, isNull);
expect(c.indentStyle, isNull);
expect(c.insertFinalNewline, isNull);
});
test('rejects junk values rather than guessing', () {
final c = EditorConfig.fromProps({'indent_style': 'tabs', 'indent_size': '-3', 'end_of_line': 'mac'});
expect(c.indentStyle, isNull);
expect(c.indentSize, isNull);
expect(c.endOfLine, isNull);
});
test('toJson omits unset keys', () {
expect(const EditorConfig().toJson(), isEmpty);
expect(const EditorConfig(indentStyle: 'space', indentSize: 2).toJson(), {'indent_style': 'space', 'indent_size': 2});
});
});
group('applyEditorConfigOnSave', () {
test('trims trailing whitespace on every line, any EOL', () {
const cfg = EditorConfig(trimTrailingWhitespace: true);
expect(applyEditorConfigOnSave('a \nb\t\nc', cfg), 'a\nb\nc');
expect(applyEditorConfigOnSave('a \r\nb \r\n', cfg), 'a\r\nb\r\n');
});
test('inserts a final newline when missing', () {
const cfg = EditorConfig(insertFinalNewline: true);
expect(applyEditorConfigOnSave('abc', cfg), 'abc\n');
expect(applyEditorConfigOnSave('abc\n', cfg), 'abc\n'); // already present
});
test('insert_final_newline=false strips trailing newlines', () {
const cfg = EditorConfig(insertFinalNewline: false);
expect(applyEditorConfigOnSave('abc\n\n', cfg), 'abc');
});
test('final newline uses the configured EOL', () {
const cfg = EditorConfig(endOfLine: 'crlf', insertFinalNewline: true);
expect(applyEditorConfigOnSave('abc', cfg), 'abc\r\n');
});
test('normalizes EOL across the whole file', () {
const cfg = EditorConfig(endOfLine: 'crlf');
expect(applyEditorConfigOnSave('a\nb\nc', cfg), 'a\r\nb\r\nc');
const lf = EditorConfig(endOfLine: 'lf');
expect(applyEditorConfigOnSave('a\r\nb\r\n', lf), 'a\nb\n');
});
test('no opinion leaves the content untouched', () {
expect(applyEditorConfigOnSave('a \r\nb', EditorConfig.empty), 'a \r\nb');
expect(applyEditorConfigOnSave('', const EditorConfig(insertFinalNewline: true)), '');
});
test('with no EOL set, the inserted final newline matches the file style', () {
const cfg = EditorConfig(insertFinalNewline: true);
expect(applyEditorConfigOnSave('a\r\nb', cfg), 'a\r\nb\r\n'); // detected crlf
});
test('composes trim + EOL + final newline in order', () {
const cfg = EditorConfig(trimTrailingWhitespace: true, endOfLine: 'lf', insertFinalNewline: true);
expect(applyEditorConfigOnSave('a \r\nb\t', cfg), 'a\nb\n');
});
});
group('resolveEditorConfig — glob matching', () {
late Directory root;
setUp(() async => root = await Directory.systemTemp.createTemp('clide-ec-glob-'));
tearDown(() => root.deleteSync(recursive: true));
Future<void> write(String relPath, String contents) async {
final f = File('${root.path}/$relPath');
await f.parent.create(recursive: true);
await f.writeAsString(contents);
}
test('[*] applies to every file', () async {
await write('.editorconfig', 'root = true\n[*]\nindent_size = 2\n');
expect(resolveEditorConfig(root, 'a.txt').indentSize, 2);
expect(resolveEditorConfig(root, 'sub/b.dart').indentSize, 2);
});
test('extension globs and brace alternation', () async {
await write('.editorconfig', 'root = true\n[*.dart]\nindent_size = 2\n[*.{js,ts}]\nindent_size = 4\n');
expect(resolveEditorConfig(root, 'main.dart').indentSize, 2);
expect(resolveEditorConfig(root, 'app.js').indentSize, 4);
expect(resolveEditorConfig(root, 'app.ts').indentSize, 4);
expect(resolveEditorConfig(root, 'readme.md').indentSize, isNull);
});
test('a slash anchors the glob to the config dir; ** spans dirs', () async {
await write('.editorconfig', 'root = true\n[lib/**.dart]\nindent_size = 3\n');
expect(resolveEditorConfig(root, 'lib/a.dart').indentSize, 3);
expect(resolveEditorConfig(root, 'lib/deep/b.dart').indentSize, 3);
expect(resolveEditorConfig(root, 'test/c.dart').indentSize, isNull);
});
test('character class and negation', () async {
await write('.editorconfig', 'root = true\n[[a-c].txt]\nindent_size = 5\n[!x].md]\nindent_size = 6\n');
expect(resolveEditorConfig(root, 'b.txt').indentSize, 5);
expect(resolveEditorConfig(root, 'z.txt').indentSize, isNull);
});
test('numeric range', () async {
await write('.editorconfig', 'root = true\n[file{1..3}.txt]\nindent_size = 7\n');
expect(resolveEditorConfig(root, 'file2.txt').indentSize, 7);
expect(resolveEditorConfig(root, 'file9.txt').indentSize, isNull);
});
test('? matches exactly one non-separator char', () async {
await write('.editorconfig', 'root = true\n[?.txt]\nindent_size = 9\n');
expect(resolveEditorConfig(root, 'a.txt').indentSize, 9);
expect(resolveEditorConfig(root, 'ab.txt').indentSize, isNull);
});
test('leading **/ matches in the config dir and below', () async {
await write('.editorconfig', 'root = true\n[**/foo.txt]\nindent_size = 3\n');
expect(resolveEditorConfig(root, 'foo.txt').indentSize, 3);
expect(resolveEditorConfig(root, 'a/b/foo.txt').indentSize, 3);
});
test('negated character class', () async {
await write('.editorconfig', 'root = true\n[[!a-c].txt]\nindent_size = 4\n');
expect(resolveEditorConfig(root, 'z.txt').indentSize, 4);
expect(resolveEditorConfig(root, 'b.txt').indentSize, isNull);
});
test('an oversized numeric range falls back to a generic integer match', () async {
await write('.editorconfig', 'root = true\n[v{1..5000}]\nindent_size = 6\n');
expect(resolveEditorConfig(root, 'v123').indentSize, 6);
expect(resolveEditorConfig(root, 'vx').indentSize, isNull);
});
test('a brace with no top-level comma and an unterminated class are literal', () async {
await write('.editorconfig', 'root = true\n[a{b}.txt]\nindent_size = 2\n[lit[.md]\nindent_size = 8\n');
// {b} is a literal brace group → matches the literal text "a{b}.txt".
expect(resolveEditorConfig(root, 'a{b}.txt').indentSize, 2);
// "[lit[.md" has an unterminated class → the leading [ is literal.
expect(resolveEditorConfig(root, 'lit[.md').indentSize, 8);
});
test('backslash escapes a glob metacharacter to a literal', () async {
await write('.editorconfig', 'root = true\n[a\\{b.txt]\nindent_size = 5\n');
expect(resolveEditorConfig(root, 'a{b.txt').indentSize, 5);
expect(resolveEditorConfig(root, 'aXb.txt').indentSize, isNull);
});
test('an unterminated brace (even with an escaped }) is literal', () async {
await write('.editorconfig', 'root = true\n[x{a\\}b.txt]\nindent_size = 6\n');
expect(resolveEditorConfig(root, 'x{a}b.txt').indentSize, 6);
});
test('escaped chars inside a class and inside an alternation', () async {
await write('.editorconfig', 'root = true\n[[a\\-c].txt]\nindent_size = 3\n[{a\\,b,c}.md]\nindent_size = 4\n');
expect(resolveEditorConfig(root, 'c.txt').indentSize, 3); // c is in the class
expect(resolveEditorConfig(root, 'c.md').indentSize, 4); // 'c' alternative
});
});
group('resolveEditorConfig — precedence', () {
late Directory root;
setUp(() async => root = await Directory.systemTemp.createTemp('clide-ec-prec-'));
tearDown(() => root.deleteSync(recursive: true));
Future<void> write(String relPath, String contents) async {
final f = File('${root.path}/$relPath');
await f.parent.create(recursive: true);
await f.writeAsString(contents);
}
test('a nearer config overrides a farther one', () async {
await write('.editorconfig', 'root = true\n[*]\nindent_size = 2\nindent_style = space\n');
await write('sub/.editorconfig', '[*]\nindent_size = 4\n');
final c = resolveEditorConfig(root, 'sub/x.dart');
expect(c.indentSize, 4); // overridden by nearer
expect(c.indentStyle, 'space'); // inherited from farther
});
test('root = true halts the ascent', () async {
await write('.editorconfig', '[*]\nindent_style = tab\n'); // would apply if reached
await write('sub/.editorconfig', 'root = true\n[*]\nindent_size = 4\n');
final c = resolveEditorConfig(root, 'sub/x.dart');
expect(c.indentSize, 4);
expect(c.indentStyle, isNull); // top-level file never consulted
});
test('later sections in one file win', () async {
await write('.editorconfig', 'root = true\n[*]\nindent_size = 2\n[*.dart]\nindent_size = 4\n');
expect(resolveEditorConfig(root, 'a.dart').indentSize, 4);
});
test('a nearer "unset" clears an inherited property', () async {
await write('.editorconfig', 'root = true\n[*]\nindent_style = space\n');
await write('sub/.editorconfig', '[*]\nindent_style = unset\n');
expect(resolveEditorConfig(root, 'sub/x.txt').indentStyle, isNull);
});
test('no .editorconfig anywhere resolves to empty', () async {
expect(resolveEditorConfig(root, 'a.txt').isEmpty, isTrue);
});
test('a malformed file is skipped, not fatal', () async {
await write('.editorconfig', 'root = true\n[*\nindent_size = 2\nnonsense line\n[*]\nindent_size = 8\n');
// The broken section header is ignored; the valid [*] still applies.
expect(resolveEditorConfig(root, 'a.txt').indentSize, 8);
});
test('comments (# and ;) are ignored', () async {
await write('.editorconfig', '# top comment\nroot = true\n[*]\n; inline note\nindent_size = 2\n');
expect(resolveEditorConfig(root, 'a.txt').indentSize, 2);
});
});
}
+47
View File
@@ -151,4 +151,51 @@ void main() {
expect(s.hashCode, isNot(equals(const Selection(start: 3, end: 8).hashCode)));
expect(s.toString(), 'Selection(3-7)');
});
group('.editorconfig (T-29)', () {
test('open resolves the editorconfig for the file', () async {
await File('${sandbox.path}/.editorconfig').writeAsString('root = true\n[*]\nindent_style = space\nindent_size = 2\n');
final buf = await reg.open('README.md');
expect(buf.editorConfig.indentStyle, 'space');
expect(buf.editorConfig.indentSize, 2);
// Exposed over IPC for the UI.
expect(buf.toJson()['editorConfig'], {'indent_style': 'space', 'indent_size': 2, 'tab_width': 2});
});
test('save trims trailing whitespace + adds a final newline on disk', () async {
await File('${sandbox.path}/.editorconfig').writeAsString('root = true\n[*]\ntrim_trailing_whitespace = true\ninsert_final_newline = true\n');
final buf = await reg.open('README.md');
reg.setContent(buf.id, 'line one \nline two');
sink.events.clear();
await reg.save(buf.id);
final onDisk = await File('${sandbox.path}/README.md').readAsString();
expect(onDisk, 'line one\nline two\n');
// The in-memory buffer reconciles to the normalized text...
expect(buf.content, 'line one\nline two\n');
expect(buf.dirty, isFalse);
// ...and the UI is told to reload it.
expect(sink.ofKind('editor.edited'), hasLength(1));
expect(sink.ofKind('editor.saved'), hasLength(1));
});
test('save normalizes EOL to the configured style', () async {
await File('${sandbox.path}/.editorconfig').writeAsString('root = true\n[*]\nend_of_line = crlf\n');
final buf = await reg.open('README.md');
reg.setContent(buf.id, 'a\nb\n');
await reg.save(buf.id);
expect(await File('${sandbox.path}/README.md').readAsString(), 'a\r\nb\r\n');
});
test('save without an editorconfig writes content verbatim (no extra edit event)', () async {
final buf = await reg.open('README.md');
reg.setContent(buf.id, 'kept \nas-is');
sink.events.clear();
await reg.save(buf.id);
expect(await File('${sandbox.path}/README.md').readAsString(), 'kept \nas-is');
expect(sink.ofKind('editor.edited'), isEmpty); // nothing to reconcile
expect(sink.ofKind('editor.saved'), hasLength(1));
});
});
}