route editor config through a source-agnostic EditorSettings model (T-29)

Introduce EditorSettings as the effective, source-agnostic editor configuration
the editor and save path obey. .editorconfig is demoted to one *source*
(editorconfig.dart now returns EditorSettings); editor_settings_resolver.dart is
the single composition seam where future sources — a settings panel, a clide
settings file — layer in via merge() without the editor changing.

The registry resolves settings on buffer load and, when a .editorconfig is saved
in-app, re-resolves every open buffer and emits editor.settings-changed (a hook
in save, not a filesystem watcher — the realistic case, cheaply). Buffer JSON
carries editorSettings. 100% line coverage on the new model + resolver.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 09:15:49 +02:00
co-authored by Claude Opus 4.8
parent 55c66601c3
commit d4b39e430a
10 changed files with 438 additions and 277 deletions
+2
View File
@@ -2403,3 +2403,5 @@ Repro: open the composer, type ''/'' then a command fragment; (a) include a ''-'
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);
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-290', 'story', 'T-8', 'Edit editor settings (.editorconfig) from the settings panel', 'Write path for editor settings (T-29 follow-up). The editor now READS .editorconfig into a source-agnostic EditorSettings model (lib/src/editor/editor_settings.dart) and resolves it via editor_settings_resolver.dart. Add a settings-panel surface to EDIT these values and persist them — initially by writing the project .editorconfig (indent_style/size, end_of_line, max_line_length, trim_trailing_whitespace, insert_final_newline), later possibly a clide-owned settings file that merges as a higher-precedence source in resolveEditorSettings. On save the registry already re-resolves open buffers (editor.settings-changed), so the panel edits apply live. Respect EditorConfig semantics (sections/globs); start with the [*] section for the workspace defaults. Refs: T-29, D-88, editor_settings.dart, editor_settings_resolver.dart.', 'backlog', 'medium', NULL, NULL, 'D-88', '2026-06-09 07:15:14', '2026-06-09 07:15:14', NULL, '54bd063a8718b263a316cfac4f23b40e', 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-291', 'task', 'T-8', 'Re-resolve .editorconfig on external file changes (fs-watch)', 'T-29 follow-up. Today open buffers re-resolve their EditorSettings when a .editorconfig is saved IN-APP (a hook in EditorRegistry.save → editor.settings-changed). External edits — another editor, a git checkout/branch switch — are NOT picked up until reopen. Wire a filesystem watch so an external .editorconfig change re-resolves open buffers too. Prefer piggybacking the files subsystem''s existing watcher over a new dedicated watch; debounce; only re-resolve buffers under the changed config''s directory. Rare case, hence deferred from the initial T-29 work (the in-app save hook covers the common path). Refs: T-29, lib/src/editor/editor_settings_resolver.dart, EditorRegistry._reresolveSettings.', 'backlog', 'low', NULL, NULL, NULL, '2026-06-09 07:15:22', '2026-06-09 07:15:22', NULL, '032edf9cabf61178afb7d324e3273511', 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);
+8 -7
View File
@@ -6,7 +6,7 @@
/// transitions.
library;
import 'editorconfig.dart';
import 'editor_settings.dart';
class Selection {
const Selection({required this.start, required this.end});
@@ -45,7 +45,7 @@ class EditorBuffer {
required this.content,
Selection? selection,
this.dirty = false,
this.editorConfig = EditorConfig.empty,
this.settings = EditorSettings.empty,
}) : selection = selection ?? const Selection.collapsed(0);
/// Stable daemon-local id (`b_1`, `b_2`, …).
@@ -70,10 +70,11 @@ 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;
/// Effective editor settings for this file (T-29), resolved from its sources
/// (today: `.editorconfig`). Drives the editor's indent/ruler rendering on
/// the UI side and the save-time normalization daemon-side.
/// [EditorSettings.empty] when nothing applies.
EditorSettings settings;
Map<String, Object?> toJson() => {
'id': id,
@@ -81,7 +82,7 @@ class EditorBuffer {
'length': content.length,
'selection': selection.toJson(),
'dirty': dirty,
'editorConfig': editorConfig.toJson(),
'editorSettings': settings.toJson(),
};
/// Full snapshot including [content] — for `editor.read` / tests /
+140
View File
@@ -0,0 +1,140 @@
/// [EditorSettings] — the effective, source-agnostic editor configuration for
/// one file (T-29).
///
/// The editor surface and the save path obey *this* object, never a particular
/// source file. Today the only source is the project's `.editorconfig` (parsed
/// in `editorconfig.dart`), but a settings panel or a clide-owned settings file
/// can layer in later via [merge] without the editor, registry, or save path
/// changing — that is the whole point of routing everything through one model.
///
/// Flutter-free (no `dart:io`, no `dart:ui`): it travels from the daemon to the
/// UI as plain JSON and is unit-tested under `dart test`.
library;
class EditorSettings {
const EditorSettings({
this.indentStyle,
this.indentSize,
this.tabWidth,
this.endOfLine,
this.maxLineLength,
this.trimTrailingWhitespace,
this.insertFinalNewline,
});
/// `tab` or `space`.
final String? indentStyle;
/// Columns per indent level.
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.
final int? maxLineLength;
final bool? trimTrailingWhitespace;
final bool? insertFinalNewline;
/// Nothing set — the editor keeps all of its built-in defaults.
static const empty = EditorSettings();
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,
};
/// The text one Tab press inserts, or null to keep the editor's default
/// (Flutter's focus traversal) — the editor only takes over Tab when a source
/// has an opinion about indentation.
String? get indentUnit {
if (indentStyle == 'tab') return '\t';
if (indentStyle == 'space') return ' ' * (indentSize ?? 4);
if (indentSize != null) return ' ' * indentSize!; // a size with no style → spaces
return null;
}
/// Layer [other] on top: every field [other] sets overrides this one, fields
/// it leaves null fall through. The composition order (lowest precedence
/// first) lives in the resolver — a higher-precedence source (settings panel,
/// clide settings file) merges over a lower one (.editorconfig).
EditorSettings merge(EditorSettings other) => EditorSettings(
indentStyle: other.indentStyle ?? indentStyle,
indentSize: other.indentSize ?? indentSize,
tabWidth: other.tabWidth ?? tabWidth,
endOfLine: other.endOfLine ?? endOfLine,
maxLineLength: other.maxLineLength ?? maxLineLength,
trimTrailingWhitespace: other.trimTrailingWhitespace ?? trimTrailingWhitespace,
insertFinalNewline: other.insertFinalNewline ?? insertFinalNewline,
);
/// Only the set keys, for the IPC 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,
};
factory EditorSettings.fromJson(Object? raw) {
if (raw is! Map) return empty;
return EditorSettings(
indentStyle: raw['indent_style'] as String?,
indentSize: (raw['indent_size'] as num?)?.toInt(),
tabWidth: (raw['tab_width'] as num?)?.toInt(),
endOfLine: raw['end_of_line'] as String?,
maxLineLength: (raw['max_line_length'] as num?)?.toInt(),
trimTrailingWhitespace: raw['trim_trailing_whitespace'] as bool?,
insertFinalNewline: raw['insert_final_newline'] as bool?,
);
}
/// Apply the on-save text fixes these settings request: end-of-line
/// normalization, trailing-whitespace trimming, and final-newline
/// insertion/removal. Returns [content] unchanged where nothing is set.
String applyOnSave(String content) {
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 (trimTrailingWhitespace == true) {
out = out.replaceAll(RegExp(r'[ \t]+(?=\r\n|\r|\n|$)'), '');
}
final eol = eolString;
if (eol != null) {
out = out.replaceAll(RegExp(r'\r\n|\r|\n'), eol);
}
if (insertFinalNewline == true) {
if (out.isNotEmpty && !out.endsWith('\n') && !out.endsWith('\r')) {
out += eol ?? _detectEol(out) ?? '\n';
}
} else if (insertFinalNewline == false) {
out = out.replaceAll(RegExp(r'(\r\n|\r|\n)+$'), '');
}
return out;
}
static String? _detectEol(String s) => RegExp(r'\r\n|\r|\n').firstMatch(s)?.group(0);
}
@@ -0,0 +1,25 @@
/// Composes the effective [EditorSettings] for a file from its sources (T-29).
///
/// This is the single seam the editor stack calls — [EditorRegistry] resolves a
/// buffer's settings here on load and whenever a source changes. Today the only
/// source is the project's `.editorconfig`; a workspace settings file or a
/// settings-panel override layers in by adding another `.merge(...)` below, in
/// increasing-precedence order. The editor and save path never learn the
/// source — they only see the merged result.
library;
import 'dart:io';
import 'editor_settings.dart';
import 'editorconfig.dart';
/// The merged settings for [relPath] (workspace-relative, `/`-separated).
EditorSettings resolveEditorSettings(Directory workspaceRoot, String relPath) {
// Lowest precedence first; later sources override earlier ones.
var settings = EditorSettings.empty;
settings = settings.merge(readEditorConfig(workspaceRoot, relPath));
// Future sources slot in here, e.g.:
// settings = settings.merge(readWorkspaceSettingsFile(workspaceRoot, relPath));
// settings = settings.merge(settingsPanelOverrides(relPath));
return settings;
}
+60 -148
View File
@@ -1,142 +1,33 @@
/// EditorConfig support (T-29) — read `.editorconfig` from the workspace and
/// resolve the properties that apply to a given file.
/// `.editorconfig` as an [EditorSettings] source (T-29).
///
/// Reads `.editorconfig` files from the workspace and resolves the properties
/// that apply to a given file into the source-agnostic [EditorSettings] model.
/// This is one *source* feeding `editor_settings_resolver.dart`; it is not the
/// thing the editor obeys directly.
///
/// 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
/// dependency, per prefer-zero-deps). 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.
/// alongside [EditorRegistry], so it imports only `dart:io` + the model.
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,
});
import 'editor_settings.dart';
/// `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].
/// Resolve the EditorConfig-sourced [EditorSettings] 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) {
EditorSettings readEditorConfig(Directory workspaceRoot, String relPath) {
final rel = relPath.replaceAll('\\', '/').replaceAll(RegExp(r'^/+'), '');
final segs = rel.split('/');
final dirSegs = segs.sublist(0, segs.length - 1);
@@ -170,42 +61,63 @@ EditorConfig resolveEditorConfig(Directory workspaceRoot, String relPath) {
}
}
}
return EditorConfig.fromProps(props);
return editorSettingsFromProps(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|$)'), '');
/// Build [EditorSettings] from a merged raw EditorConfig property map (keys
/// already lowercased). Applies the spec's `indent_size`/`tab_width`
/// cross-defaulting; a property valued `unset` (or unparseable for its type)
/// resolves to null. Public for direct unit testing of the mapping.
EditorSettings editorSettingsFromProps(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 eol = cfg.eolString;
if (eol != null) {
out = out.replaceAll(RegExp(r'\r\n|\r|\n'), eol);
}
final indentStyle = _oneOf(lc('indent_style'), const {'tab', 'space'});
int? tabWidth = _posInt(lc('tab_width'));
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)+$'), '');
final rawIndent = lc('indent_size');
int? indentSize;
if (rawIndent == 'tab') {
indentSize = tabWidth;
} else {
indentSize = _posInt(rawIndent);
}
return out;
// 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 EditorSettings(
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')),
);
}
String? _detectEol(String s) {
final m = RegExp(r'\r\n|\r|\n').firstMatch(s);
return m?.group(0);
String? _oneOf(String? v, Set<String> allowed) => (v != null && allowed.contains(v)) ? v : null;
int? _posInt(String? v) {
if (v == null) return null;
final n = int.tryParse(v);
return (n != null && n > 0) ? n : null;
}
bool? _bool(String? v) => switch (v) {
'true' => true,
'false' => false,
_ => null,
};
// ---------------------------------------------------------------------------
// INI parsing
// ---------------------------------------------------------------------------
+28 -7
View File
@@ -12,7 +12,7 @@ import 'dart:io';
import '../ipc/envelope.dart';
import '../panes/event_sink.dart';
import 'buffer.dart';
import 'editorconfig.dart';
import 'editor_settings_resolver.dart';
class EditorRegistry {
EditorRegistry({
@@ -57,7 +57,7 @@ class EditorRegistry {
id: id,
path: path,
content: content,
editorConfig: resolveEditorConfig(workspaceRoot, path),
settings: resolveEditorSettings(workspaceRoot, path),
);
_buffers[id] = buf;
_pathToId[path] = id;
@@ -150,15 +150,16 @@ class EditorRegistry {
});
}
/// 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.
/// Persist [id] to disk. Applies the buffer's on-save settings (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. Saving a `.editorconfig` re-resolves the
/// settings of every open buffer (its rules just changed).
Future<bool> save(String id) async {
final buf = _buffers[id];
if (buf == null) return false;
final normalized = applyEditorConfigOnSave(buf.content, buf.editorConfig);
final normalized = buf.settings.applyOnSave(buf.content);
final changed = normalized != buf.content;
final absolute = _absolutePathOf(buf.path);
@@ -183,9 +184,29 @@ class EditorRegistry {
buf.dirty = false;
_emit('editor.saved', {'id': id, 'path': buf.path});
if (_isEditorConfigPath(buf.path)) _reresolveSettings();
return true;
}
/// Recompute every open buffer's effective settings from its sources and tell
/// the UI about the ones that changed. Called when a `.editorconfig` is saved
/// in-app (the file's rules changed under the open buffers).
void _reresolveSettings() {
for (final buf in _buffers.values) {
final next = resolveEditorSettings(workspaceRoot, buf.path);
if (next.toJson().toString() == buf.settings.toJson().toString()) continue;
buf.settings = next;
_emit('editor.settings-changed', {
'id': buf.id,
'path': buf.path,
'editorSettings': next.toJson(),
});
}
}
bool _isEditorConfigPath(String path) => path == '.editorconfig' || path.endsWith('/.editorconfig');
/// Close a buffer. Idempotent.
void close(String id) {
final buf = _buffers.remove(id);
@@ -0,0 +1,23 @@
import 'dart:io';
import 'package:clide/src/editor/editor_settings_resolver.dart';
import 'package:test/test.dart';
void main() {
group('resolveEditorSettings', () {
late Directory root;
setUp(() async => root = await Directory.systemTemp.createTemp('clide-ec-resolve-'));
tearDown(() => root.deleteSync(recursive: true));
test('composes from the .editorconfig source', () async {
await File('${root.path}/.editorconfig').writeAsString('root = true\n[*]\nindent_style = space\nindent_size = 2\n');
final s = resolveEditorSettings(root, 'lib/a.dart');
expect(s.indentStyle, 'space');
expect(s.indentSize, 2);
});
test('no source resolves to empty settings', () async {
expect(resolveEditorSettings(root, 'a.txt').isEmpty, isTrue);
});
});
}
+81
View File
@@ -0,0 +1,81 @@
import 'package:clide/src/editor/editor_settings.dart';
import 'package:test/test.dart';
void main() {
group('EditorSettings model', () {
test('toJson omits unset keys; fromJson round-trips', () {
expect(const EditorSettings().toJson(), isEmpty);
const s = EditorSettings(indentStyle: 'space', indentSize: 2, maxLineLength: 80, insertFinalNewline: true);
expect(EditorSettings.fromJson(s.toJson()).toJson(), s.toJson());
expect(EditorSettings.fromJson('not a map'), EditorSettings.empty);
});
test('isEmpty + eolString', () {
expect(EditorSettings.empty.isEmpty, isTrue);
expect(const EditorSettings(indentSize: 2).isEmpty, isFalse);
expect(const EditorSettings(endOfLine: 'crlf').eolString, '\r\n');
expect(const EditorSettings(endOfLine: 'lf').eolString, '\n');
expect(const EditorSettings(endOfLine: 'cr').eolString, '\r');
expect(EditorSettings.empty.eolString, isNull);
});
test('indentUnit reflects style + size', () {
expect(const EditorSettings(indentStyle: 'tab').indentUnit, '\t');
expect(const EditorSettings(indentStyle: 'space', indentSize: 3).indentUnit, ' ');
expect(const EditorSettings(indentStyle: 'space').indentUnit, ' '); // defaults to 4
expect(const EditorSettings(indentSize: 2).indentUnit, ' '); // size with no style → spaces
expect(EditorSettings.empty.indentUnit, isNull); // no opinion → keep default
});
test('merge — later source wins per set field, others fall through', () {
const base = EditorSettings(indentStyle: 'space', indentSize: 2, maxLineLength: 80);
const over = EditorSettings(indentSize: 4, insertFinalNewline: true);
final m = base.merge(over);
expect(m.indentSize, 4); // overridden
expect(m.indentStyle, 'space'); // inherited
expect(m.maxLineLength, 80); // inherited
expect(m.insertFinalNewline, isTrue); // added
// Merging empty over a value is a no-op; value over empty adopts it.
expect(base.merge(EditorSettings.empty).toJson(), base.toJson());
expect(EditorSettings.empty.merge(base).toJson(), base.toJson());
});
});
group('EditorSettings.applyOnSave', () {
test('trims trailing whitespace on every line, any EOL', () {
const cfg = EditorSettings(trimTrailingWhitespace: true);
expect(cfg.applyOnSave('a \nb\t\nc'), 'a\nb\nc');
expect(cfg.applyOnSave('a \r\nb \r\n'), 'a\r\nb\r\n');
});
test('inserts a final newline when missing', () {
const cfg = EditorSettings(insertFinalNewline: true);
expect(cfg.applyOnSave('abc'), 'abc\n');
expect(cfg.applyOnSave('abc\n'), 'abc\n');
});
test('insert_final_newline=false strips trailing newlines', () {
expect(const EditorSettings(insertFinalNewline: false).applyOnSave('abc\n\n'), 'abc');
});
test('final newline uses the configured EOL, else the detected one', () {
expect(const EditorSettings(endOfLine: 'crlf', insertFinalNewline: true).applyOnSave('abc'), 'abc\r\n');
expect(const EditorSettings(insertFinalNewline: true).applyOnSave('a\r\nb'), 'a\r\nb\r\n');
});
test('normalizes EOL across the whole file', () {
expect(const EditorSettings(endOfLine: 'crlf').applyOnSave('a\nb\nc'), 'a\r\nb\r\nc');
expect(const EditorSettings(endOfLine: 'lf').applyOnSave('a\r\nb\r\n'), 'a\nb\n');
});
test('no opinion (and empty content) leaves the text untouched', () {
expect(EditorSettings.empty.applyOnSave('a \r\nb'), 'a \r\nb');
expect(const EditorSettings(insertFinalNewline: true).applyOnSave(''), '');
});
test('composes trim + EOL + final newline in order', () {
const cfg = EditorSettings(trimTrailingWhitespace: true, endOfLine: 'lf', insertFinalNewline: true);
expect(cfg.applyOnSave('a \r\nb\t'), 'a\nb\n');
});
});
}
+50 -111
View File
@@ -4,9 +4,9 @@ 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({
group('editorSettingsFromProps', () {
test('maps the common indent + newline keys', () {
final c = editorSettingsFromProps({
'indent_style': 'space',
'indent_size': '2',
'end_of_line': 'lf',
@@ -24,83 +24,31 @@ void main() {
});
test('indent_size = tab follows tab_width', () {
final c = EditorConfig.fromProps({'indent_style': 'tab', 'indent_size': 'tab', 'tab_width': '4'});
final c = editorSettingsFromProps({'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);
expect(editorSettingsFromProps({'indent_style': 'tab', 'tab_width': '8'}).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'});
final c = editorSettingsFromProps({'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'});
final c = editorSettingsFromProps({'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', () {
group('readEditorConfig — glob matching', () {
late Directory root;
setUp(() async => root = await Directory.systemTemp.createTemp('clide-ec-glob-'));
tearDown(() => root.deleteSync(recursive: true));
@@ -113,88 +61,80 @@ void main() {
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);
expect(readEditorConfig(root, 'a.txt').indentSize, 2);
expect(readEditorConfig(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);
expect(readEditorConfig(root, 'main.dart').indentSize, 2);
expect(readEditorConfig(root, 'app.js').indentSize, 4);
expect(readEditorConfig(root, 'app.ts').indentSize, 4);
expect(readEditorConfig(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);
expect(readEditorConfig(root, 'lib/a.dart').indentSize, 3);
expect(readEditorConfig(root, 'lib/deep/b.dart').indentSize, 3);
expect(readEditorConfig(root, 'test/c.dart').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);
expect(readEditorConfig(root, 'a.txt').indentSize, 9);
expect(readEditorConfig(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);
expect(readEditorConfig(root, 'foo.txt').indentSize, 3);
expect(readEditorConfig(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);
expect(readEditorConfig(root, 'z.txt').indentSize, 4);
expect(readEditorConfig(root, 'b.txt').indentSize, isNull);
});
test('numeric range', () async {
await write('.editorconfig', 'root = true\n[file{1..3}.txt]\nindent_size = 7\n');
expect(readEditorConfig(root, 'file2.txt').indentSize, 7);
expect(readEditorConfig(root, 'file9.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);
expect(readEditorConfig(root, 'v123').indentSize, 6);
expect(readEditorConfig(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);
expect(readEditorConfig(root, 'a{b}.txt').indentSize, 2);
expect(readEditorConfig(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);
expect(readEditorConfig(root, 'a{b.txt').indentSize, 5);
expect(readEditorConfig(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);
expect(readEditorConfig(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
expect(readEditorConfig(root, 'c.txt').indentSize, 3);
expect(readEditorConfig(root, 'c.md').indentSize, 4);
});
});
group('resolveEditorConfig — precedence', () {
group('readEditorConfig — precedence', () {
late Directory root;
setUp(() async => root = await Directory.systemTemp.createTemp('clide-ec-prec-'));
tearDown(() => root.deleteSync(recursive: true));
@@ -208,43 +148,42 @@ void main() {
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
final c = readEditorConfig(root, 'sub/x.dart');
expect(c.indentSize, 4);
expect(c.indentStyle, 'space');
});
test('root = true halts the ascent', () async {
await write('.editorconfig', '[*]\nindent_style = tab\n'); // would apply if reached
await write('.editorconfig', '[*]\nindent_style = tab\n');
await write('sub/.editorconfig', 'root = true\n[*]\nindent_size = 4\n');
final c = resolveEditorConfig(root, 'sub/x.dart');
final c = readEditorConfig(root, 'sub/x.dart');
expect(c.indentSize, 4);
expect(c.indentStyle, isNull); // top-level file never consulted
expect(c.indentStyle, isNull);
});
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);
expect(readEditorConfig(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);
expect(readEditorConfig(root, 'sub/x.txt').indentStyle, isNull);
});
test('no .editorconfig anywhere resolves to empty', () async {
expect(resolveEditorConfig(root, 'a.txt').isEmpty, isTrue);
expect(readEditorConfig(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);
expect(readEditorConfig(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);
expect(readEditorConfig(root, 'a.txt').indentSize, 2);
});
});
}
+21 -4
View File
@@ -153,13 +153,13 @@ void main() {
});
group('.editorconfig (T-29)', () {
test('open resolves the editorconfig for the file', () async {
test('open resolves the settings 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);
expect(buf.settings.indentStyle, 'space');
expect(buf.settings.indentSize, 2);
// Exposed over IPC for the UI.
expect(buf.toJson()['editorConfig'], {'indent_style': 'space', 'indent_size': 2, 'tab_width': 2});
expect(buf.toJson()['editorSettings'], {'indent_style': 'space', 'indent_size': 2, 'tab_width': 2});
});
test('save trims trailing whitespace + adds a final newline on disk', () async {
@@ -197,5 +197,22 @@ void main() {
expect(sink.ofKind('editor.edited'), isEmpty); // nothing to reconcile
expect(sink.ofKind('editor.saved'), hasLength(1));
});
test('saving a .editorconfig re-resolves open buffers and notifies', () async {
// README opens with no rules in effect.
final readme = await reg.open('README.md');
expect(readme.settings.indentSize, isNull);
// Author a .editorconfig in the editor and save it.
final cfg = await reg.open('.editorconfig');
reg.setContent(cfg.id, 'root = true\n[*]\nindent_size = 4\n');
sink.events.clear();
await reg.save(cfg.id);
// The open README picks up the new rules without reopening.
expect(readme.settings.indentSize, 4);
final changed = sink.ofKind('editor.settings-changed');
expect(changed.map((e) => e.data['id']), contains(readme.id));
});
});
}